"""Motor-CAD electromagnetic simulation core for PCB axial flux motor.

Pure computation module, no GUI dependencies.
Handles Motor-CAD connection, model loading, parameter write-back
verification, magnetic calculation, result export parsing, and
metric extraction with bilingual (English/Chinese) field matching.

All source is ASCII; Chinese field names use \\uXXXX escapes.

Phase 1 metrics: average torque, torque ripple (%), system efficiency,
total losses (plus auxiliary: copper/iron/magnet loss, back EMF,
input/output power, shaft speed).
"""

from __future__ import annotations

import csv
import json
import math
import os
import time
import traceback
from datetime import datetime
from pathlib import Path


# ---------------------------------------------------------------------------
# Metric definitions: key, display label, and aliases (English + Chinese).
# Chinese aliases use Unicode escapes so this file stays pure ASCII.
# ---------------------------------------------------------------------------

METRIC_DEFINITIONS = [
    {
        "key": "tavg_nm",
        "label": "Average Torque [Nm]",
        "aliases": [
            "Average torque (virtual work)",
            "\u5e73\u5747\u8f6c\u77e9 (virtual work)",
            "\u5e73\u5747\u8f6c\u77e9(virtual work)",
        ],
    },
    {
        "key": "ripple_pct",
        "label": "Torque Ripple [%]",
        "aliases": [
            "Torque Ripple (VW) [%]",
            "Torque Ripple (VW)[%]",
        ],
    },
    {
        "key": "ripple_nm",
        "label": "Torque Ripple [Nm]",
        "aliases": [
            "Torque Ripple (VW)",
        ],
    },
    {
        "key": "efficiency_pct",
        "label": "System Efficiency [%]",
        "aliases": [
            "System Efficiency",
            "\u7cfb\u7edf\u6548\u7387",
        ],
    },
    {
        "key": "total_losses_w",
        "label": "Total Losses [W]",
        "aliases": [
            "Total Losses (on load)",
            "\u603b\u635f\u8017(\u989d\u5b9a)",
            "\u603b\u635f\u8017 (\u989d\u5b9a)",
        ],
    },
    {
        "key": "copper_loss_w",
        "label": "DC Copper Loss [W]",
        "aliases": [
            "Armature DC Copper Loss (on load)",
            "\u7535\u67a2\u76f4\u6d41\u94dc\u8017 (\u5e26\u8f7d)",
            "\u7535\u67a2\u76f4\u6d41\u94dc\u8017(\u5e26\u8f7d)",
        ],
    },
    {
        "key": "iron_loss_w",
        "label": "Stator Iron Loss [W]",
        "aliases": [
            "Stator iron Loss [total] (on load)",
            "\u5b9a\u5b50\u94c1\u635f[\u603b\u635f\u8017](\u989d\u5b9a)",
            "\u5b9a\u5b50\u94c1\u635f[\u603b\u635f\u8017] (\u989d\u5b9a)",
        ],
    },
    {
        "key": "magnet_loss_w",
        "label": "Magnet Loss [W]",
        "aliases": [
            "Magnet Loss (on load)",
            "\u6c38\u78c1\u4f53\u635f\u8017(\u989d\u5b9a)",
            "\u6c38\u78c1\u4f53\u635f\u8017 (\u989d\u5b9a)",
        ],
    },
    {
        "key": "back_emf_v",
        "label": "Back EMF LL rms [V]",
        "aliases": [
            "Back EMF Line-Line Voltage (rms)",
            "\u7ebf\u95f4\u53cd\u5411\u7535\u52a8\u52bf\u6709\u6548\u503c",
        ],
    },
    {
        "key": "back_emf_thd_pct",
        "label": "Back EMF THD [%]",
        "aliases": [
            "Harmonic Distortion Back EMF Line-Line Voltage",
            "\u7ebf\u53cd\u5411\u7535\u52a8\u52bf\u8c10\u6ce2",
            "\u7ebf\u7535\u538b\u8c10\u6ce2",
        ],
    },
    {
        "key": "input_power_w",
        "label": "Input Power [W]",
        "aliases": [
            "Input Power",
            "\u8f93\u5165\u529f\u7387",
        ],
    },
    {
        "key": "output_power_w",
        "label": "Output Power [W]",
        "aliases": [
            "Output Power",
            "\u8f93\u51fa\u529f\u7387_\u7535\u538b\u9650\u5236\u9644\u8fd1\u5de5\u4f5c\u70b9",
        ],
    },
    {
        "key": "em_power_w",
        "label": "EM Power [W]",
        "aliases": [
            "Electromagnetic Power",
            "\u7535\u78c1\u529f\u7387_\u7535\u538b\u9650\u5236\u9644\u8fd1\u5de5\u4f5c\u70b9",
        ],
    },
    {
        "key": "shaft_speed_rpm",
        "label": "Shaft Speed [rpm]",
        "aliases": [
            "Shaft Speed",
            "\u8f6c\u901f[RPM]",
            "\u8f6c\u901f [RPM]",
        ],
    },
    {
        "key": "no_load_speed_rpm",
        "label": "No-load Speed [rpm]",
        "aliases": [
            "No load speed",
            "\u7a7a\u8f7d\u8f6c\u901f",
        ],
    },
]

