from __future__ import annotations  # Postponed annotations for forward references and speed

import os  # For environment variables and filesystem checks
import pathlib  # Path handling across platforms
from dataclasses import dataclass, field  # Lightweight typed containers
from typing import Any, Dict, Optional  # Type hints for clarity

import yaml  # Safe YAML parsing for configuration files


@dataclass
class FlashConfig:
    """Flashing-related configuration.

    enabled: Whether to trigger flashing at session start.
    hex_path: Path to the firmware HEX file (if any).
    """

    enabled: bool = False  # Off by default
    hex_path: Optional[str] = None  # No default file path


@dataclass
class InterfaceConfig:
    """LIN interface configuration.

    type: Adapter type name: "mock" for the simulated adapter, "babylin" for real hardware via SDK.
    channel: Channel index to use (0-based in most SDKs); default chosen by project convention.
    bitrate: Informational; typically SDF/schedule defines effective bitrate for BabyLIN.
    dll_path: Legacy/optional pointer to vendor DLLs when using ctypes (not used by SDK wrapper).
    node_name: Optional friendly name for display/logging.
    func_names: Legacy mapping for ctypes function names; ignored by SDK wrapper.
    sdf_path: Path to the SDF to load on connect (BabyLIN only).
    schedule_nr: Schedule index to start after connect (BabyLIN only).
    """

    type: str = "mock"  # "mock" or "babylin"
    channel: int = 1  # Default channel index (project-specific default)
    bitrate: int = 19200  # Typical LIN bitrate; SDF may override
    dll_path: Optional[str] = None  # Legacy ctypes option; not used with SDK wrapper
    node_name: Optional[str] = None  # Optional label for node/adapter
    func_names: Dict[str, str] = field(default_factory=dict)  # Legacy ctypes mapping; safe to leave empty
    # SDK wrapper options
    sdf_path: Optional[str] = None  # Path to SDF file to load (BabyLIN)
    schedule_nr: int = 0  # Schedule number to start after connect (BabyLIN)


@dataclass
class EcuTestConfig:
    """Top-level, fully-typed configuration for the framework.

    interface: Settings for LIN communication (mock or BabyLIN).
    flash: Optional flashing behavior configuration.
    """

    interface: InterfaceConfig = field(default_factory=InterfaceConfig)
    flash: FlashConfig = field(default_factory=FlashConfig)
    # Serial power supply (e.g., Owon) configuration
    # Test code can rely on these values to interact with PSU if enabled
    power_supply: "PowerSupplyConfig" = field(default_factory=lambda: PowerSupplyConfig())


@dataclass
class PowerSupplyConfig:
    """Serial power supply configuration (e.g., Owon PSU).

    enabled: Whether PSU tests/features should be active.
    port: Serial device (e.g., COM4 on Windows, /dev/ttyUSB0 on Linux).
    baudrate/timeout/eol: Basic line settings; eol often "\n" or "\r\n".
    parity: One of "N", "E", "O".
    stopbits: 1 or 2.
    xonxoff/rtscts/dsrdtr: Flow control flags.
    idn_substr: Optional substring to assert in *IDN? responses.
    do_set/set_voltage/set_current: Optional demo/test actions.
    """

    enabled: bool = False
    port: Optional[str] = None
    baudrate: int = 115200
    timeout: float = 1.0
    eol: str = "\n"
    parity: str = "N"
    stopbits: float = 1.0
    xonxoff: bool = False
    rtscts: bool = False
    dsrdtr: bool = False
    idn_substr: Optional[str] = None
    do_set: bool = False
    set_voltage: float = 1.0
    set_current: float = 0.1


DEFAULT_CONFIG_RELATIVE = pathlib.Path("config") / "test_config.yaml"  # Default config path relative to repo root
ENV_CONFIG_PATH = "ECU_TESTS_CONFIG"  # Env var to override config file location


def _deep_update(base: Dict[str, Any], updates: Dict[str, Any]) -> Dict[str, Any]:
    """Recursively merge dict 'updates' into dict 'base'.

    - Nested dicts are merged by key
    - Scalars/collections at any level are replaced entirely
    - Mutation occurs in-place on 'base' and the same object is returned
    """
    for k, v in updates.items():  # Iterate all update keys
        if isinstance(v, dict) and isinstance(base.get(k), dict):  # Both sides dict → recurse
            base[k] = _deep_update(base[k], v)
        else:  # Otherwise replace
            base[k] = v
    return base  # Return the mutated base for chaining


