#!/usr/bin/env python3
"""Confluence 需求文档获取脚本（Playwright - 通用版）"""

import json
import sys
import os
import re
import shutil
from pathlib import Path
from urllib.parse import quote, urlencode, urljoin, urlparse, unquote
from urllib.request import Request, urlopen
from urllib.error import HTTPError, URLError

try:
    from playwright.sync_api import sync_playwright
except ImportError:
    print("❌ 请先安装 playwright: pip install playwright && playwright install chromium")
    sys.exit(1)

DEFAULT_CONFLUENCE_USERNAME = "lixinxin"
DEFAULT_CONFLUENCE_PASSWORD = "Xin@147258"
# Confluence REST API Bearer Token（优先于 Playwright 登录，确保 Step1 可稳定拉取 PRD）
DEFAULT_CONFLUENCE_API_TOKEN = "NTc3NDMzNjQxNjE3OiDitymjYiVP9cQ4PfTzkgOeb+DA"


def env(name: str, default: str | None = None) -> str:
    v = os.environ.get(name, default)
    if v is None:
        return ""
    return str(v).strip()


def env_bool(name: str, default: bool = False) -> bool:
    v = env(name, "1" if default else "0").lower()
    return v in ("1", "true", "yes", "on")


def load_env_file(file_path: Path) -> None:
    """轻量加载 .env 文件（不依赖 python-dotenv）。"""
    if not file_path.exists() or not file_path.is_file():
        return
    try:
        for raw in file_path.read_text(encoding="utf-8").splitlines():
            line = raw.strip()
            if not line or line.startswith("#") or "=" not in line:
                continue
            key, value = line.split("=", 1)
            key = key.strip()
            value = value.strip().strip('"').strip("'")
            if key and key not in os.environ:
                os.environ[key] = value
    except Exception:
        # 失败不阻断，继续走显式环境变量
        pass


def normalize_version(raw: str) -> str:
    """将 1.2.3 / V1.2.3 / XXX-V1.2.3 / XXX_V1.2.3 统一为 1.2.3"""
    v = raw.strip()
    v = re.sub(r"^[A-Z]+[-_]?V?", "", v, flags=re.IGNORECASE)
    v = re.sub(r"^V", "", v, flags=re.IGNORECASE)
    return v


def version_slug(prefix: str, version: str) -> str:
    """1.2.3 -> <prefix>-v123"""
    return prefix.lower() + "-v" + version.replace(".", "")


# 图片统一放在版本目录下的 image/ 子目录，与 PRD Markdown 同级
IMAGE_SUBDIR = "image"


def dedupe_keywords(items: list[str]) -> list[str]:
    """去重并保持顺序。"""
    seen: set[str] = set()
    out: list[str] = []
    for item in items:
        key = item.strip()
        if not key or key in seen:
            continue
        seen.add(key)
        out.append(key)
    return out


def normalize_match_token(text: str) -> str:
    """匹配时统一 -/_ 与大小写，避免 ADI-V 与 ADI_V 标题不一致导致漏命中。"""
    return text.lower().replace("-", "_").replace(".", "")


def search_keywords(prefix: str, version: str, raw_input: str | None = None) -> list[str]:
    """固定顺序（与用户 Confluence 全局搜索习惯一致）。

    ADI 等产品的典型降级链：
    用户原样输入 → ADI_V2.3.4 → ADI-V2.3.4 → ADI_2.3.4 → ADI-2.3.4
    → ADI_v2.3.4 → ADI-v2.3.4 → ADI V2.3.4 → ADI_V2.3.4结算 → V2.3.4
    
    BreakX 降级链（精确搜索优先）：
    BreakX_V1.3 → breakx_v1.3 → BreakX_1.3 → breakx_1.3 → 报错
    """
    p = prefix.strip()
    # BreakX（破圈引擎）PRD 搜索：严格按产品名+版本优先，禁止纯版本号导致误匹配
    if p.upper() in ("BREAKX", "BX"):
        return dedupe_keywords(
            [
                # 不使用 raw_input，避免纯版本号 "1.3" 误匹配其他产品
                f"BreakX_V{version}",  # 1. 完全匹配
                f"breakx_v{version}",  # 2. 全部转换成小写
                f"BreakX_{version}",   # 3. 去掉V
                f"breakx_{version}",   # 4. 去掉V并且转换成小写
            ]
        )
    adi_long_title_kw: list[str] = []
    if p.upper() == "ADI":
        adi_long_title_kw = [
            f"ADI_V{version}结算",
            f"ADI_V{version}结算、开票、回款",
        ]
    return dedupe_keywords(
        [
            *( [raw_input.strip()] if raw_input and raw_input.strip() else [] ),
            f"{prefix}_V{version}",
            f"{prefix}-V{version}",
            f"{prefix}_{version}",
            f"{prefix}-{version}",
            f"{prefix}_v{version}",
            f"{prefix}-v{version}",
            f"{prefix} V{version}",
            *adi_long_title_kw,
            f"V{version}",
        ]
    )


def sanitize_filename(name: str) -> str:
    name = re.sub(r'[\\/:*?"<>|]', "-", name)
    return name.strip() or "需求文档"


