#!/usr/bin/env python3
"""Generate tests/hardware/_generated/lin_api.py from an LDF.

Reads an LDF via ldfparser, emits a single Python file containing:

- One ``IntEnum`` per ``Signal_encoding_types`` block that has logical values
- One class per pure-physical encoding type with PHY_MIN / PHY_MAX / SCALE / OFFSET / UNIT
- One class per frame with NAME / FRAME_ID / LENGTH / PUBLISHER / SIGNALS /
  SIGNAL_LAYOUT and classmethods ``send`` / ``receive`` / ``read_signal``
  that delegate to a ``FrameIO`` passed in by the caller
- A ``SIGNAL_ENCODINGS`` dict mapping signal name → encoding class

Generation rules and the rationale for this layer live in
``docs/22_generated_lin_api.md``.

Usage:

    python scripts/gen_lin_api.py vendor/4SEVEN_color_lib_test.ldf
    python scripts/gen_lin_api.py <ldf> --out path/to/out.py
"""
from __future__ import annotations

import argparse
import hashlib
import re
from pathlib import Path

from ldfparser import parse_ldf


GENERATOR_VERSION = 1


# --- name normalisation ----------------------------------------------------


def _pascal(name: str) -> str:
    """``ALM_Req_A`` -> ``AlmReqA``; ``LED_State`` -> ``LedState``.

    Names without underscores pass through unchanged so already-PascalCase
    identifiers like ``ColorConfigFrameRed`` survive intact.
    """
    if "_" not in name:
        return name
    return "".join(p[:1].upper() + p[1:].lower() for p in name.split("_") if p)


def _enum_member(info: str) -> str:
    """LDF info text -> enum member name.

    Steps: drop anything after the first ``(`` (parenthetical clarifications
    that bloat the name), uppercase, collapse non-identifier runs to ``_``,
    strip leading/trailing ``_``. Empty results fall back to ``VALUE``; names
    starting with a digit get a ``V_`` prefix.
    """
    head = info.split("(", 1)[0]
    s = re.sub(r"[^A-Za-z0-9]+", "_", head).strip("_").upper()
    if not s:
        return "VALUE"
    if s[0].isdigit():
        return f"V_{s}"
    return s


def _suffix_collisions(pairs):
    """If two entries share a member name, suffix all colliding entries with ``_0X<hex>``."""
    counts = {}
    for name, _ in pairs:
        counts[name] = counts.get(name, 0) + 1
    out = []
    for name, value in pairs:
        if counts[name] > 1:
            out.append((f"{name}_0X{value:02X}", value))
        else:
            out.append((name, value))
    return out


# --- ldfparser duck-typing -------------------------------------------------
# Avoid importing internal ldfparser.encoding classes so generator-side
# imports don't break across ldfparser revisions.


def _is_logical(converter) -> bool:
    return hasattr(converter, "info") and hasattr(converter, "phy_value")


def _is_physical(converter) -> bool:
    return hasattr(converter, "scale") and hasattr(converter, "offset")


def _encoding_kind(enc) -> str:
    convs = enc.get_converters()
    has_log = any(_is_logical(c) for c in convs)
    has_phy = any(_is_physical(c) for c in convs)
    if has_log and has_phy:
        return "mixed"
    if has_log:
        return "logical"
    return "physical"


# --- emitters --------------------------------------------------------------


def emit_enum(enc) -> str:
    convs = enc.get_converters()
    pairs = [
        (_enum_member(c.info), int(c.phy_value))
        for c in convs if _is_logical(c)
    ]
    pairs.sort(key=lambda kv: kv[1])
    pairs = _suffix_collisions(pairs)

    physical_comments = [
        f"    # physical_value {p.phy_min}..{p.phy_max} scale={p.scale} offset={p.offset} unit={p.unit!r} — pass int directly"
        for p in convs if _is_physical(p)
    ]

    suffix = " (logical + physical)" if physical_comments else ""
    lines = [
        f"class {_pascal(enc.name)}(IntEnum):",
        f'    """Signal_encoding_types.{enc.name}{suffix}"""',
    ]
    for name, value in pairs:
        lines.append(f"    {name} = 0x{value:02X}")
    lines.extend(physical_comments)
    return "\n".join(lines)


def emit_physical_class(enc) -> str:
    convs = enc.get_converters()
    phys = [c for c in convs if _is_physical(c)]
    p = phys[0]  # multiple physical ranges in one encoding are rare
    return "\n".join([
        f"class {_pascal(enc.name)}:",
        f'    """Signal_encoding_types.{enc.name} (physical)."""',
        f"    PHY_MIN = {p.phy_min}",
        f"    PHY_MAX = {p.phy_max}",
        f"    SCALE = {p.scale}",
        f"    OFFSET = {p.offset}",
        f"    UNIT = {p.unit!r}",
    ])


