//! Error-handling coverage harness.
//!
//! Reads `tests/error_coverage.toml` and runs one assertion bundle per
//! `[[case]]` entry. For each `wired = true` case whose `trigger_kind`
//! the harness supports, asserts that the documented
//! [`MarcError`](mrrc::MarcError) variant, code, slug, and positional
//! context fire when the parser exercises the trigger. For each
//! `wired = false` case (or one whose `trigger_kind` the harness does
//! not yet implement), prints a skip reason so the gap between
//! documentation and implementation is visible in CI output.
//!
//! The harness emits `wired: X/Y (skipped: Z)` on every run. The
//! numbers are monotonic over time: when detection lands for a
//! previously-unwired trigger, flipping `wired = false` →
//! `wired = true` in the manifest is sufficient and the assertion
//! picks up the new case automatically.
//!
//! Currently supported `trigger_kind` values:
//!   * `parse_iso2709` — feed bytes to [`MarcReader`](mrrc::MarcReader)
//!     in strict mode and capture the first error
//!   * `parse_holdings` — feed bytes to
//!     [`HoldingsMarcReader`](mrrc::HoldingsMarcReader) in strict mode
//!     and capture the first error (for holdings-reader-only fire sites)
//!   * `parse_authority` — feed bytes to
//!     [`AuthorityMarcReader`](mrrc::AuthorityMarcReader) in strict
//!     mode and capture the first error (for authority-reader-only
//!     fire sites)
//!   * `parse_marcxml` — feed UTF-8 text to
//!     [`mrrc::marcxml::marcxml_to_record`] and capture the error
//!   * `accessor` — parse the fixture cleanly, then call a hard-coded
//!     accessor whose lookup is documented to raise the case's variant
//!     (currently only `e105_field_not_found` →
//!     `Record::get_field_or_err("999")`)
//!   * `io_error` — wrap a `Read` impl that returns `std::io::Error`
//!     from the first read in a [`MarcReader`](mrrc::MarcReader) and
//!     capture the resulting [`MarcError::IoError`](mrrc::MarcError).
//!     This is the raw-io / leader-boundary path: the failure precedes
//!     any record positioning, so the error carries no positional context
//!   * `io_error_parse_path` — read one complete record, then fail the
//!     underlying source while reading the next record's data area, so
//!     the [`MarcError::IoError`](mrrc::MarcError) is enriched with the
//!     in-progress record's `record_index`, `byte_offset`, and
//!     `source_name`
//!   * `parse_iso2709_lenient` — feed bytes to
//!     [`MarcReader`](mrrc::MarcReader) in lenient mode (no recovery
//!     cap) and return the first error in a yielded record's `errors`
//!     [`Arc<Vec<MarcError>>`](mrrc::MarcError) whose `code()` matches
//!     the case. Used for fire sites that surface via `record.errors`
//!     in lenient/permissive rather than as raised `Err`.
//!   * `recovery_cap` — drive a stream of malformed records past
//!     [`MarcReader::with_max_errors`](mrrc::MarcReader::with_max_errors)
//!     in lenient mode and capture the
//!     [`MarcError::FatalReaderError`](mrrc::MarcError) that fires
//!     when the cap trips
//!   * `writer` — construct a record whose serialized length exceeds
//!     the ISO 2709 99999-byte limit and call
//!     [`MarcWriter::write_record`](mrrc::MarcWriter::write_record),
//!     capturing the resulting
//!     [`MarcError::WriterError`](mrrc::MarcError)
//!   * `programmatic_validator` — construct constrained `Leader` /
//!     `Record` state programmatically and invoke
//!     [`RecordStructureValidator`](mrrc::RecordStructureValidator)
//!     directly. Used to exercise defensive checks guarding values
//!     that the byte-parse path cannot produce (e.g.,
//!     `data_base_address > 99999` from a 5-digit ASCII field).
//!   * `programmatic_writer_check` — invoke
//!     [`mrrc::iso2709::check_iso2709_size`] directly with crafted
//!     arguments. Used to exercise defensive branches in the writer's
//!     size-check helper that the writer's normal control flow does
//!     not reach (e.g., `base_address > 99999` while `record_length
//!     ≤ 99999`).
//!
//! The remaining kind (`parse_marcjson`) skips with a per-kind reason
//! — there is no public Rust `str → Record` API for MARCJSON, so the
//! case is exercised on the Python side instead.

