"""Motor-CAD Parameter Scan Studio - PyQt5 desktop GUI (Chinese UI).

Modern industrial-style GUI for Motor-CAD torque ripple parameter
scans. Uses solver.py as the computation core (no GUI dependency).
Five-layer crash protection, QThread worker, bilingual metric
extraction, and per-point CSV/log persistence.

All source is ASCII; Chinese UI strings use \\uXXXX escapes.
"""

from __future__ import annotations

import json
import os
import sys
import time
import traceback
from datetime import datetime
from pathlib import Path

from PyQt5.QtCore import QObject, Qt, QThread, QTimer, pyqtSignal
from PyQt5.QtGui import QFont, QTextCursor
from PyQt5.QtWidgets import (
    QApplication,
    QComboBox,
    QDoubleSpinBox,
    QFileDialog,
    QFrame,
    QGridLayout,
    QGroupBox,
    QHBoxLayout,
    QLabel,
    QLineEdit,
    QMainWindow,
    QMessageBox,
    QProgressBar,
    QPushButton,
    QSpinBox,
    QStatusBar,
    QTableWidget,
    QTableWidgetItem,
    QTextEdit,
    QVBoxLayout,
    QWidget,
)

# Import solver core.
from solver import (
    METRIC_KEYS,
    METRIC_LABELS,
    MotorCADSolver,
    git_preflight,
    values_inclusive,
)

APP_NAME = "Motor-CAD \u53c2\u6570\u626b\u63cf\u5de5\u4f5c\u5ba4"
APP_VERSION = "1.0.0"
PROFILE_FILE = "scan_parameters.json"
DEFAULT_MODEL_NAME = "MARS-9S8P_SSSR_Halbach_PCB-V1.0.mot"

# Key metrics shown as large result cards.
CARD_METRICS = [
    ("ripple_pct", "%"),
    ("ripple_nm", "Nm"),
    ("tavg_nm", "Nm"),
    ("efficiency_pct", "%"),
    ("total_losses_w", "W"),
    ("back_emf_v", "V"),
]

# Chinese labels for metrics (overrides solver.py English labels).
METRIC_LABELS_ZH = {
    "ripple_pct": "\u8f6c\u77e9\u8109\u52a8 [%]",
    "ripple_nm": "\u8f6c\u77e9\u8109\u52a8 [Nm]",
    "tavg_nm": "\u5e73\u5747\u8f6c\u77e9 [Nm]",
    "efficiency_pct": "\u7cfb\u7edf\u6548\u7387 [%]",
    "back_emf_v": "\u53cd\u7535\u52a8\u52bf LL \u6709\u6548\u503c [V]",
    "back_emf_thd_pct": "\u53cd\u7535\u52a8\u52bf\u8c10\u6ce2 [%]",
    "total_losses_w": "\u603b\u635f\u8017 [W]",
    "copper_loss_w": "\u76f4\u6d41\u94dc\u8017 [W]",
    "magnet_loss_w": "\u78c1\u94a2\u635f\u8017 [W]",
    "iron_loss_w": "\u5b9a\u5b50\u94c1\u8017 [W]",
    "input_power_w": "\u8f93\u5165\u529f\u7387 [W]",
    "output_power_w": "\u8f93\u51fa\u529f\u7387 [W]",
    "em_power_w": "\u7535\u78c1\u529f\u7387 [W]",
    "shaft_speed_rpm": "\u8f74\u8f6c\u901f [rpm]",
    "no_load_speed_rpm": "\u7a7a\u8f7d\u8f6c\u901f [rpm]",
    "shaft_torque_nm": "\u8f74\u8f6c\u77e9 [Nm]",
}

# Global reference to keep long-lived objects alive (crash protection layer 5).
_GLOBAL_SOLVER_REF = None
_GLOBAL_WORKER_REF = None
_GLOBAL_THREAD_REF = None


# ---------------------------------------------------------------------------
# Crash protection layer 1: global exception hook
# ---------------------------------------------------------------------------

def _global_excepthook(exc_type, exc_value, exc_tb):
    """Write uncaught exceptions to a log file and show a dialog."""
    msg = "".join(traceback.format_exception(exc_type, exc_value, exc_tb))
    log_path = os.path.join(os.path.expanduser("~"), "motorcad_scan_studio_error.log")
    try:
        with open(log_path, "a", encoding="utf-8") as fh:
            fh.write(f"\n=== {time.strftime('%Y-%m-%d %H:%M:%S')} ===\n{msg}")
    except Exception:
        pass
    try:
        QMessageBox.critical(None, "\u5e94\u7528\u5f02\u5e38", str(exc_value))
    except Exception:
        pass


