from __future__ import annotations

import argparse
import dataclasses
import errno
import hashlib
import json
import os
import shlex
import socketserver
import subprocess
import sys
import tempfile
import threading
import time
from contextlib import contextmanager
from pathlib import Path
from typing import Any, Dict, Iterator, Optional

try:
    import fcntl
except Exception:  # pragma: no cover - non-POSIX platforms
    fcntl = None  # type: ignore[assignment]

from ..cli import _resolve_vin_from_specs
from ..core.bus import CANBusEmulation
from ..core.can import (
    NullInterface,
    SocketCANInterface,
    UnsupportedConfiguration,
    missing_python_can_modules,
    selected_python_can_backend,
)
from ..core.external_can import ExternalCANBridge
from ..setup import Setup, SetupError
from ..ui.status import build_bus_snapshot
from ..ui.connection_logs import ConnectionLogStore, strip_ansi


DEFAULT_PORT = int(os.getenv("ECUMULATOR_SIDECAR_PORT", "29190"))
SIDECAR_API_VERSION = 5
SIDECAR_LOCK_PATH = Path(
    os.environ.get(
        "ECUMULATOR_SIDECAR_LOCK_PATH",
        Path(tempfile.gettempdir()) / "ecumulator-sidecar.lock",
    )
)


class SidecarError(Exception):
    pass


def _command_runs_sidecar(command: str) -> bool:
    try:
        parts = shlex.split(command)
    except ValueError:
        parts = command.split()
    if not parts:
        return False

    first = Path(parts[0]).name
    if first in {"pymulator-sidecar", "pymulator.sidecar"}:
        return True

    shell_launcher = first in {"sh", "bash", "zsh", "fish", "dash", "ksh", "csh", "tcsh"}
    if shell_launcher:
        return False

    for idx, value in enumerate(parts[:-1]):
        if value == "-m" and parts[idx + 1] in {"pymulator.sidecar", "pymulator.sidecar.daemon"}:
            return True

    for token in parts[1:]:
        if token.startswith("-"):
            continue
        normalized = token.replace("\\", "/")
        if normalized.endswith("/pymulator/sidecar/daemon.py") or normalized.endswith("daemon.py"):
            return True

    return False


def _find_running_sidecar_process(current_pid: int | None = None) -> Optional[tuple[int, str]]:
    current_pid = os.getpid() if current_pid is None else current_pid
    try:
        result = subprocess.run(
            ["ps", "-ax", "-o", "pid=", "-o", "command="],
            capture_output=True,
            text=True,
            check=False,
        )
    except Exception:
        return None
    if result.returncode != 0:
        return None

    for line in result.stdout.splitlines():
        stripped = line.strip()
        if not stripped:
            continue
        fields = stripped.split(maxsplit=1)
        if not fields:
            continue
        try:
            pid = int(fields[0])
        except ValueError:
            continue
        if pid == current_pid:
            continue
        command = fields[1] if len(fields) > 1 else ""
        if _command_runs_sidecar(command):
            return pid, command
    return None


def _open_sidecar_lock_fd(path: Path) -> int:
    path.parent.mkdir(parents=True, exist_ok=True)
    return os.open(path, os.O_RDWR | os.O_CREAT, 0o666)


def _acquire_sidecar_lock_fd(path: Path) -> Optional[int]:
    if fcntl is None:
        return None
    fd = _open_sidecar_lock_fd(path)
    try:
        fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
    except BlockingIOError:
        os.close(fd)
        return None
    except Exception:
        os.close(fd)
        raise
    return fd


def _write_sidecar_lock_metadata(fd: int, path: Path) -> None:
    payload = json.dumps(
        {
            "pid": os.getpid(),
            "started_at": int(time.time()),
            "command": " ".join(sys.argv),
        }
    )
    os.lseek(fd, 0, os.SEEK_SET)
    os.ftruncate(fd, 0)
    os.write(fd, payload.encode("utf-8"))
    os.fsync(fd)
    try:
        os.chmod(path, 0o666)
    except Exception:
        pass


def _release_sidecar_lock_fd(fd: int) -> None:
    try:
        if fcntl is not None:
            fcntl.flock(fd, fcntl.LOCK_UN)
    finally:
        os.close(fd)


def _format_sidecar_running_error(pid: int, command: str) -> str:
    return (
        f"Error: another sidecar instance is already running (pid {pid}).\n"
        f"Command: {command}"
    )


@contextmanager
def _sidecar_singleton_guard() -> Iterator[None]:
    running = _find_running_sidecar_process()
    if running is not None:
        pid, command = running
        raise SystemExit(_format_sidecar_running_error(pid, command))

    if fcntl is None:
        yield
        return

    fd = _acquire_sidecar_lock_fd(SIDECAR_LOCK_PATH)
    if fd is None:
        running = _find_running_sidecar_process()
        if running is not None:
            pid, command = running
            raise SystemExit(_format_sidecar_running_error(pid, command))
        raise SystemExit("Error: unable to acquire sidecar singleton lock.")

    _write_sidecar_lock_metadata(fd, SIDECAR_LOCK_PATH)
    try:
        yield
    finally:
        _release_sidecar_lock_fd(fd)


def _parse_optional_tester_address(value: Any) -> Optional[int]:
    if value is None:
        return None
    if isinstance(value, str):
        trimmed = value.strip()
        if not trimmed:
            return None
        try:
            address = int(trimmed, 0)
        except ValueError as exc:
            raise SidecarError("doipTesterAddress must be a number (e.g. 0x0E80)") from exc
    else:
        try:
            address = int(value)
        except (TypeError, ValueError) as exc:
            raise SidecarError("doipTesterAddress must be a number (e.g. 0x0E80)") from exc
    if not (0 <= address <= 0xFFFF):
        raise SidecarError("doipTesterAddress must be between 0x0000 and 0xFFFF")
    return address


def _parse_service_string(value: Any, default: str) -> str:
    if value is None:
        return default
    text = str(value).strip()
    return text or default


