"""LAN7800 EEPROM image model.

Pure, hardware-free representation of a LAN7800 EEPROM image (a raw .bin of
256 or 512 bytes) plus typed accessors for the fields the Core v1 tool edits:
signature, MAC, VID/PID/bcdDevice (across the SS/HS/FS descriptor blocks),
string descriptors, and serial.

Layout facts come from docs/01-eeprom-mechanism.md (datasheet DS00001992G
Table 10-2 + lan78xx.c). Anything the doc flagged "VERIFY against a real dump"
is centralized here as a named constant with a comment so it can be corrected
in one place once a physical adapter is available.
"""
from __future__ import annotations

from dataclasses import dataclass, field
from typing import Optional

# ---- constants (authoritative: lan78xx.c + datasheet Table 10-2) ----------
EEPROM_MAGIC = 0x78A5          # ethtool -E magic for LAN78xx
SIG_OFFSET = 0x00
SIG_VALUE = 0xA5              # byte0; device ignores EEPROM if != 0xA5
MAC_OFFSET = 0x01
MAC_LEN = 6
MICROCHIP_OUI = (0x00, 0x80, 0x0F)
MAX_EEPROM_SIZE = 512
SIZES = (256, 512)

# String descriptor pointer table: 5 entries at 0x25..0x2E, each (len, word_off).
STR_TABLE_OFFSET = 0x25
STR_NAMES = ("manufacturer", "product", "serial", "configuration", "interface")

# Config Flags 0..3: four 32-bit LE words at 0x13/0x17/0x1B/0x1F (docs/01).
CONFIG_FLAGS_OFFSET = 0x13
CONFIG_FLAGS_COUNT = 4

# Device-descriptor block pointers (word units). VID/PID/bcdDevice live inside.
# LAN7800 exposes SuperSpeed + HighSpeed + FullSpeed descriptor blocks.
DESC_PTRS = {"ss": 0x31, "hs": 0x35, "fs": 0x39}
# offsets within a standard USB device descriptor:
DD_VID = 8      # idVendor  (LE u16)
DD_PID = 10     # idProduct (LE u16)
DD_BCDDEV = 12  # bcdDevice (LE u16)
DD_LEN = 18     # bLength of a device descriptor (0x12)

# LAN7800 identity guard — this tool is LAN7800 ONLY.
LAN7800_VID = 0x0424
LAN7800_PID = 0x7800

# MAC octet order in EEPROM. RESOLVED against real hardware (2026-07-30): the
# lan78xx driver reads EEPROM offset 0x01 as addr[0], i.e. NATURAL order, so
# MAC AA:BB:CC:DD:EE:FF is stored AA BB CC DD EE FF (0x01=AA ... 0x06=FF).
# The datasheet doc's "little-endian FF EE DD CC BB AA" note was WRONG: entering
# the device's known MAC 00:80:0F:78:00:00 under LE produced the byte-reverse
# 00:00:78:0F:80:00 on the wire. Natural order is confirmed correct.
MAC_LITTLE_ENDIAN = False


class EepromError(Exception):
    pass


@dataclass
class DescriptorView:
    kind: str            # ss / hs / fs
    ptr_word: int        # raw pointer value (word units)
    byte_offset: int     # resolved byte offset of the descriptor block
    present: bool
    looks_valid: bool    # bLength==0x12 and bDescriptorType==0x01
    vid: Optional[int] = None
    pid: Optional[int] = None
    bcd_device: Optional[int] = None


@dataclass
class StringView:
    name: str
    length_field: int
    word_offset: int
    byte_offset: int
    present: bool
    value: Optional[str] = None


