"""Thin wrapper over `ldfparser` for use in tests.

Loads an LDF (LIN Description File) and exposes per-frame `pack()` /
`unpack()` helpers plus a `frame_lengths()` map suitable for plugging
into the MUM adapter's `frame_lengths` argument.

Typical usage:

    from ecu_framework.lin.ldf import LdfDatabase

    db = LdfDatabase("./vendor/4SEVEN_color_lib_test.ldf")
    frame = db.frame("ALM_Req_A")
    payload = frame.pack(
        AmbLightColourRed=0xFF,
        AmbLightColourGreen=0xFF,
        AmbLightColourBlue=0xFF,
        AmbLightIntensity=0xFF,
        AmbLightLIDFrom=0x01,
        AmbLightLIDTo=0x01,
    )
    # → bytes(8); unspecified signals fall back to their LDF init_value.

    decoded = db.frame("ALM_Status").unpack(b"\\x07\\x00\\x00\\x00")
    # → {'ALMNadNo': 7, 'ALMVoltageStatus': 0, ...}

The wrapper uses `encode_raw` / `decode_raw` rather than `encode` / `decode`
so signal *encoding types* (logical/physical conversions) are bypassed —
tests work with raw integer values, which is what `LinFrame.data` carries.
If you need encoding-type interpretation, use `Frame.encode()` /
`Frame.decode()` (which delegate to the underlying ldfparser methods).
"""
from __future__ import annotations

from pathlib import Path
from typing import Any, Dict, List, Tuple, Union


class FrameNotFound(KeyError):
    """Raised when a frame name or ID isn't present in the loaded LDF."""


class Frame:
    """Lightweight wrapper around an `ldfparser` frame object.

    Exposes the attributes tests actually need (`id`, `name`, `length`,
    `signal_layout`) and `pack`/`unpack` helpers that work on raw bytes.
    """

    __slots__ = ("_raw",)

    def __init__(self, raw_frame: Any) -> None:
        self._raw = raw_frame

    @property
    def name(self) -> str:
        return str(self._raw.name)

    @property
    def id(self) -> int:
        return int(self._raw.frame_id)

    @property
    def length(self) -> int:
        return int(self._raw.length)

    def signal_layout(self) -> List[Tuple[int, str, int]]:
        """Return [(start_bit, signal_name, width_in_bits), ...]."""
        return [(int(off), s.name, int(s.width)) for off, s in self._raw.signal_map]

    def signal_names(self) -> List[str]:
        return [s.name for _, s in self._raw.signal_map]

    # ---- raw (integer) packing ------------------------------------------
    def pack(self, *args: Dict[str, int], **kwargs: int) -> bytes:
        """Encode signal values into the raw payload for this frame.

        Accepts either a single dict positional argument or keyword args:

            frame.pack(AmbLightColourRed=255, AmbLightColourGreen=128)
            frame.pack({"AmbLightColourRed": 255, "AmbLightColourGreen": 128})

        Signals not provided fall back to the `init_value` declared in the
        LDF (handled by ldfparser's `encode_raw`). Returns bytes of length
        `self.length`.
        """
        if args and kwargs:
            raise TypeError("pack() takes either a positional dict or kwargs, not both")
        if args:
            if len(args) != 1 or not isinstance(args[0], dict):
                raise TypeError("pack() positional argument must be a dict")
            values = dict(args[0])
        else:
            values = dict(kwargs)
        encoded = self._raw.encode_raw(values)
        return bytes(encoded)

    def unpack(self, data: Union[bytes, bytearray, list]) -> Dict[str, int]:
        """Decode raw bytes into a `{signal_name: int}` dict."""
        return dict(self._raw.decode_raw(bytes(data)))

    # ---- encoding-aware (logical/physical values) -----------------------
    def encode(self, values: Dict[str, Any]) -> bytes:
        """Encode using LDF encoding types (logical → numeric, physical scaling).

        Useful when you want to write 'Immediate color Update' instead of `0`.
        Falls back to ldfparser's `encode`.
        """
        encoded = self._raw.encode(values)
        return bytes(encoded)

    def decode(self, data: Union[bytes, bytearray, list]) -> Dict[str, Any]:
        """Decode using LDF encoding types (numeric → logical/physical)."""
        return dict(self._raw.decode(bytes(data)))

    def __repr__(self) -> str:
        return f"Frame(name={self.name!r}, id=0x{self.id:02X}, length={self.length})"


class LdfDatabase:
    """Load an LDF file and expose its frames in a test-friendly form."""

    def __init__(self, path: Union[str, Path]) -> None:
        # Lazy import keeps the framework importable on machines without ldfparser
        # — only `LdfDatabase` instantiation requires it.
        try:
            from ldfparser import parse_ldf  # type: ignore
        except Exception as e:
            raise RuntimeError(
                "ldfparser is not installed. Install it with: pip install ldfparser"
            ) from e

        self.path = Path(path)
        if not self.path.is_file():
            raise FileNotFoundError(f"LDF not found: {self.path}")
        self._raw = parse_ldf(str(self.path))

    @property
    def baudrate(self) -> int:
        return int(self._raw.baudrate)

    @property
    def protocol_version(self) -> str:
        return str(self._raw.protocol_version)

    def frame(self, key: Union[str, int]) -> Frame:
        """Look up a frame by name (str) or by frame_id (int)."""
        try:
            raw = self._raw.get_frame(key)
        except LookupError as e:
            raise FrameNotFound(f"Frame {key!r} not found in {self.path.name}") from e
        return Frame(raw)

    def frames(self) -> List[Frame]:
        """Return all unconditional frames (excludes diagnostic/event-triggered)."""
        return [Frame(rf) for rf in self._raw.frames]

    def frame_lengths(self) -> Dict[int, int]:
        """`{frame_id: length}` map suitable for `MumLinInterface(frame_lengths=...)`."""
        return {int(rf.frame_id): int(rf.length) for rf in self._raw.frames}

    def signal_names(self, frame_key: Union[str, int]) -> List[str]:
        """Convenience: list signal names for a given frame."""
        return self.frame(frame_key).signal_names()

    def __repr__(self) -> str:
        try:
            n = sum(1 for _ in self._raw.frames)
        except Exception:
            n = "?"
        return f"LdfDatabase(path={self.path!s}, frames={n})"


__all__ = ["LdfDatabase", "Frame", "FrameNotFound"]