def _parse_service_bool(value: Any, default: bool = False) -> bool:
    if value is None:
        return default
    if isinstance(value, bool):
        return value
    if isinstance(value, (int, float)):
        return value != 0
    if isinstance(value, str):
        lowered = value.strip().lower()
        if lowered in {"1", "true", "yes", "on"}:
            return True
        if lowered in {"0", "false", "no", "off", ""}:
            return False
    return bool(value)


def _parse_transport_profile(bus_options: dict[str, Any]) -> str:
    value = str(bus_options.get("transportProfile", "isotp")).strip().lower()
    if value in {"isotp", "isotp_fd"}:
        return value
    raise SidecarError("bus.transportProfile must be one of: isotp, isotp_fd")


def _validate_services_for_transport_profile(services: dict[str, Any], transport_profile: str) -> None:
    if transport_profile != "isotp_fd":
        return
    if bool(services.get("hsfz")) or bool(services.get("doip")) or bool(services.get("elm327")):
        raise SidecarError("HSFZ, DoIP, and ELM327 cannot be enabled when bus.transportProfile is isotp_fd")


def _bus_signature(bus_options: dict[str, Any]) -> tuple[Any, ...]:
    try:
        bitrate = int(bus_options.get("bitrate", 500000))
    except (TypeError, ValueError) as exc:
        raise SidecarError("bus.bitrate must be an integer") from exc
    try:
        timing_factor = float(bus_options.get("timingFactor", 0.0))
    except (TypeError, ValueError) as exc:
        raise SidecarError("bus.timingFactor must be a number") from exc
    return (
        str(bus_options.get("mode", "virtual")),
        bitrate,
        timing_factor,
        str(bus_options.get("interface", "")),
        _parse_transport_profile(bus_options),
    )


def _ecuconnect_reported_identity(services: dict[str, Any]) -> tuple[str, str]:
    version = _parse_service_string(services.get("ecuconnectVersion"), "0.9.999")
    hardware_revision = _parse_service_string(services.get("ecuconnectHardwareRevision"), "PVIRT")
    return version, hardware_revision


def _enet_service_broker_settings(services: dict[str, Any]) -> tuple[str, str, str]:
    vin = _parse_service_string(services.get("enetServiceBrokerVin"), "")
    url = _parse_service_string(services.get("enetServiceBrokerUrl"), "")
    mode = _parse_service_string(services.get("enetServiceBrokerMode"), "isotp").lower()
    return vin, url, mode


def _external_can_settings(services: dict[str, Any]) -> tuple[str, int, Optional[int], str, bool]:
    mode = _parse_service_string(services.get("externalCanMode"), "classic").lower()
    if mode not in {"classic", "fd"}:
        raise SidecarError("externalCanMode must be one of: classic, fd")
    access_mode = _parse_service_string(services.get("externalCanAccessMode"), "normal").lower()
    if access_mode not in {"normal", "listen_only"}:
        raise SidecarError("externalCanAccessMode must be one of: normal, listen_only")
    termination = _parse_service_bool(services.get("externalCanTermination"), False)
    try:
        bitrate = int(services.get("externalCanBitrate", 500000))
    except (TypeError, ValueError) as exc:
        raise SidecarError("externalCanBitrate must be an integer") from exc
    if bitrate <= 0:
        raise SidecarError("externalCanBitrate must be greater than 0")
    if mode != "fd":
        return mode, bitrate, None, access_mode, termination
    try:
        data_bitrate = int(services.get("externalCanDataBitrate", bitrate))
    except (TypeError, ValueError) as exc:
        raise SidecarError("externalCanDataBitrate must be an integer") from exc
    if data_bitrate <= 0:
        raise SidecarError("externalCanDataBitrate must be greater than 0")
    return mode, bitrate, data_bitrate, access_mode, termination


def _external_can_context(
    interface: str,
    mode: str,
    bitrate: int,
    data_bitrate: Optional[int],
    access_mode: str,
    termination: bool,
) -> str:
    pieces = [
        f"interface={interface}",
        f"mode={mode}",
        f"access_mode={access_mode}",
        f"bitrate={bitrate}",
        f"termination={'on' if termination else 'off'}",
    ]
    if mode == "fd":
        pieces.append(f"data_bitrate={data_bitrate if data_bitrate is not None else bitrate}")
    return ", ".join(pieces)


def _parse_isotp_fc_settings(services: dict[str, Any]) -> dict[str, Any]:
    def _int_value(key: str, default: int) -> int:
        raw = services.get(key, default)
        try:
            return int(raw)
        except (TypeError, ValueError) as exc:
            raise SidecarError(f"{key} must be an integer") from exc

    def _float_value(key: str, default: float) -> float:
        raw = services.get(key, default)
        try:
            return float(raw)
        except (TypeError, ValueError) as exc:
            raise SidecarError(f"{key} must be a number") from exc

    def _optional_int_value(key: str) -> Optional[int]:
        raw = services.get(key)
        if raw is None:
            return None
        if isinstance(raw, str) and not raw.strip():
            return None
        try:
            return int(raw)
        except (TypeError, ValueError) as exc:
            raise SidecarError(f"{key} must be an integer") from exc

    return {
        "wait_count": max(_int_value("isotpFcWaitCount", 0), 0),
        "wait_ms": max(_float_value("isotpFcWaitMs", 50.0), 0.0),
        "wait_probability": max(_float_value("isotpFcWaitProb", 0.0), 0.0),
        "overflow_probability": max(_float_value("isotpFcOverflowProb", 0.0), 0.0),
        "seed": _optional_int_value("isotpFcRngSeed"),
        "overflow": bool(services.get("isotpFcOverflow", False)),
    }


def _isotp_fc_signature(services: dict[str, Any]) -> tuple[Any, ...]:
    config = _parse_isotp_fc_settings(services)
    return (
        config["wait_count"],
        config["wait_ms"],
        config["wait_probability"],
        config["overflow_probability"],
        config["seed"],
        config["overflow"],
    )


