"""由 nodes/edges 生成 Microsoft Visio **.vsdx**（依赖第三方包 ``vsdx`` + 其自带 ``media.vsdx`` 模板）。"""

from __future__ import annotations

import os
import shutil
from contextlib import contextmanager
from pathlib import Path
from typing import Any, Callable

try:
    import vsdx as _vsdx_pkg
    from vsdx import VisioFile
    from vsdx.connectors import Connect
    from vsdx.media import Media

    _VSDX_AVAILABLE = True
except ImportError:  # pragma: no cover - exercised when extra not installed
    _VSDX_AVAILABLE = False
    _vsdx_pkg = None  # type: ignore[assignment]
    VisioFile = None  # type: ignore[misc, assignment]
    Connect = None  # type: ignore[misc, assignment]
    Media = None  # type: ignore[misc, assignment]

MAX_NODES = 400
MAX_EDGES = 1200

PolicyCheck = Callable[[Path], None]


def vsdx_installed() -> bool:
    return _VSDX_AVAILABLE


def media_template_vsdx() -> Path | None:
    if not _VSDX_AVAILABLE or _vsdx_pkg is None:
        return None
    return Path(_vsdx_pkg.__file__).resolve().parent / "media" / "media.vsdx"


@contextmanager
def _chdir_vsdx_package() -> Any:
    if not _VSDX_AVAILABLE or _vsdx_pkg is None:
        raise RuntimeError("vsdx not installed")
    pkg = Path(_vsdx_pkg.__file__).resolve().parent
    old = os.getcwd()
    try:
        # vsdx.Media() uses os.path.relpath(__file__); cross-drive cwd on Windows raises ValueError.
        os.chdir(pkg)
        yield
    finally:
        os.chdir(old)


def _norm_graph(
    nodes: list[dict[str, Any]], edges: list[dict[str, Any]]
) -> dict[str, Any] | tuple[dict[str, str], list[tuple[str, str, dict[str, Any]]], list[dict[str, Any]]]:
    if not isinstance(nodes, list) or not isinstance(edges, list):
        return {"ok": False, "error": "nodes and edges must be arrays"}
    if len(nodes) > MAX_NODES:
        return {"ok": False, "error": f"at most {MAX_NODES} nodes"}
    if len(edges) > MAX_EDGES:
        return {"ok": False, "error": f"at most {MAX_EDGES} edges"}
    id_map: dict[str, str] = {}
    norm_nodes: list[tuple[str, str, dict[str, Any]]] = []
    for i, raw in enumerate(nodes):
        if not isinstance(raw, dict):
            return {"ok": False, "error": f"nodes[{i}] must be object"}
        uid = str(raw.get("id") if raw.get("id") is not None else f"n{i}")
        if uid in id_map:
            return {"ok": False, "error": f"duplicate node id: {uid!r}"}
        id_map[uid] = uid
        lbl = str(raw.get("label") if raw.get("label") is not None else uid)
        norm_nodes.append((uid, uid, {**raw, "label": lbl}))
    norm_edges: list[dict[str, Any]] = []
    for i, e in enumerate(edges):
        if not isinstance(e, dict):
            return {"ok": False, "error": f"edges[{i}] must be object"}
        s = str(e.get("source", e.get("from", "")))
        t = str(e.get("target", e.get("to", "")))
        if s not in id_map or t not in id_map:
            return {
                "ok": False,
                "error": f"edges[{i}] unknown source/target: {s!r} -> {t!r}",
            }
        norm_edges.append(e)
    return id_map, norm_nodes, norm_edges


def _shape_kind(meta: dict[str, Any]) -> str:
    s = str(meta.get("shape") or meta.get("kind") or "process").strip().lower()
    if s in ("decision", "diamond", "branch", "condition"):
        return "decision"
    if s in ("terminator", "terminal", "start", "end", "ellipse", "circle"):
        return "terminator"
    return "process"


def _layout_xy(
    idx: int,
    cols: int,
    *,
    x0: float = 1.2,
    y0: float = 9.0,
    dx: float = 2.35,
    dy: float = 1.75,
) -> tuple[float, float]:
    cols = max(1, min(12, cols))
    c = idx % cols
    r = idx // cols
    x = x0 + c * dx
    y = y0 - r * dy
    return x, y