def parse_html_to_markdown(html_content: str) -> str:
    md = html_content
    md = re.sub(r"<h1[^>]*>(.*?)</h1>", r"# \1\n\n", md, flags=re.DOTALL | re.IGNORECASE)
    md = re.sub(r"<h2[^>]*>(.*?)</h2>", r"## \1\n\n", md, flags=re.DOTALL | re.IGNORECASE)
    md = re.sub(r"<h3[^>]*>(.*?)</h3>", r"### \1\n\n", md, flags=re.DOTALL | re.IGNORECASE)
    md = re.sub(r"<h4[^>]*>(.*?)</h4>", r"#### \1\n\n", md, flags=re.DOTALL | re.IGNORECASE)
    md = re.sub(r"<li[^>]*>(.*?)</li>", r"- \1\n", md, flags=re.DOTALL | re.IGNORECASE)
    md = re.sub(r'<a[^>]*href="([^"]+)"[^>]*>(.*?)</a>', r"[\2](\1)", md, flags=re.DOTALL | re.IGNORECASE)
    md = re.sub(r'<img[^>]*src="([^"]+)"[^>]*>', r"![](\1)", md, flags=re.DOTALL | re.IGNORECASE)
    md = re.sub(r"<table[^>]*>", r"\n", md, flags=re.DOTALL | re.IGNORECASE)
    md = re.sub(r"</table>", r"\n", md, flags=re.DOTALL | re.IGNORECASE)
    md = re.sub(r"<tr[^>]*>", r"|", md, flags=re.DOTALL | re.IGNORECASE)
    md = re.sub(r"</tr>", r"\n", md, flags=re.DOTALL | re.IGNORECASE)
    md = re.sub(r"<t[dh][^>]*>(.*?)</t[dh]>", r"|\1", md, flags=re.DOTALL | re.IGNORECASE)
    md = re.sub(r"<p[^>]*>(.*?)</p>", r"\1\n\n", md, flags=re.DOTALL | re.IGNORECASE)
    md = re.sub(r"<br[^>]*>", r"\n", md, flags=re.DOTALL | re.IGNORECASE)
    md = re.sub(r"<strong[^>]*>(.*?)</strong>", r"**\1**", md, flags=re.DOTALL | re.IGNORECASE)
    md = re.sub(r"<b[^>]*>(.*?)</b>", r"**\1**", md, flags=re.DOTALL | re.IGNORECASE)
    md = re.sub(r"<em[^>]*>(.*?)</em>", r"*\1*", md, flags=re.DOTALL | re.IGNORECASE)
    md = re.sub(r"<code[^>]*>(.*?)</code>", r"`\1`", md, flags=re.DOTALL | re.IGNORECASE)
    md = re.sub(r"<pre[^>]*>(.*?)</pre>", r"```\n\1\n```", md, flags=re.DOTALL | re.IGNORECASE)
    md = re.sub(r"<[^>]+>", "", md)
    md = re.sub(r"\n{3,}", "\n\n", md)
    return md.strip()


def is_wiki_page_href(href: str) -> bool:
    if not href:
        return False
    href_lower = href.lower()
    if any(x in href_lower for x in ("dosearchsite", "login.action", "dashboard")):
        return False
    return any(x in href_lower for x in ("/pages/", "/display/", "/spaces/"))


def absolutize_href(base_url: str, href: str) -> str:
    return base_url + href if href.startswith("/") else href


def href_path_matches_keyword(href: str, keyword: str) -> bool:
    path_norm = normalize_match_token(unquote(urlparse(href).path))
    kw_norm = normalize_match_token(keyword)
    return kw_norm in path_norm


def text_matches_keyword(text: str, keyword: str) -> bool:
    """标题/链接文案匹配：忽略 -/_ 差异；支持前缀命中（如 ADI_V2.3.4结算…）。"""
    t_norm = normalize_match_token(text or "")
    kw_norm = normalize_match_token(keyword)
    return kw_norm in t_norm or t_norm.startswith(kw_norm)


def score_link_match(text: str, href: str, prefix: str, version: str, keyword: str) -> int:
    """对搜索结果链接打分；>=2 视为命中。"""
    t = (text or "").lower()
    path = unquote(urlparse(href).path).lower()
    kw_norm = normalize_match_token(keyword)
    p = prefix.lower()
    v_compact = version.replace(".", "")
    t_norm = normalize_match_token(text or "")
    t_compact = re.sub(r"\s+", "", t_norm)
    path_norm = normalize_match_token(path)

    score = 0
    if text_matches_keyword(text, keyword):
        score += 4
    if kw_norm in t_compact:
        score += 3
    if p in t and version in t:
        score += 4
    if p in t_norm and v_compact in t_compact:
        score += 3
    if href_path_matches_keyword(href, keyword):
        score += 4
    slug = f"{p}v{v_compact}"
    if slug in path_norm or f"{p}-v{v_compact}" in path or f"{p}_v{v_compact}" in path:
        score += 4
    # BreakX：标题以 V1.0.2 开头时加分
    if p in ("breakx", "bx") and (t.startswith(f"v{version}") or f"v{version}" in t[:12]):
        score += 5
    space = env("CONFLUENCE_SPACE", "").lower()
    if space and f"/spaces/{space}/" in href.lower():
        score += 5
    # ADI/产品 PRD 标题或 URL 含版本号时加权，降低误命中其他空间页面
    if re.search(r"adi[-_]?v", text, re.IGNORECASE) or re.search(
        r"adi[-_]?v", unquote(href), re.IGNORECASE
    ):
        score += 2
    return score


def collect_search_candidates(
    page, base_url: str, prefix: str, version: str, keyword: str
) -> list[tuple[str, str, int]]:
    """返回 [(url, title, score), ...]；标题命中或 URL 路径命中均可得分（>=2）。"""
    seen: set[str] = set()
    scored: list[tuple[int, str, str]] = []
    debug = env_bool("CONFLUENCE_DEBUG")

    selectors = [
        ".search-result .title a",
        ".search-result-title a",
        "#search-results .gs-title a",
        "ol.search-results-list a",
        ".search-results a",
        ".search-result a",
        "#search-results a",
        ".search-results-panel a",
        "a[data-linked-resource-id]",
        f"a[href*='{keyword}']",
        f"a:has-text('{keyword}')",
        "a[href*='/pages/']",
        "a[href*='/display/']",
    ]

    for selector in selectors:
        links = page.locator(selector)
        count = min(links.count(), 200)
        if debug and count:
            print(f"   [debug] selector={selector!r} count={count}")
        for i in range(count):
            href = links.nth(i).get_attribute("href") or ""
            raw_text = (links.nth(i).inner_text() or "").strip()
            title_text = raw_text.split("\n")[0].strip()
            link_title_attr = (links.nth(i).get_attribute("title") or "").strip()
            text = link_title_attr or title_text
            if not is_wiki_page_href(href):
                continue
            full_url = absolutize_href(base_url, href)
            if full_url in seen:
                continue
            seen.add(full_url)

            s = score_link_match(text, href, prefix, version, keyword)
            if s >= 2:
                scored.append((s, full_url, text[:120]))

    if debug and not scored:
        print("   [debug] 未解析到 score>=2 的 wiki 页面链接")

    scored.sort(key=lambda x: x[0], reverse=True)
    return [(u, t, s) for s, u, t in scored]