def _isotp_fc_enabled(config: dict[str, Any]) -> bool:
    return (
        config["wait_count"] > 0
        or bool(config["overflow"])
        or config["wait_probability"] > 0.0
        or config["overflow_probability"] > 0.0
    )


def _parse_semantic_log_line(
    text: str,
    ecu_name: str,
    fallback_direction: str,
) -> tuple[str, list[str], str]:
    clean = strip_ansi(text).strip()
    if not clean:
        direction = "inbound" if fallback_direction == "request" else "outbound"
        participants = ["Tester", ecu_name] if direction == "inbound" else [ecu_name, "Tester"]
        return direction, participants, ""

    if "←" in clean:
        left, right = clean.split("←", 1)
        target = left.strip() or ecu_name
        event = right.strip()
        return "inbound", ["Tester", target], event

    if "→" in clean:
        left, right = clean.split("→", 1)
        source = left.strip() or ecu_name
        event = right.strip()
        return "outbound", [source, "Tester"], event

    direction = "inbound" if fallback_direction == "request" else "outbound"
    participants = ["Tester", ecu_name] if direction == "inbound" else [ecu_name, "Tester"]
    return direction, participants, clean


def _build_light_snapshot(bus: CANBusEmulation, services: list[tuple[str, Any]]) -> dict[str, Any]:
    recent_frames = []
    if hasattr(bus, "recent_frames"):
        recent_frames = bus.recent_frames()
    snapshots = []
    for name, service in services:
        data: dict[str, Any] = {}
        if hasattr(service, "ui_snapshot"):
            try:
                data = service.ui_snapshot() or {}
            except Exception:
                data = {}
        error_raw = data.get("error")
        error = str(error_raw).strip() if error_raw is not None else None
        if not error:
            error = None
        broker_host_raw = data.get("broker_host")
        broker_host = str(broker_host_raw).strip() if broker_host_raw is not None else None
        if not broker_host:
            broker_host = None
        broker_name_raw = data.get("broker_name")
        broker_name = str(broker_name_raw).strip() if broker_name_raw is not None else None
        if not broker_name:
            broker_name = None
        listen_host_raw = data.get("listen_host")
        listen_host = str(listen_host_raw).strip() if listen_host_raw is not None else None
        if not listen_host:
            listen_host = None
        listen_display_host_raw = data.get("listen_display_host")
        listen_display_host = (
            str(listen_display_host_raw).strip() if listen_display_host_raw is not None else None
        )
        if not listen_display_host:
            listen_display_host = None
        listen_port_raw = data.get("listen_port")
        try:
            listen_port = int(listen_port_raw) if listen_port_raw is not None else None
        except (TypeError, ValueError):
            listen_port = None
        snapshots.append(
            {
                "name": name,
                "connected": bool(data.get("connected", False)),
                "clients": int(data.get("clients", 0)),
                "last_rx": data.get("last_rx"),
                "last_tx": data.get("last_tx"),
                "rx_bytes": int(data.get("rx_bytes", 0)),
                "tx_bytes": int(data.get("tx_bytes", 0)),
                "tester_port": data.get("tester_port"),
                "broker_host": broker_host,
                "broker_name": broker_name,
                "listen_host": listen_host,
                "listen_display_host": listen_display_host,
                "listen_port": listen_port,
                "listen_is_wildcard": bool(data.get("listen_is_wildcard", False)),
                "error": error,
            }
        )
    return {
        "timestamp": time.time(),
        "last_activity": getattr(bus, "last_activity", 0.0),
        "recent_frames": recent_frames,
        "services": snapshots,
    }