def _to_dataclass(cfg: Dict[str, Any]) -> EcuTestConfig:
    """Convert a merged plain dict config into strongly-typed dataclasses.

    Defensive casting is used to ensure correct types even if YAML contains strings.
    """
    iface = cfg.get("interface", {})  # Sub-config for interface
    flash = cfg.get("flash", {})  # Sub-config for flashing
    psu = cfg.get("power_supply", {})  # Sub-config for power supply
    return EcuTestConfig(
        interface=InterfaceConfig(
            type=str(iface.get("type", "mock")).lower(),  # Normalize to lowercase
            channel=int(iface.get("channel", 1)),  # Coerce to int
            bitrate=int(iface.get("bitrate", 19200)),  # Coerce to int
            dll_path=iface.get("dll_path"),  # Optional legacy field
            node_name=iface.get("node_name"),  # Optional friendly name
            func_names=dict(iface.get("func_names", {}) or {}),  # Ensure a dict
            sdf_path=iface.get("sdf_path"),  # Optional SDF path
            schedule_nr=int(iface.get("schedule_nr", 0)),  # Coerce to int
        ),
        flash=FlashConfig(
            enabled=bool(flash.get("enabled", False)),  # Coerce to bool
            hex_path=flash.get("hex_path"),  # Optional hex path
        ),
        power_supply=PowerSupplyConfig(
            enabled=bool(psu.get("enabled", False)),
            port=psu.get("port"),
            baudrate=int(psu.get("baudrate", 115200)),
            timeout=float(psu.get("timeout", 1.0)),
            eol=str(psu.get("eol", "\n")),
            parity=str(psu.get("parity", "N")),
            stopbits=float(psu.get("stopbits", 1.0)),
            xonxoff=bool(psu.get("xonxoff", False)),
            rtscts=bool(psu.get("rtscts", False)),
            dsrdtr=bool(psu.get("dsrdtr", False)),
            idn_substr=psu.get("idn_substr"),
            do_set=bool(psu.get("do_set", False)),
            set_voltage=float(psu.get("set_voltage", 1.0)),
            set_current=float(psu.get("set_current", 0.1)),
        ),
    )


def load_config(workspace_root: Optional[str] = None, overrides: Optional[Dict[str, Any]] = None) -> EcuTestConfig:
    """Load configuration from YAML file, environment, overrides, or defaults.

    Precedence (highest to lowest):
    1. in-memory 'overrides' dict
    2. YAML file specified by env var ECU_TESTS_CONFIG
    3. YAML at ./config/test_config.yaml (relative to workspace_root)
    4. built-in defaults in this function
    """
    # Start with built-in defaults; minimal, safe baseline
    base: Dict[str, Any] = {
        "interface": {
            "type": "mock",  # mock by default for developer friendliness
            "channel": 1,
            "bitrate": 19200,
        },
        "flash": {
            "enabled": False,
            "hex_path": None,
        },
        "power_supply": {
            "enabled": False,
            "port": None,
            "baudrate": 115200,
            "timeout": 1.0,
            "eol": "\n",
            "parity": "N",
            "stopbits": 1.0,
            "xonxoff": False,
            "rtscts": False,
            "dsrdtr": False,
            "idn_substr": None,
            "do_set": False,
            "set_voltage": 1.0,
            "set_current": 0.1,
        },
    }

    cfg_path: Optional[pathlib.Path] = None  # Resolved configuration file path

    # 2) Environment variable can point to any YAML file
    env_path = os.getenv(ENV_CONFIG_PATH)
    if env_path:
        candidate = pathlib.Path(env_path)
        if candidate.is_file():  # Only accept existing files
            cfg_path = candidate

    # 3) Fallback to default path under the provided workspace root
    if cfg_path is None and workspace_root:
        candidate = pathlib.Path(workspace_root) / DEFAULT_CONFIG_RELATIVE
        if candidate.is_file():
            cfg_path = candidate

    # Load YAML file if we have one
    if cfg_path and cfg_path.is_file():
        with open(cfg_path, "r", encoding="utf-8") as f:
            file_cfg = yaml.safe_load(f) or {}  # Parse YAML safely; empty → {}
            if isinstance(file_cfg, dict):  # Only merge dicts
                _deep_update(base, file_cfg)

    # Optionally merge a dedicated PSU YAML if present (or env var path)
    # This allows users to keep sensitive or machine-specific serial settings separate
    psu_env = os.getenv("OWON_PSU_CONFIG")
    psu_default = None
    if workspace_root:
        candidate = pathlib.Path(workspace_root) / "config" / "owon_psu.yaml"
        if candidate.is_file():
            psu_default = candidate
    psu_path: Optional[pathlib.Path] = pathlib.Path(psu_env) if psu_env else psu_default
    if psu_path and psu_path.is_file():
        with open(psu_path, "r", encoding="utf-8") as f:
            psu_cfg = yaml.safe_load(f) or {}
            if isinstance(psu_cfg, dict):
                base.setdefault("power_supply", {})
                # Merge PSU YAML into power_supply section
                base["power_supply"] = _deep_update(base["power_supply"], psu_cfg)

    # 1) In-memory overrides always win
    if overrides:
        _deep_update(base, overrides)

    # Convert to typed dataclasses for ergonomic downstream usage
    return _to_dataclass(base)
