from __future__ import annotations

import os
import threading
import time
from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional

try:
    from watchdog.events import FileSystemEventHandler
    from watchdog.observers import Observer
except ImportError:
    FileSystemEventHandler = None  # type: ignore[assignment]
    Observer = None  # type: ignore[assignment]
    _WATCHDOG_AVAILABLE = False
else:
    _WATCHDOG_AVAILABLE = True

from .core.ecu import ECU
from .specs.parser import load_command_handler, load_vehicle, parse_command_handler


class SetupError(Exception):
    pass


@dataclass
class Configuration:
    ecu: str
    property_name: str
    value: str


def _clamp(value: int, minimum: int, maximum: int) -> int:
    return min(maximum, max(minimum, value))


def _encode_kwp_p2_ms(ms: float) -> int:
    if ms <= 0:
        return 0x00
    linear_max = 0xF0 * 25.0
    if ms <= linear_max:
        return _clamp(int(round(ms / 25.0)), 0x01, 0xF0)
    nibble = _clamp(int(round(ms / (0x100 * 25.0))), 0x01, 0x0E)
    return 0xF0 | nibble


def _encode_kwp_p3_ms(ms: float) -> int:
    if ms <= 0:
        return 0x00
    if ms >= 5000:
        return 0xFF
    return _clamp(int(round(ms / 0.5)), 0x00, 0xFE)


def _encode_kwp_p4_ms(ms: float) -> int:
    if ms <= 0:
        return 0x00
    return _clamp(int(round(ms / 0.5)), 0x00, 0xFF)


def _normalize_kwp_timing_parameters(values: Any) -> Any:
    if not isinstance(values, list):
        return values
    if not values:
        return []
    if all(isinstance(item, int) and 0 <= item <= 0xFF for item in values):
        return values
    try:
        p2 = float(values[0]) if len(values) > 0 else 0.0
        p3 = float(values[1]) if len(values) > 1 else 0.0
        p4 = float(values[2]) if len(values) > 2 else 0.0
    except (TypeError, ValueError):
        return values
    return [_encode_kwp_p2_ms(p2), _encode_kwp_p3_ms(p3), _encode_kwp_p4_ms(p4)]


def _inline_ecu_to_handler_spec(ecu: Dict[str, Any], vehicle_vin: str) -> Optional[Dict[str, Any]]:
    kind = ecu.get("kind")
    if isinstance(kind, str) and kind:
        return ecu

    protocol = str(ecu.get("protocol", "")).strip().lower()
    if protocol not in {"uds", "kwp", "obd2"}:
        return None

    name = str(ecu.get("name") or ecu.get("role") or "ECU")
    arbitration_in = ecu.get("arbitration")
    if not isinstance(arbitration_in, dict):
        arbitration_in = {}

    arbitration = {
        "request": arbitration_in.get("request", arbitration_in.get("requestId", 0x7E0)),
        "reply": arbitration_in.get("reply", arbitration_in.get("replyId", 0x7E8)),
        "extendedRequest": arbitration_in.get(
            "extendedRequest", arbitration_in.get("extendedRequestId")
        ),
        "extendedReply": arbitration_in.get("extendedReply", arbitration_in.get("extendedReplyId")),
        "auxiliary": arbitration_in.get("auxiliary", arbitration_in.get("auxiliaryIds", [])),
        "auxiliaryRequestExtensions": arbitration_in.get("auxiliaryRequestExtensions", []),
    }

    if protocol == "obd2":
        block = ecu.get("obd2")
        block_obj: Dict[str, Any] = dict(block) if isinstance(block, dict) else {}
        block_obj.setdefault("arbitrationInfo", arbitration)
        vin = ecu.get("vin") or vehicle_vin
        if vin and "vin" not in block_obj:
            block_obj["vin"] = vin
        return {"name": name, "kind": "obd2", "obd2": block_obj}

    block = ecu.get(protocol)
    block_obj = dict(block) if isinstance(block, dict) else {}
    block_obj.setdefault("arbitration", arbitration)
    if protocol == "kwp" and "timingParameters" in block_obj:
        block_obj["timingParameters"] = _normalize_kwp_timing_parameters(
            block_obj.get("timingParameters")
        )
    return {"name": name, "kind": protocol, protocol: block_obj}