class SidecarRuntime:
    def __init__(self) -> None:
        self._lock = threading.Lock()
        self._setup: Optional[Setup] = None
        self._bus: Optional[CANBusEmulation] = None
        self._services: list[tuple[str, Any]] = []
        self._running = False
        self._spec_path: Optional[str] = None
        self._spec_digest: Optional[str] = None
        self._options: dict[str, Any] = {}
        self._connection_logs = ConnectionLogStore(history_limit=64, event_limit=1024)
        self._service_session_ids: dict[str, str] = {}
        self._service_log_lock = threading.Lock()

    def validate(self, spec_path: str) -> None:
        if not spec_path:
            raise SidecarError("spec_path is required")
        setup = Setup()
        setup.add(spec_path)

    def _compute_spec_digest(self, spec_path: str) -> Optional[str]:
        if not spec_path:
            return None
        try:
            with open(spec_path, "rb") as handle:
                vehicle_bytes = handle.read()
        except OSError:
            return None
        hasher = hashlib.sha256()
        hasher.update(vehicle_bytes)
        try:
            vehicle_data = json.loads(vehicle_bytes.decode("utf-8"))
        except (json.JSONDecodeError, UnicodeDecodeError):
            return hasher.hexdigest()
        if isinstance(vehicle_data, dict) and isinstance(vehicle_data.get("ecus"), list):
            base_dir = os.path.dirname(spec_path) or "."
            for ecu in vehicle_data["ecus"]:
                path = ecu.get("path") if isinstance(ecu, dict) else None
                if not isinstance(path, str) or not path:
                    continue
                full_path = os.path.join(base_dir, path)
                try:
                    with open(full_path, "rb") as handle:
                        ecu_bytes = handle.read()
                except OSError:
                    ecu_bytes = b"[missing]"
                hasher.update(b"\0")
                hasher.update(path.encode("utf-8"))
                hasher.update(b"\0")
                hasher.update(ecu_bytes)
        return hasher.hexdigest()

    def start(self, spec_path: str, options: Optional[dict[str, Any]] = None) -> None:
        if not spec_path:
            raise SidecarError("spec_path is required")
        opts = options or {}
        new_digest = self._compute_spec_digest(spec_path)
        bus_options_raw = opts.get("bus", {})
        bus_options = bus_options_raw if isinstance(bus_options_raw, dict) else {}
        services_raw = opts.get("services", {})
        services = services_raw if isinstance(services_raw, dict) else {}
        log = bool(opts.get("log", False))
        transport_profile = _parse_transport_profile(bus_options)
        _validate_services_for_transport_profile(services, transport_profile)
        isotp_fc_signature = _isotp_fc_signature(services)
        bus_signature = _bus_signature(bus_options)
        with self._lock:
            if self._running:
                old_services_raw = self._options.get("services", {})
                old_services = old_services_raw if isinstance(old_services_raw, dict) else {}
                old_bus_raw = self._options.get("bus", {})
                old_bus = old_bus_raw if isinstance(old_bus_raw, dict) else {}
                if (
                    self._spec_path == spec_path
                    and self._spec_digest == new_digest
                    and _bus_signature(old_bus) == bus_signature
                    and bool(self._options.get("log", False)) == log
                    and _isotp_fc_signature(old_services) == isotp_fc_signature
                ):
                    self._update_services_locked(services, log)
                    self._options = opts
                    return
                self._stop_locked()
            self._start_locked(spec_path, opts, new_digest)

    def stop(self) -> None:
        with self._lock:
            self._stop_locked()

    def status(self) -> dict[str, Any]:
        pid = os.getpid()
        with self._lock:
            running = self._running
            spec_path = self._spec_path
            options = self._options
            bus = self._bus
            services = list(self._services)
        if not running or bus is None:
            return {
                "running": False,
                "apiVersion": SIDECAR_API_VERSION,
                "pid": pid,
                "specPath": spec_path,
                "options": options,
                "connectionLog": self._connection_logs.summary(),
            }
        snapshot = build_bus_snapshot(bus, services=services)
        return {
            "running": True,
            "apiVersion": SIDECAR_API_VERSION,
            "pid": pid,
            "specPath": spec_path,
            "options": options,
            "snapshot": dataclasses.asdict(snapshot),
            "connectionLog": self._connection_logs.summary(),
        }

    def status_light(self) -> dict[str, Any]:
        pid = os.getpid()
        with self._lock:
            running = self._running
            spec_path = self._spec_path
            options = self._options
            bus = self._bus
            services = list(self._services)
        if not running or bus is None:
            return {
                "running": False,
                "apiVersion": SIDECAR_API_VERSION,
                "pid": pid,
                "specPath": spec_path,
                "options": options,
                "connectionLog": self._connection_logs.summary(),
            }
        snapshot = _build_light_snapshot(bus, services)
        return {
            "running": True,
            "apiVersion": SIDECAR_API_VERSION,
            "pid": pid,
            "specPath": spec_path,
            "options": options,
            "snapshot": snapshot,
            "connectionLog": self._connection_logs.summary(),
        }

    def connection_log_active(self) -> dict[str, Any]:
        return self._connection_logs.active_payload()

    def connection_log_active_delta(self, after: Any = None) -> dict[str, Any]:
        if after is None:
            return self._connection_logs.active_payload_delta()
        try:
            after_seq = int(after)
        except (TypeError, ValueError):
            return self._connection_logs.active_payload_delta()
        return self._connection_logs.active_payload_delta(after_seq)

    def connection_log_clear_active(self) -> dict[str, Any]:
        cleared = self._connection_logs.clear_active()
        payload = self._connection_logs.active_payload_delta()
        payload["cleared"] = cleared
        return payload

    def clear_recent_frames(self) -> dict[str, Any]:
        with self._lock:
            bus = self._bus
        if bus is not None and hasattr(bus, "clear_recent_frames"):
            bus.clear_recent_frames()
        return self.status_light()

    def discover_enet_broker(self) -> dict[str, str]:
        from ..enet_service_broker.server import _resolve_host_name, discover_broker_endpoint

        endpoint = discover_broker_endpoint()
        broker_name = _resolve_host_name(endpoint.stream_host) or ""
        return {
            "url": endpoint.base_url,
            "host": endpoint.stream_host,
            "name": broker_name,
        }

    def _start_locked(self, spec_path: str, options: dict[str, Any], digest: Optional[str]) -> None:
        bus_options_raw = options.get("bus", {})
        bus_options = bus_options_raw if isinstance(bus_options_raw, dict) else {}
        services_raw = options.get("services", {})
        services = services_raw if isinstance(services_raw, dict) else {}
        log = bool(options.get("log", False))

        mode = bus_options.get("mode", "virtual")
        bitrate = int(bus_options.get("bitrate", 500000))
        timing_factor = float(bus_options.get("timingFactor", 0.0))
        interface = bus_options.get("interface", "")
        transport_profile = _parse_transport_profile(bus_options)
        _validate_services_for_transport_profile(services, transport_profile)

        if mode == "socketcan":
            if not interface:
                raise SidecarError("bus.interface is required for socketcan mode")
            missing_can = missing_python_can_modules(interface)
            if missing_can:
                details = ", ".join(missing_can)
                raise SidecarError(f"Missing CAN backend dependencies: {details}")
            try:
                wires = SocketCANInterface(interface)
            except UnsupportedConfiguration as exc:
                raise SidecarError(str(exc)) from exc
        else:
            wires = NullInterface()

        setup = Setup()
        try:
            setup.add(spec_path)
        except SetupError as exc:
            raise SidecarError(str(exc)) from exc

        setup.enable_logging = log
        setup.diagnostic_log_sink = self._on_diagnostic_log
        bus = CANBusEmulation(wires, timing_factor=timing_factor)
        setup.attach(bus)
        for ecu in setup._ecu_map.values():
            ecu.set_transport_profile(transport_profile)
        isotp_fc = _parse_isotp_fc_settings(services)
        if _isotp_fc_enabled(isotp_fc):
            for ecu in setup._ecu_map.values():
                ecu.configure_isotp_flow_control(
                    wait_count=isotp_fc["wait_count"],
                    wait_ms=isotp_fc["wait_ms"],
                    overflow=isotp_fc["overflow"],
                    wait_probability=isotp_fc["wait_probability"],
                    overflow_probability=isotp_fc["overflow_probability"],
                    seed=isotp_fc["seed"],
                )
        bus.operate(bitrate)

        service_instances = self._start_services(bus, setup, services, log)

        self._setup = setup
        self._bus = bus
        self._services = service_instances
        self._running = True
        self._spec_path = spec_path
        self._spec_digest = digest
        self._options = options

    def _stop_locked(self) -> None:
        for _name, service in self._services:
            try:
                service.stop()
            except Exception:
                pass
        if self._bus is not None:
            try:
                self._bus.shutdown()
            except Exception:
                pass
        self._setup = None
        self._bus = None
        self._services = []
        self._running = False
        self._spec_digest = None
        with self._service_log_lock:
            self._connection_logs.close_all_active(reason="Connection dropped")
            self._service_session_ids.clear()

    def _service_event_sink(self, service_name: str):
        def _sink(event: dict[str, Any]) -> None:
            self._on_service_event(service_name, event)

        return _sink

    def _apply_service_identity_event(self, service_name: str, kind: str, event: dict[str, Any]) -> None:
        if service_name != "ECUconnect" or kind != "reported_identity_changed":
            return
        firmware_raw = event.get("firmware")
        hardware_raw = event.get("hardware")
        source = str(event.get("source") or "").strip().lower()
        event_timestamp = float(event.get("timestamp") or time.time())
        updated_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(event_timestamp))
        firmware = str(firmware_raw).strip() if firmware_raw is not None else ""
        hardware = str(hardware_raw).strip() if hardware_raw is not None else ""
        if not firmware and not hardware and source != "firmware_update":
            return

        with self._lock:
            current_options = self._options if isinstance(self._options, dict) else {}
            current_services = current_options.get("services")
            if not isinstance(current_services, dict):
                current_services = {}
            next_services = dict(current_services)
            changed = False

            if firmware and next_services.get("ecuconnectVersion") != firmware:
                next_services["ecuconnectVersion"] = firmware
                changed = True
            if hardware and next_services.get("ecuconnectHardwareRevision") != hardware:
                next_services["ecuconnectHardwareRevision"] = hardware
                changed = True

            if source == "firmware_update":
                if next_services.get("ecuconnectVersionFromUpdate") is not True:
                    next_services["ecuconnectVersionFromUpdate"] = True
                    changed = True
                if next_services.get("ecuconnectVersionUpdatedAt") != updated_at:
                    next_services["ecuconnectVersionUpdatedAt"] = updated_at
                    changed = True

            if changed:
                next_options = dict(current_options)
                next_options["services"] = next_services
                self._options = next_options

    def _on_service_event(self, service_name: str, event: dict[str, Any]) -> None:
        kind = str(event.get("kind") or "").strip().lower()
        if not kind:
            return
        self._apply_service_identity_event(service_name, kind, event)
        timestamp = float(event.get("timestamp") or time.time())
        peer = str(event.get("peer") or "unknown")

        with self._service_log_lock:
            session_id = self._service_session_ids.get(service_name)

            if kind == "connection_opened":
                session_id = self._connection_logs.begin(service_name, peer)
                self._service_session_ids[service_name] = session_id
                return

            if kind == "connection_closed":
                if session_id is not None:
                    reason = str(event.get("message") or "Connection closed")
                    self._connection_logs.end(session_id=session_id, reason=reason, timestamp=timestamp)
                    self._service_session_ids.pop(service_name, None)
                return

            if session_id is None:
                return

            if kind == "transport_rx":
                # Keep low-level transport bytes out of the primary connection log view.
                return

            if kind == "transport_tx":
                # Keep low-level transport bytes out of the primary connection log view.
                return

            if kind == "adapter_command":
                # Adapter command traffic is intentionally not part of the high-level semantic log.
                return

            if kind in {"diagnostic_request", "diagnostic_response"}:
                # ECU semantic logs are appended via _on_diagnostic_log; skip duplicate protocol-level entries.
                return

            if kind == "connection_error":
                message = str(event.get("message") or "Connection error")
                self._connection_logs.append(
                    session_id=session_id,
                    timestamp=timestamp,
                    direction="event",
                    participants=[service_name, peer],
                    event=message,
                    details={"kind": kind},
                )
                return

            self._connection_logs.append(
                session_id=session_id,
                timestamp=timestamp,
                direction="event",
                participants=[service_name, peer],
                event=f"Event: {kind}",
                details={"kind": kind},
            )

    def _on_diagnostic_log(self, event: dict[str, Any]) -> None:
        text = event.get("text")
        if not isinstance(text, str) or not text:
            return
        semantic_line = strip_ansi(text).strip()
        if not semantic_line:
            return
        direction_raw = str(event.get("direction") or "").lower()
        ecu_name = str(event.get("ecu") or "ECU")
        timestamp = float(event.get("timestamp") or time.time())
        direction, participants, semantic_event = _parse_semantic_log_line(semantic_line, ecu_name, direction_raw)
        if not semantic_event:
            return

        details: dict[str, Any] = {"kind": "diagnostic", "semanticLine": semantic_line}
        payload = event.get("bytes")
        if isinstance(payload, list):
            details["bytes"] = [int(byte) & 0xFF for byte in payload]

        with self._service_log_lock:
            self._connection_logs.append_to_latest(
                timestamp=timestamp,
                direction=direction,
                participants=participants,
                event=semantic_event,
                details=details,
            )

    def _start_services(
        self,
        bus: CANBusEmulation,
        setup: Setup,
        services: dict[str, Any],
        log: bool,
    ) -> list[tuple[str, Any]]:
        instances: list[tuple[str, Any]] = []
        print(f"DEBUG: _start_services called with: {services}", flush=True)
        for key in ("externalCan", "logger", "ecuconnect", "hsfz", "doip", "elm327", "enetServiceBroker"):
            if not services.get(key):
                continue
            name, server = self._create_service_instance(key, bus, setup, services, log)
            instances.append((name, server))
        return instances

    def _service_signature(self, key: str, services: dict[str, Any], setup: Setup) -> tuple:
        if key == "logger":
            return (
                bool(services.get("logger")),
                services.get("loggerHost", "0.0.0.0"),
                services.get("loggerName", "") or "",
            )
        if key == "ecuconnect":
            version, hardware_revision = _ecuconnect_reported_identity(services)
            return (
                bool(services.get("ecuconnect")),
                services.get("ecuconnectHost", "0.0.0.0"),
                int(services.get("ecuconnectPort", 8129)),
                version,
                hardware_revision,
            )
        if key == "hsfz":
            vin = services.get("hsfzVin", "") or _resolve_vin_from_specs(setup._path_map) or ""
            return (
                bool(services.get("hsfz")),
                services.get("hsfzHost", "0.0.0.0"),
                int(services.get("hsfzPort", 6801)),
                vin or "WBA45678901234567",
            )
        if key == "doip":
            vin = services.get("doipVin", "") or _resolve_vin_from_specs(setup._path_map) or ""
            tester_address = _parse_optional_tester_address(services.get("doipTesterAddress"))
            return (
                bool(services.get("doip")),
                services.get("doipHost", "0.0.0.0"),
                int(services.get("doipPort", 13400)),
                vin or "WBA45678901234567",
                tester_address,
            )
        if key == "elm327":
            host = _parse_service_string(services.get("elm327Host"), "0.0.0.0")
            name = _parse_service_string(services.get("elm327Name"), "ELM327/STN2xxx")
            startup_file = _parse_service_string(services.get("elm327StartupFile"), "")
            log_directory = _parse_service_string(services.get("elm327LogDirectory"), "")
            ati_version = _parse_service_string(services.get("elm327AtiVersion"), "ELM327 1.4a")
            at1_descriptor = _parse_service_string(services.get("elm327At1Descriptor"), "OBDII to RS232 Interpreter")
            at2_name = _parse_service_string(services.get("elm327At2Name"), "ELMpythonator")
            atign_state = _parse_service_string(services.get("elm327AtignState"), "ON").upper()
            if atign_state not in {"ON", "OFF"}:
                atign_state = "ON"
            atrv_voltage = _parse_service_string(services.get("elm327AtrvVoltage"), "12.1V")
            sti_version = _parse_service_string(services.get("elm327StiVersion"), "STN2200 2.2.0")
            stix_version = _parse_service_string(services.get("elm327StixVersion"), "STN2200 2.2.0 [26.12.1971]")
            stsn_serial = _parse_service_string(services.get("elm327StsnSerial"), "123456789012")
            stmfr_manufacturer = _parse_service_string(services.get("elm327StmfrManufacturer"), "OBD Solutions LLC")
            return (
                bool(services.get("elm327")),
                host,
                int(services.get("elm327Port", 35000)),
                name,
                startup_file,
                log_directory,
                ati_version,
                at1_descriptor,
                at2_name,
                atign_state,
                atrv_voltage,
                sti_version,
                stix_version,
                stsn_serial,
                stmfr_manufacturer,
            )
        if key == "externalCan":
            enabled = bool(services.get("externalCan"))
            interface = services.get("externalCanInterface", "") or ""
            if not enabled:
                return (enabled, interface)
            mode, bitrate, data_bitrate, access_mode, termination = _external_can_settings(services)
            return (
                enabled,
                interface,
                mode,
                access_mode,
                bitrate,
                data_bitrate,
                termination,
            )
        if key == "enetServiceBroker":
            vin, url, mode = _enet_service_broker_settings(services)
            if not vin:
                vin = _resolve_vin_from_specs(setup._path_map) or ""
            return (
                bool(services.get("enetServiceBroker")),
                vin,
                url,
                mode,
            )
        return (bool(services.get(key)),)

    def _create_service_instance(
        self,
        key: str,
        bus: CANBusEmulation,
        setup: Setup,
        services: dict[str, Any],
        log: bool,
    ) -> tuple[str, Any]:
        if key == "logger":
            from ..ecuconnect.logger import ECUconnectLoggerServer

            host = services.get("loggerHost", "0.0.0.0")
            name = services.get("loggerName", "") or None
            logger_server = ECUconnectLoggerServer(bus, host=host, log=log, service_name=name)
            if not logger_server.start():
                raise SidecarError("ECUconnect logger failed to start")
            return ("Logger", logger_server)
        if key == "ecuconnect":
            from ..ecuconnect import server as ecuconnect_server_module

            if ecuconnect_server_module.lz4_block is None:
                raise SidecarError("ECUconnect requires lz4 compression support (pip install lz4).")

            from ..ecuconnect.server import ECUconnectServer

            host = services.get("ecuconnectHost", "0.0.0.0")
            port = int(services.get("ecuconnectPort", 8129))
            version, hardware_revision = _ecuconnect_reported_identity(services)
            server = ECUconnectServer(
                bus,
                host=host,
                port=port,
                log=log,
                firmware_version=version,
                hardware_revision=hardware_revision,
                event_sink=self._service_event_sink("ECUconnect"),
            )
            server.start()
            return ("ECUconnect", server)
        if key == "hsfz":
            from ..hsfz.server import HSFZServer

            host = services.get("hsfzHost", "0.0.0.0")
            port = int(services.get("hsfzPort", 6801))
            vin = services.get("hsfzVin", "") or _resolve_vin_from_specs(setup._path_map) or ""
            server = HSFZServer(
                bus,
                host=host,
                port=port,
                log=log,
                vin=vin or "WBA45678901234567",
                event_sink=self._service_event_sink("HSFZ"),
            )
            if not server.start():
                raise SidecarError("HSFZ server failed to start")
            return ("HSFZ", server)
        if key == "doip":
            from ..doip.server import DoIPServer

            host = services.get("doipHost", "0.0.0.0")
            port = int(services.get("doipPort", 13400))
            vin = services.get("doipVin", "") or _resolve_vin_from_specs(setup._path_map) or ""
            tester_address = _parse_optional_tester_address(services.get("doipTesterAddress"))
            server = DoIPServer(
                bus,
                host=host,
                port=port,
                log=log,
                vin=vin or "WBA45678901234567",
                tester_address=tester_address,
                event_sink=self._service_event_sink("DoIP"),
            )
            if not server.start():
                raise SidecarError("DoIP server failed to start")
            return ("DoIP", server)
        if key == "elm327":
            from elmpythonator.service import ELMService

            host = _parse_service_string(services.get("elm327Host"), "0.0.0.0")
            port = int(services.get("elm327Port", 35000))
            name = _parse_service_string(services.get("elm327Name"), "ELM327/STN2xxx")
            startup_file = _parse_service_string(services.get("elm327StartupFile"), "")
            log_directory = _parse_service_string(services.get("elm327LogDirectory"), "")
            ati_version = _parse_service_string(services.get("elm327AtiVersion"), "ELM327 1.4a")
            at1_descriptor = _parse_service_string(services.get("elm327At1Descriptor"), "OBDII to RS232 Interpreter")
            at2_name = _parse_service_string(services.get("elm327At2Name"), "ELMpythonator")
            atign_state = _parse_service_string(services.get("elm327AtignState"), "ON").upper()
            if atign_state not in {"ON", "OFF"}:
                atign_state = "ON"
            atrv_voltage = _parse_service_string(services.get("elm327AtrvVoltage"), "12.1V")
            sti_version = _parse_service_string(services.get("elm327StiVersion"), "STN2200 2.2.0")
            stix_version = _parse_service_string(services.get("elm327StixVersion"), "STN2200 2.2.0 [26.12.1971]")
            stsn_serial = _parse_service_string(services.get("elm327StsnSerial"), "123456789012")
            stmfr_manufacturer = _parse_service_string(services.get("elm327StmfrManufacturer"), "OBD Solutions LLC")
            server = ELMService(
                bus,
                host=host,
                port=port,
                adapter_name=name,
                startup_file=startup_file,
                log_directory=log_directory,
                ati_version=ati_version,
                at1_descriptor=at1_descriptor,
                at2_name=at2_name,
                atign_state=atign_state,
                atrv_voltage=atrv_voltage,
                sti_version=sti_version,
                stix_version=stix_version,
                stsn_serial=stsn_serial,
                stmfr_manufacturer=stmfr_manufacturer,
                event_sink=self._service_event_sink("ELM327/STN2xxx"),
            )
            server.start()
            return ("ELM327/STN2xxx", server)
        if key == "externalCan":
            interface = services.get("externalCanInterface", "") or ""
            if not interface:
                raise SidecarError("externalCanInterface is required for External CAN")
            mode, bitrate, data_bitrate, access_mode, termination = _external_can_settings(services)
            context = _external_can_context(interface, mode, bitrate, data_bitrate, access_mode, termination)
            missing_can = missing_python_can_modules(interface)
            if missing_can:
                details = ", ".join(missing_can)
                raise SidecarError(f"Missing CAN backend dependencies: {details}")
            # If the bus already uses a physical python-can backend, external CAN is redundant.
            if isinstance(bus.wires, SocketCANInterface):
                raise SidecarError("External CAN is already active via attached bus mode")
            try:
                bridge = ExternalCANBridge(
                    bus,
                    interface=interface,
                    bitrate=bitrate,
                    fd_mode=mode == "fd",
                    data_bitrate=data_bitrate,
                    listen_only=access_mode == "listen_only",
                    termination=termination,
                    log=log,
                )
            except UnsupportedConfiguration as exc:
                raise SidecarError(str(exc)) from exc
            try:
                if not bridge.start():
                    raise SidecarError(f"External CAN bridge failed to start ({context})")
            except Exception as exc:
                raise SidecarError(f"External CAN open failed ({context}): {exc}") from exc
            return ("CAN-Bus", bridge)
        if key == "enetServiceBroker":
            from ..enet_service_broker.server import ENETServiceBrokerService

            vin, broker_url, mode = _enet_service_broker_settings(services)
            if not vin:
                vin = _resolve_vin_from_specs(setup._path_map) or ""
            if not vin:
                raise SidecarError("VIN is required for ENET Service Broker")
            server = ENETServiceBrokerService(
                bus,
                vin=vin,
                broker_url=broker_url,
                mode=mode,
                log=log,
                event_sink=self._service_event_sink("ENET Service Broker"),
            )
            if not server.start():
                raise SidecarError("ENET Service Broker failed to start")
            return ("ENET Service Broker", server)
        raise SidecarError(f"Unknown service '{key}'")

    def _update_services_locked(self, services: dict[str, Any], log: bool) -> None:
        if self._bus is None or self._setup is None:
            return
        bus_options_raw = self._options.get("bus", {})
        bus_options = bus_options_raw if isinstance(bus_options_raw, dict) else {}
        transport_profile = _parse_transport_profile(bus_options)
        _validate_services_for_transport_profile(services, transport_profile)
        current = {name: service for name, service in self._services}
        updated: list[tuple[str, Any]] = []
        old_services = self._options.get("services", {})
        for key in ("externalCan", "logger", "ecuconnect", "hsfz", "doip", "elm327", "enetServiceBroker"):
            name = {
                "externalCan": "CAN-Bus",
                "logger": "Logger",
                "ecuconnect": "ECUconnect",
                "hsfz": "HSFZ",
                "doip": "DoIP",
                "elm327": "ELM327/STN2xxx",
                "enetServiceBroker": "ENET Service Broker",
            }[key]
            enabled = bool(services.get(key))
            existing = current.pop(name, None)
            if not enabled:
                if existing:
                    try:
                        existing.stop()
                    except Exception:
                        pass
                continue
            if existing and self._service_signature(key, services, self._setup) == self._service_signature(key, old_services, self._setup):
                updated.append((name, existing))
                continue
            if existing:
                try:
                    existing.stop()
                except Exception:
                    pass
            created_name, created = self._create_service_instance(key, self._bus, self._setup, services, log)
            updated.append((created_name, created))

        for _name, service in current.items():
            try:
                service.stop()
            except Exception:
                pass
        self._services = updated

    def dispatch(self, payload: str, server: "SidecarServer") -> dict[str, Any]:
        data = _parse_request(payload)
        request_id = data.get("id")
        method = data.get("method")
        params = data.get("params") or {}

        if not isinstance(method, str):
            return _build_response(request_id, False, error="method is required")
        if not isinstance(params, dict):
            return _build_response(request_id, False, error="params must be an object")

        try:
            if method == "status":
                return _build_response(request_id, True, result=self.status())
            if method == "status_light":
                return _build_response(request_id, True, result=self.status_light())
            if method == "connection_log_active":
                return _build_response(request_id, True, result=self.connection_log_active())
            if method == "connection_log_active_delta":
                return _build_response(
                    request_id,
                    True,
                    result=self.connection_log_active_delta(params.get("after")),
                )
            if method == "connection_log_clear_active":
                return _build_response(request_id, True, result=self.connection_log_clear_active())
            if method == "clear_recent_frames":
                return _build_response(request_id, True, result=self.clear_recent_frames())
            if method == "discover_enet_broker":
                return _build_response(request_id, True, result=self.discover_enet_broker())
            if method == "validate":
                self.validate(params.get("spec_path") or "")
                return _build_response(request_id, True, result={"valid": True})
            if method == "apply":
                opts = params.get("options") or {}
                print(f"DEBUG: apply received options: {opts}", flush=True)
                self.start(params.get("spec_path") or "", options=opts)
                return _build_response(request_id, True, result=self.status())
            if method == "start":
                self.start(params.get("spec_path") or "", options=params.get("options") or {})
                return _build_response(request_id, True, result=self.status())
            if method == "stop":
                self.stop()
                return _build_response(request_id, True, result={"stopped": True})
            if method == "shutdown":
                self.stop()
                threading.Thread(target=_shutdown_later, args=(server,), daemon=True).start()
                return _build_response(request_id, True, result={"shutdown": True})
        except Exception as exc:
            return _build_response(request_id, False, error=str(exc))

        return _build_response(request_id, False, error=f"unknown method '{method}'")


