"""I/O helpers that build :class:`mgf.hrb.identity.Snapshot` from
sysfs — FEEDBACK paper SDK-07.

``mgf.hrb.identity`` is pure logic (no I/O) so consumers can score
candidate snapshots against captured ones without the broker, the
USB worker, or even a real device. The identity comparator is the
ONE piece of mgf-hrb's value that's adoptable today — but until the
USB worker (USB-01 / USB-02) ships, ``Client.inspect`` can't
populate the identifying fields, and every consumer ends up
reinventing the same 30-50 lines of sysfs parsing.

This module fills that gap: a single function that reads the
identifying fields straight from ``/sys/bus/usb/devices/<NAME>/``
and returns a ready-to-compare ``Snapshot``. Stdlib only (no Zig,
no broker, no third-party dep). The boundary is clear — I/O lives
here; ``identity.py`` stays pure.

The mapping from sysfs files to ``Snapshot`` fields:

  ===============  ===================================================
  Field            sysfs source
  ===============  ===================================================
  ``vid``          ``idVendor``         (hex string → int)
  ``pid``          ``idProduct``        (hex string → int)
  ``serial_number``  ``serial``         (text, optional)
  ``bus``          ``busnum``           (decimal string → int)
  ``port_chain``   parsed from ``devpath`` — sysfs's per-bus port
                     path (e.g. ``"1.4.2"`` → ``(1, 4, 2)``)
  ``urn``          synthesised from ``bus`` + ``port_chain`` if
                     not supplied
  ``identity_class``  ``INTRINSIC`` when a non-empty serial reads;
                     ``TOPOLOGICAL`` when serial is absent (the
                     iFig DFU case)
  ``captured_at_ms``  ``mgf.hrb.identity.now_ms()`` if not supplied
  ===============  ===================================================
"""

from __future__ import annotations

from pathlib import Path
from typing import TYPE_CHECKING

from mgf.hrb.domain import IdentityClass
from mgf.hrb.identity import Snapshot, now_ms

if TYPE_CHECKING:
    from os import PathLike

__all__ = ["snapshot_from_sysfs"]


def _read_optional_text(path: Path) -> str | None:
    """Read text from sysfs, stripping the trailing newline.

    Returns ``None`` if the file is absent or unreadable — every
    sysfs attribute file we touch is optional (a fresh USB device
    may genuinely not expose ``serial``, an interface node may
    lack a few of these). Read errors propagate as ``OSError``
    only for the *required* fields (idVendor / idProduct /
    busnum / devpath); the helper raises explicitly there.
    """
    try:
        return path.read_text(encoding="utf-8", errors="replace").strip()
    except (OSError, FileNotFoundError):
        return None


def _read_required_text(path: Path) -> str:
    """Read text from a sysfs attribute that MUST exist for the
    helper to return a usable Snapshot. Raises a clear
    ``FileNotFoundError`` if absent — the caller is responsible
    for checking the path is a real USB-device entry."""
    if not path.exists():
        raise FileNotFoundError(
            f"required sysfs attribute missing: {path}. "
            "snapshot_from_sysfs() expects a USB device directory "
            "(e.g. /sys/bus/usb/devices/1-2)."
        )
    return path.read_text(encoding="utf-8").strip()


def _parse_hex(text: str) -> int:
    """Parse a sysfs hex value (e.g. ``"05ac"``) to int. sysfs
    emits these as 4-hex-digit no-prefix strings."""
    return int(text, 16)


def _parse_port_chain(devpath: str) -> tuple[int, ...]:
    """Parse sysfs ``devpath`` into a port-chain tuple.

    sysfs ``devpath`` for a USB device is the per-bus port path —
    e.g. ``"4.2"`` for a device plugged into port 2 of a hub on
    port 4 of the host's root hub. The root hub itself reports
    ``"0"``. We tolerate either dot or dash separators (some
    older kernels used dashes).

    Returns ``(0,)`` for the root hub or a malformed input rather
    than raising — the comparator handles empty / single-element
    port chains correctly.
    """
    # Some kernels emit "1-4.2" (the bus prefix glued); strip it
    # if present. The bus part is in busnum, not devpath.
    if "-" in devpath:
        # Split on the FIRST dash; the part before is the bus, after
        # is the per-bus path. devpath itself is per-bus, but tolerate
        # the prefixed form.
        _bus_prefix, _, rest = devpath.partition("-")
        # If the prefix looks like a bus number (purely digits),
        # treat `rest` as the chain; otherwise fall back.
        if _bus_prefix.isdigit():
            devpath = rest
    parts: list[int] = []
    for tok in devpath.replace("-", ".").split("."):
        tok = tok.strip()
        if not tok:
            continue
        try:
            parts.append(int(tok))
        except ValueError:
            # Malformed segment — skip rather than crash. The
            # comparator's port_chain weight tolerates noisy data.
            continue
    if not parts:
        return (0,)
    return tuple(parts)