class Setup:
    def __init__(self) -> None:
        self._path_map: Dict[str, str] = {}
        self._ecu_map: Dict[str, ECU] = {}
        self._config_map: Dict[str, List[Configuration]] = {}
        self._interface = None
        self.enable_logging = False
        self.diagnostic_log_sink: Optional[Callable[[dict[str, Any]], None]] = None

    def add(self, ecu_spec: str) -> None:
        components = ecu_spec.split(":")
        if len(components) == 1:
            path = components[0]
            if not os.path.exists(path):
                raise SetupError(f"File not found: {path}")
            self.add_vehicle(path)
        elif len(components) == 2:
            ecu_name, path = components
            path = os.path.expanduser(path)
            if not os.path.exists(path):
                raise SetupError(f"File not found: {path}")
            self.add_ecu(ecu_name, path)
        else:
            raise SetupError(f"Invalid format: {ecu_spec}")

    def add_vehicle(self, path: str) -> None:
        vehicle = load_vehicle(path)
        real_path = os.path.realpath(path)
        base_dir = os.path.dirname(real_path)
        for ecu in vehicle.ecus:
            if not isinstance(ecu, dict):
                raise SetupError(
                    f"Invalid vehicle JSON in {path}: each ECU entry must be an object."
                )
            ecu_name = ecu.get("name") or ecu.get("role")
            if not isinstance(ecu_name, str) or not ecu_name.strip():
                raise SetupError(
                    f"Invalid vehicle JSON in {path}: each ECU entry must include a 'name'."
                )
            ecu_name = ecu_name.strip()
            ecu_path = ecu.get("path")
            if isinstance(ecu_path, str) and ecu_path.strip():
                self.add_ecu(ecu_name, os.path.join(base_dir, ecu_path))
            else:
                inline_spec = _inline_ecu_to_handler_spec(ecu, vehicle.vin)
                if inline_spec is None:
                    raise SetupError(
                        f"Invalid vehicle JSON in {path}: ECU '{ecu_name}' is missing a valid 'path' and inline spec."
                    )
                self.add_ecu_from_spec(
                    ecu_name=ecu_name,
                    spec=inline_spec,
                    base_dir=base_dir,
                    source=f"{real_path}#{ecu_name}",
                )
            self.add_configuration(f"{ecu_name}.vin={vehicle.vin}")

    def add_ecu(self, ecu_name: str, path: str) -> None:
        real_path = os.path.realpath(path)
        handler = load_command_handler(real_path)
        ecu = self._ecu_map.setdefault(ecu_name, ECU(name=ecu_name, handlers=[]))
        ecu.add_command_handler(handler)
        self._path_map[ecu_name] = real_path
        Monitor.shared().add_path(real_path, lambda: self._reload_ecu(ecu, ecu_name, real_path))

    def add_ecu_from_spec(
        self, ecu_name: str, spec: Dict[str, Any], base_dir: str, source: str
    ) -> None:
        handler = parse_command_handler(spec, base_dir)
        ecu = self._ecu_map.setdefault(ecu_name, ECU(name=ecu_name, handlers=[]))
        ecu.add_command_handler(handler)
        self._path_map[ecu_name] = source

    def _reload_ecu(self, ecu: ECU, ecu_name: str, path: str) -> None:
        try:
            handler = load_command_handler(path)
            ecu.replace_command_handler(handler)
            print(f"Reloaded ECU '{ecu_name}' from {path}")
        except Exception as exc:
            print(f"Failed to reload ECU '{ecu_name}' from {path}: {exc}")
            return

    def add_configuration(self, config_spec: str) -> None:
        if "=" not in config_spec:
            raise SetupError(f"Invalid config: {config_spec}")
        property_part, value = config_spec.split("=", 1)
        if "." not in property_part:
            raise SetupError(f"Invalid config: {config_spec}")
        # Allow ECU names containing dots by splitting on the last separator.
        ecu_name, property_name = property_part.rsplit(".", 1)
        if ecu_name not in self._ecu_map:
            raise SetupError(f"ECU not found: {ecu_name}")
        configs = self._config_map.setdefault(ecu_name, [])
        configs.append(Configuration(ecu=ecu_name, property_name=property_name, value=value))

    def attach(self, interface) -> None:
        self._interface = interface
        for name, ecu in self._ecu_map.items():
            ecu.enable_logging = self.enable_logging
            ecu.diagnostic_log_sink = self.diagnostic_log_sink
            for config in self._config_map.get(name, []):
                ecu.configure(config.property_name, config.value)
            interface.attach(ecu)

    def describe_loaded_configs(self) -> str:
        if not self._path_map:
            return "no ECU configs"
        items = "\n".join(f"- {name}={path}" for name, path in sorted(self._path_map.items()))
        count = len(self._path_map)
        label = "config" if count == 1 else "configs"
        return f"{count} ECU {label}:\n{items}"