use std::fs;
use std::io::{self, Cursor, Read};
use std::path::{Path, PathBuf};

use mrrc::{
    AuthorityMarcReader, Field, HoldingsMarcReader, Leader, MarcReader, MarcWriter, Record,
    RecordStructureValidator, RecoveryMode, Subfield, ValidationLevel,
};
use serde::Deserialize;

#[derive(Debug, Deserialize)]
struct Manifest {
    #[allow(dead_code)]
    schema_version: u32,
    case: Vec<Case>,
}

#[derive(Debug, Deserialize)]
struct Case {
    id: String,
    code: String,
    variant: String,
    slug: String,
    #[serde(default = "default_trigger_kind")]
    trigger_kind: String,
    #[serde(default)]
    trigger_fixture: Option<String>,
    #[allow(dead_code)]
    description: String,
    expected_context: Vec<String>,
    recovery_modes: Vec<String>,
    /// Validation level the harness should use when exercising the
    /// trigger. Optional; defaults to `"structural"` (mrrc's
    /// `MarcReader` default). Cases that require strict-MARC byte
    /// validation (E201, E202, E301) should set this to
    /// `"strict_marc"`.
    #[serde(default)]
    validation_level: Option<String>,
    wired: bool,
    #[serde(default)]
    skip_reason: Option<String>,
}

fn parse_validation_level(case: &Case) -> ValidationLevel {
    match case.validation_level.as_deref() {
        None | Some("structural") => ValidationLevel::Structural,
        Some("strict_marc") => ValidationLevel::StrictMarc,
        Some(other) => panic!(
            "case {}: unknown validation_level {:?} (expected \"structural\" or \"strict_marc\")",
            case.id, other
        ),
    }
}

fn default_trigger_kind() -> String {
    "parse_iso2709".to_string()
}

fn manifest_path() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/error_coverage.toml")
}

fn load_manifest() -> Manifest {
    let text = fs::read_to_string(manifest_path()).expect("read error_coverage.toml");
    toml::from_str(&text).expect("parse error_coverage.toml")
}

fn fixture_path(case: &Case) -> PathBuf {
    let rel = case.trigger_fixture.as_ref().unwrap_or_else(|| {
        panic!(
            "case {} has trigger_kind requiring a fixture but none set",
            case.id
        )
    });
    Path::new(env!("CARGO_MANIFEST_DIR")).join(rel)
}

fn fixture_bytes(case: &Case) -> Vec<u8> {
    let path = fixture_path(case);
    fs::read(&path).unwrap_or_else(|e| panic!("read fixture {}: {e}", path.display()))
}

fn fixture_text(case: &Case) -> String {
    let path = fixture_path(case);
    fs::read_to_string(&path).unwrap_or_else(|e| panic!("read fixture {}: {e}", path.display()))
}

/// Outcome of attempting to fire a case's documented trigger:
/// either an error fired (and we can assert against it) or the
/// harness chose not to exercise this trigger and produced a reason
/// to record as a skip.
enum TriggerOutcome {
    Fired(mrrc::MarcError),
    NoError,
    UnsupportedKind(String),
}