def _synth_urn(bus: int, port_chain: tuple[int, ...]) -> str:
    """Build a stable URN from sysfs identifying fields.

    This is deterministic + matches the shape the broker's USB
    worker will emit (when it ships per USB-02). Shape:

        hw:/usb/bus<N>/port-<a>-<b>-<c>/dev

    Where the port-chain segments are dash-joined. For the root
    hub (devpath ``0`` or empty), produces ``hw:/usb/bus<N>/dev``.
    Note: this is a *device-level* URN; interface-level URNs
    (``/if0``, ``/if1``) require the interface number, which the
    helper doesn't synthesise — callers needing interface URNs
    pass them explicitly via the ``urn`` kwarg.
    """
    chain_segment = ""
    if port_chain and port_chain != (0,):
        chain_segment = "/port-" + "-".join(str(p) for p in port_chain)
    return f"hw:/usb/bus{bus}{chain_segment}/dev"


def snapshot_from_sysfs(
    syspath: str | PathLike[str],
    *,
    urn: str | None = None,
    captured_at_ms: int | None = None,
) -> Snapshot:
    """Build a :class:`Snapshot` from a sysfs USB-device directory.

    The directory is the kind of path ``udevadm info -q path -n
    /dev/bus/usb/...`` returns, e.g.
    ``/sys/bus/usb/devices/1-4.2``. The helper reads the required
    sysfs attributes (``idVendor``, ``idProduct``, ``busnum``,
    ``devpath``) and the optional ``serial`` to populate the
    Snapshot's identifying fields.

    Args:
        syspath: filesystem path to a USB-device sysfs directory.
            Required.
        urn: explicit URN to attach. If ``None``, the helper
            synthesises one from bus + port_chain (see
            :func:`_synth_urn`). Callers that need interface-
            level URNs (e.g. ``.../dev/if0``) should pass them
            explicitly.
        captured_at_ms: timestamp for the snapshot. If ``None``,
            uses :func:`now_ms`.

    Returns:
        A fresh frozen :class:`Snapshot`. Pass to
        :func:`mgf.hrb.identity.same_likely` to compare against
        another snapshot taken later.

    Raises:
        FileNotFoundError: if any required sysfs attribute is
            absent (the path is probably not a USB-device
            directory).
        ValueError: if ``idVendor`` / ``idProduct`` / ``busnum``
            are present but unparseable (corrupt sysfs — bug
            or hardware fault).

    Example:
        >>> from mgf.hrb.sysfs import snapshot_from_sysfs
        >>> from mgf.hrb.identity import same_likely
        >>> before = snapshot_from_sysfs("/sys/bus/usb/devices/1-4.2")
        >>> # ... mode transition ...
        >>> after = snapshot_from_sysfs("/sys/bus/usb/devices/1-4.2")
        >>> if same_likely(before, after):
        ...     print("same physical device")
    """
    root = Path(syspath)

    vid_text = _read_required_text(root / "idVendor")
    pid_text = _read_required_text(root / "idProduct")
    busnum_text = _read_required_text(root / "busnum")
    devpath_text = _read_required_text(root / "devpath")

    try:
        vid = _parse_hex(vid_text)
        pid = _parse_hex(pid_text)
        bus = int(busnum_text)
    except ValueError as exc:
        raise ValueError(
            f"sysfs attribute under {root} is unparseable: "
            f"idVendor={vid_text!r}, idProduct={pid_text!r}, "
            f"busnum={busnum_text!r}"
        ) from exc

    port_chain = _parse_port_chain(devpath_text)
    serial = _read_optional_text(root / "serial") or None
    # Treat empty-string serial as "no serial" — the iFig DFU
    # case where the serial attribute exists but the device
    # didn't volunteer one for this mode.
    if serial == "":
        serial = None

    # IdentityClass discipline matches identity.zig: INTRINSIC
    # when a non-empty serial is present (the device identifies
    # itself stably), TOPOLOGICAL when it isn't (port location is
    # the only handle we have).
    identity_class = IdentityClass.INTRINSIC if serial else IdentityClass.TOPOLOGICAL

    return Snapshot(
        urn=urn if urn is not None else _synth_urn(bus, port_chain),
        identity_class=identity_class,
        vid=vid,
        pid=pid,
        serial_number=serial,
        bus=bus,
        port_chain=port_chain,
        captured_at_ms=(captured_at_ms if captured_at_ms is not None else now_ms()),
    )
