"""图谱 / Chart.js / Mermaid / 可视化 HTML 结构检查与轻量清洗建议。"""

from __future__ import annotations

import json
import re
from typing import Any

MAX_MERMAID_CHARS = 200_000
MAX_HTML_CHARS = 600_000
MAX_GRAPH_NODES = 20_000
MAX_GRAPH_EDGES = 100_000

_MAX_NETWORK_REF_ISSUES = 24


def _is_network_url(url: str) -> bool:
    """True if URL loads over the network (http(s) or protocol-relative //)."""
    u = (url or "").strip()
    if not u:
        return False
    # srcset: "https://x 1x" → first token
    u = u.split()[0]
    ul = u.lower()
    if ul.startswith("#"):
        return False
    if ul.startswith(
        ("data:", "mailto:", "tel:", "javascript:", "about:", "blob:")
    ):
        return False
    return ul.startswith("http://") or ul.startswith("https://") or ul.startswith("//")


def _network_ref_issues_from_html(html: str) -> list[str]:
    """
    Exported dashboard HTML must not reference remote assets (offline + file:// safe).
    Detects common tag attributes and url(...) in inline / <style> CSS.
    """
    issues: list[str] = []
    seen: set[str] = set()

    def push(where: str, raw: str) -> None:
        if len(issues) >= _MAX_NETWORK_REF_ISSUES:
            return
        key = (where, raw.strip()[:512])
        if key in seen:
            return
        seen.add(key)
        preview = raw.strip()[:100] + ("…" if len(raw.strip()) > 100 else "")
        issues.append(
            f"network_ref: {where} uses off-page URL ({preview}) — "
            "forbidden: do not use http(s):// or // in viz HTML; download into "
            "sibling **res/** or **deliverables/_assets/** and use a relative path "
            "(see **scripts/download_viz_res.py** for bundled Chart/Mermaid/vis)."
        )

    def check_attr(where: str, value: str | None) -> None:
        if not value:
            return
        for chunk in re.split(r",\s*", value):
            tok = chunk.strip().split()[0] if chunk.strip() else ""
            if _is_network_url(tok):
                push(where, tok)

    _TAG_ATTR = (
        (r"""<\s*script[^>]*\ssrc\s*=\s*(["'])(.*?)\1""", "<script src>"),
        (r"""<\s*link[^>]*\shref\s*=\s*(["'])(.*?)\1""", "<link href>"),
        (r"""<\s*img[^>]*\ssrc\s*=\s*(["'])(.*?)\1""", "<img src>"),
        (r"""<\s*iframe[^>]*\ssrc\s*=\s*(["'])(.*?)\1""", "<iframe src>"),
        (r"""<\s*embed[^>]*\ssrc\s*=\s*(["'])(.*?)\1""", "<embed src>"),
        (r"""<\s*object[^>]*\sdata\s*=\s*(["'])(.*?)\1""", "<object data>"),
        (r"""<\s*video[^>]*\sposter\s*=\s*(["'])(.*?)\1""", "<video poster>"),
        (r"""<\s*video[^>]*\ssrc\s*=\s*(["'])(.*?)\1""", "<video src>"),
        (r"""<\s*audio[^>]*\ssrc\s*=\s*(["'])(.*?)\1""", "<audio src>"),
        (r"""<\s*source[^>]*\ssrc\s*=\s*(["'])(.*?)\1""", "<source src>"),
        (r"""<\s*track[^>]*\ssrc\s*=\s*(["'])(.*?)\1""", "<track src>"),
        (r"""<\s*base[^>]*\shref\s*=\s*(["'])(.*?)\1""", "<base href>"),
    )
    for pattern, label in _TAG_ATTR:
        for m in re.finditer(pattern, html, re.I | re.DOTALL):
            check_attr(label, m.group(2))

    for m in re.finditer(
        r"""<\s*img[^>]*\ssrcset\s*=\s*(["'])(.*?)\1""",
        html,
        re.I | re.DOTALL,
    ):
        check_attr("<img srcset>", m.group(2))

    for m in re.finditer(
        r"""<\s*source[^>]*\ssrcset\s*=\s*(["'])(.*?)\1""",
        html,
        re.I | re.DOTALL,
    ):
        check_attr("<source srcset>", m.group(2))

    _URL_IN_CSS = re.compile(
        r"""url\(\s*["']?(https?://[^\"')]+|//[^\"')]+)""",
        re.I,
    )
    for m in re.finditer(r"<style[^>]*>(.*?)</style>", html, re.I | re.DOTALL):
        block = m.group(1) or ""
        for um in _URL_IN_CSS.finditer(block):
            if _is_network_url(um.group(1)):
                push("CSS url() in <style>", um.group(1))

    for m in re.finditer(
        r"""style\s*=\s*(["'])(.*?)\1""",
        html,
        re.I | re.DOTALL,
    ):
        for um in _URL_IN_CSS.finditer(m.group(2) or ""):
            if _is_network_url(um.group(1)):
                push("CSS url() in style attribute", um.group(1))

    return issues


