"""Use case: track a device across a mode-change (USB replug).

Cites: `recipes/track-a-device-across-a-mode-change.md`.

The scenario in words:

  1. A consumer (e.g. an iFig firmware updater) has a USB device
     in mode A. It captures a `Snapshot` of the device's identity
     (vid, pid, serial, bus, port_chain, captured_at_ms).
  2. The consumer triggers a mode change — DFU enter, soft reset,
     personality switch. The device disappears from the bus
     briefly and reappears with a NEW pid (and possibly no serial).
     The URN renumbers; `(vid, pid)` no longer matches.
  3. The consumer captures a Snapshot of the new device and asks
     `same_likely(before, after)`. The comparator returns
     `LIKELY_SAME` or `SAME` if both Snapshots reflect the same
     physical unit — same vendor, same bus, same hub port chain,
     within the debounce window.

What this test pins, end-to-end:

  * The recipe's four-step pattern (find → snapshot → wait →
    confirm) maps to four assertions against the comparator.
  * Real-world transitions the comparator must handle:
      - iFig Recovery (PID 0x1281) → DFU (PID 0x1227): same VID,
        same port, PID changes — LIKELY_SAME with the
        `tolerate_mode_change=True` default.
      - Fast unplug/replug at the SAME port: serial-preserving →
        SAME.
      - Same VID at a different port (a SECOND device): DIFFERENT
        even though VID matches.
      - PID-only match (different bus): DIFFERENT — the comparator
        does NOT treat (vid, pid) alone as identity.

No real hardware needed — Snapshots are pure dataclasses; the
comparator is a pure function. Hardware-gated only insofar as
USB-side capture wires off the comparator (Phase 5 §1 of the
recipe is the worker → snapshot path; this test covers the
comparator-application step alone).
"""

from __future__ import annotations

import pytest

from mgf.hrb import (
    Client,
    Confidence,
    IdentityClass,
    Snapshot,
)
from mgf.hrb.identity import ComparatorOpts, same_likely

pytestmark = [pytest.mark.use_case, pytest.mark.timeout(60)]


# iFig vendor + the two PIDs from the recipe.
IFIG_VID = 0x05AC
IFIG_RECOVERY_PID = 0x1281
IFIG_DFU_PID = 0x1227

# A neighbouring port (different physical socket on the same hub) —
# used for the "same VID, different device" negative test.
NEIGHBOUR_PORT_CHAIN = (1, 2, 4)
HOME_PORT_CHAIN = (1, 2, 3)
T_RECOVERY_MS = 10_000
T_DFU_MS = 10_750  # 750 ms later — well inside the 1 s debounce default.


def _ifig_snapshot(
    *,
    urn: str,
    pid: int,
    serial: str | None,
    port_chain: tuple[int, ...] = HOME_PORT_CHAIN,
    captured_at_ms: int = T_RECOVERY_MS,
    vid: int = IFIG_VID,
    bus: int = 1,
) -> Snapshot:
    """Build a USB-interface Snapshot — the exact shape a real
    USB worker would emit. Single helper to keep the per-test
    setup short."""
    # USB interface URNs are topological — derived from the bus port
    # path, not from device-intrinsic identifiers. Matches what a
    # USB worker would assign at capture time.
    return Snapshot(
        urn=urn,
        identity_class=IdentityClass.TOPOLOGICAL,
        vid=vid,
        pid=pid,
        serial_number=serial,
        bus=bus,
        port_chain=port_chain,
        captured_at_ms=captured_at_ms,
    )


def test_recovery_to_dfu_mode_change_is_likely_same(broker: Client) -> None:
    """The motivating case: iFig Recovery (PID 0x1281) re-enumerates
    as DFU (PID 0x1227). Same VID, same physical port, 750 ms
    apart. Comparator MUST say LIKELY_SAME — the consumer can
    proceed with the firmware-flash step without confusing this
    for a different device.

    The serial is None in DFU mode (devices commonly omit it);
    `tolerate_missing_serial=True` is the default. Pin that
    default so a future tightening of the comparator doesn't
    silently break this scenario.
    """
    _ = broker  # consume the fixture so this test still requires a live broker
    recovery = _ifig_snapshot(
        urn="hw:/usb/001/002.if0",
        pid=IFIG_RECOVERY_PID,
        serial="IFIG-RECOVERY-SN",
        captured_at_ms=T_RECOVERY_MS,
    )
    dfu = _ifig_snapshot(
        urn="hw:/usb/001/003.if0",  # devnum rotated — URN differs.
        pid=IFIG_DFU_PID,
        serial=None,  # DFU bootloader drops the serial.
        captured_at_ms=T_DFU_MS,
    )
    verdict = same_likely(recovery, dfu)
    assert verdict >= Confidence.LIKELY_SAME, (
        f"Recovery→DFU should be LIKELY_SAME or stronger, got {verdict.name}"
    )


