//! Single source of truth for what to do when a sector read fails.
//!
//! Pass 1 (`Disc::sweep`) calls into `handle_read_error` after every failed
//! `read_sectors`. The handler classifies the error, updates the in-flight
//! context (counters, damage window, retry budgets), and returns a
//! `ReadAction` the caller dispatches on. Pass N patch has its own
//! `handle_read_failure` in `disc/patch.rs` that does not route here.
//!
//! Adding a new error class = add one arm in `handle_read_error`.
//! Adding new logging on errors = one place.

use crate::error::Error;
use crate::scsi;

/// In-flight bookkeeping a read loop must keep across iterations. The
/// handler reads and mutates this. Caller owns the storage.
pub struct ReadCtx {
    /// Number of sectors per read attempt. The handler uses this to
    /// decide whether to bisect (only worthwhile when batch > 1).
    pub batch: u16,
    /// Successful reads since the last failure. Resets to 0 on failure.
    /// Used by callers to drive damage-zone exit / speed restoration.
    pub consecutive_good: u64,
    /// Failed reads since the last success. Resets to 0 on success.
    /// Drives long-pause escalation on persistent failure.
    pub consecutive_failures: u64,
    /// Failed OUTER batch reads since the last outer success — bisect
    /// inner-sector failures are NOT counted here. Drives the
    /// fast-entry damage-jump on Pass 1 (skip the disc-level grind
    /// once we're clearly in a damaged region; Pass N will recover
    /// the actual sectors). Reset on outer success.
    pub consecutive_outer_failures: u64,
    /// Sliding window of recent read outcomes (true=ok, false=fail).
    /// Capped at `damage_window_max`. Drives damage-jump decisions.
    pub damage_window: Vec<bool>,
    /// Maximum number of outcome entries kept in `damage_window`; the
    /// oldest is evicted once this is exceeded. A whole count (e.g. 16).
    pub damage_window_max: usize,
    /// Fraction of `damage_window` entries that must be failures before
    /// the window-based damage-jump fires, as a whole-number percentage
    /// (e.g. `12` = 12%).
    pub damage_threshold_pct: usize,
    /// Trigger a damage-jump after this many consecutive outer-batch
    /// failures, even when the damage_window isn't full yet. Pass 1
    /// uses a small value (1 — jump on the first outer failure; see
    /// the 2026-05-11 rewrite in `for_sweep`) so we don't spend ~40
    /// minutes grinding to fill a 16-block window before the first jump
    /// on a damage zone we entered cleanly. Pass N uses a larger value
    /// (or disables this — see `bisect_on_marginal`) because Pass N's
    /// whole job IS to grind on the bad ranges.
    pub fast_jump_threshold: u64,
    /// Multiplier applied to damage-jump distance. Doubles each jump,
    /// resets to 1 after `damage_window_max` consecutive good reads.
    pub jump_multiplier: u64,
    /// NOT_READY retries used so far for the current LBA. Reset to 0
    /// on any non-NOT_READY response.
    pub not_ready_retries: u32,
    /// Bridge-degradation cooldowns used so far.
    pub bridge_degradation_count: u32,
    /// Whether we're currently inside a damage-jump bisect attempt.
    /// Caller sets this true when entering single-sector mode for a
    /// failed batch, so the handler doesn't recursively request another
    /// bisect on the inner-sector failures.
    pub bisecting: bool,
    /// Whether to return `Bisect` on a marginal-media batch failure.
    /// Pass 1 sweep sets this false: a failed batch becomes
    /// SkipBlock (mark the whole 32-sector ECC block NonTrimmed,
    /// advance, let Pass N recover the salvageable sectors with
    /// proper recovery semantics). Pass N sets this true: bisection
    /// is its core job, and it has the right tools (single-sector
    /// reads, 60s recovery timeout, retry budget, escalating skip).
    pub bisect_on_marginal: bool,
    /// Count of consecutive firmware-wedge responses (HARDWARE_ERROR
    /// or ILLEGAL_REQUEST sense keys) since the last successful read.
    /// Pass 1 uses this to drive the wedge-skip path: each wedge
    /// triggers a 1 GB jump + cooldown pause. Reaching
    /// `WEDGE_ABORT_THRESHOLD` consecutive wedges with no good read
    /// in between → real AbortPass.
    pub wedge_count: u64,
    // ── Diagnostic counters (added 2026-05-10) ──
    //
    // Aggregate state for post-mortem analysis of wedge incidents.
    // Every Pass 1 / Pass N sweep now produces a structured summary
    // at the WARN log on each error AND an end-of-pass INFO summary.
    // Goal: when a wedge happens, the operator should be able to tell
    // from the logs whether it was triggered by ONE read at a
    // physically-damaged sector (immediate failure) or by accumulated
    // exposure across MANY reads (firmware-state buildup), and what
    // the timing pattern looked like.
    /// `Instant` of the most recent successful read. Used to compute
    /// "time since last good" for the WARN log on each error. None
    /// before the first successful read.
    pub last_success_at: Option<std::time::Instant>,
    /// `Instant` of the most recent failed read. Used to compute
    /// "time since last error" for the WARN log. None before the
    /// first error.
    pub last_error_at: Option<std::time::Instant>,
    /// Last error's sense-key "family" (Medium / Hardware / IllegalRequest
    /// / NotReady / Other). Used to detect WEDGE TRANSITIONS — when
    /// the family changes from Medium → Hardware/IllegalRequest, the
    /// drive almost certainly just entered fast-fail mode. That
    /// transition gets its own WARN log so the trace is unambiguous.
    pub last_error_family: Option<SenseFamily>,
    /// Sum of all errors observed during this sweep. Reported in the
    /// end-of-pass summary.
    pub total_errors: u64,
    /// Sum of all successful reads during this sweep.
    pub total_reads_ok: u64,
    /// Count of damage zones entered (transitions from clean → in-damage).
    pub zones_entered: u64,
    /// Count of damage-jumps executed during this sweep.
    pub jumps_taken: u64,
    /// True between "first error after a clean period" and "16 consecutive
    /// good reads after the last error in the cluster." Used to count
    /// zone entries and to bound zone_reads accurately.
    pub in_damage_zone: bool,
}

/// Coarse classification of a SCSI sense key for diagnostic logging.
/// Wedge-family events (Hardware + IllegalRequest) get their own
/// transition log when the sense family changes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SenseFamily {
    NotReady,
    Medium,
    Hardware,
    IllegalRequest,
    Other,
}

impl SenseFamily {
    pub fn from_sense_key(sense_key: u8) -> Self {
        match sense_key {
            scsi::SENSE_KEY_NOT_READY => SenseFamily::NotReady,
            scsi::SENSE_KEY_MEDIUM_ERROR => SenseFamily::Medium,
            scsi::SENSE_KEY_HARDWARE_ERROR => SenseFamily::Hardware,
            scsi::SENSE_KEY_ILLEGAL_REQUEST => SenseFamily::IllegalRequest,
            _ => SenseFamily::Other,
        }
    }

    /// True for the "wedge family" — Hardware + IllegalRequest are
    /// the senses the BU40N firmware returns in its fast-fail state.
    pub fn is_wedge_family(self) -> bool {
        matches!(self, SenseFamily::Hardware | SenseFamily::IllegalRequest)
    }
}