def emit_frame(frame) -> str:
    layout = sorted(frame.signal_map, key=lambda t: t[0])
    publisher_name = frame.publisher.name
    lines = [
        f"class {_pascal(frame.name)}:",
        f'    """LDF frame {frame.name} — published by {publisher_name}."""',
        f'    NAME = "{frame.name}"',
        f"    FRAME_ID = 0x{frame.frame_id:02X}",
        f"    LENGTH = {frame.length}",
        f'    PUBLISHER = "{publisher_name}"',
        "    SIGNALS: tuple[str, ...] = (",
    ]
    for _, sig in layout:
        lines.append(f'        "{sig.name}",')
    lines.append("    )")
    lines.append("    SIGNAL_LAYOUT: tuple[tuple[int, str, int], ...] = (")
    for offset, sig in layout:
        lines.append(f'        ({offset}, "{sig.name}", {sig.width}),')
    lines.append("    )")
    lines.extend([
        "",
        "    @classmethod",
        '    def send(cls, fio: "FrameIO", **signals) -> None:',
        "        fio.send(cls.NAME, **signals)",
        "",
        "    @classmethod",
        '    def receive(cls, fio: "FrameIO", timeout: float = 1.0):',
        "        return fio.receive(cls.NAME, timeout=timeout)",
        "",
        "    @classmethod",
        "    def read_signal(",
        '        cls, fio: "FrameIO", signal: str, *,',
        "        timeout: float = 1.0, default=None,",
        "    ):",
        "        return fio.read_signal(cls.NAME, signal, timeout=timeout, default=default)",
    ])
    return "\n".join(lines)


def emit_signal_encodings_map(ldf) -> str:
    pairs = []
    for sig in ldf.get_signals():
        enc = sig.encoding_type
        if enc is not None:
            pairs.append((sig.name, _pascal(enc.name)))
    pairs.sort()
    lines = ["SIGNAL_ENCODINGS: dict[str, type] = {"]
    for sig, enc in pairs:
        lines.append(f'    "{sig}": {enc},')
    lines.append("}")
    return "\n".join(lines)


# --- main ------------------------------------------------------------------


def render(ldf_path: Path) -> str:
    ldf = parse_ldf(str(ldf_path))
    src_hash = hashlib.sha256(ldf_path.read_bytes()).hexdigest()[:12]

    header = (
        f'"""AUTO-GENERATED from {ldf_path.name}\n'
        f'SHA256: {src_hash}\n'
        f'DO NOT EDIT — re-run: python scripts/gen_lin_api.py {ldf_path}\n'
        f'Generator version: {GENERATOR_VERSION}\n'
        f'"""'
    )
    imports = (
        "from __future__ import annotations\n"
        "\n"
        "from enum import IntEnum\n"
        "from typing import TYPE_CHECKING\n"
        "\n"
        "if TYPE_CHECKING:\n"
        "    from frame_io import FrameIO"
    )

    encoding_sections = []
    for enc in ldf.get_signal_encoding_types():
        kind = _encoding_kind(enc)
        if kind in ("logical", "mixed"):
            encoding_sections.append(emit_enum(enc))
        else:
            encoding_sections.append(emit_physical_class(enc))

    frame_sections = [emit_frame(f) for f in ldf.frames]

    parts = [
        header,
        imports,
        "# === Encoding types ========================================================",
        *encoding_sections,
        "# === Frames ================================================================",
        *frame_sections,
        "# === Signal → encoding map =================================================",
        emit_signal_encodings_map(ldf),
    ]
    return "\n\n\n".join(parts) + "\n"


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    parser.add_argument("ldf", type=Path, help="Path to the LDF file")
    parser.add_argument(
        "--out",
        type=Path,
        default=Path("tests/hardware/_generated/lin_api.py"),
        help="Output path (default: %(default)s)",
    )
    args = parser.parse_args()

    if not args.ldf.is_file():
        raise SystemExit(f"LDF not found: {args.ldf}")

    rendered = render(args.ldf)
    args.out.parent.mkdir(parents=True, exist_ok=True)
    args.out.write_text(rendered)

    ldf = parse_ldf(str(args.ldf))
    print(
        f"wrote {args.out}  "
        f"({len(ldf.frames)} frames, "
        f"{len(list(ldf.get_signal_encoding_types()))} encoding types)"
    )
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