# ---------------------------------------------------------------------------
# QSS stylesheet - modern industrial style
# ---------------------------------------------------------------------------

STYLESHEET = """
QMainWindow, QWidget {
    background-color: #eef1f5;
    color: #1e293b;
    font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
    font-size: 10pt;
}
QFrame#paramPanel {
    background-color: #ffffff;
    border: 1px solid #e2e8f0;
    border-radius: 8px;
}
QGroupBox {
    background-color: #ffffff;
    border: 1px solid #e2e8f0;
    border-radius: 8px;
    margin-top: 16px;
    padding-top: 14px;
    font-weight: 600;
    font-size: 11pt;
}
QGroupBox::title {
    subcontrol-origin: margin;
    left: 14px;
    padding: 0 8px;
    background-color: #ffffff;
    color: #1e293b;
}
QPushButton {
    padding: 8px 20px;
    border-radius: 6px;
    background-color: #2563eb;
    color: #ffffff;
    border: 1px solid #2563eb;
    font-weight: 600;
    min-height: 32px;
}
QPushButton:hover { background-color: #1d4ed8; }
QPushButton:disabled {
    background-color: #94a3b8;
    border-color: #94a3b8;
}
QPushButton#stopBtn {
    background-color: #dc2626;
    border-color: #dc2626;
}
QPushButton#stopBtn:hover { background-color: #b91c1c; }
QPushButton#stopBtn:disabled {
    background-color: #94a3b8;
    border-color: #94a3b8;
}
QLineEdit, QComboBox, QSpinBox, QDoubleSpinBox {
    padding: 6px 10px;
    border: 1px solid #cbd5e1;
    border-radius: 6px;
    background-color: #f8fafc;
    min-height: 20px;
}
QLineEdit:focus, QComboBox:focus, QSpinBox:focus, QDoubleSpinBox:focus {
    border-color: #2563eb;
    background-color: #ffffff;
}
QLabel { color: #1e293b; }
QLabel#sectionLabel {
    font-size: 11pt;
    font-weight: 600;
    color: #1e293b;
}
QLabel#hintLabel {
    font-size: 9pt;
    color: #64748b;
}
QLabel#totalLabel {
    font-size: 10pt;
    font-weight: 600;
    color: #2563eb;
}
QFrame#resultCard {
    background-color: #ffffff;
    border: 1px solid #e2e8f0;
    border-radius: 10px;
}
QLabel#cardLabel {
    font-size: 9pt;
    color: #64748b;
    font-weight: 500;
    letter-spacing: 1px;
}
QLabel#cardValue {
    font-size: 22pt;
    font-weight: 700;
    color: #1e293b;
}
QLabel#cardUnit {
    font-size: 11pt;
    color: #64748b;
}
QTextEdit#logView {
    background-color: #f8fafc;
    color: #334155;
    border: 1px solid #e2e8f0;
    border-radius: 8px;
    padding: 10px;
    font-family: Consolas, "Cascadia Code", monospace;
    font-size: 9.5pt;
}
QTableWidget {
    background-color: #ffffff;
    border: 1px solid #e2e8f0;
    border-radius: 8px;
    gridline-color: #e2e8f0;
    font-size: 9.5pt;
}
QHeaderView::section {
    background-color: #f1f5f9;
    color: #475569;
    font-weight: 600;
    padding: 6px;
    border: none;
    border-bottom: 1px solid #e2e8f0;
}
QProgressBar {
    border: 1px solid #e2e8f0;
    border-radius: 6px;
    background-color: #f1f5f9;
    text-align: center;
    height: 20px;
}
QProgressBar::chunk {
    background-color: #2563eb;
    border-radius: 5px;
}
QStatusBar {
    background-color: #1e293b;
    color: #e2e8f0;
    font-size: 9.5pt;
}
QStatusBar QLabel { color: #e2e8f0; }
QScrollBar:vertical {
    width: 10px;
    background-color: #f1f5f9;
}
QScrollBar::handle:vertical {
    background-color: #cbd5e1;
    border-radius: 5px;
}
QScrollBar::handle:vertical:hover { background-color: #94a3b8; }
"""


# ---------------------------------------------------------------------------
# Result card widget
# ---------------------------------------------------------------------------