impl ReadCtx {
    /// Initial context for a Pass 1 sweep. The job is "fast and
    /// accurate, get the most data in the shortest time" — Pass N
    /// is the one that grinds on the bad ranges. So bisect-on-
    /// marginal is OFF (failed batches become SkipBlock; whole 32-
    /// sector blocks marked NonTrimmed for Pass N to revisit), and
    /// the damage-jump fast-path triggers after just 1 consecutive
    /// outer-batch failure — the user's wedge-prevention principle
    /// (2026-05-11): once the drive returns ANY recoverable error,
    /// retrying the same LBA quickly is what triggers the firmware
    /// fast-fail transition. On the damage-jump and marginal paths Pass 1
    /// jumps immediately rather than grinding the same LBA. Transient errors
    /// (NOT_READY, bridge degradation) are still retried a small bounded
    /// number of times (`NOT_READY_MAX_RETRIES` / `BRIDGE_DEGRADATION_MAX_RETRIES`)
    /// in both passes before falling through to the skip path.
    /// Pass N owns the heavy retries — it gets per-sector timeouts that don't
    /// hammer the firmware the same way.
    pub fn for_sweep(batch: u16) -> Self {
        Self {
            batch,
            consecutive_good: 0,
            consecutive_failures: 0,
            consecutive_outer_failures: 0,
            damage_window: Vec::with_capacity(16),
            damage_window_max: 16,
            damage_threshold_pct: 12,
            fast_jump_threshold: 1,
            jump_multiplier: 1,
            not_ready_retries: 0,
            bridge_degradation_count: 0,
            bisecting: false,
            bisect_on_marginal: false,
            wedge_count: 0,
            last_success_at: None,
            last_error_at: None,
            last_error_family: None,
            total_errors: 0,
            total_reads_ok: 0,
            zones_entered: 0,
            jumps_taken: 0,
            in_damage_zone: false,
        }
    }

    /// Initial context for a Pass 2-N patch. Pass N's whole reason to
    /// exist is to recover sectors Pass 1 skipped — bisection on
    /// marginal media is part of the job, and the fast-jump
    /// threshold is loose so we don't bail too early on a range that
    /// has scattered good sectors mixed in.
    ///
    /// `damage_threshold_pct = 6` is looser than Pass 1 (12%): Pass N triggers
    /// the damage-skip at half Pass 1 density because the patch loop exists to chip
    /// away at bad ranges, so being more eager to skip clustered bad sectors
    /// converges faster on the recoverable good sectors inside a range.
    pub fn for_patch(batch: u16) -> Self {
        Self {
            batch,
            consecutive_good: 0,
            consecutive_failures: 0,
            consecutive_outer_failures: 0,
            damage_window: Vec::with_capacity(16),
            damage_window_max: 16,
            damage_threshold_pct: PATCH_DAMAGE_THRESHOLD_PCT,
            // Pass N is allowed to grind: window-based jump only,
            // matching the historical behaviour for patch passes.
            fast_jump_threshold: u64::MAX,
            jump_multiplier: 1,
            not_ready_retries: 0,
            bridge_degradation_count: 0,
            bisecting: false,
            bisect_on_marginal: true,
            wedge_count: 0,
            last_success_at: None,
            last_error_at: None,
            last_error_family: None,
            total_errors: 0,
            total_reads_ok: 0,
            zones_entered: 0,
            jumps_taken: 0,
            in_damage_zone: false,
        }
    }

    /// Caller calls this after every successful read.
    pub fn on_success(&mut self) {
        self.consecutive_good += 1;
        self.consecutive_failures = 0;
        self.not_ready_retries = 0;
        // Any successful read clears the wedge-skip counter — the
        // drive recovered, so further wedges should reset the skip
        // budget instead of accumulating toward a real abort.
        self.wedge_count = 0;
        // A successful read also means the bridge recovered, so the
        // 15s-cooldown retry budget should be available again for the
        // next bridge-degradation event. Without this reset the budget
        // saturates permanently after 5 cumulative events across the
        // whole pass and later degradations skip the cooldown retry,
        // needlessly losing data.
        self.bridge_degradation_count = 0;
        // Outer-success only: a good single-sector read inside a
        // bisect doesn't mean we've left the damaged batch. Only an
        // outer-batch success resets the outer-failure counter.
        if !self.bisecting {
            self.consecutive_outer_failures = 0;
        }
        self.damage_window.push(true);
        if self.damage_window.len() > self.damage_window_max {
            self.damage_window.remove(0);
        }
        // Diagnostic state.
        self.total_reads_ok += 1;
        self.last_success_at = Some(std::time::Instant::now());
        // If we were in a damage zone and accumulated enough good
        // reads to exit (damage_window now all-good), the zone is
        // over. Don't reset zones_entered — that's a sweep total.
        if self.in_damage_zone && self.consecutive_good >= self.damage_window_max as u64 {
            self.in_damage_zone = false;
            self.last_error_family = None;
            // Reset the damage-jump multiplier so the NEXT zone starts
            // from the base jump distance. Without this the multiplier
            // stays at whatever the prior zone inflated it to (up to
            // MAX_JUMP_MULTIPLIER=64), so the next zone's first jump is
            // 64x oversized and skips recoverable data. The field doc
            // promises this reset.
            self.jump_multiplier = 1;
        }
    }

    /// Final per-pass summary suitable for an INFO log at the end of
    /// `sweep` / `patch`. Caller renders this to a single structured
    /// log line.
    pub fn pass_summary(&self) -> PassSummary {
        PassSummary {
            total_reads_ok: self.total_reads_ok,
            total_errors: self.total_errors,
            zones_entered: self.zones_entered,
            jumps_taken: self.jumps_taken,
        }
    }
}

/// End-of-pass stats logged at INFO for post-mortem analysis. Lets
/// an operator answer "how damaged is this disc?" from a single log
/// line per pass.
#[derive(Debug, Clone, Copy)]
pub struct PassSummary {
    pub total_reads_ok: u64,
    pub total_errors: u64,
    pub zones_entered: u64,
    pub jumps_taken: u64,
}

/// What the caller should do after a read failure. The caller owns the
/// I/O side-effects (sleep, write zeros, advance pos) — the handler
/// only decides which side-effects.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReadAction {
    /// Pause `pause_secs` then retry the same LBA / batch. Used for
    /// transient conditions (NOT_READY, bridge degradation) that the
    /// drive may recover from on its own.
    Retry { pause_secs: u64 },
    /// Re-issue the failed batch as `batch` single-sector reads. Each
    /// inner read is itself dispatched through `handle_read_error` with
    /// `bisecting = true` so it cannot recurse.
    Bisect,
    /// Mark the failed range NonTrimmed (zero-fill, retry in Pass N+),
    /// then pause `pause_secs` before resuming the next LBA.
    SkipBlock { pause_secs: u64 },
    /// Mark the failed range NonTrimmed AND advance position by
    /// `sectors` (zero-filling the gap as NonTrimmed). Then pause
    /// `pause_secs`. Used when the damage-window threshold is crossed.
    JumpAhead { sectors: u64, pause_secs: u64 },
    /// Unrecoverable at this layer. Caller propagates `Err` up to the
    /// outer pass loop / autorip, which can attempt USB re-enumeration,
    /// drop session, etc.
    AbortPass,
}

// Pause budget constants. Tuned from 2026-05-07 BU40N traces showing
// bridge wedges 524 ms after a 5.4-second internal ECC retry. The
// post-failure pauses give the drive — and the bridge — time to settle.
/// Pause between a failed read and the next read attempt — applied
/// by Pass 1 sweep via `handle_read_error`. Pass N patch uses its own
/// `POST_FAILURE_PAUSE_SECS` (see `disc/patch.rs`).
///
/// 2026-05-11 reframe: a failed read is a failed read, regardless of
/// which pass is running. The prior split (1s for Pass N, 5s for Pass
/// 1 via `PASS_1_FAIL_PAUSE_SECS`) was solving an imaginary cost
/// problem — real damaged-disc cases mark <50 MB NonTrimmed, and the
/// extra 5s/error is single-digit minutes per pass, not hours. The
/// cost of NOT pausing — a drive wedge that aborts the entire
/// multi-pass recovery — is much worse.
///
/// The wedge avoidance principle: error → drive ECC retry (5-10s
/// internal) → return → cooldown pause → next read. Same shape
/// everywhere reads can fail.
const FAIL_PAUSE_SECS: u64 = 5;
/// Long cooldown applied when a damage zone is first entered (the
/// FIRST read failure after a clean run, before the drive has had a
/// chance to cycle in retries that push it toward fast-fail).
///
/// Empirical: a 2026-05-11 wedge incident showed 7 medium
/// errors in 6.5 seconds (~1s per attempt + ~1s pause) push the
/// BU40N's firmware into IllegalRequest fast-fail mode permanently.
/// Once there, only physical eject + reload clears it. Giving the
/// drive 30s of breathing room after the FIRST error in a zone —
/// before we start adding more error counts in the firmware's
/// internal window — prevents the transition.
///
/// Cost on clean discs: zero (first-error path doesn't trigger).
/// Cost on damaged discs: ~30s × N damage zones; on a 5-zone disc
/// that's 2.5 min extra. Trade for never wedging the drive.
pub(crate) const ZONE_ENTRY_COOLDOWN_SECS: u64 = 30;
/// Cooldown when a long streak of failures suggests the drive is
/// stuck in a damage zone and needs MORE breathing room than the
/// standard inter-error pause. Same value as `FAIL_PAUSE_SECS`
/// because empirically 5s is enough; kept as a separate name so the
/// escalation policy is explicit at the call site.
const CONSECUTIVE_FAIL_LONG_PAUSE_SECS: u64 = 5;
const CONSECUTIVE_FAIL_LONG_PAUSE_THRESHOLD: u64 = 10;
const POST_JUMP_EXTRA_PAUSE_SECS: u64 = 2;
const NOT_READY_PAUSE_SECS: u64 = 3;
const NOT_READY_MAX_RETRIES: u32 = 3;
const BRIDGE_DEGRADATION_PAUSE_SECS: u64 = 15;
const BRIDGE_DEGRADATION_MAX_RETRIES: u32 = 5;

