#!/usr/bin/env python3
"""Use a local logged-in browser session to probe tender detail availability.

This crawler intentionally does not read, print, or export cookies/tokens.  It
reuses a Playwright persistent Chrome profile and executes same-origin fetches
inside the browser page, then writes only tender data and permission summaries.
"""

from __future__ import annotations

import argparse
import json
import re
import sys
import time
from datetime import datetime
from pathlib import Path
from typing import Any

from openpyxl import Workbook
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from openpyxl.utils import get_column_letter
from playwright.sync_api import sync_playwright

ROOT = Path(__file__).resolve().parents[1]
DEFAULT_SOURCE = ROOT / "docs" / "deliverables" / "20260617-raw-excel-entity-tables" / "原始Excel招投标实体抽取表.xlsx"
DEFAULT_PROFILE = ROOT / "tmp" / "yfb-auth-profile"
DEFAULT_OUT_DIR = ROOT / "tmp" / "yfb-browser-session-crawl"
DEFAULT_CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"

FONT = "Microsoft YaHei"
BLUE = "1F4E79"
LIGHT_BLUE = "D9EAF7"
LIGHT_GREEN = "E2F0D9"
LIGHT_YELLOW = "FFF2CC"
LIGHT_RED = "FCE4D6"
WHITE = "FFFFFF"
BORDER = Border(
    left=Side(style="thin", color="B7C9DA"),
    right=Side(style="thin", color="B7C9DA"),
    top=Side(style="thin", color="B7C9DA"),
    bottom=Side(style="thin", color="B7C9DA"),
)

SENSITIVE_KEY = re.compile(r"(token|cookie|authorization|openid|openId|phone|mobile|email|qrCode)", re.I)


def clean(value: Any) -> str:
    if value is None:
        return ""
    text = str(value).strip()
    return "" if text in {"None", "nan", "--", "暂未公布"} else text


def short(value: Any, limit: int = 900) -> str:
    text = re.sub(r"\s+", " ", clean(value))
    return text[:limit] + ("..." if len(text) > limit else "")


def strip_html(text: str) -> str:
    text = re.sub(r"<script\b[^<]*(?:(?!</script>)<[^<]*)*</script>", " ", text, flags=re.I)
    text = re.sub(r"<style\b[^<]*(?:(?!</style>)<[^<]*)*</style>", " ", text, flags=re.I)
    text = re.sub(r"<[^>]+>", " ", text)
    return re.sub(r"\s+", " ", text).strip()


def load_projects(source: Path, limit: int = 0) -> list[dict[str, str]]:
    from openpyxl import load_workbook

    wb = load_workbook(source, read_only=True, data_only=True)
    ws = wb["招投标项目表"]
    headers = [clean(cell.value) for cell in next(ws.iter_rows(min_row=2, max_row=2))]
    projects: list[dict[str, str]] = []
    for row in ws.iter_rows(min_row=3, values_only=True):
        item = {header: clean(value) for header, value in zip(headers, row) if header}
        if not item.get("项目ID"):
            continue
        projects.append(item)
        if limit and len(projects) >= limit:
            break
    wb.close()
    return projects


def parse_qianlima_url(url: str) -> dict[str, str]:
    match = re.search(r"/infoDetail/(\d+)/(\d+)/([^/?#]+)", url)
    if not match:
        return {}
    return {"contentId": match.group(1), "areaId": match.group(2), "pageFrom": match.group(3)}


def api_path(endpoint: str, params: dict[str, str]) -> str:
    query = "&".join(f"{k}={v}" for k, v in params.items() if v)
    return f"/new_qd_yfbsite/api/{endpoint}" + (f"?{query}" if query else "")


def sanitize_summary(obj: Any, depth: int = 0) -> Any:
    if depth > 2:
        return short(obj, 160)
    if isinstance(obj, dict):
        out: dict[str, Any] = {}
        for key, value in obj.items():
            if SENSITIVE_KEY.search(str(key)):
                out[key] = "[redacted]"
            elif isinstance(value, dict):
                out[key] = {"keys": list(value.keys())[:40], "summary": sanitize_summary(value, depth + 1)}
            elif isinstance(value, list):
                first = sanitize_summary(value[0], depth + 1) if value else ""
                out[key] = {"count": len(value), "first": first}
            else:
                out[key] = short(value, 260)
        return out
    if isinstance(obj, list):
        return {"count": len(obj), "first": sanitize_summary(obj[0], depth + 1) if obj else ""}
    return short(obj, 260)