def read_breadcrumb_text(page) -> str:
    parts: list[str] = []
    for selector in [
        "#breadcrumbs li",
        "#breadcrumbs a",
        "ol.aui-nav-breadcrumbs li",
        ".breadcrumbs li",
        "nav[aria-label='Breadcrumbs'] li",
    ]:
        loc = page.locator(selector)
        for i in range(min(loc.count(), 20)):
            txt = (loc.nth(i).inner_text() or "").strip()
            if txt and txt not in parts:
                parts.append(txt)
    return " > ".join(parts)


PRD_TECHNICAL_TITLE_MARKERS = (
    "技术文档",
    "技术方案",
    "详细设计",
    "接口文档",
    "后端",
    "数据库",
    "实现文档",
    "设计文档",
    "开发文档",
    "API文档",
    "数据结构",
    "表设计",
)


def has_forbidden_prd_title(title: str) -> bool:
    """技术文档/后端设计页必须在 PRD 拉取阶段直接拦截。"""
    title_upper = (title or "").upper()
    return any(marker.upper() in title_upper for marker in PRD_TECHNICAL_TITLE_MARKERS)


def is_adi_requirement_page(title: str, crumbs: str, page_url: str) -> bool:
    """ADI 需求页常无 PRD 字样，标题形如 ADI_V2.3.4结算、开票、回款数据对接欢聚。"""
    title_upper = (title or "").upper()
    crumbs_upper = (crumbs or "").upper()

    if has_forbidden_prd_title(title):
        print(f"      ✗ 命中技术文档标题，拒绝作为 PRD: {title[:80]}")
        return False
    if re.search(r"ADI[_-]?V\d+\.\d+\.\d+", title_upper):
        print(f"      ✓ ADI 版本标题命中: {title[:80]}")
        return True
    adi_markers = ("结算", "开票", "回款", "对接欢聚", "数据对接")
    if any(m in title for m in adi_markers) and "ADI" in title_upper:
        print(f"      ✓ ADI 财务对接标题命中: {title[:80]}")
        return True
    if "ADI" in crumbs_upper and re.search(r"V\d+\.\d+\.\d+", title_upper + crumbs_upper):
        print(f"      ✓ ADI 面包屑+版本命中: {crumbs[:80]} / {title[:60]}")
        return True
    if "/spaces/yyzcpt/" in page_url.lower() and re.search(r"ADI[_-]?V", title_upper):
        print(f"      ✓ yyzcpt 空间 ADI 页面: {title[:80]}")
        return True
    return False


def is_prd_page(page, page_url: str) -> bool:
    """检查页面面包屑/父级是否属于 PRD（需求文档）。"""
    page.goto(page_url, wait_until="domcontentloaded", timeout=60000)
    page.wait_for_timeout(1200)

    crumbs = read_breadcrumb_text(page)
    crumbs_upper = crumbs.upper()
    prefix = env("DOC_PRODUCT_PREFIX", "").upper()

    try:
        title = (page.locator("#title-text").inner_text() or "").strip()
    except Exception:
        title = ""

    if has_forbidden_prd_title(title):
        print(f"      ✗ 命中技术文档标题，拒绝作为 PRD: {title[:80]}")
        return False

    # ADI：标题常无「PRD/需求」字样，单独识别
    if prefix == "ADI" and is_adi_requirement_page(title, crumbs, page_url):
        return True

    # BreakX：xhs 空间 PRD 挂在「产品设计」下，不一定有 PRD 字样
    if prefix in ("BREAKX", "BX") and "/spaces/xhs/" in page_url.lower():
        if "产品设计" in crumbs or "BREAKX" in crumbs_upper or "破圈" in crumbs:
            print(f"      ✓ BreakX/xhs 产品设计命中: {crumbs[:120]}")
            return True
    if re.search(r"\bPRD\b", crumbs_upper) or "需求" in crumbs or "PRD" in crumbs_upper:
        print(f"      ✓ PRD 父级命中: {crumbs[:120]}")
        return True

    title_upper = title.upper()
    if re.search(r"\bPRD\b", title_upper) or "需求文档" in title:
        print(f"      ✓ 标题含 PRD/需求: {title[:80]}")
        return True

    print(f"      ✗ 非 PRD 父级: {crumbs[:120] or title[:80]}")
    return False


def resolve_direct_page_url(base_url: str) -> str | None:
    """支持 CONFLUENCE_PAGE_URL 或 CONFLUENCE_PAGE_ID 直链，跳过搜索。"""
    page_url = env("CONFLUENCE_PAGE_URL")
    if page_url:
        return page_url
    page_id = resolve_direct_page_id()
    if page_id:
        return f"{base_url.rstrip('/')}/pages/viewpage.action?pageId={page_id}"
    return None


def resolve_direct_page_id() -> str | None:
    """从环境变量或 URL 中解析 pageId。"""
    page_id = env("CONFLUENCE_PAGE_ID")
    if page_id:
        return page_id
    page_url = env("CONFLUENCE_PAGE_URL")
    if not page_url:
        return None
    m = re.search(r"/pages/(\d+)", page_url)
    if m:
        return m.group(1)
    m = re.search(r"pageId=(\d+)", page_url, flags=re.IGNORECASE)
    if m:
        return m.group(1)
    return None


def get_api_token() -> str:
    """读取 Confluence REST Bearer Token。"""
    return (
        env("CONFLUENCE_API_TOKEN")
        or env("CONFLUENCE_TOKEN")
        or env("CONFLUENCE_BEARER_TOKEN")
        or DEFAULT_CONFLUENCE_API_TOKEN
    )