/// Exercise an accessor case: parse the fixture cleanly with the
/// appropriate reader type, then invoke the accessor whose lookup is
/// documented to fire the case's variant. New `accessor` cases need
/// their case-id branch wired here — accessor names, arguments, and
/// the reader type aren't yet expressed in the manifest schema.
fn exercise_accessor(case: &Case) -> TriggerOutcome {
    let bytes = fixture_bytes(case);

    match case.id.as_str() {
        "e105_field_not_found" => {
            let mut reader =
                MarcReader::new(Cursor::new(bytes)).with_recovery_mode(RecoveryMode::Strict);
            let Ok(Some(record)) = reader.read_record() else {
                return TriggerOutcome::UnsupportedKind(format!(
                    "{}: bibliographic fixture did not parse to a record",
                    case.id
                ));
            };
            match record.get_field_or_err("999") {
                Ok(_) => TriggerOutcome::NoError,
                Err(e) => TriggerOutcome::Fired(e),
            }
        },
        "e105_authority_field_not_found" => {
            let mut reader = AuthorityMarcReader::new(Cursor::new(bytes))
                .with_recovery_mode(RecoveryMode::Strict);
            let Ok(Some(record)) = reader.read_record() else {
                return TriggerOutcome::UnsupportedKind(format!(
                    "{}: authority fixture did not parse to a record",
                    case.id
                ));
            };
            match record.get_field_or_err("999") {
                Ok(_) => TriggerOutcome::NoError,
                Err(e) => TriggerOutcome::Fired(e),
            }
        },
        "e105_holdings_field_not_found" => {
            let mut reader = HoldingsMarcReader::new(Cursor::new(bytes))
                .with_recovery_mode(RecoveryMode::Strict);
            let Ok(Some(record)) = reader.read_record() else {
                return TriggerOutcome::UnsupportedKind(format!(
                    "{}: holdings fixture did not parse to a record",
                    case.id
                ));
            };
            match record.get_field_or_err("999") {
                Ok(_) => TriggerOutcome::NoError,
                Err(e) => TriggerOutcome::Fired(e),
            }
        },
        other => TriggerOutcome::UnsupportedKind(format!(
            "trigger_kind=accessor: case {other} has no harness branch; add one in exercise_accessor"
        )),
    }
}

/// Drive `MarcReader` over `bytes` in `mode` at `level` and return
/// the first error encountered. Successful records and EOF
/// (`Ok(None)`) both resolve to `None`.
fn first_iso2709_error(
    bytes: &[u8],
    mode: RecoveryMode,
    level: ValidationLevel,
) -> Option<mrrc::MarcError> {
    let mut reader = MarcReader::new(Cursor::new(bytes))
        .with_recovery_mode(mode)
        .with_validation_level(level);
    loop {
        match reader.read_record() {
            Ok(Some(_)) => {},
            Ok(None) => return None,
            Err(e) => return Some(e),
        }
    }
}

/// Drive `HoldingsMarcReader` in strict mode and return the first
/// `Err`. Mirrors `first_iso2709_error` for the holdings reader type.
fn first_holdings_error(bytes: &[u8], level: ValidationLevel) -> Option<mrrc::MarcError> {
    let mut reader = HoldingsMarcReader::new(Cursor::new(bytes))
        .with_recovery_mode(RecoveryMode::Strict)
        .with_validation_level(level);
    loop {
        match reader.read_record() {
            Ok(Some(_)) => {},
            Ok(None) => return None,
            Err(e) => return Some(e),
        }
    }
}

/// Drive `AuthorityMarcReader` in strict mode and return the first
/// `Err`. Mirrors `first_iso2709_error` for the authority reader type.
fn first_authority_error(bytes: &[u8], level: ValidationLevel) -> Option<mrrc::MarcError> {
    let mut reader = AuthorityMarcReader::new(Cursor::new(bytes))
        .with_recovery_mode(RecoveryMode::Strict)
        .with_validation_level(level);
    loop {
        match reader.read_record() {
            Ok(Some(_)) => {},
            Ok(None) => return None,
            Err(e) => return Some(e),
        }
    }
}

/// `Read` impl that returns `std::io::Error` on the first read.
/// Used to exercise the parser's underlying-stream-failure path
/// for E007 `IoError` without touching the filesystem.
struct FailingReader;

impl Read for FailingReader {
    fn read(&mut self, _buf: &mut [u8]) -> io::Result<usize> {
        Err(io::Error::new(
            io::ErrorKind::Other,
            "synthetic read failure",
        ))
    }
}

/// Build a single record with a structurally invalid directory entry
/// (non-digit field-length bytes). In lenient mode each such record
/// increments the recovery error counter once; concatenating N+1 of
/// these and configuring `with_max_errors(N)` trips the cap on the
/// (N+1)th read. Mirrors `build_bad_record` in `src/reader.rs`'s
/// unit-test module.
fn build_bad_record() -> Vec<u8> {
    const FIELD_TERMINATOR: u8 = 0x1E;
    const RECORD_TERMINATOR: u8 = 0x1D;

    let mut directory = Vec::new();
    directory.extend_from_slice(b"245ABCD00000");
    directory.push(FIELD_TERMINATOR);

    let base_address = 24 + directory.len();
    let record_length = base_address + 1;

    let mut leader = Vec::new();
    leader.extend_from_slice(format!("{record_length:05}").as_bytes());
    leader.extend_from_slice(b"nam a22");
    leader.extend_from_slice(format!("{base_address:05}").as_bytes());
    leader.extend_from_slice(b" i 4500");

    let mut out = Vec::new();
    out.extend_from_slice(&leader);
    out.extend_from_slice(&directory);
    out.push(RECORD_TERMINATOR);
    out
}