def fetch_json(page: Any, path: str) -> dict[str, Any]:
    return page.evaluate(
        """async (path) => {
            try {
                const r = await fetch(path, { credentials: 'include' });
                const contentType = r.headers.get('content-type') || '';
                const downloadFilename = r.headers.get('download-filename') || '';
                const text = await r.text();
                let parsed = null;
                try { parsed = JSON.parse(text); } catch (e) {}
                return {
                    ok: r.ok,
                    status: r.status,
                    contentType,
                    downloadFilename,
                    length: text.length,
                    json: parsed,
                    textHead: parsed ? '' : text.slice(0, 300)
                };
            } catch (e) {
                return { error: String(e) };
            }
        }""",
        path,
    )


def classify_detail(data: Any) -> tuple[str, str]:
    if not isinstance(data, dict):
        return "无JSON数据", "接口未返回 data 对象。"
    if data.get("briefDescription") or data.get("summary") or data.get("announcementContent"):
        return "可采正文", "已取得 briefDescription/summary/announcementContent 等正文结构。"
    err_type = data.get("errType")
    if err_type == 6:
        return "需电脑端/会员授权", "接口只返回 errType=6，页面会展示电脑端或会员授权入口。"
    if err_type in {1, 3}:
        return "地区/会员限制", f"接口只返回 errType={err_type}，当前账号未取得该地区或会员权限。"
    if err_type in {2, 4, 5, 11}:
        return "访问次数/专享限制", f"接口只返回 errType={err_type}，当前账号触发次数或专享限制。"
    return "权限摘要", "接口返回权限摘要，但未返回正文结构。"