def rest_request(base_url: str, token: str, path: str, params: dict | None = None) -> dict:
    """调用 Confluence REST API，失败时抛出可读异常。"""
    query = f"?{urlencode(params)}" if params else ""
    url = f"{base_url.rstrip('/')}{path}{query}"
    req = Request(
        url,
        headers={
            "Authorization": f"Bearer {token}",
            "Accept": "application/json",
        },
    )
    try:
        with urlopen(req, timeout=60) as resp:
            return json.loads(resp.read().decode("utf-8"))
    except HTTPError as e:
        body = e.read().decode("utf-8", errors="replace")[:300]
        raise RuntimeError(f"REST API HTTP {e.code}: {body}") from e
    except URLError as e:
        raise RuntimeError(f"REST API 网络错误: {e}") from e


def rest_fetch_page(base_url: str, token: str, page_id: str) -> dict:
    """按 pageId 拉取页面标题与 storage HTML。"""
    data = rest_request(
        base_url,
        token,
        f"/rest/api/content/{page_id}",
        {"expand": "body.storage,space"},
    )
    html = data.get("body", {}).get("storage", {}).get("value", "")
    if not html or len(html) < 20:
        raise RuntimeError(f"pageId={page_id} 正文为空或过短")
    return {
        "id": str(data.get("id", page_id)),
        "title": data.get("title", "").strip() or f"page-{page_id}",
        "html": html,
        "space": (data.get("space") or {}).get("key", ""),
        "url": f"{base_url.rstrip('/')}/pages/viewpage.action?pageId={page_id}",
    }


def rest_search_candidates(
    base_url: str, token: str, prefix: str, version: str, keyword: str
) -> list[tuple[int, str, str]]:
    """CQL 搜索页面，返回 [(score, page_id, title), ...]。"""
    cql = f'type=page and title ~ "{keyword.replace(chr(34), "")}"'
    space = env("CONFLUENCE_SPACE")
    if space:
        cql = f'space="{space}" and {cql}'
    data = rest_request(
        base_url,
        token,
        "/rest/api/content/search",
        {"cql": cql, "limit": "15"},
    )
    scored: list[tuple[int, str, str]] = []
    for item in data.get("results", []):
        title = (item.get("title") or "").strip()
        page_id = str(item.get("id", ""))
        if not page_id or has_forbidden_prd_title(title):
            if title and has_forbidden_prd_title(title):
                print(f"   跳过技术文档候选: {title[:80]}")
            continue
        href = f"/spaces/{(item.get('space') or {}).get('key', '')}/pages/{page_id}/"
        score = score_link_match(title, href, prefix, version, keyword)
        if score >= 2:
            scored.append((score, page_id, title))
    scored.sort(key=lambda x: x[0], reverse=True)
    return scored


def rest_resolve_target(
    base_url: str, token: str, prefix: str, version: str, keywords: list[str]
) -> tuple[str, str, str, str] | None:
    """REST 搜索命中页面，返回 (page_id, title, keyword, url)。"""
    direct_id = resolve_direct_page_id()
    if direct_id:
        page = rest_fetch_page(base_url, token, direct_id)
        if has_forbidden_prd_title(page["title"]):
            print(f"❌ 直链页面不是业务 PRD，命中技术文档黑名单: {page['title']}")
            print("   请确认提供的 pageId 是业务需求文档，而非技术文档/后端设计文档")
            raise RuntimeError(f"直链页面不是 PRD，命中技术文档标题: {page['title']}")
        kw = keywords[0] if keywords else f"{prefix}_V{version}"
        return page["id"], page["title"], kw, page["url"]

    for kw in keywords:
        print(f"\n🔍 [REST] 搜索: {kw}")
        candidates = rest_search_candidates(base_url, token, prefix, version, kw)
        if not candidates:
            print(f"   未找到 {kw}")
            continue
        for score, page_id, title in candidates:
            print(f"   候选 (score={score}): {title[:80]}")
            page = rest_fetch_page(base_url, token, page_id)
            # 双重校验：标题黑名单 + ADI 特殊规则
            if has_forbidden_prd_title(page["title"]):
                print(f"      ✗ 命中技术文档标题，拒绝作为 PRD: {page['title'][:80]}")
                continue
            if prefix.upper() == "ADI" and is_adi_title_match(page["title"], version):
                print(f"      ✓ ADI 业务 PRD 命中")
                return page_id, page["title"], kw, page["url"]
            if score >= 4:
                return page_id, page["title"], kw, page["url"]
    print("   ⚠ REST 搜索结果均未通过 PRD 校验")
    print("      → 所有候选页面要么是技术/后端/设计文档，要么评分不足")
    print("      → 不回退到非 PRD 候选；请确认业务需求文档的 pageId")
    return None


def is_adi_title_match(title: str, version: str) -> bool:
    """ADI 长标题页识别（面包屑可无 PRD 字样）。"""
    if has_forbidden_prd_title(title):
        return False
    title_upper = (title or "").upper()
    v_compact = version.replace(".", "")
    if re.search(rf"ADI[_-]?V{re.escape(version)}", title_upper):
        return True
    if re.search(rf"ADI[_-]?V{v_compact}", normalize_match_token(title)):
        return True
    markers = ("结算", "开票", "回款", "对接欢聚", "数据对接", "蒲公英", "报价单")
    return "ADI" in title_upper and any(m in title for m in markers)


def clean_confluence_storage_html(html: str) -> str:
    """清理 Confluence storage 宏标签，便于转 Markdown。"""
    html = re.sub(
        r"<ac:structured-macro[^>]*>.*?</ac:structured-macro>",
        "",
        html,
        flags=re.DOTALL,
    )
    html = re.sub(r"<ac:parameter[^>]*>.*?</ac:parameter>", "", html, flags=re.DOTALL)
    html = re.sub(
        r"<ac:plain-text-body><!\[CDATA\[(.*?)\]\]></ac:plain-text-body>",
        r"\1",
        html,
        flags=re.DOTALL,
    )
    html = re.sub(r"<ac:link[^>]*><ri:page[^>]*></ac:link>", "", html)
    html = re.sub(r"<ac:image[^>]*>.*?</ac:image>", "", html, flags=re.DOTALL)
    return html