@dataclass
class Eeprom:
    data: bytearray

    # ---- construction ------------------------------------------------------
    @classmethod
    def from_bytes(cls, raw: bytes) -> "Eeprom":
        if len(raw) not in SIZES:
            # tolerate any length up to 512 by padding to the nearest known size
            if len(raw) == 0 or len(raw) > MAX_EEPROM_SIZE:
                raise EepromError(
                    f"unexpected EEPROM length {len(raw)}; expected one of {SIZES}")
            size = 256 if len(raw) <= 256 else 512
            buf = bytearray(raw) + bytearray(size - len(raw))
            return cls(buf)
        return cls(bytearray(raw))

    @classmethod
    def blank(cls, size: int = MAX_EEPROM_SIZE) -> "Eeprom":
        if size not in SIZES:
            raise EepromError(f"size must be one of {SIZES}")
        return cls(bytearray(size))

    @classmethod
    def default_template(cls, size: int = MAX_EEPROM_SIZE) -> "Eeprom":
        """A sensible DEFAULT LAN7800 image: 0xA5 signature, a Microchip-OUI MAC
        placeholder (00:80:0F:00:00:00), and SS/HS/FS USB device-descriptor
        blocks carrying VID 0x0424 / PID 0x7800 / bcdDevice 0x0300. Strings are
        left blank. This is the single source of truth for the webapp's default
        pre-populated fields. Descriptor blocks sit at 0x60/0x80/0xA0, clear of
        the fixed LED/GPIO register offsets."""
        import struct
        e = cls.blank(size)
        e.set_signature()
        e.set_mac("00:80:0F:00:00:00")
        dd = struct.pack("<BBHBBBBHHHBBBB", 0x12, 0x01, 0x0300, 0xFF, 0x00, 0x00,
                         64, LAN7800_VID, LAN7800_PID, 0x0300, 0, 0, 0, 1)
        block = 0x60
        for kind in ("ss", "hs", "fs"):
            e.data[block:block + len(dd)] = dd
            e._put_u16le(DESC_PTRS[kind], block // 2)   # pointer in word units
            block += 24
        return e

    def to_bytes(self) -> bytes:
        return bytes(self.data)

    @property
    def size(self) -> int:
        return len(self.data)

    # ---- low-level helpers -------------------------------------------------
    def _u16le(self, off: int) -> int:
        if off + 1 >= self.size:
            raise EepromError(f"read u16 out of range at 0x{off:02X}")
        return self.data[off] | (self.data[off + 1] << 8)

    def _put_u16le(self, off: int, val: int) -> None:
        if off + 1 >= self.size:
            raise EepromError(f"write u16 out of range at 0x{off:02X}")
        self.data[off] = val & 0xFF
        self.data[off + 1] = (val >> 8) & 0xFF

    def _u32le(self, off: int) -> int:
        return (self.data[off] | (self.data[off + 1] << 8)
                | (self.data[off + 2] << 16) | (self.data[off + 3] << 24))

    def _put_u32le(self, off: int, val: int) -> None:
        for i in range(4):
            self.data[off + i] = (val >> (8 * i)) & 0xFF

    # ---- config flags ------------------------------------------------------
    def config_flags(self) -> list[int]:
        return [self._u32le(CONFIG_FLAGS_OFFSET + 4 * i)
                for i in range(CONFIG_FLAGS_COUNT)]

    def set_config_flag(self, i: int, val: int) -> None:
        if not 0 <= i < CONFIG_FLAGS_COUNT:
            raise EepromError(f"config flag index must be 0..{CONFIG_FLAGS_COUNT-1}")
        if not 0 <= val <= 0xFFFFFFFF:
            raise EepromError("config flag must be a 32-bit value")
        self._put_u32le(CONFIG_FLAGS_OFFSET + 4 * i, val)

    # ---- signature ---------------------------------------------------------
    @property
    def signature(self) -> int:
        return self.data[SIG_OFFSET]

    @property
    def has_signature(self) -> bool:
        return self.signature == SIG_VALUE

    def set_signature(self) -> None:
        self.data[SIG_OFFSET] = SIG_VALUE

    # ---- MAC ---------------------------------------------------------------
    @property
    def mac(self) -> str:
        octets = self.data[MAC_OFFSET:MAC_OFFSET + MAC_LEN]
        if MAC_LITTLE_ENDIAN:
            octets = octets[::-1]
        return ":".join(f"{b:02X}" for b in octets)

    def set_mac(self, mac: str) -> None:
        octets = parse_mac(mac)
        stored = octets[::-1] if MAC_LITTLE_ENDIAN else octets
        self.data[MAC_OFFSET:MAC_OFFSET + MAC_LEN] = bytes(stored)

    # ---- descriptor blocks (VID/PID/bcdDevice) -----------------------------
    def descriptor(self, kind: str) -> DescriptorView:
        if kind not in DESC_PTRS:
            raise EepromError(f"unknown descriptor kind {kind!r}")
        ptr_off = DESC_PTRS[kind]
        ptr_word = self._u16le(ptr_off)
        byte_off = ptr_word * 2
        present = ptr_word != 0 and byte_off + DD_LEN <= self.size
        view = DescriptorView(kind=kind, ptr_word=ptr_word, byte_offset=byte_off,
                              present=present, looks_valid=False)
        if present:
            b_len = self.data[byte_off]
            b_type = self.data[byte_off + 1]
            view.looks_valid = (b_len == DD_LEN and b_type == 0x01)
            view.vid = self._u16le(byte_off + DD_VID)
            view.pid = self._u16le(byte_off + DD_PID)
            view.bcd_device = self._u16le(byte_off + DD_BCDDEV)
        return view

    def descriptors(self) -> list[DescriptorView]:
        return [self.descriptor(k) for k in DESC_PTRS]

    def _set_desc_field(self, field_off: int, val: int) -> list[str]:
        """Write a u16 field into every present descriptor block. Returns the
        list of blocks touched (kinds)."""
        touched = []
        for d in self.descriptors():
            if d.present:
                self._put_u16le(d.byte_offset + field_off, val)
                touched.append(d.kind)
        if not touched:
            raise EepromError(
                "no USB device-descriptor block present to write into; "
                "load a base image that already has descriptor pointers")
        return touched

    def set_vid(self, vid: int) -> list[str]:
        _check_u16(vid, "VID")
        return self._set_desc_field(DD_VID, vid)

    def set_pid(self, pid: int) -> list[str]:
        _check_u16(pid, "PID")
        return self._set_desc_field(DD_PID, pid)

    def set_bcd_device(self, bcd: int) -> list[str]:
        _check_u16(bcd, "bcdDevice")
        return self._set_desc_field(DD_BCDDEV, bcd)

    # ---- string descriptors ------------------------------------------------
    def string(self, name: str) -> StringView:
        if name not in STR_NAMES:
            raise EepromError(f"unknown string {name!r}; one of {STR_NAMES}")
        idx = STR_NAMES.index(name)
        entry = STR_TABLE_OFFSET + idx * 2
        length_field = self.data[entry]
        word_off = self.data[entry + 1]
        byte_off = word_off * 2
        present = length_field != 0 and word_off != 0 and byte_off < self.size
        sv = StringView(name=name, length_field=length_field, word_offset=word_off,
                        byte_offset=byte_off, present=present)
        if present:
            sv.value = self._read_usb_string(byte_off)
        return sv

    def strings(self) -> list[StringView]:
        return [self.string(n) for n in STR_NAMES]

    def _read_usb_string(self, off: int) -> Optional[str]:
        # USB string descriptor: bLength, bDescriptorType(0x03), UTF-16LE payload
        if off + 1 >= self.size:
            return None
        b_len = self.data[off]
        b_type = self.data[off + 1]
        if b_type != 0x03 or b_len < 2 or off + b_len > self.size:
            return None
        payload = self.data[off + 2:off + b_len]
        try:
            return payload.decode("utf-16-le", errors="replace")
        except Exception:
            return None

    def set_string_inplace(self, name: str, value: str) -> None:
        """Overwrite an existing string descriptor IN PLACE.

        v1 constraint: the new UTF-16LE payload must fit within the existing
        descriptor's byte length (we do not relocate/repack the string heap in
        v1 — that is an ADVANCED item). Shorter strings are zero-padded and
        bLength is updated. Raises if it would not fit or the string is absent.
        """
        sv = self.string(name)
        if not sv.present:
            raise EepromError(
                f"string {name!r} is absent in this image; v1 can edit existing "
                f"strings in place but not allocate new ones (ADVANCED)")
        encoded = value.encode("utf-16-le")
        capacity = self.data[sv.byte_offset]  # existing bLength (incl. 2-byte header)
        new_len = 2 + len(encoded)
        if new_len > capacity:
            raise EepromError(
                f"string {name!r}: new value needs {new_len} bytes but the "
                f"existing slot holds {capacity}; in-place edit only in v1")
        off = sv.byte_offset
        self.data[off] = new_len
        self.data[off + 1] = 0x03
        self.data[off + 2:off + 2 + len(encoded)] = encoded
        # zero any leftover tail within the old slot
        for i in range(off + new_len, off + capacity):
            self.data[i] = 0x00


# ---- module helpers -------------------------------------------------------
def parse_mac(mac: str) -> list[int]:
    parts = mac.replace("-", ":").split(":")
    if len(parts) != 6:
        raise EepromError(f"invalid MAC {mac!r}: need 6 octets")
    try:
        octets = [int(p, 16) for p in parts]
    except ValueError:
        raise EepromError(f"invalid MAC {mac!r}: non-hex octet")
    if any(o < 0 or o > 0xFF for o in octets):
        raise EepromError(f"invalid MAC {mac!r}: octet out of range")
    return octets


def parse_u16(text) -> int:
    if isinstance(text, int):
        val = text
    else:
        t = str(text).strip().lower()
        try:
            val = int(t, 16) if t.startswith("0x") else int(t, 0)
        except ValueError:
            raise EepromError(
                f"invalid 16-bit value {text!r}: expected decimal or 0x-hex")
    _check_u16(val, "value")
    return val


def _check_u16(val: int, label: str) -> None:
    if not isinstance(val, int) or val < 0 or val > 0xFFFF:
        raise EepromError(f"{label} must be a 16-bit value (0..0xFFFF), got {val!r}")