METRIC_KEYS = [m["key"] for m in METRIC_DEFINITIONS]
METRIC_LABELS = {m["key"]: m["label"] for m in METRIC_DEFINITIONS}

# Phase 1 required metrics (must be present for a valid result).
REQUIRED_METRICS = ["tavg_nm", "ripple_pct", "efficiency_pct", "total_losses_w"]

# Section name aliases for priority ordering.
SECTION_PRIORITY = [
    "E-Magnetics",
    "\u7535\u78c1",
    "Drive",
    "\u9a71\u52a8",
    "Losses",
    "\u635f\u8017",
    "Materials",
    "\u6750\u6599",
    "Miscellaneous",
    "\u6742\u9879",
]


# ---------------------------------------------------------------------------
# Utility functions
# ---------------------------------------------------------------------------

def _normalize_name(name: str) -> str:
    """Normalize a field name for matching: unify brackets, remove
    whitespace, lowercase."""
    s = name
    s = s.replace("\uff08", "(").replace("\uff09", ")")
    s = "".join(s.split())
    return s.lower()


# Pre-normalize aliases for fast matching.
_METRIC_ALIAS_MAP: dict[str, str] = {}
for _m in METRIC_DEFINITIONS:
    for _alias in _m["aliases"]:
        _METRIC_ALIAS_MAP[_normalize_name(_alias)] = _m["key"]


def parse_export(path: Path) -> dict[str, dict[str, float]]:
    """Parse a Motor-CAD semicolon-delimited export file.

    Returns a dict of section_name -> {field_name: value}.
    Handles UTF-8, cp1252, gbk, and latin-1 encodings.
    """
    text = None
    for encoding in ("utf-8-sig", "gbk", "cp1252", "latin-1"):
        try:
            text = Path(path).read_text(encoding=encoding)
            break
        except UnicodeDecodeError:
            continue
    if text is None:
        return {}
    result: dict[str, dict[str, float]] = {}
    section = "(root)"
    for raw in text.splitlines():
        line = raw.strip()
        if not line:
            continue
        if ";" not in line:
            section = line
            result.setdefault(section, {})
            continue
        parts = line.split(";")
        try:
            field = parts[0].strip().strip('"')
            value = float(parts[1])
            result.setdefault(section, {})[field] = value
        except (IndexError, ValueError):
            continue
    return result