/// Build a minimal structurally valid ISO 2709 record carrying a single
/// `001` control field. Used by the `io_error_parse_path` trigger to get
/// the parser past the leader read so the synthetic failure lands in
/// `read_record_data`'s context-carrying path rather than the leader
/// boundary.
fn build_valid_record(control_001: &str) -> Vec<u8> {
    const FIELD_TERMINATOR: u8 = 0x1E;
    const RECORD_TERMINATOR: u8 = 0x1D;

    let mut field_data = Vec::new();
    field_data.extend_from_slice(control_001.as_bytes());
    field_data.push(FIELD_TERMINATOR);

    let mut directory = Vec::new();
    directory.extend_from_slice(b"001");
    directory.extend_from_slice(format!("{:04}", field_data.len()).as_bytes());
    directory.extend_from_slice(b"00000");
    directory.push(FIELD_TERMINATOR);

    let base_address = 24 + directory.len();
    let record_length = base_address + field_data.len() + 1;

    let mut leader = Vec::new();
    leader.extend_from_slice(format!("{record_length:05}").as_bytes());
    leader.extend_from_slice(b"nam a22");
    leader.extend_from_slice(format!("{base_address:05}").as_bytes());
    leader.extend_from_slice(b" i 4500");

    let mut out = Vec::new();
    out.extend_from_slice(&leader);
    out.extend_from_slice(&directory);
    out.extend_from_slice(&field_data);
    out.push(RECORD_TERMINATOR);
    out
}

/// `Read` impl that serves a prepared buffer, then returns an `io::Error`
/// (rather than a clean `Ok(0)` EOF) once exhausted — driving the parser
/// into the underlying-read-failure path of `read_record_data`.
struct FailAfterBuffer {
    data: Vec<u8>,
    pos: usize,
}

impl Read for FailAfterBuffer {
    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
        if self.pos >= self.data.len() {
            return Err(io::Error::new(
                io::ErrorKind::Other,
                "synthetic mid-record read failure",
            ));
        }
        let n = std::cmp::min(buf.len(), self.data.len() - self.pos);
        buf[..n].copy_from_slice(&self.data[self.pos..self.pos + n]);
        self.pos += n;
        Ok(n)
    }
}

