"""Runtime identity-continuity comparator — Python mirror of
``hrb-code/src/identity.zig`` (FEEDBACK paper MODEL-01).

The question this layer answers: a device drops off the bus and re-appears
with a flipped PID, a possibly-vanished serial, a rotated devnum (the
canonical iFig DFU sequence ``0x05ac:0x12ab`` → ``0x05ac:0x1281`` →
``0x05ac:0x1227``). *Is this the same physical unit I started with?*

The algorithm and weight table here MUST match ``identity.zig``'s
``sameLikelyExplained`` byte-for-byte. Per D-035 these mirrors will be
generated from the canonical Zig once the codegen lands; until then,
they're hand-maintained and the test in ``tests/unit/mgf/hrb/test_identity.py``
pins the iFig score numbers (11 / 13 / 15) against the spec, so any
drift between Python and Zig is caught.

This module is PURE LOGIC — no I/O, no syscalls — so consumers can
``snapshot()`` a Resource and compare against a future candidate without
the broker being involved.

What this module is NOT:
  * a cryptographic identity proof (a malicious device deliberately
    mimicking another scores LIKELY_SAME — that is a capability question,
    not an identity question);
  * deterministic across firmware updates that alter the iSerialNumber;
  * portable across hosts (port chain is a host property).

These non-goals are listed honestly per the design's fidelity discipline.
"""

from __future__ import annotations

import time
from collections.abc import Iterable
from dataclasses import dataclass, field
from enum import IntEnum
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from mgf.hrb.domain import IdentityClass, Resource

__all__ = [
    "MAX_PORT_DEPTH",
    "MAX_SERIAL_BYTES",
    "ComparatorOpts",
    "Confidence",
    "Contribution",
    "ScoreResult",
    "Snapshot",
    "now_ms",
    "same_likely",
    "same_likely_explained",
    "snapshot",
]

# Constants mirror identity.zig.
MAX_PORT_DEPTH: int = 8
MAX_SERIAL_BYTES: int = 64


class Confidence(IntEnum):
    """The comparator's verdict tier. Ordered weakest→strongest so consumer
    code can compare numerically — e.g.
    ``if conf >= Confidence.LIKELY_SAME: ...``."""

    DIFFERENT = 0
    AMBIGUOUS = 1
    LIKELY_SAME = 2
    SAME = 3


@dataclass(frozen=True, slots=True)
class Snapshot:
    """A captured identity at a moment in time. Backend-free; survives the
    device dropping off the bus; safe to outlive a debounce window."""

    urn: str
    identity_class: IdentityClass
    vid: int
    pid: int
    serial_number: str | None
    bus: int
    port_chain: tuple[int, ...]
    captured_at_ms: int

    def __post_init__(self) -> None:
        sn = self.serial_number
        if sn is not None and len(sn.encode("utf-8")) > MAX_SERIAL_BYTES:
            # G2: refuse to silently truncate. A snapshot built from an
            # oversized serial reports "no serial" rather than a partial
            # value — exactly as identity.zig's Snapshot.init does.
            object.__setattr__(self, "serial_number", None)
        if len(self.port_chain) > MAX_PORT_DEPTH:
            object.__setattr__(self, "port_chain", self.port_chain[:MAX_PORT_DEPTH])


@dataclass(frozen=True, slots=True)
class ComparatorOpts:
    """Per-call tunables — let the caller widen/narrow what counts as the
    'same physical unit' across a transition. Defaults match the iFig case
    that earned the weights (the Recovery↔DFU sequence in mgf-usb v0.2.5)."""

    tolerate_mode_change: bool = True
    tolerate_port_change: bool = False
    tolerate_missing_serial: bool = True
    port_debounce_ms: int = 1000


@dataclass(frozen=True, slots=True)
class Contribution:
    """One scored signal that contributed to the final tier."""

    label: str
    score: int


@dataclass(frozen=True, slots=True)
class ScoreResult:
    """The comparator's full reasoning — the tier, the numeric score, and
    the signals that produced it. Exposed via ``same_likely_explained`` for
    tooling, debug, and the flaky-test 'what did the comparator see'
    question."""

    confidence: Confidence
    score: int
    contributions: tuple[Contribution, ...] = field(default_factory=tuple)


# --- API ----------------------------------------------------------------------


