from __future__ import annotations

import asyncio
import uuid
from pathlib import Path
from typing import TYPE_CHECKING, Any

from aicd.agent.tool_args_coerce import (
    coerce_tool_json_array,
    coerce_tool_json_block_object,
    coerce_tool_json_object,
)
from aicd.tasks.safe_ids import validate_topic_slug
from aicd.tools.code_gen_tree import build_scan_payload, write_gen_tree_json
from aicd.tools.code_gen_tree_query import query_tree, query_tree_regex
from aicd.tools.code_gen_tree_store import conventional_slug_dir, read_manifest, write_versioned_tree
from aicd.tools.code_intel import graph_json_format, mermaid_block, python_outline
from aicd.tools.document_outline import build_outline_payload, write_outline_json
from aicd.tools import docx_compose as docx_compose_mod
from aicd.tools import docx_to_pdf as docx_to_pdf_mod
from aicd.tools import markdown_to_pdf as md_pdf_mod
from aicd.tools import pdf_compose_rl as pdf_rl_mod
from aicd.tools import mermaid_png as mermaid_png_mod
from aicd.tools import office_write as office_write_mod
from aicd.tools import style_reference as style_reference_mod
from aicd.tools.browser_automation import run_browser_automation

if TYPE_CHECKING:
    from aicd.agent.tool_router import ToolRouter


def _doc_convert_markdown(router: ToolRouter, args: dict[str, Any]) -> dict[str, Any]:
    return router._docs.convert_to_markdown(
        args["source_path"], args["output_dir"]
    )


def _doc_extract(router: ToolRouter, args: dict[str, Any]) -> dict[str, Any]:
    sn = args.get("sheet_names")
    if sn is not None:
        co = coerce_tool_json_array(sn, "sheet_names", allow_empty=True)
        if isinstance(co, dict):
            return co
        sn = co
    names = [str(x) for x in sn] if sn else None
    return router._docs.convert_document(
        args["source_path"],
        args["output_dir"],
        output_format=str(args.get("output_format", "markdown")),
        page_start=args.get("page_start"),
        page_end=args.get("page_end"),
        use_ocr=bool(args.get("use_ocr", False)),
        sheet_names=names,
    )


async def _browser_cdp_navigate(
    router: ToolRouter, args: dict[str, Any]
) -> dict[str, Any]:
    return await router._browser.navigate(
        args["url"], wait_until=str(args.get("wait_until", "load"))
    )


async def _browser_cdp_automation(
    router: ToolRouter, args: dict[str, Any]
) -> dict[str, Any]:
    raw = args.get("steps")
    coerced = coerce_tool_json_array(raw, "steps", allow_empty=False)
    if isinstance(coerced, dict):
        return coerced
    steps: list[dict[str, Any]] = []
    for i, item in enumerate(coerced):
        if not isinstance(item, dict):
            return {"ok": False, "error": f"steps[{i}] must be object"}
        steps.append(dict(item))
    sm = args.get("slow_mo_ms")
    sm_i: int | None = None
    if sm is not None and str(sm).strip() != "":
        try:
            sm_i = int(sm)
        except (TypeError, ValueError):
            return {"ok": False, "error": "slow_mo_ms must be integer"}
    vp = args.get("viewport")
    vp_d: dict[str, Any] | None = vp if isinstance(vp, dict) else None
    try:
        to = int(args.get("timeout_ms", 60_000))
    except (TypeError, ValueError):
        return {"ok": False, "error": "timeout_ms must be integer"}
    return await run_browser_automation(
        steps=steps,
        policy_check=router._policy.check_allowed,
        cwd=router.cwd,
        default_timeout_ms=to,
        headless=bool(args.get("headless", True)),
        viewport=vp_d,
        slow_mo_ms=sm_i,
    )


def _diagram_mermaid(router: ToolRouter, args: dict[str, Any]) -> dict[str, Any]:
    return mermaid_block(args.get("title"), args["mermaid_body"])