#[allow(clippy::too_many_lines)] // one branch per trigger_kind; grows linearly with coverage
fn fire_trigger(case: &Case) -> TriggerOutcome {
    match case.trigger_kind.as_str() {
        "parse_iso2709" => match first_iso2709_error(
            &fixture_bytes(case),
            RecoveryMode::Strict,
            parse_validation_level(case),
        ) {
            Some(e) => TriggerOutcome::Fired(e),
            None => TriggerOutcome::NoError,
        },
        "parse_iso2709_lenient" => {
            // Drive MarcReader in lenient with no recovery cap and return
            // the first error in a yielded record's `errors` Arc whose
            // `code()` matches `case.code`. Lenient mode accumulates
            // multiple errors per record (a truncated record with a
            // malformed directory entry pushes both E005 and E106), so
            // matching on the case's code lets one trigger drive multiple
            // cases over the same fixture without depending on push order.
            let bytes = fixture_bytes(case);
            let mut reader = MarcReader::new(Cursor::new(&bytes[..]))
                .with_recovery_mode(RecoveryMode::Lenient)
                .with_max_errors(0)
                .with_validation_level(parse_validation_level(case));
            loop {
                match reader.read_record() {
                    Ok(Some(record)) => {
                        if let Some(err) =
                            record.errors.iter().find(|e| e.code() == case.code)
                        {
                            return TriggerOutcome::Fired(err.clone());
                        }
                    },
                    Ok(None) => return TriggerOutcome::NoError,
                    // A lenient stream shouldn't raise — but if it does
                    // (e.g. fatal cap, unrecoverable structural failure),
                    // surface the error so the harness can compare codes.
                    Err(e) => return TriggerOutcome::Fired(e),
                }
            }
        },
        "parse_marcxml" => {
            let text = fixture_text(case);
            match mrrc::marcxml::marcxml_to_record(&text) {
                Ok(_) => TriggerOutcome::NoError,
                Err(e) => TriggerOutcome::Fired(e),
            }
        },
        "parse_marcjson" => TriggerOutcome::UnsupportedKind(
            "no public Rust str-to-Record API for MARCJSON; case is exercised in the Python harness".to_string(),
        ),
        "io_error" => {
            let mut reader = MarcReader::new(FailingReader).with_recovery_mode(RecoveryMode::Strict);
            match reader.read_record() {
                Ok(_) => TriggerOutcome::NoError,
                Err(e) => TriggerOutcome::Fired(e),
            }
        },
        "io_error_parse_path" => {
            // Complete record 1, then only the 24-byte leader of record 2.
            // The reader errors when the parser reads record 2's data area,
            // so the IoError is enriched with record 2's positional context
            // (vs. the raw-io leader-boundary From fallback that `io_error`
            // exercises).
            let rec1 = build_valid_record("rec1");
            let rec2 = build_valid_record("rec2");
            let mut stream = rec1;
            stream.extend_from_slice(&rec2[..24]);
            let mut reader = MarcReader::new(FailAfterBuffer {
                data: stream,
                pos: 0,
            })
            .with_recovery_mode(RecoveryMode::Strict)
            .with_source("synthetic-stream.mrc");
            match reader.read_record() {
                Ok(_) => match reader.read_record() {
                    Ok(_) => TriggerOutcome::NoError,
                    Err(e) => TriggerOutcome::Fired(e),
                },
                Err(e) => TriggerOutcome::Fired(e),
            }
        },
        "recovery_cap" => {
            const CAP: usize = 1;
            let mut stream = Vec::new();
            for _ in 0..=(CAP + 1) {
                stream.extend_from_slice(&build_bad_record());
            }
            let mut reader = MarcReader::new(Cursor::new(stream))
                .with_recovery_mode(RecoveryMode::Lenient)
                .with_max_errors(CAP);
            loop {
                match reader.read_record() {
                    Ok(Some(_)) => {},
                    Ok(None) => return TriggerOutcome::NoError,
                    Err(e) => return TriggerOutcome::Fired(e),
                }
            }
        },
        "parse_holdings" => match first_holdings_error(
            &fixture_bytes(case),
            parse_validation_level(case),
        ) {
            Some(e) => TriggerOutcome::Fired(e),
            None => TriggerOutcome::NoError,
        },
        "parse_authority" => match first_authority_error(
            &fixture_bytes(case),
            parse_validation_level(case),
        ) {
            Some(e) => TriggerOutcome::Fired(e),
            None => TriggerOutcome::NoError,
        },
        "programmatic_validator" => exercise_programmatic_validator(case),
        "programmatic_writer_check" => exercise_programmatic_writer_check(case),
        "accessor" => exercise_accessor(case),
        "writer" => exercise_writer(case),
        other => TriggerOutcome::UnsupportedKind(format!("unknown trigger_kind {other:?}")),
    }
}

/// Dispatch by case id within the `programmatic_validator` trigger family.
///
/// Some defensive checks in `RecordStructureValidator` guard against
/// values that the binary parse path cannot produce — e.g.,
/// `data_base_address > 99999` is unreachable from a 5-digit ASCII
/// field but a caller constructing a `Leader` programmatically (public
/// struct, public fields) can supply that value. This dispatch
/// exercises each such defensive check by constructing the bad state
/// in-test and asserting the documented variant surfaces.
fn exercise_programmatic_validator(case: &Case) -> TriggerOutcome {
    let mut leader =
        Leader::from_bytes(b"00150nam a2200061   4500").expect("baseline leader parses");

    match case.id.as_str() {
        "e002_data_base_address_overflow_programmatic" => {
            leader.data_base_address = 100_000;
            match RecordStructureValidator::validate_leader(&leader) {
                Ok(()) => TriggerOutcome::NoError,
                Err(e) => TriggerOutcome::Fired(e),
            }
        },
        other => TriggerOutcome::UnsupportedKind(format!(
            "trigger_kind=programmatic_validator: case {other} has no harness branch; add one in exercise_programmatic_validator"
        )),
    }
}