class SidecarRequestHandler(socketserver.StreamRequestHandler):
    def handle(self) -> None:
        server: SidecarServer = self.server  # type: ignore[assignment]
        while True:
            line = self.rfile.readline()
            if not line:
                return
            payload = line.decode("utf-8", errors="ignore").strip()
            if not payload:
                continue
            response = server.runtime.dispatch(payload, server)
            encoded = (json.dumps(response) + "\n").encode("utf-8")
            self.wfile.write(encoded)


class SidecarServer(socketserver.ThreadingTCPServer):
    allow_reuse_address = True

    def __init__(self, host: str, port: int, runtime: SidecarRuntime) -> None:
        super().__init__((host, port), SidecarRequestHandler)
        self.runtime = runtime


def _build_response(request_id: Optional[str], ok: bool, result: Any = None, error: Optional[str] = None) -> dict:
    payload: dict[str, Any] = {"ok": ok}
    if request_id is not None:
        payload["id"] = request_id
    if ok:
        payload["result"] = result
    else:
        payload["error"] = {"message": error or "unknown error"}
    return payload


def _parse_request(payload: str) -> dict[str, Any]:
    try:
        data = json.loads(payload)
    except json.JSONDecodeError as exc:
        raise SidecarError(f"invalid JSON: {exc}") from exc
    if not isinstance(data, dict):
        raise SidecarError("request must be a JSON object")
    return data