class ResultCard(QFrame):
    """A white card showing a metric label, large value, and unit."""

    def __init__(self, label: str, unit: str, parent=None):
        super().__init__(parent)
        self.setObjectName("resultCard")
        self.setFixedHeight(90)
        layout = QVBoxLayout(self)
        layout.setContentsMargins(14, 10, 14, 10)
        layout.setSpacing(2)

        self.label_widget = QLabel(label.upper())
        self.label_widget.setObjectName("cardLabel")
        layout.addWidget(self.label_widget)

        value_row = QHBoxLayout()
        value_row.setSpacing(4)
        self.value_widget = QLabel("--")
        self.value_widget.setObjectName("cardValue")
        value_row.addWidget(self.value_widget)
        self.unit_widget = QLabel(unit)
        self.unit_widget.setObjectName("cardUnit")
        value_row.addWidget(self.unit_widget)
        value_row.addStretch()
        layout.addLayout(value_row)

    def set_value(self, value):
        if value is None or value == "":
            self.value_widget.setText("--")
        else:
            try:
                self.value_widget.setText(f"{float(value):.4g}")
            except (ValueError, TypeError):
                self.value_widget.setText(str(value))


# ---------------------------------------------------------------------------
# Scan worker (QObject for QThread)
# ---------------------------------------------------------------------------

class ScanWorker(QObject):
    """Runs a Motor-CAD parameter scan in a background thread."""

    log = pyqtSignal(str)
    row = pyqtSignal(dict)
    progress = pyqtSignal(int, int)
    finished = pyqtSignal(dict)
    error = pyqtSignal(str)

    def __init__(self, config: dict):
        super().__init__()
        self.config = config
        self._solver = None

    def run(self):
        try:
            self._solver = MotorCADSolver(
                model_path=Path(self.config["model"]),
                log_cb=lambda m: self.log.emit(m),
                progress_cb=lambda i, t: self.progress.emit(i, t),
                row_cb=lambda r: self.row.emit(r),
            )
            self._solver.connect()

            result = self._solver.run_scan(
                points=self.config["points"],
                output_dir=Path(self.config["output_dir"]),
                torque_points=self.config.get("torque_points", 0),
                airgap_mesh=self.config.get("airgap_mesh", 0),
                scan_name=self.config.get("scan_name", "scan"),
                extra_csv_fields=self.config.get("extra_csv_fields", []),
            )
            self.finished.emit(result)
        except Exception as exc:
            self.error.emit(f"{type(exc).__name__}: {exc}\n{traceback.format_exc()}")
        finally:
            if self._solver is not None:
                try:
                    self._solver.disconnect()
                except Exception:
                    pass

    def cancel(self):
        if self._solver is not None:
            self._solver.cancel()


# ---------------------------------------------------------------------------
# Main window
# ---------------------------------------------------------------------------