/// Dispatch by case id within the `programmatic_writer_check` trigger
/// family.
///
/// Some writer-side defensive checks in `iso2709::check_iso2709_size`
/// guard against values that the writer's normal control flow cannot
/// produce (e.g., `base_address > 99999` while `record_length <=
/// 99999` is impossible because `base_address <= record_length`), but
/// the helper is a public function and direct callers can hit the
/// guard. This dispatch exercises each such defensive check by
/// invoking the helper directly with crafted arguments.
fn exercise_programmatic_writer_check(case: &Case) -> TriggerOutcome {
    match case.id.as_str() {
        "e404_check_iso2709_size_base_address_overflow_programmatic" => {
            // `record_length` and `base_address` are independent in the
            // helper's signature; choose values that pass the
            // record_length guard so the test isolates the base_address
            // defensive check.
            match mrrc::iso2709::check_iso2709_size(
                /* record_length */ 1,
                /* base_address */ 100_000,
                /* record_index */ Some(1),
                /* record_control_number */ None,
            ) {
                Ok(()) => TriggerOutcome::NoError,
                Err(e) => TriggerOutcome::Fired(e),
            }
        },
        other => TriggerOutcome::UnsupportedKind(format!(
            "trigger_kind=programmatic_writer_check: case {other} has no harness branch; add one in exercise_programmatic_writer_check"
        )),
    }
}

/// Dispatch by case id within the `writer` trigger family.
///
/// `MarcError::WriterError` (E404) has three distinct production fire
/// sites: the ISO 2709 99999-byte size cap, the
/// `validate_directory_tag` check on every field tag, and the
/// finished-writer reuse guard in `MarcWriter::write_record`. Each
/// gets its own manifest case under the same variant slug; this
/// dispatch picks the right driver.
fn exercise_writer(case: &Case) -> TriggerOutcome {
    let leader =
        Leader::from_bytes(b"00000nam a2200000 i 4500").expect("synthetic minimal leader parses");

    match case.id.as_str() {
        "e404_record_too_large_for_iso2709" => {
            let mut record = Record::new(leader);
            // ~100k subfield value forces the writer's serialized record
            // length past the ISO 2709 99999-byte ceiling.
            let big_value = "x".repeat(100_000);
            let field = Field {
                tag: "999".to_string(),
                indicator1: ' ',
                indicator2: ' ',
                subfields: smallvec::smallvec![Subfield {
                    code: 'a',
                    value: big_value,
                }],
            };
            record.add_field(field);

            let mut buf = Vec::new();
            let mut writer = MarcWriter::new(&mut buf);
            match writer.write_record(&record) {
                Ok(()) => TriggerOutcome::NoError,
                Err(e) => TriggerOutcome::Fired(e),
            }
        },
        "e404_writer_non_ascii_tag" => {
            let mut record = Record::new(leader);
            // 2-byte tag fails validate_directory_tag's "exactly 3
            // ASCII bytes" check on serialization.
            let field = Field {
                tag: "12".to_string(),
                indicator1: ' ',
                indicator2: ' ',
                subfields: smallvec::smallvec![Subfield {
                    code: 'a',
                    value: "x".to_string(),
                }],
            };
            record.add_field(field);

            let mut buf = Vec::new();
            let mut writer = MarcWriter::new(&mut buf);
            match writer.write_record(&record) {
                Ok(()) => TriggerOutcome::NoError,
                Err(e) => TriggerOutcome::Fired(e),
            }
        },
        "e404_writer_finished_writer_reuse" => {
            let record = Record::new(leader);
            let mut buf = Vec::new();
            let mut writer = MarcWriter::new(&mut buf);
            if let Err(e) = writer.finish() {
                return TriggerOutcome::UnsupportedKind(format!(
                    "{}: finish() unexpectedly failed before reuse trigger could fire: {e}",
                    case.id
                ));
            }
            match writer.write_record(&record) {
                Ok(()) => TriggerOutcome::NoError,
                Err(e) => TriggerOutcome::Fired(e),
            }
        },
        other => TriggerOutcome::UnsupportedKind(format!(
            "trigger_kind=writer: case {other} has no harness branch; add one in exercise_writer"
        )),
    }
}

