"""JSON / HTML / XML 处理与轻量结构转换、文本搜索。"""

from __future__ import annotations

import html
import json
import re
import xml.etree.ElementTree as ET
from html.parser import HTMLParser
from typing import Any

MAX_TEXT = 1_000_000
MAX_JSON_DEPTH = 80
MAX_XML_NODES = 5000


class _StripHTMLParser(HTMLParser):
    def __init__(self) -> None:
        super().__init__()
        self._chunks: list[str] = []

    def handle_data(self, data: str) -> None:
        self._chunks.append(data)

    def get_text(self) -> str:
        return " ".join(" ".join(self._chunks).split())


def op_json_parse(payload: dict[str, Any]) -> dict[str, Any]:
    s = str(payload.get("text") or "")
    if len(s) > MAX_TEXT:
        return {"ok": False, "error": f"text exceeds {MAX_TEXT} chars"}
    try:
        obj = json.loads(s)
    except json.JSONDecodeError as e:
        return {"ok": False, "error": str(e)}
    return {"ok": True, "value": obj}


def op_json_stringify(payload: dict[str, Any]) -> dict[str, Any]:
    obj = payload.get("value")
    if obj is None:
        jt = payload.get("json_text")
        if jt is not None:
            try:
                obj = json.loads(str(jt))
            except json.JSONDecodeError as e:
                return {"ok": False, "error": f"json_text: {e}"}
        else:
            return {
                "ok": False,
                "error": "pass value (object) or json_text (JSON string to re-format)",
            }
    try:
        indent = payload.get("indent")
        ind = int(indent) if indent is not None else None
    except (TypeError, ValueError):
        ind = None
    if ind is not None:
        ind = max(0, min(8, ind))
    try:
        s = json.dumps(obj, ensure_ascii=False, indent=ind, default=str)
    except (TypeError, ValueError) as e:
        return {"ok": False, "error": str(e)}
    if len(s) > MAX_TEXT:
        return {"ok": False, "error": "JSON output too large for tool cap"}
    return {"ok": True, "text": s}


def op_json_pretty(payload: dict[str, Any]) -> dict[str, Any]:
    s = str(payload.get("text") or "")
    if len(s) > MAX_TEXT:
        return {"ok": False, "error": f"text exceeds {MAX_TEXT} chars"}
    try:
        obj = json.loads(s)
    except json.JSONDecodeError as e:
        return {"ok": False, "error": str(e)}
    try:
        indent = int(payload.get("indent", 2))
    except (TypeError, ValueError):
        indent = 2
    indent = max(0, min(8, indent))
    out = json.dumps(obj, ensure_ascii=False, indent=indent, default=str)
    if len(out) > MAX_TEXT:
        return {"ok": False, "error": "pretty output too large"}
    return {"ok": True, "text": out}


def _get_by_path(obj: Any, path: str) -> tuple[Any | None, str | None]:
    parts = [p for p in path.split(".") if p]
    cur: Any = obj
    for p in parts:
        if isinstance(cur, dict):
            if p not in cur:
                return None, f"missing key {p!r}"
            cur = cur[p]
        elif isinstance(cur, list):
            try:
                idx = int(p)
            except ValueError:
                return None, f"expected int index in list, got {p!r}"
            if idx < 0 or idx >= len(cur):
                return None, f"index {idx} out of range (len={len(cur)})"
            cur = cur[idx]
        else:
            return None, f"cannot descend into {type(cur).__name__} at {p!r}"
    return cur, None


def op_json_path_get(payload: dict[str, Any]) -> dict[str, Any]:
    s = str(payload.get("text") or "")
    path = str(payload.get("path") or "")
    if not path:
        return {"ok": False, "error": "path required (dot-separated, e.g. a.b.0)"}
    if len(s) > MAX_TEXT:
        return {"ok": False, "error": "text too large"}
    try:
        obj = json.loads(s)
    except json.JSONDecodeError as e:
        return {"ok": False, "error": str(e)}
    val, err = _get_by_path(obj, path)
    if err:
        return {"ok": False, "error": err}
    if isinstance(val, (dict, list)):
        snippet = json.dumps(val, ensure_ascii=False, default=str)
        if len(snippet) > 120_000:
            return {
                "ok": True,
                "type": type(val).__name__,
                "truncated": True,
                "preview": snippet[:50_000] + "…",
            }
        return {"ok": True, "type": type(val).__name__, "value": val}
    return {"ok": True, "type": type(val).__name__, "value": val}