def _as_str_id(x: Any) -> str:
    if x is None:
        return ""
    if isinstance(x, bool):
        return str(x).lower()
    return str(x)


def validate_graph_json(
    nodes: list[Any], edges: list[Any], *, normalize: bool = True
) -> dict[str, Any]:
    issues: list[str] = []
    if not isinstance(nodes, list):
        return {"ok": False, "error": "nodes must be a JSON array"}
    if not isinstance(edges, list):
        return {"ok": False, "error": "edges must be a JSON array"}
    if len(nodes) > MAX_GRAPH_NODES:
        issues.append(f"node count {len(nodes)} exceeds cap {MAX_GRAPH_NODES}")
    if len(edges) > MAX_GRAPH_EDGES:
        issues.append(f"edge count {len(edges)} exceeds cap {MAX_GRAPH_EDGES}")

    seen_ids: set[str] = set()
    norm_nodes: list[dict[str, Any]] = []
    for i, n in enumerate(nodes):
        if not isinstance(n, dict):
            issues.append(f"node[{i}] is not an object")
            continue
        nid = n.get("id")
        if nid is None:
            issues.append(f"node[{i}] missing id")
            continue
        sid = _as_str_id(nid)
        if sid in seen_ids:
            issues.append(f"duplicate node id: {sid!r}")
        seen_ids.add(sid)
        lbl = n.get("label", n.get("title", sid))
        entry = {"id": sid, "label": str(lbl) if lbl is not None else sid}
        for k in ("title", "group", "shape", "color"):
            if k in n and n[k] is not None:
                entry[k] = n[k]
        norm_nodes.append(entry)

    norm_edges: list[dict[str, Any]] = []
    for i, e in enumerate(edges):
        if not isinstance(e, dict):
            issues.append(f"edge[{i}] is not an object")
            continue
        a = e.get("from", e.get("source"))
        b = e.get("to", e.get("target"))
        if a is None or b is None:
            issues.append(
                f"edge[{i}] needs from/to or source/target (got keys {list(e.keys())})"
            )
            continue
        sa, sb = _as_str_id(a), _as_str_id(b)
        if sa not in seen_ids:
            issues.append(f"edge[{i}] from/source {sa!r} not in nodes")
        if sb not in seen_ids:
            issues.append(f"edge[{i}] to/target {sb!r} not in nodes")
        ne = {"from": sa, "to": sb}
        if e.get("label") is not None:
            ne["label"] = e["label"]
        if "arrows" in e:
            ne["arrows"] = e["arrows"]
        norm_edges.append(ne)

    ok = not any(
        "missing id" in x
        or "needs from/to" in x
        or "not in nodes" in x
        or "is not an object" in x
        or "duplicate node id" in x
        for x in issues
    )
    out: dict[str, Any] = {
        "ok": ok,
        "issues": issues[:200],
        "issues_truncated": len(issues) > 200,
        "node_count": len(nodes),
        "edge_count": len(edges),
        "unique_node_ids": len(seen_ids),
    }
    if normalize and ok:
        out["normalized"] = {"nodes": norm_nodes, "edges": norm_edges}
    return out


def validate_chartjs_config(cfg: Any) -> dict[str, Any]:
    issues: list[str] = []
    if not isinstance(cfg, dict):
        return {"ok": False, "error": "config must be a JSON object"}
    ct = str(cfg.get("type") or "").strip().lower()
    if ct not in ("bar", "line", "pie", "doughnut", "radar", "scatter", "bubble"):
        if ct:
            issues.append(
                f"type {ct!r} may be unsupported in Aicd helpers (known: bar|line|…)"
            )
        else:
            issues.append("missing type")
    data = cfg.get("data")
    if not isinstance(data, dict):
        issues.append("data must be an object with labels and datasets")
        return {
            "ok": False,
            "issues": issues,
            "hint": "Use chart_spec.series_to_chartjs_config shape",
        }
    labels = data.get("labels")
    dss = data.get("datasets")
    if not isinstance(labels, list):
        issues.append("data.labels must be an array")
    if not isinstance(dss, list) or not dss:
        issues.append("data.datasets must be a non-empty array")
    else:
        ds0 = dss[0]
        if isinstance(ds0, dict) and isinstance(labels, list):
            vals = ds0.get("data")
            if not isinstance(vals, list):
                issues.append("datasets[0].data must be an array of numbers")
            elif len(labels) != len(vals):
                issues.append(
                    f"labels length {len(labels)} != datasets[0].data length {len(vals)}"
                )
        for j, ds in enumerate(dss):
            if not isinstance(ds, dict):
                issues.append(f"datasets[{j}] must be object")
    hard = [
        x
        for x in issues
        if "may be unsupported" not in x and "unsupported in Aicd" not in x
    ]
    ok = len(hard) == 0
    return {"ok": ok, "issues": issues, "type": ct or None}


