#!/usr/bin/env python3
"""Plot normE on boundary face domains (e.g. PBC dst faces 2 and 5)."""

from __future__ import annotations

import argparse
from pathlib import Path

import matplotlib.pyplot as plt
import numpy as np
from matplotlib.collections import PolyCollection

plt.rcParams["font.sans-serif"] = ["Microsoft YaHei", "SimHei", "Arial"]
plt.rcParams["axes.unicode_minus"] = False

# Reuse mesh loader
import importlib.util

_ROOT = Path(__file__).resolve().parents[1]
_spec = importlib.util.spec_from_file_location(
    "validate_pbcmesh", _ROOT / "tools" / "validate_and_fix_pbcmesh.py"
)
_v = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_v)
load_mesh_dat = _v.load_mesh_dat
face_vertices = _v.face_vertices


def face_vertex_ids(tet: np.ndarray, num_tet: int, num_face: int) -> np.ndarray:
    """Return 3 mesh vertex ids (0-based) for a boundary face."""
    tv = tet[num_tet - 1] - 1
    if num_face == 1:
        local = [0, 1, 2]
    elif num_face == 2:
        local = [0, 1, 3]
    elif num_face == 3:
        local = [0, 2, 3]
    elif num_face == 4:
        local = [1, 2, 3]
    else:
        raise ValueError(f"bad face id {num_face}")
    return tv[np.array(local)]


def load_normE(path: Path) -> np.ndarray:
    vals: list[float] = []
    for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
        line = line.strip()
        if not line:
            continue
        if line.startswith("//"):
            line = line[2:].strip()
            if not line:
                continue
        vals.append(float(line.split()[0]))
    return np.array(vals)