def test_fast_replug_at_same_port_preserves_identity(broker: Client) -> None:
    """A < 1s unplug/replug of the same device at the same port.
    VID + PID + serial all preserved; only the captured_at_ms
    shifts. Comparator returns SAME — the strongest tier."""
    _ = broker
    before = _ifig_snapshot(
        urn="hw:/usb/001/002.if0",
        pid=IFIG_RECOVERY_PID,
        serial="IFIG-RECOVERY-SN",
        captured_at_ms=T_RECOVERY_MS,
    )
    after = _ifig_snapshot(
        urn="hw:/usb/001/004.if0",
        pid=IFIG_RECOVERY_PID,
        serial="IFIG-RECOVERY-SN",
        captured_at_ms=T_RECOVERY_MS + 500,
    )
    assert same_likely(before, after) >= Confidence.LIKELY_SAME


def test_different_port_means_different_device(broker: Client) -> None:
    """Same vendor and PID but plugged into a SECOND port on the
    same hub. The comparator must say DIFFERENT — VID alone is
    not identity."""
    _ = broker
    home = _ifig_snapshot(
        urn="hw:/usb/001/002.if0",
        pid=IFIG_RECOVERY_PID,
        serial="HOME-SN",
        port_chain=HOME_PORT_CHAIN,
    )
    neighbour = _ifig_snapshot(
        urn="hw:/usb/001/005.if0",
        pid=IFIG_RECOVERY_PID,
        serial="NEIGHBOUR-SN",
        port_chain=NEIGHBOUR_PORT_CHAIN,
    )
    assert same_likely(home, neighbour) == Confidence.DIFFERENT


def test_pid_only_match_across_buses_is_different(broker: Client) -> None:
    """Two devices with the same (VID, PID) on DIFFERENT USB
    controllers (e.g. an internal hub vs. a back-panel port). The
    comparator looks at bus + port_chain, not just (VID, PID), so
    these are DIFFERENT — preventing a consumer from mis-targeting
    the wrong physical unit during a multi-step flow."""
    _ = broker
    on_bus_1 = _ifig_snapshot(urn="hw:/usb/001/002.if0", pid=IFIG_RECOVERY_PID, serial=None, bus=1)
    on_bus_2 = _ifig_snapshot(urn="hw:/usb/002/002.if0", pid=IFIG_RECOVERY_PID, serial=None, bus=2)
    assert same_likely(on_bus_1, on_bus_2) == Confidence.DIFFERENT


def test_port_change_intolerance_is_overridable(broker: Client) -> None:
    """The default policy says port-change → DIFFERENT. A consumer
    that explicitly opts in to `tolerate_port_change=True` (e.g.
    a hub-replacement detection flow) gets the looser verdict.
    Pin that the opts override actually changes the outcome —
    otherwise the knob is decorative."""
    _ = broker
    home = _ifig_snapshot(
        urn="hw:/usb/001/002.if0",
        pid=IFIG_RECOVERY_PID,
        serial="SAME-SN",
        port_chain=HOME_PORT_CHAIN,
    )
    moved = _ifig_snapshot(
        urn="hw:/usb/001/006.if0",
        pid=IFIG_RECOVERY_PID,
        serial="SAME-SN",
        port_chain=NEIGHBOUR_PORT_CHAIN,
    )
    strict = same_likely(home, moved)
    lenient = same_likely(home, moved, opts=ComparatorOpts(tolerate_port_change=True))
    # Strict refuses to call it the same — anywhere from AMBIGUOUS down
    # to DIFFERENT depending on the matched-fields tally. Lenient is
    # strictly stronger; that's the knob's contract.
    assert strict < Confidence.LIKELY_SAME, (
        f"strict comparator should NOT call port-changed snapshots "
        f"LIKELY_SAME or better; got {strict.name}"
    )
    assert lenient >= Confidence.LIKELY_SAME, (
        f"tolerate_port_change=True should accept the moved snapshot, got {lenient.name}"
    )
    assert lenient > strict, "tolerate_port_change=True must strictly increase confidence"
