#!/usr/bin/env python3
"""
BEANalyzer UltraPro - Toyota/Lexus BEAN MPX laboratory tool

Features:
- Live BEAN MPX capture from Arduino serial bridge
- User-selectable dashboard cards
- User-selectable live plots
- Friendly BEAN-DBC editor with message table, signal examples, raw JSON view
- CSV replay and JSON export
- Manual BEAN and CAN transmit console with repetition/loop/delay
- Optional CAN gateway via python-can: canalystii, slcan/CANable, socketcan, pcan, etc.
- Optional Arduino serial CAN bridge for EasyMultiCAN/EasyESP32CAN/MCP2515-style firmware
"""
from __future__ import annotations

import csv
import json
import math
import queue
import re
import shutil
import sys
import time
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any, Optional

from PySide6.QtCore import Qt, QThread, Signal, QTimer, QSize, QUrl
from PySide6.QtGui import QAction, QColor, QFont, QPixmap, QIcon, QDesktopServices
from PySide6.QtWidgets import (
    QApplication, QCheckBox, QComboBox, QFileDialog, QFormLayout, QFrame,
    QGridLayout, QGroupBox, QHBoxLayout, QLabel, QLineEdit, QListWidget,
    QListWidgetItem, QMainWindow, QMessageBox, QPushButton, QPlainTextEdit,
    QSpinBox, QDoubleSpinBox, QSplitter, QStatusBar, QTabWidget, QTableWidget,
    QTableWidgetItem, QTextEdit, QVBoxLayout, QWidget, QHeaderView, QScrollArea,
    QAbstractItemView, QSplashScreen, QDialog, QMenu
)

import serial
from serial.tools import list_ports

try:
    import pyqtgraph as pg
except Exception:
    pg = None

try:
    import can
except Exception:
    can = None

APP_DIR = Path(__file__).resolve().parent
CONFIG_DIR = APP_DIR / "config"
ASSETS_DIR = APP_DIR / "assets"
DBC_PATH = CONFIG_DIR / "bean_dbc_default.json"
CAN_MAP_PATH = CONFIG_DIR / "bean_can_map.json"
SPLASH_PATH = ASSETS_DIR / "splashscreen.png"
APP_VERSION = "4.18"
WEBSITE_URL = "https://aend-tech.com"
AED_LOGO_PATH = ASSETS_DIR / "logo_aed.png"

CSV_HEADERS = [
    "pc_time_iso", "pc_time", "arduino_ms", "delta_ms", "bus", "type",
    "context", "did", "mid", "data", "decoded", "function",
    "source_ecu", "category", "changes", "raw",
    # v4.18 real BEAN physical/link-layer export fields
    "capture_marker", "bean_header", "priority", "ml", "data_len",
    "dst_id", "mes_id", "crc", "crc_ok", "eom", "post_eom",
    "rsp_candidate", "eof_candidate",
    # user / analyzer event marker
    "event_marker",
]
APP_ICON_PATH = ASSETS_DIR / "app_icon.png"
BAUDRATE = 115200
MAX_ROWS = 12000
SPLASH_MIN_TIME_MS = 1800


def get_user_data_dir() -> Path:
    """Return a writable per-user application data directory."""
    if sys.platform == "darwin":
        base = Path.home() / "Library" / "Application Support"
    elif sys.platform.startswith("win"):
        base = Path(os.environ.get("APPDATA", str(Path.home() / "AppData" / "Roaming")))
    else:
        base = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local" / "share")))
    return base / "BEANalyzer UltraPro"


USER_DATA_DIR = get_user_data_dir()
USER_LOGS_DIR = USER_DATA_DIR / "logs"


def beanalyzer_excepthook(exc_type, exc_value, exc_tb):
    """Write uncaught exceptions to a user-visible crash log before Qt exits."""
    import traceback
    try:
        log_dir = get_user_data_dir() / "logs"
        log_dir.mkdir(parents=True, exist_ok=True)
        with open(log_dir / "crash.log", "a", encoding="utf-8") as f:
            f.write("\n===== BEANalyzer UltraPro crash =====\n")
            f.write(datetime.now().isoformat(timespec="seconds") + "\n")
            traceback.print_exception(exc_type, exc_value, exc_tb, file=f)
    except Exception:
        pass
    sys.__excepthook__(exc_type, exc_value, exc_tb)

sys.excepthook = beanalyzer_excepthook

# -----------------------------------------------------------------------------
# Small helpers
# -----------------------------------------------------------------------------

def hex_byte(value: int) -> str:
    return f"{value & 0xFF:02X}"




def safe_set_menu_role(action: QAction, role_name: str) -> None:
    """Set QAction menu role without crashing on older PySide6 builds.

    Some PySide6 versions expose roles as QAction.MenuRole.AboutRole, while
    older/quirky builds expose them directly or not at all. A failed menu-role
    assignment must never prevent the application from starting.
    """
    try:
        menu_role_enum = getattr(QAction, "MenuRole", None)
        role = None
        if menu_role_enum is not None:
            role = getattr(menu_role_enum, role_name, None)
        if role is None:
            role = getattr(QAction, role_name, None)
        if role is not None:
            action.setMenuRole(role)
    except Exception:
        # Menu role is cosmetic/native-integration only. Never crash at startup.
        pass



def serial_port_score(port_info) -> int:
    """Sort real USB serial adapters before Bluetooth/pseudo ports."""
    text = " ".join([
        str(getattr(port_info, "device", "") or ""),
        str(getattr(port_info, "description", "") or ""),
        str(getattr(port_info, "manufacturer", "") or ""),
        str(getattr(port_info, "product", "") or ""),
        str(getattr(port_info, "hwid", "") or ""),
    ]).lower()
    score = 50
    if any(k in text for k in ["usbserial", "usbmodem", "wch", "ch340", "ch341", "cp210", "silicon labs", "ftdi", "arduino", "mega", "uno", "nano", "esp32", "esp", "ttyusb", "ttyacm"]):
        score -= 40
    if getattr(port_info, "vid", None) is not None and getattr(port_info, "pid", None) is not None:
        score -= 20
    if any(k in text for k in ["bluetooth", "wireless", "airpods", "redmibuds", "buds", "iphone", "ipad"]):
        score += 100
    if str(getattr(port_info, "device", "")).startswith("/dev/cu."):
        score -= 2
    return score


def serial_port_label(port_info) -> str:
    label = f"{port_info.device} - {port_info.description}"
    if port_info.vid is not None and port_info.pid is not None:
        label += f" [VID:PID {port_info.vid:04X}:{port_info.pid:04X}]"
    if serial_port_score(port_info) >= 100:
        label += "  ⚠ unlikely for Arduino/BEAN"
    return label


def is_unlikely_bean_serial_port(port_text: str) -> bool:
    text = str(port_text or "").lower()
    return any(k in text for k in ["bluetooth", "wireless", "airpods", "redmibuds", "buds", "iphone", "ipad"])


def combo_selected_port(combo: QComboBox) -> str:
    data = combo.currentData()
    if data:
        return str(data).strip()
    text = combo.currentText().strip()
    # If the combo contains a generated label, keep only the actual device before " - ".
    if " - " in text:
        text = text.split(" - ", 1)[0].strip()
    return text


def parse_hex_bytes(text: str) -> list[int]:
    """Parse a loose hex byte string.

    Accepts common forms used by live capture, CSV export and manual editing:
    "01 53 FE", "0x01,0x53", "[67]", "01 53 FE 24".
    Non-byte separators are ignored when possible.
    """
    cleaned = (
        str(text)
        .replace(",", " ")
        .replace(";", " ")
        .replace("[", " ")
        .replace("]", " ")
        .replace("0x", "")
        .replace("0X", "")
        .strip()
    )
    if not cleaned:
        return []
    out: list[int] = []
    for part in cleaned.split():
        part = part.strip()
        if not part:
            continue
        # Ignore non-hex fragments safely, useful when loading hand-edited CSVs.
        if not re.fullmatch(r"[0-9A-Fa-f]{1,2}", part):
            continue
        out.append(int(part, 16) & 0xFF)
    return out


def bytes_to_hex(values: list[int]) -> str:
    return " ".join(hex_byte(v) for v in values)


def bean_crc8(data: list[int]) -> int:
    """Toyota BEAN CRC-8.

    Parameters:
    - width: 8
    - polynomial: 0x13
    - init: 0x00
    - refin/refout: false
    - xorout: 0x00

    Reference check:
    CRC8(13 FE 24 00) = B4
    """
    crc = 0x00
    poly = 0x13
    for byte in data:
        crc ^= (int(byte) & 0xFF)
        for _ in range(8):
            if crc & 0x80:
                crc = ((crc << 1) ^ poly) & 0xFF
            else:
                crc = (crc << 1) & 0xFF
    return crc & 0xFF


def bean_build_frame(payload: list[int], priority: int = 0x2, include_eom: bool = True) -> list[int]:
    """Build a logical BEAN frame from payload: DST/MID/DATA...

    Header byte = PRI(4 bits) + ML(4 bits)

    Data link layer rule:
        ML = number of bytes of [DST-ID + MES-ID + DATA]
        DATA length = ML - 2
        Normal valid DATA length = 1..11 bytes
        Normal valid ML range = 3..13
    """
    payload = [int(x) & 0xFF for x in payload]
    if len(payload) < 3:
        raise ValueError("BEAN payload must contain at least DST-ID, MES-ID and 1 DATA byte")
    if len(payload) > 13:
        raise ValueError("BEAN payload too long: ML must be 3..13 bytes (DST + MES + 1..11 DATA bytes)")
    header = ((int(priority) & 0x0F) << 4) | (len(payload) & 0x0F)
    core = [header] + payload
    crc = bean_crc8(core)
    return core + [crc] + ([0x7E] if include_eom else [])


def bean_strip_capture_tail(frame: list[int]) -> list[int]:
    out = list(frame)
    if len(out) >= 2 and out[-2] == 0x7E and out[-1] in (0x00, 0x40):
        return out[:-2]
    if out and out[-1] == 0x7E:
        return out[:-1]
    return out


def bean_payload_from_capture(frame: list[int]) -> list[int]:
    """Extract DST/MID/DATA from a common captured frame.

    Common BEANalyzer capture:
        01 CTX DST MID DATA... 7E EOF
    returns:
        DST MID DATA...
    """
    frame = [int(x) & 0xFF for x in frame]
    core = bean_strip_capture_tail(frame)
    if len(core) >= 4 and core[0] == 0x01:
        return core[2:]
    # If it looks like HEADER DST MID DATA CRC, remove header and CRC.
    if len(core) >= 4 and (core[0] >> 4) <= 0x0F:
        ml = core[0] & 0x0F
        if ml and len(core) >= 1 + ml + 1:
            return core[1:1 + ml]
    return core


def bean_decode_logical_frame(frame: list[int]) -> dict[str, Any]:
    """Decode logical BEAN frame: HEADER DST MID DATA CRC EOM? EOF?.

    This does not de-stuff physical bits. It expects de-stuffed bytes.
    """
    data = [int(x) & 0xFF for x in frame]
    result = {
        "ok": False,
        "error": "",
        "header": None,
        "priority": None,
        "ml": None,
        "dst": None,
        "mid": None,
        "payload_data": [],
        "data_len": None,
        "ml_valid": False,
        "post_eom_raw": None,
        "rsp_candidate_bits": None,
        "eof_candidate_bits": None,
        "ifs_candidate_bit": None,
        "crc_received": None,
        "crc_calculated": None,
        "crc_ok": False,
        "has_eom": False,
        "has_eof_byte": False,
    }
    if len(data) < 4:
        result["error"] = "Frame too short"
        return result

    # Drop optional post-EOM byte captured by many sniffers.
    # This byte is not normal DATA. It may contain sampled RSP(2 bits), EOF(6 bits)
    # and/or idle alignment depending on the firmware capture method.
    work = list(data)
    if len(work) >= 2 and work[-2] == 0x7E:
        result["has_eof_byte"] = True
        result["post_eom_raw"] = work[-1]
        post = work[-1] & 0xFF
        result["rsp_candidate_bits"] = (post >> 6) & 0x03
        result["eof_candidate_bits"] = post & 0x3F
        result["ifs_candidate_bit"] = None
        work = work[:-1]
    if work and work[-1] == 0x7E:
        result["has_eom"] = True
        work_no_eom = work[:-1]
    else:
        work_no_eom = work

    if len(work_no_eom) < 4:
        result["error"] = "Frame has no room for header, dst, mid and crc"
        return result

    header = work_no_eom[0]
    priority = (header >> 4) & 0x0F
    ml = header & 0x0F

    expected_total = 1 + ml + 1  # header + ML bytes + CRC
    if len(work_no_eom) < expected_total:
        result["error"] = f"Frame shorter than ML expects: got {len(work_no_eom)} bytes, expected {expected_total}"
        return result

    core = work_no_eom[:1 + ml]
    crc_received = work_no_eom[1 + ml]
    crc_calculated = bean_crc8(core)
    payload = core[1:]
    data_len = max(0, ml - 2)
    result.update({
        "ok": True,
        "header": header,
        "priority": priority,
        "ml": ml,
        "dst": payload[0] if len(payload) >= 1 else None,
        "mid": payload[1] if len(payload) >= 2 else None,
        "payload_data": payload[2:] if len(payload) >= 2 else [],
        "data_len": data_len,
        "ml_valid": 3 <= ml <= 13,
        "crc_received": crc_received,
        "crc_calculated": crc_calculated,
        "crc_ok": crc_received == crc_calculated,
    })
    return result



def bean_capture_metadata(frame: list[int]) -> dict[str, Any]:
    """Return robust physical/link-layer metadata for a captured BEAN frame.

    Expected real capture format:
        01 HEADER DST-ID MES-ID DATA... CRC 7E POST

    This helper keeps CRC/EOM/Post-EOM out of DATA and exposes fields for CSV/export.
    """
    out: dict[str, Any] = {
        "capture_marker": "",
        "bean_header": "",
        "priority": "",
        "ml": "",
        "data_len": "",
        "dst_id": "",
        "mes_id": "",
        "data": [],
        "crc": "",
        "crc_ok": "",
        "eom": "",
        "post_eom": "",
        "rsp_candidate": "",
        "eof_candidate": "",
    }
    data = [int(x) & 0xFF for x in frame]
    if len(data) >= 6 and data[0] == 0x01:
        marker_byte = data[0]
        header = data[1]
        pri = (header >> 4) & 0x0F
        ml = header & 0x0F
        crc_index = 2 + ml
        out.update({
            "capture_marker": hex_byte(marker_byte),
            "bean_header": hex_byte(header),
            "priority": pri,
            "ml": ml,
            "data_len": max(0, ml - 2),
        })
        if 3 <= ml <= 13 and len(data) > crc_index:
            core = data[1:crc_index]
            crc_rx = data[crc_index]
            crc_calc = bean_crc8(core)
            out.update({
                "dst_id": hex_byte(data[2]) if len(data) > 2 else "",
                "mes_id": hex_byte(data[3]) if len(data) > 3 else "",
                "data": data[4:crc_index],
                "crc": hex_byte(crc_rx),
                "crc_ok": "1" if crc_rx == crc_calc else "0",
            })
            if len(data) > crc_index + 1:
                out["eom"] = hex_byte(data[crc_index + 1])
            if len(data) > crc_index + 2:
                post = data[crc_index + 2]
                out["post_eom"] = hex_byte(post)
                out["rsp_candidate"] = format((post >> 6) & 0x03, "02b")
                out["eof_candidate"] = format(post & 0x3F, "06b")
    return out


def bean_bitstuff_bits_from_bytes(frame_without_eom: list[int]) -> list[int]:
    """Return bit-stuffed bit stream for bytes from SOF/header through CRC.

    Bits are sent MSB first. A SOF dominant/start bit of 1 is prepended, matching
    the reference sketch's initial line-high timing before bytes.
    """
    bits = [1]
    same_count = 0
    last = None

    def push_bit(bit: int):
        nonlocal same_count, last, bits
        bit = 1 if bit else 0
        if last is None:
            same_count = 1
            last = bit
            bits.append(bit)
            return
        if bit == last:
            same_count += 1
            bits.append(bit)
            if same_count >= 5:
                stuffed = 0 if bit else 1
                bits.append(stuffed)
                last = stuffed
                same_count = 1
        else:
            bits.append(bit)
            last = bit
            same_count = 1

    for byte in frame_without_eom:
        for i in range(7, -1, -1):
            push_bit((byte >> i) & 1)
    return bits


def read_u16_be(hi: int, lo: int) -> int:
    return ((hi & 0xFF) << 8) | (lo & 0xFF)


def clamp(value: float, low: float, high: float) -> float:
    return max(low, min(high, value))


def safe_eval_formula(formula: str, raw: float, data: list[int]) -> float:
    allowed = {
        "__builtins__": {},
        "raw": raw,
        "data": data,
        "len": len,
        "min": min,
        "max": max,
        "abs": abs,
        "round": round,
        "math": math,
    }
    return eval(formula, allowed, {})


def parse_capture_line(line: str) -> Optional[dict[str, Any]]:
    line = line.strip()
    if line.startswith("B,"):
        # BEAN frame: B,<arduino_ms>,<msg_type>,<len>,<hex bytes>
        parts = line.split(",", 4)
        if len(parts) != 5:
            return None
        try:
            frame = parse_hex_bytes(parts[4])
            return {
                "kind": "BEAN",
                "arduino_ms": int(parts[1]),
                "msg_type": parts[2],
                "frame_len": int(parts[3]),
                "frame": frame,
                "raw_hex": bytes_to_hex(frame),
            }
        except Exception:
            return None

    if line.startswith("CAN,"):
        # CAN frame from Arduino serial bridge: CAN,<ms>,RX,<id>,<ext>,<dlc>,<data>
        parts = line.split(",", 6)
        if len(parts) != 7:
            return None
        try:
            data = parse_hex_bytes(parts[6])
            return {
                "kind": "CAN",
                "arduino_ms": int(parts[1]),
                "msg_type": parts[2],
                "can_id": int(parts[3], 16),
                "extended": bool(int(parts[4])),
                "dlc": int(parts[5]),
                "data": data,
                "raw_hex": bytes_to_hex(data),
            }
        except Exception:
            return None

    return None


# -----------------------------------------------------------------------------
# BEAN-DBC model
# -----------------------------------------------------------------------------

@dataclass
class DecodedSignal:
    name: str
    value: Any
    text: str
    unit: str = ""
    card: str = ""
    plot: bool = False
    raw: Any = None
    raw_bytes: list[int] = field(default_factory=list)
    kind: str = ""
    offset: int = 0

    def raw_decimal_text(self) -> str:
        if isinstance(self.raw, bool):
            return "1" if self.raw else "0"
        if isinstance(self.raw, int):
            return str(self.raw)
        if isinstance(self.value, (int, float, bool)):
            return str(int(self.value)) if isinstance(self.value, bool) else str(self.value)
        return str(self.raw) if self.raw is not None else "--"

    def raw_hex_text(self) -> str:
        if self.raw_bytes:
            return " ".join(f"0x{b & 0xFF:02X}" for b in self.raw_bytes)
        if isinstance(self.raw, int):
            return f"0x{self.raw:X}"
        return "--"

    def binary_text(self) -> str:
        if self.raw_bytes:
            return " ".join(f"0b{b & 0xFF:08b}" for b in self.raw_bytes)
        if isinstance(self.raw, int):
            return f"0b{self.raw:b}"
        return "--"

    def boolean_text(self) -> str:
        if isinstance(self.value, bool):
            return "ON" if self.value else "OFF"
        if isinstance(self.raw, int):
            return "ON" if self.raw != 0 else "OFF"
        if isinstance(self.value, list):
            return "ON" if len(self.value) else "OFF"
        return "--"

    def decoded_value_text(self) -> str:
        if isinstance(self.value, list):
            text = " | ".join(map(str, self.value)) if self.value else "none"
        elif isinstance(self.value, (int, float)) and not isinstance(self.value, bool):
            text = format_decoded_numeric_value(self.name, self.value, self.unit)
        else:
            text = str(self.value)
        if self.unit and isinstance(self.value, (int, float)) and not isinstance(self.value, bool):
            text += f" {self.unit}"
        return text

    def display_text(self, mode: str = "Decoded") -> str:
        mode = mode.lower()
        if mode.startswith("decoded"):
            return self.decoded_value_text()
        if mode.startswith("full"):
            return self.text
        if mode.startswith("raw dec"):
            return self.raw_decimal_text()
        if mode.startswith("raw hex"):
            return self.raw_hex_text()
        if mode.startswith("binary"):
            return self.binary_text()
        if mode.startswith("boolean"):
            return self.boolean_text()
        return self.decoded_value_text()


@dataclass
class DecodeResult:
    decoded: bool = False
    message_name: str = ""
    category: str = "unknown"
    function: str = ""
    source: str = ""
    source_ecu: str = ""
    doc_status: str = ""
    context: Optional[int] = None
    did: Optional[int] = None
    mid: Optional[int] = None
    data: list[int] = field(default_factory=list)
    text: str = "Unknown / undecoded"
    signals: list[DecodedSignal] = field(default_factory=list)


class BeanDbc:
    def __init__(self, path: Path):
        self.path = path
        self.raw: dict[str, Any] = {}
        self.plausible_dids: set[int] = set()
        self.messages: list[dict[str, Any]] = []
        self.load(path)

    def load(self, path: Path):
        self.path = path
        self.raw = json.loads(path.read_text(encoding="utf-8"))
        self.plausible_dids = {int(x, 16) for x in self.raw.get("plausible_dids", [])}
        self.messages = self.raw.get("messages", [])

    def save(self, path: Optional[Path] = None):
        if path is not None:
            self.path = path
        self.raw["messages"] = self.messages
        self.path.write_text(json.dumps(self.raw, indent=2, ensure_ascii=False), encoding="utf-8")

    def find_packet(self, frame: list[int]) -> tuple[Optional[int], Optional[int], Optional[int], list[int], Optional[dict[str, Any]]]:
        if len(frame) < 3:
            return None, None, None, [], None

        # Preferred parser for real BEANalyzer captures:
        #   01 HEADER DST-ID MES-ID DATA... CRC 7E POST
        # HEADER = PRI(4 bits) + ML(4 bits)
        # ML = length(DST-ID + MES-ID + DATA), therefore DATA length = ML - 2.
        # This avoids treating CRC/EOM/Post-EOM bytes as DATA.
        if len(frame) >= 6 and frame[0] == 0x01:
            header = frame[1]
            ml = header & 0x0F
            crc_index = 2 + ml
            if 3 <= ml <= 13 and len(frame) > crc_index:
                core = frame[1:crc_index]
                crc_rx = frame[crc_index]
                crc_calc = bean_crc8(core)
                if crc_rx == crc_calc:
                    did = frame[2]
                    mid = frame[3]
                    data = frame[4:crc_index]
                    for msg in self.messages:
                        try:
                            msg_mid = int(str(msg.get("mid", "-1")), 16)
                        except Exception:
                            continue
                        if msg_mid != mid:
                            continue
                        msg_did_text = msg.get("did")
                        if msg_did_text:
                            try:
                                if did != int(str(msg_did_text), 16):
                                    continue
                            except Exception:
                                continue
                        else:
                            if did not in self.plausible_dids:
                                continue
                        return header, did, mid, data, msg
                    return header, did, mid, data, None

        # Legacy fallback: search DID/MID anywhere in the frame.
        # Used only when the physical CRC/ML parser cannot validate the frame.
        for i in range(0, len(frame) - 2):
            did = frame[i]
            mid = frame[i + 1]
            for msg in self.messages:
                try:
                    msg_mid = int(str(msg.get("mid", "-1")), 16)
                except Exception:
                    continue
                if msg_mid != mid:
                    continue
                msg_did_text = msg.get("did")
                if msg_did_text:
                    try:
                        if did != int(str(msg_did_text), 16):
                            continue
                    except Exception:
                        continue
                else:
                    if did not in self.plausible_dids:
                        continue
                context = frame[i - 1] if i > 0 else None
                data = frame[i + 2:]
                return context, did, mid, data, msg
        return None, None, None, [], None

    def decode(self, frame: list[int]) -> DecodeResult:
        context, did, mid, data, msg = self.find_packet(frame)
        if not msg:
            return DecodeResult(context=context, did=did, mid=mid, data=data)
        res = DecodeResult(
            decoded=True,
            message_name=msg.get("name", "Unnamed"),
            category=msg.get("category", "unknown"),
            function=msg.get("function", ""),
            source=msg.get("source", ""),
            source_ecu=msg.get("source_ecu", ""),
            doc_status=msg.get("doc_status", ""),
            context=context,
            did=did,
            mid=mid,
            data=data,
        )
        parts = []
        for sig in msg.get("signals", []):
            decoded = self.decode_signal(sig, data, msg)
            if decoded:
                res.signals.append(decoded)
                parts.append(decoded.text)
        res.text = res.message_name + (": " + ", ".join(parts) if parts else "")
        return res

    def decode_signal(self, sig: dict[str, Any], data: list[int], msg: dict[str, Any]) -> Optional[DecodedSignal]:
        kind = sig.get("kind", "u8")
        name = sig.get("name", "signal")
        unit = sig.get("unit", "")
        card = sig.get("card", "")
        plot = bool(sig.get("plot", False))

        if kind == "constant":
            value = sig.get("value", "active")
            return DecodedSignal(name, value, f"{name}: {value}", unit, card, plot, raw=value, raw_bytes=[], kind=kind, offset=-1)

        offset = int(sig.get("offset", 0))
        if offset >= len(data):
            return None

        if kind == "u8":
            raw = data[offset]
            special = msg.get("special_values", {})
            if hex_byte(raw) in special and "formula" in sig:
                value = special[hex_byte(raw)]
                return DecodedSignal(name, value, f"{name}: {value}", unit, card, plot, raw=raw, raw_bytes=[raw], kind=kind, offset=offset)
            value = raw
            if "formula" in sig:
                try:
                    value = safe_eval_formula(sig["formula"], raw, data)
                except Exception:
                    value = raw
            return DecodedSignal(name, value, self.format_signal(name, value, unit), unit, card, plot, raw=raw, raw_bytes=[raw], kind=kind, offset=offset)

        if kind == "u16be":
            if offset + 1 >= len(data):
                return None
            raw = read_u16_be(data[offset], data[offset + 1])
            value = raw
            if "formula" in sig:
                try:
                    value = safe_eval_formula(sig["formula"], raw, data)
                except Exception:
                    value = raw
            return DecodedSignal(name, value, self.format_signal(name, value, unit), unit, card, plot, raw=raw, raw_bytes=[data[offset], data[offset + 1]], kind=kind, offset=offset)

        if kind == "bool":
            raw = data[offset]
            mask = int(str(sig.get("mask", "01")), 16)
            value = bool(raw & mask)
            return DecodedSignal(name, value, f"{name}: {'ON' if value else 'OFF'}", unit, card, plot, raw=raw, raw_bytes=[raw], kind=kind, offset=offset)

        if kind == "flags":
            raw = data[offset]
            items = []
            for bit_hex, label in sig.get("flags", {}).items():
                if raw & int(bit_hex, 16):
                    items.append(str(label))
            return DecodedSignal(name, items, f"{name}: " + (" | ".join(items) if items else "none"), unit, card, plot, raw=raw, raw_bytes=[raw], kind=kind, offset=offset)

        if kind == "map":
            raw = data[offset]
            value = sig.get("values", {}).get(hex_byte(raw), f"unknown 0x{raw:02X}")
            return DecodedSignal(name, value, f"{name}: {value}", unit, card, plot, raw=raw, raw_bytes=[raw], kind=kind, offset=offset)

        return None

    @staticmethod
    def format_signal(name: str, value: Any, unit: str) -> str:
        if isinstance(value, (int, float)) and not isinstance(value, bool):
            text = format_decoded_numeric_value(name, value, unit)
        else:
            text = str(value)
        return f"{name}: {text} {unit}" if unit else f"{name}: {text}"



# -----------------------------------------------------------------------------
# Unit conversion layer
# -----------------------------------------------------------------------------

UNIT_MODE_METRIC = "Metric"
UNIT_MODE_IMPERIAL = "Imperial"
UNIT_MODE_FORCE_IMPERIAL = "Force Imperial Units"
UNIT_MODE_RAW = "Raw ECU / decoded"


def is_rpm_signal(name: str, unit: str = "") -> bool:
    n = str(name or "").lower()
    u = str(unit or "").lower().replace(" ", "")
    return u in ("rpm", "r/min", "rev/min") or n in ("rpm", "engine_rpm", "tachometer_rpm") or n.endswith("_rpm") or "rpm" in n


def format_decoded_numeric_value(name: str, value: Any, unit: str) -> str:
    """Format decoded numeric values for UI.

    RPM is intentionally displayed as an integer because a dashboard tachometer
    does not show fractional RPM and decimals make the monitor noisy.
    """
    if isinstance(value, float) and is_rpm_signal(name, unit):
        return str(int(round(value)))
    if isinstance(value, int) and is_rpm_signal(name, unit):
        return str(int(value))
    if isinstance(value, float):
        return f"{value:.2f}".rstrip("0").rstrip(".")
    return str(value)