def pick_metric(results: dict[str, dict[str, float]], metric_key: str):
    """Extract a metric value from parsed export results.

    Tries exact alias match first (section priority order), then
    falls back to prefix-based fuzzy match across all sections.
    Returns the value (float) or None if not found.
    """
    wanted_aliases = set()
    for m in METRIC_DEFINITIONS:
        if m["key"] == metric_key:
            for alias in m["aliases"]:
                wanted_aliases.add(_normalize_name(alias))
            break
    if not wanted_aliases:
        return None

    # Phase 1: exact match in priority section order.
    for section_name in SECTION_PRIORITY:
        section = results.get(section_name)
        if section is None:
            continue
        for field, value in section.items():
            if _normalize_name(field) in wanted_aliases:
                return value

    # Phase 2: exact match across all sections.
    for section in results.values():
        for field, value in section.items():
            if _normalize_name(field) in wanted_aliases:
                return value

    # Phase 3: prefix fuzzy match.
    for alias_norm in wanted_aliases:
        for section in results.values():
            for field, value in section.items():
                field_norm = _normalize_name(field)
                if field_norm.startswith(alias_norm) or alias_norm.startswith(field_norm):
                    if len(field_norm) > 3:
                        return value
    return None


def extract_all_metrics(results: dict[str, dict[str, float]]) -> dict[str, float]:
    """Extract all defined metrics from parsed results."""
    out: dict[str, float] = {}
    for m in METRIC_DEFINITIONS:
        val = pick_metric(results, m["key"])
        if val is not None:
            out[m["key"]] = val
    return out


def check_required_metrics(metrics: dict[str, float]) -> tuple[bool, list[str]]:
    """Check that all Phase 1 required metrics are present.
    Returns (ok, list_of_missing_keys)."""
    missing = [k for k in REQUIRED_METRICS if k not in metrics]
    return (len(missing) == 0, missing)


# ---------------------------------------------------------------------------
# Motor-CAD solver
# ---------------------------------------------------------------------------

# Candidate Motor-CAD exe paths for fallback when MOTORCAD_ACTIVEX is unset.
_MOTORCAD_EXE_CANDIDATES = [
    r"D:\Program Files\ANSYS Inc\v261\motorcad\MotorCAD.exe",
    r"E:\Program Files\ANSYS Inc\v261\motorcad\MotorCAD.exe",
    r"C:\Program Files\ANSYS Inc\v261\motorcad\MotorCAD.exe",
]