def download_images_http(
    base_url: str,
    token: str,
    content_html: str,
    version_dir: Path,
    request_get=None,
) -> str:
    """下载正文图片到版本目录下的 image/ 并改写相对路径；request_get 可注入 Playwright page.request.get。"""
    image_dir = version_dir / IMAGE_SUBDIR
    image_dir.mkdir(parents=True, exist_ok=True)
    img_urls = re.findall(r'<img[^>]*src="([^"]+)"', content_html)
    for i, img_url in enumerate(img_urls):
        original_url = img_url
        if img_url.startswith("//"):
            img_url = "https:" + img_url
        elif img_url.startswith("/"):
            img_url = base_url.rstrip("/") + img_url
        elif not img_url.startswith("http"):
            img_url = urljoin(base_url, img_url)

        if not img_url.startswith("http") or img_url.startswith("data:"):
            continue
        parsed = urlparse(img_url)
        ext = os.path.splitext(parsed.path)[1] or ".png"
        filename = f"image_{i + 1}{ext}"
        filepath = image_dir / filename
        try:
            if request_get:
                resp = request_get(img_url)
                body = resp.body() if hasattr(resp, "body") else resp.read()
                ok = resp.ok if hasattr(resp, "ok") else True
            else:
                req = Request(
                    img_url,
                    headers={"Authorization": f"Bearer {token}"},
                )
                with urlopen(req, timeout=30) as resp:
                    body = resp.read()
                    ok = True
            if ok and body:
                filepath.write_bytes(body)
                content_html = content_html.replace(
                    f'src="{original_url}"', f'src="{IMAGE_SUBDIR}/{filename}"'
                )
        except Exception:
            pass
    return content_html


def write_prd_markdown(
    title: str,
    matched_keyword: str,
    target_url: str,
    content_html: str,
    prefix: str,
    base_url: str,
    token: str,
    version_dir: Path,
    request_get=None,
) -> Path:
    """将 HTML 转 Markdown 并写入版本目录（与 image/ 同级）。"""
    version_dir.mkdir(parents=True, exist_ok=True)
    html = clean_confluence_storage_html(content_html)
    html = download_images_http(base_url, token, html, version_dir, request_get)
    content_md = parse_html_to_markdown(html)
    md_content = f"""# {title}

> 版本: {matched_keyword}
> 来源: {target_url}

---

{content_md}
"""
    md_filename = sanitize_filename(title) + ".md"
    if not re.search(rf"{prefix}[-_]?V", md_filename, flags=re.IGNORECASE):
        md_filename = f"{matched_keyword}-{md_filename}"
    md_path = version_dir / md_filename
    md_path.write_text(md_content, encoding="utf-8")
    return md_path


def ensure_prd_image_dir_metadata(content: str) -> str:
    """在 PRD 头部写入原型图片相对路径，供 OPSX UI 解析。"""
    if "原型图片目录" in content:
        return content
    lines = content.split("\n")
    title_idx = next(
        (i for i, line in enumerate(lines) if line.strip().startswith("# ")),
        -1,
    )
    insert_at = title_idx + 1 if title_idx >= 0 else 0
    lines.insert(insert_at, "")
    lines.insert(insert_at + 1, f"> 原型图片目录: {IMAGE_SUBDIR}/")
    return "\n".join(lines)


def sync_to_openspec_source(md_path: Path, version_dir: Path) -> Path | None:
    """将 PRD 与 image/ 同步到 openspec source/（需设置环境变量 SDD_SYNC_SOURCE_DIR）。"""
    raw = env("SDD_SYNC_SOURCE_DIR")
    if not raw:
        return None
    source_dir = Path(raw).expanduser()
    if not source_dir.is_absolute():
        source_dir = (Path.cwd() / source_dir).resolve()
    source_dir.mkdir(parents=True, exist_ok=True)
    content = ensure_prd_image_dir_metadata(md_path.read_text(encoding="utf-8"))
    dest_md = source_dir / "PRD.md"
    dest_md.write_text(content, encoding="utf-8")
    src_image = version_dir / IMAGE_SUBDIR
    dest_image = source_dir / IMAGE_SUBDIR
    if src_image.is_dir():
        if dest_image.exists():
            shutil.rmtree(dest_image)
        shutil.copytree(src_image, dest_image)
        print(f"📋 已同步到 openspec source: {dest_md}")
        print(f"🖼️ 已同步图片目录: {dest_image}")
    else:
        print(f"📋 已同步到 openspec source: {dest_md}")
    return dest_md


def try_rest_download(
    base_url: str,
    prefix: str,
    version: str,
    keywords: list[str],
    version_dir: Path,
) -> bool:
    """REST API 主路径：Bearer Token 拉取 PRD，不依赖 Playwright 登录。"""
    token = get_api_token()
    if not token:
        print("   ⚠ 未配置 CONFLUENCE_API_TOKEN，跳过 REST")
        return False
    try:
        resolved = rest_resolve_target(base_url, token, prefix, version, keywords)
        if not resolved:
            print("   REST 未命中任何页面")
            return False
        page_id, title, matched_keyword, target_url = resolved
        print(f"✅ [REST] 命中: {matched_keyword} | {title[:80]}")
        page = rest_fetch_page(base_url, token, page_id)
        md_path = write_prd_markdown(
            page["title"],
            matched_keyword,
            target_url,
            page["html"],
            prefix,
            base_url,
            token,
            version_dir,
        )
        if md_path.stat().st_size < 80:
            raise RuntimeError("生成的 Markdown 过短")
        sync_to_openspec_source(md_path, version_dir)
        print("\n✅ 获取完成（REST API）！")
        print(f"📄 主文档: {md_path}")
        print(f"🖼️ 图片目录: {version_dir / IMAGE_SUBDIR}")
        return True
    except Exception as e:
        print(f"   REST 拉取失败: {e}")
        return False