def _diagram_graph_json(router: ToolRouter, args: dict[str, Any]) -> dict[str, Any]:
    n = coerce_tool_json_array(args.get("nodes"), "nodes", allow_empty=True)
    if isinstance(n, dict):
        return n
    e = coerce_tool_json_array(args.get("edges"), "edges", allow_empty=True)
    if isinstance(e, dict):
        return e
    return graph_json_format(n, e)


def _code_outline_python(router: ToolRouter, args: dict[str, Any]) -> dict[str, Any]:
    return python_outline(args["path"])


def _code_gen_tree_scan(router: ToolRouter, args: dict[str, Any]) -> dict[str, Any]:
    root = router._resolve_path(str(args["path"]))
    try:
        mf = int(args.get("max_files", 120))
    except (TypeError, ValueError):
        return {"ok": False, "error": "max_files must be integer"}
    mf = max(1, min(2000, mf))
    recursive = bool(args.get("recursive", True))
    sp = str(args.get("scan_profile", "polyglot")).strip().lower()
    if sp == "auto":
        sp = "polyglot"
    if sp not in ("polyglot", "python_only"):
        sp = "polyglot"
    return build_scan_payload(
        root, recursive=recursive, max_files=mf, scan_profile=sp
    )


def _code_gen_tree_sync(router: ToolRouter, args: dict[str, Any]) -> dict[str, Any]:
    root = router._resolve_path(str(args["path"]))
    try:
        mf = int(args.get("max_files", 120))
    except (TypeError, ValueError):
        return {"ok": False, "error": "max_files must be integer"}
    mf = max(1, min(2000, mf))
    recursive = bool(args.get("recursive", True))
    sp = str(args.get("scan_profile", "polyglot")).strip().lower()
    if sp == "auto":
        sp = "polyglot"
    if sp not in ("polyglot", "python_only"):
        sp = "polyglot"
    payload = build_scan_payload(
        root, recursive=recursive, max_files=mf, scan_profile=sp
    )
    if not payload.get("ok"):
        return payload
    use_v = bool(args.get("use_versioned_store", False))
    if use_v:
        raw_slug = args.get("topic_slug")
        s = str(raw_slug).strip().lower() if raw_slug is not None else ""
        try:
            slug = validate_topic_slug(s if s else "default")
        except ValueError as e:
            return {"ok": False, "error": str(e)}
        if not slug:
            return {"ok": False, "error": "topic_slug resolved empty"}
        st = write_versioned_tree(
            router._ai_output,
            slug,
            payload,
            scan_root=str(root),
        )
        return {
            **payload,
            **st,
            "use_latest_revision_for_continuation": True,
            "conventional_layout_note": (
                f"aicd/output/code_gen_tree/{slug}/manifest.json"
            ),
        }
    out_raw = args.get("output_json_path")
    if out_raw is None or str(out_raw).strip() == "":
        return {
            "ok": False,
            "error": "output_json_path is required when use_versioned_store is false",
        }
    out_p = router._resolve_path(str(out_raw).strip())
    wr = write_gen_tree_json(payload, out_p)
    return {**payload, **wr}


def _code_gen_tree_manifest(router: ToolRouter, args: dict[str, Any]) -> dict[str, Any]:
    raw = args.get("topic_slug")
    s = str(raw).strip().lower() if raw is not None else ""
    try:
        slug = validate_topic_slug(s if s else "default")
    except ValueError as e:
        return {"ok": False, "error": str(e)}
    if not slug:
        return {"ok": False, "error": "topic_slug resolved empty"}
    man = read_manifest(router._ai_output, slug)
    if not man:
        return {
            "ok": False,
            "error": "no manifest for this topic_slug",
            "topic_slug": slug,
            "hint": "Run code_gen_tree_sync with use_versioned_store=true",
        }
    base = conventional_slug_dir(router._ai_output, slug)
    rel = str(man.get("latest_tree_path") or "")
    abs_tree = (base / rel).resolve()
    router._policy.check_allowed(abs_tree)
    return {"ok": True, **man, "absolute_tree_path": str(abs_tree)}


