#!/usr/bin/env python3
"""Verify FemType=5 (3D eigen frequency) outputs without requiring a pre-made reference.

Checks (in order):
  1. Self-consistency: normE == sqrt(|Ex|^2+|Ey|^2+|Ez|^2) per mode
  2. Independent freq: SciPy eigs(A,B,sigma) on exported A/B vs OutFile/freq
  3. Optional MATLAB: compare freq/normE if mat_outdir contains those files

Usage:
  python tools/verify_eigen3d.py build/Release/OutFile
  python tools/verify_eigen3d.py build/Release/OutFile --json eigen3d.json
  python tools/verify_eigen3d.py build/Release/OutFile --mat-outdir path/to/matlab/OutFile
"""
from __future__ import annotations

import argparse
import json
import re
from pathlib import Path

import numpy as np
from scipy import sparse
from scipy.sparse.linalg import eigs

C_LIGHT = 2.9979e8


def load_real_coo(prefix: Path) -> sparse.csr_matrix:
    ai = np.loadtxt(prefix / "Ai.txt", dtype=np.int64)
    aj = np.loadtxt(prefix / "Aj.txt", dtype=np.int64)
    av = np.loadtxt(prefix / "Av.txt", dtype=np.float64)
    n = int(max(ai.max(), aj.max()) + 1)
    return sparse.csr_matrix((av, (ai, aj)), shape=(n, n))


def load_complex_vector_modes(path: Path) -> list[np.ndarray]:
    """Ex/Ey/Ez: each mode is nv complex values."""
    modes: list[list[complex]] = []
    cur: list[complex] = []
    for line in path.read_text().splitlines():
        line = line.strip()
        if not line:
            continue
        if line == "//" or line.startswith("//"):
            if line.startswith("//") and len(line) > 2:
                parts = line[2:].strip().split()
                if parts:
                    cur.append(complex(float(parts[0]), float(parts[1]) if len(parts) > 1 else 0.0))
            if cur:
                modes.append(cur)
                cur = []
            continue
        parts = line.split()
        cur.append(complex(float(parts[0]), float(parts[1])))
    if cur:
        modes.append(cur)
    return [np.asarray(m, dtype=np.complex128) for m in modes]


def load_real_modes(path: Path) -> list[np.ndarray]:
    modes: list[list[float]] = []
    cur: list[float] = []
    for line in path.read_text().splitlines():
        line = line.strip()
        if not line:
            continue
        if line == "//" or line.startswith("//"):
            if line.startswith("//") and len(line) > 2:
                rest = line[2:].strip().split()
                if rest:
                    cur.append(float(rest[0]))
            if cur:
                modes.append(cur)
                cur = []
            continue
        cur.append(float(line.split()[0]))
    if cur:
        modes.append(cur)
    return [np.asarray(m, dtype=np.float64) for m in modes]


def load_freq(path: Path) -> np.ndarray:
    rows = []
    for line in path.read_text().splitlines():
        line = line.strip()
        if not line:
            continue
        parts = line.split()
        rows.append(complex(float(parts[0]), float(parts[1]) if len(parts) > 1 else 0.0))
    return np.asarray(rows, dtype=np.complex128)


def self_check(outdir: Path) -> dict:
    ex = load_complex_vector_modes(outdir / "Ex")
    ey = load_complex_vector_modes(outdir / "Ey")
    ez = load_complex_vector_modes(outdir / "Ez")
    ne = load_real_modes(outdir / "normE")
    n_mode = len(ne)
    if not (len(ex) == len(ey) == len(ez) == n_mode):
        raise SystemExit(
            f"mode count mismatch: Ex={len(ex)} Ey={len(ey)} Ez={len(ez)} normE={n_mode}"
        )
    rels, corrs = [], []
    for i in range(n_mode):
        n_calc = np.sqrt(np.abs(ex[i]) ** 2 + np.abs(ey[i]) ** 2 + np.abs(ez[i]) ** 2)
        n_ref = ne[i]
        if n_ref.size != n_calc.size:
            raise SystemExit(f"mode {i}: size {n_ref.size} vs {n_calc.size}")
        denom = np.linalg.norm(n_ref)
        rel = np.linalg.norm(n_calc - n_ref) / (denom if denom > 0 else 1.0)
        corr = np.corrcoef(n_calc, n_ref)[0, 1] if denom > 0 else 1.0
        rels.append(rel)
        corrs.append(corr)
    return {
        "n_modes": n_mode,
        "n_vertex": int(ne[0].size) if ne else 0,
        "normE_rel_err": rels,
        "normE_corr": corrs,
    }