def run_search_dosearch(page, base_url: str, prefix: str, version: str, keyword: str) -> list[tuple[str, str, int]]:
    """打开 Confluence 全站专用搜索页（dosearchsite），比首页 #all-updates 顶栏搜索更稳定。"""
    url = f"{base_url.rstrip('/')}/dosearchsite.action?queryString={quote(keyword)}"
    print(f"   → 专用搜索页: .../dosearchsite.action?queryString={quote(keyword)}")
    for attempt in range(2):
        try:
            page.goto(url, wait_until="domcontentloaded", timeout=60000)
        except Exception as exc:
            # 搜索页常有重定向/长连接，networkidle 易误报 ERR_ABORTED
            if "ERR_ABORTED" not in str(exc) and "Timeout" not in str(exc):
                raise
            if attempt == 0:
                print("   导航被中断，等待搜索结果页稳定...")
            try:
                page.goto(url, wait_until="load", timeout=60000)
            except Exception:
                pass
        page.wait_for_load_state("domcontentloaded", timeout=30000)
        page.wait_for_timeout(2500)
        if is_login_page(page):
            return []
        current = page.url.lower()
        on_search = "dosearchsite" in current or "search" in current
        if on_search or attempt == 1:
            break
        print("   未停留在搜索结果页，重试一次...")
    return collect_search_candidates(page, base_url, prefix, version, keyword)


def run_search_quick(page, base_url: str, prefix: str, version: str, keyword: str) -> list[tuple[str, str, int]]:
    """顶栏快速搜索（dosearchsite 无结果时的兜底，非主路径）。"""
    search_selector = None
    for selector in [
        "#quick-search-query",
        "#search-query-input",
        "input[name='search-query']",
        "input.search-query",
        "input[placeholder*='搜索']",
        "input[placeholder*='Search']",
        "input[id*='search']",
    ]:
        if page.locator(selector).count() > 0:
            search_selector = selector
            break
    if not search_selector:
        return []
    print(f"   → 顶栏搜索框（兜底）: {search_selector}  关键词: {keyword}")
    page.fill(search_selector, keyword, timeout=8000)
    page.keyboard.press("Enter")
    page.wait_for_load_state("domcontentloaded", timeout=30000)
    page.wait_for_timeout(2500)
    return collect_search_candidates(page, base_url, prefix, version, keyword)


def resolve_prd_target(
    page, base_url: str, prefix: str, version: str, keywords: list[str]
) -> tuple[str | None, str | None, str]:
    """依次用每个关键词搜索，直到找到父级为 PRD 的页面。
    
    BreakX 严格匹配模式：按 BreakX_V1.3 → breakx_v1.3 → BreakX_1.3 → breakx_1.3 顺序，
    找不到就报错，不继续尝试其他宽泛关键词。
    """
    seen_urls: set[str] = set()
    is_breakx_strict = prefix.strip().upper() in ("BREAKX", "BX")

    for kw in keywords:
        print(f"\n🔍 搜索关键词: {kw}")
        batches = [
            ("dosearchsite", run_search_dosearch(page, base_url, prefix, version, kw)),
            ("quick-search", run_search_quick(page, base_url, prefix, version, kw)),
        ]
        for method_name, candidates in batches:
            if not candidates:
                print(f"   ({method_name}) 无结果")
                continue
            print(f"   ({method_name}) {len(candidates)} 条候选，检查 PRD 父级...")
            for url, title, _score in candidates:
                if url in seen_urls:
                    continue
                seen_urls.add(url)
                print(f"    · 候选: {title[:70]}")
                # 先执行标题级黑名单快速拒绝
                if has_forbidden_prd_title(title):
                    print(f"      ✗ 命中技术文档标题，拒绝作为 PRD: {title[:80]}")
                    continue
                if is_prd_page(page, url):
                    return url, kw, title

    # BreakX 严格模式：所有预定义关键词都找不到时，直接报错
    if is_breakx_strict:
        print("\n❌ BreakX 严格匹配失败")
        print(f"   已尝试完所有关键词: {', '.join(keywords)}")
        print("   → 请确认 Confluence 中是否存在标题为以下格式之一的 PRD 页面：")
        print(f"      • BreakX_V{keywords[0].split('_V')[-1] if '_V' in keywords[0] else keywords[0].split('_')[-1]}")
        print(f"      • breakx_v{keywords[0].split('_V')[-1] if '_V' in keywords[0] else keywords[0].split('_')[-1]}")
        print(f"      • BreakX_{keywords[0].split('_V')[-1] if '_V' in keywords[0] else keywords[0].split('_')[-1]}")
        print(f"      • breakx_{keywords[0].split('_V')[-1] if '_V' in keywords[0] else keywords[0].split('_')[-1]}")
        raise RuntimeError(f"BreakX PRD 未找到，已尝试: {', '.join(keywords)}")

    print("   ⚠ 搜索结果均未通过 PRD 校验")
    print("      → 所有候选页面要么是技术/后端/设计文档，要么面包屑不含 PRD 父级")
    print("      → 不回退到非 PRD 候选；请确认业务需求文档的 Confluence 标题与 pageId")
    return None, None, ""


def is_login_page(page) -> bool:
    """判断是否仍在登录页（禁止用宽泛的 'login' in url，避免误判）。"""
    url = page.url.lower()
    if "login.action" in url:
        return True
    user_field = page.locator("#username-field, input[name='os_username']")
    pass_field = page.locator("#password-field, input[name='os_password']")
    if user_field.count() > 0 and pass_field.count() > 0:
        try:
            if user_field.first.is_visible() and pass_field.first.is_visible():
                return True
        except Exception:
            return True
    return False


def is_session_authenticated(page, base_url: str) -> bool:
    """通过 Confluence 页面 meta 与会话页探测是否已登录（比 URL 判断更可靠）。"""
    try:
        remote_user = page.locator('meta[name="ajs-remote-user"]').get_attribute("content") or ""
        if remote_user.strip():
            return True
    except Exception:
        pass
    return not is_login_page(page)