def _code_gen_tree_query(router: ToolRouter, args: dict[str, Any]) -> dict[str, Any]:
    action = str(args.get("action", "")).strip().lower()
    if action not in ("stats", "filter", "children", "subtree", "regex"):
        return {
            "ok": False,
            "error": "action must be stats|filter|children|subtree|regex",
        }
    tp_raw = args.get("tree_json_path")
    path_resolved: Path
    if tp_raw is not None and str(tp_raw).strip() != "":
        path_resolved = router._resolve_path(str(tp_raw).strip())
    else:
        raw_ts = args.get("topic_slug")
        s = str(raw_ts).strip().lower() if raw_ts is not None else ""
        try:
            slug = validate_topic_slug(s if s else "default")
        except ValueError as e:
            return {"ok": False, "error": str(e)}
        if not slug:
            return {"ok": False, "error": "topic_slug resolved empty"}
        man = read_manifest(router._ai_output, slug)
        if not man:
            return {"ok": False, "error": "no manifest", "topic_slug": slug}
        base = conventional_slug_dir(router._ai_output, slug)
        rel = str(man.get("latest_tree_path") or "")
        path_resolved = (base / rel).resolve()
    router._policy.check_allowed(path_resolved)

    if action == "regex":
        qrx = args.get("qname_regex")
        if qrx is None or not str(qrx).strip():
            return {"ok": False, "error": "qname_regex required for regex action"}
        try:
            lim = int(args.get("limit", 100))
        except (TypeError, ValueError):
            return {"ok": False, "error": "limit must be integer"}
        return query_tree_regex(
            path_resolved, qname_regex=str(qrx).strip(), limit=lim
        )

    kinds = args.get("kinds")
    kl: list[str] | None = None
    if kinds is not None:
        co = coerce_tool_json_array(kinds, "kinds", allow_empty=True)
        if isinstance(co, dict):
            return co
        kl = [str(x) for x in co]
    slang = args.get("source_langs")
    sl: list[str] | None = None
    if slang is not None:
        co2 = coerce_tool_json_array(slang, "source_langs", allow_empty=True)
        if isinstance(co2, dict):
            return co2
        sl = [str(x) for x in co2]
    try:
        lim = int(args.get("limit", 200))
        off = int(args.get("offset", 0))
        sd = int(args.get("subtree_depth", 12))
    except (TypeError, ValueError):
        return {"ok": False, "error": "limit/offset/subtree_depth must be integers"}
    pid = args.get("parent_id")
    ps = str(pid).strip() if pid is not None and str(pid).strip() else None
    qn = args.get("qname_contains")
    fc = args.get("file_contains")
    return query_tree(
        path_resolved,
        action=action,
        parent_id=ps,
        subtree_depth=sd,
        qname_contains=str(qn).strip() if qn is not None and str(qn).strip() else None,
        file_contains=str(fc).strip() if fc is not None and str(fc).strip() else None,
        kinds=kl,
        source_langs=sl,
        limit=lim,
        offset=off,
        include_docstrings=bool(args.get("include_docstrings", False)),
    )


async def _diagram_mermaid_png(
    router: ToolRouter, args: dict[str, Any]
) -> dict[str, Any]:
    """Playwright 同步 API 不可在 asyncio 事件循环中直接调用，放到线程里执行。"""
    out = router._resolve_path(str(args["output_png_path"]))
    theme_vars = args.get("theme_variables")
    if theme_vars is not None:
        tv_obj, tv_err = coerce_tool_json_object(theme_vars, "theme_variables")
        if tv_err:
            return {"ok": False, "error": tv_err}
        theme_vars = tv_obj or None
    body = str(args["mermaid_body"])
    theme = str(args.get("theme", "neutral"))
    scale = float(args.get("scale", 3))
    policy = router._policy.check_allowed

    def _sync() -> dict[str, Any]:
        return mermaid_png_mod.render_mermaid_to_png(
            body,
            out,
            policy_check=policy,
            theme=theme,
            theme_variables=theme_vars,
            scale=scale,
        )

    return await asyncio.to_thread(_sync)


