"""OBDash GUI -- vehicle-agnostic scanner shell.

Menu bar: File (captures) | Profile (vehicle profiles) | View | Help.
Toolbar:  port / baud / mock / connect + per-profile preset buttons.
Left:     PID browser (live values, confidence badges, checkboxes).
Center:   stacked Graph view (pyqtgraph overlay) and Table view.

Vehicle data comes entirely from the active JSON profile (profiles/*.json),
so the app is not Ford-specific -- load another profile to scan another truck.
Runs against MockLink with no hardware (tick "Mock" + Connect).
"""
import os
import shutil

from PySide6 import QtCore, QtGui, QtWidgets
import pyqtgraph as pg

from obdcore import (list_profiles, profiles_dir, save_profile, load_profile,
                     export_csv, replay_csv, TimeSeriesStore)
from .controller import Controller
from .widgets import MultiAxisPlot, SinglePlot, GaugeGrid

PLOT_WINDOW_S = 60.0
REFRESH_MS = 100
CURVE_COLORS = [
    "#e6194B", "#3cb44b", "#4363d8", "#f58231", "#911eb4", "#42d4f4",
    "#f032e6", "#bfef45", "#fabed4", "#469990", "#9A6324", "#ffe119",
    "#000075", "#a9a9a9", "#800000",
]
GROUP_ORDER = ["fuel", "ficm", "air", "engine", "driveline", "power", "misc"]
GROUP_LABEL = {"fuel": "Fuel / Injection", "ficm": "FICM", "air": "Air / Boost",
               "engine": "Engine", "driveline": "Driveline", "power": "Power",
               "misc": "Other"}
CONF_BADGE = {"verified": "", "doc": "  [DOC]", "tentative": "  [?]"}
THEMES = {"dark": ("#111", "#ccc"), "light": ("#fafafa", "#222")}