/// Base of the damage-jump distance formula: `jump_sectors =
/// JUMP_BASE_SECTORS × batch × jump_multiplier`. Bumped 2026-05-10
/// from 256 → 1024 (4×) so the first damage-jump at batch=32 covers
/// 64 MB instead of 16 MB. Empirically the BU40N's damage clusters
/// are 100+ MB wide; 16 MB jumps landed inside the cluster and the
/// re-read added to the firmware wedge counter. 64 MB → 128 MB
/// (after one doubling) clears almost any single-cluster damage in
/// 2 jumps.
const JUMP_BASE_SECTORS: u64 = 1024;

// Firmware-wedge skip policy for Pass 1 sweep
// ===========================================
//
// When the BU40N (or similar drives) hits a physical-damage cluster,
// its firmware can transition into a "wedge" state where it returns
// HARDWARE_ERROR or ILLEGAL_REQUEST for every subsequent read —
// often for many LBAs after the actual bad sector. Once wedged,
// recovery requires either a physical eject + reload or a significant
// cool-down period; hammering the same LBA only deepens the state.
//
// Pass 1's pre-fix behavior was to immediately AbortPass on the
// first HARDWARE_ERROR / ILLEGAL_REQUEST, killing the rip at
// whatever percentage it had reached. That's the wrong call when:
//   - the damage zone may be small (jumping past it could resume
//     normal reads), AND
//   - even if the drive stays wedged, finishing the sweep gives us
//     an honest mapfile for Pass N to attack later.
//
// New policy: treat wedge sense codes the same way the damage-window
// treats persistent failure — JumpAhead by a large distance with a
// cooldown pause. Allow up to WEDGE_ABORT_THRESHOLD consecutive
// wedges (no successful read in between) before declaring the drive
// truly stuck and surfacing AbortPass to autorip.

/// One-gigabyte jump (1024 MiB) on each wedge. Big enough to clear
/// almost any single-cluster damage zone we've seen.
const WEDGE_JUMP_SECTORS: u64 = 524_288;
/// Cooldown pause after each wedge. A wedged drive needs a
/// significant cool-down to leave fast-fail; 30 s strikes a balance
/// between giving the drive a chance to recover and not stalling the
/// rip if the drive is permanently stuck.
const WEDGE_PAUSE_SECS: u64 = 30;
/// Bail after this many consecutive wedges with no good read in
/// between. At 1 GB jumps this lets us scan ~16 GB worth of fully
/// wedged area before giving up — generous enough to clear most
/// physical-damage clusters, bounded enough to not loop forever on
/// a permanently bricked drive.
const WEDGE_ABORT_THRESHOLD: u64 = 16;

/// Pass-N wedge-skip distance. Pass N's batch=1 reads target
/// specific NonTrimmed sectors from Pass 1, so a big 1 GB skip
/// would blow past the current NonTrimmed range and abandon many
/// sectors that might still recover. Use a smaller skip just to
/// move past the bricked LBA + a small buffer — the outer patch
/// loop's next iteration picks up the next sector in the same or
/// next range.
const WEDGE_PASS_N_SKIP_SECTORS: u64 = 64;

/// Single source of truth for the Pass-N damage-window threshold.
/// [`ReadCtx::for_patch`] reads this constant for the Pass-N damage-skip
/// threshold.
///
/// 6% means: with a 16-entry sliding window, the damage-skip fires
/// once 1 out of 16 recent reads has failed. Pass 1 uses a 12%
/// threshold via `damage_threshold_pct` on `for_sweep`; Pass N is
/// twice as eager because patch's whole job is to converge on the
/// bad sub-zones inside a NonTrimmed range — a faster trigger
/// produces tighter convergence in fewer iterations.
pub const PATCH_DAMAGE_THRESHOLD_PCT: usize = 6;