def ensure_logged_in(page, base_url: str, username: str, password: str, max_retries: int = 3) -> None:
    """登录 Confluence；用 expect_navigation 包裹点击，并以 remote-user 校验会话。"""
    login_url = f"{base_url.rstrip('/')}/login.action"
    if not username or not password:
        raise RuntimeError("需要登录但未提供 CONFLUENCE_USERNAME / CONFLUENCE_PASSWORD")

    last_err = ""
    for attempt in range(1, max_retries + 1):
        page.goto(login_url, wait_until="domcontentloaded", timeout=60000)
        if is_session_authenticated(page, base_url):
            print("✅ 已处于登录状态")
            return

        print(f"🔐 正在登录 Confluence...（第 {attempt}/{max_retries} 次）")
        page.wait_for_selector("#username-field, input[name='os_username']", state="visible", timeout=15000)
        # 等待 Atlassian 登录网关 React 表单可交互，避免偶发「点了但没提交」
        page.wait_for_timeout(400)

        user_sel = "#username-field" if page.locator("#username-field").count() else "input[name='os_username']"
        pass_sel = "#password-field" if page.locator("#password-field").count() else "input[name='os_password']"
        btn_sel = "#login-button" if page.locator("#login-button").count() else "#login"

        page.fill(user_sel, username, timeout=8000)
        page.fill(pass_sel, password, timeout=8000)
        page.wait_for_timeout(200)

        btn = page.locator(btn_sel)
        try:
            # 关键：导航与点击同一事务，避免 click 后 wait_for_url 的竞态（旧逻辑约 30% 失败）
            with page.expect_navigation(timeout=45000, wait_until="domcontentloaded"):
                btn.click()
        except Exception as nav_err:
            last_err = f"点击登录后未发生导航: {nav_err}"
            print(f"   ⚠ {last_err}")
            page.wait_for_timeout(2000)

        page.wait_for_timeout(1500)

        if is_session_authenticated(page, base_url):
            ru = page.locator('meta[name="ajs-remote-user"]').get_attribute("content") or ""
            print(f"✅ 登录成功（用户: {ru or username}）")
            return

        err = ""
        for sel in [".aui-message.error", ".aui-message.aui-message-error", "#login-error"]:
            loc = page.locator(sel)
            if loc.count():
                err = (loc.first.inner_text() or "").strip()
                break
        last_err = err or f"仍在登录页 ({page.url[:80]})"
        print(f"   ⚠ 登录未成功: {last_err}")

        debug_path = Path.cwd() / f"confluence-login-fail-{attempt}.png"
        try:
            page.screenshot(path=str(debug_path), full_page=True)
            print(f"   已保存登录失败截图: {debug_path}")
        except Exception:
            pass

        page.context.clear_cookies()

    raise RuntimeError(
        f"Confluence 登录失败（已重试 {max_retries} 次）。请检查账号密码或 .env 中的 CONFLUENCE_* 配置。{last_err}"
    )


def main():
    if len(sys.argv) < 2:
        print("❌ 请提供版本号")
        print("   用法: python3 confluence-doc.py <版本号>")
        sys.exit(1)

    # 从脚本目录向上查找 .env（兼容 adInsight-web/frontend 子包）
    script_dir = Path(__file__).resolve().parent
    cur = script_dir
    for _ in range(8):
        for env_name in [".env.pre", ".env", ".env.local", ".env.development", ".env.test"]:
            load_env_file(cur / env_name)
        if cur.parent == cur:
            break
        cur = cur.parent

    base_url = env("CONFLUENCE_BASE_URL", "https://confluence.huan.tv") or "https://confluence.huan.tv"
    search_url = env("CONFLUENCE_SEARCH_URL") or f"{base_url.rstrip('/')}/#all-updates"
    username = (
        env("CONFLUENCE_USERNAME")
        or env("CONFLUENCE_USER")
        or DEFAULT_CONFLUENCE_USERNAME
    )
    password = (
        env("CONFLUENCE_PASSWORD")
        or env("CONFLUENCE_PASS")
        or DEFAULT_CONFLUENCE_PASSWORD
    )
    prefix = os.environ.get("DOC_PRODUCT_PREFIX", "{{DOC_PRODUCT_PREFIX}}").strip() or "{{DOC_PRODUCT_PREFIX}}"
    if prefix.upper() in ("BREAKX", "BX") and not os.environ.get("CONFLUENCE_SPACE"):
        os.environ["CONFLUENCE_SPACE"] = "xhs"
    raw_arg = sys.argv[1].strip()
    version = normalize_version(raw_arg)
    keywords = search_keywords(prefix, version, raw_input=raw_arg)

    # 输出目录默认为仓库 docs/ 下
    default_download = os.environ.get("DOC_DOWNLOAD_DIR")
    if default_download:
        docs_root = Path(default_download)
    else:
        docs_root = Path(__file__).resolve().parents[1]

    slug = version_slug(prefix, version)
    version_dir = docs_root / slug
    version_dir.mkdir(parents=True, exist_ok=True)

    print(f"\n📥 开始获取 {prefix} {keywords[0]} 需求文档")
    print(f"🔍 将依次尝试: {', '.join(keywords)}")
    print(f"📁 输出根目录: {docs_root}")
    print(f"📁 版本目录: {version_dir}")
    print(f"🖼️ 图片目录: {version_dir / IMAGE_SUBDIR}")

    headed = env_bool("CONFLUENCE_HEADED") or env("CONFLUENCE_HEADLESS", "1").lower() in (
        "0",
        "false",
        "no",
    )
    headless = not headed
    slow_mo = int(env("CONFLUENCE_SLOW_MO", "400" if headed else "0") or "0")

    print(f"🖥️  浏览器: headless={headless}, slow_mo={slow_mo}ms（有头模式: CONFLUENCE_HEADED=1）")

    force_playwright = env_bool("CONFLUENCE_FORCE_PLAYWRIGHT")
    use_rest = env_bool("CONFLUENCE_USE_REST")  # 默认关闭；仅显式开启时才走 Token
    skip_playwright = env_bool("CONFLUENCE_SKIP_PLAYWRIGHT")

    playwright_ok = False
    if not skip_playwright:
        print("\n📄 使用 Playwright 登录并拉取 PRD（主路径）...")
        try:
            playwright_ok = _download_via_playwright(
                base_url=base_url,
                username=username,
                password=password,
                prefix=prefix,
                version=version,
                keywords=keywords,
                version_dir=version_dir,
                headless=headless,
                slow_mo=slow_mo,
            )
        except Exception as e:
            print(f"\n⚠ Playwright 拉取失败: {e}")

    if playwright_ok:
        return

    if use_rest:
        print("\n🌐 Playwright 未成功，回退 REST API（Bearer Token）...")
        if try_rest_download(base_url, prefix, version, keywords, version_dir):
            return

    print("\n❌ PRD 拉取失败：Playwright 与 REST 均未成功。")
    print("   排查：")
    print("     1. CONFLUENCE_HEADED=1 CONFLUENCE_SLOW_MO=500 有头模式观察登录与搜索结果")
    print("     2. 到 Confluence 确认业务 PRD 页面的准确标题与 pageId")
    print("     3. 若搜索命中了技术/后端/设计文档，这些已被黑名单拒绝")
    print("     4. 使用业务 PRD 的直链重跑：CONFLUENCE_PAGE_ID=<pageId> python3 confluence-doc.py <版本>")
    print("     5. 禁止手动复制非 PRD 页面到 source/PRD.md")
    sys.exit(1)