def scipy_freq_check(outdir: Path, json_path: Path | None) -> dict:
    A = load_real_coo_with_names(outdir, "A")
    B = load_real_coo_with_names(outdir, "B")
    freq_cpp = load_freq(outdir / "freq")
    k = freq_cpp.size

    ff0 = None
    if json_path and json_path.exists():
        js = json.loads(json_path.read_text(encoding="utf-8"))
        ff0 = float(js.get("searchValue", 0))
        lam0 = float(js.get("lambda0", 0.8))
        if ff0 <= 0:
            ff0 = C_LIGHT / lam0

    if ff0 is None or ff0 <= 0:
        ff0 = C_LIGHT / 0.8

    k0 = 2.0 * np.pi / (C_LIGHT / ff0)
    sigma = k0 * k0

    print(f"  SciPy eigs: n={A.shape[0]}, k={k}, sigma={sigma:.6g} (ff0={ff0:.6g} Hz) ...")
    vals, _ = eigs(A, k=k, M=B, sigma=sigma, which="LM")
    vals = np.sort(vals.real)[::-1]
    freq_scipy = C_LIGHT / (2.0 * np.pi / np.sqrt(vals))

    freq_cpp_hz = freq_cpp.real
    freq_scipy_hz = np.sort(freq_scipy)[::-1]

    # Match modes by nearest frequency (ordering may differ)
    used = set()
    pairs = []
    for f in freq_cpp_hz:
        j = int(np.argmin([abs(f - s) if idx not in used else np.inf for idx, s in enumerate(freq_scipy_hz)]))
        used.add(j)
        pairs.append((f, freq_scipy_hz[j], abs(f - freq_scipy_hz[j]) / max(abs(f), 1e-30)))

    rels = [p[2] for p in pairs]
    return {
        "freq_cpp_Hz": freq_cpp_hz.tolist(),
        "freq_scipy_Hz": freq_scipy_hz.tolist(),
        "freq_rel_err_matched": rels,
        "freq_max_rel_err": float(max(rels) if rels else 0.0),
    }


def load_real_coo_with_names(outdir: Path, which: str) -> sparse.csr_matrix:
    ai = np.loadtxt(outdir / f"{which}i.txt", dtype=np.int64)
    aj = np.loadtxt(outdir / f"{which}j.txt", dtype=np.int64)
    av = np.loadtxt(outdir / f"{which}v.txt", dtype=np.float64)
    n = int(max(ai.max(), aj.max()) + 1)
    return sparse.csr_matrix((av, (ai, aj)), shape=(n, n))


def compare_matlab(outdir: Path, mat_outdir: Path) -> dict:
    freq_cpp = load_freq(outdir / "freq").real
    if not (mat_outdir / "freq").exists():
        return {"skipped": "matlab OutFile/freq not found"}
    freq_mat = load_freq(mat_outdir / "freq").real
    k = min(freq_cpp.size, freq_mat.size)
    freq_cpp = np.sort(freq_cpp[:k])[::-1]
    freq_mat = np.sort(freq_mat[:k])[::-1]
    rel_f = np.linalg.norm(freq_cpp - freq_mat) / np.linalg.norm(freq_mat)

    out = {"freq_rel_err": float(rel_f)}
    if (mat_outdir / "normE").exists():
        ne_cpp_modes = load_real_modes(outdir / "normE")
        ne_mat_modes = load_real_modes(mat_outdir / "normE")
        m = min(len(ne_cpp_modes), len(ne_mat_modes))
        rels, corrs = [], []
        for i in range(m):
            a, b = ne_cpp_modes[i], ne_mat_modes[i]
            if a.size != b.size:
                continue
            rels.append(np.linalg.norm(a - b) / np.linalg.norm(b))
            corrs.append(np.corrcoef(a, b)[0, 1])
        out["normE_rel_err_per_mode"] = rels
        out["normE_corr_per_mode"] = corrs
    return out