def write_flowchart_vsdx(
    out_path: Path,
    nodes: list[dict[str, Any]],
    edges: list[dict[str, Any]],
    *,
    policy_check: PolicyCheck | None = None,
    layout_cols: int = 4,
) -> dict[str, Any]:
    """复制 ``media.vsdx``，在首页放置流程图形状与连线后保存。

    ``shape`` / ``kind``：``process``（矩形）、``decision``（矩形，标签前加 ``◇``）、``terminator``（椭圆模板，近似起止符）。
    坐标为 Visio 英寸（PinX/PinY）；未给 ``x``/``y`` 时使用网格布局。
    """
    if not _VSDX_AVAILABLE:
        return {
            "ok": False,
            "error": "vsdx 未安装",
            "hint": "在同一 Python 环境中执行: pip install \"aicd[visio]\" 或 pip install \"vsdx>=0.6.1\"。"
            " 勿使用不存在的包名 python-visio。安装后 runtime_env_refresh。",
        }
    tpl = media_template_vsdx()
    if tpl is None or not tpl.is_file():
        return {"ok": False, "error": f"built-in Visio template missing: {tpl}"}

    out_path = out_path.expanduser().resolve()
    if policy_check is not None:
        policy_check(out_path)
    out_path.parent.mkdir(parents=True, exist_ok=True)

    ng = _norm_graph(nodes, edges)
    if isinstance(ng, dict):
        return ng
    _, norm_nodes, norm_edges = ng
    if not norm_nodes:
        return {"ok": False, "error": "nodes must be non-empty"}

    shutil.copy(tpl, out_path)
    abs_out = str(out_path)
    cols = int(layout_cols) if layout_cols else 4

    with _chdir_vsdx_package():
        with VisioFile(abs_out) as vis:
            page = vis.pages[0]
            rect_src = page.find_shape_by_text(Media.rectangle_text)
            circ_src = page.find_shape_by_text(Media.circle_text)
            if rect_src is None or circ_src is None:
                return {"ok": False, "error": "template page missing RECTANGLE/CIRCLE masters"}

            shapes_by_id: dict[str, Any] = {}
            for idx, (uid, _mid, meta) in enumerate(norm_nodes):
                kind = _shape_kind(meta)
                src = circ_src if kind == "terminator" else rect_src
                sh = src.copy(page)
                lbl = str(meta["label"])
                if kind == "decision" and not lbl.startswith("◇"):
                    lbl = f"◇ {lbl}"
                sh.text = lbl
                x = meta.get("x")
                y = meta.get("y")
                if x is None or y is None:
                    px, py = _layout_xy(idx, cols)
                else:
                    try:
                        px = float(x)
                        py = float(y)
                    except (TypeError, ValueError):
                        return {"ok": False, "error": f"node {uid!r}: x/y must be numbers"}
                sh.x = px
                sh.y = py
                shapes_by_id[uid] = sh

            for e in norm_edges:
                s = str(e.get("source", e.get("from", "")))
                t = str(e.get("target", e.get("to", "")))
                fa = shapes_by_id.get(s)
                tb = shapes_by_id.get(t)
                if fa is None or tb is None:
                    continue
                conn = Connect.create(page, fa, tb)
                el = e.get("label")
                if el is not None and str(el).strip():
                    conn.text = str(el).strip()

            for label in (
                Media.rectangle_text,
                Media.circle_text,
                Media.straight_connector_text,
            ):
                while True:
                    s = page.find_shape_by_text(label)
                    if s is None:
                        break
                    s.remove()

            vis.save_vsdx(abs_out)

    return {
        "ok": True,
        "path": abs_out,
        "node_count": len(norm_nodes),
        "edge_count": len(norm_edges),
        "hint": "使用 Microsoft Visio 或兼容编辑器打开 .vsdx；决策节点为带 ◇ 前缀的矩形（库内无菱形主形状）。",
    }


def summarize_vsdx_page(
    vsdx_path: Path,
    *,
    policy_check: PolicyCheck | None = None,
    max_shapes: int = 400,
) -> dict[str, Any]:
    """读取 .vsdx 第一页：形状 id、文本、位置（英寸）摘要。"""
    if not _VSDX_AVAILABLE:
        return {
            "ok": False,
            "error": "vsdx 未安装",
            "hint": "pip install \"aicd[visio]\" 或 pip install \"vsdx>=0.6.1\"；勿使用 python-visio。",
        }
    p = vsdx_path.expanduser().resolve()
    if not p.is_file():
        return {"ok": False, "error": "file not found"}
    if p.suffix.lower() != ".vsdx":
        return {"ok": False, "error": "path must end with .vsdx"}
    if policy_check is not None:
        policy_check(p)

    page_name = ""
    rows: list[dict[str, Any]] = []
    conn_brief: list[dict[str, str]] = []
    connect_count = 0
    total_shapes = 0
    truncated = False

    with _chdir_vsdx_package():
        with VisioFile(str(p)) as vis:
            if not vis.pages:
                return {"ok": False, "error": "no pages in file"}
            page = vis.pages[0]
            page_name = page.name
            connects_raw = page.get_connects()
            connect_count = len(connects_raw)
            all_sh = page.all_shapes
            total_shapes = len(all_sh)
            for sh in all_sh:
                if len(rows) >= max_shapes:
                    truncated = True
                    break
                tid = getattr(sh, "ID", None)
                txt = (sh.text or "").strip()
                if not txt and not getattr(sh, "connector", False):
                    continue
                try:
                    px = float(sh.x) if sh.x is not None else None
                except (TypeError, ValueError):
                    px = None
                try:
                    py = float(sh.y) if sh.y is not None else None
                except (TypeError, ValueError):
                    py = None
                rows.append(
                    {
                        "id": str(tid) if tid is not None else "",
                        "text": txt[:500],
                        "x_in": px,
                        "y_in": py,
                        "connector": bool(getattr(sh, "connector", False)),
                    }
                )

            for c in connects_raw[: min(500, len(connects_raw))]:
                conn_brief.append(
                    {
                        "from_sheet": str(c.from_id or ""),
                        "to_sheet": str(c.to_id or ""),
                    }
                )

    return {
        "ok": True,
        "path": str(p),
        "page_name": page_name,
        "shape_rows": rows,
        "shape_row_count": len(rows),
        "connect_count": connect_count,
        "connects_preview": conn_brief,
        "truncated": truncated or total_shapes > max_shapes,
    }