def row_from_detail(project: dict[str, str], detail: dict[str, Any], attach: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
    params = parse_qianlima_url(clean(project.get("官网查看地址")))
    payload = detail.get("json") if isinstance(detail.get("json"), dict) else {}
    data = payload.get("data") if isinstance(payload, dict) else None
    attach_payload = attach.get("json") if isinstance(attach.get("json"), dict) else {}
    attach_data = attach_payload.get("data") if isinstance(attach_payload, dict) else None
    status, note = classify_detail(data)

    info: dict[str, Any] = data if isinstance(data, dict) else {}
    brief = info.get("briefDescription") if isinstance(info.get("briefDescription"), dict) else {}
    summary = info.get("summary") if isinstance(info.get("summary"), dict) else {}
    announcement = info.get("announcementContent")
    downlinks = info.get("downlinkList") if isinstance(info.get("downlinkList"), list) else []

    permission_row = {
        "项目ID": project.get("项目ID", ""),
        "项目名称": project.get("项目名称", ""),
        "招标公司": project.get("招标公司", ""),
        "中标单位": project.get("投标公司/中标单位", ""),
        "金额_万元": project.get("中标金额_万元", ""),
        "来源链接": project.get("官网查看地址", ""),
        "contentId": params.get("contentId", ""),
        "areaId": params.get("areaId", ""),
        "pageFrom": params.get("pageFrom", ""),
        "详情状态": status,
        "详情说明": note,
        "详情HTTP": detail.get("status", ""),
        "详情code": payload.get("code", "") if isinstance(payload, dict) else "",
        "详情msg": payload.get("msg", "") if isinstance(payload, dict) else "",
        "响应长度": detail.get("length", ""),
        "errType": info.get("errType", ""),
        "subscribed": info.get("subscribed", ""),
        "附件HTTP": attach.get("status", ""),
        "附件code": attach_payload.get("code", "") if isinstance(attach_payload, dict) else "",
        "附件msg": attach_payload.get("msg", "") if isinstance(attach_payload, dict) else "",
        "attachmentCount": attach_data.get("attachmentCount", "") if isinstance(attach_data, dict) else "",
        "down": attach_data.get("down", "") if isinstance(attach_data, dict) else "",
        "downlinkList数量": len(downlinks),
    }

    content_row = {
        "项目ID": project.get("项目ID", ""),
        "contentId": params.get("contentId", ""),
        "接口标题": brief.get("title", ""),
        "接口地区": brief.get("areaName", ""),
        "接口日期": brief.get("date", ""),
        "合同/项目名称": summary.get("contractName", ""),
        "招标单位": ((summary.get("bidUnit") or {}).get("unitName", "") if isinstance(summary.get("bidUnit"), dict) else ""),
        "中标单位": ((summary.get("winBidUnit") or {}).get("unitName", "") if isinstance(summary.get("winBidUnit"), dict) else ""),
        "报名开始": summary.get("registrationStartTime", ""),
        "报名截止": summary.get("registrationEndTime", ""),
        "投标截止": summary.get("bidEndTime", ""),
        "联系电话": "[redacted]" if summary.get("contractPhone") else "",
        "正文摘要": short(strip_html(json.dumps(announcement, ensure_ascii=False)), 1800),
        "附件文件名": "；".join(short(item.get("title", ""), 120) for item in downlinks if isinstance(item, dict)),
    }

    field_row = {
        "项目ID": project.get("项目ID", ""),
        "contentId": params.get("contentId", ""),
        "详情data字段": "、".join(info.keys()) if isinstance(info, dict) else "",
        "summary字段": "、".join(summary.keys()) if isinstance(summary, dict) else "",
        "briefDescription字段": "、".join(brief.keys()) if isinstance(brief, dict) else "",
        "附件权限字段": "、".join(attach_data.keys()) if isinstance(attach_data, dict) else "",
        "详情脱敏结构": json.dumps(sanitize_summary(data), ensure_ascii=False),
    }
    return permission_row, content_row, field_row


def style_cell(cell: Any, fill: str | None = None, bold: bool = False, color: str = "1F1F1F") -> None:
    cell.font = Font(name=FONT, size=10, bold=bold, color=color)
    cell.alignment = Alignment(wrap_text=True, vertical="top")
    cell.border = BORDER
    if fill:
        cell.fill = PatternFill("solid", fgColor=fill)


def fill_for(value: Any) -> str | None:
    text = clean(value)
    if text in {"可采正文", "True", "true"}:
        return LIGHT_GREEN
    if text in {"需电脑端/会员授权", "地区/会员限制", "访问次数/专享限制", "权限摘要", "False", "false"}:
        return LIGHT_YELLOW
    if text in {"无JSON数据"} or text.startswith("HTTP"):
        return LIGHT_RED
    return None


def write_sheet(wb: Workbook, name: str, rows: list[dict[str, Any]]) -> None:
    ws = wb.create_sheet(name[:31])
    if not rows:
        rows = [{"说明": "无记录"}]
    headers = list(rows[0].keys())
    ws.sheet_view.showGridLines = False
    ws.merge_cells(start_row=1, start_column=1, end_row=1, end_column=len(headers))
    title = ws.cell(1, 1, name)
    title.font = Font(name=FONT, size=15, bold=True, color=WHITE)
    title.fill = PatternFill("solid", fgColor=BLUE)
    title.alignment = Alignment(horizontal="center", vertical="center")
    title.border = BORDER
    for col, header in enumerate(headers, 1):
        cell = ws.cell(2, col, header)
        style_cell(cell, LIGHT_BLUE, True, "14344A")
        cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
    for row_index, row in enumerate(rows, 3):
        for col_index, header in enumerate(headers, 1):
            value = row.get(header, "")
            cell = ws.cell(row_index, col_index, value)
            style_cell(cell, fill_for(value))
            if header.endswith("链接") and clean(value).startswith("http"):
                cell.hyperlink = clean(value)
                cell.style = "Hyperlink"
                style_cell(cell, fill_for(value))
    for idx, header in enumerate(headers, 1):
        width = min(max(len(header) + 4, 14), 48)
        if header in {"正文摘要", "详情脱敏结构", "详情说明"}:
            width = 72
        ws.column_dimensions[get_column_letter(idx)].width = width
    ws.freeze_panes = "A3"
    ws.auto_filter.ref = f"A2:{get_column_letter(len(headers))}{ws.max_row}"


def write_workbook(out_dir: Path, tables: dict[str, list[dict[str, Any]]]) -> Path:
    out_dir.mkdir(parents=True, exist_ok=True)
    wb = Workbook()
    wb.remove(wb.active)
    for name, rows in tables.items():
        write_sheet(wb, name, rows)
    path = out_dir / "乙方宝登录会话智慧食堂详情可采集性矩阵.xlsx"
    wb.save(path)
    return path


def write_html(out_dir: Path, tables: dict[str, list[dict[str, Any]]], workbook: Path) -> Path:
    rows = tables["项目权限矩阵"]
    total = len(rows)
    accessible = sum(1 for row in rows if row.get("详情状态") == "可采正文")
    restricted = total - accessible
    generated = datetime.now().strftime("%Y-%m-%d %H:%M:%S")

    def table(rows: list[dict[str, Any]], limit: int = 80) -> str:
        if not rows:
            return "<p>无记录</p>"
        headers = list(rows[0].keys())
        head = "".join(f"<th>{h}</th>" for h in headers)
        body = []
        for row in rows[:limit]:
            cells = []
            for header in headers:
                value = clean(row.get(header, ""))
                if value.startswith("http"):
                    value = f'<a href="{value}" target="_blank" rel="noreferrer">打开链接</a>'
                cells.append(f"<td>{value}</td>")
            body.append("<tr>" + "".join(cells) + "</tr>")
        return f"<table><thead><tr>{head}</tr></thead><tbody>{''.join(body)}</tbody></table>"

    html_text = f"""<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>乙方宝登录会话智慧食堂详情可采集性矩阵</title>
<style>
body{{margin:0;background:#f5f7fa;color:#17212b;font-family:"Microsoft YaHei",Arial,sans-serif;line-height:1.55}}
main{{width:min(1440px,calc(100% - 36px));margin:0 auto;padding:28px 0 56px}}
header,section{{background:white;border:1px solid #d8e1ea;border-radius:8px;padding:18px;margin-bottom:16px}}
h1{{margin:0;font-size:30px}} h2{{font-size:18px;margin:0 0 12px}}
.metrics{{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-top:16px}}
.metric{{border:1px solid #d8e1ea;border-radius:8px;padding:12px;background:#fbfcfd}}
.metric strong{{display:block;font-size:24px}}
a{{color:#0f766e;text-decoration:none}} table{{width:100%;border-collapse:collapse;table-layout:fixed;font-size:13px}}
th,td{{border:1px solid #d8e1ea;padding:8px;vertical-align:top;word-break:break-word}} th{{background:#e8f4f2}}
.note{{background:#fff3d8;border:1px solid #ecd28c;border-radius:8px;padding:12px;margin-top:14px;color:#573900}}
</style>
</head>
<body><main>
<header>
<h1>乙方宝登录会话智慧食堂详情可采集性矩阵</h1>
<p>生成时间：{generated}。本报告使用本地 Chrome 登录会话请求接口，不导出 Cookie、Token、OpenID。</p>
<p><a href="{workbook.name}">下载 Excel</a></p>
<div class="metrics"><div class="metric"><strong>{total}</strong><span>项目数</span></div><div class="metric"><strong>{accessible}</strong><span>可采正文</span></div><div class="metric"><strong>{restricted}</strong><span>受权限限制</span></div></div>
<div class="note">如“详情状态”为需电脑端/会员授权，说明登录有效但该项目仍需账号权限或手机端授权；本脚本不会绕过付费或地区限制。</div>
</header>
<section><h2>权限矩阵</h2>{table(rows, 100)}</section>
<section><h2>可见正文摘要</h2>{table(tables["可见正文摘要"], 100)}</section>
</main></body></html>"""
    path = out_dir / "乙方宝登录会话智慧食堂详情可采集性矩阵.html"
    path.write_text(html_text, encoding="utf-8")
    return path


def crawl(args: argparse.Namespace) -> tuple[Path, Path, dict[str, list[dict[str, Any]]]]:
    projects = load_projects(args.source, args.limit)
    permission_rows: list[dict[str, Any]] = []
    content_rows: list[dict[str, Any]] = []
    field_rows: list[dict[str, Any]] = []
    api_rows: list[dict[str, Any]] = []

    with sync_playwright() as p:
        context = p.chromium.launch_persistent_context(
            str(args.profile),
            headless=False,
            executable_path=args.chrome,
            viewport={"width": 1440, "height": 900},
            args=["--disable-blink-features=AutomationControlled"],
        )
        page = context.pages[0] if context.pages else context.new_page()
        page.goto("https://qiye.qianlima.com/new_qd_yfbsite/", wait_until="domcontentloaded", timeout=90000)
        time.sleep(2)
        for index, project in enumerate(projects, 1):
            params = parse_qianlima_url(clean(project.get("官网查看地址")))
            if not params:
                continue
            detail_path = api_path("subZhaobiao/zbDetail", params)
            attach_path = api_path("subZhaobiao/checkAttachmentPermission", {"contentId": params["contentId"]})
            detail = fetch_json(page, detail_path)
            attach = fetch_json(page, attach_path)
            permission, content, field = row_from_detail(project, detail, attach)
            permission["序号"] = index
            permission_rows.append(permission)
            content_rows.append(content)
            field_rows.append(field)
            api_rows.append(
                {
                    "序号": index,
                    "项目ID": project.get("项目ID", ""),
                    "contentId": params.get("contentId", ""),
                    "详情接口": detail_path,
                    "详情HTTP": detail.get("status", ""),
                    "详情长度": detail.get("length", ""),
                    "附件接口": attach_path,
                    "附件HTTP": attach.get("status", ""),
                    "附件长度": attach.get("length", ""),
                }
            )
            print(
                f"[{index}/{len(projects)}] {permission['详情状态']} "
                f"contentId={params.get('contentId')} len={detail.get('length')}",
                flush=True,
            )
            time.sleep(args.delay)
        context.close()

    summary = [
        {"指标": "项目数", "值": len(permission_rows), "说明": "读取实体 Excel 中的乙方宝/千里马项目。"},
        {"指标": "可采正文项目数", "值": sum(1 for row in permission_rows if row.get("详情状态") == "可采正文"), "说明": "接口返回正文结构。"},
        {"指标": "需电脑端/会员授权项目数", "值": sum(1 for row in permission_rows if row.get("详情状态") == "需电脑端/会员授权"), "说明": "登录有效但仍缺权限。"},
        {"指标": "地区/会员限制项目数", "值": sum(1 for row in permission_rows if row.get("详情状态") == "地区/会员限制"), "说明": "当前账号未覆盖对应地区或会员级别。"},
        {"指标": "输出目录", "值": str(args.output_dir), "说明": "登录态结果默认放在 tmp，避免误公开。"},
    ]
    tables = {
        "运行摘要": summary,
        "项目权限矩阵": permission_rows,
        "可见正文摘要": content_rows,
        "接口字段索引": field_rows,
        "接口调用记录": api_rows,
    }
    workbook = write_workbook(args.output_dir, tables)
    html_path = write_html(args.output_dir, tables, workbook)
    return workbook, html_path, tables


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Probe Yifangbao tender details with a logged-in browser session.")
    parser.add_argument("--source", type=Path, default=DEFAULT_SOURCE)
    parser.add_argument("--profile", type=Path, default=DEFAULT_PROFILE)
    parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUT_DIR)
    parser.add_argument("--chrome", default=DEFAULT_CHROME)
    parser.add_argument("--limit", type=int, default=0)
    parser.add_argument("--delay", type=float, default=0.35)
    return parser.parse_args()


def main() -> int:
    args = parse_args()
    workbook, html_path, tables = crawl(args)
    summary = {row["指标"]: row["值"] for row in tables["运行摘要"]}
    print(json.dumps({"workbook": str(workbook), "html": str(html_path), "summary": summary}, ensure_ascii=False, indent=2))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