def _shutdown_later(server: SidecarServer) -> None:
    time.sleep(0.05)
    server.shutdown()


def _pid_exists(pid: int) -> bool:
    if pid <= 0:
        return False
    if pid == os.getpid():
        return True
    try:
        os.kill(pid, 0)
        return True
    except ProcessLookupError:
        return False
    except PermissionError:
        return True
    except OSError as exc:
        return getattr(exc, "errno", None) != errno.ESRCH


def _shutdown_when_owner_exits(server: SidecarServer, owner_pid: int) -> None:
    while True:
        time.sleep(0.5)
        if not _pid_exists(owner_pid):
            print(
                f"ECUmulator sidecar owner process {owner_pid} exited; shutting down sidecar",
                flush=True,
            )
            server.shutdown()
            return


def main(argv: Optional[list[str]] = None) -> None:
    parser = argparse.ArgumentParser(description="ECUmulator sidecar control server")
    parser.add_argument("--host", default="127.0.0.1", help="Bind host (default: 127.0.0.1)")
    parser.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"Bind port (default: {DEFAULT_PORT})")
    parser.add_argument("--spec", default="", help="Optional vehicle spec to autostart")
    parser.add_argument("--options", default="", help="Optional JSON options to autostart")
    parser.add_argument(
        "--owner-pid",
        type=int,
        default=0,
        help="Optional owner process PID; sidecar exits when owner is gone",
    )
    args = parser.parse_args(argv)

    with _sidecar_singleton_guard():
        runtime = SidecarRuntime()
        if args.spec:
            options = {}
            if args.options:
                try:
                    options = json.loads(args.options)
                except json.JSONDecodeError:
                    options = {}
            runtime.start(args.spec, options=options)

        server = SidecarServer(args.host, args.port, runtime)
        print(f"ECUmulator sidecar listening on {args.host}:{args.port}")
        if args.owner_pid > 0 and args.owner_pid != os.getpid():
            threading.Thread(
                target=_shutdown_when_owner_exits,
                args=(server, args.owner_pid),
                daemon=True,
            ).start()
        try:
            server.serve_forever()
        except KeyboardInterrupt:
            pass
        finally:
            runtime.stop()
            server.server_close()