def collect_face_tris(
    mesh: dict, domain: int, normE: np.ndarray
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """Return (points Nx2 in xz, triangles Mx3, face_values M)."""
    V = mesh["vertex"]
    T = mesh["tet"]
    dom = mesh["domain_of_tri"]
    conn = mesh["conn_of_tri"]

    pts_list: list[np.ndarray] = []
    tri_list: list[tuple[int, int, int]] = []
    vals_list: list[float] = []
    pt_index: dict[tuple[float, float], int] = {}

    def get_pt(x: float, z: float) -> int:
        key = (round(x, 10), round(z, 10))
        if key not in pt_index:
            pt_index[key] = len(pts_list)
            pts_list.append([x, z])
        return pt_index[key]

    for tri_idx in np.where(dom == domain)[0]:
        num_tet, num_face = int(conn[tri_idx, 0]), int(conn[tri_idx, 1])
        vids = face_vertex_ids(T, num_tet, num_face)
        xyz = V[vids]
        i0 = get_pt(xyz[0, 0], xyz[0, 2])
        i1 = get_pt(xyz[1, 0], xyz[1, 2])
        i2 = get_pt(xyz[2, 0], xyz[2, 2])
        tri_list.append((i0, i1, i2))
        vals_list.append(float(np.mean(normE[vids])))

    if not pts_list:
        return np.zeros((0, 2)), np.zeros((0, 3), dtype=int), np.zeros(0)

    return np.array(pts_list), np.array(tri_list, dtype=int), np.array(vals_list)


def plot_domain_ax(ax, pts: np.ndarray, tris: np.ndarray, fvals: np.ndarray, title: str):
    if len(pts) == 0:
        ax.set_title(title + " (empty)")
        return
    # Per-triangle constant color
    polys = pts[tris]
    coll = PolyCollection(
        polys,
        array=fvals,
        cmap="jet",
        edgecolors="k",
        linewidths=0.15,
        alpha=0.95,
    )
    ax.add_collection(coll)
    ax.set_aspect("equal")
    ax.set_xlabel("x (m)")
    ax.set_ylabel("z (m)")
    ax.set_title(title)
    ax.autoscale()
    mappable = coll
    return mappable


def _tri_centroids(pts: np.ndarray, tris: np.ndarray) -> np.ndarray:
    return np.array([pts[t].mean(axis=0) for t in tris])


def _centroids_inside_patch(
    centroids: np.ndarray, patch_pts: np.ndarray, patch_tris: np.ndarray
) -> np.ndarray:
    """True where centroid lies inside the xz footprint of a patch (bbox test)."""
    xmin, xmax = patch_pts[:, 0].min(), patch_pts[:, 0].max()
    zmin, zmax = patch_pts[:, 1].min(), patch_pts[:, 1].max()
    return (
        (centroids[:, 0] >= xmin)
        & (centroids[:, 0] <= xmax)
        & (centroids[:, 1] >= zmin)
        & (centroids[:, 1] <= zmax)
    )


def _append_tris_unified(
    pts_acc: list[np.ndarray],
    tris_acc: list[tuple[int, int, int]],
    vals_acc: list[float],
    pt_index: dict[tuple[float, float], int],
    patch_pts: np.ndarray,
    patch_tris: np.ndarray,
    patch_vals: np.ndarray,
) -> None:
    def get_pt(x: float, z: float) -> int:
        key = (round(x, 10), round(z, 10))
        if key not in pt_index:
            pt_index[key] = len(pts_acc)
            pts_acc.append([x, z])
        return pt_index[key]

    for tri, val in zip(patch_tris, patch_vals):
        p0, p1, p2 = patch_pts[tri]
        i0 = get_pt(p0[0], p0[1])
        i1 = get_pt(p1[0], p1[1])
        i2 = get_pt(p2[0], p2[1])
        tris_acc.append((i0, i1, i2))
        vals_acc.append(float(val))


def collect_domains_combined(
    mesh: dict,
    domains: list[int],
    normE: np.ndarray,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """Merge face domains on one x-z plane with shared vertex indexing.

    Adjacent patches (e.g. domain 2 + 5) share boundary vertices; duplicate
    points at the same (x, z) are welded so triangles tile without z-fighting.
    """
    if not domains:
        return np.zeros((0, 2)), np.zeros((0, 3), dtype=int), np.zeros(0)

    raw = [(d, *collect_face_tris(mesh, d, normE)) for d in domains]
    raw = [(d, p, t, f) for d, p, t, f in raw if len(t) > 0]
    if not raw:
        return np.zeros((0, 2)), np.zeros((0, 3), dtype=int), np.zeros(0)

    pts_acc: list[list[float]] = []
    tris_acc: list[tuple[int, int, int]] = []
    vals_acc: list[float] = []
    pt_index: dict[tuple[float, float], int] = {}

    for _, pts, tris, fvals in raw:
        _append_tris_unified(
            pts_acc, tris_acc, vals_acc, pt_index, pts, tris, fvals,
        )

    if not pts_acc:
        return np.zeros((0, 2)), np.zeros((0, 3), dtype=int), np.zeros(0)
    return (
        np.array(pts_acc),
        np.array(tris_acc, dtype=int),
        np.array(vals_acc),
    )


def plot_combined_faces(
    mesh: dict,
    domains: list[int],
    normE_cpp: np.ndarray,
    normE_mat: np.ndarray | None,
    output: Path,
    title: str | None = None,
    cpp_outdir: Path | None = None,
    mat_outdir: Path | None = None,
    layout: str = "merge",
) -> None:
    """Plot PBC face domains on one axes.

    layout:
      merge – weld shared vertices and tile all domains (default)
      side  – one panel per domain, horizontal row
    """
    has_mat = normE_mat is not None
    plot_domains = list(domains)
    use_side = layout == "side" and len(plot_domains) > 1
    ncols = (len(plot_domains) if use_side else 1) * (2 if has_mat else 1)
    fig_w = 5.5 * ncols if use_side else (6.8 * ncols if has_mat else 6.0)
    fig, axes = plt.subplots(
        1, ncols if ncols else 1,
        figsize=(fig_w, 5.5),
        squeeze=False,
        facecolor="white",
    )

    cpp_is_mat = (
        has_mat
        and cpp_outdir is not None
        and mat_outdir is not None
        and cpp_outdir.resolve() == mat_outdir.resolve()
    )
    mappables = []
    labels = ["C++ OpticsFEM", "MATLAB 参考"] if has_mat else ["OpticsFEM |E|"]
    if cpp_is_mat:
        labels[0] = "MATLAB 参考 (C++ OutFile 待 pbc3d_sbc.json 重跑)"
    datasets = [normE_cpp]
    if has_mat:
        datasets.append(normE_mat)

    domain_label = " + ".join(f"domain {d}" for d in plot_domains)

    if use_side:
        mappables = []
        for i, dom in enumerate(plot_domains):
            col = i * (2 if has_mat else 1)
            for j, (normE, suffix) in enumerate(
                [(normE_cpp, "C++"), (normE_mat, "MATLAB")] if has_mat else [(normE_cpp, "")]
            ):
                if normE is None:
                    continue
                pts, tris, fvals = collect_face_tris(mesh, dom, normE)
                ax = axes[0, col + j]
                label = f"{suffix} domain {dom}" if suffix else f"domain {dom}"
                m = plot_domain_ax(ax, pts, tris, fvals, f"{label}\n(n_tri={len(fvals)})")
                if m is not None:
                    mappables.append(m)
    else:
        for col, (normE, label) in enumerate(zip(datasets, labels)):
            pts, tris, fvals = collect_domains_combined(mesh, plot_domains, normE)
            n_tri = len(fvals)
            m = plot_domain_ax(
                axes[0, col],
                pts,
                tris,
                fvals,
                f"{label}\n(domain {' + '.join(map(str, plot_domains))}, n_tri={n_tri})",
            )
            if m is not None:
                mappables.append(m)

    if mappables:
        vmin = min(m.get_array().min() for m in mappables if m.get_array() is not None)
        vmax = max(m.get_array().max() for m in mappables if m.get_array() is not None)
        for m in mappables:
            m.set_clim(vmin, vmax)
        if has_mat:
            fig.colorbar(
                mappables[0],
                ax=axes.ravel().tolist(),
                shrink=0.82,
                pad=0.02,
                label="|E| (V/m)",
            )
        else:
            fig.subplots_adjust(right=0.88)
            cbar = fig.colorbar(
                mappables[0],
                ax=axes[0, 0],
                fraction=0.046,
                pad=0.04,
                label="|E| (V/m)",
            )
            cbar.ax.tick_params(labelsize=9)

    fig.subplots_adjust(top=0.92, bottom=0.12, left=0.10, right=0.88 if not has_mat else 0.95)
    output.parent.mkdir(parents=True, exist_ok=True)
    fig.savefig(output, dpi=160, bbox_inches="tight", facecolor="white")
    plt.close(fig)


def main() -> None:
    parser = argparse.ArgumentParser(description="Plot normE on mesh face domains")
    parser.add_argument(
        "--mesh",
        type=Path,
        default=_ROOT / "build" / "Release" / "PBCmesh.dat",
    )
    parser.add_argument(
        "--outdir",
        type=Path,
        default=_ROOT / "build" / "Release" / "OutFile",
        help="OpticsFEM OutFile with normE",
    )
    parser.add_argument(
        "--mat-outdir",
        type=Path,
        default=_ROOT.parent
        / "三维matlab代码"
        / "matlab 3D一阶基+散射边界条件+单周期边界"
        / "OutFile",
        help="MATLAB OutFile for comparison (set empty to skip)",
    )
    parser.add_argument(
        "--domains",
        type=int,
        nargs="+",
        default=[2, 5],
        help="DomainOfTri ids to plot",
    )
    parser.add_argument(
        "--output",
        type=Path,
        default=_ROOT / "build" / "Release" / "OutFile" / "faces_2_5.png",
    )
    parser.add_argument(
        "--combined",
        action="store_true",
        help="Plot all --domains on the same axes (connected geometry)",
    )
    parser.add_argument(
        "--no-compare",
        action="store_true",
        help="Skip MATLAB comparison panel (simulation result only)",
    )
    parser.add_argument(
        "--layout",
        choices=["merge", "side"],
        default="merge",
        help="merge: weld vertices and tile domains; side: horizontal panels",
    )
    args = parser.parse_args()

    mesh = load_mesh_dat(args.mesh)
    normE_cpp = load_normE(args.outdir / "normE")
    if normE_cpp.size != mesh["n_vertex"]:
        raise SystemExit(
            f"normE size {normE_cpp.size} != NbrVertex {mesh['n_vertex']}"
        )

    has_mat = (
        not args.no_compare
        and args.mat_outdir is not None
        and (args.mat_outdir / "normE").exists()
    )
    normE_mat = load_normE(args.mat_outdir / "normE") if has_mat else None

    if args.combined:
        plot_combined_faces(
            mesh,
            args.domains,
            normE_cpp,
            normE_mat,
            args.output,
            cpp_outdir=args.outdir,
            mat_outdir=args.mat_outdir if has_mat else None,
            layout=args.layout,
        )
        print(f"Saved combined {args.output}")
        return

    ncols = 2 if has_mat else 1
    nrows = len(args.domains)
    fig, axes = plt.subplots(
        nrows, ncols, figsize=(6.5 * ncols, 5 * nrows), squeeze=False, facecolor="white"
    )

    mappables = []
    for row, dom in enumerate(args.domains):
        pts, tris, fvals = collect_face_tris(mesh, dom, normE_cpp)
        m = plot_domain_ax(
            axes[row, 0],
            pts,
            tris,
            fvals,
            f"C++ domain {dom}  (n_tri={len(fvals)})",
        )
        if m is not None:
            mappables.append(m)

        if has_mat:
            pts_m, tris_m, fvals_m = collect_face_tris(mesh, dom, normE_mat)
            m2 = plot_domain_ax(
                axes[row, 1],
                pts_m,
                tris_m,
                fvals_m,
                f"MATLAB domain {dom}",
            )
            if m2 is not None:
                mappables.append(m2)

    if mappables:
        vmin = min(m.get_array().min() for m in mappables if m.get_array() is not None)
        vmax = max(m.get_array().max() for m in mappables if m.get_array() is not None)
        for m in mappables:
            m.set_clim(vmin, vmax)
        fig.colorbar(mappables[0], ax=axes.ravel().tolist(), shrink=0.6, label="|E| (V/m)")

    fig.suptitle(
        "normE on PBC faces (x–z projection, y≈0)\n"
        + ", ".join(f"domain {d}" for d in args.domains),
        fontsize=13,
        fontweight="bold",
    )
    fig.tight_layout()
    args.output.parent.mkdir(parents=True, exist_ok=True)
    fig.savefig(args.output, dpi=160, bbox_inches="tight")
    print(f"Saved {args.output}")
    plt.close(fig)


if __name__ == "__main__":
    main()