def _docx_compose(router: ToolRouter, args: dict[str, Any]) -> dict[str, Any]:
    path = router._resolve_path(str(args["path"]))
    raw = args.get("blocks")
    coerced = coerce_tool_json_array(raw, "blocks", allow_empty=True)
    if isinstance(coerced, dict):
        return coerced
    raw = coerced
    blocks_out: list[dict[str, Any]] = []
    for bi, b in enumerate(raw):
        blk, berr = coerce_tool_json_block_object(b, "blocks", bi)
        if berr:
            return {"ok": False, "error": berr}
        bt = str(blk.get("type") or "").lower()
        if bt == "image" and blk.get("path"):
            blocks_out.append(
                {**blk, "path": str(router._resolve_path(str(blk["path"])))}
            )
        else:
            blocks_out.append(dict(blk))
    de = args.get("default_east_asia_font")
    dl = args.get("default_latin_font")
    dc = args.get("default_color_hex")
    db = args.get("default_body_font_pt")
    east = str(de).strip() if isinstance(de, str) and de.strip() else None
    lat = str(dl).strip() if isinstance(dl, str) and dl.strip() else None
    col = str(dc).strip() if isinstance(dc, str) and dc.strip() else None
    body_pt: float | None = None
    if db is not None and str(db).strip() != "":
        try:
            body_pt = float(db)
        except (TypeError, ValueError):
            return {"ok": False, "error": "default_body_font_pt must be a number"}

    profile_notes = ""
    spp = args.get("style_profile_path")
    if spp is not None and str(spp).strip():
        prof_path = router._resolve_path(str(spp).strip())
        prof = style_reference_mod.load_docx_style_profile(
            prof_path, policy_check=router._policy.check_allowed
        )
        if not prof.get("ok"):
            return prof
        profile_notes = str(prof.get("notes") or "")
        dd = prof.get("docx_defaults") or {}
        if east is None:
            v = dd.get("default_east_asia_font")
            if isinstance(v, str) and v.strip():
                east = v.strip()
        if lat is None:
            v = dd.get("default_latin_font")
            if isinstance(v, str) and v.strip():
                lat = v.strip()
        if col is None:
            v = dd.get("default_color_hex")
            if isinstance(v, str) and v.strip():
                col = v.strip()
        if body_pt is None and dd.get("default_body_font_pt") is not None:
            try:
                body_pt = float(dd["default_body_font_pt"])
            except (TypeError, ValueError):
                pass

    out = docx_compose_mod.docx_compose(
        path,
        blocks_out,
        policy_check=router._policy.check_allowed,
        append=bool(args.get("append", False)),
        default_east_asia_font=east,
        default_latin_font=lat,
        default_color_hex=col,
        default_body_font_pt=body_pt,
    )
    if profile_notes and out.get("ok"):
        out = {
            **out,
            "style_profile_notes": profile_notes[:8000],
        }
    return out


def _style_reference_scan(router: ToolRouter, args: dict[str, Any]) -> dict[str, Any]:
    root = router._resolve_path(str(args["path"]))
    return style_reference_mod.scan_style_reference(
        root,
        policy_check=router._policy.check_allowed,
        max_files=int(args.get("max_files", 48)),
    )


def _xlsx_write_workbook(router: ToolRouter, args: dict[str, Any]) -> dict[str, Any]:
    path = router._resolve_path(str(args["path"]))
    raw = args.get("sheets")
    coerced = coerce_tool_json_array(raw, "sheets", allow_empty=False)
    if isinstance(coerced, dict):
        return coerced
    sheets: list[dict[str, Any]] = []
    for i, item in enumerate(coerced):
        if not isinstance(item, dict):
            return {"ok": False, "error": f"sheets[{i}] must be object"}
        sheets.append(dict(item))
    return office_write_mod.xlsx_write_workbook(
        path,
        sheets,
        policy_check=router._policy.check_allowed,
        append=bool(args.get("append", False)),
    )


def _pptx_inspect_template(router: ToolRouter, args: dict[str, Any]) -> dict[str, Any]:
    p = router._resolve_path(str(args["path"]))
    return office_write_mod.pptx_inspect_template(
        p, policy_check=router._policy.check_allowed
    )


