"""Read operation: load a page and extract its main content as text.

The DOM work lives in ``js/extract_article.js`` (one injected pass returning
typed blocks); this module drives the browser, renders those blocks into the
requested format, and enforces the character budget.

Truncation is the part that matters for the caller: a 200k-character page
pasted into a model's context is worse than useless. ``max_chars`` cuts on a
block boundary and says so in-band, and ``--output`` keeps the full text on
disk so nothing is actually lost.
"""
from __future__ import annotations

import json
from pathlib import Path

from . import js_loader
from .bootstrap import ensure_runtime
from .browser import apply_pre_action_waits, build_context_options, launch_with_browser_install


def extract(cfg: dict) -> dict:
    """Load cfg['url'] and return the raw extraction dict from the page."""
    ensure_runtime()
    from playwright.sync_api import sync_playwright

    js_cfg = {
        "selector": cfg.get("selector") or "",
        "includeLinks": bool(cfg.get("includeLinks")),
        "includeImages": bool(cfg.get("includeImages")),
        "inline": "markdown" if cfg.get("format", "markdown") == "markdown" else "plain",
    }

    with sync_playwright() as p:
        browser = launch_with_browser_install(p, cfg.get("browser"))
        try:
            context = browser.new_context(**build_context_options(p, cfg, None))
            if cfg.get("timeout"):
                context.set_default_timeout(cfg["timeout"])
            page = context.new_page()
            try:
                try:
                    response = page.goto(cfg["url"], wait_until="domcontentloaded")
                except Exception as e:
                    # 一条模型能据以行动的错误，胜过 30 行 Playwright traceback。
                    # 导航失败是这个 skill 最常见的失败形态（打不开 / 超时 / DNS），
                    # 让它读起来像"这个网址打不开"，而不是像程序崩了。
                    raise SystemExit(
                        f"打开页面失败：{cfg['url']}\n"
                        f"{type(e).__name__}: {str(e).splitlines()[0]}\n"
                        "可能是网络不可达、站点屏蔽了无头浏览器，或加载超过了超时时间。"
                        "可尝试加大 --timeout、换 --user-agent，或确认该 URL 在本机能打开。"
                    )
                status = response.status if response else None
                if status is not None and status >= 400:
                    # Keep going: many sites serve real content under a 403/404
                    # (paywalls, soft 404s). The status rides along in the output
                    # so the caller can tell "empty page" from "blocked".
                    pass
                apply_pre_action_waits(page, cfg)
                # SPA 正文往往在 domcontentloaded 之后才注水。networkidle 等不到就
                # 算了——静态页本来就不会再有请求，硬等只是白白花掉 20 秒。
                try:
                    page.wait_for_load_state("networkidle", timeout=cfg.get("networkIdleMs", 8000))
                except Exception:
                    pass
                settle = cfg.get("settleMs")
                if settle:
                    page.wait_for_timeout(settle)

                data = page.evaluate(js_loader.load("extract_article"), js_cfg)
                data["status"] = status
                data["finalUrl"] = page.url
                return data
            finally:
                context.close()
        finally:
            browser.close()


# ── rendering ────────────────────────────────────────────────────────────────

def _render_blocks(blocks: list, fmt: str) -> list[str]:
    """One string per block, ready to be joined with a blank line."""
    md = fmt == "markdown"
    out: list[str] = []
    for block in blocks:
        kind = block.get("type")
        if kind == "heading":
            level = int(block.get("level") or 2)
            out.append(f"{'#' * min(level, 6)} {block['text']}" if md else block["text"].upper())
        elif kind == "paragraph":
            out.append(block["text"])
        elif kind == "quote":
            out.append(f"> {block['text']}" if md else f'"{block["text"]}"')
        elif kind == "code":
            body = block.get("text", "")
            out.append(f"```{block.get('lang') or ''}\n{body}\n```" if md else body)
        elif kind == "list":
            ordered = bool(block.get("ordered"))
            lines = []
            counter = 1
            for item in block.get("items") or []:
                indent = "  " * int(item.get("depth") or 0)
                if ordered and not item.get("depth"):
                    lines.append(f"{indent}{counter}. {item['text']}")
                    counter += 1
                else:
                    lines.append(f"{indent}- {item['text']}")
            out.append("\n".join(lines))
        elif kind == "table":
            rows = block.get("rows") or []
            if not rows:
                continue
            if md:
                width = max(len(r) for r in rows)
                padded = [r + [""] * (width - len(r)) for r in rows]
                lines = ["| " + " | ".join(padded[0]) + " |",
                         "| " + " | ".join(["---"] * width) + " |"]
                lines += ["| " + " | ".join(r) + " |" for r in padded[1:]]
                out.append("\n".join(lines))
            else:
                out.append("\n".join("\t".join(r) for r in rows))
        elif kind == "image":
            src = block.get("src") or ""
            alt = block.get("alt") or ""
            caption = block.get("caption") or ""
            if md:
                line = f"![{alt}]({src})"
                out.append(f"{line}\n{caption}" if caption else line)
            else:
                out.append(f"[image: {alt or caption or src}]")
        elif kind == "rule":
            out.append("---" if md else "—")
    return [s for s in out if s.strip()]