def op_html_to_text(payload: dict[str, Any]) -> dict[str, Any]:
    s = str(payload.get("html") or payload.get("text") or "")
    if len(s) > MAX_TEXT:
        return {"ok": False, "error": f"html exceeds {MAX_TEXT} chars"}
    parser = _StripHTMLParser()
    try:
        parser.feed(s)
        parser.close()
    except Exception as e:  # noqa: BLE001
        return {"ok": False, "error": f"parse: {e}"}
    text = parser.get_text()
    if payload.get("unescape_entities"):
        text = html.unescape(text)
    return {"ok": True, "text": text}


def op_html_escape(payload: dict[str, Any]) -> dict[str, Any]:
    s = str(payload.get("text") or "")
    if len(s) > MAX_TEXT:
        return {"ok": False, "error": "text too large"}
    return {"ok": True, "text": html.escape(s, quote=True)}


def op_xml_parse_flat(payload: dict[str, Any]) -> dict[str, Any]:
    s = str(payload.get("xml") or payload.get("text") or "")
    if len(s) > MAX_TEXT:
        return {"ok": False, "error": "xml too large"}
    try:
        root = ET.fromstring(s)
    except ET.ParseError as e:
        return {"ok": False, "error": str(e)}

    rows: list[dict[str, Any]] = []

    def walk(el: ET.Element, depth: int) -> None:
        if len(rows) >= MAX_XML_NODES:
            return
        if depth > MAX_JSON_DEPTH:
            return
        tag = el.tag.split("}")[-1] if "}" in el.tag else el.tag
        text = (el.text or "").strip()
        attrs = {k.split("}")[-1]: v for k, v in el.attrib.items()}
        children = list(el)
        rows.append(
            {
                "tag": tag,
                "depth": depth,
                "text": text[:2000] if text else "",
                "attrib": attrs,
                "child_tags": [c.tag.split("}")[-1] for c in children[:20]],
            }
        )
        for c in children:
            walk(c, depth + 1)

    walk(root, 0)
    return {
        "ok": True,
        "root_tag": root.tag.split("}")[-1],
        "nodes": rows,
        "truncated": len(rows) >= MAX_XML_NODES,
        "note": "trusted XML only; no XXE hardening",
    }


def op_xml_find_tags(payload: dict[str, Any]) -> dict[str, Any]:
    s = str(payload.get("xml") or payload.get("text") or "")
    name = str(payload.get("tag") or "")
    if not name:
        return {"ok": False, "error": "tag required"}
    if len(s) > MAX_TEXT:
        return {"ok": False, "error": "xml too large"}
    try:
        root = ET.fromstring(s)
    except ET.ParseError as e:
        return {"ok": False, "error": str(e)}
    found: list[dict[str, Any]] = []
    for el in root.iter():
        t = el.tag.split("}")[-1]
        if t == name or name == "*":
            txt = "".join(el.itertext()).strip()
            found.append(
                {
                    "tag": t,
                    "text": txt[:5000],
                    "attrib": {k.split("}")[-1]: v for k, v in el.attrib.items()},
                }
            )
            if len(found) >= 500:
                break
    return {"ok": True, "matches": found, "count": len(found)}