def main() -> None:
    parser = argparse.ArgumentParser(description="Verify 3D eigenfrequency OutFile")
    parser.add_argument("outdir", type=Path, help="C++ OutFile directory")
    parser.add_argument("--json", type=Path, default=None, help="eigen3d.json for sigma")
    parser.add_argument("--mat-outdir", type=Path, default=None, help="MATLAB OutFile for comparison")
    parser.add_argument("--skip-scipy", action="store_true", help="Skip SciPy eigs (slow)")
    args = parser.parse_args()

    outdir = args.outdir.resolve()
    if not (outdir / "normE").exists():
        raise SystemExit(f"missing {outdir / 'normE'}")

    print("=== 1. Self-check: normE vs Ex/Ey/Ez ===")
    sc = self_check(outdir)
    print(f"  modes={sc['n_modes']}, vertices/mode={sc['n_vertex']}")
    for i, (rel, corr) in enumerate(zip(sc["normE_rel_err"], sc["normE_corr"])):
        ok = "OK" if rel < 1e-10 else "WARN"
        print(f"  mode {i}: rel_err={rel:.3e}, corr={corr:.6f}  [{ok}]")

    if not args.skip_scipy and (outdir / "Bi.txt").exists():
        print("\n=== 2. Independent freq: SciPy eigs(A,B) vs freq ===")
        js = args.json
        if js is None:
            cand = outdir.parent / "eigen3d.json"
            js = cand if cand.exists() else None
        fc = scipy_freq_check(outdir, js)
        print(f"  C++ freq (Hz):   {[f'{x:.6e}' for x in fc['freq_cpp_Hz']]}")
        print(f"  SciPy freq (Hz): {[f'{x:.6e}' for x in fc['freq_scipy_Hz']]}")
        print(f"  matched max rel err: {fc['freq_max_rel_err']:.3e}")
        if fc["freq_max_rel_err"] < 1e-4:
            print("  => freq solver consistent with exported A/B [OK]")
        else:
            print("  => freq mismatch — check solver or matrix export [WARN]")

    if args.mat_outdir:
        print(f"\n=== 3. Compare MATLAB: {args.mat_outdir} ===")
        mc = compare_matlab(outdir, args.mat_outdir.resolve())
        if mc.get("skipped"):
            print(f"  skipped: {mc['skipped']}")
        else:
            print(f"  freq rel err: {mc['freq_rel_err']:.3e}")
            if "normE_rel_err_per_mode" in mc:
                for i, (rel, corr) in enumerate(
                    zip(mc["normE_rel_err_per_mode"], mc["normE_corr_per_mode"])
                ):
                    print(f"  normE mode {i}: rel={rel:.3e}, corr={corr:.6f}")

    print("\n--- How to get a MATLAB reference ---")
    print("  cd 三维matlab代码/matlab 3D一阶本征问题/3D一阶本征问题2")
    print("  % need SBCmesh.mat (from COMSOL/export); then run main.m")
    print("  % add after main.m loop:")
    print("  %   writematrix([solverff0.real, zeros(num,1)], 'OutFile/freq')")
    print("  %   for i=1:numberSolve, export one normE block with // separator")
    print("  Then: python tools/verify_eigen3d.py ... --mat-outdir path/to/matlab/OutFile")


if __name__ == "__main__":
    main()