def _header(data: dict, fmt: str) -> str:
    meta = data.get("metadata") or {}
    lines = []
    title = meta.get("title")
    if title:
        lines.append(f"# {title}" if fmt == "markdown" else title)
    trailer = []
    if meta.get("siteName"):
        trailer.append(meta["siteName"])
    if meta.get("byline"):
        trailer.append(meta["byline"])
    if meta.get("publishedTime"):
        trailer.append(meta["publishedTime"])
    trailer.append(data.get("finalUrl") or meta.get("url") or "")
    lines.append(" · ".join(t for t in trailer if t))
    repository = meta.get("repository") or {}
    stats = [f"{label}: {repository[key]}" for key, label in
             (("stars", "Stars"), ("forks", "Forks"), ("watchers", "Watchers"))
             if repository.get(key) is not None]
    if stats:
        lines.append("GitHub repository statistics (observed on page): " + " · ".join(stats))
    return "\n".join(lines)


def render(data: dict, cfg: dict) -> tuple[str, str, bool]:
    """Return (stdout_text, full_text, truncated).

    ``full_text`` is always the complete document; ``stdout_text`` is what the
    caller should print, i.e. the same thing cut to ``maxChars``.
    """
    fmt = cfg.get("format", "markdown")
    blocks = list(data.get("blocks") or [])
    # 标题已经在 header 里印过一次；正文第一个块又是同一句时，去掉重复的那个。
    title = ((data.get("metadata") or {}).get("title") or "").strip()
    if blocks and title and blocks[0].get("type") == "heading":
        first = (blocks[0].get("text") or "").strip().rstrip("¶").strip()
        if first and (first == title or title.startswith(first)):
            blocks = blocks[1:]

    body_parts = _render_blocks(blocks, fmt)
    body = "\n\n".join(body_parts)

    if fmt == "json":
        payload = {
            "url": data.get("finalUrl"),
            "status": data.get("status"),
            "metadata": data.get("metadata"),
            "container": data.get("container"),
            "charCount": data.get("charCount"),
            "blocks": data.get("blocks"),
        }
        full = json.dumps(payload, ensure_ascii=False, indent=2)
        # JSON 不做块级截断：切一半的 JSON 不是 JSON。超预算时只报告，
        # 让调用方自己决定是改格式还是配 --output。
        return full, full, False

    full = f"{_header(data, fmt)}\n\n{body}".strip()

    max_chars = int(cfg.get("maxChars") or 0)
    if max_chars <= 0 or len(full) <= max_chars:
        return full, full, False

    # Cut on a block boundary so the tail is a whole paragraph, not half a word.
    head = f"{_header(data, fmt)}\n\n"
    kept: list[str] = []
    used = len(head)
    for part in body_parts:
        if used + len(part) + 2 > max_chars:
            break
        kept.append(part)
        used += len(part) + 2
    if not kept:
        # Single oversized block (one giant <pre>, say) — fall back to a hard cut.
        kept = [body[: max(0, max_chars - len(head))]]
    shown = (head + "\n\n".join(kept)).strip()
    notice = (
        f"\n\n---\n[truncated] 已显示 {len(shown)} / {len(full)} 字符。"
        "需要全文时加大 --max-chars（0 = 不限），或用 --output 把全文写到文件再按需读取。"
    )
    return shown + notice, full, True


def do_read(cfg: dict) -> dict:
    """Extract, render, optionally persist. Returns a small result summary."""
    data = extract(cfg)

    if data.get("error") == "selector-not-found":
        raise SystemExit(
            f'selector "{data.get("selector")}" 在页面上未找到（{cfg["url"]}）。'
            "请换一个选择器，或去掉 --selector 让正文自动识别。"
        )

    stdout_text, full_text, truncated = render(data, cfg)

    out_path = None
    if cfg.get("output"):
        out_path = Path(cfg["output"]).resolve()
        out_path.parent.mkdir(parents=True, exist_ok=True)
        out_path.write_text(full_text, encoding="utf-8")

    return {
        "text": stdout_text,
        "fullText": full_text,
        "truncated": truncated,
        "outputPath": str(out_path) if out_path else None,
        "charCount": data.get("charCount") or 0,
        "bodyCharCount": data.get("bodyCharCount") or 0,
        "blockCount": len(data.get("blocks") or []),
        "container": data.get("container"),
        "containerReason": data.get("containerReason"),
        "status": data.get("status"),
        "finalUrl": data.get("finalUrl"),
        "metadata": data.get("metadata") or {},
    }