def _download_via_playwright(
    base_url: str,
    username: str,
    password: str,
    prefix: str,
    version: str,
    keywords: list[str],
    version_dir: Path,
    headless: bool,
    slow_mo: int,
) -> bool:
    """Playwright 主路径：登录 → 搜索/直链 → 写 Markdown。"""
    with sync_playwright() as p:
        launch_opts: dict = {"headless": headless}
        if slow_mo > 0:
            launch_opts["slow_mo"] = slow_mo
        browser = p.chromium.launch(**launch_opts)
        context = browser.new_context(
            user_agent=(
                "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
                "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
            )
        )
        page = context.new_page()
        try:
            print("\n📄 打开 Confluence 并登录...")
            ensure_logged_in(page, base_url, username, password)
            print(f"📄 当前页: {page.url[:120]}")

            direct_url = resolve_direct_page_url(base_url)
            target_url = direct_url if direct_url else None
            matched_keyword = (
                "CONFLUENCE_PAGE_URL"
                if env("CONFLUENCE_PAGE_URL")
                else ("CONFLUENCE_PAGE_ID" if env("CONFLUENCE_PAGE_ID") else None)
            )

            matched_title = ""
            if not target_url:
                target_url, matched_keyword, matched_title = resolve_prd_target(
                    page, base_url, prefix, version, keywords
                )

            if not target_url or not matched_keyword:
                debug_path = version_dir / "confluence-search-debug.png"
                try:
                    page.screenshot(path=str(debug_path), full_page=True)
                    print(f"   已保存调试截图: {debug_path}")
                except Exception:
                    pass
                print(f"❌ 未找到 {prefix} {version} 的业务需求文档（PRD）。")
                print(
                    "   排查：1) 页面标题是否含 ADI_V2.3.4（下划线，非 ADI-V）及中文后缀"
                )
                print(
                    "   2) 依次尝试 ADI_V2.3.4 / ADI-V2.3.4 / ADI_V2.3.4结算 / V2.3.4"
                )
                print("   3) 若搜索命中了「技术文档」「后端」「详细设计」等页面，这些已被黑名单自动拒绝")
                print("   4) 直链解决：CONFLUENCE_PAGE_ID=<正确pageId> 或 CONFLUENCE_PAGE_URL=<完整URL>")
                print("   5) ADI 页面包屑可能无 PRD 字样，脚本已按 ADI_V* 标题规则识别")
                print("   6) 禁止手动复制技术文档到 source/PRD.md，必须找到真正的业务 PRD")
                return False

            print(f"✅ 命中关键词: {matched_keyword}")
            if matched_title:
                print(f"   页面: {matched_title[:100]}")
            print(f"   URL: {target_url[:120]}...")
            
            # 直链模式下也需检查标题黑名单
            if direct_url:
                page.goto(target_url, wait_until="domcontentloaded", timeout=60000)
                try:
                    direct_title = page.locator("#title-text").text_content().strip()
                    if has_forbidden_prd_title(direct_title):
                        print(f"❌ 直链页面不是业务 PRD，命中技术文档黑名单: {direct_title}")
                        print("   请确认提供的 pageId/URL 是业务需求文档，而非技术文档/后端设计文档")
                        raise RuntimeError("直链页面命中技术文档标题，拒绝作为 PRD")
                except Exception as e:
                    if "技术文档" in str(e) or "黑名单" in str(e):
                        raise
            
            page.goto(target_url, wait_until="networkidle", timeout=60000)

            if not is_prd_page(page, target_url):
                raise RuntimeError("命中的页面未通过 PRD 校验，疑似技术文档或设计文档")

            try:
                title = page.locator("#title-text").text_content().strip()
            except Exception:
                title = matched_title or f"{matched_keyword} 需求文档"

            content_elem = page.locator("#main-content")
            if not content_elem.count():
                content_elem = page.locator(".wiki-content")
            if not content_elem.count():
                content_elem = page.locator(".content-body")
            content_html = content_elem.inner_html() if content_elem.count() else ""
            if len(content_html.strip()) < 20:
                raise RuntimeError("Playwright 拉取正文为空，请检查登录态或 pageId")

            token = get_api_token()
            md_path = write_prd_markdown(
                title,
                matched_keyword,
                target_url,
                content_html,
                prefix,
                base_url,
                token,
                version_dir,
                request_get=page.request.get,
            )

            sync_to_openspec_source(md_path, version_dir)

            print("\n✅ 获取完成（Playwright）！")
            print(f"📄 主文档: {md_path}")
            print(f"🖼️ 图片目录: {version_dir / IMAGE_SUBDIR}")
            return True
        except Exception as e:
            print(f"\n❌ Playwright 获取失败: {e}")
            return False
        finally:
            if env_bool("CONFLUENCE_DEBUG_PAUSE", default=not headless):
                try:
                    input("\n⏸ 调试暂停：按 Enter 关闭浏览器...")
                except (EOFError, KeyboardInterrupt):
                    pass
            browser.close()


if __name__ == "__main__":
    main()