def convert_signal_for_units(sig: DecodedSignal, mode: str) -> DecodedSignal:
    """Return a display-oriented signal converted to the selected unit system.

    The decoder should keep values in the ECU/base decoded unit. This layer converts only
    user-facing display values. Raw decimal/hex/binary modes remain based on the original
    raw bytes stored in the signal.
    """
    if mode == UNIT_MODE_RAW:
        return sig
    if not isinstance(sig.value, (int, float)) or isinstance(sig.value, bool):
        return sig

    name = (sig.name or "").lower()
    unit = (sig.unit or "").strip()
    value = float(sig.value)
    new_value = value
    new_unit = unit
    converted = False

    imperial = mode in (UNIT_MODE_IMPERIAL, UNIT_MODE_FORCE_IMPERIAL)

    # Temperature
    if imperial and (unit in ("°C", "C", "degC") or "temp" in name or "temperature" in name):
        new_value = value * 9.0 / 5.0 + 32.0
        new_unit = "°F"
        converted = True
    elif not imperial and unit in ("°F", "F", "degF"):
        new_value = (value - 32.0) * 5.0 / 9.0
        new_unit = "°C"
        converted = True

    # Speed
    elif imperial and (unit in ("km/h", "kph") or name in ("speed", "vehicle_speed") or "speed" in name or (mode == UNIT_MODE_FORCE_IMPERIAL and ("speed" in name or name in ("rpm_speed",)))):
        new_value = value * 0.621371
        new_unit = "mph"
        converted = True
    elif not imperial and unit in ("mph", "MPH"):
        new_value = value / 0.621371
        new_unit = "km/h"
        converted = True

    # Distance
    elif imperial and (unit == "km" or name.endswith("_km") or "distance" in name or (mode == UNIT_MODE_FORCE_IMPERIAL and ("odo" in name or "trip" in name and "unit" not in name))):
        new_value = value * 0.621371
        new_unit = "mi"
        converted = True
    elif not imperial and unit in ("mi", "mile", "miles"):
        new_value = value / 0.621371
        new_unit = "km"
        converted = True

    # Fuel economy / consumption
    # L/100km -> MPG US by default. This is the most common "Imperial" display users expect.
    elif imperial and unit.lower().replace(" ", "") in ("l/100km", "l/100kms"):
        new_value = 235.214583 / value if value else 0.0
        new_unit = "mpg US"
        converted = True
    elif not imperial and unit.lower().replace(" ", "") in ("mpgus", "mpg"):
        new_value = 235.214583 / value if value else 0.0
        new_unit = "L/100km"
        converted = True
    # km/L -> MPG US
    elif imperial and unit.lower().replace(" ", "") in ("km/l", "kmpl"):
        new_value = value * 2.35214583
        new_unit = "mpg US"
        converted = True
    elif not imperial and unit.lower().replace(" ", "") in ("mpgus_from_kml",):
        new_value = value / 2.35214583
        new_unit = "km/L"
        converted = True
    # Forced mode fallbacks for signals where DBC unit is missing but signal name is clear.
    elif mode == UNIT_MODE_FORCE_IMPERIAL and not unit and ("fuel_rate" in name or "fuel_consumption" in name):
        # Conservative fallback: many Toyota trip streams are interpreted as L/h or L/100km depending on MID.
        # Without a unit we do not know the exact domain; display as "imperial converted candidate".
        new_value = value * 0.264172052
        new_unit = "gal/h US candidate"
        converted = True

    # L/h remains L/h in metric; optional approximate gal/h in imperial.
    elif imperial and unit.lower().replace(" ", "") in ("l/h", "lph"):
        new_value = value * 0.264172052
        new_unit = "gal/h US"
        converted = True

    if not converted:
        return sig

    # Preserve raw bytes/raw value for Raw Dec/Hex/Binary card modes.
    text_value = format_decoded_numeric_value(sig.name, new_value, new_unit)
    text = f"{sig.name}: {text_value} {new_unit}"
    return DecodedSignal(
        name=sig.name,
        value=new_value,
        text=text,
        unit=new_unit,
        card=sig.card,
        plot=sig.plot,
        raw=sig.raw,
        raw_bytes=list(sig.raw_bytes),
        kind=sig.kind,
        offset=sig.offset,
    )


# -----------------------------------------------------------------------------
# Device interfaces
# -----------------------------------------------------------------------------

class SerialReader(QThread):
    frame_received = Signal(dict)
    info_received = Signal(str)
    error_received = Signal(str)
    status_received = Signal(str)

    def __init__(self, port: str, baudrate: int = BAUDRATE):
        super().__init__()
        self.port = port
        self.baudrate = baudrate
        self.running = False
        self.ser: Optional[serial.Serial] = None

    def run(self):
        try:
            self.ser = serial.Serial(self.port, self.baudrate, timeout=0.1)
        except Exception as exc:
            self.error_received.emit(f"Serial open failed: {exc}")
            return
        self.running = True
        self.status_received.emit(f"Connected to {self.port}")
        while self.running:
            try:
                line = self.ser.readline().decode("utf-8", errors="replace").strip()
            except Exception as exc:
                self.error_received.emit(f"Serial read failed: {exc}")
                break
            if not line:
                continue
            parsed = parse_capture_line(line)
            if parsed:
                self.frame_received.emit(parsed)
            else:
                self.info_received.emit(line)
        try:
            if self.ser:
                self.ser.close()
        except Exception:
            pass
        self.status_received.emit("Serial disconnected")

    def stop(self):
        self.running = False

    def write_line(self, line: str):
        if self.ser and self.ser.is_open:
            self.ser.write((line.strip() + "\n").encode("ascii"))


class PythonCanBus:
    def __init__(self):
        self.bus = None
        self.on_tx = None

    def connect(self, interface: str, channel: str, bitrate: int, **kwargs):
        if can is None:
            raise RuntimeError("python-can is not installed. Install requirements.txt or python-can[canalystii].")
        params = {"interface": interface, "channel": channel, "bitrate": bitrate}
        params.update({k: v for k, v in kwargs.items() if v not in (None, "")})
        self.bus = can.interface.Bus(**params)

    def disconnect(self):
        if self.bus:
            try:
                self.bus.shutdown()
            except Exception:
                pass
        self.bus = None

    def send(self, can_id: int, data: list[int], extended: bool = False):
        if not self.bus:
            raise RuntimeError("CAN bus is not connected")
        msg = can.Message(arbitration_id=can_id, data=bytes(data[:8]), is_extended_id=extended)
        self.bus.send(msg)
        if self.on_tx:
            self.on_tx({
                "kind": "CAN",
                "direction": "TX",
                "msg_type": "TX",
                "can_id": can_id,
                "extended": extended,
                "dlc": len(data[:8]),
                "data": list(data[:8]),
                "source": "python-can",
            })


class CanRxWorker(QThread):
    frame_received = Signal(dict)
    status_received = Signal(str)
    error_received = Signal(str)

    def __init__(self, pycan: PythonCanBus):
        super().__init__()
        self.pycan = pycan
        self.running = False

    def run(self):
        if not self.pycan.bus:
            self.error_received.emit("python-can RX worker started without an active bus")
            return
        self.running = True
        self.status_received.emit("python-can RX monitor started")
        while self.running:
            try:
                msg = self.pycan.bus.recv(timeout=0.1)
            except Exception as exc:
                self.error_received.emit(f"python-can receive failed: {exc}")
                break
            if msg is None:
                continue
            data = list(bytes(msg.data))
            self.frame_received.emit({
                "kind": "CAN",
                "direction": "RX",
                "msg_type": "RX",
                "can_id": int(msg.arbitration_id),
                "extended": bool(msg.is_extended_id),
                "dlc": int(getattr(msg, "dlc", len(data))),
                "data": data,
                "timestamp": float(getattr(msg, "timestamp", time.time()) or time.time()),
                "source": "python-can",
            })
        self.status_received.emit("python-can RX monitor stopped")

    def stop(self):
        self.running = False


class SerialCanBridge:
    def __init__(self):
        self.reader: Optional[SerialReader] = None
        self.on_tx = None

    def attach_reader(self, reader: SerialReader):
        self.reader = reader

    def send(self, can_id: int, data: list[int], extended: bool = False):
        if not self.reader:
            raise RuntimeError("Arduino CAN serial bridge is not attached")
        line = f"CTX,{can_id:X},{1 if extended else 0},{len(data)},{bytes_to_hex(data)}"
        self.reader.write_line(line)
        if self.on_tx:
            self.on_tx({
                "kind": "CAN",
                "direction": "TX",
                "msg_type": "TX",
                "can_id": can_id,
                "extended": extended,
                "dlc": len(data[:8]),
                "data": list(data[:8]),
                "source": "Arduino CAN serial bridge",
            })


class CanGateway:
    def __init__(self, map_path: Path):
        self.map_path = map_path
        self.raw_map = json.loads(map_path.read_text(encoding="utf-8")) if map_path.exists() else {"messages": []}
        self.signal_cache: dict[str, Any] = {}
        self.last_tx: dict[str, float] = {}
        self.enabled = False
        self.sender: Optional[Any] = None

    def set_sender(self, sender: Any):
        self.sender = sender

    def update_signals(self, signals: list[DecodedSignal]):
        for sig in signals:
            if isinstance(sig.value, (int, float, bool)):
                self.signal_cache[sig.name] = sig.value

    def tick(self):
        if not self.enabled or not self.sender:
            return
        now = time.time()
        for msg in self.raw_map.get("messages", []):
            name = msg.get("name", str(msg.get("can_id")))
            period = int(msg.get("period_ms", 100))
            if (now - self.last_tx.get(name, 0)) * 1000 < period:
                continue
            self.last_tx[name] = now
            self.send_mapped(msg)

    def send_mapped(self, msg: dict[str, Any]):
        can_id = int(str(msg.get("can_id", "0")), 16)
        extended = bool(msg.get("extended", False))
        data = [0] * 8
        for sig in msg.get("signals", []):
            source = sig.get("source")
            byte = int(sig.get("byte", 0))
            typ = sig.get("type", "u8")
            invalid = sig.get("invalid", 0)
            value = self.signal_cache.get(source, invalid)
            try:
                value = float(value)
            except Exception:
                value = float(invalid)
            if typ == "u8":
                data[byte] = int(clamp(value, 0, 255))
            elif typ in ("u8_offset40", "s8_offset40"):
                data[byte] = int(clamp(value + 40, 0, 255))
            elif typ == "u16be":
                scale = float(sig.get("scale", 1.0)) or 1.0
                raw = int(clamp(value / scale, 0, 65535))
                data[byte] = (raw >> 8) & 0xFF
                data[byte + 1] = raw & 0xFF
            elif typ == "u16be_x10":
                raw = int(clamp(value * 10.0, 0, 65535))
                data[byte] = (raw >> 8) & 0xFF
                data[byte + 1] = raw & 0xFF
        self.sender.send(can_id, data, extended)



class ReplayFilter:
    """
    Replay include-list filter.

    Keys follow the same logic as the Message Monitor:
    - BEAN known/decoded frames: CTX + DID + MID
    - BEAN fallback frames: first bytes
    - CAN frames: CAN ID

    If no keys are enabled, the filter is disabled and replay passes everything.
    """
    def __init__(self, enabled_keys: Optional[set[str]] = None):
        self.enabled_keys = enabled_keys or set()

    def enabled(self) -> bool:
        return bool(self.enabled_keys)

    def allows(self, parsed: dict[str, Any]) -> bool:
        if not self.enabled_keys:
            return True
        key = self.key_from_parsed(parsed)
        return key in self.enabled_keys

    @staticmethod
    def bean_key_from_frame(frame: list[int]) -> tuple[str, str, str, str]:
        # Typical BEANalyzer raw frame: 01 CTX DID MID DATA... 7E EOF
        if len(frame) >= 4 and frame[0] == 0x01:
            return ("BEAN", hex_byte(frame[1]), hex_byte(frame[2]), hex_byte(frame[3]))

        # Fallback: try to use first 3 useful bytes as CTX/DID/MID-ish.
        if len(frame) >= 3:
            return ("BEAN", "", hex_byte(frame[0]), hex_byte(frame[1]))

        return ("BEAN_UNKNOWN", bytes_to_hex(frame[:4]), "", "")

    @classmethod
    def key_from_parsed(cls, parsed: dict[str, Any]) -> str:
        kind = str(parsed.get("kind", "BEAN")).upper()
        if kind == "CAN":
            return f"CAN|{int(parsed.get('can_id', 0)):X}"

        frame = list(parsed.get("frame") or [])
        bus, ctx, did, mid = cls.bean_key_from_frame(frame)
        return f"{bus}|{ctx}|{did}|{mid}"

    @classmethod
    def label_from_parsed(cls, parsed: dict[str, Any]) -> str:
        kind = str(parsed.get("kind", "BEAN")).upper()
        if kind == "CAN":
            return f"CAN ID 0x{int(parsed.get('can_id', 0)):X}"

        frame = list(parsed.get("frame") or [])
        bus, ctx, did, mid = cls.bean_key_from_frame(frame)
        if bus == "BEAN":
            if ctx:
                return f"BEAN CTX {ctx} / DID {did} / MID {mid}"
            return f"BEAN DID {did} / MID {mid}"
        return f"BEAN unknown {ctx}"



# -----------------------------------------------------------------------------
# Replay worker
# -----------------------------------------------------------------------------

class ReplayWorker(QThread):
    frame_received = Signal(dict)
    status_received = Signal(str)
    finished_replay = Signal()
    bean_stream_frame = Signal(dict)

    def __init__(self, csv_path: Path, speed: float, bean_stream_enabled: bool = False, replay_filter: Optional[ReplayFilter] = None, loop_mode: bool = False):
        super().__init__()
        self.csv_path = csv_path
        self.speed = max(0.1, speed)
        self.bean_stream_enabled = bool(bean_stream_enabled)
        self.replay_filter = replay_filter or ReplayFilter()
        self.loop_mode = bool(loop_mode)
        self.running = False

    @staticmethod
    def _parse_int(value: Any, default: int = 0) -> int:
        try:
            if value is None:
                return default
            text = str(value).strip()
            if not text:
                return default
            if text.lower().startswith("0x"):
                return int(text, 16)
            return int(float(text))
        except Exception:
            return default

    @staticmethod
    def _parse_can_id(row: dict[str, str], raw: str) -> int:
        # Preferred columns from BEANalyzer exports.
        for key in ("can_id", "mid", "id", "arbitration_id"):
            value = (row.get(key) or "").strip()
            if not value:
                continue
            try:
                return int(value.replace("0x", "").replace("0X", ""), 16)
            except Exception:
                pass

        # Common candump/python-can style: 123#11223344
        if "#" in raw:
            left = raw.split("#", 1)[0].strip()
            return int(left.replace("0x", "").replace("0X", ""), 16)

        return 0

    @staticmethod
    def _parse_can_data(row: dict[str, str], raw: str) -> list[int]:
        if "#" in raw:
            return parse_hex_bytes(raw.split("#", 1)[1])
        return parse_hex_bytes(row.get("data") or row.get("raw_hex") or row.get("payload_hex") or "")

    @staticmethod
    def _parse_bean_frame(row: dict[str, str], raw: str) -> list[int]:
        # Best path: BEANalyzer CSV raw column contains the full frame.
        frame = parse_hex_bytes(raw)
        if frame:
            return frame

        # Fallback for partially edited CSVs: try raw_hex.
        frame = parse_hex_bytes(row.get("raw_hex") or row.get("frame") or "")
        if frame:
            return frame

        # Last-resort reconstruction from context/DID/MID/data when available.
        ctx = parse_hex_bytes(row.get("context") or row.get("ctx") or "")
        did = parse_hex_bytes(row.get("did") or "")
        mid = parse_hex_bytes(row.get("mid") or "")
        data = parse_hex_bytes(row.get("data") or "")
        rebuilt = ctx + did + mid + data
        return rebuilt

    @staticmethod
    def _strip_bean_tail(data: list[int]) -> list[int]:
        """Remove typical captured BEAN EOM/EOF bytes from replay TX payload data."""
        out = list(data)
        # Common captured frame trailer: 7E 40 or 7E 00.
        if len(out) >= 2 and out[-2] == 0x7E and out[-1] in (0x00, 0x40):
            return out[:-2]
        # Conservative fallback if the capture only kept EOM.
        if out and out[-1] == 0x7E:
            return out[:-1]
        return out

    @classmethod
    def _bean_tx_payload_from_row(cls, row: dict[str, str], frame: list[int]) -> list[int]:
        """
        Build the payload expected by the Arduino BEAN bridge BTX command.

        BEANalyzer live/raw captures often contain:
            LEN/marker, CTX, DID, MID, DATA..., 7E, EOF

        The Arduino TX command expects only:
            DID, MID, DATA...
        """
        did_col = parse_hex_bytes(row.get("did") or "")
        mid_col = parse_hex_bytes(row.get("mid") or "")
        data_col = cls._strip_bean_tail(parse_hex_bytes(row.get("data") or ""))

        if did_col and mid_col:
            return [did_col[0], mid_col[0]] + data_col

        # Typical BEANalyzer raw frame: 01 CTX DID MID DATA... 7E EOF
        if len(frame) >= 4 and frame[0] == 0x01:
            did = frame[2]
            mid = frame[3]
            data = cls._strip_bean_tail(frame[4:])
            return [did, mid] + data

        # If a CSV already stores only the TX payload, use it as-is after tail cleanup.
        return cls._strip_bean_tail(frame)


    def _row_to_frame(self, row: dict[str, str]) -> Optional[dict[str, Any]]:
        raw = (row.get("raw") or row.get("raw_hex") or row.get("frame") or "").strip()
        bus = (row.get("bus") or row.get("kind") or "").strip().upper()
        msg_type = row.get("type") or row.get("msg_type") or "R"
        arduino_ms = self._parse_int(row.get("arduino_ms"), 0)

        # Auto-detect CAN rows even if the bus column is missing.
        if bus == "CAN" or "#" in raw:
            can_id = self._parse_can_id(row, raw)
            data = self._parse_can_data(row, raw)
            return {
                "kind": "CAN",
                "arduino_ms": arduino_ms,
                "msg_type": msg_type or "RX",
                "can_id": can_id,
                "extended": bool(int(row.get("extended", "0") or 0)) if str(row.get("extended", "0")).isdigit() else False,
                "dlc": self._parse_int(row.get("dlc"), len(data)),
                "data": data,
                "raw_hex": bytes_to_hex(data),
                "replay": True,
            }

        frame = self._parse_bean_frame(row, raw)
        if not frame:
            return None

        return {
            "kind": "BEAN",
            "arduino_ms": arduino_ms,
            "msg_type": msg_type or "R",
            "frame": frame,
            "raw_hex": bytes_to_hex(frame),
            "frame_len": len(frame),
            "bean_tx_payload": self._bean_tx_payload_from_row(row, frame),
            "replay": True,
        }

    def run(self):
        self.running = True
        self.status_received.emit(f"Replaying {self.csv_path.name}" + (" [LOOP MODE]" if self.loop_mode else ""))

        total_replayed = 0
        total_skipped = 0
        total_filtered = 0
        pass_index = 0

        try:
            while self.running:
                pass_index += 1
                last_ms = None
                replayed = 0
                skipped = 0
                filtered = 0

                if self.loop_mode:
                    self.status_received.emit(f"Replay loop pass #{pass_index} started.")

                with self.csv_path.open("r", encoding="utf-8-sig", newline="") as f:
                    reader = csv.DictReader(f)
                    if not reader.fieldnames:
                        self.status_received.emit("Replay error: CSV has no header row.")
                        return

                    if pass_index == 1:
                        self.status_received.emit("Replay columns: " + ", ".join(reader.fieldnames))

                    for row in reader:
                        if not self.running:
                            break

                        parsed = self._row_to_frame(row)
                        if parsed is None:
                            skipped += 1
                            continue

                        if not self.replay_filter.allows(parsed):
                            filtered += 1
                            continue

                        arduino_ms = int(parsed.get("arduino_ms", 0))
                        if last_ms is not None:
                            delay_ms = max(0, arduino_ms - last_ms)
                            if delay_ms:
                                time.sleep(delay_ms / 1000.0 / self.speed)
                        last_ms = arduino_ms

                        self.frame_received.emit(parsed)
                        if self.bean_stream_enabled and parsed.get("kind") == "BEAN":
                            self.bean_stream_frame.emit(parsed)
                        replayed += 1

                total_replayed += replayed
                total_skipped += skipped
                total_filtered += filtered

                if not self.loop_mode or not self.running:
                    break

                self.status_received.emit(
                    f"Replay loop pass #{pass_index} finished. "
                    f"Frames replayed: {replayed}. Filtered out: {filtered}. Skipped rows: {skipped}. Restarting..."
                )

            if self.loop_mode and pass_index > 1:
                self.status_received.emit(
                    f"Replay stopped. Passes: {pass_index}. Total frames replayed: {total_replayed}. "
                    f"Filtered out: {total_filtered}. Skipped rows: {total_skipped}."
                )
            else:
                self.status_received.emit(
                    f"Replay finished. Frames replayed: {total_replayed}. "
                    f"Filtered out: {total_filtered}. Skipped rows: {total_skipped}."
                )
        except Exception as exc:
            self.status_received.emit(f"Replay error: {exc}")
        finally:
            self.finished_replay.emit()

    def stop(self):
        self.running = False


# -----------------------------------------------------------------------------
# GUI widgets
# -----------------------------------------------------------------------------

class SignalCard(QGroupBox):
    DISPLAY_MODES = ["Decoded", "Full decoded text", "Raw decimal", "Raw hex", "Binary", "Boolean"]

    def __init__(self, signal_name: str):
        super().__init__(signal_name)
        self.signal_name = signal_name
        self.last_signal: Optional[DecodedSignal] = None

        self.mode_combo = QComboBox()
        self.mode_combo.addItems(self.DISPLAY_MODES)
        self.mode_combo.setToolTip(
            "Choose how this card displays the signal: decoded value, raw decimal, raw hex, binary, boolean, or full decoded text."
        )

        self.label = QLabel("--")
        self.label.setAlignment(Qt.AlignCenter)
        self.label.setWordWrap(True)
        self.label.setStyleSheet("font-size: 22px; font-weight: bold; padding: 8px;")

        self.raw_label = QLabel("")
        self.raw_label.setAlignment(Qt.AlignCenter)
        self.raw_label.setWordWrap(True)
        self.raw_label.setStyleSheet("font-size: 10px; color: #aeb7c2; padding: 2px;")

        lay = QVBoxLayout(self)
        lay.addWidget(self.mode_combo)
        lay.addWidget(self.label, 1)
        lay.addWidget(self.raw_label)

        self.mode_combo.currentTextChanged.connect(self.refresh)

    def set_signal(self, sig: DecodedSignal):
        self.last_signal = sig
        self.refresh()

    def set_value(self, value: Any, unit: str = ""):
        # Backwards-compatible fallback for older calls.
        sig = DecodedSignal(self.signal_name, value, str(value), unit=unit, raw=value, raw_bytes=[], kind="legacy")
        self.set_signal(sig)

    def refresh(self):
        if self.last_signal is None:
            self.label.setText("--")
            self.raw_label.setText("")
            return

        mode = self.mode_combo.currentText()
        self.label.setText(self.last_signal.display_text(mode))

        raw_info = []
        if self.last_signal.kind:
            raw_info.append(f"kind: {self.last_signal.kind}")
        if self.last_signal.raw is not None:
            raw_info.append(f"raw dec: {self.last_signal.raw_decimal_text()}")
        if self.last_signal.raw_bytes:
            raw_info.append(f"raw hex: {self.last_signal.raw_hex_text()}")
        self.raw_label.setText(" | ".join(raw_info))



# -----------------------------------------------------------------------------
# CAN gateway live viewer / TX console
# -----------------------------------------------------------------------------

class CanGatewayMonitorWindow(QMainWindow):
    """Live CAN gateway monitor with manual CAN transmit console."""

    def __init__(self, parent_app):
        super().__init__(parent_app)
        self.parent_app = parent_app
        self.setWindowTitle("BEANalyzer CAN Gateway Monitor")
        screen = QApplication.primaryScreen()
        if screen:
            available = screen.availableGeometry()
            self.resize(min(1500, int(available.width() * 0.92)), min(820, int(available.height() * 0.82)))
        else:
            self.resize(1400, 760)
        self.setMinimumSize(980, 560)
        self.tx_timer = QTimer(self)
        self.tx_timer.timeout.connect(self._tx_timer_tick)
        self.tx_remaining = 0
        self.build_ui()

    def build_ui(self):
        root = QWidget(); self.setCentralWidget(root)
        layout = QVBoxLayout(root)

        top = QHBoxLayout()
        self.backend_combo = QComboBox(); self.backend_combo.addItems(["python-can", "Arduino CAN serial bridge"])
        self.id_edit = QLineEdit(); self.id_edit.setPlaceholderText("ID e.g. 200 or 18DAF110"); self.id_edit.setMaximumWidth(160)
        self.data_edit = QLineEdit(); self.data_edit.setPlaceholderText("Data bytes e.g. 01 02 03 04 05 06 07 08")
        self.ext_check = QCheckBox("Extended")
        self.count_spin = QSpinBox(); self.count_spin.setRange(0, 100000); self.count_spin.setValue(1); self.count_spin.setToolTip("0 = continuous loop")
        self.delay_spin = QSpinBox(); self.delay_spin.setRange(1, 60000); self.delay_spin.setValue(250); self.delay_spin.setSuffix(" ms")
        self.send_btn = QPushButton("Send / Start")
        self.stop_btn = QPushButton("Stop")
        self.clear_btn = QPushButton("Clear")
        self.export_csv_btn = QPushButton("Export CSV...")
        self.export_json_btn = QPushButton("Export JSON...")
        top.addWidget(QLabel("Backend:")); top.addWidget(self.backend_combo)
        top.addWidget(QLabel("ID:")); top.addWidget(self.id_edit)
        top.addWidget(QLabel("Data:")); top.addWidget(self.data_edit, 1)
        top.addWidget(self.ext_check)
        top.addWidget(QLabel("Count:")); top.addWidget(self.count_spin)
        top.addWidget(QLabel("Delay:")); top.addWidget(self.delay_spin)
        top.addWidget(self.send_btn); top.addWidget(self.stop_btn); top.addWidget(self.clear_btn); top.addWidget(self.export_csv_btn); top.addWidget(self.export_json_btn)

        top_widget = QWidget(); top_widget.setLayout(top)
        top_scroll = QScrollArea(); top_scroll.setWidgetResizable(True); top_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded); top_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff); top_scroll.setFrameShape(QFrame.NoFrame); top_scroll.setMaximumHeight(58); top_scroll.setWidget(top_widget)
        layout.addWidget(top_scroll, 0)

        self.filter_edit = QLineEdit(); self.filter_edit.setPlaceholderText("Filter by direction, backend, CAN ID, data, raw...")
        layout.addWidget(self.filter_edit)

        self.table = QTableWidget(); self.table.setColumnCount(11)
        self.table.setHorizontalHeaderLabels(["PC Time", "Direction", "Backend", "CAN ID", "Ext", "DLC", "Data", "Raw", "Count ID", "Delta ID ms", "Notes"])
        for idx, width in enumerate([115, 80, 170, 110, 55, 55, 260, 320, 90, 100, 260]):
            self.table.setColumnWidth(idx, width)
        self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Interactive)
        self.table.setAlternatingRowColors(True); self.table.setWordWrap(False)
        self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
        self.table.setHorizontalScrollMode(QAbstractItemView.ScrollPerPixel); self.table.setVerticalScrollMode(QAbstractItemView.ScrollPerPixel)
        layout.addWidget(self.table, 1)

        self.status_label = QLabel("Gateway monitor ready. RX frames appear from python-can RX worker or Arduino CAN serial bridge. TX frames appear when sent by manual TX or gateway mapping.")
        self.status_label.setStyleSheet("color: #aeb7c2;")
        layout.addWidget(self.status_label)

        self._rows = []
        self._last_by_id = {}
        self._count_by_id = {}
        self.send_btn.clicked.connect(self.start_tx)
        self.stop_btn.clicked.connect(self.stop_tx)
        self.clear_btn.clicked.connect(self.clear)
        self.export_csv_btn.clicked.connect(self.export_csv)
        self.export_json_btn.clicked.connect(self.export_json)
        self.filter_edit.textChanged.connect(self.apply_filter)

    def clear(self):
        self.table.setRowCount(0); self._rows.clear(); self._last_by_id.clear(); self._count_by_id.clear()

    def add_event(self, event: dict[str, Any]):
        if event.get("kind") != "CAN":
            return
        can_id = int(event.get("can_id", 0))
        data = list(event.get("data", []))[:8]
        direction = str(event.get("direction") or event.get("msg_type") or "RX").upper()
        backend = str(event.get("source") or event.get("backend") or "")
        extended = bool(event.get("extended", False))
        now = time.time(); key = (backend, can_id, extended, direction)
        last = self._last_by_id.get(key)
        delta = "" if last is None else str(int((now - last) * 1000))
        self._last_by_id[key] = now
        self._count_by_id[key] = self._count_by_id.get(key, 0) + 1
        raw = f"{can_id:X}#{bytes_to_hex(data)}"
        row_data = {
            "pc_time": datetime.now().strftime("%H:%M:%S.%f")[:-3],
            "direction": direction,
            "backend": backend,
            "can_id": f"0x{can_id:X}",
            "extended": "YES" if extended else "NO",
            "dlc": str(int(event.get("dlc", len(data)))),
            "data": bytes_to_hex(data),
            "raw": raw,
            "count_id": str(self._count_by_id[key]),
            "delta_id_ms": delta,
            "notes": str(event.get("notes", "")),
        }
        self._rows.append(row_data)
        row = self.table.rowCount(); self.table.insertRow(row)
        values = [row_data[k] for k in ["pc_time", "direction", "backend", "can_id", "extended", "dlc", "data", "raw", "count_id", "delta_id_ms", "notes"]]
        bg = QColor("#143b22") if direction == "RX" else QColor("#3b2514")
        for col, value in enumerate(values):
            item = QTableWidgetItem(str(value)); item.setFlags(item.flags() ^ Qt.ItemIsEditable); item.setBackground(bg)
            self.table.setItem(row, col, item)
        if self.table.rowCount() > MAX_ROWS:
            self.table.removeRow(0)
        self.table.scrollToBottom()
        self.apply_filter_to_row(row)

    def apply_filter(self):
        for row in range(self.table.rowCount()):
            self.apply_filter_to_row(row)

    def apply_filter_to_row(self, row: int):
        text = self.filter_edit.text().strip().lower()
        if not text:
            self.table.setRowHidden(row, False); return
        joined = " ".join(self.table.item(row, col).text() if self.table.item(row, col) else "" for col in range(self.table.columnCount())).lower()
        self.table.setRowHidden(row, text not in joined)

    def parse_tx_fields(self):
        can_id = int(self.id_edit.text().strip().replace("0x", ""), 16)
        data = parse_hex_bytes(self.data_edit.text().strip())
        return can_id, data, self.ext_check.isChecked(), self.backend_combo.currentText()

    def start_tx(self):
        try:
            self.tx_remaining = self.count_spin.value()
            self.send_once()
            if self.count_spin.value() == 0 or self.count_spin.value() > 1:
                self.tx_timer.start(self.delay_spin.value())
        except Exception as exc:
            self.stop_tx(); QMessageBox.critical(self, "CAN TX error", str(exc))

    def _tx_timer_tick(self):
        try:
            if self.count_spin.value() != 0:
                self.tx_remaining -= 1
                if self.tx_remaining <= 1:
                    self.tx_timer.stop(); return
            self.send_once()
        except Exception as exc:
            self.stop_tx(); QMessageBox.critical(self, "CAN TX error", str(exc))

    def send_once(self):
        can_id, data, ext, backend = self.parse_tx_fields()
        self.parent_app.send_can_frame(can_id, data, ext, backend, note="CAN Gateway Monitor manual TX")

    def stop_tx(self):
        self.tx_timer.stop()

    def export_csv(self):
        path, _ = QFileDialog.getSaveFileName(self, "Export CAN gateway monitor CSV", f"beanalyzer_can_gateway_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv", "CSV files (*.csv)")
        if not path:
            return
        keys = ["pc_time", "direction", "backend", "can_id", "extended", "dlc", "data", "raw", "count_id", "delta_id_ms", "notes"]
        with open(path, "w", newline="", encoding="utf-8") as f:
            writer = csv.DictWriter(f, fieldnames=keys); writer.writeheader(); writer.writerows(self._rows)
        QMessageBox.information(self, "CSV exported", f"CAN gateway monitor CSV exported to:\n{path}")

    def export_json(self):
        path, _ = QFileDialog.getSaveFileName(self, "Export CAN gateway monitor JSON", f"beanalyzer_can_gateway_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json", "JSON files (*.json)")
        if not path:
            return
        Path(path).write_text(json.dumps({"exported_at": datetime.now().isoformat(timespec="seconds"), "rows": self._rows}, indent=2, ensure_ascii=False), encoding="utf-8")
        QMessageBox.information(self, "JSON exported", f"CAN gateway monitor JSON exported to:\n{path}")