def _pptx_compose(router: ToolRouter, args: dict[str, Any]) -> dict[str, Any]:
    out_path = router._resolve_path(str(args["path"]))
    raw = args.get("slides")
    coerced = coerce_tool_json_array(raw, "slides", allow_empty=False)
    if isinstance(coerced, dict):
        return coerced
    slides: list[dict[str, Any]] = []
    for i, item in enumerate(coerced):
        if not isinstance(item, dict):
            return {"ok": False, "error": f"slides[{i}] must be object"}
        slide = dict(item)
        ip = slide.get("image_path")
        if ip is not None and str(ip).strip():
            slide["image_path"] = str(router._resolve_path(str(ip).strip()))
        raw_imgs = slide.get("images")
        if isinstance(raw_imgs, list):
            fixed_imgs: list[dict[str, Any]] = []
            for j, im in enumerate(raw_imgs):
                if not isinstance(im, dict):
                    return {"ok": False, "error": f"slides[{i}].images[{j}] must be object"}
                im2 = dict(im)
                ip2 = im2.get("image_path")
                if ip2 is not None and str(ip2).strip():
                    im2["image_path"] = str(router._resolve_path(str(ip2).strip()))
                fixed_imgs.append(im2)
            slide["images"] = fixed_imgs
        slides.append(slide)
    tpl = args.get("template_path")
    tpl_path = None
    if tpl is not None and str(tpl).strip():
        tpl_path = router._resolve_path(str(tpl).strip())
    return office_write_mod.pptx_compose(
        out_path,
        slides,
        policy_check=router._policy.check_allowed,
        template_path=tpl_path,
    )


def _docx_export_pdf(router: ToolRouter, args: dict[str, Any]) -> dict[str, Any]:
    src = router._resolve_path(str(args["docx_path"]))
    op = args.get("output_pdf_path")
    out: Path | None = None
    if op is not None and str(op).strip():
        out = router._resolve_path(str(op).strip())
        if out.suffix.lower() != ".pdf":
            return {"ok": False, "error": "output_pdf_path must end with .pdf"}
    pref = args.get("prefer")
    pref_s = str(pref).strip().lower() if pref is not None and str(pref).strip() else None
    if pref_s is not None and pref_s not in ("libreoffice", "docx2pdf"):
        return {
            "ok": False,
            "error": "prefer must be libreoffice, docx2pdf, or omitted",
        }
    return docx_to_pdf_mod.export_docx_to_pdf(
        src,
        out,
        policy_check=router._policy.check_allowed,
        prefer=pref_s,
    )


def _markdown_to_pdf(router: ToolRouter, args: dict[str, Any]) -> dict[str, Any]:
    out = router._resolve_path(str(args["output_pdf_path"]))
    mp = args.get("markdown_path")
    mpath: Path | None = None
    if mp is not None and str(mp).strip():
        mpath = router._resolve_path(str(mp).strip())
    mt = args.get("markdown_text")
    mt_s = str(mt) if mt is not None else None
    ap = args.get("archive_path")
    archive: Path | None = None
    if ap is not None and str(ap).strip():
        archive = router._resolve_path(str(ap).strip())
    eng = str(args.get("engine") or "pymupdf").strip().lower()
    paper = str(args.get("paper") or "a4")
    margin_pt = float(args.get("margin_pt", 36))
    uc_raw = args.get("user_css")
    uc = str(uc_raw) if uc_raw is not None and str(uc_raw).strip() else None
    pextra = args.get("pandoc_extra_args")
    pandoc_args: list[str] | None = None
    if isinstance(pextra, list):
        pandoc_args = [str(x) for x in pextra]
    snap_arg = args.get("source_markdown_snapshot_path")
    snap_path: Path | None = None
    if snap_arg is not None and str(snap_arg).strip():
        snap_path = router._resolve_path(str(snap_arg).strip())
    if eng == "pandoc" and mpath is None and mt_s is not None:
        sub = (router._ai_tmp / "markdown_to_pdf").resolve()
        sub.mkdir(parents=True, exist_ok=True)
        tmp_md = sub / f"pandoc_{uuid.uuid4().hex[:12]}.md"
        tmp_md.write_text(mt_s, encoding="utf-8")
        mpath = tmp_md
        mt_s = None
    return md_pdf_mod.export_markdown_to_pdf(
        output_pdf_path=out,
        policy_check=router._policy.check_allowed,
        markdown_path=mpath,
        markdown_text=mt_s,
        engine=eng,
        archive_path=archive,
        user_css=uc,
        paper=paper,
        margin_pt=margin_pt,
        pandoc_extra_args=pandoc_args,
        source_snapshot_path=snap_path,
    )