fn json_probe_key(field: &str) -> &str {
    match field {
        "bytes_near" => "bytes_near_hex",
        "found" => "found_hex",
        "source_name" => "source",
        other => other,
    }
}

/// Per-case outcome the harness reports up to the top-level test.
enum WiredOutcome {
    Asserted,
    SkippedByHarness(String),
    Failed(String),
}

/// Exercise one wired case. Returns the outcome so the caller can
/// distinguish assertion failures (manifest claims something the
/// parser doesn't deliver) from harness skips (this harness cannot
/// exercise the case's `trigger_kind`, but the wiring may still be
/// in place and exercised by another harness).
fn run_wired(case: &Case) -> WiredOutcome {
    // The harness's parse_iso2709/parse_marcxml/parse_marcjson/accessor
    // branches all drive the parser in strict mode. Triggers with their
    // own intrinsic mode requirements (recovery_cap drives lenient) carry
    // that mode inside the trigger branch and bypass this gate.
    let strict_only_trigger = !matches!(
        case.trigger_kind.as_str(),
        "recovery_cap" | "parse_iso2709_lenient"
    );
    if strict_only_trigger && !case.recovery_modes.iter().any(|m| m == "strict") {
        return WiredOutcome::SkippedByHarness(
            "case contract does not cover strict mode; non-strict assertions pending".to_string(),
        );
    }

    let err = match fire_trigger(case) {
        TriggerOutcome::Fired(e) => e,
        TriggerOutcome::NoError => {
            return WiredOutcome::Failed(format!(
                "{} ({} / {}): expected {} error, got success",
                case.id, case.code, case.variant, case.code
            ));
        },
        TriggerOutcome::UnsupportedKind(reason) => {
            return WiredOutcome::SkippedByHarness(reason);
        },
    };

    if err.code() != case.code {
        return WiredOutcome::Failed(format!(
            "{} ({}): expected code {}, got {} ({:?})",
            case.id,
            case.variant,
            case.code,
            err.code(),
            err
        ));
    }
    if err.slug() != case.slug {
        return WiredOutcome::Failed(format!(
            "{} ({}): expected slug {:?}, got {:?}",
            case.id,
            case.variant,
            case.slug,
            err.slug()
        ));
    }
    let dict = err.to_json_value();
    for field in &case.expected_context {
        let key = json_probe_key(field);
        let present = dict.get(key).is_some_and(|v| !v.is_null());
        if !present {
            return WiredOutcome::Failed(format!(
                "{} ({}): expected_context field {} not populated (probed via {:?}); error JSON: {}",
                case.id, case.variant, field, key, dict
            ));
        }
    }
    WiredOutcome::Asserted
}

#[test]
fn manifest_is_well_formed() {
    let manifest = load_manifest();
    assert_eq!(manifest.schema_version, 1, "schema_version drift");
    assert!(!manifest.case.is_empty(), "manifest has no cases");

    let mut ids = std::collections::HashSet::new();
    for case in &manifest.case {
        let inserted = ids.insert(case.id.clone());
        assert!(inserted, "duplicate case id {}", case.id);

        if matches!(
            case.trigger_kind.as_str(),
            "parse_iso2709" | "parse_marcxml" | "parse_marcjson" | "accessor"
        ) {
            assert!(
                case.trigger_fixture.is_some(),
                "case {}: trigger_kind {:?} requires a trigger_fixture",
                case.id,
                case.trigger_kind
            );
            let path = fixture_path(case);
            assert!(
                path.exists(),
                "case {}: fixture {} does not exist",
                case.id,
                path.display()
            );
        }

        if !case.wired {
            assert!(
                case.skip_reason.is_some(),
                "case {} is unwired but has no skip_reason",
                case.id
            );
        }
    }
}