class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle(f"{APP_NAME} v{APP_VERSION}")
        self.resize(1400, 900)
        self.setMinimumSize(1100, 700)

        self.profiles = self._load_profiles()
        self.worker = None
        self.thread = None
        self.run_start_time = None

        self._build_ui()
        self._apply_profile(next(iter(self.profiles)) if self.profiles else None)

    # -- utility --

    def _app_root(self) -> Path:
        if getattr(sys, "frozen", False):
            return Path(sys.executable).resolve().parent
        return Path(__file__).resolve().parent

    def _load_profiles(self) -> dict:
        path = self._app_root() / PROFILE_FILE
        if path.exists():
            try:
                return json.loads(path.read_text(encoding="ascii"))
            except (json.JSONDecodeError, OSError):
                pass
        return {}

    def _default_model_path(self) -> str:
        for base in (self._app_root(), *self._app_root().parents):
            candidate = base / DEFAULT_MODEL_NAME
            if candidate.exists():
                return str(candidate)
        return str(self._app_root() / DEFAULT_MODEL_NAME)

    # -- UI construction --

    def _build_ui(self):
        central = QWidget()
        self.setCentralWidget(central)
        main_layout = QHBoxLayout(central)
        main_layout.setContentsMargins(8, 8, 8, 4)
        main_layout.setSpacing(8)

        # Left: parameter panel.
        self.param_panel = self._build_param_panel()
        self.param_panel.setObjectName("paramPanel")
        self.param_panel.setFixedWidth(380)
        main_layout.addWidget(self.param_panel)

        # Right: results + log.
        right = QWidget()
        right_layout = QVBoxLayout(right)
        right_layout.setContentsMargins(0, 0, 0, 0)
        right_layout.setSpacing(8)
        right_layout.addWidget(self._build_results_area(), stretch=3)
        right_layout.addWidget(self._build_log_area(), stretch=2)
        main_layout.addWidget(right, stretch=1)

        # Status bar.
        self.status_bar = QStatusBar()
        self.setStatusBar(self.status_bar)
        self.status_label = QLabel("\u5c31\u7eea")
        self.commit_label = QLabel("Git: --")
        self.time_label = QLabel("\u6301\u7eed\u65f6\u95f4: --")
        self.status_bar.addWidget(self.status_label, 1)
        self.status_bar.addPermanentWidget(self.commit_label)
        self.status_bar.addPermanentWidget(self.time_label)

        # Check git on startup.
        QTimer.singleShot(100, self._refresh_git_status)

    def _build_param_panel(self) -> QWidget:
        panel = QFrame()
        layout = QVBoxLayout(panel)
        layout.setContentsMargins(12, 12, 12, 12)
        layout.setSpacing(10)

        # Model group.
        model_group = QGroupBox("\u6a21\u578b")
        model_layout = QVBoxLayout(model_group)
        model_row = QHBoxLayout()
        self.model_edit = QLineEdit(self._default_model_path())
        model_row.addWidget(self.model_edit, 1)
        browse_btn = QPushButton("\u6d4f\u89c8")
        browse_btn.setFixedWidth(70)
        browse_btn.clicked.connect(self._browse_model)
        model_row.addWidget(browse_btn)
        model_layout.addLayout(model_row)
        layout.addWidget(model_group)

        # Profile group.
        profile_group = QGroupBox("\u626b\u63cf\u914d\u7f6e")
        profile_layout = QVBoxLayout(profile_group)
        self.profile_combo = QComboBox()
        self.profile_combo.addItems(list(self.profiles.keys()))
        self.profile_combo.currentTextChanged.connect(self._apply_profile)
        profile_layout.addWidget(self.profile_combo)
        layout.addWidget(profile_group)

        # Parameter 1 group.
        p1_group = QGroupBox("\u53c2\u65701\uff1a\u4e2d\u592e\u78c1\u5757\u5f27\u89d2 [ED]")
        p1_layout = QGridLayout(p1_group)
        p1_layout.addWidget(QLabel("\u8d77\u59cb"), 0, 0)
        self.p1_start = QDoubleSpinBox()
        self.p1_start.setRange(0, 180)
        self.p1_start.setDecimals(1)
        self.p1_start.setSingleStep(1)
        p1_layout.addWidget(self.p1_start, 0, 1)
        p1_layout.addWidget(QLabel("\u7ec8\u6b62"), 0, 2)
        self.p1_stop = QDoubleSpinBox()
        self.p1_stop.setRange(0, 180)
        self.p1_stop.setDecimals(1)
        self.p1_stop.setSingleStep(1)
        p1_layout.addWidget(self.p1_stop, 0, 3)
        p1_layout.addWidget(QLabel("\u6b65\u957f"), 1, 0)
        self.p1_step = QDoubleSpinBox()
        self.p1_step.setRange(0.1, 50)
        self.p1_step.setDecimals(1)
        self.p1_step.setSingleStep(0.5)
        p1_layout.addWidget(self.p1_step, 1, 1)
        for spin in (self.p1_start, self.p1_stop, self.p1_step):
            spin.valueChanged.connect(self._update_total)
        layout.addWidget(p1_group)

        # Parameter 2 group.
        p2_group = QGroupBox("\u53c2\u65702\uff1a\u69fd\u53e3\u5bbd\u5ea6 [mm]")
        p2_layout = QGridLayout(p2_group)
        p2_layout.addWidget(QLabel("\u8d77\u59cb"), 0, 0)
        self.p2_start = QDoubleSpinBox()
        self.p2_start.setRange(0.2, 20)
        self.p2_start.setDecimals(1)
        self.p2_start.setSingleStep(0.2)
        p2_layout.addWidget(self.p2_start, 0, 1)
        p2_layout.addWidget(QLabel("\u7ec8\u6b62"), 0, 2)
        self.p2_stop = QDoubleSpinBox()
        self.p2_stop.setRange(0.2, 20)
        self.p2_stop.setDecimals(1)
        self.p2_stop.setSingleStep(0.2)
        p2_layout.addWidget(self.p2_stop, 0, 3)
        p2_layout.addWidget(QLabel("\u6b65\u957f"), 1, 0)
        self.p2_step = QDoubleSpinBox()
        self.p2_step.setRange(0.1, 10)
        self.p2_step.setDecimals(1)
        self.p2_step.setSingleStep(0.2)
        p2_layout.addWidget(self.p2_step, 1, 1)
        for spin in (self.p2_start, self.p2_stop, self.p2_step):
            spin.valueChanged.connect(self._update_total)
        layout.addWidget(p2_group)

        # Linked copper width group.
        link_group = QGroupBox("\u8054\u52a8\u94dc\u7ebf\u5bbd\u5ea6")
        link_layout = QGridLayout(link_group)
        link_layout.addWidget(QLabel("\u6bcf\u4fa7\u5bfc\u4f53\u6570"), 0, 0)
        self.conductor_count = QSpinBox()
        self.conductor_count.setRange(1, 10)
        link_layout.addWidget(self.conductor_count, 0, 1)
        link_layout.addWidget(QLabel("\u95f4\u9699 [mm]"), 0, 2)
        self.clearance = QDoubleSpinBox()
        self.clearance.setRange(0, 5)
        self.clearance.setDecimals(2)
        self.clearance.setSingleStep(0.1)
        link_layout.addWidget(self.clearance, 0, 3)
        formula_label = QLabel(
            "\u94dc\u7ebf\u5bbd = (\u69fd\u53e3\u5bbd - \u95f4\u9699) / 2 / \u5bfc\u4f53\u6570"
        )
        formula_label.setObjectName("hintLabel")
        link_layout.addWidget(formula_label, 1, 0, 1, 4)
        layout.addWidget(link_group)

        # Solver settings group.
        solver_group = QGroupBox("\u6c42\u89e3\u8bbe\u7f6e")
        solver_layout = QGridLayout(solver_group)
        solver_layout.addWidget(QLabel("\u8f6c\u77e9\u91c7\u6837\u70b9"), 0, 0)
        self.torque_points = QSpinBox()
        self.torque_points.setRange(0, 500)
        self.torque_points.setValue(30)
        self.torque_points.setSpecialValueText("model default")
        solver_layout.addWidget(self.torque_points, 0, 1)
        solver_layout.addWidget(QLabel("\u6c14\u9699\u7f51\u683c"), 0, 2)
        self.airgap_mesh = QSpinBox()
        self.airgap_mesh.setRange(0, 5000)
        self.airgap_mesh.setValue(840)
        self.airgap_mesh.setSpecialValueText("model default")
        solver_layout.addWidget(self.airgap_mesh, 0, 3)
        layout.addWidget(solver_group)

        # Total and buttons.
        self.total_label = QLabel("\u603b\u7ec4\u5408\u6570: 0")
        self.total_label.setObjectName("totalLabel")
        layout.addWidget(self.total_label)

        self.progress_bar = QProgressBar()
        self.progress_bar.setValue(0)
        layout.addWidget(self.progress_bar)

        btn_row = QHBoxLayout()
        self.start_btn = QPushButton("\u5f00\u59cb\u626b\u63cf")
        self.start_btn.clicked.connect(self._start_scan)
        btn_row.addWidget(self.start_btn)
        self.stop_btn = QPushButton("\u5f53\u524d\u70b9\u540e\u505c\u6b62")
        self.stop_btn.setObjectName("stopBtn")
        self.stop_btn.setEnabled(False)
        self.stop_btn.clicked.connect(self._stop_scan)
        btn_row.addWidget(self.stop_btn)
        layout.addLayout(btn_row)

        layout.addStretch()
        return panel

    def _build_results_area(self) -> QWidget:
        container = QWidget()
        layout = QVBoxLayout(container)
        layout.setContentsMargins(0, 0, 0, 0)
        layout.setSpacing(8)

        # Result cards.
        cards_header = QLabel("\u6700\u65b0\u70b9\u7ed3\u679c")
        cards_header.setObjectName("sectionLabel")
        layout.addWidget(cards_header)

        cards_widget = QWidget()
        cards_layout = QGridLayout(cards_widget)
        cards_layout.setContentsMargins(0, 0, 0, 0)
        cards_layout.setSpacing(8)
        self.cards = {}
        for idx, (key, unit) in enumerate(CARD_METRICS):
            label = METRIC_LABELS_ZH.get(key, METRIC_LABELS.get(key, key))
            card = ResultCard(label, unit)
            row, col = divmod(idx, 3)
            cards_layout.addWidget(card, row, col)
            self.cards[key] = card
        cards_layout.setColumnStretch(0, 1)
        cards_layout.setColumnStretch(1, 1)
        cards_layout.setColumnStretch(2, 1)
        layout.addWidget(cards_widget)

        # Full metrics table.
        table_header = QLabel("\u5168\u90e8\u6307\u6807")
        table_header.setObjectName("sectionLabel")
        layout.addWidget(table_header)

        self.metrics_table = QTableWidget()
        self.metrics_table.setColumnCount(2)
        self.metrics_table.setHorizontalHeaderLabels(
            ["\u6307\u6807", "\u6570\u503c"]
        )
        self.metrics_table.verticalHeader().setVisible(False)
        self.metrics_table.setRowCount(len(METRIC_KEYS))
        for row_idx, key in enumerate(METRIC_KEYS):
            label = METRIC_LABELS_ZH.get(key, METRIC_LABELS.get(key, key))
            self.metrics_table.setItem(row_idx, 0, QTableWidgetItem(label))
            self.metrics_table.setItem(row_idx, 1, QTableWidgetItem("--"))
        self.metrics_table.horizontalHeader().setStretchLastSection(True)
        layout.addWidget(self.metrics_table, stretch=1)

        return container

    def _build_log_area(self) -> QWidget:
        container = QWidget()
        layout = QVBoxLayout(container)
        layout.setContentsMargins(0, 0, 0, 0)
        layout.setSpacing(4)

        header = QLabel("\u7a0b\u5e8f\u65e5\u5fd7")
        header.setObjectName("sectionLabel")
        layout.addWidget(header)

        self.log_view = QTextEdit()
        self.log_view.setObjectName("logView")
        self.log_view.setReadOnly(True)
        layout.addWidget(self.log_view)

        return container

    # -- log helpers (HTML colored) --

    def _append_log(self, text: str, color: str = "#334155"):
        try:
            escaped = text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
            html = f'<span style="color:{color};">{escaped}</span>'
            self.log_view.append(html)
            self.log_view.moveCursor(QTextCursor.End)
        except Exception:
            pass

    def _classify_log(self, text: str) -> str:
        lower = text.lower()
        if "error" in lower or "failed" in lower or "traceback" in lower:
            return "#dc2626"
        if "warning" in lower or "cancel" in lower:
            return "#d97706"
        if "complete" in lower or "ok" in lower or "ended" in lower:
            return "#16a34a"
        if "opening" in lower or "starting" in lower or "run directory" in lower:
            return "#475569"
        if "result" in lower or "csv" in lower or ".mot" in lower or "directory" in lower:
            return "#7c3aed"
        return "#334155"

    # -- profile --

    def _apply_profile(self, profile_name: str | None):
        try:
            if not profile_name or profile_name not in self.profiles:
                return
            profile = self.profiles[profile_name]
            p1 = profile.get("parameter_1", {})
            p2 = profile.get("parameter_2", {})
            linked = profile.get("linked_value", {})
            self.p1_start.setValue(float(p1.get("default_start", 90)))
            self.p1_stop.setValue(float(p1.get("default_stop", 110)))
            self.p1_step.setValue(float(p1.get("default_step", 1)))
            self.p2_start.setValue(float(p2.get("default_start", 5.4)))
            self.p2_stop.setValue(float(p2.get("default_stop", 7.4)))
            self.p2_step.setValue(float(p2.get("default_step", 0.2)))
            self.conductor_count.setValue(int(linked.get("default_conductor_count", 1)))
            self.clearance.setValue(float(linked.get("default_clearance", 0.2)))
            self._update_total()
        except Exception as exc:
            self._append_log(f"Profile apply error: {exc}", "#dc2626")

    def _update_total(self):
        try:
            p1_vals = values_inclusive(
                self.p1_start.value(), self.p1_stop.value(), self.p1_step.value()
            )
            p2_vals = values_inclusive(
                self.p2_start.value(), self.p2_stop.value(), self.p2_step.value()
            )
            total = len(p1_vals) * len(p2_vals)
            self.total_label.setText(
                f"\u603b\u7ec4\u5408\u6570: {len(p1_vals)} x {len(p2_vals)} = {total}"
            )
        except ValueError:
            self.total_label.setText("\u603b\u7ec4\u5408\u6570: invalid range")

    # -- actions --

    def _browse_model(self):
        try:
            path, _ = QFileDialog.getOpenFileName(
                self,
                "\u9009\u62e9 Motor-CAD \u6a21\u578b",
                "",
                "Motor-CAD models (*.mot);;All files (*.*)",
            )
            if path:
                self.model_edit.setText(path)
        except Exception as exc:
            self._append_log(f"Browse error: {exc}", "#dc2626")

    def _refresh_git_status(self):
        try:
            ok, commit = git_preflight(self._app_root())
            if ok:
                self.commit_label.setText(f"Git: {commit}")
            else:
                self.commit_label.setText(f"Git: {commit}")
        except Exception:
            self.commit_label.setText("Git: unavailable")

    def _start_scan(self):
        try:
            # Git preflight.
            ok, commit = git_preflight(self._app_root())
            if not ok:
                QMessageBox.warning(self, "Git \u9884\u68c0\u5931\u8d25", commit)
                return

            # Validate model.
            model_path = Path(self.model_edit.text()).resolve()
            if not model_path.exists():
                QMessageBox.warning(
                    self,
                    "\u6a21\u578b\u672a\u627e\u5230",
                    f"\u6a21\u578b\u4e0d\u5b58\u5728\uff1a\n{model_path}",
                )
                return

            # Build points.
            p1_vals = values_inclusive(
                self.p1_start.value(), self.p1_stop.value(), self.p1_step.value()
            )
            p2_vals = values_inclusive(
                self.p2_start.value(), self.p2_stop.value(), self.p2_step.value()
            )
            clearance = self.clearance.value()
            conductors = self.conductor_count.value()

            # Range checks.
            profile_name = self.profile_combo.currentText()
            profile = self.profiles.get(profile_name, {})
            p1_cfg = profile.get("parameter_1", {})
            p2_cfg = profile.get("parameter_2", {})
            if p1_cfg:
                if p1_vals[0] < p1_cfg.get("minimum", 0) or p1_vals[-1] > p1_cfg.get("maximum", 180):
                    QMessageBox.warning(
                        self, "\u8303\u56f4\u9519\u8bef",
                        f"\u53c2\u65701\u5fc5\u987b\u5728\u8303\u56f4\u5185 "
                        f"{p1_cfg.get('minimum', 0)}..{p1_cfg.get('maximum', 180)}",
                    )
                    return
            if p2_cfg:
                if p2_vals[0] < p2_cfg.get("minimum", 0.2) or p2_vals[-1] > p2_cfg.get("maximum", 20):
                    QMessageBox.warning(
                        self, "\u8303\u56f4\u9519\u8bef",
                        f"\u53c2\u65702\u5fc5\u987b\u5728\u8303\u56f4\u5185 "
                        f"{p2_cfg.get('minimum', 0.2)}..{p2_cfg.get('maximum', 20)}",
                    )
                    return
            if clearance >= p2_vals[0]:
                QMessageBox.warning(
                    self, "\u8303\u56f4\u9519\u8bef",
                    "\u95f4\u9699\u5fc5\u987b\u5c0f\u4e8e\u6bcf\u4e2a\u69fd\u53e3\u5bbd\u5ea6",
                )
                return

            total = len(p1_vals) * len(p2_vals)
            est_hours = total * 180.0 / 3600.0
            confirm = QMessageBox.question(
                self,
                "\u786e\u8ba4\u626b\u63cf",
                f"\u53c2\u65701\u6c34\u5e73\u6570: {len(p1_vals)}\n"
                f"\u53c2\u65702\u6c34\u5e73\u6570: {len(p2_vals)}\n"
                f"\u603b\u4eff\u771f\u6570: {total}\n"
                f"\u9884\u8ba1\u8017\u65f6: {est_hours:.1f} \u5c0f\u65f6\n\n"
                f"\u5f00\u59cb\u626b\u63cf\uff1f",
                QMessageBox.Yes | QMessageBox.No,
            )
            if confirm != QMessageBox.Yes:
                return

            # Build point list.
            points = []
            for slot_opening in p2_vals:
                copper_width = round((slot_opening - clearance) / 2.0 / conductors, 10)
                for magnet_arc in p1_vals:
                    points.append({
                        "writes": [
                            ("Slot_Opening", slot_opening),
                            ("Copper_Width", copper_width),
                            ("MagnetCentralArc_HalbachRing", magnet_arc),
                        ],
                        "central_mag_arc_ed": magnet_arc,
                        "slot_opening_mm": slot_opening,
                        "copper_width_mm": copper_width,
                    })

            config = {
                "model": str(model_path),
                "points": points,
                "output_dir": str(self._app_root() / "runs"),
                "torque_points": self.torque_points.value(),
                "airgap_mesh": self.airgap_mesh.value(),
                "scan_name": "two_parameter_scan",
                "extra_csv_fields": ["central_mag_arc_ed", "slot_opening_mm", "copper_width_mm"],
                "git_commit": commit,
            }

            # Reset UI.
            self.log_view.clear()
            self.progress_bar.setMaximum(total)
            self.progress_bar.setValue(0)
            self.start_btn.setEnabled(False)
            self.stop_btn.setEnabled(True)
            self.status_label.setText("\u8fd0\u884c\u4e2d...")
            self.run_start_time = time.time()
            self._append_log(
                f"Starting scan: {total} points, commit {commit}", "#2563eb"
            )

            # Start worker thread.
            self.thread = QThread()
            self.worker = ScanWorker(config)
            self.worker.moveToThread(self.thread)
            self.thread.started.connect(self.worker.run)
            self.worker.log.connect(self._on_worker_log)
            self.worker.row.connect(self._on_worker_row)
            self.worker.progress.connect(self._on_worker_progress)
            self.worker.finished.connect(self._on_worker_finished)
            self.worker.error.connect(self._on_worker_error)
            self.worker.finished.connect(self.thread.quit)
            self.worker.finished.connect(self.worker.deleteLater)
            self.thread.finished.connect(self.thread.deleteLater)
            self.thread.start()

            # Keep global references (crash protection layer 5).
            global _GLOBAL_WORKER_REF, _GLOBAL_THREAD_REF
            _GLOBAL_WORKER_REF = self.worker
            _GLOBAL_THREAD_REF = self.thread

        except Exception as exc:
            self._append_log(
                f"Start error: {exc}\n{traceback.format_exc()}", "#dc2626"
            )
            QMessageBox.critical(self, "\u542f\u52a8\u5931\u8d25", str(exc))

    def _stop_scan(self):
        try:
            if self.worker is not None:
                self.worker.cancel()
                self.status_label.setText(
                    "\u505c\u6b62\u8bf7\u6c42\uff1a\u7b49\u5f85\u5f53\u524d\u70b9\u5b8c\u6210"
                )
                self._append_log(
                    "Stop requested after current point", "#d97706"
                )
        except Exception as exc:
            self._append_log(f"Stop error: {exc}", "#dc2626")

    # -- worker signal handlers (each wrapped in try-except, layer 4) --

    def _on_worker_log(self, text: str):
        try:
            color = self._classify_log(text)
            self._append_log(text, color)
        except Exception:
            pass

    def _on_worker_row(self, row: dict):
        try:
            # Update cards.
            for key, _ in CARD_METRICS:
                self.cards[key].set_value(row.get(key, ""))
            # Update table.
            for row_idx, key in enumerate(METRIC_KEYS):
                val = row.get(key, "--")
                if val == "" or val is None:
                    val = "--"
                else:
                    try:
                        val = f"{float(val):.6g}"
                    except (ValueError, TypeError):
                        val = str(val)
                self.metrics_table.item(row_idx, 1).setText(val)
        except Exception as exc:
            self._append_log(f"Row update error: {exc}", "#dc2626")

    def _on_worker_progress(self, current: int, total: int):
        try:
            self.progress_bar.setValue(current)
            elapsed = time.time() - self.run_start_time if self.run_start_time else 0
            self.time_label.setText(
                f"\u6301\u7eed\u65f6\u95f4: {elapsed:.0f}s | {current}/{total}"
            )
            self.status_label.setText(f"\u8fd0\u884c\u4e2d {current}/{total}")
        except Exception:
            pass

    def _on_worker_finished(self, result: dict):
        try:
            summary = result.get("summary", {})
            ok = summary.get("ok", 0)
            failed = summary.get("failed", 0)
            elapsed = time.time() - self.run_start_time if self.run_start_time else 0
            self.status_label.setText(
                f"\u5b8c\u6210: {ok} OK, {failed} \u5931\u8d25"
            )
            self.time_label.setText(f"\u6301\u7eed\u65f6\u95f4: {elapsed:.0f}s")
            self._append_log(
                f"Scan complete: {ok} OK, {failed} failed. "
                f"Results: {result.get('csv_path', '')}",
                "#16a34a",
            )
            QMessageBox.information(
                self,
                "\u626b\u63cf\u5b8c\u6210",
                f"\u7ed3\u679c\u4fdd\u5b58\u5728\uff1a\n{result.get('run_dir', '')}",
            )
        except Exception as exc:
            self._append_log(f"Finish error: {exc}", "#dc2626")
        finally:
            self._reset_controls()
            self._cleanup_thread()

    def _on_worker_error(self, message: str):
        try:
            self.status_label.setText("\u5931\u8d25")
            self._append_log(message, "#dc2626")
            QMessageBox.critical(self, "\u626b\u63cf\u5931\u8d25", message)
        except Exception:
            pass
        finally:
            self._reset_controls()
            self._cleanup_thread()

    def _reset_controls(self):
        try:
            self.start_btn.setEnabled(True)
            self.stop_btn.setEnabled(False)
        except Exception:
            pass

    def _cleanup_thread(self):
        # Delayed cleanup to avoid race with deleteLater (crash protection).
        try:
            QTimer.singleShot(200, self._do_cleanup)
        except Exception:
            pass

    def _do_cleanup(self):
        try:
            global _GLOBAL_WORKER_REF, _GLOBAL_THREAD_REF
            _GLOBAL_WORKER_REF = None
            _GLOBAL_THREAD_REF = None
            self.worker = None
            self.thread = None
        except Exception:
            pass


# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------

def main() -> int:
    # Crash protection layer 1.
    sys.excepthook = _global_excepthook

    app = QApplication(sys.argv)
    app.setStyle("Fusion")
    app.setStyleSheet(STYLESHEET)

    # Crash protection layer 2: don't quit when last window closes.
    app.setQuitOnLastWindowClosed(False)

    # Default font.
    font = QFont("Segoe UI", 10)
    app.setFont(font)

    window = MainWindow()
    window.show()

    return app.exec_()


if __name__ == "__main__":
    raise SystemExit(main())