def op_structure_convert(payload: dict[str, Any]) -> dict[str, Any]:
    """list[dict] -> dict keyed by field; sort_keys on dict."""
    op = str(payload.get("mode") or "").strip().lower()
    obj = payload.get("value")
    if op == "list_to_dict":
        if not isinstance(obj, list):
            return {"ok": False, "error": "value must be JSON array"}
        key_field = str(payload.get("key_field") or "id")
        out: dict[str, Any] = {}
        dup = 0
        for i, item in enumerate(obj):
            if not isinstance(item, dict):
                return {"ok": False, "error": f"item {i} is not object"}
            k = item.get(key_field)
            if k is None:
                return {"ok": False, "error": f"missing {key_field} at index {i}"}
            ks = str(k)
            if ks in out:
                dup += 1
            out[ks] = item
        return {"ok": True, "dict": out, "duplicate_keys_overwritten": dup}
    if op == "sort_object_keys":
        if not isinstance(obj, dict):
            return {"ok": False, "error": "value must be JSON object"}

        def sort_obj(o: Any) -> Any:
            if isinstance(o, dict):
                return {k: sort_obj(o[k]) for k in sorted(o.keys(), key=str)}
            if isinstance(o, list):
                return [sort_obj(x) for x in o]
            return o

        return {"ok": True, "value": sort_obj(obj)}
    if op == "transpose_records":
        if not isinstance(obj, list) or not obj:
            return {"ok": False, "error": "value must be non-empty array of objects"}
        keys: set[str] = set()
        for it in obj:
            if isinstance(it, dict):
                keys.update(it.keys())
        cols = sorted(keys)
        series: dict[str, list[Any]] = {c: [] for c in cols}
        for it in obj:
            if not isinstance(it, dict):
                return {"ok": False, "error": "all items must be objects"}
            for c in cols:
                series[c].append(it.get(c))
        return {"ok": True, "columns": cols, "series": series, "rows": len(obj)}
    return {
        "ok": False,
        "error": f"unknown mode: {op!r}",
        "hint": "list_to_dict|sort_object_keys|transpose_records",
    }


def op_text_find(payload: dict[str, Any]) -> dict[str, Any]:
    text = str(payload.get("text") or "")
    if len(text) > MAX_TEXT:
        return {"ok": False, "error": "text too large"}
    sub = str(payload.get("substring") or "")
    if sub:
        positions = []
        start = 0
        max_hits = int(payload.get("max_hits", 200))
        max_hits = max(1, min(2000, max_hits))
        while len(positions) < max_hits:
            i = text.find(sub, start)
            if i < 0:
                break
            positions.append(i)
            start = i + max(1, len(sub))
        return {"ok": True, "count": len(positions), "positions": positions}
    pattern = str(payload.get("pattern") or "")
    if not pattern:
        return {"ok": False, "error": "substring or pattern required"}
    flags = 0
    if payload.get("ignore_case"):
        flags |= re.IGNORECASE
    if payload.get("multiline"):
        flags |= re.MULTILINE
    try:
        rx = re.compile(pattern, flags)
    except re.error as e:
        return {"ok": False, "error": str(e)}
    max_hits = int(payload.get("max_hits", 100))
    max_hits = max(1, min(2000, max_hits))
    matches: list[dict[str, Any]] = []
    for i, m in enumerate(rx.finditer(text)):
        if i >= max_hits:
            break
        matches.append(
            {
                "start": m.start(),
                "end": m.end(),
                "match": m.group(0)[:500],
            }
        )
    return {"ok": True, "matches": matches, "count": len(matches)}


def run_operation(operation: str, payload: dict[str, Any]) -> dict[str, Any]:
    op = operation.strip().lower().replace("-", "_")
    if op in ("json_parse",):
        return op_json_parse(payload)
    if op in ("json_stringify", "json_dumps"):
        return op_json_stringify(payload)
    if op in ("json_pretty", "json_format"):
        return op_json_pretty(payload)
    if op in ("json_path_get", "json_get"):
        return op_json_path_get(payload)
    if op in ("html_to_text", "html_strip"):
        return op_html_to_text(payload)
    if op in ("html_escape",):
        return op_html_escape(payload)
    if op in ("xml_parse_flat", "xml_flat"):
        return op_xml_parse_flat(payload)
    if op in ("xml_find_tags", "xml_find"):
        return op_xml_find_tags(payload)
    if op in ("structure_convert", "struct_convert"):
        return op_structure_convert(payload)
    if op in ("text_find", "find_text"):
        return op_text_find(payload)
    return {
        "ok": False,
        "error": f"unknown operation: {operation!r}",
        "hint": "json_parse|json_stringify|json_pretty|json_path_get|html_to_text|html_escape|"
        "xml_parse_flat|xml_find_tags|structure_convert|text_find",
    }