class MainWindow(QtWidgets.QMainWindow):
    def __init__(self):
        super().__init__()
        self.ctl = Controller()
        self.curves = {}
        self._color_i = 0
        self._theme = "dark"
        self._temp_f = False            # display temperatures in F instead of C

        self._build_menubar()
        self._build_connection_bar()
        self._build_pid_browser()
        self._build_center()
        self._build_statusbar()
        self._refresh_title()
        self._rebuild_for_profile()

        self.timer = QtCore.QTimer(self)
        self.timer.timeout.connect(self._tick)
        self.timer.setInterval(REFRESH_MS)

    # ---------- menus ----------
    def _build_menubar(self):
        mb = self.menuBar()

        filem = mb.addMenu("&File")
        self._act(filem, "New Capture", self._new_capture, "Clear the current data buffers")
        filem.addSeparator()
        self.rec_act = self._act(filem, "Start Recording…", self._toggle_record,
                                 "Stream samples to a CSV as they arrive")
        self._act(filem, "Export Capture As… (CSV)", self._export_capture,
                  "Write the current in-memory buffers to a CSV")
        self._act(filem, "Open Capture (Replay)…", self._open_capture,
                  "Load a recorded CSV and plot it (disconnect first)")
        filem.addSeparator()
        self._act(filem, "Quit", self.close, shortcut="Ctrl+Q")

        self.profm = mb.addMenu("&Profile")
        self._rebuild_profile_menu()

        diagm = mb.addMenu("&Diagnostics")
        self.read_dtc_act = self._act(diagm, "Read Codes", self._read_codes,
                                      "Read stored / pending / permanent trouble codes")
        self.clear_dtc_act = self._act(diagm, "Clear Codes…", self._clear_codes,
                                       "Erase stored codes + freeze frame (mode 04)")
        diagm.addSeparator()
        self._act(diagm, "Freeze Frame", self._freeze_frame,
                  "Sensor snapshot captured when a code set (mode 02)")
        self._act(diagm, "Emissions Readiness", self._readiness,
                  "I/M readiness monitors + MIL (will it pass inspection?)")
        self._act(diagm, "Vehicle Info (VIN)", self._vehicle_info,
                  "VIN, calibration IDs, ECU name (mode 09)")
        diagm.addSeparator()
        self._act(diagm, "Service & Bi-directional…", self._service_actions,
                  "Actuator tests, service resets, and other profile-defined functions")

        viewm = mb.addMenu("&View")
        self.view_graph = self._act(viewm, "Graph View", lambda: self._set_view(0),
                                    checkable=True)
        self.view_table = self._act(viewm, "Table View", lambda: self._set_view(1),
                                    checkable=True)
        self.view_graph.setChecked(True)
        self.view_gauge = self._act(viewm, "Gauge View", lambda: self._set_view(2),
                                    checkable=True)
        self.view_trip = self._act(viewm, "Trip / Performance", lambda: self._set_view(3),
                                   checkable=True)
        viewm.addSeparator()
        self.show_pids = self._act(viewm, "Show PID Panel", self._toggle_pid_dock,
                                   checkable=True)
        self.show_pids.setChecked(True)
        self.norm_act = self._act(viewm, "Normalize Graph (% of range)",
                                  self._sync_norm_from_menu, checkable=True)
        viewm.addSeparator()
        self.theme_act = self._act(viewm, "Light Theme", self._toggle_theme, checkable=True)

        unitsm = mb.addMenu("&Units")
        self._temp_grp = QtGui.QActionGroup(self); self._temp_grp.setExclusive(True)
        self.temp_c_act = self._act(unitsm, "Temperature: °C", lambda: self._set_temp(False),
                                    checkable=True)
        self.temp_f_act = self._act(unitsm, "Temperature: °F", lambda: self._set_temp(True),
                                    checkable=True)
        self._temp_grp.addAction(self.temp_c_act); self._temp_grp.addAction(self.temp_f_act)
        self.temp_c_act.setChecked(True)

        helpm = mb.addMenu("&Help")
        self._act(helpm, "About OBDash", self._about)
        self._act(helpm, "PID Confidence Legend", self._legend)
        self._act(helpm, "Active Profile Info", self._profile_info)

    def _act(self, menu, text, slot, tip="", checkable=False, shortcut=None):
        a = QtGui.QAction(text, self)
        a.triggered.connect(lambda _=False: slot())
        if tip:
            a.setStatusTip(tip)
        a.setCheckable(checkable)
        if shortcut:
            a.setShortcut(shortcut)
        menu.addAction(a)
        return a

    def _rebuild_profile_menu(self):
        self.profm.clear()
        self._profile_group = QtGui.QActionGroup(self)
        self._profile_group.setExclusive(True)
        active = getattr(self.ctl.profile, "path", None)
        for path, meta in list_profiles():
            a = QtGui.QAction(meta.get("name", os.path.basename(path)), self)
            a.setCheckable(True)
            a.setChecked(active and os.path.abspath(path) == os.path.abspath(active))
            a.triggered.connect(lambda _=False, p=path: self._load_profile(p))
            self._profile_group.addAction(a)
            self.profm.addAction(a)
        self.profm.addSeparator()
        self._act(self.profm, "Load Profile…", self._load_profile_dialog)
        self._act(self.profm, "Import Profile…", self._import_profile)
        self._act(self.profm, "Reload Active", self._reload_profile)
        self._act(self.profm, "Edit Active Profile (JSON)…", self._edit_profile)
        self._act(self.profm, "Export Active Profile As…", self._export_profile)

    # ---------- toolbar ----------
    def _build_connection_bar(self):
        tb = QtWidgets.QToolBar("Connection")
        tb.setMovable(False)
        self.addToolBar(tb)

        self.conn_kind = QtWidgets.QComboBox()
        self.conn_kind.addItems(["Serial / USB / BT-SPP", "WiFi", "Bluetooth LE"])
        self.conn_kind.currentIndexChanged.connect(self._conn_kind_changed)
        tb.addWidget(self.conn_kind)

        # serial inputs
        self._serial_w = []
        self._serial_w.append(tb.addWidget(QtWidgets.QLabel(" Port ")))
        self.port_combo = QtWidgets.QComboBox(); self.port_combo.setMinimumWidth(180)
        self._refresh_ports()
        self._serial_w.append(tb.addWidget(self.port_combo))
        b = QtWidgets.QToolButton(); b.setText("↻"); b.clicked.connect(self._refresh_ports)
        self._serial_w.append(tb.addWidget(b))
        self._serial_w.append(tb.addWidget(QtWidgets.QLabel(" Baud ")))
        self.baud_edit = QtWidgets.QLineEdit("38400"); self.baud_edit.setFixedWidth(64)
        self._serial_w.append(tb.addWidget(self.baud_edit))

        # wifi inputs
        self._wifi_w = []
        self._wifi_w.append(tb.addWidget(QtWidgets.QLabel(" Host ")))
        self.host_edit = QtWidgets.QLineEdit("192.168.0.10"); self.host_edit.setFixedWidth(120)
        self._wifi_w.append(tb.addWidget(self.host_edit))
        self._wifi_w.append(tb.addWidget(QtWidgets.QLabel(" : ")))
        self.tcpport_edit = QtWidgets.QLineEdit("35000"); self.tcpport_edit.setFixedWidth(56)
        self._wifi_w.append(tb.addWidget(self.tcpport_edit))

        # ble inputs
        self._ble_w = []
        self._ble_w.append(tb.addWidget(QtWidgets.QLabel(" Device ")))
        self.ble_combo = QtWidgets.QComboBox(); self.ble_combo.setMinimumWidth(200)
        self.ble_combo.setEditable(True)
        self._ble_w.append(tb.addWidget(self.ble_combo))
        self.ble_scan_btn = QtWidgets.QToolButton(); self.ble_scan_btn.setText("Scan")
        self.ble_scan_btn.clicked.connect(self._ble_scan)
        self._ble_w.append(tb.addWidget(self.ble_scan_btn))

        tb.addSeparator()
        self.mock_chk = QtWidgets.QCheckBox("Mock"); tb.addWidget(self.mock_chk)
        self.connect_btn = QtWidgets.QPushButton("Connect")
        self.connect_btn.clicked.connect(self._toggle_connect)
        tb.addWidget(self.connect_btn)
        tb.addSeparator()
        self._preset_tb = tb
        self._preset_sep = tb.addSeparator()
        self._preset_buttons = []
        self._conn_kind_changed()

    def _conn_kind_changed(self):
        k = self.conn_kind.currentIndex()
        for a in self._serial_w:
            a.setVisible(k == 0)
        for a in self._wifi_w:
            a.setVisible(k == 1)
        for a in self._ble_w:
            a.setVisible(k == 2)

    def _ble_scan(self):
        try:
            from obdcore.transport import ble_scan
        except Exception:
            QtWidgets.QMessageBox.information(self, "Bluetooth LE",
                "BLE needs the 'bleak' package (pip install bleak). Classic-Bluetooth "
                "ELM327 pair as a serial port — use Serial instead.")
            return
        self.status.showMessage("Scanning for BLE devices…")
        QtWidgets.QApplication.processEvents()
        try:
            devs = ble_scan(timeout=6.0)
        except Exception as e:
            QtWidgets.QMessageBox.critical(self, "BLE scan failed", str(e)); return
        self.ble_combo.clear()
        for addr, name in devs:
            self.ble_combo.addItem(f"{name}  [{addr}]", addr)
        self.status.showMessage(f"Found {len(devs)} BLE device(s).")

    def _rebuild_preset_buttons(self):
        for b in self._preset_buttons:
            self._preset_tb.removeAction(b)
        self._preset_buttons = []
        for name in self.ctl.reg.preset_names():
            btn = QtWidgets.QPushButton(name.capitalize())
            btn.setEnabled(self.ctl.connected)
            btn.clicked.connect(lambda _=False, n=name: self._apply_preset(n))
            self._preset_buttons.append(self._preset_tb.addWidget(btn))
            self._preset_buttons[-1].setProperty("presetbtn", True)
            btn.setProperty("preset", True)

    # ---------- PID browser ----------
    def _build_pid_browser(self):
        self.pid_dock = QtWidgets.QDockWidget("PIDs", self)
        self.tree = QtWidgets.QTreeWidget()
        self.tree.setColumnCount(2)
        self.tree.setHeaderLabels(["Signal", "Value"])
        self.tree.itemChanged.connect(self._on_item_changed)
        self.pid_dock.setWidget(self.tree)
        self.pid_dock.visibilityChanged.connect(
            lambda vis: self.show_pids.setChecked(vis))
        self.addDockWidget(QtCore.Qt.LeftDockWidgetArea, self.pid_dock)

    def _populate_tree(self):
        self.tree.blockSignals(True)
        self.tree.clear()
        self._items = {}
        groups = {}
        for p in self.ctl.reg.all():
            g = groups.get(p.group)
            if g is None:
                g = QtWidgets.QTreeWidgetItem([GROUP_LABEL.get(p.group, p.group), ""])
                g.setFlags(QtCore.Qt.ItemIsEnabled)
                f = g.font(0); f.setBold(True); g.setFont(0, f)
                groups[p.group] = g
            it = QtWidgets.QTreeWidgetItem([f"{p.name}{CONF_BADGE.get(p.confidence,'')}", "--"])
            it.setFlags(QtCore.Qt.ItemIsUserCheckable | QtCore.Qt.ItemIsEnabled)
            it.setCheckState(0, QtCore.Qt.Unchecked)
            it.setData(0, QtCore.Qt.UserRole, p.key)
            it.setToolTip(0, f"{p.key} (mode {p.mode} {p.pid}) {p.unit}  {p.notes}")
            g.addChild(it)
            self._items[p.key] = it
        for gk in GROUP_ORDER:
            if gk in groups:
                self.tree.addTopLevelItem(groups[gk])
        for gk, g in groups.items():           # any custom groups not in GROUP_ORDER
            if gk not in GROUP_ORDER:
                self.tree.addTopLevelItem(g)
        self.tree.expandAll()
        self.tree.resizeColumnToContents(0)
        self.tree.blockSignals(False)

    # ---------- diagnostics (DTCs) -- menu-driven dialogs, no docked panel ----------
    _DIAG_GROUPS = [("stored", "Stored (mode 03)"),
                    ("pending", "Pending (mode 07)"),
                    ("permanent", "Permanent (mode 0A)")]

    def _codes_tree(self, codes):
        """Build a populated QTreeWidget of codes for a dialog (not docked)."""
        tree = QtWidgets.QTreeWidget()
        tree.setColumnCount(3)
        tree.setHeaderLabels(["Code", "Description", "System"])
        tree.header().setSectionResizeMode(1, QtWidgets.QHeaderView.Stretch)
        total = 0
        for bucket, label in self._DIAG_GROUPS:
            lst = codes.get(bucket, [])
            total += len(lst)
            top = QtWidgets.QTreeWidgetItem([f"{label}  ({len(lst)})", "", ""])
            f = top.font(0); f.setBold(True); top.setFont(0, f)
            tree.addTopLevelItem(top)
            if not lst:
                none = QtWidgets.QTreeWidgetItem(["—", "(no codes)", ""])
                none.setForeground(1, QtGui.QBrush(QtGui.QColor("#888")))
                top.addChild(none)
            for code in lst:
                d = self.ctl.dtcdb.get(code)
                it = QtWidgets.QTreeWidgetItem([code, d.desc, d.system])
                if getattr(d, "no_start", False):
                    red = QtGui.QBrush(QtGui.QColor("#e6194B"))
                    bf = it.font(0); bf.setBold(True)
                    for c in range(3):
                        it.setFont(c, bf); it.setForeground(c, red)
                    it.setToolTip(0, "No-start / drive-disabling fault")
                top.addChild(it)
            top.setExpanded(True)
        tree.resizeColumnToContents(0)
        tree.resizeColumnToContents(2)
        return tree, total

    def _show_codes_dialog(self, codes, title, header=""):
        dlg = QtWidgets.QDialog(self)
        dlg.setWindowTitle(title)
        dlg.resize(560, 360)
        lay = QtWidgets.QVBoxLayout(dlg)
        tree, total = self._codes_tree(codes)
        if header:
            lbl = QtWidgets.QLabel(header); lbl.setWordWrap(True); lay.addWidget(lbl)
        lay.addWidget(tree)
        foot = QtWidgets.QLabel("Bold red = no-start / drive-disabling fault.")
        foot.setStyleSheet("color:#888;"); lay.addWidget(foot)
        bb = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.Close)
        bb.rejected.connect(dlg.reject); bb.accepted.connect(dlg.accept)
        lay.addWidget(bb)
        dlg.exec()
        return total

    def _read_codes(self):
        if not self.ctl.connected:
            QtWidgets.QMessageBox.information(
                self, "Not connected", "Connect (or tick Mock) before reading codes.")
            return
        try:
            codes = self.ctl.read_dtcs()
        except Exception as e:
            QtWidgets.QMessageBox.critical(self, "Read failed", str(e)); return
        total = self._show_codes_dialog(codes, "Trouble Codes")
        self.status.showMessage(f"Read codes: {total} found." if total
                                else "Read codes: none stored.")

    def _clear_codes(self):
        if not self.ctl.connected:
            QtWidgets.QMessageBox.information(
                self, "Not connected", "Connect (or tick Mock) before clearing codes.")
            return
        btn = QtWidgets.QMessageBox.warning(
            self, "Clear codes?",
            "This erases stored + pending codes AND freeze-frame data, and "
            "resets emissions monitors.\n\n"
            "Read the codes first — on a no-start especially.  If the fault is "
            "still present the code comes right back.\n"
            "Permanent codes (mode 0A) will NOT clear until the fault is fixed "
            "and the vehicle self-clears them over several drive cycles.\n\n"
            "Clear codes now?",
            QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,
            QtWidgets.QMessageBox.No)
        if btn != QtWidgets.QMessageBox.Yes:
            self.status.showMessage("Cancelled. No codes cleared.")
            return
        try:
            ok = self.ctl.clear_dtcs()
        except Exception as e:
            QtWidgets.QMessageBox.critical(self, "Clear failed", str(e)); return
        if not ok:
            QtWidgets.QMessageBox.warning(
                self, "No acknowledgement",
                "The ECU did not acknowledge the clear.\n"
                "Make sure the key is in RUN and connected, then try again.")
            self.status.showMessage("Clear not acknowledged by ECU.")
            return
        try:
            codes = self.ctl.read_dtcs()
        except Exception:
            codes = {}
        returned = sum(len(codes.get(b, [])) for b, _ in self._DIAG_GROUPS)
        if returned:
            self._show_codes_dialog(
                codes, "Cleared — codes returned",
                "These codes came back immediately after clearing — an active "
                "fault is still present:")
            self.status.showMessage(f"Cleared — {returned} code(s) returned (active fault).")
        else:
            QtWidgets.QMessageBox.information(self, "Codes cleared",
                                              "Cleared. No codes on re-read.")
            self.status.showMessage("Cleared. No codes on re-read.")

    # ---------- standard OBD services (dialogs) ----------
    def _need_connection(self):
        if not self.ctl.connected:
            QtWidgets.QMessageBox.information(
                self, "Not connected", "Connect (or tick Mock) first.")
            return False
        return True

    def _vehicle_info(self):
        if not self._need_connection():
            return
        try:
            info = self.ctl.read_vehicle_info() or {}
        except Exception as e:
            QtWidgets.QMessageBox.critical(self, "Read failed", str(e)); return
        rows = [("VIN", info.get("vin") or "—"),
                ("Calibration ID", info.get("calibration") or "—"),
                ("ECU Name", info.get("ecu_name") or "—")]
        text = "\n".join(f"{k}:\t{v}" for k, v in rows)
        QtWidgets.QMessageBox.information(self, "Vehicle Info", text)
        self.status.showMessage(f"VIN: {info.get('vin') or 'not reported'}")

    def _readiness(self):
        if not self._need_connection():
            return
        try:
            r = self.ctl.read_readiness()
        except Exception as e:
            QtWidgets.QMessageBox.critical(self, "Read failed", str(e)); return
        if not r:
            QtWidgets.QMessageBox.information(self, "Readiness", "No readiness data returned.")
            return
        dlg = QtWidgets.QDialog(self); dlg.setWindowTitle("Emissions Readiness")
        dlg.resize(420, 360)
        lay = QtWidgets.QVBoxLayout(dlg)
        not_ready = [m for m in r["monitors"] if not m["ready"]]
        passed = (not r["mil"]) and r["dtc_count"] == 0 and len(not_ready) <= 1
        head = QtWidgets.QLabel(
            f"<b>{'LIKELY PASS' if passed else 'NOT READY'}</b> — "
            f"MIL {'ON' if r['mil'] else 'off'}, {r['dtc_count']} code(s), "
            f"{r['ready_count']}/{r['total']} monitors ready "
            f"({r['ignition']} ignition)")
        head.setStyleSheet(f"color:{'#3cb44b' if passed else '#e6a23c'};")
        head.setWordWrap(True); lay.addWidget(head)
        tree = QtWidgets.QTreeWidget(); tree.setHeaderLabels(["Monitor", "Status"])
        tree.header().setStretchLastSection(False)
        tree.header().setSectionResizeMode(0, QtWidgets.QHeaderView.Stretch)
        for m in r["monitors"]:
            it = QtWidgets.QTreeWidgetItem([m["name"], "READY" if m["ready"] else "not ready"])
            it.setForeground(1, QtGui.QBrush(QtGui.QColor("#3cb44b" if m["ready"] else "#e6a23c")))
            tree.addTopLevelItem(it)
        lay.addWidget(tree)
        bb = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.Close)
        bb.rejected.connect(dlg.reject); lay.addWidget(bb)
        dlg.exec()
        self.status.showMessage(f"Readiness: {r['ready_count']}/{r['total']} ready, "
                                f"MIL {'on' if r['mil'] else 'off'}")

    def _freeze_frame(self):
        if not self._need_connection():
            return
        try:
            ff = self.ctl.read_freeze_frame() or {}
        except Exception as e:
            QtWidgets.QMessageBox.critical(self, "Read failed", str(e)); return
        vals = ff.get("values") or []
        if not vals and not ff.get("dtc"):
            QtWidgets.QMessageBox.information(self, "Freeze Frame",
                "No freeze-frame data stored (no fault has captured one).")
            return
        dlg = QtWidgets.QDialog(self); dlg.setWindowTitle("Freeze Frame")
        dlg.resize(440, 380)
        lay = QtWidgets.QVBoxLayout(dlg)
        d = ff.get("dtc")
        cap = self.ctl.dtcdb.get(d).desc if d else "(unknown)"
        lay.addWidget(QtWidgets.QLabel(f"Captured by: <b>{d or '—'}</b> — {cap}"))
        tree = QtWidgets.QTreeWidget(); tree.setHeaderLabels(["Signal", "Value"])
        tree.header().setSectionResizeMode(0, QtWidgets.QHeaderView.Stretch)
        for name, val, unit in vals:
            tree.addTopLevelItem(QtWidgets.QTreeWidgetItem([name, f"{val} {unit}".strip()]))
        lay.addWidget(tree)
        bb = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.Close)
        bb.rejected.connect(dlg.reject); lay.addWidget(bb)
        dlg.exec()

    _RISK_COLOR = {"safe": "#3cb44b", "caution": "#e6a23c", "danger": "#e6194B"}

    def _service_actions(self):
        if not self._need_connection():
            return
        acts = self.ctl.actions()
        dlg = QtWidgets.QDialog(self); dlg.setWindowTitle("Service & Bi-directional Functions")
        dlg.resize(560, 420)
        lay = QtWidgets.QVBoxLayout(dlg)
        if not acts:
            lay.addWidget(QtWidgets.QLabel(
                "No service functions are defined for this vehicle profile yet.\n\n"
                "These are manufacturer-specific — add them to the profile's \"actions\"\n"
                "block (see profiles/PROFILE_SPEC.md). OBDash never synthesizes command\n"
                "bytes; it only runs what a verified profile defines."))
        else:
            lay.addWidget(QtWidgets.QLabel(
                "<b>Caution:</b> these send commands to the vehicle. Read each warning."))
            scroll = QtWidgets.QScrollArea(); scroll.setWidgetResizable(True)
            inner = QtWidgets.QWidget(); il = QtWidgets.QVBoxLayout(inner)
            for a in acts:
                row = QtWidgets.QFrame()
                row.setStyleSheet("QFrame{border:1px solid #333;border-radius:6px;}")
                rl = QtWidgets.QHBoxLayout(row)
                txt = QtWidgets.QLabel(
                    f"<b>{a.name}</b>  "
                    f"<span style='color:{self._RISK_COLOR.get(a.risk,'#999')}'>[{a.risk}]</span>"
                    f"<br><span style='color:#999'>{a.description}</span>")
                txt.setWordWrap(True)
                rl.addWidget(txt, 1)
                btn = QtWidgets.QPushButton("Run")
                btn.clicked.connect(lambda _=False, act=a: self._run_action(act))
                rl.addWidget(btn)
                il.addWidget(row)
            il.addStretch(1)
            scroll.setWidget(inner); lay.addWidget(scroll)
        bb = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.Close)
        bb.rejected.connect(dlg.reject); lay.addWidget(bb)
        dlg.exec()

    def _run_action(self, action):
        if action.risk in ("caution", "danger"):
            msg = (action.warning or "This sends a command to the vehicle.") + \
                "\n\nProceed?"
            btn = QtWidgets.QMessageBox.warning(
                self, f"Run: {action.name}", msg,
                QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,
                QtWidgets.QMessageBox.No)
            if btn != QtWidgets.QMessageBox.Yes:
                return
        try:
            res = self.ctl.run_action(action) or {}
        except Exception as e:
            QtWidgets.QMessageBox.critical(self, action.name, str(e)); return
        if res.get("ok"):
            QtWidgets.QMessageBox.information(self, action.name, res.get("message", "Done."))
            self.status.showMessage(f"{action.name}: {res.get('message','done')}")
        else:
            QtWidgets.QMessageBox.warning(self, action.name,
                                          "Failed: " + res.get("message", "no response"))
            self.status.showMessage(f"{action.name} failed: {res.get('message','')}")

    # ---------- center (graph + table stack) ----------
    def _build_center(self):
        self.stack = QtWidgets.QStackedWidget()

        # graph page: a sub-stack of multi-axis (raw) and single-axis (normalized)
        gpage = QtWidgets.QWidget(); gl = QtWidgets.QVBoxLayout(gpage)
        gl.setContentsMargins(4, 4, 4, 4)
        bar = QtWidgets.QHBoxLayout()
        self.norm_chk = QtWidgets.QCheckBox("Normalize (% of range)")
        self.norm_chk.toggled.connect(self._set_normalize)
        bar.addWidget(self.norm_chk)
        bar.addWidget(QtWidgets.QLabel("  (off = true multi-axis, one Y scale per unit)"))
        bar.addStretch(1)
        bar.addWidget(QtWidgets.QLabel(f"window: {int(PLOT_WINDOW_S)}s"))
        gl.addLayout(bar)
        self.multi = MultiAxisPlot()
        self.single = SinglePlot()
        self.graph_stack = QtWidgets.QStackedWidget()
        self.graph_stack.addWidget(self.multi)      # index 0 = multi-axis
        self.graph_stack.addWidget(self.single)     # index 1 = normalized
        gl.addWidget(self.graph_stack)
        self.stack.addWidget(gpage)                 # center index 0 = graph

        # table page (center index 1)
        self.table = QtWidgets.QTableWidget(0, 6)
        self.table.setHorizontalHeaderLabels(["Signal", "Value", "Unit", "Min", "Max", "Conf"])
        self.table.horizontalHeader().setStretchLastSection(True)
        self.table.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers)
        self.stack.addWidget(self.table)

        # gauge page (center index 2)
        self.gauges = GaugeGrid()
        self.stack.addWidget(self.gauges)

        # trip / performance page (center index 3)
        self.stack.addWidget(self._build_trip_page())

        self.setCentralWidget(self.stack)
        self._apply_theme()

    def _build_trip_page(self):
        page = QtWidgets.QWidget()
        page.setStyleSheet("background:#111; color:#ddd;")
        lay = QtWidgets.QVBoxLayout(page)
        lay.setContentsMargins(24, 24, 24, 24)
        self._trip_labels = {}

        def big(title):
            box = QtWidgets.QFrame()
            box.setStyleSheet("QFrame{background:#1a1a1a;border-radius:8px;}")
            v = QtWidgets.QVBoxLayout(box)
            t = QtWidgets.QLabel(title); t.setStyleSheet("color:#999;font-size:11px;")
            val = QtWidgets.QLabel("--"); val.setStyleSheet("color:#fff;font-size:26px;font-weight:bold;")
            v.addWidget(t); v.addWidget(val)
            return box, val

        grid = QtWidgets.QGridLayout()
        cards = [("Instant MPG", "inst_mpg"), ("Average MPG", "avg_mpg"),
                 ("Trip Distance (mi)", "dist"), ("Fuel Used (gal)", "fuel"),
                 ("0-60 mph (s)", "zero60"), ("1/4 mile (s)", "quarter"),
                 ("Speed (mph)", "speed"), ("Trip Time", "time")]
        for i, (title, key) in enumerate(cards):
            box, val = big(title)
            self._trip_labels[key] = val
            grid.addWidget(box, i // 4, i % 4)
        lay.addLayout(grid)
        self._trip_note = QtWidgets.QLabel(
            "MPG needs a MAF sensor (speed-density/diesel vehicles report distance + "
            "0-60 only). Best 0-60 / 1/4-mile are kept; pull away from a stop to time a run.")
        self._trip_note.setWordWrap(True)
        self._trip_note.setStyleSheet("color:#888;font-size:11px;")
        lay.addWidget(self._trip_note)
        lay.addStretch(1)
        return page

    def _graph(self):
        """The active graph widget (multi-axis unless Normalize is on)."""
        return self.single if self.norm_chk.isChecked() else self.multi

    def _build_statusbar(self):
        self.status = self.statusBar()
        self.status.showMessage("Not connected.  Pick a port (or Mock) and Connect.")

    # ---------- profile lifecycle ----------
    def _rebuild_for_profile(self):
        self._populate_tree()
        self._rebuild_preset_buttons()
        self._populate_table_rows()
        self._refresh_title()

    def _refresh_title(self):
        self.setWindowTitle(f"OBDash  —  {self.ctl.profile.name}")

    def _load_profile(self, path):
        if self.ctl.connected:
            QtWidgets.QMessageBox.information(self, "Disconnect first",
                "Disconnect before switching vehicle profiles.")
            self._rebuild_profile_menu()
            return
        try:
            self.ctl.load_profile(path)
        except Exception as e:
            QtWidgets.QMessageBox.critical(self, "Profile load failed", str(e))
            return
        self.curves.clear(); self._color_i = 0
        self.multi.clear(); self.single.clear(); self.gauges.rebuild([])
        self._rebuild_for_profile()
        self._rebuild_profile_menu()
        self.status.showMessage(f"Loaded profile: {self.ctl.profile.name}")

    def _load_profile_dialog(self):
        path, _ = QtWidgets.QFileDialog.getOpenFileName(
            self, "Load vehicle profile", profiles_dir(), "Profiles (*.json)")
        if path:
            self._load_profile(path)

    def _import_profile(self):
        path, _ = QtWidgets.QFileDialog.getOpenFileName(
            self, "Import vehicle profile", "", "Profiles (*.json)")
        if not path:
            return
        dest = os.path.join(profiles_dir(), os.path.basename(path))
        try:
            load_profile(path)                  # validate before copying in
            shutil.copyfile(path, dest)
        except Exception as e:
            QtWidgets.QMessageBox.critical(self, "Import failed", str(e))
            return
        self._load_profile(dest)

    def _reload_profile(self):
        if getattr(self.ctl.profile, "path", None):
            self._load_profile(self.ctl.profile.path)

    def _export_profile(self):
        path, _ = QtWidgets.QFileDialog.getSaveFileName(
            self, "Export active profile", profiles_dir(), "Profiles (*.json)")
        if path:
            save_profile(self.ctl.profile, path)
            self.status.showMessage(f"Exported profile to {path}")

    def _edit_profile(self):
        p = getattr(self.ctl.profile, "path", None)
        if not p:
            return
        dlg = JsonEditDialog(p, self)
        if dlg.exec() == QtWidgets.QDialog.Accepted and not self.ctl.connected:
            self._load_profile(p)

    def _profile_info(self):
        m = self.ctl.profile.meta
        text = "\n".join(f"{k}: {v}" for k, v in m.items())
        QtWidgets.QMessageBox.information(self, "Active profile", text or "(no metadata)")

    # ---------- connection ----------
    def _refresh_ports(self):
        self.port_combo.clear()
        try:
            from obdcore.link import find_ports
            ports = find_ports()
        except Exception:
            ports = []
        for p in ports:
            self.port_combo.addItem(f"{p.device}  ({p.description})", p.device)
        if not ports:
            self.port_combo.addItem("(no ports found)", None)

    def _conn_spec(self):
        k = self.conn_kind.currentIndex()
        if k == 1:
            try:
                p = int(self.tcpport_edit.text())
            except ValueError:
                p = 35000
            return {"kind": "wifi", "host": self.host_edit.text().strip(), "port": p}
        if k == 2:
            addr = self.ble_combo.currentData() or self.ble_combo.currentText().strip()
            return {"kind": "ble", "address": addr}
        try:
            baud = int(self.baud_edit.text())
        except ValueError:
            baud = 38400
        return {"kind": "serial", "port": self.port_combo.currentData(), "baud": baud}

    def _toggle_connect(self):
        if self.ctl.connected:
            self._disconnect(); return
        try:
            ok = self.ctl.connect(mock=self.mock_chk.isChecked(), conn=self._conn_spec())
        except Exception as e:
            QtWidgets.QMessageBox.critical(self, "Connect failed", str(e)); return
        self.ctl.start(); self.timer.start()
        self.connect_btn.setText("Disconnect")
        for b in self.findChildren(QtWidgets.QPushButton):
            if b.property("preset"):
                b.setEnabled(True)
        kind = "MOCK" if self.mock_chk.isChecked() else "ELM327"
        self.status.showMessage(f"Connected ({kind})  protocol "
                                f"{getattr(self.ctl.link,'protocol','?')}  "
                                f"{'(ECU answered)' if ok else '(no 0100 ack — key to RUN?)'}")
        names = self.ctl.reg.preset_names()
        if names:
            self._apply_preset(names[0])

    def _disconnect(self):
        self.timer.stop()
        for key in list(self.curves):
            self._remove_curve(key)
        self.ctl.stop()
        self.rec_act.setText("Start Recording…")
        self.tree.blockSignals(True)
        for it in self._items.values():
            it.setCheckState(0, QtCore.Qt.Unchecked); it.setText(1, "--")
        self.tree.blockSignals(False)
        self.connect_btn.setText("Connect")
        for b in self.findChildren(QtWidgets.QPushButton):
            if b.property("preset"):
                b.setEnabled(False)
        self.status.showMessage("Disconnected.")

    # ---------- PID selection ----------
    def _apply_preset(self, name):
        if not self.ctl.connected:
            return
        wanted = set(self.ctl.reg.presets.get(name, []))
        self.tree.blockSignals(True)
        for key, it in self._items.items():
            it.setCheckState(0, QtCore.Qt.Checked if key in wanted else QtCore.Qt.Unchecked)
        self.tree.blockSignals(False)
        for key in self._items:
            self._sync_key(key)

    def _on_item_changed(self, item, col):
        if col == 0:
            key = item.data(0, QtCore.Qt.UserRole)
            if key:
                self._sync_key(key)

    def _sync_key(self, key):
        checked = self._items[key].checkState(0) == QtCore.Qt.Checked
        has = key in self.curves
        if checked and not has:
            if self.ctl.connected:
                self.ctl.subscribe(key)
            self._add_curve(key)
        elif not checked and has:
            self.ctl.unsubscribe(key)
            self._remove_curve(key)

    def _add_curve(self, key):
        p = self.ctl.reg.get(key)
        color = CURVE_COLORS[self._color_i % len(CURVE_COLORS)]; self._color_i += 1
        self.curves[key] = color                     # curves maps key -> color
        self._graph().add_curve(key, f"{p.name} ({self._dunit(p)})", self._dunit(p), color)
        self._refresh_gauges()

    def _remove_curve(self, key):
        if self.curves.pop(key, None) is None:
            return
        self.multi.remove_curve(key)
        self.single.remove_curve(key)
        self._refresh_gauges()

    def _set_normalize(self, on):
        """Swap between true multi-axis (raw) and single-axis (% of range)."""
        self.norm_act.setChecked(on)
        self.multi.clear(); self.single.clear()
        self.graph_stack.setCurrentIndex(1 if on else 0)
        active = self._graph()
        for key, color in self.curves.items():
            p = self.ctl.reg.get(key)
            active.add_curve(key, f"{p.name} ({self._dunit(p)})", self._dunit(p), color)
        self._apply_theme()
        self._redraw_curves(static=not self.ctl.connected)

    # ---------- display units (C / F) ----------
    def _is_temp(self, p):
        return p.unit == "C"

    def _dval(self, p, v):
        if v is None:
            return None
        return v * 9 / 5 + 32 if (self._temp_f and self._is_temp(p)) else v

    def _dunit(self, p):
        return "F" if (self._temp_f and self._is_temp(p)) else p.unit

    def _dzones(self, p):
        z = {"warn_hi": p.warn_hi, "redline_hi": p.redline_hi,
             "warn_lo": p.warn_lo, "redline_lo": p.redline_lo}
        if self._temp_f and self._is_temp(p):
            z = {k: (None if x is None else x * 9 / 5 + 32) for k, x in z.items()}
        return z

    def _set_temp(self, to_f):
        if to_f == self._temp_f:
            return
        self._temp_f = to_f
        # graph: relabel + rescale by rebuilding curves on the active widget
        self.multi.clear(); self.single.clear()
        active = self._graph()
        for key, color in self.curves.items():
            p = self.ctl.reg.get(key)
            active.add_curve(key, f"{p.name} ({self._dunit(p)})", self._dunit(p), color)
        for key, r in getattr(self, "_table_row", {}).items():
            self.table.item(r, 2).setText(self._dunit(self.ctl.reg.get(key)))
        self._refresh_gauges()
        if self.ctl.connected:
            self._tick()

    def _refresh_gauges(self):
        specs = []
        for key, color in self.curves.items():
            p = self.ctl.reg.get(key)
            specs.append((key, p.name, self._dunit(p), self._dval(p, p.vmin),
                          self._dval(p, p.vmax), color, self._dzones(p)))
        self.gauges.rebuild(specs)

    # ---------- view ----------
    def _set_view(self, idx):
        self.stack.setCurrentIndex(idx)
        self.view_graph.setChecked(idx == 0)
        self.view_table.setChecked(idx == 1)
        self.view_gauge.setChecked(idx == 2)
        self.view_trip.setChecked(idx == 3)

    def _toggle_pid_dock(self):
        self.pid_dock.setVisible(self.show_pids.isChecked())

    def _sync_norm_from_menu(self):
        self.norm_chk.setChecked(self.norm_act.isChecked())

    def _toggle_theme(self):
        self._theme = "light" if self.theme_act.isChecked() else "dark"
        self._apply_theme()

    def _apply_theme(self):
        bg, _fg = THEMES[self._theme]
        self.multi.set_background(bg)
        self.single.set_background(bg)

    def _populate_table_rows(self):
        pids = self.ctl.reg.all()
        self.table.setRowCount(len(pids))
        self._table_row = {}
        for r, p in enumerate(pids):
            self._table_row[p.key] = r
            self.table.setItem(r, 0, QtWidgets.QTableWidgetItem(p.name))
            self.table.setItem(r, 1, QtWidgets.QTableWidgetItem("--"))
            self.table.setItem(r, 2, QtWidgets.QTableWidgetItem(self._dunit(p)))
            self.table.setItem(r, 3, QtWidgets.QTableWidgetItem("--"))
            self.table.setItem(r, 4, QtWidgets.QTableWidgetItem("--"))
            self.table.setItem(r, 5, QtWidgets.QTableWidgetItem(p.confidence))
        self.table.resizeColumnsToContents()

    # ---------- captures ----------
    def _new_capture(self):
        self.ctl.store.clear()
        import time
        self.ctl.t0 = time.time()
        self.status.showMessage("New capture — buffers cleared.")

    def _toggle_record(self):
        if self.ctl.store.recorder is None:
            path, _ = QtWidgets.QFileDialog.getSaveFileName(
                self, "Record capture to", "", "CSV (*.csv)")
            if not path:
                return
            self.ctl.record(path)
            self.rec_act.setText("Stop Recording")
            self.status.showMessage(f"Recording to {path}")
        else:
            self.ctl.stop_record()
            self.rec_act.setText("Start Recording…")
            self.status.showMessage("Recording stopped.")

    def _export_capture(self):
        path, _ = QtWidgets.QFileDialog.getSaveFileName(self, "Export capture", "", "CSV (*.csv)")
        if path:
            export_csv(self.ctl.store, path)
            self.status.showMessage(f"Exported capture to {path}")

    def _open_capture(self):
        if self.ctl.connected:
            QtWidgets.QMessageBox.information(self, "Disconnect first",
                "Disconnect before replaying a capture."); return
        path, _ = QtWidgets.QFileDialog.getOpenFileName(self, "Open capture", "", "CSV (*.csv)")
        if not path:
            return
        store = TimeSeriesStore()
        replay_csv(path, store)
        self.ctl.store = store
        for key in list(self.curves):
            self._remove_curve(key)
        import time
        ts = [t for s in store.snapshot().values() for t, _ in s]
        self.ctl.t0 = min(ts) if ts else time.time()
        self.tree.blockSignals(True)
        for key in store.keys():
            if key in self._items:
                self._items[key].setCheckState(0, QtCore.Qt.Checked)
                self._add_curve(key)
        self.tree.blockSignals(False)
        self._redraw_curves(static=True)
        self.status.showMessage(f"Replay: {os.path.basename(path)} ({len(ts)} samples)")

    # ---------- help ----------
    def _about(self):
        QtWidgets.QMessageBox.about(self, "About OBDash",
            "OBDash — vehicle-agnostic OBD-II scanner\n\n"
            "Open source. Vehicle data lives in JSON profiles you can add/share.\n"
            "git.jpaul.io/justin/obdash")

    def _legend(self):
        QtWidgets.QMessageBox.information(self, "PID confidence",
            "verified  — multi-source or read on a real vehicle\n"
            "[DOC]      — documented in sources, not yet read on this vehicle\n"
            "[?]        — single-source / disputed scaling; sanity-check first")

    # ---------- refresh ----------
    def _redraw_curves(self, static=False):
        if self.ctl.t0 is None:
            return
        if static:
            since = None
        else:
            since = (self.ctl.t0 or 0) + max(0.0, self.ctl.now() - PLOT_WINDOW_S)
        normalize = self.norm_chk.isChecked()
        active = self._graph()
        for key in self.curves:
            p = self.ctl.reg.get(key)
            xs, ys = [], []
            for t, v in self.ctl.store.channel(key).series(since=since):
                if v is None:
                    continue
                xs.append(t - self.ctl.t0)
                if normalize and p.vmax != p.vmin:
                    ys.append((v - p.vmin) / (p.vmax - p.vmin) * 100.0)
                else:
                    ys.append(self._dval(p, v))
            active.set_data(key, xs, ys)
        if normalize:
            self.single.set_y_label("% of range")

    def _tick(self):
        if not self.ctl.connected:
            return
        self.tree.blockSignals(True)
        for key, it in self._items.items():
            v = self.ctl.store.latest(key)
            p = self.ctl.reg.get(key)
            dv = self._dval(p, v)
            txt = "--" if dv is None else f"{dv:g} {self._dunit(p)}".strip()
            it.setText(1, txt)
            if key in self._table_row:
                r = self._table_row[key]
                lo, hi = self.ctl.store.minmax(key)
                dlo, dhi = self._dval(p, lo), self._dval(p, hi)
                self.table.item(r, 1).setText("--" if dv is None else f"{dv:g}")
                self.table.item(r, 3).setText("--" if dlo is None else f"{dlo:g}")
                self.table.item(r, 4).setText("--" if dhi is None else f"{dhi:g}")
        self.tree.blockSignals(False)
        spd, maf = self.ctl.update_trip()            # accumulate trip/perf every tick
        idx = self.stack.currentIndex()
        if idx == 2:                                 # gauge view
            for key in self.curves:
                p = self.ctl.reg.get(key)
                lo, hi = self.ctl.store.minmax(key)
                self.gauges.set_value(key, self._dval(p, self.ctl.store.latest(key)),
                                      peak=self._dval(p, hi))
        elif idx == 3:                               # trip / performance view
            self._update_trip_page(spd, maf)
        else:
            self._redraw_curves()

    def _update_trip_page(self, spd, maf):
        t, s = self.ctl.trip, self.ctl.trip.stats()
        L = self._trip_labels
        L["inst_mpg"].setText(f"{t.instant_mpg(spd, maf):.1f}" if (spd and maf) else "--")
        L["avg_mpg"].setText(f"{s['avg_mpg']:.1f}" if self.ctl.maf_key else "n/a (no MAF)")
        L["dist"].setText(f"{s['distance_mi']:.2f}")
        L["fuel"].setText(f"{s['fuel_gal']:.3f}" if self.ctl.maf_key else "n/a")
        L["speed"].setText(f"{spd / 1.60934:.0f}" if spd is not None else "--")
        mm, ss = divmod(int(s["elapsed_s"]), 60)
        L["time"].setText(f"{mm}:{ss:02d}")
        pm = self.ctl.perf
        L["zero60"].setText(f"{pm.best_0_60}" if pm.best_0_60 else "--")
        L["quarter"].setText(f"{pm.best_quarter}" if pm.best_quarter else "--")

    def closeEvent(self, ev):
        try:
            self.timer.stop()
            self.ctl.stop()
        finally:
            super().closeEvent(ev)


class JsonEditDialog(QtWidgets.QDialog):
    """Minimal raw-JSON editor for the active profile (validates on save)."""

    def __init__(self, path, parent=None):
        super().__init__(parent)
        self.path = path
        self.setWindowTitle(f"Edit profile — {os.path.basename(path)}")
        self.resize(720, 560)
        lay = QtWidgets.QVBoxLayout(self)
        self.edit = QtWidgets.QPlainTextEdit()
        self.edit.setFont(QtGui.QFont("monospace"))
        with open(path) as f:
            self.edit.setPlainText(f.read())
        lay.addWidget(self.edit)
        bb = QtWidgets.QDialogButtonBox(
            QtWidgets.QDialogButtonBox.Save | QtWidgets.QDialogButtonBox.Cancel)
        bb.accepted.connect(self._save); bb.rejected.connect(self.reject)
        lay.addWidget(bb)

    def _save(self):
        import json
        text = self.edit.toPlainText()
        try:
            json.loads(text)                    # syntax check
        except Exception as e:
            QtWidgets.QMessageBox.critical(self, "Invalid JSON", str(e)); return
        tmp = self.path + ".tmp"
        with open(tmp, "w") as f:
            f.write(text)
        try:
            load_profile(tmp)                   # schema/formula validation
        except Exception as e:
            os.remove(tmp)
            QtWidgets.QMessageBox.critical(self, "Invalid profile", str(e)); return
        os.replace(tmp, self.path)
        self.accept()


def run():
    import sys
    app = QtWidgets.QApplication(sys.argv)
    win = MainWindow()
    win.resize(1150, 700)
    win.show()
    sys.exit(app.exec())


if __name__ == "__main__":
    run()