class Monitor:
    _instance: Optional["Monitor"] = None

    def __init__(self) -> None:
        self._paths: Dict[str, float] = {}
        self._callbacks: Dict[str, Callable[[], None]] = {}
        self._lock = threading.Lock()
        self._use_watchdog = _WATCHDOG_AVAILABLE
        self._observer = None
        self._handler = None
        self._watched_dirs: set[str] = set()
        if self._use_watchdog:
            self._observer = Observer()
            self._handler = _WatchdogHandler(self)
            self._observer.start()
        else:
            self._thread = threading.Thread(target=self._watch_loop, daemon=True)
            self._thread.start()

    @classmethod
    def shared(cls) -> "Monitor":
        if cls._instance is None:
            cls._instance = Monitor()
        return cls._instance

    def add_path(self, path: str, callback: Callable[[], None]) -> None:
        real_path = os.path.realpath(path)
        mtime = os.path.getmtime(real_path)
        with self._lock:
            self._paths[real_path] = mtime
            self._callbacks[real_path] = callback
            if self._use_watchdog:
                self._ensure_watched(os.path.dirname(real_path))

    def _watch_loop(self) -> None:
        while True:
            time.sleep(1.0)
            with self._lock:
                paths = list(self._paths.keys())
            for path in paths:
                self._maybe_reload(path)

    def _maybe_reload(self, path: str) -> None:
        try:
            mtime = os.path.getmtime(path)
        except OSError:
            return
        callback = None
        with self._lock:
            last_mtime = self._paths.get(path)
            if last_mtime is None or mtime == last_mtime:
                return
            self._paths[path] = mtime
            callback = self._callbacks.get(path)
        if callback:
            callback()

    def _handle_watchdog_event(self, path: str) -> None:
        self._maybe_reload(os.path.realpath(path))

    def _ensure_watched(self, directory: str) -> None:
        if directory in self._watched_dirs or self._observer is None:
            return
        self._observer.schedule(self._handler, directory, recursive=False)
        self._watched_dirs.add(directory)


if _WATCHDOG_AVAILABLE:

    class _WatchdogHandler(FileSystemEventHandler):
        def __init__(self, monitor: Monitor) -> None:
            self._monitor = monitor

        def on_modified(self, event) -> None:
            if event.is_directory:
                return
            self._monitor._handle_watchdog_event(event.src_path)

        def on_created(self, event) -> None:
            if event.is_directory:
                return
            self._monitor._handle_watchdog_event(event.src_path)

        def on_moved(self, event) -> None:
            if event.is_directory:
                return
            self._monitor._handle_watchdog_event(event.dest_path)