def _pdf_compose(router: ToolRouter, args: dict[str, Any]) -> dict[str, Any]:
    out = router._resolve_path(str(args["path"]))
    blocks_co = coerce_tool_json_array(args.get("blocks"), "blocks", allow_empty=False)
    if isinstance(blocks_co, dict):
        return blocks_co
    cf = args.get("cjk_font_path")
    cjk: Path | None = None
    if cf is not None and str(cf).strip():
        cjk = router._resolve_path(str(cf).strip())
    return pdf_rl_mod.pdf_compose(
        out,
        blocks_co,
        policy_check=router._policy.check_allowed,
        page_size=str(args.get("page_size") or "a4"),
        margin_cm=float(args.get("margin_cm", 2.0)),
        cjk_font_path=str(cjk) if cjk is not None else None,
        title=args.get("title"),
    )


def _document_outline(router: ToolRouter, args: dict[str, Any]) -> dict[str, Any]:
    p = router._resolve_path(str(args["path"]))
    if not p.is_file():
        return {"ok": False, "error": f"not a file: {p}"}
    suf = p.suffix.lower()
    if suf not in {".md", ".markdown", ".html", ".htm", ".txt"}:
        return {
            "ok": False,
            "error": f"unsupported extension {suf!r}; use .md/.html/.txt",
        }
    max_lv = int(args.get("max_heading_level", 6))
    max_lv = max(1, min(6, max_lv))
    payload = build_outline_payload(p, max_heading_level=max_lv)
    stem = p.stem[:80] or "doc"
    lib_raw = args.get("library_doc_slug")
    lib_slug: str | None = None
    if lib_raw is not None and str(lib_raw).strip():
        try:
            lib_slug = validate_topic_slug(str(lib_raw))
        except ValueError as e:
            return {"ok": False, "error": str(e)}
        if not lib_slug:
            return {"ok": False, "error": "library_doc_slug is empty after normalization"}
    out: dict[str, Any] = {"ok": True, **payload}
    if bool(args.get("save_to_tmp", True)):
        sub = router._ai_tmp / "outlines"
        dest = sub / f"{stem}.json"
        write_outline_json(payload, dest)
        out["saved_path"] = str(dest)
    if lib_slug:
        lib_sub = (router._ai_output / "output" / "library" / lib_slug / "outlines").resolve()
        dest_lib = lib_sub / f"{stem}.json"
        write_outline_json(payload, dest_lib)
        out["library_saved_path"] = str(dest_lib)
    return out


HANDLERS: dict[str, Any] = {
    "doc_convert_markdown": _doc_convert_markdown,
    "doc_extract": _doc_extract,
    "browser_cdp_navigate": _browser_cdp_navigate,
    "browser_cdp_automation": _browser_cdp_automation,
    "diagram_mermaid": _diagram_mermaid,
    "diagram_mermaid_png": _diagram_mermaid_png,
    "diagram_graph_json": _diagram_graph_json,
    "docx_compose": _docx_compose,
    "docx_export_pdf": _docx_export_pdf,
    "markdown_to_pdf": _markdown_to_pdf,
    "pdf_compose": _pdf_compose,
    "style_reference_scan": _style_reference_scan,
    "xlsx_write_workbook": _xlsx_write_workbook,
    "pptx_inspect_template": _pptx_inspect_template,
    "pptx_compose": _pptx_compose,
    "code_outline_python": _code_outline_python,
    "code_gen_tree_scan": _code_gen_tree_scan,
    "code_gen_tree_sync": _code_gen_tree_sync,
    "code_gen_tree_manifest": _code_gen_tree_manifest,
    "code_gen_tree_query": _code_gen_tree_query,
    "document_outline": _document_outline,
}