def now_ms() -> int:
    """Monotonic milliseconds since an opaque epoch (matches
    ``identity.zig``'s ``nowMs``). Use this for ``Snapshot.captured_at_ms``
    when capturing in code; tests construct fixed values for determinism."""
    return time.monotonic_ns() // 1_000_000


def snapshot(resource: Resource, captured_at_ms: int | None = None) -> Snapshot:
    """Capture a ``Resource`` as a ``Snapshot`` — pure function, copies the
    identifying fields into a stable record that survives the device
    dropping off the bus.

    The skeleton's ``Resource`` doesn't carry a bus / port_chain explicitly
    (the broker's wire format will, once expanded). For now this builds a
    minimal Snapshot from the present fields; consumers needing the full
    USB shape construct ``Snapshot`` directly from worker-supplied data.
    """
    return Snapshot(
        urn=resource.urn,
        identity_class=resource.identity_class,
        vid=resource.vid or 0,
        pid=resource.pid or 0,
        serial_number=resource.serial_number,
        bus=0,
        port_chain=(),
        captured_at_ms=captured_at_ms if captured_at_ms is not None else now_ms(),
    )


def same_likely(a: Snapshot, b: Snapshot, opts: ComparatorOpts | None = None) -> Confidence:
    """The comparator's lightweight verdict. For consumer code that just
    wants "should I treat this as the same device?" without the reasoning
    trace."""
    return same_likely_explained(a, b, opts).confidence


def same_likely_explained(
    a: Snapshot, b: Snapshot, opts: ComparatorOpts | None = None
) -> ScoreResult:
    """The comparator's full reasoning.

    Algorithm matches ``hrb-code/src/identity.zig``'s
    ``sameLikelyExplained`` exactly — same short-circuits, same weights,
    same buckets. See ``hrb-design/mgf-hrb-usb-worker.md`` §4.3 for the
    derivation.
    """
    o = opts or ComparatorOpts()
    contributions: list[Contribution] = []

    # 1. VID mismatch is fatal — short-circuit DIFFERENT.
    if a.vid != b.vid:
        return ScoreResult(
            confidence=Confidence.DIFFERENT,
            score=0,
            contributions=(Contribution("vid_mismatch", 0),),
        )
    contributions.append(Contribution("vid_match", 3))

    # 2. SERIAL: present-on-both-and-different is fatal; asymmetry is soft.
    a_has = a.serial_number is not None
    b_has = b.serial_number is not None
    if a_has and b_has:
        if a.serial_number == b.serial_number:
            contributions.append(Contribution("serial_match", 6))
        else:
            return ScoreResult(
                confidence=Confidence.DIFFERENT,
                score=0,
                contributions=tuple([*contributions, Contribution("serial_mismatch", 0)]),
            )
    elif a_has != b_has:
        if o.tolerate_missing_serial:
            contributions.append(Contribution("serial_asymmetric_tolerated", 2))
        else:
            contributions.append(Contribution("serial_asymmetric_strict", -4))
    # both absent → no contribution

    # 3. PORT: match or (configurably) mismatch.
    ports_eq = a.bus == b.bus and a.port_chain == b.port_chain
    if ports_eq:
        contributions.append(Contribution("port_match", 4))
    elif o.tolerate_port_change:
        contributions.append(Contribution("port_mismatch_tolerated", 0))
    else:
        contributions.append(Contribution("port_mismatch_strict", -6))

    # 4. MODE CHANGE EXPLAINED: a recognised PID flip on the same port,
    #    within the debounce window.
    if o.tolerate_mode_change and a.pid != b.pid and ports_eq:
        delta = abs(b.captured_at_ms - a.captured_at_ms)
        if delta <= o.port_debounce_ms:
            contributions.append(Contribution("mode_change_explained", 2))

    score = sum(c.score for c in contributions)
    return ScoreResult(
        confidence=_bucket(score),
        score=score,
        contributions=tuple(contributions),
    )


def _bucket(score: int) -> Confidence:
    """Score → tier translation. Mirrors ``identity.zig``::bucket exactly:
    ≥12 → SAME, 6..11 → LIKELY_SAME, 1..5 → AMBIGUOUS, ≤0 → DIFFERENT."""
    if score >= 12:
        return Confidence.SAME
    if score >= 6:
        return Confidence.LIKELY_SAME
    if score >= 1:
        return Confidence.AMBIGUOUS
    return Confidence.DIFFERENT


# Keep the `Iterable` import referenced for forward-compat type hints once
# we add a batched comparator; suppress unused-import noise.
_ = Iterable