class MotorCADSolver:
    """Manages a dedicated, foreground-visible Motor-CAD instance and
    runs electromagnetic simulations with parameter write-back verification
    and result extraction.

    No GUI dependencies. Callbacks (log_cb) allow the caller to receive
    log events.
    """

    def __init__(self, log_cb=None):
        self.log_cb = log_cb
        self.mc = None
        self.model_path = None

    def _log(self, text: str) -> None:
        if self.log_cb:
            self.log_cb(text)

    def connect(self) -> None:
        """Open a dedicated, foreground-visible Motor-CAD instance.

        Falls back to set_motorcad_exe if MOTORCAD_ACTIVEX is not set.
        """
        import ansys.motorcad.core as pymotorcad

        if not os.environ.get("MOTORCAD_ACTIVEX"):
            try:
                from ansys.motorcad.core import set_motorcad_exe
                for candidate in _MOTORCAD_EXE_CANDIDATES:
                    if os.path.exists(candidate):
                        set_motorcad_exe(candidate)
                        self._log(f"MOTORCAD_ACTIVEX not set; using {candidate}")
                        break
            except (ImportError, Exception):
                pass

        self._log("Opening a separate, visible Motor-CAD instance")
        self.mc = pymotorcad.MotorCAD(open_new_instance=True, keep_instance_open=False)
        self.mc.set_visible(True)
        self.mc.set_variable("MessageDisplayState", 2)
        self.mc.display_screen("Scripting")
        self._log("Motor-CAD connected and visible")

    def disconnect(self) -> None:
        """Reload the baseline model (if loaded) and close the instance."""
        if self.mc is not None:
            try:
                if self.model_path:
                    self.mc.load_from_file(str(self.model_path))
            except Exception:
                pass
            try:
                self.mc.quit()
            except Exception:
                pass
            self.mc = None
            self._log("Motor-CAD disconnected")

    def load_model(self, model_path: str | Path) -> None:
        """Load a .mot model file."""
        self.model_path = str(Path(model_path).resolve())
        self._log(f"Loading model: {self.model_path}")
        self.mc.load_from_file(self.model_path)
        self.mc.set_visible(True)
        self.mc.display_screen("Scripting")
        self._log("Model loaded")

    def write_and_verify(self, variable: str, value: float) -> None:
        """Write a variable and read it back. Raises RuntimeError on
        mismatch."""
        self.mc.set_variable(variable, value)
        applied = float(self.mc.get_variable(variable))
        if not math.isclose(applied, value, rel_tol=1e-8, abs_tol=1e-7):
            raise RuntimeError(
                f"Write verification failed for {variable}: "
                f"wrote {value}, read {applied}"
            )

    def write_parameters(self, params: dict[str, float]) -> None:
        """Write multiple parameters with write-back verification.
        Params are written in dict order."""
        for variable, value in params.items():
            self._log(f"  Setting {variable} = {value}")
            self.write_and_verify(variable, value)

    def run_magnetic(self) -> None:
        """Run the electromagnetic calculation."""
        self._log("Starting magnetic calculation...")
        start = time.time()
        self.mc.do_magnetic_calculation()
        elapsed = time.time() - start
        self._log(f"Magnetic calculation finished in {elapsed:.1f}s")

    def export_and_extract(self, output_dir: str | Path, tag: str = "") -> dict:
        """Export EMagnetic results and extract all metrics.

        Args:
            output_dir: directory to save raw export CSV.
            tag: optional tag for the filename (e.g. timestamp or case id).

        Returns:
            dict with metrics, raw_path, status, error.
        """
        result = {
            "metrics": {},
            "raw_path": "",
            "status": "FAILED",
            "error": "",
        }
        try:
            out_dir = Path(output_dir)
            out_dir.mkdir(parents=True, exist_ok=True)
            ts = datetime.now().strftime("%Y%m%d_%H%M%S_%f")[:-3]
            filename = f"result_{tag}_{ts}.csv" if tag else f"result_{ts}.csv"
            raw_path = out_dir / filename
            self.mc.export_results("EMagnetic", str(raw_path))
            self._log(f"Raw results exported: {raw_path}")

            parsed = parse_export(raw_path)
            metrics = extract_all_metrics(parsed)
            result["metrics"] = metrics
            result["raw_path"] = str(raw_path)

            ok, missing = check_required_metrics(metrics)
            if ok:
                result["status"] = "OK"
            else:
                result["status"] = "UNCERTAIN"
                result["error"] = f"Missing required metrics: {missing}"
                self._log(f"WARNING: {result['error']}")
        except Exception as exc:
            result["status"] = "FAILED"
            result["error"] = f"{type(exc).__name__}: {exc}"
            self._log(result["error"])
            self._log(traceback.format_exc())

        return result

    def run_single(
        self,
        model_path: str | Path,
        params: dict[str, float] | None = None,
        output_dir: str | Path = "output",
        tag: str = "",
    ) -> dict:
        """Run a complete single-point simulation.

        Steps: load model -> write params -> magnetic calc -> export+extract.

        Args:
            model_path: path to .mot baseline model.
            params: dict of variable_name -> value to set (optional).
            output_dir: directory for raw export.
            tag: tag for result filename.

        Returns:
            dict with metrics, status, error, raw_path, solve_time_s.
        """
        started = time.time()
        result = {
            "metrics": {},
            "status": "FAILED",
            "error": "",
            "raw_path": "",
            "solve_time_s": 0,
            "params": params or {},
        }
        try:
            self.load_model(model_path)
            if params:
                self.write_parameters(params)
            self.run_magnetic()
            ext = self.export_and_extract(output_dir, tag)
            result.update(ext)
        except Exception as exc:
            result["status"] = "FAILED"
            result["error"] = f"{type(exc).__name__}: {exc}"
            self._log(result["error"])
            self._log(traceback.format_exc())

        result["solve_time_s"] = round(time.time() - started, 1)
        return result