#[test]
fn coverage_assertions() {
    let manifest = load_manifest();
    let total = manifest.case.len();
    let mut failures: Vec<String> = Vec::new();
    let mut wired_count = 0usize;
    let mut asserted = 0usize;
    let mut harness_skips: Vec<(String, String)> = Vec::new();
    let mut unwired_skips: Vec<(String, String)> = Vec::new();

    for case in &manifest.case {
        if case.wired {
            wired_count += 1;
            match run_wired(case) {
                WiredOutcome::Asserted => asserted += 1,
                WiredOutcome::SkippedByHarness(reason) => {
                    harness_skips.push((case.id.clone(), reason));
                },
                WiredOutcome::Failed(reason) => failures.push(reason),
            }
        } else {
            let reason = case
                .skip_reason
                .clone()
                .unwrap_or_else(|| "unwired (no reason provided)".into());
            unwired_skips.push((case.id.clone(), reason));
        }
    }

    for (id, reason) in &unwired_skips {
        eprintln!("[error_coverage] UNWIRED {id}: {reason}");
    }
    for (id, reason) in &harness_skips {
        eprintln!("[error_coverage] HARNESS-SKIP {id}: {reason}");
    }
    eprintln!(
        "[error_coverage] wired in manifest: {wired_count}/{total} \
         | harness asserted: {asserted}/{wired_count} \
         | harness skipped: {} (unwired: {}, harness limitations: {})",
        unwired_skips.len() + harness_skips.len(),
        unwired_skips.len(),
        harness_skips.len(),
    );

    assert!(
        failures.is_empty(),
        "wired-case failures:\n  - {}",
        failures.join("\n  - ")
    );
}

/// Path to the `error_classification` fuzz target source. The fuzz crate
/// is a standalone workspace on a separate (nightly) toolchain, so it
/// cannot read this manifest at build time. This test bridges the gap
/// from the root crate, which can read both.
fn error_classification_target_path() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR")).join("fuzz/fuzz_targets/error_classification.rs")
}

/// Extract the string literals in the fuzz target's `DOCUMENTED_CODES`
/// slice. Parses the `const DOCUMENTED_CODES: &[&str] = &[ ... ];`
/// declaration and pulls every `"Exxx"` literal between the brackets.
fn fuzz_documented_codes() -> std::collections::BTreeSet<String> {
    let source = fs::read_to_string(error_classification_target_path())
        .expect("read error_classification.rs");
    let decl = source
        .find("DOCUMENTED_CODES")
        .expect("DOCUMENTED_CODES declaration in error_classification.rs");
    // The type annotation (`&[&str]`) carries its own brackets, so anchor on
    // the `=` and take the first `[` after it — the start of the slice literal.
    let eq = source[decl..]
        .find('=')
        .map(|i| decl + i)
        .expect("`=` after DOCUMENTED_CODES");
    let open = source[eq..]
        .find('[')
        .map(|i| eq + i)
        .expect("opening bracket of DOCUMENTED_CODES slice literal");
    let close = source[open..]
        .find(']')
        .map(|i| open + i)
        .expect("closing bracket of DOCUMENTED_CODES slice");
    source[open + 1..close]
        .split(',')
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .map(|s| s.trim_matches('"').to_string())
        .collect()
}

/// Guard against drift between the error-coverage manifest and the
/// `error_classification` fuzz target's hand-maintained `DOCUMENTED_CODES`
/// list. The target asserts every `Err` it surfaces carries a documented
/// code; if a new code lands in the manifest but not the fuzz list (or
/// vice versa), that assertion silently weakens. This test fails when the
/// two sets diverge, pointing the contributor at the fuzz file to update.
#[test]
fn fuzz_documented_codes_match_manifest() {
    let manifest = load_manifest();
    let manifest_codes: std::collections::BTreeSet<String> =
        manifest.case.iter().map(|c| c.code.clone()).collect();
    let fuzz_codes = fuzz_documented_codes();

    let missing_from_fuzz: Vec<&String> = manifest_codes.difference(&fuzz_codes).collect();
    let extra_in_fuzz: Vec<&String> = fuzz_codes.difference(&manifest_codes).collect();

    assert!(
        missing_from_fuzz.is_empty() && extra_in_fuzz.is_empty(),
        "error_classification DOCUMENTED_CODES has drifted from \
         tests/error_coverage.toml.\n  In manifest but missing from fuzz \
         list (add to fuzz/fuzz_targets/error_classification.rs): {missing_from_fuzz:?}\n  \
         In fuzz list but not in manifest (remove from the fuzz file or add a \
         manifest case): {extra_in_fuzz:?}"
    );
}