# -----------------------------------------------------------------------------
# CANalyst-style fixed message monitor window
# -----------------------------------------------------------------------------

class MessageMonitorWindow(QMainWindow):
    """
    Fixed-row message monitor.

    Unlike the Live tab, this window does not append every received frame.
    It keeps one stable row per message key and updates it in place, highlighting
    changed bytes/cells in red for a short time.
    """

    def __init__(self, parent_app):
        super().__init__(parent_app)
        self.parent_app = parent_app
        self.setWindowTitle("BEANalyzer Message Monitor - Fixed ID/Command View")
        screen = QApplication.primaryScreen()
        if screen:
            available = screen.availableGeometry()
            self.resize(
                min(1500, int(available.width() * 0.92)),
                min(760, int(available.height() * 0.82))
            )
        else:
            self.resize(1400, 720)
        self.setMinimumSize(980, 560)

        self.rows_by_key: dict[tuple, int] = {}
        self.last_payloads: dict[tuple, list[int]] = {}
        self.last_update_ms: dict[tuple, int] = {}
        self.counts: dict[tuple, int] = {}
        self.first_seen_order: list[tuple] = []
        self.highlight_until: dict[tuple[int, int], float] = {}
        self.export_rows: dict[str, dict[str, Any]] = {}
        self.column_indexes: dict[str, int] = {}

        self.highlight_duration_ms = 1500
        self.build_ui()

        self.fade_timer = QTimer(self)
        self.fade_timer.timeout.connect(self.fade_highlights)
        self.fade_timer.start(80)

    def build_ui(self):
        root = QWidget()
        self.setCentralWidget(root)
        layout = QVBoxLayout(root)

        top = QHBoxLayout()

        self.filter_edit = QLineEdit()
        self.filter_edit.setPlaceholderText("Filter monitor rows: CTX, DID, MID, name, decoded, raw...")
        self.filter_edit.setMinimumWidth(380)

        self.freeze_check = QCheckBox("Freeze")
        self.freeze_check.setToolTip("Pause monitor table updates without stopping capture/logging")

        self.show_unknown_check = QCheckBox("Show unknown")
        self.show_unknown_check.setChecked(True)

        self.clear_btn = QPushButton("Clear monitor")
        self.export_csv_btn = QPushButton("Export CSV...")
        self.export_json_btn = QPushButton("Export JSON...")
        self.load_dbc_btn = QPushButton("Load decoder JSON...")
        self.reset_dbc_btn = QPushButton("Reload default decoder")

        self.highlight_spin = QSpinBox()
        self.highlight_spin.setRange(100, 10000)
        self.highlight_spin.setValue(self.highlight_duration_ms)
        self.highlight_spin.setSuffix(" ms")
        self.highlight_spin.setToolTip("How long changed bytes stay highlighted")

        top.addWidget(QLabel("Monitor filter:"))
        top.addWidget(self.filter_edit, 1)
        top.addWidget(self.freeze_check)
        top.addWidget(self.show_unknown_check)
        top.addWidget(QLabel("Fade:"))
        top.addWidget(self.highlight_spin)
        top.addWidget(self.clear_btn)
        top.addWidget(self.export_csv_btn)
        top.addWidget(self.export_json_btn)
        top.addWidget(self.load_dbc_btn)
        top.addWidget(self.reset_dbc_btn)

        top_widget = QWidget()
        top_widget.setLayout(top)
        top_scroll = QScrollArea()
        top_scroll.setWidgetResizable(True)
        top_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
        top_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
        top_scroll.setFrameShape(QFrame.NoFrame)
        top_scroll.setMaximumHeight(54)
        top_scroll.setWidget(top_widget)
        layout.addWidget(top_scroll, 0)

        self.table = QTableWidget()
        self.byte_count = 16
        headers = [
            "#", "Bus", "Ctx", "DID", "MID / CAN ID", "Name", "Function",
            "Source ECU", "Count", "Cycle ms", "Last update", "Decoded",
            "Capture", "Header", "PRI", "ML", "DataLen", "DST", "MES",
            "CRC", "CRC OK", "EOM", "Post-EOM", "RSP", "EOF"
        ]
        headers += [f"B{i}" for i in range(self.byte_count)]
        headers += ["Raw"]

        self.column_indexes = {name: idx for idx, name in enumerate(headers)}

        self.table.setColumnCount(len(headers))
        self.table.setHorizontalHeaderLabels(headers)

        widths = [
            55, 55, 45, 45, 90, 220, 190, 240, 75, 80, 115, 420,
            70, 70, 50, 50, 70, 55, 55,
            60, 70, 55, 75, 55, 55
        ]
        widths += [54] * self.byte_count
        widths += [420]

        for idx, width in enumerate(widths):
            self.table.setColumnWidth(idx, width)

        header = self.table.horizontalHeader()
        header.setSectionResizeMode(QHeaderView.Interactive)
        header.setContextMenuPolicy(Qt.CustomContextMenu)
        header.customContextMenuRequested.connect(self.show_column_context_menu)
        self.table.setAlternatingRowColors(True)
        self.table.setWordWrap(False)
        self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
        self.table.setHorizontalScrollMode(QAbstractItemView.ScrollPerPixel)
        self.table.setVerticalScrollMode(QAbstractItemView.ScrollPerPixel)
        self.table.verticalHeader().setDefaultSectionSize(24)

        layout.addWidget(self.table, 1)

        help_text = QLabel(
            "Fixed-row monitor: one row per CTX/DID/MID or CAN ID. "
            "Changed bytes are highlighted in red and fade out automatically. "
            "BEAN physical fields are split into separate columns. "
            "Right-click the table header to show/hide columns."
        )
        help_text.setStyleSheet("color: #aeb7c2;")
        layout.addWidget(help_text)

        self.clear_btn.clicked.connect(self.clear_monitor)
        self.export_csv_btn.clicked.connect(self.export_csv)
        self.export_json_btn.clicked.connect(self.export_json)
        self.load_dbc_btn.clicked.connect(self.load_decoder_json)
        self.reset_dbc_btn.clicked.connect(self.reload_default_decoder)
        self.highlight_spin.valueChanged.connect(lambda v: setattr(self, "highlight_duration_ms", int(v)))
        self.filter_edit.textChanged.connect(self.apply_filter)
        self.show_unknown_check.stateChanged.connect(self.apply_filter)

    def show_column_context_menu(self, pos):
        header = self.table.horizontalHeader()
        menu = QMenu(self)
        menu.setTitle("Show / Hide columns")

        # Group by visual order. All actions are checkable; tick = visible.
        for col in range(self.table.columnCount()):
            header_item = self.table.horizontalHeaderItem(col)
            if not header_item:
                continue
            title = header_item.text()
            action = menu.addAction(title)
            action.setCheckable(True)
            action.setChecked(not self.table.isColumnHidden(col))
            action.toggled.connect(lambda checked, c=col: self.table.setColumnHidden(c, not checked))

            # Add separators after useful groups.
            if title in ("Decoded", "EOF", "B15"):
                menu.addSeparator()

        menu.addSeparator()
        show_all = menu.addAction("Show all columns")
        hide_physical = menu.addAction("Hide BEAN physical fields")
        show_physical = menu.addAction("Show BEAN physical fields")
        compact = menu.addAction("Compact default view")

        selected = menu.exec(header.mapToGlobal(pos))
        if selected == show_all:
            for col in range(self.table.columnCount()):
                self.table.setColumnHidden(col, False)
        elif selected == hide_physical:
            for name in ("Capture", "Header", "PRI", "ML", "DataLen", "DST", "MES", "CRC", "CRC OK", "EOM", "Post-EOM", "RSP", "EOF"):
                col = self.column_indexes.get(name)
                if col is not None:
                    self.table.setColumnHidden(col, True)
        elif selected == show_physical:
            for name in ("Capture", "Header", "PRI", "ML", "DataLen", "DST", "MES", "CRC", "CRC OK", "EOM", "Post-EOM", "RSP", "EOF"):
                col = self.column_indexes.get(name)
                if col is not None:
                    self.table.setColumnHidden(col, False)
        elif selected == compact:
            # Keep common fields + payload bytes + RAW. Hide deep physical fields by default.
            for col in range(self.table.columnCount()):
                self.table.setColumnHidden(col, False)
            for name in ("Capture", "Header", "PRI", "ML", "DataLen", "CRC", "CRC OK", "EOM", "Post-EOM", "RSP", "EOF"):
                col = self.column_indexes.get(name)
                if col is not None:
                    self.table.setColumnHidden(col, True)

    def clear_monitor(self):
        self.table.setRowCount(0)
        self.rows_by_key.clear()
        self.last_payloads.clear()
        self.last_update_ms.clear()
        self.counts.clear()
        self.first_seen_order.clear()
        self.highlight_until.clear()
        self.export_rows.clear()

    def make_key_for_bean(self, context: str, did: str, mid: str, frame: list[int]) -> tuple:
        if did or mid:
            return ("BEAN", context, did, mid)
        return ("BEAN_UNKNOWN", tuple(frame[:4]))

    def make_key_for_can(self, can_id: int) -> tuple:
        return ("CAN", can_id)

    @staticmethod
    def bytes_to_display(payload: list[int], max_len: int) -> list[str]:
        out = []
        for i in range(max_len):
            if i < len(payload):
                out.append(f"{payload[i]:02X}")
            else:
                out.append("")
        return out

    def update_can_frame(self, parsed: dict[str, Any]):
        if self.freeze_check.isChecked():
            return

        can_id = int(parsed.get("can_id", 0))
        payload = list(parsed.get("data", []))
        raw = f"{can_id:X}#{bytes_to_hex(payload)}"
        key = self.make_key_for_can(can_id)

        self.update_row(
            key=key,
            bus="CAN",
            context="",
            did="",
            mid=f"0x{can_id:X}",
            name="CAN frame",
            function="can_frame",
            source_ecu="",
            decoded="CAN frame",
            payload=payload,
            raw=raw,
            category="can",
            physical={},
        )

    def update_bean_frame(self, frame: list[int], parsed: dict[str, Any], decode: DecodeResult, context: str, did: str, mid: str, data_text: str, raw: str):
        if self.freeze_check.isChecked():
            return

        is_unknown = not decode.decoded
        if is_unknown and not self.show_unknown_check.isChecked():
            return

        payload = decode.data if decode.data else frame
        key = self.make_key_for_bean(context, did, mid, frame)
        physical = bean_capture_metadata(frame)

        self.update_row(
            key=key,
            bus="BEAN",
            context=context,
            did=did,
            mid=mid,
            name=decode.message_name if decode.decoded else "Unknown / undecoded",
            function=decode.function,
            source_ecu=decode.source_ecu,
            decoded=decode.text,
            payload=payload,
            raw=raw,
            category=decode.category,
            physical=physical,
        )

    def update_row(self, key: tuple, bus: str, context: str, did: str, mid: str, name: str, function: str, source_ecu: str, decoded: str, payload: list[int], raw: str, category: str, physical: Optional[dict[str, Any]] = None):
        now = time.time()
        now_ms = int(now * 1000)

        previous_payload = self.last_payloads.get(key)
        previous_update = self.last_update_ms.get(key)

        if key not in self.rows_by_key:
            row = self.table.rowCount()
            self.table.insertRow(row)
            self.rows_by_key[key] = row
            self.first_seen_order.append(key)
            self.counts[key] = 0

            # Initialize empty items.
            for col in range(self.table.columnCount()):
                item = QTableWidgetItem("")
                item.setFlags(item.flags() ^ Qt.ItemIsEditable)
                self.table.setItem(row, col, item)
        else:
            row = self.rows_by_key[key]

        self.counts[key] = self.counts.get(key, 0) + 1
        cycle = "" if previous_update is None else str(now_ms - previous_update)

        self.last_payloads[key] = list(payload)
        self.last_update_ms[key] = now_ms

        physical = physical or {}
        row_values = [
            str(row + 1),
            bus,
            context,
            did,
            mid,
            name,
            function,
            source_ecu,
            str(self.counts[key]),
            cycle,
            datetime.now().strftime("%H:%M:%S.%f")[:-3],
            decoded,
            str(physical.get("capture_marker", "")),
            str(physical.get("bean_header", "")),
            str(physical.get("priority", "")),
            str(physical.get("ml", "")),
            str(physical.get("data_len", "")),
            str(physical.get("dst_id", "")),
            str(physical.get("mes_id", "")),
            str(physical.get("crc", "")),
            str(physical.get("crc_ok", "")),
            str(physical.get("eom", "")),
            str(physical.get("post_eom", "")),
            str(physical.get("rsp_candidate", "")),
            str(physical.get("eof_candidate", "")),
        ]
        row_values += self.bytes_to_display(payload, self.byte_count)
        row_values += [raw]

        bg = BeanalyzerUltraPro.category_color(category) if category else None

        for col, value in enumerate(row_values):
            item = self.table.item(row, col)
            if item is None:
                item = QTableWidgetItem("")
                item.setFlags(item.flags() ^ Qt.ItemIsEditable)
                self.table.setItem(row, col, item)
            item.setText(str(value))
            if bg:
                item.setBackground(bg)

        changed_byte_indexes = self.changed_indexes(previous_payload, payload)
        b0_col = self.column_indexes.get("B0", 0)
        for byte_idx in changed_byte_indexes:
            col = b0_col + byte_idx
            if col < self.table.columnCount():
                self.highlight_until[(row, col)] = now + (self.highlight_duration_ms / 1000.0)
                item = self.table.item(row, col)
                if item:
                    item.setBackground(QColor("#d03030"))
                    item.setForeground(QColor("#ffffff"))

        # Highlight decoded and count cells briefly when any byte changes.
        if changed_byte_indexes:
            for col in (8, 10, 11):
                self.highlight_until[(row, col)] = now + (self.highlight_duration_ms / 1000.0)
                item = self.table.item(row, col)
                if item:
                    item.setBackground(QColor("#7a2020"))
                    item.setForeground(QColor("#ffffff"))

        self.export_rows[str(key)] = {
            "bus": bus,
            "context": context,
            "did": did,
            "mid": mid,
            "name": name,
            "function": function,
            "source_ecu": source_ecu,
            "count": self.counts[key],
            "cycle_ms": cycle,
            "last_update": datetime.now().isoformat(timespec="milliseconds"),
            "decoded": decoded,
            "payload_hex": bytes_to_hex(payload),
            "raw": raw,
            "category": category,
            "capture_marker": physical.get("capture_marker", ""),
            "bean_header": physical.get("bean_header", ""),
            "priority": physical.get("priority", ""),
            "ml": physical.get("ml", ""),
            "data_len": physical.get("data_len", ""),
            "dst_id": physical.get("dst_id", ""),
            "mes_id": physical.get("mes_id", ""),
            "crc": physical.get("crc", ""),
            "crc_ok": physical.get("crc_ok", ""),
            "eom": physical.get("eom", ""),
            "post_eom": physical.get("post_eom", ""),
            "rsp_candidate": physical.get("rsp_candidate", ""),
            "eof_candidate": physical.get("eof_candidate", ""),
        }

        self.apply_filter_to_row(row)

    @staticmethod
    def changed_indexes(previous: Optional[list[int]], current: list[int]) -> list[int]:
        if previous is None:
            return list(range(len(current)))

        changes = []
        max_len = max(len(previous), len(current))
        for idx in range(max_len):
            old = previous[idx] if idx < len(previous) else None
            new = current[idx] if idx < len(current) else None
            if old != new:
                changes.append(idx)
        return changes

    def fade_highlights(self):
        now = time.time()
        expired = []

        for (row, col), until in list(self.highlight_until.items()):
            if now >= until:
                expired.append((row, col))
                continue

            remaining = max(0.0, min(1.0, (until - now) / (self.highlight_duration_ms / 1000.0)))
            intensity = int(35 + 155 * remaining)
            item = self.table.item(row, col)
            if item:
                item.setBackground(QColor(intensity, 32, 32))
                item.setForeground(QColor("#ffffff"))

        for key in expired:
            row, col = key
            item = self.table.item(row, col)
            if item:
                # Restore to category row color approximately by re-applying filter row data.
                # If no category information is known here, default table theme takes over on next update.
                item.setForeground(QColor("#e8e8e8"))
                item.setBackground(QColor("#20242c"))
            self.highlight_until.pop(key, None)

    def apply_filter(self):
        for row in range(self.table.rowCount()):
            self.apply_filter_to_row(row)

    def apply_filter_to_row(self, row: int):
        text_filter = self.filter_edit.text().strip().lower()
        show_unknown = self.show_unknown_check.isChecked()

        values = []
        for col in range(self.table.columnCount()):
            item = self.table.item(row, col)
            values.append(item.text() if item else "")

        joined = " ".join(values).lower()
        is_unknown = "unknown" in values[5].lower() or "unknown" in values[11].lower()

        hidden = False
        if text_filter and text_filter not in joined:
            hidden = True
        if is_unknown and not show_unknown:
            hidden = True

        self.table.setRowHidden(row, hidden)

    def export_csv(self):
        path, _ = QFileDialog.getSaveFileName(
            self,
            "Export monitor CSV",
            f"beanalyzer_monitor_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv",
            "CSV files (*.csv)"
        )
        if not path:
            return

        with open(path, "w", newline="", encoding="utf-8") as f:
            writer = csv.writer(f)
            headers = [self.table.horizontalHeaderItem(c).text() for c in range(self.table.columnCount())]
            writer.writerow(headers)
            for row in range(self.table.rowCount()):
                writer.writerow([
                    self.table.item(row, col).text() if self.table.item(row, col) else ""
                    for col in range(self.table.columnCount())
                ])

        QMessageBox.information(self, "CSV exported", f"Monitor CSV exported to:\n{path}")

    def export_json(self):
        path, _ = QFileDialog.getSaveFileName(
            self,
            "Export monitor JSON",
            f"beanalyzer_monitor_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json",
            "JSON files (*.json)"
        )
        if not path:
            return

        payload = {
            "exported_at": datetime.now().isoformat(timespec="seconds"),
            "rows": list(self.export_rows.values()),
        }
        Path(path).write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
        QMessageBox.information(self, "JSON exported", f"Monitor JSON exported to:\n{path}")

    def load_decoder_json(self):
        path, _ = QFileDialog.getOpenFileName(
            self,
            "Load BEAN-DBC decoder JSON",
            str(CONFIG_DIR),
            "JSON files (*.json)"
        )
        if not path:
            return

        try:
            self.parent_app.dbc.load(Path(path))
            self.parent_app.load_dbc_ui()
            QMessageBox.information(self, "Decoder loaded", f"Loaded decoder:\n{path}\n\nNew frames will use the new decoder.")
        except Exception as exc:
            QMessageBox.critical(self, "Decoder load error", str(exc))

    def reload_default_decoder(self):
        try:
            self.parent_app.dbc.load(DBC_PATH)
            self.parent_app.load_dbc_ui()
            QMessageBox.information(self, "Decoder reloaded", f"Reloaded default decoder:\n{DBC_PATH}")
        except Exception as exc:
            QMessageBox.critical(self, "Decoder reload error", str(exc))



# -----------------------------------------------------------------------------
# About dialog
# -----------------------------------------------------------------------------

class AboutDialog(QDialog):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setWindowTitle("About BEANalyzer UltraPro")
        self.setModal(True)
        self.resize(760, 500)
        self.setMinimumSize(680, 440)

        if APP_ICON_PATH.exists():
            self.setWindowIcon(QIcon(str(APP_ICON_PATH)))

        root = QVBoxLayout(self)
        root.setContentsMargins(22, 22, 22, 18)
        root.setSpacing(14)

        header = QHBoxLayout()
        header.setSpacing(18)

        icon_label = QLabel()
        icon_label.setFixedSize(128, 128)
        if APP_ICON_PATH.exists():
            pix = QPixmap(str(APP_ICON_PATH)).scaled(128, 128, Qt.KeepAspectRatio, Qt.SmoothTransformation)
            icon_label.setPixmap(pix)
        icon_label.setAlignment(Qt.AlignCenter)
        header.addWidget(icon_label)

        title_box = QVBoxLayout()
        title = QLabel("BEANalyzer UltraPro")
        title.setTextFormat(Qt.RichText)
        title.setStyleSheet("font-size: 34px; font-weight: 800; color: #35d8ff; letter-spacing: 0.5px;")
        subtitle = QLabel("Toyota / Lexus BEAN MPX Laboratory")
        subtitle.setStyleSheet("font-size: 15px; color: #b8c7d9; letter-spacing: 1.5px;")
        version = QLabel(f"Version {APP_VERSION}")
        version.setStyleSheet("font-size: 13px; color: #8ea0b8;")
        title_box.addWidget(title)
        title_box.addWidget(subtitle)
        title_box.addWidget(version)
        title_box.addStretch(1)
        header.addLayout(title_box, 1)
        root.addLayout(header)

        line = QFrame()
        line.setFrameShape(QFrame.HLine)
        line.setStyleSheet("background-color: #23445a; max-height: 1px;")
        root.addWidget(line)

        description = QLabel(
            "<b>BEANalyzer UltraPro</b> is a professional desktop laboratory for Toyota/Lexus "
            "BEAN MPX research, live capture, decoder editing, replay analysis, message monitoring, "
            "manual transmission, and BEAN-to-CAN gateway development."
        )
        description.setWordWrap(True)
        description.setStyleSheet("font-size: 13px; color: #e6eef8; line-height: 1.35;")
        root.addWidget(description)

        brand_row = QHBoxLayout()
        brand_row.setSpacing(16)
        aed_label = QLabel()
        aed_label.setFixedSize(190, 72)
        if AED_LOGO_PATH.exists():
            aed_pix = QPixmap(str(AED_LOGO_PATH)).scaled(190, 72, Qt.KeepAspectRatio, Qt.SmoothTransformation)
            aed_label.setPixmap(aed_pix)
        aed_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
        brand_row.addWidget(aed_label)

        brand_text = QLabel(
            "<b>© 2026 AE&amp;D — Anyzun Electronics &amp; Development</b><br>"
            f"<a href=\"{WEBSITE_URL}\">{WEBSITE_URL}</a><br>"
            "Independent research and development software. Toyota and Lexus names are used only "
            "to describe technical compatibility and vehicle context."
        )
        brand_text.setOpenExternalLinks(False)
        brand_text.linkActivated.connect(lambda _url: QDesktopServices.openUrl(QUrl(WEBSITE_URL)))
        brand_text.setWordWrap(True)
        brand_text.setStyleSheet("font-size: 12px; color: #c9d4e2;")
        brand_row.addWidget(brand_text, 1)
        root.addLayout(brand_row)

        buttons = QHBoxLayout()
        website_btn = QPushButton("Open Website")
        website_btn.clicked.connect(lambda: QDesktopServices.openUrl(QUrl(WEBSITE_URL)))
        close_btn = QPushButton("Close")
        close_btn.clicked.connect(self.accept)
        buttons.addStretch(1)
        buttons.addWidget(website_btn)
        buttons.addWidget(close_btn)
        root.addLayout(buttons)

        self.setStyleSheet("""
            QDialog { background-color: #10141b; }
            QLabel { color: #e8eef8; }
            QPushButton { background-color: #263348; color: white; border: 1px solid #4a668f; padding: 8px 14px; border-radius: 6px; }
            QPushButton:hover { background-color: #334868; }
            a { color: #19c7ff; text-decoration: none; }
        """)


# -----------------------------------------------------------------------------
# Main application
# -----------------------------------------------------------------------------