def validate_mermaid_body(body: str) -> dict[str, Any]:
    if not isinstance(body, str):
        return {"ok": False, "error": "mermaid_body must be string"}
    s = body.strip()
    if len(s) > MAX_MERMAID_CHARS:
        return {"ok": False, "error": f"body exceeds {MAX_MERMAID_CHARS} chars"}
    issues: list[str] = []
    if not s:
        issues.append("empty diagram")
    if "```" in s:
        issues.append("do not embed triple-backticks inside mermaid_body (tool adds fence)")
    low = s.lower()
    starters = (
        "flowchart",
        "graph",
        "sequencediagram",
        "classdiagram",
        "statediagram",
        "erdiagram",
        "journey",
        "gantt",
        "pie",
        "mindmap",
        "timeline",
        "block-beta",
    )
    if s and not any(low.startswith(x) or f"\n{x}" in low for x in starters):
        issues.append(
            "no common diagram keyword at start (e.g. flowchart LR, sequenceDiagram)"
        )
    ok = bool(s) and "```" not in s
    return {
        "ok": ok,
        "issues": issues,
        "char_count": len(s),
        "preview_head": s[:240].replace("\n", " ") + ("…" if len(s) > 240 else ""),
    }


def validate_html_viz_embed(html: str) -> dict[str, Any]:
    if not isinstance(html, str):
        return {"ok": False, "error": "html must be string"}
    if len(html) > MAX_HTML_CHARS:
        return {"ok": False, "error": f"html exceeds {MAX_HTML_CHARS} chars"}
    lower = html.lower()
    issues: list[str] = []

    def first_idx(sub: str) -> int:
        return lower.find(sub)

    chart_lib = first_idx("chart.umd") >= 0 or first_idx("chart.min.js") >= 0
    mermaid_lib = first_idx("mermaid.min.js") >= 0
    vis_lib = first_idx("vis-network.min.js") >= 0

    inline_scripts = list(re.finditer(r"<script(?![^>]*\ssrc=)[^>]*>", lower))
    first_inline = inline_scripts[0].start() if inline_scripts else -1

    def use_before_lib(needle: str, lib_marker: str) -> bool:
        u = lower.find(needle)
        if u < 0:
            return False
        L = lower.find(lib_marker)
        if L < 0:
            return False
        return u < L and (first_inline < 0 or u < first_inline or u < L)

    if first_inline >= 0:
        if "new chart(" in lower and chart_lib:
            nc = lower.find("new chart(")
            cl = lower.find("chart.umd")
            if cl < 0:
                cl = lower.find("chart.min.js")
            if nc >= 0 and cl >= 0 and nc < cl:
                issues.append(
                    "possible new Chart() before Chart.js script loads (visitors get Chart is not defined)"
                )
        if "vis.network" in lower and vis_lib:
            vn = lower.find("vis.network")
            vl = lower.find("vis-network.min.js")
            if vn >= 0 and vl >= 0 and vn < vl:
                issues.append(
                    "possible vis.Network before vis-network.min.js loads"
                )
        if "mermaid." in lower and mermaid_lib:
            mi = lower.find("mermaid.init")
            if mi < 0:
                mi = lower.find("mermaid.run")
            ml = lower.find("mermaid.min.js")
            if mi >= 0 and ml >= 0 and mi < ml:
                issues.append("Mermaid API used before mermaid.min.js loads")

    if not chart_lib and "new chart(" in lower:
        issues.append("uses new Chart but no local chart script reference found")
    if not vis_lib and "vis.network" in lower:
        issues.append("uses vis.Network but no vis-network.min.js reference found")
    if not mermaid_lib and re.search(r"class=[\"']mermaid[\"']", lower):
        issues.append("mermaid class on element but no mermaid.min.js reference found")

    issues.extend(_network_ref_issues_from_html(html))

    _hard = [
        i
        for i in issues
        if "possible" in i
        or "uses " in i
        or i.startswith("network_ref:")
    ]
    return {
        "ok": len(_hard) == 0,
        "issues": issues,
        "has_chart_js_ref": chart_lib,
        "has_mermaid_ref": mermaid_lib,
        "has_vis_ref": vis_lib,
        "inline_script_count": len(inline_scripts),
    }


def run_operation(operation: str, payload: dict[str, Any]) -> dict[str, Any]:
    op = operation.strip().lower().replace("-", "_")
    if op in ("graph_json", "diagram_graph_json"):
        return validate_graph_json(
            list(payload.get("nodes") or []),
            list(payload.get("edges") or []),
            normalize=bool(payload.get("normalize", True)),
        )
    if op in ("chartjs", "chartjs_config", "chart"):
        cfg = payload.get("config")
        if cfg is None and payload.get("config_json"):
            try:
                cfg = json.loads(str(payload["config_json"]))
            except json.JSONDecodeError as e:
                return {"ok": False, "error": f"config_json: {e}"}
        return validate_chartjs_config(cfg)
    if op in ("mermaid", "mermaid_body"):
        return validate_mermaid_body(str(payload.get("mermaid_body") or payload.get("text") or ""))
    if op in ("html_viz", "html_embed", "html"):
        return validate_html_viz_embed(str(payload.get("html") or payload.get("text") or ""))
    return {
        "ok": False,
        "error": f"unknown operation: {operation!r}",
        "hint": "graph_json|chartjs_config|mermaid_body|html_viz",
    }