/// THE single error-handling entry point. Updates `ctx`, returns the
/// action the caller must apply.
///
/// New error class = add a new arm here. New logging on errors = add
/// it once at the top. New retry policy = adjust the constants. No
/// other read site needs to change.
pub fn handle_read_error(err: &Error, ctx: &mut ReadCtx) -> ReadAction {
    ctx.consecutive_failures += 1;
    ctx.consecutive_good = 0;
    // Outer-failure counter — only OUTER batch failures count toward
    // the fast-jump trigger. Bisect inner failures are part of
    // recovering an already-failed batch and don't represent
    // independent damage signal.
    if !ctx.bisecting {
        ctx.consecutive_outer_failures += 1;
    }

    // Diagnostic instrumentation — compute timing context BEFORE
    // mutating the timestamps so the log reflects the gap to the
    // PREVIOUS error / success, not zero.
    let now = std::time::Instant::now();
    let ms_since_last_error = ctx
        .last_error_at
        .map(|t| now.duration_since(t).as_millis() as u64);
    let ms_since_last_success = ctx
        .last_success_at
        .map(|t| now.duration_since(t).as_millis() as u64);

    let current_family = err
        .scsi_sense()
        .map(|s| SenseFamily::from_sense_key(s.sense_key))
        .unwrap_or(SenseFamily::Other);

    // Zone-entry tracking: this is the first error after a clean run
    // (or the first error of the sweep). Capture the genuine
    // clean->damaged transition here, BEFORE mutating in_damage_zone,
    // so the 30s zone-entry cooldown below keys off the real
    // transition rather than re-deriving it from a counter that the
    // fast-jump path resets after every jump.
    let is_zone_entry_transition = !ctx.in_damage_zone && !ctx.bisecting;
    if is_zone_entry_transition {
        ctx.in_damage_zone = true;
        ctx.zones_entered += 1;
    }

    ctx.total_errors += 1;
    ctx.last_error_at = Some(now);

    // Wedge transition: previous error was MEDIUM, this one is
    // HARDWARE or ILLEGAL_REQUEST. That's the moment the drive's
    // firmware flipped into fast-fail mode. Distinct WARN so logs
    // make it unambiguous when the wedge "started."
    let is_wedge_transition = matches!(ctx.last_error_family, Some(prev) if !prev.is_wedge_family())
        && current_family.is_wedge_family();
    ctx.last_error_family = Some(current_family);

    tracing::warn!(
        target: "freemkv::disc",
        phase = "read_error",
        consecutive_failures = ctx.consecutive_failures,
        consecutive_outer_failures = ctx.consecutive_outer_failures,
        ms_since_last_error,
        ms_since_last_success,
        total_errors = ctx.total_errors,
        total_reads_ok = ctx.total_reads_ok,
        batch = ctx.batch,
        bisecting = ctx.bisecting,
        wedge_count = ctx.wedge_count,
        sense_family = ?current_family,
        sense_key = err.scsi_sense().map(|s| s.sense_key),
        asc = err.scsi_sense().map(|s| s.asc),
        ascq = err.scsi_sense().map(|s| s.ascq),
        error = %err,
        "read failed; classifying"
    );

    if is_wedge_transition {
        // NOTE: this is the FIRST escalation into the hardware/illegal-request
        // sense family — NOT a confirmed wedge. Drives frequently recover and keep
        // reading after one such error (a single bad spot), so calling it a "wedge"
        // here over-claims (it sent past investigations chasing a drive ghost). A
        // genuine wedge is PERSISTENT — see the `wedge_skip` / WEDGE_ABORT_THRESHOLD
        // path below, which only fires after repeated fast-fails with no recovery.
        tracing::warn!(
            target: "freemkv::disc",
            phase = "fastfail_escalation",
            errors_in_zone = ctx.total_errors,
            ms_since_last_success,
            new_family = ?current_family,
            "drive escalated into the fast-fail sense family (was returning recoverable medium \
             errors before this) — often transient; only a PERSISTENT run is a real wedge"
        );
    }

    // 1. Transport failure: bridge crash / USB disconnect. The outer
    //    pass loop knows how to handle this (rediscover sg path,
    //    re-open drive). Inline single-sector retry here was tried in
    //    pre-v0.17.0 builds and observed to make wedges worse.
    if err.is_scsi_transport_failure() {
        return ReadAction::AbortPass;
    }

    // 2. Bridge degradation: the SCSI status byte is non-standard —
    //    neither GOOD (0x00), CHECK CONDITION (0x02), nor TRANSPORT
    //    FAILURE (0xFF). The USB bridge firmware returns these bogus
    //    status bytes (e.g. 0x04, 0x05) with empty sense data when it
    //    enters a semi-stuck state preceding a crash. This is keyed on
    //    the status byte alone, NOT on sense_key/ASC/ASCQ — a real
    //    NOT_READY 04/3E bad-sector error arrives as CHECK CONDITION
    //    (0x02) and is handled by the generic NOT_READY branch below.
    //    The bridge typically recovers after a long cooldown; if we've
    //    exhausted our retry budget, fall through to the marginal/skip
    //    path below.
    if err.is_bridge_degradation() && ctx.bridge_degradation_count < BRIDGE_DEGRADATION_MAX_RETRIES
    {
        ctx.bridge_degradation_count += 1;
        return ReadAction::Retry {
            pause_secs: BRIDGE_DEGRADATION_PAUSE_SECS,
        };
    }

    let sense_key = err.scsi_sense().map(|s| s.sense_key).unwrap_or(0);

    // 3. Generic NOT_READY (other ASC codes): drive's mechanical
    //    pickup may be moving. Pause and retry briefly.
    if sense_key == scsi::SENSE_KEY_NOT_READY && ctx.not_ready_retries < NOT_READY_MAX_RETRIES {
        ctx.not_ready_retries += 1;
        return ReadAction::Retry {
            pause_secs: NOT_READY_PAUSE_SECS,
        };
    }
    if sense_key != scsi::SENSE_KEY_NOT_READY {
        ctx.not_ready_retries = 0;
    }

    // 4. Hardware error / illegal request — the firmware-wedge family.
    //    The drive transitioned into a fast-fail state where it
    //    rejects reads near the LBA. Same response shape for both
    //    passes (2026-05-11 reframe — error handling is centralized,
    //    and the wedge is a code-induced state we can avoid via
    //    pacing + skip):
    //
    //    - Pass 1 sweep (bisect_on_marginal=false): jump
    //      WEDGE_JUMP_SECTORS (1 GB) ahead, pause WEDGE_PAUSE_SECS,
    //      mark skipped region NonTrimmed.
    //    - Pass N patch (bisect_on_marginal=true): give up on the
    //      current sector (the granular target), pause for cooldown,
    //      let the outer patch loop move to the next NonTrimmed
    //      range. Implemented as a small JumpAhead so the same code
    //      path serves both — Pass N's batch=1 means JumpAhead by
    //      WEDGE_PASS_N_SKIP_SECTORS effectively skips just this
    //      sector and a small buffer (gives the drive room to
    //      recover before the next per-sector attempt).
    //
    //    Both paths share the WEDGE_ABORT_THRESHOLD budget — only
    //    AbortPass after N consecutive wedges with no successful
    //    read in between.
    if sense_key == scsi::SENSE_KEY_HARDWARE_ERROR || sense_key == scsi::SENSE_KEY_ILLEGAL_REQUEST {
        // Count every wedge, including bisect-inner ones. A wedge is a
        // firmware fast-fail state regardless of whether we're inside a
        // bisect; if we did NOT count bisect-inner wedges, a drive that
        // wedges mid-bisect would burn a 30s WEDGE_PAUSE cooldown per
        // inner sector and never reach WEDGE_ABORT_THRESHOLD from inside
        // the bisect — ~16 min of cooldown sleeping on a batch=32 bisect.
        ctx.wedge_count += 1;
        if ctx.wedge_count >= WEDGE_ABORT_THRESHOLD {
            tracing::warn!(
                target: "freemkv::disc",
                phase = "wedge_abort",
                wedge_count = ctx.wedge_count,
                threshold = WEDGE_ABORT_THRESHOLD,
                pass = if ctx.bisect_on_marginal { "N" } else { "1" },
                "wedge-skip exhausted — drive appears permanently stuck"
            );
            return ReadAction::AbortPass;
        }
        let jump_sectors = if ctx.bisect_on_marginal {
            WEDGE_PASS_N_SKIP_SECTORS
        } else {
            WEDGE_JUMP_SECTORS
        };
        tracing::warn!(
            target: "freemkv::disc",
            phase = "wedge_skip",
            pass = if ctx.bisect_on_marginal { "N" } else { "1" },
            wedge_count = ctx.wedge_count,
            jump_sectors,
            pause_secs = WEDGE_PAUSE_SECS,
            "wedge detected — skipping ahead and pausing for drive cooldown"
        );
        ctx.jumps_taken += 1;
        return ReadAction::JumpAhead {
            sectors: jump_sectors,
            pause_secs: WEDGE_PAUSE_SECS,
        };
    }

    // 5. Marginal media (MEDIUM_ERROR / ABORTED_COMMAND) on a multi-
    //    sector batch: the drive can often read the same sectors
    //    individually. Bisect into single-sector reads (gentler on the
    //    bridge too — shorter SCSI transactions). Avoid recursive
    //    bisect.
    //
    //    Pass 1 sweep sets `bisect_on_marginal=false` to skip this:
    //    its job is "fast and accurate, get the most data in the
    //    shortest time." Pass N is purpose-built to recover
    //    individual sectors with proper recovery semantics, and Pass
    //    1 grinding through 32-sector bisects costs ~2.5 min per bad
    //    block AND fills the damage window slower than it should.
    //    Whole-block NonTrimmed → SkipBlock → advance → Pass N
    //    revisits.
    let is_marginal = matches!(
        sense_key,
        scsi::SENSE_KEY_MEDIUM_ERROR | scsi::SENSE_KEY_ABORTED_COMMAND
    );
    if is_marginal && ctx.batch > 1 && !ctx.bisecting && ctx.bisect_on_marginal {
        return ReadAction::Bisect;
    }

    // 6. Single-sector failure or unbisectable error — record in
    //    damage window, decide between skip-in-place vs damage-jump.
    //
    //    SKIP damage-window updates while bisecting: the window
    //    represents per-batch outcomes, not per-sector. Updating it
    //    inside a bisect inner loop (potentially 32+ sector failures
    //    per batch) would over-weight the window and cause runaway
    //    JumpAhead distance via excessive multiplier doublings.
    if !ctx.bisecting {
        ctx.damage_window.push(false);
        if ctx.damage_window.len() > ctx.damage_window_max {
            ctx.damage_window.remove(0);
        }
    }

    let bad_count = ctx.damage_window.iter().filter(|&&b| !b).count();
    let bad_pct = if ctx.damage_window.is_empty() {
        0
    } else {
        bad_count * 100 / ctx.damage_window.len()
    };

    // Inter-error pause — wedge prevention via pacing.
    //
    // Zone-entry case (first error after a clean run): apply the
    // long ZONE_ENTRY_COOLDOWN_SECS pause. The empirical wedge
    // observed 2026-05-11 happened ~7 errors into a damage zone,
    // each retry adding to the firmware's internal counter. A 30s
    // pause at zone entry lets the drive's bridge / firmware
    // counters reset before we issue the next read.
    //
    // Subsequent errors in the same zone: the standard 5s pause.
    // (We've already jumped past the initial damage; further errors
    // mean we landed in another bad cluster — same pacing applies.)
    //
    // Long-streak escalation: same 5s currently; kept as a separate
    // branch for future tuning. Pass N (bisect_on_marginal=true)
    // uses the standard pauses — it's running single-sector retries
    // on already-known-bad LBAs by design.
    let is_zone_entry = is_zone_entry_transition && !ctx.bisecting && !ctx.bisect_on_marginal;
    let pause_secs = if is_zone_entry {
        ZONE_ENTRY_COOLDOWN_SECS
    } else if ctx.consecutive_failures >= CONSECUTIVE_FAIL_LONG_PAUSE_THRESHOLD {
        CONSECUTIVE_FAIL_LONG_PAUSE_SECS
    } else {
        FAIL_PAUSE_SECS
    };

    // 7. Damage-jump: too many failures → skip ahead by an escalating
    //    gap. Multiplier capped so we can't accidentally skip the
    //    entire rest of the disc (observed 2026-05-07: a saturated
    //    multiplier produced a 56 GB jump). Saturating arithmetic on
    //    the sector calc as defence in depth.
    //
    //    Jump base bumped 2026-05-10 from 256 to 1024 sectors per
    //    multiplier unit (= 64 MB first jump at batch=32, up from
    //    16 MB). The smaller base routinely landed jumps back inside
    //    damage clusters of 100+ MB, each landing adding to the
    //    firmware's wedge counter. 64 MB initial + 128 MB second +
    //    256 MB third clears almost any single-cluster damage
    //    pattern we've seen in 2 jumps.
    //
    //    Two triggers, evaluated in order:
    //
    //    a. **Fast-entry** — `consecutive_outer_failures >= fast_jump_threshold`.
    //       Fires on Pass 1 (threshold=1) so we don't spend ~40 min
    //       grinding to fill a 16-block damage window before the
    //       first jump on a damage zone we entered cleanly. Doesn't
    //       fire on Pass N (threshold=u64::MAX).
    //
    //    b. **Window-based** — original behaviour: 12% bad in a
    //       sliding window of 16 outer reads. Pass N's only path,
    //       and Pass 1's fallback if the failures are scattered
    //       enough that we don't hit the consecutive threshold.
    const MAX_JUMP_MULTIPLIER: u64 = 64;
    let fast_trigger = !ctx.bisecting && ctx.consecutive_outer_failures >= ctx.fast_jump_threshold;
    let window_trigger =
        ctx.damage_window.len() >= ctx.damage_window_max && bad_pct >= ctx.damage_threshold_pct;
    if fast_trigger || window_trigger {
        let mult = ctx.jump_multiplier.min(MAX_JUMP_MULTIPLIER);
        let sectors = JUMP_BASE_SECTORS
            .saturating_mul(ctx.batch as u64)
            .saturating_mul(mult);
        ctx.jump_multiplier = (ctx.jump_multiplier.saturating_mul(2)).min(MAX_JUMP_MULTIPLIER);
        // Reset the outer-failure counter so a long damaged region
        // doesn't keep firing fast-jump every read after the initial
        // jump fired. The window-based trigger handles further jumps.
        ctx.consecutive_outer_failures = 0;
        ctx.jumps_taken += 1;
        return ReadAction::JumpAhead {
            sectors,
            pause_secs: pause_secs + POST_JUMP_EXTRA_PAUSE_SECS,
        };
    }

    // 8. Default: zero-fill the failed batch as NonTrimmed and pause
    //    before the next read.
    ReadAction::SkipBlock { pause_secs }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::error::Error;
    use crate::scsi::ScsiSense;

    fn medium_err() -> Error {
        Error::DiscRead {
            sector: 100,
            status: Some(2),
            sense: Some(ScsiSense {
                sense_key: scsi::SENSE_KEY_MEDIUM_ERROR,
                asc: 0x11,
                ascq: 0x05,
            }),
        }
    }

    fn hardware_err() -> Error {
        Error::DiscRead {
            sector: 100,
            status: Some(2),
            sense: Some(ScsiSense {
                sense_key: scsi::SENSE_KEY_HARDWARE_ERROR,
                asc: 0x44,
                ascq: 0x00,
            }),
        }
    }

    fn illegal_request_err() -> Error {
        Error::DiscRead {
            sector: 100,
            status: Some(2),
            sense: Some(ScsiSense {
                sense_key: scsi::SENSE_KEY_ILLEGAL_REQUEST,
                asc: 0x24,
                ascq: 0x00,
            }),
        }
    }

    #[test]
    fn pass_n_marginal_with_batch_gt_1_bisects() {
        let mut ctx = ReadCtx::for_patch(32);
        let action = handle_read_error(&medium_err(), &mut ctx);
        assert_eq!(action, ReadAction::Bisect);
    }

    #[test]
    fn pass_1_marginal_jumps_immediately_not_bisecting() {
        // 2026-05-11 wedge-prevention rewrite: Pass 1 jumps on the
        // FIRST marginal error (fast_jump_threshold=1) rather than
        // SkipBlock. Retrying the same LBA quickly is what triggers
        // the BU40N's firmware fast-fail transition; immediate jump
        // prevents the cascade. Pass N still bisects (its job is
        // per-sector recovery on already-known-bad LBAs).
        let mut ctx = ReadCtx::for_sweep(32);
        let action = handle_read_error(&medium_err(), &mut ctx);
        match action {
            ReadAction::JumpAhead { .. } => {}
            other => panic!("expected JumpAhead on first Pass 1 marginal error, got {other:?}"),
        }
    }

    #[test]
    fn medium_error_with_batch_1_skips() {
        let mut ctx = ReadCtx::for_patch(1);
        let action = handle_read_error(&medium_err(), &mut ctx);
        match action {
            ReadAction::SkipBlock { pause_secs } => assert!(pause_secs >= 1),
            other => panic!("expected SkipBlock, got {other:?}"),
        }
    }

    #[test]
    fn medium_error_while_bisecting_does_not_recurse() {
        let mut ctx = ReadCtx::for_patch(32);
        ctx.bisecting = true;
        let action = handle_read_error(&medium_err(), &mut ctx);
        match action {
            ReadAction::SkipBlock { .. } => {}
            other => panic!("expected SkipBlock, got {other:?}"),
        }
    }

    #[test]
    fn pass_1_jumps_immediately_on_first_outer_failure() {
        // 2026-05-11 rewrite: fast_jump_threshold is 1 on Pass 1, not
        // 4. Even ONE error triggers a jump because BU40N's firmware
        // fast-fail mode is sensitive to retry cadence. The wedge
        // observed 2026-05-11 happened at 7 errors / 6.5s — by then
        // we were already wedged. Jumping on error #1 means we
        // physically can't reach the cascade.
        let mut ctx = ReadCtx::for_sweep(32);
        let a = handle_read_error(&medium_err(), &mut ctx);
        assert!(
            matches!(a, ReadAction::JumpAhead { .. }),
            "expected JumpAhead on first outer failure (fast_jump_threshold=1), got {a:?}"
        );
    }

    #[test]
    fn pass_n_does_not_fast_jump() {
        // Pass N's whole reason to exist is to grind on bad ranges.
        // It should NOT bail after 4 consecutive failures the way
        // Pass 1 does — it bisects and skips with proper recovery.
        let mut ctx = ReadCtx::for_patch(32);
        for _ in 0..4 {
            let a = handle_read_error(&medium_err(), &mut ctx);
            assert!(
                !matches!(a, ReadAction::JumpAhead { .. }),
                "Pass N must not fast-jump; got {a:?}"
            );
        }
    }

    #[test]
    fn outer_success_resets_consecutive_outer_failures() {
        // With fast_jump_threshold=1 each Pass 1 error fires a jump
        // and resets `consecutive_outer_failures` to 0 inside the
        // handler. So we can't accumulate "3" the old way — instead,
        // verify the counter goes back to 0 after on_success too.
        let mut ctx = ReadCtx::for_sweep(32);
        handle_read_error(&medium_err(), &mut ctx);
        // After fast-jump, consecutive_outer_failures already 0.
        assert_eq!(ctx.consecutive_outer_failures, 0);
        // on_success keeps it at 0 (defensive).
        ctx.bisecting = false;
        ctx.on_success();
        assert_eq!(ctx.consecutive_outer_failures, 0);
    }

    #[test]
    fn bisect_inner_success_does_not_reset_outer_counter() {
        let mut ctx = ReadCtx::for_patch(32);
        for _ in 0..3 {
            handle_read_error(&medium_err(), &mut ctx);
        }
        assert_eq!(ctx.consecutive_outer_failures, 3);
        // A successful inner-sector read during bisect is not the
        // same as escaping the bad outer batch.
        ctx.bisecting = true;
        ctx.on_success();
        assert_eq!(
            ctx.consecutive_outer_failures, 3,
            "bisect inner success must not reset outer-failure counter"
        );
    }

    #[test]
    fn pass_1_hardware_error_jumps_ahead_not_aborts() {
        // New wedge-skip policy: Pass 1 (bisect_on_marginal=false)
        // should JumpAhead with a 1 GB skip + cooldown pause instead
        // of immediately aborting. Aborting on first wedge was the
        // pre-fix behavior that killed rips at 48% on damaged discs.
        let mut ctx = ReadCtx::for_sweep(32);
        let action = handle_read_error(&hardware_err(), &mut ctx);
        match action {
            ReadAction::JumpAhead {
                sectors,
                pause_secs,
            } => {
                assert_eq!(sectors, WEDGE_JUMP_SECTORS);
                assert_eq!(pause_secs, WEDGE_PAUSE_SECS);
            }
            other => panic!("expected JumpAhead, got {other:?}"),
        }
        assert_eq!(ctx.wedge_count, 1);
    }

    #[test]
    fn pass_1_hardware_error_aborts_after_threshold() {
        // After WEDGE_ABORT_THRESHOLD consecutive wedges with no good
        // read in between, autorip should see a real AbortPass so it
        // can surface "drive is stuck, power-cycle required" to the
        // user — rather than looping forever on a permanently bricked
        // drive.
        let mut ctx = ReadCtx::for_sweep(32);
        for i in 0..WEDGE_ABORT_THRESHOLD - 1 {
            let action = handle_read_error(&hardware_err(), &mut ctx);
            assert!(
                matches!(action, ReadAction::JumpAhead { .. }),
                "iter {i}: expected JumpAhead, got {action:?}"
            );
        }
        // The Nth wedge crosses the threshold.
        let action = handle_read_error(&hardware_err(), &mut ctx);
        assert_eq!(action, ReadAction::AbortPass);
    }

    #[test]
    fn pass_1_good_read_resets_wedge_count() {
        // A single successful read between wedges must clear the
        // skip counter — otherwise a disc with a few scattered bad
        // zones would eventually run out of skip budget even though
        // the drive was recovering between zones.
        let mut ctx = ReadCtx::for_sweep(32);
        for _ in 0..(WEDGE_ABORT_THRESHOLD - 1) {
            handle_read_error(&hardware_err(), &mut ctx);
        }
        assert_eq!(ctx.wedge_count, WEDGE_ABORT_THRESHOLD - 1);
        ctx.on_success();
        assert_eq!(ctx.wedge_count, 0);
        // After the success, we should still get JumpAhead (not
        // AbortPass) on the next wedge.
        let action = handle_read_error(&hardware_err(), &mut ctx);
        assert!(matches!(action, ReadAction::JumpAhead { .. }));
    }

    #[test]
    fn pass_n_hardware_error_also_skips_not_aborts() {
        // 2026-05-11 reframe: error handling is centralized, the
        // wedge is a code-induced state, and the avoidance principle
        // (skip + pause + continue) applies to Pass N too. Previously
        // Pass N AbortPass'd on first wedge — same fatal-at-48% bug
        // Pass 1 had pre-fix. Now Pass N gets a smaller skip
        // (WEDGE_PASS_N_SKIP_SECTORS, not the 1 GB Pass 1 jump)
        // because Pass N's job IS to revisit specific NonTrimmed
        // ranges; over-skipping abandons recoverable sectors.
        let mut ctx = ReadCtx::for_patch(1);
        let action = handle_read_error(&hardware_err(), &mut ctx);
        match action {
            ReadAction::JumpAhead {
                sectors,
                pause_secs,
            } => {
                assert_eq!(sectors, WEDGE_PASS_N_SKIP_SECTORS);
                assert_eq!(pause_secs, WEDGE_PAUSE_SECS);
            }
            other => panic!("expected JumpAhead, got {other:?}"),
        }
        assert_eq!(ctx.wedge_count, 1);
    }

    #[test]
    fn pass_n_hardware_error_aborts_after_threshold() {
        // Same threshold as Pass 1 — after WEDGE_ABORT_THRESHOLD
        // consecutive wedges with no good read in between, give up.
        let mut ctx = ReadCtx::for_patch(1);
        for _ in 0..WEDGE_ABORT_THRESHOLD - 1 {
            let action = handle_read_error(&hardware_err(), &mut ctx);
            assert!(matches!(action, ReadAction::JumpAhead { .. }));
        }
        let action = handle_read_error(&hardware_err(), &mut ctx);
        assert_eq!(action, ReadAction::AbortPass);
    }

    #[test]
    fn pass_1_illegal_request_also_routes_to_wedge_skip() {
        // ILLEGAL_REQUEST is the other half of the wedge family:
        // drive saying "I won't parse your CDB" after entering the
        // fast-fail state. Same treatment as HARDWARE_ERROR.
        let mut ctx = ReadCtx::for_sweep(32);
        let action = handle_read_error(&illegal_request_err(), &mut ctx);
        assert!(matches!(action, ReadAction::JumpAhead { .. }));
    }

    #[test]
    fn long_failure_streak_extends_pause_on_pass_n() {
        // Pass N keeps the cooldown behaviour: after many consecutive
        // failures, pauses extend to give the drive time to recover.
        // Pass 1 explicitly does NOT pause — see
        // `pass_1_does_not_pause_on_skip` below.
        let mut ctx = ReadCtx::for_patch(1);
        for _ in 0..15 {
            handle_read_error(&medium_err(), &mut ctx);
        }
        let final_action = handle_read_error(&medium_err(), &mut ctx);
        match final_action {
            ReadAction::SkipBlock { pause_secs } => {
                assert!(pause_secs >= CONSECUTIVE_FAIL_LONG_PAUSE_SECS);
            }
            ReadAction::JumpAhead { pause_secs, .. } => {
                assert!(pause_secs >= CONSECUTIVE_FAIL_LONG_PAUSE_SECS);
            }
            other => panic!("expected long-pause action, got {other:?}"),
        }
    }

    #[test]
    fn pass_1_zone_entry_uses_long_cooldown() {
        // 2026-05-11 wedge-prevention rewrite: Pass 1's FIRST error
        // (zone entry) gets a 30 s ZONE_ENTRY_COOLDOWN_SECS pause +
        // a 2 s POST_JUMP_EXTRA on top (since we're also jumping).
        // The long pause prevents the retry cadence that triggers
        // firmware fast-fail. Subsequent errors in the same zone fall
        // back to the standard 5 s FAIL_PAUSE_SECS.
        let mut ctx = ReadCtx::for_sweep(32);
        let action = handle_read_error(&medium_err(), &mut ctx);
        match action {
            ReadAction::JumpAhead { pause_secs, .. } => {
                assert_eq!(
                    pause_secs,
                    ZONE_ENTRY_COOLDOWN_SECS + POST_JUMP_EXTRA_PAUSE_SECS,
                    "first-error pause should be 30 + 2 = 32 s"
                );
            }
            other => panic!("expected JumpAhead on first Pass 1 error, got {other:?}"),
        }
    }

    #[test]
    fn pass_1_subsequent_in_zone_errors_skip_long_cooldown() {
        // Regression: the fast-jump path resets consecutive_outer_failures
        // to 0 after each jump, so the next in-zone error re-increments it
        // to 1. Zone-entry must key off the genuine clean->damaged
        // transition (in_damage_zone), not the counter, otherwise every
        // error in a damaged region pays the 30 s cooldown.
        let mut ctx = ReadCtx::for_sweep(32);
        // First error: genuine zone entry, gets the long cooldown.
        let first = handle_read_error(&medium_err(), &mut ctx);
        match first {
            ReadAction::JumpAhead { pause_secs, .. } => assert_eq!(
                pause_secs,
                ZONE_ENTRY_COOLDOWN_SECS + POST_JUMP_EXTRA_PAUSE_SECS
            ),
            other => panic!("expected JumpAhead on first error, got {other:?}"),
        }
        // We are now still in the damage zone; the jump reset the outer
        // counter. A second error must NOT re-arm the 30 s cooldown.
        assert!(ctx.in_damage_zone);
        let second = handle_read_error(&medium_err(), &mut ctx);
        let pause = match second {
            ReadAction::JumpAhead { pause_secs, .. } => pause_secs,
            ReadAction::SkipBlock { pause_secs } => pause_secs,
            other => panic!("expected pausing action, got {other:?}"),
        };
        assert_ne!(
            pause,
            ZONE_ENTRY_COOLDOWN_SECS + POST_JUMP_EXTRA_PAUSE_SECS,
            "subsequent in-zone error must not pay the 30 s zone-entry cooldown"
        );
        assert!(
            pause <= FAIL_PAUSE_SECS + POST_JUMP_EXTRA_PAUSE_SECS,
            "subsequent in-zone pause should be the standard fail pause, got {pause}"
        );
    }

    #[test]
    fn pass_n_pauses_uniformly_on_failed_read() {
        // Pass N (bisect_on_marginal=true) is exempt from the
        // zone-entry long pause — its whole job is to retry single
        // sectors on already-known-bad LBAs, and the 30 s pause every
        // single-sector failure would multiply slow recovery
        // pointlessly. Pass N keeps the standard 5 s FAIL_PAUSE_SECS.
        let mut ctx = ReadCtx::for_patch(1);
        let action = handle_read_error(&medium_err(), &mut ctx);
        match action {
            ReadAction::SkipBlock { pause_secs } => assert_eq!(pause_secs, FAIL_PAUSE_SECS),
            ReadAction::JumpAhead { pause_secs, .. } => {
                assert_eq!(pause_secs, FAIL_PAUSE_SECS + POST_JUMP_EXTRA_PAUSE_SECS)
            }
            ReadAction::Bisect => {}
            other => panic!("expected pausing action, got {other:?}"),
        }
    }

    #[test]
    fn damage_window_fills_then_jumps() {
        let mut ctx = ReadCtx::for_sweep(1);
        ctx.damage_window_max = 4;
        ctx.damage_threshold_pct = 50;
        let mut saw_jump = false;
        for _ in 0..6 {
            let a = handle_read_error(&medium_err(), &mut ctx);
            if matches!(a, ReadAction::JumpAhead { .. }) {
                saw_jump = true;
                break;
            }
        }
        assert!(
            saw_jump,
            "expected at least one JumpAhead in 6 failures with 50% threshold"
        );
    }

    #[test]
    fn jump_multiplier_resets_after_damage_zone_exit() {
        // A zone that doubles the multiplier must not carry the inflated
        // value into the next zone — otherwise the next zone's first
        // jump is up to 64x oversized and skips recoverable data.
        let mut ctx = ReadCtx::for_sweep(32);
        // First zone: a few errors push jumps and double the multiplier.
        for _ in 0..4 {
            handle_read_error(&medium_err(), &mut ctx);
        }
        assert!(
            ctx.jump_multiplier > 1,
            "expected the multiplier to inflate inside a damage zone"
        );
        // Exit the zone: damage_window_max consecutive good reads.
        ctx.bisecting = false;
        for _ in 0..ctx.damage_window_max {
            ctx.on_success();
        }
        assert!(!ctx.in_damage_zone, "zone should have exited");
        assert_eq!(
            ctx.jump_multiplier, 1,
            "jump_multiplier must reset to 1 on zone exit"
        );
    }

    #[test]
    fn bridge_degradation_count_resets_on_success() {
        // After a good read the bridge recovered; the 15s-cooldown retry
        // budget must be available again instead of staying saturated
        // for the whole pass.
        let mut ctx = ReadCtx::for_patch(1);
        ctx.bridge_degradation_count = BRIDGE_DEGRADATION_MAX_RETRIES;
        ctx.on_success();
        assert_eq!(ctx.bridge_degradation_count, 0);
    }

    #[test]
    fn wedge_abort_reachable_during_bisect() {
        // A drive that wedges mid-bisect must still reach the abort
        // threshold rather than burning a WEDGE_PAUSE cooldown per inner
        // sector forever.
        let mut ctx = ReadCtx::for_patch(32);
        ctx.bisecting = true;
        let mut aborted = false;
        for _ in 0..WEDGE_ABORT_THRESHOLD {
            if matches!(
                handle_read_error(&hardware_err(), &mut ctx),
                ReadAction::AbortPass
            ) {
                aborted = true;
                break;
            }
        }
        assert!(
            aborted,
            "wedge abort threshold must be reachable from inside a bisect"
        );
    }

    #[test]
    fn on_success_resets_failure_counters_and_pushes_window() {
        let mut ctx = ReadCtx::for_sweep(32);
        for _ in 0..3 {
            handle_read_error(&medium_err(), &mut ctx);
        }
        assert!(ctx.consecutive_failures > 0);
        ctx.bisecting = false;
        ctx.on_success();
        assert_eq!(ctx.consecutive_good, 1);
        assert_eq!(ctx.consecutive_failures, 0);
        assert!(*ctx.damage_window.last().unwrap());
    }

    // ----------------------------------------------------------------
    // Additional hardening: retry-budget boundaries, transport-abort
    // precedence, and the bounded-jump invariant. These guard against
    // off-by-one in the retry caps (which would either hammer a wedging
    // drive or give up a recovery one attempt early) and against an
    // unbounded jump multiplier skipping the rest of the disc.
    // ----------------------------------------------------------------

    /// NOT_READY check-condition (status 0x02 so it is NOT classified as
    /// bridge degradation, which keys off non-standard status bytes).
    /// sense_key=2 with a generic ASC routes to the NOT_READY retry path.
    fn not_ready_err() -> Error {
        Error::DiscRead {
            sector: 100,
            status: Some(crate::scsi::SCSI_STATUS_CHECK_CONDITION),
            sense: Some(ScsiSense {
                sense_key: scsi::SENSE_KEY_NOT_READY,
                asc: 0x04,
                ascq: 0x00,
            }),
        }
    }

    /// Transport failure: SCSI status 0xFF (bridge crash). CLAUDE.md
    /// "Bad-sector handling": this aborts the copy.
    fn transport_failure_err() -> Error {
        Error::DiscRead {
            sector: 100,
            status: Some(crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE),
            sense: None,
        }
    }

    /// Bridge degradation: a non-standard status byte (0x04 - neither
    /// GOOD/CHECK/TRANSPORT) with empty sense, per `Error::is_bridge_degradation`.
    fn bridge_degradation_err() -> Error {
        Error::DiscRead {
            sector: 100,
            status: Some(0x04),
            sense: None,
        }
    }

    #[test]
    fn not_ready_retries_capped_at_three_then_falls_through() {
        // CLAUDE.md "Bad-sector handling" mode 1: NOT READY -> "Pause 3s,
        // retry up to 3x, then mark NonTrimmed." NOT_READY_MAX_RETRIES=3.
        // The 1st-3rd NOT_READY must Retry; the 4th must NOT Retry (it
        // falls through to skip). Pass N (batch=1) so the marginal-bisect
        // branch is irrelevant.
        // Mutation that makes this RED: change `ctx.not_ready_retries <
        // NOT_READY_MAX_RETRIES` to `<=` (retries 4 times) or to `>`
        // (never retries).
        let mut ctx = ReadCtx::for_patch(1);
        for i in 0..NOT_READY_MAX_RETRIES {
            let a = handle_read_error(&not_ready_err(), &mut ctx);
            assert!(
                matches!(a, ReadAction::Retry { .. }),
                "NOT_READY attempt {i} should Retry, got {a:?}"
            );
        }
        // Budget exhausted: the next NOT_READY must not Retry.
        let a = handle_read_error(&not_ready_err(), &mut ctx);
        assert!(
            !matches!(a, ReadAction::Retry { .. }),
            "NOT_READY past the retry cap must fall through, got {a:?}"
        );
    }

    #[test]
    fn transport_failure_aborts_even_mid_bisect() {
        // CLAUDE.md "Bad-sector handling" mode 2: a transport failure
        // (bridge crash, status 0xFF) aborts the pass so the outer loop
        // can re-enumerate the bridge. This must hold even while
        // bisecting and even on Pass N - the wedge-skip/jump paths must
        // NOT swallow a real transport crash into a JumpAhead.
        // Mutation that makes this RED: move the transport-failure check
        // below the HARDWARE/ILLEGAL wedge arm, so a transport failure
        // that also carried a wedge-family sense would JumpAhead instead.
        let mut ctx = ReadCtx::for_patch(32);
        ctx.bisecting = true;
        assert_eq!(
            handle_read_error(&transport_failure_err(), &mut ctx),
            ReadAction::AbortPass
        );
        // And on a fresh Pass 1 context, still AbortPass.
        let mut ctx1 = ReadCtx::for_sweep(32);
        assert_eq!(
            handle_read_error(&transport_failure_err(), &mut ctx1),
            ReadAction::AbortPass
        );
    }

    #[test]
    fn bridge_degradation_retries_to_budget_then_falls_through() {
        // The bridge-degradation cooldown retry is bounded by
        // BRIDGE_DEGRADATION_MAX_RETRIES (=5). The first 5 degradation
        // errors must Retry with the long bridge cooldown; the 6th must
        // fall through to skip/jump rather than retrying forever and
        // stalling the pass.
        // Mutation that makes this RED: change the budget comparison
        // `ctx.bridge_degradation_count < BRIDGE_DEGRADATION_MAX_RETRIES`
        // to `<=` (retries 6 times).
        let mut ctx = ReadCtx::for_patch(1);
        for i in 0..BRIDGE_DEGRADATION_MAX_RETRIES {
            let a = handle_read_error(&bridge_degradation_err(), &mut ctx);
            match a {
                ReadAction::Retry { pause_secs } => {
                    assert_eq!(
                        pause_secs, BRIDGE_DEGRADATION_PAUSE_SECS,
                        "bridge retry {i} should use the bridge cooldown"
                    );
                }
                other => panic!("bridge degradation attempt {i} should Retry, got {other:?}"),
            }
        }
        let a = handle_read_error(&bridge_degradation_err(), &mut ctx);
        assert!(
            !matches!(a, ReadAction::Retry { .. }),
            "bridge degradation past the retry budget must fall through, got {a:?}"
        );
    }

    /// The documented BU40N bad-sector signature: NOT_READY
    /// (sense_key=2, ASC=0x04, ASCQ=0x3E) delivered as a CHECK CONDITION
    /// (status 0x02). This is the case the old comment on the bridge
    /// branch wrongly claimed `is_bridge_degradation` matched.
    fn not_ready_04_3e_err() -> Error {
        Error::DiscRead {
            sector: 100,
            status: Some(crate::scsi::SCSI_STATUS_CHECK_CONDITION),
            sense: Some(ScsiSense {
                sense_key: scsi::SENSE_KEY_NOT_READY,
                asc: 0x04,
                ascq: 0x3E,
            }),
        }
    }

    #[test]
    fn not_ready_04_3e_does_not_take_bridge_branch() {
        // Regression guard for the misleading-comment fix: the bridge
        // branch keys on the *status byte* (non-standard, i.e. not
        // GOOD/CHECK/TRANSPORT), NOT on the NOT_READY 04/3E sense. A real
        // 04/3E bad-sector error arrives as CHECK CONDITION (0x02), so
        // `is_bridge_degradation()` must be false for it, and it must
        // route to the generic NOT_READY retry (3 s pause) rather than
        // the bridge cooldown (15 s pause).
        let err = not_ready_04_3e_err();
        assert!(
            !err.is_bridge_degradation(),
            "04/3E arrives as CHECK CONDITION (0x02); it is not bridge degradation"
        );

        let mut ctx = ReadCtx::for_patch(1);
        match handle_read_error(&err, &mut ctx) {
            ReadAction::Retry { pause_secs } => {
                assert_eq!(
                    pause_secs, NOT_READY_PAUSE_SECS,
                    "04/3E must use the generic NOT_READY pause, not the bridge cooldown"
                );
                assert_ne!(
                    pause_secs, BRIDGE_DEGRADATION_PAUSE_SECS,
                    "04/3E must not take the bridge-degradation branch"
                );
                // Confirm it really went through the NOT_READY path.
                assert_eq!(ctx.not_ready_retries, 1);
                assert_eq!(ctx.bridge_degradation_count, 0);
            }
            other => panic!("04/3E should Retry via the NOT_READY path, got {other:?}"),
        }
    }

    #[test]
    fn jump_multiplier_caps_and_jump_distance_stays_bounded() {
        // CLAUDE.md damage-jump: multiplier doubles per jump but is
        // capped at MAX_JUMP_MULTIPLIER=64 (the "4 GiB cap"); a single
        // jump must never be allowed to grow without bound and skip the
        // rest of the disc. Drive a long single-sector failure streak on
        // a sweep ctx with a tiny window so window-trigger jumps fire
        // repeatedly, and verify the multiplier saturates at 64 and the
        // emitted jump distance equals JUMP_BASE_SECTORS * batch * 64.
        // Mutation that makes this RED: remove the
        // `.min(MAX_JUMP_MULTIPLIER)` on the multiplier doubling, or use
        // wrapping/non-saturating mul -> distance overshoots or panics.
        const MAX_JUMP_MULTIPLIER: u64 = 64;
        let batch: u16 = 32;
        let mut ctx = ReadCtx::for_sweep(batch);
        // Small window + 0% threshold so every failure can window-trigger
        // a jump and keep doubling the multiplier toward the cap.
        ctx.damage_window_max = 2;
        ctx.damage_threshold_pct = 0;
        let mut last_jump_sectors = 0u64;
        for _ in 0..40 {
            // Reset bisecting flag defensively; these are outer failures.
            ctx.bisecting = false;
            if let ReadAction::JumpAhead { sectors, .. } =
                handle_read_error(&medium_err(), &mut ctx)
            {
                last_jump_sectors = sectors;
            }
            assert!(
                ctx.jump_multiplier <= MAX_JUMP_MULTIPLIER,
                "jump_multiplier {} exceeded the cap {}",
                ctx.jump_multiplier,
                MAX_JUMP_MULTIPLIER
            );
        }
        // After saturation, the jump distance is exactly base*batch*cap.
        let expected = JUMP_BASE_SECTORS * batch as u64 * MAX_JUMP_MULTIPLIER;
        assert_eq!(
            last_jump_sectors, expected,
            "saturated jump distance must equal base*batch*64"
        );
    }
}