class BeanalyzerUltraPro(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("BEANalyzer UltraPro - Toyota/Lexus BEAN MPX Laboratory")
        # Adaptive default size. Important for laptops with limited vertical resolution.
        screen = QApplication.primaryScreen()
        if screen:
            available = screen.availableGeometry()
            self.resize(
                min(1800, int(available.width() * 0.94)),
                min(1000, int(available.height() * 0.88))
            )
            self.move(
                available.x() + max(0, (available.width() - self.width()) // 2),
                available.y() + max(0, (available.height() - self.height()) // 2)
            )
        else:
            self.resize(1500, 850)
        self.setMinimumSize(1050, 650)

        self.dbc = BeanDbc(DBC_PATH)
        self.gateway = CanGateway(CAN_MAP_PATH)
        self.python_can = PythonCanBus()
        self.serial_can = SerialCanBridge()
        self.python_can.on_tx = self.notify_can_gateway_monitor
        self.serial_can.on_tx = self.notify_can_gateway_monitor

        self.bean_reader: Optional[SerialReader] = None
        self.can_serial_reader: Optional[SerialReader] = None
        self.can_rx_worker: Optional[CanRxWorker] = None
        self.replay_worker: Optional[ReplayWorker] = None

        self.last_arduino_ms: Optional[int] = None
        self.frame_count = 0
        self.decoded_count = 0
        self.start_time = time.time()
        self.csv_file = None
        self.csv_writer = None
        self.csv_path: Optional[Path] = None
        self.export_rows: list[dict[str, Any]] = []
        self.last_row_signature = None
        self.sniffing_paused = False
        self.message_monitor_window: Optional[MessageMonitorWindow] = None
        self.can_gateway_monitor_window: Optional[CanGatewayMonitorWindow] = None
        self.event_analyzer_csv_path: Optional[Path] = None

        # Change monitor / byte watcher state.
        # Stores the last observed payload per CTX/DID/MID key so the next frame can be compared byte-by-byte.
        self.change_last_payloads: dict[tuple, list[int]] = {}
        self.change_last_raw_frames: dict[tuple, list[int]] = {}

        self.signal_cache: dict[str, DecodedSignal] = {}
        self.unit_mode = UNIT_MODE_METRIC
        self.card_display_modes: dict[str, str] = {}
        self.known_signals: set[str] = set()
        self.plot_buffers: dict[str, tuple[list[float], list[float]]] = {}
        self.plot_curves: dict[str, Any] = {}
        self.tx_loop_timer = QTimer(self)
        self.tx_loop_timer.timeout.connect(self.tx_loop_tick)
        self.tx_loop_remaining = 0
        self.tx_loop_mode = "BEAN"

        self.dbc_test_timer = QTimer(self)
        self.dbc_test_timer.timeout.connect(self.dbc_test_tick)
        self.dbc_test_remaining = 0
        self.dbc_test_payload: list[int] = []

        self.build_ui()
        self.apply_style()
        self.refresh_devices()
        self.load_dbc_ui()
        self.load_can_map_ui()

        self.status_timer = QTimer(self)
        self.status_timer.timeout.connect(self.update_status)
        self.status_timer.start(500)
        self.gateway_timer = QTimer(self)
        self.gateway_timer.timeout.connect(self.gateway.tick)
        self.gateway_timer.start(20)

    # UI construction ---------------------------------------------------------
    def build_ui(self):
        root = QWidget()
        self.setCentralWidget(root)
        main = QVBoxLayout(root)

        top = QHBoxLayout()
        self.bean_port_combo = QComboBox(); self.bean_port_combo.setEditable(True); self.bean_port_combo.setInsertPolicy(QComboBox.NoInsert); self.bean_port_combo.setMinimumWidth(260); self.bean_port_combo.setMaximumWidth(360)
        self.refresh_btn = QPushButton("Refresh USB")
        self.bean_connect_btn = QPushButton("Connect BEAN")
        self.bean_disconnect_btn = QPushButton("Disconnect BEAN")
        self.clear_live_btn = QPushButton("Clear list")
        self.clear_live_btn.setToolTip("Clear the live frame table without stopping capture")
        self.play_sniff_btn = QPushButton("▶")
        self.play_sniff_btn.setToolTip("Resume BEAN sniffing")
        self.pause_sniff_btn = QPushButton("⏸")
        self.pause_sniff_btn.setToolTip("Pause BEAN sniffing without disconnecting the serial port")
        self.new_csv_btn = QPushButton("New CSV")
        self.new_csv_btn.setToolTip("Start a new CSV log file")
        self.load_csv_btn = QPushButton("Load CSV")
        self.load_csv_btn.setToolTip("Load a CSV log into the Replay tab")
        self.export_csv_btn = QPushButton("Export CSV")
        self.export_csv_btn.setToolTip("Export the current CSV log or current session to another CSV file")
        self.message_monitor_btn = QPushButton("Message Monitor")
        self.message_monitor_btn.setToolTip("Open CANalyst-style fixed ID/command monitor window")
        self.only_decoded = QCheckBox("Only decoded")
        self.only_changes = QCheckBox("Only changes")
        self.autoscroll = QCheckBox("Auto-scroll"); self.autoscroll.setChecked(True)
        self.filter_edit = QLineEdit(); self.filter_edit.setPlaceholderText("Filter: MID, DID, signal, text, raw..."); self.filter_edit.setMinimumWidth(220); self.filter_edit.setMaximumWidth(360)
        top.addWidget(QLabel("BEAN Serial:")); top.addWidget(self.bean_port_combo); top.addWidget(self.refresh_btn)
        top.addWidget(self.bean_connect_btn); top.addWidget(self.bean_disconnect_btn); top.addWidget(self.play_sniff_btn); top.addWidget(self.pause_sniff_btn); top.addWidget(self.clear_live_btn); top.addWidget(self.new_csv_btn); top.addWidget(self.load_csv_btn); top.addWidget(self.export_csv_btn); top.addSpacing(20)
        top.addWidget(self.only_decoded); top.addWidget(self.only_changes); top.addWidget(self.autoscroll); top.addWidget(self.filter_edit); top.addStretch()
        top_widget = QWidget()
        top_widget.setLayout(top)
        top_scroll = QScrollArea()
        top_scroll.setWidgetResizable(True)
        top_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
        top_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
        top_scroll.setFrameShape(QFrame.NoFrame)
        top_scroll.setMaximumHeight(58)
        top_scroll.setWidget(top_widget)
        main.addWidget(top_scroll, 0)

        self.tabs = QTabWidget()
        self.tabs.setUsesScrollButtons(True)
        self.tabs.setElideMode(Qt.ElideRight)
        main.addWidget(self.tabs)
        self.build_live_tab(); self.build_graph_tab(); self.build_tx_tab(); self.build_gateway_tab(); self.build_dbc_tab(); self.build_replay_tab(); self.build_event_analyzer_tab(); self.build_bean_protocol_tab(); self.build_help_tab()

        self.status = QStatusBar(); self.setStatusBar(self.status)
        self.sniff_state_label = QLabel("Sniffing: LIVE")
        self.csv_state_label = QLabel("CSV: not started")
        self.status.addPermanentWidget(self.sniff_state_label)
        self.status.addPermanentWidget(self.csv_state_label)

        self.refresh_btn.clicked.connect(self.refresh_devices)
        self.bean_connect_btn.clicked.connect(self.connect_bean)
        self.bean_disconnect_btn.clicked.connect(self.disconnect_bean)
        self.clear_live_btn.clicked.connect(self.clear_live_table)
        self.play_sniff_btn.clicked.connect(self.resume_sniffing)
        self.pause_sniff_btn.clicked.connect(self.pause_sniffing)
        self.new_csv_btn.clicked.connect(self.new_csv_log)
        self.load_csv_btn.clicked.connect(self.load_csv_for_replay)
        self.export_csv_btn.clicked.connect(self.export_csv)
        self.message_monitor_btn.clicked.connect(self.open_message_monitor)

        file_menu = self.menuBar().addMenu("File")
        act_new_csv = QAction("New CSV log", self); act_new_csv.triggered.connect(self.new_csv_log)
        act_export_json = QAction("Export session JSON...", self); act_export_json.triggered.connect(self.export_json)
        act_export_csv = QAction("Export CSV...", self); act_export_csv.triggered.connect(self.export_csv)
        act_choose_csv = QAction("Choose CSV log file...", self); act_choose_csv.triggered.connect(self.choose_csv)
        act_load_csv = QAction("Load CSV for replay...", self); act_load_csv.triggered.connect(self.load_csv_for_replay)
        act_clear = QAction("Clear live table", self); act_clear.triggered.connect(self.clear_live_table)
        file_menu.addAction(act_new_csv); file_menu.addAction(act_choose_csv); file_menu.addAction(act_load_csv); file_menu.addSeparator(); file_menu.addAction(act_export_csv); file_menu.addAction(act_export_json); file_menu.addSeparator(); file_menu.addAction(act_clear)

        view_menu = self.menuBar().addMenu("View")
        act_monitor = QAction("Open Message Monitor", self); act_monitor.triggered.connect(self.open_message_monitor)
        view_menu.addAction(act_monitor)
        act_can_gateway_monitor = QAction("Open CAN Gateway Monitor", self); act_can_gateway_monitor.triggered.connect(self.open_can_gateway_monitor)
        view_menu.addAction(act_can_gateway_monitor)
        self.act_compact_top_bar = QAction("Compact top toolbar", self)
        self.act_compact_top_bar.setCheckable(True)
        self.act_compact_top_bar.triggered.connect(self.set_compact_top_toolbar)
        view_menu.addAction(self.act_compact_top_bar)
        act_fit_screen = QAction("Fit window to screen", self); act_fit_screen.triggered.connect(self.fit_window_to_screen)
        view_menu.addAction(act_fit_screen)
        view_menu.addSeparator()
        self.act_show_dashboard_panel = QAction("Show dashboard panel", self); self.act_show_dashboard_panel.setCheckable(True); self.act_show_dashboard_panel.setChecked(True); self.act_show_dashboard_panel.triggered.connect(self.set_dashboard_panel_visible)
        self.act_show_dashboard_selector = QAction("Show dashboard signal selector", self); self.act_show_dashboard_selector.setCheckable(True); self.act_show_dashboard_selector.setChecked(True); self.act_show_dashboard_selector.triggered.connect(lambda checked: self.card_config_group.setVisible(checked))
        self.act_show_dashboard_cards = QAction("Show dashboard cards", self); self.act_show_dashboard_cards.setCheckable(True); self.act_show_dashboard_cards.setChecked(True); self.act_show_dashboard_cards.triggered.connect(lambda checked: self.cards_scroll.setVisible(checked))
        self.act_show_statusbar = QAction("Show status bar", self); self.act_show_statusbar.setCheckable(True); self.act_show_statusbar.setChecked(True); self.act_show_statusbar.triggered.connect(lambda checked: self.status.setVisible(checked))
        self.act_compact_live = QAction("Compact live view", self); self.act_compact_live.triggered.connect(self.compact_live_view)
        self.act_reset_live_layout = QAction("Reset live layout", self); self.act_reset_live_layout.triggered.connect(self.reset_live_layout)
        view_menu.addAction(self.act_show_dashboard_panel)
        view_menu.addAction(self.act_show_dashboard_selector)
        view_menu.addAction(self.act_show_dashboard_cards)
        view_menu.addSeparator()
        view_menu.addAction(self.act_compact_live)
        view_menu.addAction(self.act_reset_live_layout)
        units_menu = self.menuBar().addMenu("Units")
        self.act_units_metric = QAction("Metric", self)
        self.act_units_imperial = QAction("Imperial", self)
        self.act_units_force_imperial = QAction("Force Imperial Units", self)
        self.act_units_raw = QAction("Raw ECU / decoded", self)
        for act in (self.act_units_metric, self.act_units_imperial, self.act_units_force_imperial, self.act_units_raw):
            act.setCheckable(True)
            units_menu.addAction(act)
        self.act_units_metric.setChecked(True)
        self.act_units_metric.triggered.connect(lambda checked: self.set_unit_mode(UNIT_MODE_METRIC))
        self.act_units_imperial.triggered.connect(lambda checked: self.set_unit_mode(UNIT_MODE_IMPERIAL))
        self.act_units_force_imperial.triggered.connect(lambda checked: self.set_unit_mode(UNIT_MODE_FORCE_IMPERIAL))
        self.act_units_raw.triggered.connect(lambda checked: self.set_unit_mode(UNIT_MODE_RAW))
        units_menu.addSeparator()
        act_units_info = QAction("Unit conversion info...", self)
        act_units_info.triggered.connect(self.show_units_info)
        units_menu.addAction(act_units_info)

        view_menu.addSeparator()
        view_menu.addAction(self.act_show_statusbar)

        help_menu = self.menuBar().addMenu("Help")
        act_check_updates = QAction("Check for Updates...", self)
        act_check_updates.triggered.connect(self.open_website)

        # Normal Help menu About entry.
        act_about_help = QAction("About BEANalyzer UltraPro", self)
        safe_set_menu_role(act_about_help, "NoRole")
        act_about_help.triggered.connect(self.show_about_dialog)

        # macOS native application-menu About entry.
        # This replaces the confusing automatic "About Python" item when Qt/macOS allows it.
        act_about_app = QAction("About BEANalyzer UltraPro", self)
        safe_set_menu_role(act_about_app, "AboutRole")
        act_about_app.triggered.connect(self.show_about_dialog)

        help_menu.addAction(act_check_updates)
        # On macOS, an AboutRole action placed inside a menu is normally moved
        # into the native application menu and renamed using the app name.
        # Keep a normal Help -> About item too, so the option is always visible.
        if sys.platform == "darwin":
            help_menu.addAction(act_about_app)
        help_menu.addSeparator()
        help_menu.addAction(act_about_help)

    def build_live_tab(self):
        tab = QWidget()
        layout = QVBoxLayout(tab)
        layout.setContentsMargins(8, 8, 8, 8)

        # Vertical splitter: the user can resize dashboard/cards and the live table.
        # This avoids the old huge empty top area and makes the Live tab usable on smaller screens.
        self.live_splitter = QSplitter(Qt.Vertical)
        layout.addWidget(self.live_splitter, 1)

        top_panel = QWidget()
        self.dashboard_top_panel = top_panel
        top_layout = QVBoxLayout(top_panel)
        top_layout.setContentsMargins(4, 4, 4, 4)

        self.card_config_group = QGroupBox("Dashboard cards - select any decoded signals")
        self.card_config_layout = QVBoxLayout(self.card_config_group)
        self.card_config_layout.setContentsMargins(8, 8, 8, 8)
        self.card_list = QListWidget()
        self.card_list.setMinimumHeight(70)
        self.card_list.setMaximumHeight(95)
        self.card_list.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
        self.card_list.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
        self.card_config_layout.addWidget(self.card_list)
        self.card_list.itemChanged.connect(self.rebuild_cards)
        top_layout.addWidget(self.card_config_group, 0)

        self.cards_frame = QFrame()
        self.cards_layout = QGridLayout(self.cards_frame)
        self.cards_layout.setContentsMargins(4, 4, 4, 4)
        self.cards_layout.setHorizontalSpacing(8)
        self.cards_layout.setVerticalSpacing(8)
        self.cards_scroll = QScrollArea()
        self.cards_scroll.setWidgetResizable(True)
        self.cards_scroll.setFrameShape(QFrame.NoFrame)
        self.cards_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
        self.cards_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
        self.cards_scroll.setWidget(self.cards_frame)
        top_layout.addWidget(self.cards_scroll, 1)
        self.cards: dict[str, SignalCard] = {}

        table_panel = QWidget()
        table_layout = QVBoxLayout(table_panel)
        table_layout.setContentsMargins(4, 4, 4, 4)

        table_toolbar = QHBoxLayout()
        self.clear_live_table_btn = QPushButton("Clear list")
        self.clear_live_table_btn.setToolTip("Clear the live frame table")
        self.row_height_spin = QSpinBox()
        self.row_height_spin.setRange(16, 60)
        self.row_height_spin.setValue(24)
        self.row_height_spin.setToolTip("Live table row height")
        self.dashboard_height_spin = QSpinBox()
        self.dashboard_height_spin.setRange(0, 900)
        self.dashboard_height_spin.setValue(210)
        self.dashboard_height_spin.setToolTip("Dashboard panel height in pixels. You can also drag the splitter.")
        self.selector_height_spin = QSpinBox()
        self.selector_height_spin.setRange(40, 320)
        self.selector_height_spin.setValue(110)
        self.selector_height_spin.setToolTip("Dashboard signal selector height in pixels")
        self.cards_min_height_spin = QSpinBox()
        self.cards_min_height_spin.setRange(50, 600)
        self.cards_min_height_spin.setValue(95)
        self.cards_min_height_spin.setToolTip("Minimum height for the dashboard cards area")

        self.change_monitor_enabled = QCheckBox("Highlight changes")
        self.change_monitor_enabled.setChecked(True)
        self.change_monitor_enabled.setToolTip("Compare each filtered CTX/DID/MID against its previous value and highlight changed bytes/bits")

        self.change_only_filtered = QCheckBox("Only monitored")
        self.change_only_filtered.setToolTip("Only show frames matching the Change Monitor filter")

        self.change_ctx_filter = QLineEdit()
        self.change_ctx_filter.setPlaceholderText("CTX")
        self.change_ctx_filter.setMaximumWidth(55)
        self.change_ctx_filter.setToolTip("Optional context byte filter, for example 53")

        self.change_did_filter = QLineEdit()
        self.change_did_filter.setPlaceholderText("DID")
        self.change_did_filter.setMaximumWidth(55)
        self.change_did_filter.setToolTip("Optional DID filter, for example FE or 62")

        self.change_mid_filter = QLineEdit()
        self.change_mid_filter.setPlaceholderText("MID")
        self.change_mid_filter.setMaximumWidth(55)
        self.change_mid_filter.setToolTip("Optional MID/command filter, for example 24, CD, AB, D4")

        self.change_text_filter = QLineEdit()
        self.change_text_filter.setPlaceholderText("raw/decoded contains...")
        self.change_text_filter.setMaximumWidth(190)
        self.change_text_filter.setToolTip("Optional text filter applied to decoded text and raw frame")

        self.clear_change_memory_btn = QPushButton("Clear change memory")
        self.clear_change_memory_btn.setToolTip("Forget previous values used by the byte/bit change monitor")

        self.live_message_monitor_btn = QPushButton("Message Monitor")
        self.live_message_monitor_btn.setToolTip("Open CANalyst-style fixed ID/command monitor window")

        table_toolbar.addWidget(self.clear_live_table_btn)
        table_toolbar.addSpacing(12)
        table_toolbar.addWidget(QLabel("Row height:"))
        table_toolbar.addWidget(self.row_height_spin)
        table_toolbar.addSpacing(18)
        table_toolbar.addWidget(QLabel("Dashboard height:"))
        table_toolbar.addWidget(self.dashboard_height_spin)
        table_toolbar.addWidget(QLabel("Selector:"))
        table_toolbar.addWidget(self.selector_height_spin)
        table_toolbar.addWidget(QLabel("Cards min:"))
        table_toolbar.addWidget(self.cards_min_height_spin)
        table_toolbar.addSpacing(18)
        table_toolbar.addWidget(self.change_monitor_enabled)
        table_toolbar.addWidget(self.change_only_filtered)
        table_toolbar.addWidget(QLabel("Watch:"))
        table_toolbar.addWidget(self.change_ctx_filter)
        table_toolbar.addWidget(self.change_did_filter)
        table_toolbar.addWidget(self.change_mid_filter)
        table_toolbar.addWidget(self.change_text_filter)
        table_toolbar.addWidget(self.clear_change_memory_btn)
        table_toolbar.addWidget(self.live_message_monitor_btn)
        table_toolbar.addStretch()
        table_toolbar_widget = QWidget()
        table_toolbar_widget.setLayout(table_toolbar)
        table_toolbar_scroll = QScrollArea()
        table_toolbar_scroll.setWidgetResizable(True)
        table_toolbar_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
        table_toolbar_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
        table_toolbar_scroll.setFrameShape(QFrame.NoFrame)
        table_toolbar_scroll.setMaximumHeight(48)
        table_toolbar_scroll.setWidget(table_toolbar_widget)
        table_layout.addWidget(table_toolbar_scroll, 0)

        # Dedicated Event Marker bar.
        # This used to live inside the crowded Live toolbar, which made the fields too small
        # on laptop-sized screens. Keeping it on a separate row makes the workflow usable
        # while driving/bench-testing.
        event_bar = QGroupBox("Event markers")
        event_bar.setMaximumHeight(72)
        event_layout = QHBoxLayout(event_bar)
        event_layout.setContentsMargins(8, 6, 8, 6)
        event_layout.setSpacing(8)

        self.marker_combo = QComboBox()
        self.marker_combo.addItems(["Engine OFF", "Driver door open", "Driver door closed", "Remote lock", "Remote unlock", "Custom"])
        self.marker_combo.setMinimumWidth(190)
        self.marker_combo.setSizeAdjustPolicy(QComboBox.AdjustToContents)

        self.marker_text = QLineEdit()
        self.marker_text.setPlaceholderText("Optional custom marker text, e.g. Driver door opened after remote unlock")
        self.marker_text.setMinimumWidth(360)

        self.add_marker_btn = QPushButton("Add marker")
        self.add_marker_btn.setMinimumWidth(120)
        self.add_marker_btn.setToolTip("Add a timestamped event marker to the live table and CSV log")

        self.quick_engine_off_btn = QPushButton("Engine OFF")
        self.quick_driver_open_btn = QPushButton("Door open")
        self.quick_driver_closed_btn = QPushButton("Door closed")
        self.quick_remote_lock_btn = QPushButton("Remote lock")
        self.quick_remote_unlock_btn = QPushButton("Remote unlock")

        for btn in [self.quick_engine_off_btn, self.quick_driver_open_btn, self.quick_driver_closed_btn, self.quick_remote_lock_btn, self.quick_remote_unlock_btn]:
            btn.setMinimumWidth(92)

        event_layout.addWidget(QLabel("Marker:"))
        event_layout.addWidget(self.marker_combo)
        event_layout.addWidget(self.marker_text, 1)
        event_layout.addWidget(self.add_marker_btn)
        event_layout.addSpacing(12)
        event_layout.addWidget(QLabel("Quick:"))
        event_layout.addWidget(self.quick_engine_off_btn)
        event_layout.addWidget(self.quick_driver_open_btn)
        event_layout.addWidget(self.quick_driver_closed_btn)
        event_layout.addWidget(self.quick_remote_lock_btn)
        event_layout.addWidget(self.quick_remote_unlock_btn)

        event_scroll = QScrollArea()
        event_scroll.setWidgetResizable(True)
        event_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
        event_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
        event_scroll.setFrameShape(QFrame.NoFrame)
        event_scroll.setMaximumHeight(82)
        event_scroll.setWidget(event_bar)
        table_layout.addWidget(event_scroll, 0)

        self.live_table = QTableWidget()
        live_headers = ["PC Time", "Arduino ms", "Δ ms", "Bus", "Type", "Ctx", "DID", "MID", "Data", "Decoded", "Function", "Source ECU", "Category", "Changes", "Raw"]
        self.live_column_indexes = {name: idx for idx, name in enumerate(live_headers)}
        self.live_table.setColumnCount(len(live_headers))
        self.live_table.setHorizontalHeaderLabels(live_headers)
        for idx, width in enumerate([105, 95, 65, 50, 50, 45, 45, 45, 170, 620, 180, 260, 90, 430, 320]):
            self.live_table.setColumnWidth(idx, width)
        live_header = self.live_table.horizontalHeader()
        live_header.setSectionResizeMode(QHeaderView.Interactive)
        live_header.setStretchLastSection(False)
        live_header.setContextMenuPolicy(Qt.CustomContextMenu)
        live_header.customContextMenuRequested.connect(self.show_live_column_context_menu)
        self.live_table.setAlternatingRowColors(True)
        self.live_table.setWordWrap(False)
        self.live_table.setSelectionBehavior(QAbstractItemView.SelectRows)
        self.live_table.setHorizontalScrollMode(QAbstractItemView.ScrollPerPixel)
        self.live_table.setVerticalScrollMode(QAbstractItemView.ScrollPerPixel)
        self.live_table.verticalHeader().setDefaultSectionSize(self.row_height_spin.value())
        table_layout.addWidget(self.live_table, 1)

        self.live_splitter.addWidget(top_panel)
        self.live_splitter.addWidget(table_panel)
        self.live_splitter.setChildrenCollapsible(False)
        self.live_splitter.setSizes([210, 790])
        self.live_splitter.setStretchFactor(0, 0)
        self.live_splitter.setStretchFactor(1, 1)

        self.clear_live_table_btn.clicked.connect(self.clear_live_table)
        self.row_height_spin.valueChanged.connect(lambda v: self.live_table.verticalHeader().setDefaultSectionSize(v))
        self.dashboard_height_spin.valueChanged.connect(self.set_dashboard_height)
        self.selector_height_spin.valueChanged.connect(self.set_selector_height)
        self.cards_min_height_spin.valueChanged.connect(self.set_cards_min_height)
        self.clear_change_memory_btn.clicked.connect(self.clear_change_monitor_memory)
        self.live_message_monitor_btn.clicked.connect(self.open_message_monitor)
        self.add_marker_btn.clicked.connect(self.add_event_marker_from_ui)
        self.quick_engine_off_btn.clicked.connect(lambda: self.add_event_marker("Engine OFF"))
        self.quick_driver_open_btn.clicked.connect(lambda: self.add_event_marker("Driver door open"))
        self.quick_driver_closed_btn.clicked.connect(lambda: self.add_event_marker("Driver door closed"))
        self.quick_remote_lock_btn.clicked.connect(lambda: self.add_event_marker("Remote lock"))
        self.quick_remote_unlock_btn.clicked.connect(lambda: self.add_event_marker("Remote unlock"))

        self.tabs.addTab(tab, "Live")

    def build_graph_tab(self):
        tab = QWidget(); layout = QHBoxLayout(tab)
        left = QVBoxLayout(); layout.addLayout(left, 0)
        left.addWidget(QLabel("Select numeric signals to plot:"))
        self.plot_signal_list = QListWidget(); self.plot_signal_list.setMinimumWidth(260)
        left.addWidget(self.plot_signal_list)
        self.plot_signal_list.itemChanged.connect(self.rebuild_plots)
        self.clear_graphs_btn = QPushButton("Clear graph data"); self.clear_graphs_btn.clicked.connect(self.clear_graph_data); left.addWidget(self.clear_graphs_btn)
        if pg is None:
            layout.addWidget(QLabel("pyqtgraph is not installed. Install requirements.txt."), 1)
        else:
            self.graph_area = pg.GraphicsLayoutWidget(); layout.addWidget(self.graph_area, 1)
        self.tabs.addTab(tab, "Graphs")

    def build_tx_tab(self):
        tab = QWidget(); layout = QVBoxLayout(tab)
        mode_row = QHBoxLayout()
        self.tx_bus_combo = QComboBox(); self.tx_bus_combo.addItems(["BEAN serial", "CAN python-can", "CAN Arduino serial bridge"])
        self.tx_payload_edit = QLineEdit(); self.tx_payload_edit.setPlaceholderText("BEAN bridge: 62 D2 10 00 00    CAN: ID#DATA e.g. 200#00 12 34 56")
        mode_row.addWidget(QLabel("TX bus:")); mode_row.addWidget(self.tx_bus_combo); mode_row.addWidget(self.tx_payload_edit, 1)
        layout.addLayout(mode_row)
        opt = QHBoxLayout()
        self.tx_count = QSpinBox(); self.tx_count.setRange(0, 100000); self.tx_count.setValue(1); self.tx_count.setToolTip("0 = infinite loop")
        self.tx_delay = QSpinBox(); self.tx_delay.setRange(1, 60000); self.tx_delay.setValue(250)
        self.tx_extended = QCheckBox("CAN extended")
        self.tx_send_btn = QPushButton("Send / Start"); self.tx_stop_btn = QPushButton("Stop")
        opt.addWidget(QLabel("Repetitions:")); opt.addWidget(self.tx_count); opt.addWidget(QLabel("Delay ms:")); opt.addWidget(self.tx_delay); opt.addWidget(self.tx_extended); opt.addWidget(self.tx_send_btn); opt.addWidget(self.tx_stop_btn); opt.addStretch()
        layout.addLayout(opt)
        self.tx_log = QTextEdit(); self.tx_log.setReadOnly(True); layout.addWidget(self.tx_log)
        self.tx_examples = QPlainTextEdit(); self.tx_examples.setReadOnly(True); self.tx_examples.setMaximumHeight(210)
        self.tx_examples.setPlainText("Examples for the Arduino BeanMPX bridge used by this software:\n"
                                  "IMPORTANT: Manual TX sends the payload exactly as written after BTX,.\n"
                                  "For this bridge, dashboard commands normally use 0x62 as the first byte.\n"
                                  "Low-level physical BEAN examples using 0xFE are reference frames and may not work here.\n\n"
                                  "BEAN outside temp 25 C: 62 CD 49\n"
                                  "BEAN outside temp -14 C: 62 CD 22\n"
                                  "BEAN SNOW icon: 62 D2 10 00 00\n"
                                  "BEAN ECT PWR icon: 62 D2 20 00 00\n"
                                  "BEAN Cruise icon: 62 D2 02 00 00\n"
                                  "BEAN battery icon: 62 D4 20 00 00\n"
                                  "BEAN oil icon: 62 D4 10 00 00\n"
                                  "BEAN battery + oil: 62 D4 30 00 00\n"
                                  "BEAN fuel raw mirrored: 62 A4 75 00 75\n"
                                  "BEAN ABS/VSC sample: 62 91 A0 00 00\n"
                                  "BEAN gear P sample: 62 40 80 00\n"
                                  "BEAN SRS sample: 62 7A E3\n"
                                  "BEAN lights sample: 62 7F 10 00 00\n"
                                  "CAN standard frame: 200#01 02 03 04 05 06 07 08\n"
                                  "CAN extended frame: 18DAF110#02 10 01\n"
                                  "Repetitions = 0 means continuous loop until Stop.")
        layout.addWidget(QLabel("Examples / quick help:")); layout.addWidget(self.tx_examples)
        self.tx_send_btn.clicked.connect(self.start_tx)
        self.tx_stop_btn.clicked.connect(self.stop_tx)
        self.tabs.addTab(tab, "Manual TX")

    def build_gateway_tab(self):
        tab = QWidget(); layout = QVBoxLayout(tab)
        # python-can block
        pycan = QGroupBox("python-can hardware: CANalyst-II, CANable/slcan, SocketCAN, PCAN, etc.")
        f = QFormLayout(pycan)
        self.can_interface = QComboBox(); self.can_interface.addItems(["canalystii", "slcan", "socketcan", "pcan", "kvaser", "vector", "virtual"])
        self.can_channel = QLineEdit(); self.can_channel.setPlaceholderText("can0 / COM7 / /dev/ttyACM0 / PCAN_USBBUS1 / 0")
        self.can_bitrate = QLineEdit("500000")
        self.can_connect_btn = QPushButton("Connect python-can")
        self.can_disconnect_btn = QPushButton("Disconnect python-can")
        f.addRow("Interface", self.can_interface); f.addRow("Channel", self.can_channel); f.addRow("Bitrate", self.can_bitrate)
        row = QHBoxLayout(); row.addWidget(self.can_connect_btn); row.addWidget(self.can_disconnect_btn); f.addRow(row)
        layout.addWidget(pycan)
        # Arduino serial CAN bridge
        ardc = QGroupBox("Arduino CAN serial bridge using EasyMultiCAN/EasyESP32CAN/MCP2515-style firmware")
        fa = QFormLayout(ardc)
        self.can_serial_combo = QComboBox(); self.can_serial_combo.setEditable(True); self.can_serial_combo.setInsertPolicy(QComboBox.NoInsert); self.can_serial_combo.setMinimumWidth(330)
        self.can_serial_connect_btn = QPushButton("Connect Arduino CAN bridge")
        self.can_serial_disconnect_btn = QPushButton("Disconnect Arduino CAN bridge")
        rr = QHBoxLayout(); rr.addWidget(self.can_serial_combo); rr.addWidget(self.can_serial_connect_btn); rr.addWidget(self.can_serial_disconnect_btn)
        fa.addRow("Serial device", rr)
        layout.addWidget(ardc)
        # gateway options
        gw = QGroupBox("Real-time BEAN -> CAN gateway")
        fg = QFormLayout(gw)
        self.gateway_backend = QComboBox(); self.gateway_backend.addItems(["python-can", "Arduino CAN serial bridge"])
        self.gateway_enable = QCheckBox("Enable gateway")
        self.open_can_gateway_monitor_btn = QPushButton("Open CAN Gateway Monitor")
        self.open_can_gateway_monitor_btn.setToolTip("Open live CAN gateway RX/TX viewer and manual CAN TX console")
        fg.addRow("Backend", self.gateway_backend); fg.addRow(self.gateway_enable); fg.addRow(self.open_can_gateway_monitor_btn)
        layout.addWidget(gw)
        self.can_map_editor = QPlainTextEdit(); self.can_map_editor.setFont(QFont("Courier New", 10)); layout.addWidget(QLabel("CAN map JSON:")); layout.addWidget(self.can_map_editor, 1)
        maprow = QHBoxLayout(); self.can_map_apply = QPushButton("Apply CAN map"); self.can_map_save = QPushButton("Save CAN map"); self.can_map_reload = QPushButton("Reload CAN map")
        maprow.addWidget(self.can_map_reload); maprow.addWidget(self.can_map_apply); maprow.addWidget(self.can_map_save); maprow.addStretch(); layout.addLayout(maprow)
        self.can_connect_btn.clicked.connect(self.connect_python_can); self.can_disconnect_btn.clicked.connect(self.disconnect_python_can)
        self.can_serial_connect_btn.clicked.connect(self.connect_can_serial); self.can_serial_disconnect_btn.clicked.connect(self.disconnect_can_serial)
        self.gateway_enable.stateChanged.connect(lambda: setattr(self.gateway, "enabled", self.gateway_enable.isChecked()))
        self.can_map_apply.clicked.connect(self.apply_can_map); self.can_map_save.clicked.connect(self.save_can_map); self.can_map_reload.clicked.connect(self.load_can_map_ui)
        self.open_can_gateway_monitor_btn.clicked.connect(self.open_can_gateway_monitor)
        self.tabs.addTab(tab, "Devices / CAN Gateway")

    def build_dbc_tab(self):
        tab = QWidget(); layout = QVBoxLayout(tab)
        splitter = QSplitter(Qt.Horizontal); layout.addWidget(splitter, 1)
        left = QWidget(); left.setMinimumWidth(420); ll = QVBoxLayout(left); ll.setContentsMargins(4, 4, 4, 4)
        self.dbc_filter = QLineEdit(); self.dbc_filter.setPlaceholderText("Filter messages by name, MID, category..."); ll.addWidget(self.dbc_filter)
        self.dbc_table = QTableWidget(); self.dbc_table.setColumnCount(7); self.dbc_table.setHorizontalHeaderLabels(["Name", "DID", "MID", "Category", "Function", "Source ECU", "Status"])
        self.dbc_table.setColumnWidth(0, 260); self.dbc_table.setColumnWidth(1, 55); self.dbc_table.setColumnWidth(2, 55); self.dbc_table.setColumnWidth(3, 85); self.dbc_table.setColumnWidth(4, 190); self.dbc_table.setColumnWidth(5, 240); self.dbc_table.setColumnWidth(6, 160)
        ll.addWidget(self.dbc_table)
        btns = QHBoxLayout(); self.dbc_add_btn=QPushButton("Add"); self.dbc_dup_btn=QPushButton("Duplicate"); self.dbc_del_btn=QPushButton("Delete"); btns.addWidget(self.dbc_add_btn); btns.addWidget(self.dbc_dup_btn); btns.addWidget(self.dbc_del_btn); ll.addLayout(btns)
        splitter.addWidget(left)
        right = QWidget(); rl = QVBoxLayout(right); rl.setContentsMargins(8, 4, 8, 4); rl.setSpacing(6)

        self.dbc_editor_tabs = QTabWidget()
        rl.addWidget(self.dbc_editor_tabs, 1)

        # ------------------------------------------------------------------
        # Message / Test tab
        # ------------------------------------------------------------------
        msg_tab = QWidget()
        msg_tab_layout = QVBoxLayout(msg_tab)
        msg_tab_layout.setContentsMargins(0, 0, 0, 0)
        self.dbc_msg_scroll = QScrollArea()
        self.dbc_msg_scroll.setWidgetResizable(True)
        self.dbc_msg_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
        self.dbc_msg_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
        msg_content = QWidget()
        msg_content.setMinimumWidth(760)
        msg_layout = QVBoxLayout(msg_content)
        msg_layout.setContentsMargins(8, 8, 8, 8)
        msg_layout.setSpacing(8)

        form_box = QGroupBox("Message metadata")
        form = QFormLayout(form_box)
        form.setLabelAlignment(Qt.AlignRight)
        form.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow)

        self.msg_name = QLineEdit()
        self.msg_did = QLineEdit()
        self.msg_mid = QLineEdit()
        self.msg_category = QComboBox()
        self.msg_category.addItems(["vehicle","engine","climate","body","dash","fuel","warning","trip","unknown","custom"])
        self.msg_function = QLineEdit()
        self.msg_source = QLineEdit()
        self.msg_status = QLineEdit()
        self.msg_example = QLineEdit()
        self.msg_notes = QTextEdit()
        self.msg_notes.setMinimumHeight(90)
        self.msg_references = QPlainTextEdit()
        self.msg_references.setMinimumHeight(70)
        self.msg_references.setPlaceholderText("One reference per line, or a JSON array")

        form.addRow("Name", self.msg_name)
        form.addRow("DID / DST-ID (optional)", self.msg_did)
        form.addRow("MID / MES-ID", self.msg_mid)
        form.addRow("Category", self.msg_category)
        form.addRow("Function", self.msg_function)
        form.addRow("Source ECU / candidate", self.msg_source)
        form.addRow("Doc status", self.msg_status)
        form.addRow("Example / observed frame", self.msg_example)
        form.addRow("Notes", self.msg_notes)
        form.addRow("References", self.msg_references)
        msg_layout.addWidget(form_box)

        test_box = QGroupBox("Selected command test")
        test_layout = QVBoxLayout(test_box)

        payload_row = QHBoxLayout()
        self.dbc_test_template_combo = QComboBox()
        self.dbc_test_template_combo.setMinimumWidth(260)
        self.dbc_test_payload_edit = QLineEdit()
        self.dbc_test_payload_edit.setPlaceholderText("Bridge payload to send, e.g. 62 D2 10 00 00")
        self.dbc_test_load_template_btn = QPushButton("Load template")
        payload_row.addWidget(QLabel("Template:"))
        payload_row.addWidget(self.dbc_test_template_combo, 0)
        payload_row.addWidget(QLabel("Payload:"))
        payload_row.addWidget(self.dbc_test_payload_edit, 1)
        payload_row.addWidget(self.dbc_test_load_template_btn)
        test_layout.addLayout(payload_row)

        reset_row = QHBoxLayout()
        self.dbc_reset_on_stop_check = QCheckBox("Reset on Stop")
        self.dbc_reset_on_stop_check.setChecked(True)
        self.dbc_reset_on_stop_check.setToolTip("When Stop Test is pressed, send reset_payload if available, otherwise auto-zero the selected bridge payload.")
        self.dbc_reset_payload_edit = QLineEdit()
        self.dbc_reset_payload_edit.setPlaceholderText("Reset/clear payload, e.g. 62 D2 00 00 00")
        self.dbc_reset_send_btn = QPushButton("Send reset")
        reset_row.addWidget(self.dbc_reset_on_stop_check)
        reset_row.addWidget(QLabel("Reset payload:"))
        reset_row.addWidget(self.dbc_reset_payload_edit, 1)
        reset_row.addWidget(self.dbc_reset_send_btn)
        test_layout.addLayout(reset_row)

        controls_row = QHBoxLayout()
        self.dbc_test_repetitions = QSpinBox()
        self.dbc_test_repetitions.setRange(0, 100000)
        self.dbc_test_repetitions.setValue(1)
        self.dbc_test_repetitions.setToolTip("0 = continuous loop until Stop Test")
        self.dbc_test_delay = QSpinBox()
        self.dbc_test_delay.setRange(1, 60000)
        self.dbc_test_delay.setValue(250)
        self.dbc_test_delay.setSuffix(" ms")
        self.dbc_send_once_btn = QPushButton("Send once")
        self.dbc_start_test_btn = QPushButton("Start Test")
        self.dbc_stop_test_btn = QPushButton("Stop Test")
        controls_row.addWidget(QLabel("Repetitions:"))
        controls_row.addWidget(self.dbc_test_repetitions)
        controls_row.addWidget(QLabel("Delay:"))
        controls_row.addWidget(self.dbc_test_delay)
        controls_row.addWidget(self.dbc_send_once_btn)
        controls_row.addWidget(self.dbc_start_test_btn)
        controls_row.addWidget(self.dbc_stop_test_btn)
        controls_row.addStretch()
        test_layout.addLayout(controls_row)

        self.dbc_test_status = QLabel("Select a message and choose/load a bridge_payload template.")
        self.dbc_test_status.setStyleSheet("color: #aeb7c2;")
        test_layout.addWidget(self.dbc_test_status)

        self.dbc_full_command_preview = QTextEdit()
        self.dbc_full_command_preview.setReadOnly(True)
        self.dbc_full_command_preview.setMinimumHeight(150)
        self.dbc_full_command_preview.setPlaceholderText("Full command preview will appear here.")
        test_layout.addWidget(QLabel("Full command preview:"))
        test_layout.addWidget(self.dbc_full_command_preview)

        msg_layout.addWidget(test_box)

        help_meta = QLabel("This tab edits the main message fields. Signals, TX templates and raw database JSON are now separated into their own tabs so the editor does not get crushed vertically.")
        help_meta.setWordWrap(True)
        help_meta.setStyleSheet("color: #aeb7c2;")
        msg_layout.addWidget(help_meta)
        msg_layout.addStretch()
        self.dbc_msg_scroll.setWidget(msg_content)
        msg_tab_layout.addWidget(self.dbc_msg_scroll, 1)
        self.dbc_editor_tabs.addTab(msg_tab, "Message / Test")

        # ------------------------------------------------------------------
        # Signals tab
        # ------------------------------------------------------------------
        signals_tab = QWidget()
        signals_tab_layout = QVBoxLayout(signals_tab)
        signals_tab_layout.setContentsMargins(0, 0, 0, 0)
        signals_scroll = QScrollArea()
        signals_scroll.setWidgetResizable(True)
        signals_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
        signals_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
        signals_content = QWidget()
        signals_content.setMinimumWidth(760)
        signals_layout = QVBoxLayout(signals_content)
        signals_layout.setContentsMargins(8, 8, 8, 8)

        self.signal_editor = QPlainTextEdit()
        self.signal_editor.setFont(QFont("Courier New", 10))
        self.signal_editor.setMinimumHeight(420)
        signals_layout.addWidget(QLabel("Signals JSON array:"))
        signals_layout.addWidget(self.signal_editor, 1)

        ex = QTextEdit()
        ex.setReadOnly(True)
        ex.setMaximumHeight(145)
        ex.setText("Signal examples:\n{\"name\":\"outside_temp\",\"kind\":\"u8\",\"offset\":0,\"formula\":\"raw - 48\",\"unit\":\"°C\",\"plot\":true}\n{\"name\":\"body_status\",\"kind\":\"flags\",\"offset\":0,\"flags\":{\"10\":\"Any door opened\"}}\nKinds: u8, u16be, bool, flags, map, constant.\nRecommended fields: name, kind, offset, formula, unit, plot, card, flags, values, mask.")
        signals_layout.addWidget(ex)
        signals_scroll.setWidget(signals_content)
        signals_tab_layout.addWidget(signals_scroll, 1)
        self.dbc_editor_tabs.addTab(signals_tab, "Signals")

        # ------------------------------------------------------------------
        # TX Templates tab
        # ------------------------------------------------------------------
        tx_tab = QWidget()
        tx_tab_layout = QVBoxLayout(tx_tab)
        tx_tab_layout.setContentsMargins(0, 0, 0, 0)
        tx_scroll = QScrollArea()
        tx_scroll.setWidgetResizable(True)
        tx_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
        tx_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
        tx_content = QWidget()
        tx_content.setMinimumWidth(760)
        tx_layout = QVBoxLayout(tx_content)
        tx_layout.setContentsMargins(8, 8, 8, 8)

        self.template_editor = QPlainTextEdit()
        self.template_editor.setFont(QFont("Courier New", 10))
        self.template_editor.setMinimumHeight(420)
        tx_layout.addWidget(QLabel("TX templates / examples JSON array:"))
        tx_layout.addWidget(self.template_editor, 1)

        tx_help = QTextEdit()
        tx_help.setReadOnly(True)
        tx_help.setMaximumHeight(150)
        tx_help.setText("TX template example:\n{\n  \"name\": \"IS200 SNOW\",\n  \"bridge_payload\": \"62 D2 10 00 00\",\n  \"low_level_payload\": \"FE D2 10\",\n  \"notes\": \"Confirmed SNOW icon/mode\"\n}\n\nUse bridge_payload for Manual TX / Arduino BeanMPX bridge. Use low_level_payload only as protocol reference.")
        tx_layout.addWidget(tx_help)
        tx_scroll.setWidget(tx_content)
        tx_tab_layout.addWidget(tx_scroll, 1)
        self.dbc_editor_tabs.addTab(tx_tab, "TX Templates")

        # ------------------------------------------------------------------
        # Raw JSON tab
        # ------------------------------------------------------------------
        raw_tab = QWidget()
        raw_layout = QVBoxLayout(raw_tab)
        raw_layout.setContentsMargins(8, 8, 8, 8)
        self.raw_dbc_editor = QPlainTextEdit()
        self.raw_dbc_editor.setFont(QFont("Courier New", 9)); self.raw_dbc_editor.setMinimumHeight(420)
        raw_layout.addWidget(QLabel("Raw DBC JSON preview/editor:"))
        raw_layout.addWidget(self.raw_dbc_editor, 1)
        self.dbc_editor_tabs.addTab(raw_tab, "Raw DBC JSON")

        row = QHBoxLayout()
        self.dbc_apply_msg_btn = QPushButton("Apply selected message")
        self.dbc_apply_all_btn = QPushButton("Apply DBC to decoder")
        self.dbc_save_btn = QPushButton("Save DBC")
        self.dbc_save_as_btn = QPushButton("Save DBC as...")
        row.addWidget(self.dbc_apply_msg_btn)
        row.addWidget(self.dbc_apply_all_btn)
        row.addWidget(self.dbc_save_btn)
        row.addWidget(self.dbc_save_as_btn)
        rl.addLayout(row)
        splitter.addWidget(right); splitter.setSizes([560, 1360])
        self.dbc_table.itemSelectionChanged.connect(self.load_selected_msg_into_form); self.dbc_filter.textChanged.connect(self.load_dbc_ui)
        self.dbc_apply_msg_btn.clicked.connect(self.apply_msg_form); self.dbc_apply_all_btn.clicked.connect(self.apply_raw_dbc); self.dbc_save_btn.clicked.connect(self.save_dbc); self.dbc_save_as_btn.clicked.connect(self.save_dbc_as)
        self.dbc_add_btn.clicked.connect(self.add_dbc_msg); self.dbc_dup_btn.clicked.connect(self.duplicate_dbc_msg); self.dbc_del_btn.clicked.connect(self.delete_dbc_msg)
        self.dbc_test_load_template_btn.clicked.connect(self.dbc_load_selected_template_payload)
        self.dbc_test_template_combo.currentIndexChanged.connect(self.dbc_update_full_command_preview)
        self.dbc_test_payload_edit.textChanged.connect(self.dbc_update_full_command_preview)
        self.dbc_reset_payload_edit.textChanged.connect(self.dbc_update_full_command_preview)
        self.dbc_reset_send_btn.clicked.connect(self.dbc_send_reset_payload)
        self.dbc_send_once_btn.clicked.connect(self.dbc_send_test_once)
        self.dbc_start_test_btn.clicked.connect(self.dbc_start_test)
        self.dbc_stop_test_btn.clicked.connect(self.dbc_stop_test)
        self.tabs.addTab(tab, "BEAN-DBC Editor")

    def build_replay_tab(self):
        tab = QWidget(); layout = QVBoxLayout(tab)

        row = QHBoxLayout()
        self.replay_path = QLineEdit()
        self.replay_speed = QDoubleSpinBox()
        self.replay_speed.setRange(0.1, 100)
        self.replay_speed.setValue(1.0)

        self.replay_open_btn = QPushButton("Open CSV")
        self.replay_start_btn = QPushButton("Start replay")
        self.replay_stop_btn = QPushButton("Stop replay")

        row.addWidget(self.replay_path, 1)
        row.addWidget(QLabel("Speed x"))
        row.addWidget(self.replay_speed)
        row.addWidget(self.replay_open_btn)
        row.addWidget(self.replay_start_btn)
        row.addWidget(self.replay_stop_btn)
        layout.addLayout(row)

        stream_box = QGroupBox("Replay output")
        stream_layout = QHBoxLayout(stream_box)
        self.replay_bean_stream_enable = QCheckBox("Enable BEAN stream")
        self.replay_bean_stream_enable.setToolTip("Transmit BEAN frames from the replay CSV to the connected BEAN serial bridge using BTX,DID MID DATA...")
        self.replay_loop_mode_enable = QCheckBox("Loop Mode")
        self.replay_loop_mode_enable.setToolTip("Replay the CSV continuously until Stop replay is pressed. Disabled by default: Start replay plays once and stops.")
        self.replay_bean_stream_status = QLabel("OFF — replay is visual/log-only | Loop: OFF — one pass only")
        self.replay_bean_stream_status.setStyleSheet("color: #aeb7c2;")
        stream_layout.addWidget(self.replay_bean_stream_enable)
        stream_layout.addWidget(self.replay_loop_mode_enable)
        stream_layout.addWidget(self.replay_bean_stream_status, 1)
        layout.addWidget(stream_box)

        warning = QLabel("Safety: Enable BEAN stream only on a bench harness or controlled isolated network. Replay TX removes captured EOM/EOF and sends DID/MID/DATA payloads through the Arduino BEAN bridge.")
        warning.setWordWrap(True)
        warning.setStyleSheet("color: #d7b56d;")
        layout.addWidget(warning)

        filter_box = QGroupBox("Replay filter / stream selection")
        filter_layout = QVBoxLayout(filter_box)

        filter_top = QHBoxLayout()
        self.replay_filter_enable = QCheckBox("Enable replay filter")
        self.replay_filter_enable.setToolTip("Replay only checked IDs/commands from the list below. If disabled, all CSV frames are replayed.")
        self.replay_filter_scan_btn = QPushButton("Scan CSV IDs")
        self.replay_filter_all_btn = QPushButton("Select all")
        self.replay_filter_none_btn = QPushButton("Select none")
        self.replay_filter_invert_btn = QPushButton("Invert")
        self.replay_filter_search = QLineEdit()
        self.replay_filter_search.setPlaceholderText("Filter list: CTX, DID, MID, name, decoded, raw...")
        filter_top.addWidget(self.replay_filter_enable)
        filter_top.addWidget(self.replay_filter_scan_btn)
        filter_top.addWidget(self.replay_filter_all_btn)
        filter_top.addWidget(self.replay_filter_none_btn)
        filter_top.addWidget(self.replay_filter_invert_btn)
        filter_top.addWidget(self.replay_filter_search, 1)
        filter_layout.addLayout(filter_top)

        self.replay_filter_table = QTableWidget()
        self.replay_filter_table.setColumnCount(9)
        self.replay_filter_table.setHorizontalHeaderLabels(["Use", "Bus", "Ctx", "DID", "MID / CAN ID", "Name", "Count", "Last decoded", "Raw sample"])
        self.replay_filter_table.setColumnWidth(0, 55)
        self.replay_filter_table.setColumnWidth(1, 60)
        self.replay_filter_table.setColumnWidth(5, 260)
        self.replay_filter_table.setColumnWidth(7, 420)
        self.replay_filter_table.setColumnWidth(8, 280)
        self.replay_filter_table.setMaximumHeight(260)
        self.replay_filter_table.horizontalHeader().setSectionResizeMode(QHeaderView.Interactive)
        self.replay_filter_table.setSelectionBehavior(QAbstractItemView.SelectRows)
        self.replay_filter_table.setAlternatingRowColors(True)
        filter_layout.addWidget(self.replay_filter_table)

        self.replay_filter_summary = QLabel("Replay filter: not scanned")
        self.replay_filter_summary.setStyleSheet("color: #aeb7c2;")
        filter_layout.addWidget(self.replay_filter_summary)

        layout.addWidget(filter_box)

        self.replay_log = QTextEdit()
        self.replay_log.setReadOnly(True)
        layout.addWidget(self.replay_log)

        self.replay_open_btn.clicked.connect(self.choose_replay)
        self.replay_start_btn.clicked.connect(self.start_replay)
        self.replay_stop_btn.clicked.connect(self.stop_replay)
        self.replay_filter_scan_btn.clicked.connect(self.scan_replay_filter_ids)
        self.replay_filter_all_btn.clicked.connect(lambda: self.set_replay_filter_checks(True))
        self.replay_filter_none_btn.clicked.connect(lambda: self.set_replay_filter_checks(False))
        self.replay_filter_invert_btn.clicked.connect(self.invert_replay_filter_checks)
        self.replay_filter_search.textChanged.connect(self.apply_replay_filter_search)
        self.replay_bean_stream_enable.stateChanged.connect(self.update_replay_output_status)
        self.replay_loop_mode_enable.stateChanged.connect(self.update_replay_output_status)
        self.update_replay_output_status()
        self.tabs.addTab(tab, "Replay / Export")

    def build_event_analyzer_tab(self):
        tab = QWidget()
        layout = QVBoxLayout(tab)

        top = QHBoxLayout()
        self.event_csv_path = QLineEdit()
        self.event_csv_path.setPlaceholderText("CSV log path")
        self.event_load_csv_btn = QPushButton("Load CSV...")
        self.event_use_active_btn = QPushButton("Analyze active session")
        self.event_analyze_btn = QPushButton("Analyze CSV")
        self.event_save_report_btn = QPushButton("Save report...")
        top.addWidget(self.event_csv_path, 1)
        top.addWidget(self.event_load_csv_btn)
        top.addWidget(self.event_analyze_btn)
        top.addWidget(self.event_use_active_btn)
        top.addWidget(self.event_save_report_btn)
        layout.addLayout(top)

        hint = QLabel("Real Event Analyzer correlates markers and known BEAN candidates: SNOW, driver door, body/remote status, key/lock pulses and motor/IGN stream drop.")
        hint.setWordWrap(True)
        hint.setStyleSheet("color: #aeb7c2;")
        layout.addWidget(hint)

        self.event_report = QPlainTextEdit()
        self.event_report.setReadOnly(True)
        self.event_report.setFont(QFont("Courier New", 10))
        layout.addWidget(self.event_report, 1)

        self.event_load_csv_btn.clicked.connect(self.event_analyzer_load_csv)
        self.event_analyze_btn.clicked.connect(self.event_analyzer_analyze_csv)
        self.event_use_active_btn.clicked.connect(self.event_analyzer_analyze_active)
        self.event_save_report_btn.clicked.connect(self.event_analyzer_save_report)

        self.tabs.addTab(tab, "Event Analyzer")

    def event_analyzer_load_csv(self):
        path, _ = QFileDialog.getOpenFileName(self, "Open BEANalyzer CSV log", str(USER_LOGS_DIR), "CSV files (*.csv);;All files (*)")
        if not path:
            return
        self.event_analyzer_csv_path = Path(path)
        self.event_csv_path.setText(path)

    def event_analyzer_analyze_csv(self):
        path_text = self.event_csv_path.text().strip()
        if path_text:
            self.event_analyzer_csv_path = Path(path_text)
        if not self.event_analyzer_csv_path or not self.event_analyzer_csv_path.exists():
            QMessageBox.warning(self, "Event Analyzer", "Load a CSV log first.")
            return
        try:
            rows = []
            with open(self.event_analyzer_csv_path, "r", encoding="utf-8", newline="") as f:
                for row in csv.DictReader(f):
                    rows.append(row)
            self.event_report.setPlainText(self.build_event_analysis_report(rows, source_name=str(self.event_analyzer_csv_path)))
        except Exception as exc:
            QMessageBox.critical(self, "Event Analyzer error", str(exc))

    def event_analyzer_analyze_active(self):
        rows = list(self.export_rows)
        self.event_report.setPlainText(self.build_event_analysis_report(rows, source_name="active session"))

    def event_analyzer_save_report(self):
        text = self.event_report.toPlainText()
        if not text.strip():
            QMessageBox.warning(self, "Event Analyzer", "There is no report to save.")
            return
        path, _ = QFileDialog.getSaveFileName(self, "Save event analysis report", str(USER_LOGS_DIR / "event_analysis.txt"), "Text files (*.txt);;All files (*)")
        if not path:
            return
        Path(path).write_text(text, encoding="utf-8")

    @staticmethod
    def event_row_time(row: dict[str, Any]) -> str:
        return str(row.get("pc_time") or row.get("pc_time_iso") or "")

    @staticmethod
    def event_row_ms(row: dict[str, Any]) -> int:
        try:
            return int(float(row.get("arduino_ms") or 0))
        except Exception:
            return 0

    @staticmethod
    def event_row_key_data(row: dict[str, Any]) -> tuple[str, str, list[int], str]:
        """Return DID/DST, MID/MES, real data, raw for CSV rows from old or new exports."""
        raw = str(row.get("raw") or "")
        bus = str(row.get("bus") or "").upper()
        if bus == "MARKER":
            return "MARKER", "", [], raw

        # Prefer physical v4.18 export fields when available.
        did = str(row.get("dst_id") or row.get("did") or "").upper().replace("0X", "")
        mid = str(row.get("mes_id") or row.get("mid") or "").upper().replace("0X", "")
        data_text = str(row.get("data") or "")
        data = parse_hex_bytes(data_text)

        # If old CSV has raw with physical frame, reconstruct real DATA to avoid CRC/EOM pollution.
        frame = parse_hex_bytes(raw)
        meta = bean_capture_metadata(frame)
        if meta.get("dst_id") and meta.get("mes_id") and meta.get("crc_ok") == "1":
            did = str(meta["dst_id"])
            mid = str(meta["mes_id"])
            data = list(meta.get("data") or [])

        return did.zfill(2) if did else "", mid.zfill(2) if mid else "", data, raw

    @staticmethod
    def event_format_data(data: list[int]) -> str:
        return bytes_to_hex(data) if data else "--"

    @staticmethod
    def event_bit(value: int, bit: int) -> bool:
        return bool(value & (1 << bit))

    def build_event_analysis_report(self, rows: list[dict[str, Any]], source_name: str = "") -> str:
        lines: list[str] = []
        lines.append("BEANalyzer UltraPro v4.18 - Real Event Analysis")
        lines.append("=" * 72)
        lines.append(f"Source: {source_name}")
        lines.append(f"Rows: {len(rows)}")
        lines.append("")

        markers = []
        bean_rows = []
        counts: dict[tuple[str, str], int] = {}
        crc_ok = 0
        crc_bad = 0
        unique_data: dict[tuple[str, str], set[str]] = {}

        for row in rows:
            bus = str(row.get("bus") or "").upper()
            if bus == "MARKER":
                markers.append((self.event_row_time(row), str(row.get("event_marker") or row.get("decoded") or row.get("data") or "")))
                continue
            did, mid, data, raw = self.event_row_key_data(row)
            if not did or not mid:
                continue
            key = (did, mid)
            counts[key] = counts.get(key, 0) + 1
            unique_data.setdefault(key, set()).add(bytes_to_hex(data))
            bean_rows.append((self.event_row_ms(row), self.event_row_time(row), did, mid, data, raw))
            if str(row.get("crc_ok") or "").strip() == "1":
                crc_ok += 1
            elif str(row.get("crc_ok") or "").strip() == "0":
                crc_bad += 1

        lines.append("Summary")
        lines.append("-" * 72)
        lines.append(f"BEAN/message rows parsed: {len(bean_rows)}")
        if crc_ok or crc_bad:
            lines.append(f"CRC OK rows: {crc_ok}")
            lines.append(f"CRC BAD rows: {crc_bad}")
        lines.append(f"Unique DID/MID pairs: {len(counts)}")
        lines.append("")

        if markers:
            lines.append("User event markers")
            lines.append("-" * 72)
            for t, label in markers:
                lines.append(f"{t:>15}  {label}")
            lines.append("")

        top = sorted(counts.items(), key=lambda kv: kv[1], reverse=True)[:20]
        lines.append("Top messages by count")
        lines.append("-" * 72)
        for (did, mid), count in top:
            variants = len(unique_data.get((did, mid), set()))
            lines.append(f"{did}/{mid}  count={count:<6} variants={variants}")
        lines.append("")

        # Timeline candidates for key commands.
        watched = {
            ("62", "D2"): "SNOW / dash modes/icons",
            ("FE", "DB"): "Driver door status candidate",
            ("FE", "DF"): "Body / door / remote status candidate",
            ("FE", "E4"): "Lights / key / remote pulse candidate",
            ("FE", "1E"): "Remote/key/lock pulse candidate",
            ("FE", "1F"): "Small body/key flags candidate",
            ("FE", "24"): "Vehicle speed stream",
            ("FE", "26"): "RPM stream",
            ("FE", "2C"): "Coolant temp stream",
            ("FE", "CD"): "Outside temp stream",
            ("62", "91"): "ABS/TRC/VSC candidate",
            ("FE", "91"): "ABS/TRC/VSC candidate",
        }

        last_by_key: dict[tuple[str, str], list[int]] = {}
        timeline: list[tuple[int, str, str]] = []
        for ms, t, did, mid, data, raw in bean_rows:
            key = (did, mid)
            if key not in watched:
                continue
            prev = last_by_key.get(key)
            if prev is None:
                last_by_key[key] = list(data)
                continue
            if prev != data:
                desc = f"{did}/{mid} {watched[key]}: {self.event_format_data(prev)} -> {self.event_format_data(data)}"
                # Add specific interpretations.
                if key == ("62", "D2") and data:
                    desc += " | SNOW=" + ("ON" if self.event_bit(data[0], 4) else "OFF")
                    desc += " ECT_PWR=" + ("ON" if self.event_bit(data[0], 5) else "OFF")
                    desc += " CRUISE=" + ("ON" if self.event_bit(data[0], 1) else "OFF")
                if key == ("FE", "DB") and data:
                    desc += " | driver_door_candidate=" + ("OPEN" if self.event_bit(data[0], 4) else "CLOSED")
                if key in (("FE", "E4"), ("FE", "1E"), ("FE", "DF")):
                    desc += " | body/remote/key transition candidate"
                if key in (("62", "91"), ("FE", "91")) and data:
                    desc += " | TRC/VSC reference candidate, validate bits"
                timeline.append((ms, t, desc))
                last_by_key[key] = list(data)

        lines.append("Candidate event timeline")
        lines.append("-" * 72)
        if timeline:
            for _, t, desc in sorted(timeline, key=lambda x: x[0])[:250]:
                lines.append(f"{t:>15}  {desc}")
            if len(timeline) > 250:
                lines.append(f"... {len(timeline) - 250} more changes omitted")
        else:
            lines.append("No watched event transitions found.")
        lines.append("")

        # Motor/IGN stream drop candidates.
        stream_keys = [("FE","24"), ("FE","26"), ("FE","2C"), ("FE","CD"), ("62","20")]
        lines.append("Motor / IGN stream hints")
        lines.append("-" * 72)
        for key in stream_keys:
            events = [(ms, t) for ms, t, did, mid, data, raw in bean_rows if (did, mid) == key]
            if events:
                lines.append(f"{key[0]}/{key[1]} first={events[0][1]} last={events[-1][1]} count={len(events)}")
            else:
                lines.append(f"{key[0]}/{key[1]} not observed")
        lines.append("")

        # Strong DBC suggestions
        lines.append("DBC suggestions from this analysis")
        lines.append("-" * 72)
        lines.append("FE/DB: driver door status candidate; DATA0 bit4 appears to match driver door open/closed.")
        lines.append("FE/DF: body/door/remote status candidate; watch DATA0, DATA1 and last byte flags.")
        lines.append("FE/E4: lights/key/remote pulse candidate; likely useful around lock/unlock/key events.")
        lines.append("FE/1E and FE/1F: small key/body pulse candidates; correlate with remote lock/unlock.")
        lines.append("62/D2: SNOW/dash modes; DATA0 bit4 = SNOW candidate confirmed by log.")
        lines.append("TRC OFF: keep 0x91 as candidate, but report only if FE/91 or 62/91 appears.")
        lines.append("")
        lines.append("Recommended workflow: add markers during capture, then run this analyzer to compare transitions around each marker.")
        return "\\n".join(lines)

    def build_bean_protocol_tab(self):
        tab = QWidget()
        layout = QVBoxLayout(tab)

        intro = QLabel(
            "BEAN Protocol Tools — data link layer decoder, CRC-8 validator, frame builder, capture-to-TX converter, RSP/EOF view and bit-stuffing preview."
        )
        intro.setWordWrap(True)
        layout.addWidget(intro)

        splitter = QSplitter(Qt.Vertical)
        layout.addWidget(splitter, 1)

        # Builder --------------------------------------------------------------
        builder = QWidget()
        b = QVBoxLayout(builder)
        b.addWidget(QLabel("Frame builder: enter BEAN TX payload as DST-ID / MES-ID / DATA. ML = DST + MES + DATA, so DATA length = ML - 2. Example: FE CD 49"))

        row = QHBoxLayout()
        self.bean_proto_payload = QLineEdit("FE CD 49")
        self.bean_proto_priority = QSpinBox()
        self.bean_proto_priority.setRange(0, 15)
        self.bean_proto_priority.setValue(2)
        self.bean_proto_build_btn = QPushButton("Build frame")
        self.bean_proto_send_btn = QPushButton("Send payload via BEAN")
        row.addWidget(QLabel("Payload:"))
        row.addWidget(self.bean_proto_payload, 1)
        row.addWidget(QLabel("Priority:"))
        row.addWidget(self.bean_proto_priority)
        row.addWidget(self.bean_proto_build_btn)
        row.addWidget(self.bean_proto_send_btn)
        b.addLayout(row)

        self.bean_proto_build_output = QTextEdit()
        self.bean_proto_build_output.setReadOnly(True)
        b.addWidget(self.bean_proto_build_output, 1)

        splitter.addWidget(builder)

        # Validator ------------------------------------------------------------
        validator = QWidget()
        v = QVBoxLayout(validator)
        v.addWidget(QLabel("Frame validator: enter logical BEAN frame including header, CRC and optionally EOM/EOF. Example: 13 FE 24 00 B4 7E 00"))
        row2 = QHBoxLayout()
        self.bean_proto_frame = QLineEdit("13 FE 24 00 B4 7E 00")
        self.bean_proto_validate_btn = QPushButton("Validate / decode frame")
        self.bean_proto_capture_to_payload_btn = QPushButton("Extract TX payload from capture")
        row2.addWidget(QLabel("Frame/capture:"))
        row2.addWidget(self.bean_proto_frame, 1)
        row2.addWidget(self.bean_proto_validate_btn)
        row2.addWidget(self.bean_proto_capture_to_payload_btn)
        v.addLayout(row2)

        self.bean_proto_validate_output = QTextEdit()
        self.bean_proto_validate_output.setReadOnly(True)
        v.addWidget(self.bean_proto_validate_output, 1)

        splitter.addWidget(validator)

        self.bean_proto_build_btn.clicked.connect(self.bean_protocol_build_frame)
        self.bean_proto_send_btn.clicked.connect(self.bean_protocol_send_payload)
        self.bean_proto_validate_btn.clicked.connect(self.bean_protocol_validate_frame)
        self.bean_proto_capture_to_payload_btn.clicked.connect(self.bean_protocol_extract_payload)

        splitter.setSizes([350, 350])
        self.tabs.addTab(tab, "BEAN Protocol")

    def bean_protocol_build_frame(self):
        try:
            payload = parse_hex_bytes(self.bean_proto_payload.text())
            priority = self.bean_proto_priority.value()
            frame = bean_build_frame(payload, priority=priority, include_eom=True)
            core = frame[:-1]  # without EOM
            bits = bean_bitstuff_bits_from_bytes(core)
            bit_text = "".join(str(b) for b in bits)
            decoded = bean_decode_logical_frame(frame)
            lines = [
                "INPUT PAYLOAD",
                f"  {bytes_to_hex(payload)}",
                "",
                "LOGICAL FRAME",
                f"  {bytes_to_hex(frame)}",
                "",
                "FIELDS",
                f"  Header: 0x{frame[0]:02X}",
                f"  PRI:    {decoded.get('priority')}",
                f"  ML:     {decoded.get('ml')} byte(s) = DST-ID + MES-ID + DATA",
                f"  DATA length: {decoded.get('data_len')} byte(s) = ML - 2",
                f"  ML valid: {'YES' if decoded.get('ml_valid') else 'NO'}",
                f"  DST-ID: 0x{decoded.get('dst', 0):02X}" if decoded.get("dst") is not None else "  DST-ID: --",
                f"  MES-ID: 0x{decoded.get('mid', 0):02X}" if decoded.get("mid") is not None else "  MES-ID: --",
                f"  DATA:   {bytes_to_hex(decoded.get('payload_data') or [])}",
                f"  CRC:    0x{decoded.get('crc_calculated', 0):02X}",
                f"  EOM:    0x7E",
                "",
                "DATA LINK LAYER NOTES",
                "  Access method: CSMA/CD with bit-wise arbitration",
                "  Response area: ACK/NAK is represented in RSP after EOM",
                "  DATA bytes: 1..11, therefore normal ML range is 3..13",
                "",
                "BIT-STUFFING PREVIEW",
                "  Stuffing is applied from SOF/header through CRC, not to EOM.",
                f"  Stuffed bit count: {len(bits)}",
                f"  Bits: {bit_text[:512]}{'...' if len(bit_text) > 512 else ''}",
                "",
                "ARDUINO BRIDGE TX LINE",
                f"  BTX,{bytes_to_hex(payload)}",
                "",
                "NOTE",
                "  This Protocol tab builds low-level DST/MES/DATA frames.",
                "  Manual TX examples for the BeanMPX Arduino bridge use 0x62-style payloads when required by the firmware.",
            ]
            self.bean_proto_build_output.setPlainText("\n".join(lines))
        except Exception as exc:
            self.bean_proto_build_output.setPlainText(f"ERROR: {exc}")

    def bean_protocol_validate_frame(self):
        try:
            frame = parse_hex_bytes(self.bean_proto_frame.text())
            decoded = bean_decode_logical_frame(frame)
            if not decoded.get("ok"):
                self.bean_proto_validate_output.setPlainText("ERROR: " + str(decoded.get("error")))
                return
            lines = [
                "FRAME",
                f"  {bytes_to_hex(frame)}",
                "",
                "DECODED",
                f"  Header:       0x{decoded['header']:02X}",
                f"  Priority:     {decoded['priority']}",
                f"  ML:           {decoded['ml']} byte(s) = DST-ID + MES-ID + DATA",
                f"  DATA length:  {decoded['data_len']} byte(s) = ML - 2",
                f"  ML valid:     {'YES' if decoded['ml_valid'] else 'NO'}",
                f"  DST-ID:       0x{decoded['dst']:02X}" if decoded["dst"] is not None else "  DST-ID:       --",
                f"  MES-ID / MID: 0x{decoded['mid']:02X}" if decoded["mid"] is not None else "  MES-ID / MID: --",
                f"  DATA:         {bytes_to_hex(decoded['payload_data'])}",
                f"  CRC RX:       0x{decoded['crc_received']:02X}",
                f"  CRC CALC:     0x{decoded['crc_calculated']:02X}",
                f"  CRC STATUS:   {'OK' if decoded['crc_ok'] else 'BAD'}",
                f"  EOM present:  {'YES' if decoded['has_eom'] else 'NO'}",
                f"  Post-EOM byte:{' 0x%02X' % decoded['post_eom_raw'] if decoded.get('post_eom_raw') is not None else ' --'}",
                f"  RSP candidate:{' 0b' + format(decoded['rsp_candidate_bits'], '02b') if decoded.get('rsp_candidate_bits') is not None else ' --'}",
                f"  EOF candidate:{' 0b' + format(decoded['eof_candidate_bits'], '06b') if decoded.get('eof_candidate_bits') is not None else ' --'}",
                "",
                "RSP / ACK-NAK NOTE",
                "  RSP is a 2-bit response area after EOM. Exact ACK/NAK mapping depends on capture alignment and must be validated on the interface.",
                "",
                "TX PAYLOAD",
                f"  {bytes_to_hex(([decoded['dst'], decoded['mid']] if decoded['dst'] is not None and decoded['mid'] is not None else []) + decoded['payload_data'])}",
            ]
            self.bean_proto_validate_output.setPlainText("\n".join(lines))
        except Exception as exc:
            self.bean_proto_validate_output.setPlainText(f"ERROR: {exc}")

    def bean_protocol_extract_payload(self):
        try:
            frame = parse_hex_bytes(self.bean_proto_frame.text())
            payload = bean_payload_from_capture(frame)
            self.bean_proto_payload.setText(bytes_to_hex(payload))
            self.bean_proto_validate_output.setPlainText(
                "CAPTURE TO TX PAYLOAD\n\n"
                f"Input:   {bytes_to_hex(frame)}\n"
                f"Payload: {bytes_to_hex(payload)}\n\n"
                f"Bridge line: BTX,{bytes_to_hex(payload)}"
            )
        except Exception as exc:
            self.bean_proto_validate_output.setPlainText(f"ERROR: {exc}")

    def bean_protocol_send_payload(self):
        try:
            if not self.bean_reader:
                raise RuntimeError("BEAN serial is not connected")
            payload = parse_hex_bytes(self.bean_proto_payload.text())
            if not payload:
                raise RuntimeError("Empty BEAN payload")
            self.bean_reader.write_line("BTX," + bytes_to_hex(payload))
            self.bean_proto_build_output.append("\nSENT VIA BEAN SERIAL:\n  " + bytes_to_hex(payload))
        except Exception as exc:
            QMessageBox.critical(self, "BEAN Protocol TX error", str(exc))

    def build_help_tab(self):
        tab = QWidget(); layout = QVBoxLayout(tab)
        text = QTextEdit(); text.setReadOnly(True)
        text.setMarkdown("""
# BEANalyzer UltraPro quick help

## Recommended setup
- Python 3.12.x
- Serial 115200
- Start as RX-only sniffer
- Disable ACK while sniffing unless your bench setup requires it

## USB devices
Use **Refresh USB** to list serial devices. You can select Arduino BEAN bridge, Arduino CAN bridge, CANable/slcan, etc. CANalyst-II is accessed through `python-can` and may not appear as a serial port.

Unlikely Bluetooth serial ports are marked and sorted after real USB adapters. If you see something like `/dev/cu.*Buds*`, that is normally an audio/Bluetooth pseudo-port, not the Arduino BEAN bridge.

## CAN backends
- CANalyst-II: interface `canalystii`, channel usually `0` or `1`, bitrate `500000`
- CANable/slcan: interface `slcan`, channel like `/dev/ttyACM0` or `COM7`, bitrate `500000`
- SocketCAN: interface `socketcan`, channel `can0`, bitrate `500000`
- Arduino CAN serial bridge: select serial port and use the included Arduino sketch

## Manual TX syntax
- BEAN bridge payload: `62 D2 10 00 00`  
  Manual TX sends the bytes exactly as written after `BTX,`. For the Arduino BeanMPX bridge, dashboard commands normally use `0x62` as the first byte. Low-level physical examples using destination `0xFE` are reference frames and may not work with this bridge.
- CAN: `200#01 02 03 04 05 06 07 08`
- CAN extended: check `CAN extended`
- Repetitions `0` = loop forever until Stop

## BEAN-DBC editor
The editor lets you edit messages without touching the whole JSON manually. The raw JSON view remains available for advanced edits.

## Replay BEAN stream
Replay / Export has an optional **Enable BEAN stream** checkbox. When enabled, BEAN rows from the CSV are transmitted through the connected Arduino BEAN bridge using `BTX,DID MID DATA...`. Use only on a bench harness or isolated controlled network.

## Replay filter
Replay / Export can scan the CSV and build a fixed ID/command list similar to Message Monitor. Enable **Replay filter** and check only the CTX/DID/MID or CAN IDs you want to replay/stream.

## BEAN Protocol tab
Use the BEAN Protocol tab to build BEAN logical frames, validate CRC-8/BEAN, extract TX payloads from captured frames and preview bit stuffing. CRC-8/BEAN uses polynomial `0x13`, init `0x00`, no reflection and xorout `0x00`. Reference: `CRC8(13 FE 24 00) = B4`. ML is the number of bytes of `DST-ID + MES-ID + DATA`, therefore `DATA length = ML - 2`; normal DATA length is 1..11 bytes and normal ML is 3..13. BEAN uses CSMA/CD with bit-wise arbitration and a 2-bit RSP area for ACK/NAK after EOM.
""")
        layout.addWidget(text)
        self.tabs.addTab(tab, "Help")

    def apply_style(self):
        self.setStyleSheet("""
        QMainWindow { background-color: #14171d; }
        QLabel, QCheckBox, QGroupBox { color: #e8e8e8; }
        QGroupBox { border: 1px solid #394252; border-radius: 8px; margin-top: 10px; padding: 8px; background-color: #1d222b; }
        QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 5px; }
        QPushButton { background-color: #2c3545; color: white; border: 1px solid #4b566b; padding: 6px 10px; border-radius: 5px; }
        QPushButton:hover { background-color: #39465c; }
        QComboBox, QLineEdit, QPlainTextEdit, QTextEdit, QSpinBox, QDoubleSpinBox, QListWidget { background-color: #202631; color: white; border: 1px solid #4b566b; border-radius: 4px; padding: 4px; }
        QTableWidget { background-color: #10131a; alternate-background-color: #171b24; color: #e8e8e8; gridline-color: #2a3040; selection-background-color: #344966; }
        QHeaderView::section { background-color: #252c39; color: white; padding: 5px; border: 1px solid #394252; }
        QTabBar::tab { background: #252c39; color: white; padding: 8px 14px; border: 1px solid #394252; }
        QTabBar::tab:selected { background: #39465c; }
        QStatusBar { background-color: #10131a; color: #e8e8e8; }
        """)

    # Device handling ---------------------------------------------------------
    def refresh_devices(self):
        """Refresh serial devices and put real USB adapters before Bluetooth/pseudo ports."""
        try:
            ports = sorted(list(list_ports.comports()), key=serial_port_score)
        except Exception as exc:
            QMessageBox.critical(self, "USB scan error", f"Could not scan serial ports:\n{exc}")
            ports = []

        previous_bean = combo_selected_port(self.bean_port_combo) if hasattr(self, "bean_port_combo") else ""
        previous_can = combo_selected_port(self.can_serial_combo) if hasattr(self, "can_serial_combo") else ""

        self.bean_port_combo.clear()
        self.can_serial_combo.clear()

        for p in ports:
            label = serial_port_label(p)
            self.bean_port_combo.addItem(label, p.device)
            self.can_serial_combo.addItem(label, p.device)

        # Allow direct/manual entry even when a port is not listed exactly as expected.
        if self.bean_port_combo.lineEdit():
            self.bean_port_combo.lineEdit().setPlaceholderText("/dev/cu.usbserial..., /dev/cu.usbmodem..., COMx...")
        if self.can_serial_combo.lineEdit():
            self.can_serial_combo.lineEdit().setPlaceholderText("/dev/cu.usbserial..., /dev/cu.usbmodem..., COMx...")

        def restore_or_keep(combo: QComboBox, previous: str):
            if not previous:
                return
            idx = combo.findData(previous)
            if idx >= 0:
                combo.setCurrentIndex(idx)
            else:
                combo.setEditText(previous)

        restore_or_keep(self.bean_port_combo, previous_bean)
        restore_or_keep(self.can_serial_combo, previous_can)

        if ports:
            self.status.showMessage(f"USB refreshed: {len(ports)} serial device(s). USB adapters are sorted before Bluetooth devices.")
        else:
            self.status.showMessage("USB refreshed: no serial devices found. You can still type a port manually.")

    def set_bean_connection_ui(self, connected: bool):
        if connected:
            self.bean_connect_btn.setText("BEAN connected")
            self.bean_connect_btn.setEnabled(False)
            self.bean_disconnect_btn.setEnabled(True)
        else:
            self.bean_connect_btn.setText("Connect BEAN")
            self.bean_connect_btn.setEnabled(True)
            self.bean_disconnect_btn.setEnabled(False)

    def warn_if_unlikely_serial(self, port: str, purpose: str) -> bool:
        if not is_unlikely_bean_serial_port(port):
            return True
        answer = QMessageBox.question(
            self,
            "Unlikely serial device selected",
            f"The selected {purpose} port looks like a Bluetooth/audio/pseudo serial device:\n\n{port}\n\n"
            "That is usually NOT an Arduino/BEAN/CAN bridge, and it will open without receiving valid frames.\n\n"
            "Recommended ports usually look like /dev/cu.usbserial-*, /dev/cu.usbmodem-*, /dev/ttyUSB*, /dev/ttyACM* or COMx.\n\n"
            "Do you still want to try this port?",
            QMessageBox.Yes | QMessageBox.No,
            QMessageBox.No,
        )
        return answer == QMessageBox.Yes

    def on_bean_status(self, text: str):
        self.status.showMessage(text)
        if text.startswith("Connected to"):
            self.set_bean_connection_ui(True)
            QTimer.singleShot(3000, self.check_bean_frames_after_connect)
        elif "disconnected" in text.lower():
            self.set_bean_connection_ui(False)

    def on_bean_error(self, text: str):
        self.bean_reader = None
        self.set_bean_connection_ui(False)
        self.status.showMessage(text)
        QMessageBox.critical(self, "BEAN serial error", text)

    def check_bean_frames_after_connect(self):
        if self.bean_reader and self.frame_count == 0:
            QMessageBox.warning(
                self,
                "BEAN connected, but no valid frames",
                "The serial port opened, but BEANalyzer has not received any valid BEAN capture frames yet.\n\n"
                "Check these points:\n"
                "1. You selected the Arduino BEAN bridge, not a Bluetooth device.\n"
                "2. Arduino sketch is uploaded and running at 115200 baud.\n"
                "3. The bridge prints frames in BEANalyzer format.\n"
                "4. RX/TX wiring and GND are correct.\n"
                "5. BEAN adapter is not loading or locking the bus."
            )

    def connect_bean(self):
        """Connect the BEAN serial bridge. Never let CSV/UI issues kill the button action."""
        try:
            if self.bean_reader:
                QMessageBox.information(self, "Already connected", "BEAN serial is already connected.")
                return

            port = combo_selected_port(self.bean_port_combo)
            if not port:
                QMessageBox.warning(self, "No port", "Select a BEAN serial port or type it manually.")
                return

            if not self.warn_if_unlikely_serial(port, "BEAN serial"):
                return

            self.status.showMessage(f"Opening BEAN serial port: {port}")
            self.bean_connect_btn.setText("Connecting...")
            self.bean_connect_btn.setEnabled(False)
            self.bean_disconnect_btn.setEnabled(True)
            QApplication.processEvents()

            # CSV logging is useful, but it must never prevent connection.
            self.open_csv()

            reader = SerialReader(port)
            reader.frame_received.connect(self.handle_frame)
            reader.info_received.connect(lambda s: self.status.showMessage(f"BEAN: {s}"))
            reader.error_received.connect(self.on_bean_error)
            reader.status_received.connect(self.on_bean_status)
            self.bean_reader = reader
            reader.start()

        except Exception as exc:
            self.bean_reader = None
            self.set_bean_connection_ui(False)
            QMessageBox.critical(self, "Connect BEAN failed", f"The Connect BEAN action failed before opening the serial thread:\n\n{exc}")
            self.status.showMessage(f"Connect BEAN failed: {exc}")

    def disconnect_bean(self):
        """Disconnect the BEAN serial bridge safely."""
        try:
            reader = self.bean_reader
            self.bean_reader = None
            if reader:
                self.status.showMessage("Disconnecting BEAN serial...")
                reader.stop()
                if not reader.wait(1500):
                    reader.terminate()
                    reader.wait(500)
            self.status.showMessage("BEAN serial disconnected")
        except Exception as exc:
            QMessageBox.warning(self, "Disconnect BEAN warning", f"Disconnect raised a warning:\n\n{exc}")
            self.status.showMessage(f"Disconnect BEAN warning: {exc}")
        finally:
            self.set_bean_connection_ui(False)
            self.close_csv()

    def connect_can_serial(self):
        if self.can_serial_reader:
            QMessageBox.information(self, "Already connected", "Arduino CAN serial bridge is already connected.")
            return
        port = combo_selected_port(self.can_serial_combo)
        if not port:
            QMessageBox.warning(self, "No port", "Select an Arduino CAN serial bridge port.")
            return
        if not self.warn_if_unlikely_serial(port, "Arduino CAN serial bridge"):
            return
        self.can_serial_reader = SerialReader(port)
        self.can_serial_reader.frame_received.connect(self.handle_frame)
        self.can_serial_reader.info_received.connect(lambda s: self.status.showMessage(f"CAN serial: {s}"))
        self.can_serial_reader.error_received.connect(lambda s: QMessageBox.critical(self, "CAN serial error", s))
        self.can_serial_reader.start()
        self.serial_can.attach_reader(self.can_serial_reader)
        if self.gateway_backend.currentText().startswith("Arduino"):
            self.gateway.set_sender(self.serial_can)

    def disconnect_can_serial(self):
        if self.can_serial_reader:
            self.can_serial_reader.stop(); self.can_serial_reader.wait(1000); self.can_serial_reader = None

    def connect_python_can(self):
        try:
            self.python_can.connect(self.can_interface.currentText(), self.can_channel.text().strip(), int(self.can_bitrate.text().strip()))
            if self.gateway_backend.currentText() == "python-can":
                self.gateway.set_sender(self.python_can)
            if self.can_rx_worker:
                self.can_rx_worker.stop(); self.can_rx_worker.wait(1000); self.can_rx_worker = None
            self.can_rx_worker = CanRxWorker(self.python_can)
            self.can_rx_worker.frame_received.connect(self.handle_frame)
            self.can_rx_worker.status_received.connect(lambda s: self.status.showMessage(s))
            self.can_rx_worker.error_received.connect(lambda s: QMessageBox.critical(self, "python-can RX error", s))
            self.can_rx_worker.start()
            QMessageBox.information(self, "CAN connected", "python-can backend connected and RX monitor started.")
        except Exception as exc:
            QMessageBox.critical(self, "CAN error", str(exc))

    def disconnect_python_can(self):
        if self.can_rx_worker:
            self.can_rx_worker.stop(); self.can_rx_worker.wait(1000); self.can_rx_worker = None
        self.python_can.disconnect(); QMessageBox.information(self, "CAN", "python-can disconnected.")


    def open_message_monitor(self):
        """Open or raise the fixed-row CANalyst-style monitor window."""
        if self.message_monitor_window is None:
            self.message_monitor_window = MessageMonitorWindow(self)
        self.message_monitor_window.show()
        self.message_monitor_window.raise_()
        self.message_monitor_window.activateWindow()

    def open_can_gateway_monitor(self):
        """Open or raise the live CAN gateway RX/TX monitor window."""
        if self.can_gateway_monitor_window is None:
            self.can_gateway_monitor_window = CanGatewayMonitorWindow(self)
        self.can_gateway_monitor_window.show()
        self.can_gateway_monitor_window.raise_()
        self.can_gateway_monitor_window.activateWindow()

    def notify_can_gateway_monitor(self, event: dict):
        if self.can_gateway_monitor_window is not None:
            self.can_gateway_monitor_window.add_event(event)

    def send_can_frame(self, can_id: int, data: list[int], extended: bool = False, backend: str = "python-can", note: str = ""):
        sender = self.python_can if backend == "python-can" else self.serial_can
        sender.send(can_id, data, extended)
        if note:
            self.status.showMessage(f"CAN TX {backend}: 0x{can_id:X}#{bytes_to_hex(data)}")


    def open_website(self):
        """Open the official AE&D website in the default system browser."""
        QDesktopServices.openUrl(QUrl(WEBSITE_URL))

    def set_unit_mode(self, mode: str):
        self.unit_mode = mode
        if hasattr(self, "act_units_metric"):
            self.act_units_metric.setChecked(mode == UNIT_MODE_METRIC)
            self.act_units_imperial.setChecked(mode == UNIT_MODE_IMPERIAL)
            self.act_units_force_imperial.setChecked(mode == UNIT_MODE_FORCE_IMPERIAL)
            self.act_units_raw.setChecked(mode == UNIT_MODE_RAW)

        # Refresh existing cards with converted values without changing stored raw decoded values.
        for name, card in getattr(self, "cards", {}).items():
            sig = self.signal_cache.get(name)
            if sig:
                card.set_signal(convert_signal_for_units(sig, self.unit_mode))

        # Refresh plots from now onward; existing plot points are kept as originally buffered.
        self.status.showMessage(f"Units: {mode}")

    def show_units_info(self):
        text = (
            "Unit display mode controls how decoded numeric values are shown in the UI.\n\n"
            "Metric:\n"
            "  Shows values as km/h, km, °C, L/100km or km/L where applicable.\n\n"
            "Imperial:\n"
            "  Converts known metric decoded values to mph, miles, °F, MPG or gallons where applicable.\n\n"
            "Force Imperial Units:\n"
            "  Assumes Toyota/Lexus bus values are metric/base values and forces metric-to-imperial display conversion when the signal name clearly represents speed, temperature, distance or fuel consumption.\n"
            "  Useful when the dashboard likely converts metric ECU data to imperial units in firmware.\n\n"
            "Raw ECU / decoded:\n"
            "  Shows the decoder output without unit conversion.\n\n"
            "Raw decimal / raw hex / binary card modes still use the original raw bytes."
        )
        QMessageBox.information(self, "Units", text)

    def show_about_dialog(self):
        """Show the professional About dialog."""
        dlg = AboutDialog(self)
        dlg.exec()

    # Frame handling ----------------------------------------------------------
    def handle_frame(self, parsed: dict[str, Any]):
        if self.sniffing_paused and not parsed.get("replay", False):
            return
        kind = parsed.get("kind", "BEAN")
        arduino_ms = int(parsed.get("arduino_ms", 0))
        delta_ms = 0 if self.last_arduino_ms is None else arduino_ms - self.last_arduino_ms
        self.last_arduino_ms = arduino_ms
        pc_time = datetime.now().strftime("%H:%M:%S.%f")[:-3]
        pc_iso = datetime.now().isoformat(timespec="milliseconds")

        if kind == "CAN":
            can_data = parsed.get("data", [])
            can_id = int(parsed.get("can_id", 0))
            can_raw = f"{can_id:X}#{bytes_to_hex(can_data)}"
            if self.message_monitor_window is not None:
                self.message_monitor_window.update_can_frame(parsed)
            self.notify_can_gateway_monitor({
                "kind": "CAN",
                "direction": parsed.get("direction", parsed.get("msg_type", "RX")),
                "msg_type": parsed.get("msg_type", "RX"),
                "can_id": can_id,
                "extended": parsed.get("extended", False),
                "dlc": parsed.get("dlc", len(can_data)),
                "data": can_data,
                "source": parsed.get("source", "RX"),
            })
            change_summary = self.compute_change_summary_for_key(("CAN", can_id), can_data)
            row = [pc_time, str(arduino_ms), str(delta_ms), "CAN", parsed.get("msg_type", "RX"), "", "", f"0x{can_id:X}", bytes_to_hex(can_data), "CAN frame", "can_frame", "", "can", change_summary, can_raw]
            self.add_live_row(row, "vehicle", bool(change_summary))

            self.frame_count += 1
            out_row = {
                "pc_time_iso": pc_iso,
                "pc_time": pc_time,
                "arduino_ms": arduino_ms,
                "delta_ms": delta_ms,
                "bus": "CAN",
                "type": parsed.get("msg_type", "RX"),
                "context": "",
                "did": "",
                "mid": f"0x{can_id:X}",
                "data": bytes_to_hex(can_data),
                "decoded": "CAN frame",
                "function": "can_frame",
                "source_ecu": "",
                "category": "can",
                "changes": change_summary,
                "raw": can_raw,
            }
            self.export_rows.append(out_row)
            if self.csv_writer and not parsed.get("replay", False):
                self.csv_writer.writerow([out_row.get(h, "") for h in CSV_HEADERS])
                if self.frame_count % 30 == 0:
                    self.csv_file.flush()
            return

        frame = parsed.get("frame", [])
        bean_meta = bean_capture_metadata(frame)
        res = self.dbc.decode(frame)
        if self.only_decoded.isChecked() and not res.decoded:
            return
        raw = bytes_to_hex(frame)
        data = bytes_to_hex(res.data)
        context = hex_byte(res.context) if res.context is not None else ""
        did = hex_byte(res.did) if res.did is not None else ""
        mid = hex_byte(res.mid) if res.mid is not None else ""

        if self.message_monitor_window is not None:
            self.message_monitor_window.update_bean_frame(frame, parsed, res, context, did, mid, data, raw)

        monitor_match = self.change_monitor_matches(context, did, mid, raw, res.text)
        if self.change_only_filtered.isChecked() and not monitor_match:
            return

        compare_payload = res.data if res.data else frame
        change_key = self.make_change_key(context, did, mid, frame)
        change_summary = ""
        if self.change_monitor_enabled.isChecked() and monitor_match:
            change_summary = self.compute_change_summary_for_key(change_key, compare_payload)

        data_display = self.decorate_changed_data(data, change_summary)

        signature = (kind, context, did, mid, data, res.text)
        if self.only_changes.isChecked() and signature == self.last_row_signature:
            return
        self.last_row_signature = signature
        filter_text = self.filter_edit.text().strip().lower()
        if filter_text:
            combined = " ".join([context, did, mid, data, res.text, res.function, res.source_ecu, res.category, raw, change_summary]).lower()
            if filter_text not in combined:
                return
        self.frame_count += 1
        display_text = res.text
        if res.decoded:
            self.decoded_count += 1
            self.update_signals(res.signals)
            self.gateway.update_signals(res.signals)
            converted_signals = [convert_signal_for_units(sig, self.unit_mode) for sig in res.signals]
            if converted_signals:
                display_text = res.message_name + ": " + ", ".join(sig.text for sig in converted_signals)
        row = [pc_time, str(arduino_ms), str(delta_ms), "BEAN", parsed.get("msg_type", "R"), context, did, mid, data_display, display_text, res.function, res.source_ecu, res.category, change_summary, raw]
        self.add_live_row(row, res.category, bool(change_summary))
        out_row = {"pc_time_iso": pc_iso, "pc_time": pc_time, "arduino_ms": arduino_ms, "delta_ms": delta_ms, "bus": "BEAN", "type": parsed.get("msg_type", "R"), "context": context, "did": did, "mid": mid, "data": data, "decoded": display_text, "function": res.function, "source_ecu": res.source_ecu, "category": res.category, "changes": change_summary, "raw": raw,
                   "capture_marker": bean_meta.get("capture_marker", ""), "bean_header": bean_meta.get("bean_header", ""), "priority": bean_meta.get("priority", ""), "ml": bean_meta.get("ml", ""), "data_len": bean_meta.get("data_len", ""),
                   "dst_id": bean_meta.get("dst_id", ""), "mes_id": bean_meta.get("mes_id", ""), "crc": bean_meta.get("crc", ""), "crc_ok": bean_meta.get("crc_ok", ""), "eom": bean_meta.get("eom", ""),
                   "post_eom": bean_meta.get("post_eom", ""), "rsp_candidate": bean_meta.get("rsp_candidate", ""), "eof_candidate": bean_meta.get("eof_candidate", ""), "event_marker": ""}
        self.export_rows.append(out_row)
        if self.csv_writer and not parsed.get("replay", False):
            self.csv_writer.writerow([out_row.get(h, "") for h in CSV_HEADERS])
            if self.frame_count % 30 == 0:
                self.csv_file.flush()

    def add_event_marker_from_ui(self):
        preset = self.marker_combo.currentText() if hasattr(self, "marker_combo") else "Event"
        custom = self.marker_text.text().strip() if hasattr(self, "marker_text") else ""
        if custom and preset and preset != "Custom":
            label = f"{preset} - {custom}"
        elif custom:
            label = custom
        else:
            label = preset or "Event"
        if label == "Custom":
            label = "Custom event"
        self.add_event_marker(label)

    def add_event_marker(self, label: str):
        pc_time = datetime.now().strftime("%H:%M:%S.%f")[:-3]
        pc_iso = datetime.now().isoformat(timespec="milliseconds")
        arduino_ms = self.last_arduino_ms if self.last_arduino_ms is not None else 0
        row_values = [pc_time, str(arduino_ms), "0", "MARKER", "EVENT", "", "", "", str(label), f"EVENT MARKER: {label}", "event_marker", "", "marker", "", ""]
        self.add_live_row(row_values, "warning", True)
        out_row = {
            "pc_time_iso": pc_iso,
            "pc_time": pc_time,
            "arduino_ms": arduino_ms,
            "delta_ms": 0,
            "bus": "MARKER",
            "type": "EVENT",
            "context": "",
            "did": "",
            "mid": "",
            "data": str(label),
            "decoded": f"EVENT MARKER: {label}",
            "function": "event_marker",
            "source_ecu": "",
            "category": "marker",
            "changes": "",
            "raw": "",
            "event_marker": str(label),
        }
        self.export_rows.append(out_row)
        if self.csv_writer:
            self.csv_writer.writerow([out_row.get(h, "") for h in CSV_HEADERS])
            self.csv_file.flush()
        self.status.showMessage(f"Event marker added: {label}")

    def show_live_column_context_menu(self, pos):
        """Right-click menu for Live table column visibility."""
        header = self.live_table.horizontalHeader()
        menu = QMenu(self)
        menu.setTitle("Show / Hide Live columns")

        for col in range(self.live_table.columnCount()):
            header_item = self.live_table.horizontalHeaderItem(col)
            if not header_item:
                continue
            title = header_item.text()
            action = menu.addAction(title)
            action.setCheckable(True)
            action.setChecked(not self.live_table.isColumnHidden(col))
            action.toggled.connect(lambda checked, c=col: self.live_table.setColumnHidden(c, not checked))

            if title in ("Raw", "Category", "MID", "Data"):
                menu.addSeparator()

        menu.addSeparator()
        show_all = menu.addAction("Show all columns")
        compact = menu.addAction("Compact default view")
        essentials = menu.addAction("Essential live view")
        decode_view = menu.addAction("Decoded focus view")
        raw_view = menu.addAction("Raw/debug focus view")

        selected = menu.exec(header.mapToGlobal(pos))

        if selected == show_all:
            for col in range(self.live_table.columnCount()):
                self.live_table.setColumnHidden(col, False)

        elif selected == compact:
            for col in range(self.live_table.columnCount()):
                self.live_table.setColumnHidden(col, False)
            for name in ("Function", "Source ECU", "Category", "Changes"):
                col = self.live_column_indexes.get(name)
                if col is not None:
                    self.live_table.setColumnHidden(col, True)

        elif selected == essentials:
            visible = {"PC Time", "Δ ms", "Bus", "DID", "MID", "Data", "Decoded", "Raw"}
            for name, col in self.live_column_indexes.items():
                self.live_table.setColumnHidden(col, name not in visible)

        elif selected == decode_view:
            visible = {"PC Time", "Bus", "DID", "MID", "Data", "Decoded", "Function", "Source ECU", "Category"}
            for name, col in self.live_column_indexes.items():
                self.live_table.setColumnHidden(col, name not in visible)

        elif selected == raw_view:
            visible = {"PC Time", "Arduino ms", "Δ ms", "Bus", "Type", "Ctx", "DID", "MID", "Data", "Changes", "Raw"}
            for name, col in self.live_column_indexes.items():
                self.live_table.setColumnHidden(col, name not in visible)

    def clear_live_table(self):
        """Clear only the visible live frame list. Capture, CSV logging and signal cache remain active."""
        self.live_table.setRowCount(0)
        self.last_row_signature = None
        self.status.showMessage("Live list cleared")

    def add_live_row(self, values: list[str], category: str, has_change: bool = False):
        row = self.live_table.rowCount(); self.live_table.insertRow(row)
        bg = self.category_color(category)
        change_bg = QColor("#7a2020")
        data_change_bg = QColor("#a02a2a")
        for col, value in enumerate(values):
            item = QTableWidgetItem(str(value)); item.setFlags(item.flags() ^ Qt.ItemIsEditable)
            if bg: item.setBackground(bg)
            if has_change:
                # Highlight the whole row subtly, and the Data/Changes columns strongly.
                item.setBackground(change_bg if col not in (8, 13) else data_change_bg)
            self.live_table.setItem(row, col, item)
        if self.live_table.rowCount() > MAX_ROWS:
            self.live_table.removeRow(0)
        if self.autoscroll.isChecked():
            self.live_table.scrollToBottom()


    # Change monitor / byte watcher --------------------------------------------
    def clear_change_monitor_memory(self):
        self.change_last_payloads.clear()
        self.change_last_raw_frames.clear()
        self.status.showMessage("Change monitor memory cleared")

    @staticmethod
    def normalize_hex_filter(text: str) -> str:
        return text.strip().upper().replace("0X", "").replace(" ", "")

    def change_monitor_matches(self, context: str, did: str, mid: str, raw: str, decoded: str) -> bool:
        ctx_filter = self.normalize_hex_filter(self.change_ctx_filter.text())
        did_filter = self.normalize_hex_filter(self.change_did_filter.text())
        mid_filter = self.normalize_hex_filter(self.change_mid_filter.text())
        text_filter = self.change_text_filter.text().strip().lower()

        if ctx_filter and context.upper() != ctx_filter:
            return False
        if did_filter and did.upper() != did_filter:
            return False
        if mid_filter and mid.upper() != mid_filter:
            return False
        if text_filter:
            combined = f"{raw} {decoded}".lower()
            if text_filter not in combined:
                return False

        # Empty monitor filter means "monitor everything".
        return True

    def make_change_key(self, context: str, did: str, mid: str, frame: list[int]) -> tuple:
        if did or mid:
            return ("BEAN", context, did, mid)
        # Unknown frames still get a stable key based on first bytes.
        return ("BEAN_UNKNOWN", tuple(frame[:4]))

    def compute_change_summary_for_key(self, key: tuple, payload: list[int]) -> str:
        previous = self.change_last_payloads.get(key)
        self.change_last_payloads[key] = list(payload)

        if previous is None:
            return ""

        return self.build_change_summary(previous, payload)

    @staticmethod
    def build_change_summary(previous: list[int], current: list[int]) -> str:
        changes = []
        max_len = max(len(previous), len(current))

        for idx in range(max_len):
            old = previous[idx] if idx < len(previous) else None
            new = current[idx] if idx < len(current) else None

            if old == new:
                continue

            if old is None:
                changes.append(f"B{idx}: -- -> {new} dec / 0x{new:02X} / 0b{new:08b}")
                continue

            if new is None:
                changes.append(f"B{idx}: {old} dec / 0x{old:02X} / 0b{old:08b} -> --")
                continue

            bit_diff = old ^ new
            bit_names = [f"b{bit}" for bit in range(8) if bit_diff & (1 << bit)]
            bit_text = ",".join(bit_names) if bit_names else "none"

            changes.append(
                f"B{idx}: {old}->{new} dec | 0x{old:02X}->0x{new:02X} | "
                f"0b{old:08b}->0b{new:08b} | changed {bit_text}"
            )

        return " ; ".join(changes)

    @staticmethod
    def decorate_changed_data(data_text: str, change_summary: str) -> str:
        if not change_summary:
            return data_text

        changed_indexes = set()
        for match in re.finditer(r"B(\d+):", change_summary):
            try:
                changed_indexes.add(int(match.group(1)))
            except ValueError:
                pass

        if not changed_indexes:
            return data_text

        parts = data_text.split()
        decorated = []
        for idx, part in enumerate(parts):
            if idx in changed_indexes:
                decorated.append(f"[{part}]")
            else:
                decorated.append(part)

        return " ".join(decorated)


    @staticmethod
    def category_color(category: str):
        return {
            "engine": QColor("#26384f"), "vehicle": QColor("#243d32"), "fuel": QColor("#4b3d24"),
            "climate": QColor("#233b4a"), "body": QColor("#3a2f48"), "warning": QColor("#5a2828"),
            "dash": QColor("#3c3c3c"), "trip": QColor("#2d3440"), "unknown": None
        }.get(category)

    @staticmethod
    def _decoded_signal_to_float(sig: Optional[DecodedSignal]) -> Optional[float]:
        if sig is None:
            return None
        try:
            if isinstance(sig.value, bool):
                return None
            return float(sig.value)
        except Exception:
            return None

    def calculate_derived_fuel_signals(self) -> list[DecodedSignal]:
        """Calculate distance-based fuel consumption from L/h and km/h.

        L/100km = (L/h / km/h) * 100
        MPG US  = 235.214583 / L/100km

        This uses base decoded values before UI unit conversion. At standstill,
        L/100km is not valid, so a non-numeric status signal is shown instead.
        """
        speed_sig = (
            self.signal_cache.get("speed")
            or self.signal_cache.get("vehicle_speed")
            or self.signal_cache.get("speed_kmh")
        )
        fuel_sig = (
            self.signal_cache.get("fuel_rate_lh")
            or self.signal_cache.get("fuel_rate_lh_candidate")
            or self.signal_cache.get("fuel_rate_l_per_h")
            or self.signal_cache.get("fuel_rate_lph")
            or self.signal_cache.get("fuel_rate_time_lh")
            or self.signal_cache.get("instant_fuel_rate_lh")
        )

        # Fallback: accept any numeric decoded signal whose unit is L/h and whose name
        # clearly looks like fuel rate. This covers experimental DBC names such as
        # fuel_rate_lh_candidate without having to hardcode every future variant.
        if fuel_sig is None:
            for candidate in self.signal_cache.values():
                unit = str(getattr(candidate, "unit", "") or "").lower().replace(" ", "")
                name = str(getattr(candidate, "name", "") or "").lower()
                if unit in ("l/h", "lph") and ("fuel" in name and ("rate" in name or "consumption" in name)):
                    fuel_sig = candidate
                    break

        speed_kmh = self._decoded_signal_to_float(speed_sig)
        fuel_lh = self._decoded_signal_to_float(fuel_sig)
        if speed_kmh is None or fuel_lh is None:
            return []
        if speed_kmh <= 1.0:
            return [DecodedSignal(
                name="fuel_consumption_l_100km",
                value="-- stationary",
                text="fuel_consumption_l_100km: -- stationary / use L/h at 0 km/h",
                unit="", card="fuel_consumption_l_100km", plot=False,
                raw=None, raw_bytes=[], kind="derived", offset=0,
            )]
        l_100km = (fuel_lh / speed_kmh) * 100.0
        mpg_us = 235.214583 / l_100km if l_100km > 0 else 0.0
        return [
            DecodedSignal(
                name="fuel_consumption_l_100km", value=l_100km,
                text=f"fuel_consumption_l_100km: {l_100km:.2f} L/100km",
                unit="L/100km", card="fuel_consumption_l_100km", plot=True,
                raw=None, raw_bytes=[], kind="derived", offset=0,
            ),
            DecodedSignal(
                name="fuel_consumption_mpg_us", value=mpg_us,
                text=f"fuel_consumption_mpg_us: {mpg_us:.2f} mpg US",
                unit="mpg US", card="fuel_consumption_mpg_us", plot=True,
                raw=None, raw_bytes=[], kind="derived", offset=0,
            ),
        ]

    def update_signals(self, signals: list[DecodedSignal]):
        now = time.time() - self.start_time

        # Store base decoded values first. Derived fuel consumption must be
        # calculated from ECU/base values, not from UI-converted display values.
        for sig in signals:
            self.signal_cache[sig.name] = sig

        derived_signals = self.calculate_derived_fuel_signals()
        for sig in derived_signals:
            self.signal_cache[sig.name] = sig

        for sig in list(signals) + derived_signals:
            self.add_known_signal(sig.name)

            plot_sig = convert_signal_for_units(sig, self.unit_mode)
            if isinstance(plot_sig.value, (int, float, bool)):
                if sig.name in self.plot_buffers:
                    x, y = self.plot_buffers[sig.name]
                    x.append(now); y.append(float(plot_sig.value))
                    if len(x) > 2000:
                        del x[:-2000]; del y[:-2000]

            if sig.name in self.cards:
                self.cards[sig.name].set_signal(convert_signal_for_units(sig, self.unit_mode))

        self.update_plot_curves()

    def add_known_signal(self, name: str):
        if name in self.known_signals:
            return
        self.known_signals.add(name)
        for lst in (self.card_list, self.plot_signal_list):
            item = QListWidgetItem(name); item.setFlags(item.flags() | Qt.ItemIsUserCheckable); item.setCheckState(Qt.Unchecked)
            lst.addItem(item)
        default_cards = {"speed", "rpm", "coolant_temp", "outside_temp", "fuel_level_candidate", "fuel_rate_lh", "fuel_rate_lh_candidate", "fuel_consumption_l_100km", "fuel_consumption_mpg_us"}
        if name in default_cards:
            for i in range(self.card_list.count()):
                if self.card_list.item(i).text() == name:
                    self.card_list.item(i).setCheckState(Qt.Checked)

    def rebuild_cards(self):
        # Preserve selected display modes before rebuilding.
        for name, card in getattr(self, "cards", {}).items():
            self.card_display_modes[name] = card.mode_combo.currentText()

        for card in list(self.cards.values()):
            card.setParent(None)
        self.cards.clear()

        selected = [
            self.card_list.item(i).text()
            for i in range(self.card_list.count())
            if self.card_list.item(i).checkState() == Qt.Checked
        ]

        for idx, name in enumerate(selected):
            card = SignalCard(name)
            if name in self.card_display_modes:
                card.mode_combo.setCurrentText(self.card_display_modes[name])
            card.mode_combo.currentTextChanged.connect(lambda mode, n=name: self.card_display_modes.__setitem__(n, mode))

            self.cards[name] = card

            if name in self.signal_cache:
                card.set_signal(self.signal_cache[name])

            self.cards_layout.addWidget(card, idx // 6, idx % 6)

    def rebuild_plots(self):
        selected = [self.plot_signal_list.item(i).text() for i in range(self.plot_signal_list.count()) if self.plot_signal_list.item(i).checkState() == Qt.Checked]
        for name in selected:
            if name not in self.plot_buffers:
                self.plot_buffers[name] = ([], [])
        self.plot_curves.clear()
        if pg is None or not hasattr(self, "graph_area"):
            return
        self.graph_area.clear()
        if not selected:
            return
        plot = self.graph_area.addPlot(title="Selected BEAN/CAN signals")
        plot.addLegend()
        colors = ["y", "c", "m", "g", "r", "w", "b"]
        for idx, name in enumerate(selected):
            self.plot_curves[name] = plot.plot(name=name, pen=colors[idx % len(colors)])
        self.update_plot_curves()

    def update_plot_curves(self):
        for name, curve in self.plot_curves.items():
            x, y = self.plot_buffers.get(name, ([], []))
            curve.setData(x, y)

    def clear_graph_data(self):
        for name in list(self.plot_buffers.keys()):
            self.plot_buffers[name] = ([], [])
        self.update_plot_curves()

    # TX ----------------------------------------------------------------------
    def start_tx(self):
        self.tx_loop_remaining = self.tx_count.value()
        self.tx_loop_mode = self.tx_bus_combo.currentText()
        self.send_tx_once()
        if self.tx_count.value() == 0 or self.tx_count.value() > 1:
            self.tx_loop_timer.start(self.tx_delay.value())

    def tx_loop_tick(self):
        if self.tx_count.value() != 0:
            self.tx_loop_remaining -= 1
            if self.tx_loop_remaining <= 1:
                self.tx_loop_timer.stop(); return
        self.send_tx_once()

    def stop_tx(self):
        self.tx_loop_timer.stop(); self.tx_log.append("TX stopped.")

    def send_tx_once(self):
        mode = self.tx_bus_combo.currentText(); text = self.tx_payload_edit.text().strip()
        try:
            if mode.startswith("BEAN"):
                if not self.bean_reader: raise RuntimeError("BEAN serial is not connected")
                data = parse_hex_bytes(text)
                self.bean_reader.write_line("BTX," + bytes_to_hex(data))
                self.tx_log.append(f"BEAN TX: {bytes_to_hex(data)}")
            else:
                if "#" in text:
                    id_text, data_text = text.split("#", 1)
                    can_id = int(id_text.strip(), 16)
                    data = parse_hex_bytes(data_text)
                else:
                    parts = text.split(maxsplit=1)
                    can_id = int(parts[0], 16); data = parse_hex_bytes(parts[1] if len(parts) > 1 else "")
                backend = "python-can" if mode == "CAN python-can" else "Arduino CAN serial bridge"
                self.send_can_frame(can_id, data, self.tx_extended.isChecked(), backend, note="Manual TX tab")
                self.tx_log.append(f"CAN TX ({backend}): 0x{can_id:X}#{bytes_to_hex(data)}")
        except Exception as exc:
            self.tx_loop_timer.stop(); self.tx_log.append(f"ERROR: {exc}")

    # DBC editor --------------------------------------------------------------
    def load_dbc_ui(self):
        filt = self.dbc_filter.text().lower() if hasattr(self, "dbc_filter") else ""
        self.dbc_table.setRowCount(0)
        for msg in self.dbc.messages:
            combined = json.dumps(msg, ensure_ascii=False).lower()
            if filt and filt not in combined: continue
            row = self.dbc_table.rowCount(); self.dbc_table.insertRow(row)
            vals = [msg.get("name",""), msg.get("did",""), msg.get("mid",""), msg.get("category",""), msg.get("function",""), msg.get("source_ecu", msg.get("source", "")), msg.get("doc_status","")]
            for col, val in enumerate(vals): self.dbc_table.setItem(row, col, QTableWidgetItem(str(val)))
            self.dbc_table.item(row,0).setData(Qt.UserRole, self.dbc.messages.index(msg))
        if hasattr(self, "raw_dbc_editor"):
            self.raw_dbc_editor.setPlainText(json.dumps(self.dbc.raw | {"messages": self.dbc.messages}, indent=2, ensure_ascii=False))

    def selected_msg_index(self) -> Optional[int]:
        row = self.dbc_table.currentRow()
        if row < 0: return None
        item = self.dbc_table.item(row, 0)
        return item.data(Qt.UserRole) if item else None

    def load_selected_msg_into_form(self):
        idx = self.selected_msg_index()
        if idx is None: return
        msg = self.dbc.messages[idx]
        self.msg_name.setText(msg.get("name", "")); self.msg_did.setText(str(msg.get("did", ""))); self.msg_mid.setText(str(msg.get("mid", "")))
        cat = msg.get("category", "unknown"); self.msg_category.setCurrentText(cat if cat in [self.msg_category.itemText(i) for i in range(self.msg_category.count())] else "custom")
        self.msg_function.setText(msg.get("function", "")); self.msg_source.setText(msg.get("source_ecu", msg.get("source", ""))); self.msg_status.setText(msg.get("doc_status", ""))
        self.msg_example.setText(str(msg.get("example", msg.get("observed_frame", ""))))
        notes_value = msg.get("notes", "")
        if isinstance(notes_value, list):
            self.msg_notes.setPlainText("\n".join(str(x) for x in notes_value))
        else:
            self.msg_notes.setPlainText(str(notes_value))
        refs_value = msg.get("references", msg.get("reference", []))
        if isinstance(refs_value, list):
            self.msg_references.setPlainText("\n".join(str(x) for x in refs_value))
        else:
            self.msg_references.setPlainText(str(refs_value))
        self.signal_editor.setPlainText(json.dumps(msg.get("signals", []), indent=2, ensure_ascii=False))
        normalized_templates = self.dbc_normalized_templates_from_message(msg)
        self.template_editor.setPlainText(json.dumps(normalized_templates, indent=2, ensure_ascii=False))
        self.dbc_refresh_template_combo({"tx_templates": normalized_templates})

    @staticmethod
    def _payload_from_template_bytes(value: Any) -> str:
        """Accept legacy tx_templates fields like bytes:[\"62\",\"D2\",...] or payload lists."""
        if value is None:
            return ""
        if isinstance(value, str):
            return bytes_to_hex(parse_hex_bytes(value))
        if isinstance(value, list):
            out = []
            for item in value:
                if isinstance(item, int):
                    out.append(item & 0xFF)
                elif isinstance(item, str):
                    s = item.strip().replace("0x", "").replace("0X", "")
                    if not s:
                        continue
                    # Accept "62 D2 10" inside a list item too.
                    if " " in s or "," in s:
                        out.extend(parse_hex_bytes(s))
                    else:
                        out.append(int(s, 16) & 0xFF)
            return bytes_to_hex(out)
        return ""

    @classmethod
    def normalize_tx_template(cls, tmpl: Any, index: int = 0) -> Optional[dict[str, Any]]:
        """Normalize old/new tx_template formats into a common dictionary.

        Supports:
        - {"name": "...", "bridge_payload": "62 ..."}
        - {"name": "...", "payload": "FE ..."}
        - {"name": "...", "bytes": ["62", "D2", ...]}
        - ["62", "D2", ...]
        - "62 D2 10 00 00"
        """
        if isinstance(tmpl, str):
            payload = cls._payload_from_template_bytes(tmpl)
            return {"name": f"Template {index + 1}", "payload": payload, "bridge_payload": payload}
        if isinstance(tmpl, list):
            payload = cls._payload_from_template_bytes(tmpl)
            return {"name": f"Template {index + 1}", "payload": payload, "bridge_payload": payload}
        if not isinstance(tmpl, dict):
            return None

        out = dict(tmpl)
        bytes_payload = cls._payload_from_template_bytes(out.get("bytes"))
        if bytes_payload and not out.get("payload"):
            out["payload"] = bytes_payload

        # If this is a legacy IS200/bridge command with bytes starting 62, promote it.
        payload = cls._payload_from_template_bytes(out.get("payload"))
        bridge = cls._payload_from_template_bytes(out.get("bridge_payload"))
        low = cls._payload_from_template_bytes(out.get("low_level_payload"))

        if payload:
            out["payload"] = payload
        if bridge:
            out["bridge_payload"] = bridge
        if low:
            out["low_level_payload"] = low

        if not out.get("bridge_payload"):
            if payload.upper().startswith("62 "):
                out["bridge_payload"] = payload
            elif payload.upper().startswith("FE "):
                # Best effort conversion for the Arduino BeanMPX bridge:
                # low-level DST FE / MID / DATA -> bridge command 62 / MID / DATA.
                parts = parse_hex_bytes(payload)
                if len(parts) >= 2 and parts[0] == 0xFE:
                    bridge_parts = [0x62] + parts[1:]
                    out["bridge_payload"] = bytes_to_hex(bridge_parts)
                    out.setdefault("low_level_payload", payload)
                    note = str(out.get("notes") or out.get("note") or "")
                    if "auto-converted" not in note:
                        out["notes"] = (note + " Auto-converted for BeanMPX bridge from FE to 62.").strip()
        return out

    def dbc_normalized_templates_from_message(self, msg: Optional[dict[str, Any]] = None) -> list[dict[str, Any]]:
        raw = []
        if msg is not None:
            raw = msg.get("tx_templates", [])
        else:
            raw = self.dbc_current_templates_from_editor()
        if not isinstance(raw, list):
            return []
        out = []
        for i, tmpl in enumerate(raw):
            norm = self.normalize_tx_template(tmpl, i)
            if norm:
                out.append(norm)
        return out

    def dbc_current_templates_from_editor(self) -> list[Any]:
        try:
            templates = json.loads(self.template_editor.toPlainText() or "[]")
            if isinstance(templates, list):
                return templates
        except Exception:
            pass
        return []

    def dbc_refresh_template_combo(self, msg: Optional[dict[str, Any]] = None):
        if not hasattr(self, "dbc_test_template_combo"):
            return
        self.dbc_test_template_combo.clear()
        templates = self.dbc_normalized_templates_from_message(msg)

        for i, tmpl in enumerate(templates):
            name = str(tmpl.get("name") or tmpl.get("label") or f"Template {i + 1}")
            bridge_payload = str(tmpl.get("bridge_payload") or "").strip()
            payload = str(tmpl.get("payload") or "").strip()
            tag = "bridge_payload" if bridge_payload else "payload"
            if payload.upper().startswith("FE ") and not bridge_payload:
                tag = "low-level only"
            shown_payload = bridge_payload or payload
            self.dbc_test_template_combo.addItem(f"{name} [{tag}] {shown_payload}", tmpl)

        if self.dbc_test_template_combo.count() == 0:
            self.dbc_test_template_combo.addItem("No TX templates available", {})

        self.dbc_load_selected_template_payload()

    @staticmethod
    def auto_reset_payload_from_bridge_payload(payload: str) -> str:
        """Generate a conservative reset/clear payload from a bridge payload.

        Keeps command prefix and MID, clears all data bytes to 00.
        Examples:
          62 D2 10 00 00 -> 62 D2 00 00 00
          62 D4 20 00 00 -> 62 D4 00 00 00
          62 7A E3       -> 62 7A 00
        """
        data = parse_hex_bytes(payload)
        if len(data) < 2:
            return ""
        reset = data[:]
        for i in range(2, len(reset)):
            reset[i] = 0x00
        return bytes_to_hex(reset)

    def dbc_selected_template_reset_payload(self) -> str:
        data = self.dbc_test_template_combo.currentData()
        if isinstance(data, dict):
            for key in ("reset_payload", "bridge_reset_payload", "clear_payload", "off_payload"):
                value = str(data.get(key) or "").strip()
                if value:
                    return bytes_to_hex(parse_hex_bytes(value))
        return self.auto_reset_payload_from_bridge_payload(self.dbc_test_payload_edit.text().strip())

    def dbc_load_reset_payload_for_template(self):
        reset = self.dbc_selected_template_reset_payload()
        self.dbc_reset_payload_edit.setText(reset)
        return reset

    def dbc_update_full_command_preview(self):
        if not hasattr(self, "dbc_full_command_preview"):
            return
        payload_text = self.dbc_test_payload_edit.text().strip()
        tmpl = self.dbc_test_template_combo.currentData()
        lines = []
        lines.append("MANUAL TX / ARDUINO BRIDGE")
        if payload_text:
            lines.append(f"  Payload: {payload_text}")
            lines.append(f"  Full line sent to bridge: BTX,{payload_text}")
        else:
            lines.append("  Payload: --")

        reset_text = self.dbc_reset_payload_edit.text().strip()
        if reset_text:
            lines.append("")
            lines.append("RESET / CLEAR ON STOP")
            lines.append(f"  Payload: {reset_text}")
            lines.append(f"  Full line sent to bridge: BTX,{reset_text}")

        if isinstance(tmpl, dict):
            low = str(tmpl.get("low_level_payload") or "").strip()
            payload = str(tmpl.get("payload") or "").strip()
            bridge = str(tmpl.get("bridge_payload") or "").strip()
            if bridge:
                lines.append("")
                lines.append("DBC bridge_payload")
                lines.append(f"  {bridge}")
            if low:
                lines.append("")
                lines.append("LOW-LEVEL BEAN REFERENCE")
                lines.append(f"  Payload: {low}")
                try:
                    low_bytes = parse_hex_bytes(low)
                    if len(low_bytes) >= 3:
                        frame = bean_build_frame(low_bytes, priority=2, include_eom=True)
                        lines.append(f"  Logical frame PRI=2: {bytes_to_hex(frame)}")
                    else:
                        lines.append("  Logical frame: not enough bytes, needs DST-ID + MES-ID + DATA")
                except Exception as exc:
                    lines.append(f"  Logical frame: {exc}")
            elif payload.upper().startswith("FE "):
                lines.append("")
                lines.append("LOW-LEVEL BEAN REFERENCE")
                lines.append(f"  Payload: {payload}")
                try:
                    low_bytes = parse_hex_bytes(payload)
                    if len(low_bytes) >= 3:
                        frame = bean_build_frame(low_bytes, priority=2, include_eom=True)
                        lines.append(f"  Logical frame PRI=2: {bytes_to_hex(frame)}")
                except Exception as exc:
                    lines.append(f"  Logical frame: {exc}")

        self.dbc_full_command_preview.setPlainText("\n".join(lines))

    def dbc_selected_template_payload(self) -> str:
        data = self.dbc_test_template_combo.currentData()
        if not isinstance(data, dict):
            return ""
        # Prefer bridge_payload because Manual TX/BeanMPX bridge sends exactly this after BTX,.
        return str(data.get("bridge_payload") or data.get("payload") or "").strip()

    def dbc_load_selected_template_payload(self):
        payload = self.dbc_selected_template_payload()
        if payload:
            self.dbc_test_payload_edit.setText(payload)
            data = self.dbc_test_template_combo.currentData()
            if isinstance(data, dict) and str(data.get("payload", "")).upper().startswith("FE ") and not data.get("bridge_payload"):
                self.dbc_test_status.setText("Loaded low-level payload. Warning: this may not work with the Arduino BeanMPX bridge unless the firmware expects DST/MES/DATA.")
            else:
                self.dbc_test_status.setText("Loaded template payload for BEAN bridge test.")
        else:
            self.dbc_test_payload_edit.clear()
            self.dbc_test_status.setText("No template payload available for this message.")
        self.dbc_load_reset_payload_for_template()
        self.dbc_update_full_command_preview()

    def dbc_get_test_payload(self) -> list[int]:
        payload = parse_hex_bytes(self.dbc_test_payload_edit.text())
        if not payload:
            raise RuntimeError("Empty DBC test payload")
        return payload

    def dbc_send_reset_payload(self):
        try:
            if not self.bean_reader:
                raise RuntimeError("BEAN serial is not connected")
            reset_text = self.dbc_reset_payload_edit.text().strip() or self.dbc_selected_template_reset_payload()
            if not reset_text:
                raise RuntimeError("No reset payload available")
            reset = parse_hex_bytes(reset_text)
            self.bean_reader.write_line("BTX," + bytes_to_hex(reset))
            self.dbc_test_status.setText(f"BEAN DBC reset TX: {bytes_to_hex(reset)}")
            if hasattr(self, "tx_log"):
                self.tx_log.append(f"BEAN DBC reset TX: {bytes_to_hex(reset)}")
        except Exception as exc:
            QMessageBox.critical(self, "DBC Reset TX error", str(exc))

    def dbc_send_test_once(self):
        try:
            if not self.bean_reader:
                raise RuntimeError("BEAN serial is not connected")
            payload = self.dbc_get_test_payload()
            self.bean_reader.write_line("BTX," + bytes_to_hex(payload))
            self.dbc_test_status.setText(f"BEAN DBC test TX: {bytes_to_hex(payload)}")
            if hasattr(self, "tx_log"):
                self.tx_log.append(f"BEAN DBC test TX: {bytes_to_hex(payload)}")
        except Exception as exc:
            QMessageBox.critical(self, "DBC Test TX error", str(exc))

    def dbc_start_test(self):
        try:
            if not self.bean_reader:
                raise RuntimeError("BEAN serial is not connected")
            self.dbc_test_payload = self.dbc_get_test_payload()
            self.dbc_test_remaining = self.dbc_test_repetitions.value()
            self.dbc_send_test_once()
            if self.dbc_test_repetitions.value() == 0 or self.dbc_test_repetitions.value() > 1:
                self.dbc_test_timer.start(self.dbc_test_delay.value())
                self.dbc_test_status.setText(f"DBC test running: {bytes_to_hex(self.dbc_test_payload)}")
            else:
                self.dbc_test_status.setText(f"DBC test sent once: {bytes_to_hex(self.dbc_test_payload)}")
        except Exception as exc:
            self.dbc_stop_test()
            QMessageBox.critical(self, "DBC Test error", str(exc))

    def dbc_test_tick(self):
        try:
            if self.dbc_test_repetitions.value() != 0:
                self.dbc_test_remaining -= 1
                if self.dbc_test_remaining <= 1:
                    self.dbc_test_timer.stop()
                    self.dbc_test_status.setText("DBC test finished.")
                    return
            self.dbc_send_test_once()
        except Exception as exc:
            self.dbc_stop_test()
            QMessageBox.critical(self, "DBC Test error", str(exc))

    def dbc_stop_test(self):
        if hasattr(self, "dbc_test_timer"):
            self.dbc_test_timer.stop()
        if hasattr(self, "dbc_reset_on_stop_check") and self.dbc_reset_on_stop_check.isChecked():
            try:
                if self.bean_reader:
                    reset_text = self.dbc_reset_payload_edit.text().strip() or self.dbc_selected_template_reset_payload()
                    if reset_text:
                        reset = parse_hex_bytes(reset_text)
                        self.bean_reader.write_line("BTX," + bytes_to_hex(reset))
                        if hasattr(self, "tx_log"):
                            self.tx_log.append(f"BEAN DBC reset-on-stop TX: {bytes_to_hex(reset)}")
                        if hasattr(self, "dbc_test_status"):
                            self.dbc_test_status.setText(f"DBC test stopped. Reset sent: {bytes_to_hex(reset)}")
                        return
            except Exception as exc:
                QMessageBox.warning(self, "DBC reset-on-stop error", str(exc))
        if hasattr(self, "dbc_test_status"):
            self.dbc_test_status.setText("DBC test stopped.")

    def apply_msg_form(self):
        idx = self.selected_msg_index()
        if idx is None: return
        try:
            signals = json.loads(self.signal_editor.toPlainText() or "[]")
            templates = json.loads(self.template_editor.toPlainText() or "[]")
        except Exception as exc:
            QMessageBox.critical(self, "JSON error", str(exc)); return
        msg = self.dbc.messages[idx]
        msg.update({"name": self.msg_name.text(), "mid": self.msg_mid.text().upper().replace("0X", ""), "category": self.msg_category.currentText(), "function": self.msg_function.text(), "source_ecu": self.msg_source.text(), "doc_status": self.msg_status.text(), "signals": signals})
        if self.msg_example.text().strip():
            msg["example"] = self.msg_example.text().strip()
        elif "example" in msg:
            del msg["example"]
        notes_text = self.msg_notes.toPlainText().strip()
        if notes_text:
            msg["notes"] = [line.strip() for line in notes_text.splitlines() if line.strip()] if "\n" in notes_text else notes_text
        elif "notes" in msg:
            del msg["notes"]
        refs_text = self.msg_references.toPlainText().strip()
        if refs_text:
            try:
                parsed_refs = json.loads(refs_text)
                msg["references"] = parsed_refs
            except Exception:
                msg["references"] = [line.strip() for line in refs_text.splitlines() if line.strip()]
        elif "references" in msg:
            del msg["references"]
        if self.msg_did.text().strip(): msg["did"] = self.msg_did.text().upper().replace("0X", "")
        elif "did" in msg: del msg["did"]
        if templates:
            msg["tx_templates"] = [self.normalize_tx_template(t, i) or t for i, t in enumerate(templates)]
        elif "tx_templates" in msg:
            del msg["tx_templates"]
        self.dbc_refresh_template_combo(msg)
        self.load_dbc_ui()

    def apply_raw_dbc(self):
        try:
            raw = json.loads(self.raw_dbc_editor.toPlainText())
            self.dbc.raw = raw; self.dbc.messages = raw.get("messages", []); self.dbc.plausible_dids = {int(x,16) for x in raw.get("plausible_dids", [])}
            self.load_dbc_ui(); QMessageBox.information(self, "DBC", "DBC applied.")
        except Exception as exc:
            QMessageBox.critical(self, "DBC JSON error", str(exc))

    def save_dbc(self):
        self.apply_msg_form() if self.selected_msg_index() is not None else None
        self.dbc.save(); QMessageBox.information(self, "DBC", f"Saved: {self.dbc.path}")

    def save_dbc_as(self):
        path, _ = QFileDialog.getSaveFileName(self, "Save BEAN-DBC as", "bean_dbc_custom.json", "JSON files (*.json)")
        if path:
            self.dbc.save(Path(path)); QMessageBox.information(self, "DBC", f"Saved: {path}")

    def add_dbc_msg(self):
        self.dbc.messages.append({"name":"New message","did":"FE","mid":"00","category":"custom","signals":[],"tx_templates":[]}); self.load_dbc_ui()

    def duplicate_dbc_msg(self):
        idx = self.selected_msg_index()
        if idx is not None:
            self.dbc.messages.append(json.loads(json.dumps(self.dbc.messages[idx]))); self.dbc.messages[-1]["name"] += " copy"; self.load_dbc_ui()

    def delete_dbc_msg(self):
        idx = self.selected_msg_index()
        if idx is not None and QMessageBox.question(self, "Delete", "Delete selected DBC message?") == QMessageBox.Yes:
            del self.dbc.messages[idx]; self.load_dbc_ui()

    # CAN map -----------------------------------------------------------------
    def load_can_map_ui(self):
        self.can_map_editor.setPlainText(json.dumps(self.gateway.raw_map, indent=2, ensure_ascii=False))

    def apply_can_map(self):
        try:
            self.gateway.raw_map = json.loads(self.can_map_editor.toPlainText())
            QMessageBox.information(self, "CAN map", "CAN map applied.")
        except Exception as exc:
            QMessageBox.critical(self, "CAN map error", str(exc))

    def save_can_map(self):
        self.apply_can_map(); CAN_MAP_PATH.write_text(json.dumps(self.gateway.raw_map, indent=2, ensure_ascii=False), encoding="utf-8")

    # Replay/export/logging ---------------------------------------------------
    def export_csv(self):
        """Export the active CSV log or the current in-memory session to a chosen CSV file."""
        default_name = f"beanalyzer_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
        path, _ = QFileDialog.getSaveFileName(self, "Export CSV", default_name, "CSV files (*.csv)")
        if not path:
            return
        target = Path(path)
        try:
            if self.csv_file:
                self.csv_file.flush()
            if self.csv_path and self.csv_path.exists():
                shutil.copyfile(self.csv_path, target)
            else:
                headers = CSV_HEADERS
                with target.open("w", newline="", encoding="utf-8") as f:
                    writer = csv.DictWriter(f, fieldnames=headers)
                    writer.writeheader()
                    for row in self.export_rows:
                        writer.writerow({h: row.get(h, "") for h in headers})
            self.status.showMessage(f"CSV exported: {target.name}")
            QMessageBox.information(self, "CSV exported", f"CSV exported to:\n{target}")
        except Exception as exc:
            QMessageBox.critical(self, "CSV export error", str(exc))



    def fit_window_to_screen(self):
        """Resize the app to fit inside the current usable screen area."""
        screen = QApplication.primaryScreen()
        if not screen:
            return
        available = screen.availableGeometry()
        self.resize(
            min(1800, int(available.width() * 0.94)),
            min(1000, int(available.height() * 0.88))
        )
        self.move(
            available.x() + max(0, (available.width() - self.width()) // 2),
            available.y() + max(0, (available.height() - self.height()) // 2)
        )

    def set_compact_top_toolbar(self, enabled: bool):
        """Reduce button text/widths for low-height or narrow visible areas."""
        if enabled:
            self.bean_connect_btn.setText("Connect")
            self.bean_disconnect_btn.setText("Disconnect")
            self.refresh_btn.setText("Refresh")
            self.clear_live_btn.setText("Clear")
            self.new_csv_btn.setText("New")
            self.load_csv_btn.setText("Load")
            self.export_csv_btn.setText("Export")
            self.message_monitor_btn.setText("Monitor")
            self.filter_edit.setMaximumWidth(260)
            self.bean_port_combo.setMaximumWidth(300)
        else:
            self.bean_connect_btn.setText("Connect BEAN")
            self.bean_disconnect_btn.setText("Disconnect BEAN")
            self.refresh_btn.setText("Refresh USB")
            self.clear_live_btn.setText("Clear list")
            self.new_csv_btn.setText("New CSV")
            self.load_csv_btn.setText("Load CSV")
            self.export_csv_btn.setText("Export CSV")
            self.message_monitor_btn.setText("Message Monitor")
            self.filter_edit.setMaximumWidth(360)
            self.bean_port_combo.setMaximumWidth(360)

    def set_dashboard_height(self, value: int):
        total = sum(self.live_splitter.sizes()) or 1000
        table_height = max(120, total - value)
        self.live_splitter.setSizes([value, table_height])

    def set_selector_height(self, value: int):
        self.card_list.setMinimumHeight(min(70, value))
        self.card_list.setMaximumHeight(value)

    def set_cards_min_height(self, value: int):
        self.cards_scroll.setMinimumHeight(value)

    def reset_live_layout(self):
        self.act_show_dashboard_panel.setChecked(True)
        self.act_show_dashboard_selector.setChecked(True)
        self.act_show_dashboard_cards.setChecked(True)
        self.dashboard_top_panel.setVisible(True)
        self.card_config_group.setVisible(True)
        self.cards_scroll.setVisible(True)
        self.row_height_spin.setValue(24)
        self.dashboard_height_spin.setValue(210)
        self.selector_height_spin.setValue(110)
        self.cards_min_height_spin.setValue(95)
        self.live_splitter.setSizes([210, 790])
        self.status.showMessage("Live layout reset")

    def choose_csv(self):
        path, _ = QFileDialog.getSaveFileName(self, "Choose CSV log file", f"beanalyzer_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv", "CSV files (*.csv)")
        if path:
            self.csv_path = Path(path)
            self.csv_state_label.setText(f"CSV: {self.csv_path.name} (selected)")

    def load_csv_for_replay(self):
        path, _ = QFileDialog.getOpenFileName(self, "Load CSV log for replay", "", "CSV files (*.csv)")
        if path:
            self.replay_path.setText(path)
            for idx in range(self.tabs.count()):
                if "Replay" in self.tabs.tabText(idx):
                    self.tabs.setCurrentIndex(idx)
                    break
            self.status.showMessage(f"Replay CSV loaded: {Path(path).name}")

    def new_csv_log(self):
        if self.csv_file:
            self.close_csv()
        logs_dir = APP_DIR / "logs"
        logs_dir.mkdir(exist_ok=True)
        self.csv_path = logs_dir / f"beanalyzer_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
        self.open_csv()
        self.status.showMessage(f"New CSV log started: {self.csv_path}")

    def open_csv(self) -> bool:
        """Open a writable CSV log without blocking serial connection.

        Older builds tried to create logs next to the application files. That fails
        when the app is inside /Applications, a read-only bundle, or a protected
        folder. Logs now go to the per-user application data directory.
        """
        if self.csv_file:
            return True
        try:
            if not self.csv_path:
                USER_LOGS_DIR.mkdir(parents=True, exist_ok=True)
                self.csv_path = USER_LOGS_DIR / f"beanalyzer_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
            self.csv_file = self.csv_path.open("w", newline="", encoding="utf-8")
            self.csv_writer = csv.writer(self.csv_file)
            self.csv_writer.writerow(CSV_HEADERS)
            self.csv_file.flush()
            self.csv_state_label.setText(f"CSV: {self.csv_path.name}")
            return True
        except Exception as exc:
            self.csv_file = None
            self.csv_writer = None
            self.csv_state_label.setText("CSV: disabled")
            self.status.showMessage(f"CSV log disabled: {exc}")
            QMessageBox.warning(
                self,
                "CSV log disabled",
                "BEANalyzer could not create the CSV log file, but the BEAN serial connection will continue.\n\n"
                f"Error:\n{exc}\n\n"
                f"Expected log folder:\n{USER_LOGS_DIR}"
            )
            return False

    def close_csv(self):
        try:
            if self.csv_file:
                self.csv_file.flush()
                self.csv_file.close()
        except Exception as exc:
            self.status.showMessage(f"CSV close warning: {exc}")
        self.csv_file = None
        self.csv_writer = None
        if self.csv_path:
            self.csv_state_label.setText(f"CSV: {self.csv_path.name} (closed)")
        else:
            self.csv_state_label.setText("CSV: not started")

    def pause_sniffing(self):
        self.sniffing_paused = True
        self.sniff_state_label.setText("Sniffing: PAUSED")
        self.status.showMessage("Sniffing paused. Serial port remains connected, incoming frames are ignored.")

    def resume_sniffing(self):
        self.sniffing_paused = False
        self.sniff_state_label.setText("Sniffing: LIVE")
        self.status.showMessage("Sniffing resumed.")

    def set_dashboard_panel_visible(self, checked: bool):
        self.dashboard_top_panel.setVisible(checked)
        if checked:
            self.live_splitter.setSizes([210, 790])
        else:
            self.live_splitter.setSizes([0, 1000])

    def compact_live_view(self):
        self.act_show_dashboard_selector.setChecked(False)
        self.card_config_group.setVisible(False)
        self.act_show_dashboard_cards.setChecked(True)
        self.cards_scroll.setVisible(True)
        self.dashboard_height_spin.setValue(120)
        self.live_splitter.setSizes([120, 900])

    def export_json(self):
        path, _ = QFileDialog.getSaveFileName(self, "Export JSON", f"beanalyzer_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json", "JSON files (*.json)")
        if path:
            Path(path).write_text(json.dumps({"exported_at": datetime.now().isoformat(), "frames": self.export_rows}, indent=2, ensure_ascii=False), encoding="utf-8")

    def _parse_csv_for_replay_filter(self, csv_path: Path) -> dict[str, dict[str, Any]]:
        """Scan a replay CSV and build one fixed row per BEAN CTX/DID/MID or CAN ID."""
        items: dict[str, dict[str, Any]] = {}
        parser = ReplayWorker(csv_path, 1.0)

        with csv_path.open("r", encoding="utf-8-sig", newline="") as f:
            reader = csv.DictReader(f)
            for row in reader:
                parsed = parser._row_to_frame(row)
                if parsed is None:
                    continue

                key = ReplayFilter.key_from_parsed(parsed)
                kind = str(parsed.get("kind", "BEAN")).upper()

                if kind == "CAN":
                    bus = "CAN"
                    ctx = ""
                    did = ""
                    mid = f"0x{int(parsed.get('can_id', 0)):X}"
                    name = "CAN frame"
                    raw_sample = f"{int(parsed.get('can_id', 0)):X}#{bytes_to_hex(parsed.get('data', []))}"
                    decoded = row.get("decoded") or row.get("text") or "CAN frame"
                else:
                    frame = list(parsed.get("frame") or [])
                    kbus, ctx, did, mid = ReplayFilter.bean_key_from_frame(frame)
                    bus = "BEAN"
                    raw_sample = bytes_to_hex(frame)
                    try:
                        dec = self.dbc.decode(frame)
                        name = dec.message_name if dec.decoded else "Unknown / undecoded"
                        decoded = dec.text if dec.text else (row.get("decoded") or "")
                    except Exception:
                        name = "Unknown / undecoded"
                        decoded = row.get("decoded") or ""

                if key not in items:
                    items[key] = {
                        "key": key,
                        "bus": bus,
                        "ctx": ctx,
                        "did": did,
                        "mid": mid,
                        "name": name,
                        "count": 0,
                        "decoded": decoded,
                        "raw": raw_sample,
                    }
                items[key]["count"] += 1
                if decoded:
                    items[key]["decoded"] = decoded
                if raw_sample:
                    items[key]["raw"] = raw_sample

        return items

    def scan_replay_filter_ids(self):
        path = self.replay_path.text().strip()
        if not path:
            QMessageBox.warning(self, "No CSV selected", "Select a CSV log first.")
            return

        csv_path = Path(path)
        if not csv_path.exists():
            QMessageBox.critical(self, "CSV not found", f"The selected CSV file does not exist:\n{csv_path}")
            return

        # Preserve previous checked state if rescanning.
        previous_checks = {}
        for row in range(self.replay_filter_table.rowCount()):
            key_item = self.replay_filter_table.item(row, 1)
            use_item = self.replay_filter_table.item(row, 0)
            if key_item and use_item:
                previous_checks[key_item.data(Qt.UserRole)] = use_item.checkState() == Qt.Checked

        try:
            items = self._parse_csv_for_replay_filter(csv_path)
        except Exception as exc:
            QMessageBox.critical(self, "Replay filter scan error", str(exc))
            return

        self.replay_filter_table.setRowCount(0)

        # Sort BEAN before CAN, then by CTX/DID/MID or CAN ID.
        sorted_items = sorted(
            items.values(),
            key=lambda x: (x.get("bus", ""), x.get("ctx", ""), x.get("did", ""), x.get("mid", ""), x.get("name", ""))
        )

        for item in sorted_items:
            row = self.replay_filter_table.rowCount()
            self.replay_filter_table.insertRow(row)

            use = QTableWidgetItem("")
            use.setFlags((use.flags() | Qt.ItemIsUserCheckable) ^ Qt.ItemIsEditable)
            checked = previous_checks.get(item["key"], True)
            use.setCheckState(Qt.Checked if checked else Qt.Unchecked)
            self.replay_filter_table.setItem(row, 0, use)

            vals = [item["bus"], item["ctx"], item["did"], item["mid"], item["name"], str(item["count"]), item["decoded"], item["raw"]]
            for col, val in enumerate(vals, start=1):
                cell = QTableWidgetItem(str(val))
                cell.setFlags(cell.flags() ^ Qt.ItemIsEditable)
                if col == 1:
                    cell.setData(Qt.UserRole, item["key"])
                self.replay_filter_table.setItem(row, col, cell)

        self.replay_filter_summary.setText(f"Replay filter: {len(sorted_items)} IDs/commands found. Checked rows will be replayed when filter is enabled.")
        self.apply_replay_filter_search()
        if hasattr(self, "replay_log"):
            self.replay_log.append(f"Replay filter scan: {len(sorted_items)} IDs/commands found.")

    def set_replay_filter_checks(self, checked: bool):
        state = Qt.Checked if checked else Qt.Unchecked
        for row in range(self.replay_filter_table.rowCount()):
            item = self.replay_filter_table.item(row, 0)
            if item:
                item.setCheckState(state)

    def invert_replay_filter_checks(self):
        for row in range(self.replay_filter_table.rowCount()):
            item = self.replay_filter_table.item(row, 0)
            if item:
                item.setCheckState(Qt.Unchecked if item.checkState() == Qt.Checked else Qt.Checked)

    def apply_replay_filter_search(self):
        text = self.replay_filter_search.text().strip().lower() if hasattr(self, "replay_filter_search") else ""
        for row in range(self.replay_filter_table.rowCount()):
            combined = []
            for col in range(self.replay_filter_table.columnCount()):
                item = self.replay_filter_table.item(row, col)
                if item:
                    combined.append(item.text())
            self.replay_filter_table.setRowHidden(row, bool(text and text not in " ".join(combined).lower()))

    def get_enabled_replay_filter_keys(self) -> set[str]:
        if not hasattr(self, "replay_filter_table"):
            return set()

        keys = set()
        for row in range(self.replay_filter_table.rowCount()):
            use_item = self.replay_filter_table.item(row, 0)
            key_item = self.replay_filter_table.item(row, 1)
            if not use_item or not key_item:
                continue
            if use_item.checkState() == Qt.Checked:
                key = key_item.data(Qt.UserRole)
                if key:
                    keys.add(str(key))
        return keys


    def choose_replay(self):
        path, _ = QFileDialog.getOpenFileName(self, "Open CSV log", "", "CSV files (*.csv)")
        if path:
            self.replay_path.setText(path)
            self.replay_log.append(f"Loaded CSV: {path}")
            self.scan_replay_filter_ids()

    def stream_replay_bean_frame(self, parsed: dict[str, Any]):
        """Transmit one BEAN frame from replay through the connected Arduino BEAN bridge."""
        if not self.bean_reader:
            if self.replay_worker:
                self.replay_worker.stop()
            self.replay_log.append("BEAN stream stopped: BEAN serial disconnected.")
            QMessageBox.warning(self, "BEAN stream stopped", "BEAN serial disconnected during replay stream.")
            return

        payload = parsed.get("bean_tx_payload") or []
        payload = [int(x) & 0xFF for x in payload]

        if not payload:
            self.replay_log.append("BEAN stream skipped empty payload.")
            return

        try:
            line = "BTX," + bytes_to_hex(payload)
            self.bean_reader.write_line(line)
            self.replay_log.append(f"BEAN STREAM TX: {bytes_to_hex(payload)}")
        except Exception as exc:
            if self.replay_worker:
                self.replay_worker.stop()
            self.replay_log.append(f"BEAN stream error: {exc}")
            QMessageBox.critical(self, "BEAN stream error", str(exc))

    def update_replay_output_status(self):
        if not hasattr(self, "replay_bean_stream_status"):
            return
        stream = "ON — BEAN replay frames will be transmitted" if self.replay_bean_stream_enable.isChecked() else "OFF — replay is visual/log-only"
        loop = "Loop: ON — repeats until Stop replay" if self.replay_loop_mode_enable.isChecked() else "Loop: OFF — one pass only"
        self.replay_bean_stream_status.setText(f"{stream} | {loop}")

    def start_replay(self):
        path = self.replay_path.text().strip()
        if not path:
            QMessageBox.warning(self, "No CSV selected", "Select a CSV log file first.")
            return
        csv_path = Path(path)
        if not csv_path.exists():
            QMessageBox.critical(self, "CSV not found", f"The selected CSV file does not exist:\n{csv_path}")
            return

        bean_stream_enabled = self.replay_bean_stream_enable.isChecked() if hasattr(self, "replay_bean_stream_enable") else False
        loop_mode_enabled = self.replay_loop_mode_enable.isChecked() if hasattr(self, "replay_loop_mode_enable") else False
        replay_filter_enabled = self.replay_filter_enable.isChecked() if hasattr(self, "replay_filter_enable") else False
        replay_filter_keys = self.get_enabled_replay_filter_keys() if replay_filter_enabled else set()

        if replay_filter_enabled and not replay_filter_keys:
            QMessageBox.warning(
                self,
                "Replay filter is empty",
                "Replay filter is enabled, but no IDs/commands are checked.\n\n"
                "Scan the CSV and check at least one row, or disable replay filter."
            )
            return

        if bean_stream_enabled:
            if not self.bean_reader:
                QMessageBox.warning(
                    self,
                    "BEAN stream needs connection",
                    "Enable BEAN stream is ON, but BEAN serial is not connected.\n\n"
                    "Connect the Arduino BEAN bridge first, or disable BEAN stream for visual-only replay."
                )
                return
            answer = QMessageBox.question(
                self,
                "Confirm BEAN replay stream",
                "This will transmit BEAN frames from the CSV replay to the connected BEAN network.\n\n"
                "Use this only on a bench harness or controlled isolated network.\n\n"
                "Continue?",
                QMessageBox.Yes | QMessageBox.No,
                QMessageBox.No,
            )
            if answer != QMessageBox.Yes:
                return

        self.stop_replay()
        self.replay_log.clear()
        self.replay_log.append(f"Starting replay: {csv_path}")
        self.replay_log.append(f"BEAN stream: {'ENABLED' if bean_stream_enabled else 'disabled'}")
        self.replay_log.append(f"Loop mode: {'ENABLED — replay repeats until Stop replay' if loop_mode_enabled else 'disabled — one pass only'}")
        self.replay_log.append(f"Replay filter: {'ENABLED (' + str(len(replay_filter_keys)) + ' selected)' if replay_filter_enabled else 'disabled'}")
        self.replay_worker = ReplayWorker(csv_path, self.replay_speed.value(), bean_stream_enabled=bean_stream_enabled, replay_filter=ReplayFilter(replay_filter_keys), loop_mode=loop_mode_enabled)
        self.replay_worker.frame_received.connect(self.handle_frame)
        self.replay_worker.bean_stream_frame.connect(self.stream_replay_bean_frame)
        self.replay_worker.status_received.connect(lambda s: self.replay_log.append(s))
        self.replay_worker.finished_replay.connect(lambda: self.replay_log.append("Replay worker stopped / finished."))
        self.replay_worker.start()

    def stop_replay(self):
        if self.replay_worker:
            self.replay_worker.stop(); self.replay_worker.wait(1000); self.replay_worker = None
            if hasattr(self, "replay_log"):
                self.replay_log.append("Replay stopped.")

    def update_status(self):
        elapsed = max(1, time.time() - self.start_time)
        pause_text = "PAUSED" if self.sniffing_paused else "LIVE"
        csv_text = self.csv_path.name if self.csv_path else "not started"
        self.status.showMessage(f"Frames: {self.frame_count} | Decoded: {self.decoded_count} | Avg frames/s: {self.frame_count/elapsed:.1f} | Gateway: {'ON' if self.gateway.enabled else 'OFF'} | Sniffing: {pause_text} | CSV: {csv_text}")

    def closeEvent(self, event):
        if self.message_monitor_window is not None:
            self.message_monitor_window.close()
        if self.can_gateway_monitor_window is not None:
            self.can_gateway_monitor_window.close()
        if self.can_rx_worker:
            self.can_rx_worker.stop(); self.can_rx_worker.wait(1000); self.can_rx_worker = None
        self.disconnect_bean(); self.disconnect_can_serial(); self.python_can.disconnect(); self.stop_replay(); event.accept()


def create_splash_screen(app: QApplication) -> Optional[QSplashScreen]:
    """Create and show the startup splash screen if the asset exists."""
    if not SPLASH_PATH.exists():
        return None

    pixmap = QPixmap(str(SPLASH_PATH))
    if pixmap.isNull():
        return None

    screen = app.primaryScreen()
    if screen:
        available = screen.availableGeometry()
        max_width = int(available.width() * 0.72)
        max_height = int(available.height() * 0.72)
        pixmap = pixmap.scaled(
            max_width,
            max_height,
            Qt.KeepAspectRatio,
            Qt.SmoothTransformation,
        )

    splash = QSplashScreen(pixmap)
    splash.setWindowFlag(Qt.WindowStaysOnTopHint, True)
    splash.setWindowFlag(Qt.FramelessWindowHint, True)
    splash.show()
    app.processEvents()
    return splash


def main():
    # On macOS, running a PySide app directly through Python can otherwise show
    # the native application menu as "Python" and the automatic About item as
    # "About Python". Set the app identity before Qt builds the menu bar.
    if sys.platform == "darwin":
        sys.argv[0] = "BEANalyzer UltraPro"

    app = QApplication(sys.argv)
    app.setApplicationName("BEANalyzer UltraPro")
    try:
        app.setApplicationDisplayName("BEANalyzer UltraPro")
    except Exception:
        pass
    app.setOrganizationName("AE&D")
    app.setOrganizationDomain("aend-tech.com")

    if APP_ICON_PATH.exists():
        app.setWindowIcon(QIcon(str(APP_ICON_PATH)))

    splash = create_splash_screen(app)

    win = BeanalyzerUltraPro()
    if APP_ICON_PATH.exists():
        win.setWindowIcon(QIcon(str(APP_ICON_PATH)))

    def show_main_window():
        win.show()
        win.raise_()
        win.activateWindow()
        if splash is not None:
            splash.finish(win)

    # Keep the splash visible briefly so it feels intentional, even on fast machines.
    if splash is not None:
        QTimer.singleShot(SPLASH_MIN_TIME_MS, show_main_window)
    else:
        show_main_window()

    sys.exit(app.exec())


if __name__ == "__main__":
    main()
