//! Error types for MARC operations.
//!
//! Provides [`MarcError`] for all MARC library operations and the [`Result`]
//! convenience type. Each variant carries structured positional metadata
//! describing where in a stream/record/field the problem occurred — see the
//! per-variant requirements in the documentation for which fields are always
//! populated, may be populated, or are not applicable.
//!
//! All variants are `Send + Sync` so errors can cross thread boundaries used
//! by the parallel parsing paths.

use std::fmt;
use thiserror::Error;

/// Maximum length in bytes retained in a [`MarcError`]'s `found` field.
///
/// Bounds the memory cost of an error in lenient/permissive recovery modes
/// where many errors may be accumulated.
pub const FOUND_BYTES_CAP: usize = 32;

/// Number of bytes retained before the error offset in [`BytesNear`].
pub const BYTES_NEAR_BEFORE: usize = 16;

/// Number of bytes retained after the error offset in [`BytesNear`].
pub const BYTES_NEAR_AFTER: usize = 16;

/// Truncate a byte slice to at most [`FOUND_BYTES_CAP`] bytes.
#[must_use]
pub fn truncate_bytes(input: &[u8]) -> Vec<u8> {
    if input.len() > FOUND_BYTES_CAP {
        input[..FOUND_BYTES_CAP].to_vec()
    } else {
        input.to_vec()
    }
}

/// Byte window captured near the point of an error, used to render a hex
/// dump in [`MarcError::detailed`] output.
///
/// The window is up to [`BYTES_NEAR_BEFORE`] + [`BYTES_NEAR_AFTER`] bytes
/// long and clamped at buffer boundaries; [`Self::start_offset`] is the
/// absolute stream offset of [`Self::bytes`]`[0]` so consumers can align
/// the caret at the offending byte by computing
/// `err.byte_offset - bytes_near.start_offset`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BytesNear {
    /// The captured bytes.
    pub bytes: Vec<u8>,
    /// Absolute stream offset of the first byte in [`Self::bytes`].
    pub start_offset: usize,
}

impl BytesNear {
    /// Capture a window centered on `absolute_offset` from `buffer`, given
    /// the absolute stream offset of `buffer[0]` in `buffer_start`.
    ///
    /// Returns `None` when `absolute_offset` does not fall within
    /// `[buffer_start, buffer_start + buffer.len()]`. Clamps at buffer
    /// boundaries when the window would extend past either end.
    #[must_use]
    pub fn capture(buffer: &[u8], buffer_start: usize, absolute_offset: usize) -> Option<Self> {
        if absolute_offset < buffer_start {
            return None;
        }
        let rel = absolute_offset - buffer_start;
        if rel > buffer.len() {
            return None;
        }
        let window_start = rel.saturating_sub(BYTES_NEAR_BEFORE);
        let window_end = rel.saturating_add(BYTES_NEAR_AFTER).min(buffer.len());
        Some(BytesNear {
            bytes: buffer[window_start..window_end].to_vec(),
            start_offset: buffer_start + window_start,
        })
    }
}

/// Error type for all MARC library operations.
///
/// Each variant carries structured positional metadata: the record index in
/// the stream, byte offsets, the 001 control number, the field/subfield being
/// parsed, and the source filename when known. Optional fields are populated
/// opportunistically — a field that is `None` simply means the information was
/// not available at the point the error was raised, never that it was
/// suppressed.
///
/// The default [`fmt::Display`] impl produces a one-line, actionable summary
/// with byte offset visually subordinate. Use [`MarcError::detailed`] for the
/// multi-line diagnostic format.
#[derive(Error, Debug)]
pub enum MarcError {
    /// The 24-byte leader is malformed or contains values that fail validation.
    InvalidLeader {
        /// 1-based record index in the stream, when known.
        record_index: Option<usize>,
        /// Absolute byte offset within the stream where the error occurred.
        byte_offset: Option<usize>,
        /// Byte offset within the current record (typically 0 for leader errors).
        record_byte_offset: Option<usize>,
        /// Source filename or stream identifier, when known.
        source_name: Option<String>,
        /// Human-readable description of the leader validation failure.
        message: String,
        /// Byte window captured near the error offset, for hex-dump rendering.
        bytes_near: Option<BytesNear>,
    },

    /// The leader's record-length field is invalid (non-numeric, too small, etc.).
    RecordLengthInvalid {
        /// 1-based record index in the stream.
        record_index: Option<usize>,
        /// Absolute byte offset within the stream.
        byte_offset: Option<usize>,
        /// Source filename or stream identifier, when known.
        source_name: Option<String>,
        /// The bytes that triggered the error, capped at [`FOUND_BYTES_CAP`].
        found: Option<Vec<u8>>,
        /// Human-readable description of what was expected.
        expected: Option<String>,
        /// Byte window captured near the error offset, for hex-dump rendering.
        bytes_near: Option<BytesNear>,
    },

    /// The leader's base-address-of-data field is invalid.
    BaseAddressInvalid {
        /// 1-based record index in the stream.
        record_index: Option<usize>,
        /// Absolute byte offset within the stream.
        byte_offset: Option<usize>,
        /// Source filename or stream identifier, when known.
        source_name: Option<String>,
        /// 001 control number, when already extracted.
        record_control_number: Option<String>,
        /// The bytes that triggered the error, capped at [`FOUND_BYTES_CAP`].
        found: Option<Vec<u8>>,
        /// Human-readable description of what was expected.
        expected: Option<String>,
        /// Byte window captured near the error offset, for hex-dump rendering.
        bytes_near: Option<BytesNear>,
    },

    /// The leader claims a base address of data that does not exist in the stream.
    BaseAddressNotFound {
        /// 1-based record index in the stream.
        record_index: Option<usize>,
        /// Absolute byte offset within the stream.
        byte_offset: Option<usize>,
        /// Source filename or stream identifier, when known.
        source_name: Option<String>,
        /// 001 control number, when already extracted.
        record_control_number: Option<String>,
        /// Byte window captured near the error offset, for hex-dump rendering.
        bytes_near: Option<BytesNear>,
    },

    /// A directory entry is structurally invalid (bad tag, length, or start position).
    DirectoryInvalid {
        /// 1-based record index in the stream.
        record_index: Option<usize>,
        /// Absolute byte offset within the stream.
        byte_offset: Option<usize>,
        /// Byte offset within the current record.
        record_byte_offset: Option<usize>,
        /// Source filename or stream identifier, when known.
        source_name: Option<String>,
        /// 001 control number, when already extracted.
        record_control_number: Option<String>,
        /// Field tag of the entry being parsed, when decodable.
        field_tag: Option<String>,
        /// The bytes that triggered the error, capped at [`FOUND_BYTES_CAP`].
        found: Option<Vec<u8>>,
        /// Human-readable description of what was expected.
        expected: Option<String>,
        /// Byte window captured near the error offset, for hex-dump rendering.
        bytes_near: Option<BytesNear>,
    },

    /// The record was truncated mid-stream.
    TruncatedRecord {
        /// 1-based record index in the stream.
        record_index: Option<usize>,
        /// Absolute byte offset within the stream.
        byte_offset: Option<usize>,
        /// Byte offset within the current record where truncation was detected.
        record_byte_offset: Option<usize>,
        /// Source filename or stream identifier, when known.
        source_name: Option<String>,
        /// 001 control number, when already extracted.
        record_control_number: Option<String>,
        /// Expected total record length per the leader.
        expected_length: Option<usize>,
        /// Actual bytes available before truncation.
        actual_length: Option<usize>,
        /// Byte window captured near the error offset, for hex-dump rendering.
        bytes_near: Option<BytesNear>,
    },

    /// The end-of-record marker was not found where expected.
    EndOfRecordNotFound {
        /// 1-based record index in the stream.
        record_index: Option<usize>,
        /// Absolute byte offset within the stream.
        byte_offset: Option<usize>,
        /// Byte offset within the current record.
        record_byte_offset: Option<usize>,
        /// Source filename or stream identifier, when known.
        source_name: Option<String>,
        /// 001 control number, when already extracted.
        record_control_number: Option<String>,
        /// Byte window captured near the error offset, for hex-dump rendering.
        bytes_near: Option<BytesNear>,
    },

    /// An indicator byte was invalid for its position.
    InvalidIndicator {
        /// 1-based record index in the stream.
        record_index: Option<usize>,
        /// Absolute byte offset within the stream.
        byte_offset: Option<usize>,
        /// Byte offset within the current record.
        record_byte_offset: Option<usize>,
        /// Source filename or stream identifier, when known.
        source_name: Option<String>,
        /// 001 control number, when already extracted.
        record_control_number: Option<String>,
        /// Field tag containing the bad indicator.
        field_tag: Option<String>,
        /// Indicator position (0 or 1).
        indicator_position: Option<u8>,
        /// The bytes that triggered the error, capped at [`FOUND_BYTES_CAP`].
        found: Option<Vec<u8>>,
        /// Human-readable description of what was expected.
        expected: Option<String>,
        /// Byte window captured near the error offset, for hex-dump rendering.
        bytes_near: Option<BytesNear>,
    },

    /// A subfield code byte was invalid.
    BadSubfieldCode {
        /// 1-based record index in the stream.
        record_index: Option<usize>,
        /// Absolute byte offset within the stream.
        byte_offset: Option<usize>,
        /// Byte offset within the current record.
        record_byte_offset: Option<usize>,
        /// Source filename or stream identifier, when known.
        source_name: Option<String>,
        /// 001 control number, when already extracted.
        record_control_number: Option<String>,
        /// Field tag containing the bad subfield.
        field_tag: Option<String>,
        /// The offending subfield code byte.
        subfield_code: u8,
        /// Byte window captured near the error offset, for hex-dump rendering.
        bytes_near: Option<BytesNear>,
    },

    /// A data field is structurally invalid in some way not covered by the
    /// more specific variants above.
    InvalidField {
        /// 1-based record index in the stream.
        record_index: Option<usize>,
        /// Absolute byte offset within the stream.
        byte_offset: Option<usize>,
        /// Byte offset within the current record.
        record_byte_offset: Option<usize>,
        /// Source filename or stream identifier, when known.
        source_name: Option<String>,
        /// 001 control number, when already extracted.
        record_control_number: Option<String>,
        /// Field tag involved.
        field_tag: Option<String>,
        /// Human-readable description of the problem.
        message: String,
        /// Byte window captured near the error offset, for hex-dump rendering.
        bytes_near: Option<BytesNear>,
    },

    /// A character encoding conversion failed.
    EncodingError {
        /// 1-based record index in the stream, when known.
        record_index: Option<usize>,
        /// Absolute byte offset within the stream, when known.
        byte_offset: Option<usize>,
        /// Source filename or stream identifier, when known.
        source_name: Option<String>,
        /// 001 control number, when already extracted.
        record_control_number: Option<String>,
        /// Field tag involved, when applicable.
        field_tag: Option<String>,
        /// Human-readable description of the problem.
        message: String,
        /// Byte window captured near the error offset, for hex-dump rendering.
        bytes_near: Option<BytesNear>,
    },

    /// An accessor lookup failed: a requested field was not present in the record.
    ///
    /// Unlike the parse-error variants this is not a structural failure, so it
    /// does not carry byte-offset metadata.
    FieldNotFound {
        /// 1-based record index in the stream, when known.
        record_index: Option<usize>,
        /// 001 control number of the record being queried.
        record_control_number: Option<String>,
        /// Field tag that was requested.
        field_tag: String,
    },

    /// An I/O error occurred reading or writing the underlying source/sink.
    IoError {
        /// Underlying I/O error.
        #[source]
        cause: std::io::Error,
        /// 1-based record index in the stream, when known.
        record_index: Option<usize>,
        /// Absolute byte offset within the stream, when known.
        byte_offset: Option<usize>,
        /// Source filename or stream identifier, when known.
        source_name: Option<String>,
    },

    /// An error occurred during MARCXML parsing.
    XmlError {
        /// Underlying XML parser error. Boxed so any of `quick_xml`'s error
        /// types (`Error`, `DeError`, etc.) can be wrapped.
        #[source]
        cause: Box<dyn std::error::Error + Send + Sync + 'static>,
        /// 1-based record index in the document, when known.
        record_index: Option<usize>,
        /// Byte offset within the source document, when known. For XML this
        /// is typically derived from the parser's line/column position rather
        /// than a raw byte offset; it may be `None` when the parser does not
        /// expose any position information.
        byte_offset: Option<usize>,
        /// Source filename or stream identifier, when known.
        source_name: Option<String>,
    },

    /// An error occurred during MARCJSON parsing.
    JsonError {
        /// Underlying JSON parser error.
        #[source]
        cause: serde_json::Error,
        /// 1-based record index in the document, when known.
        record_index: Option<usize>,
        /// Byte offset within the source document, when known. Computed from
        /// `serde_json::Error::line()` and `column()` when both are
        /// available; left `None` when the parser does not expose position
        /// information.
        byte_offset: Option<usize>,
        /// Source filename or stream identifier, when known.
        source_name: Option<String>,
    },

    /// An error occurred while writing a MARC record.
    WriterError {
        /// 1-based record index being written, when known.
        record_index: Option<usize>,
        /// 001 control number of the record being written, when known.
        record_control_number: Option<String>,
        /// Human-readable description of the problem.
        message: String,
    },

    /// The configured per-stream cap on recovered errors was exceeded.
    ///
    /// In [`crate::RecoveryMode::Lenient`] and [`crate::RecoveryMode::Permissive`],
    /// each recovered parse failure allocates a diagnostic object. On a
    /// pathological input stream, allowing these to accumulate without bound
    /// would expose callers to unbounded memory growth. The three ISO 2709
    /// readers therefore count each recovered event and raise this error once
    /// the configured cap (see their respective `with_max_errors` builders)
    /// is exceeded. After raising this error the reader is exhausted —
    /// subsequent calls return `Ok(None)`.
    FatalReaderError {
        /// The configured cap value.
        cap: usize,
        /// Number of recovered errors counted at the moment the cap was hit.
        errors_seen: usize,
        /// 1-based record index during which the cap was hit, when known.
        record_index: Option<usize>,
        /// Source filename or stream identifier, when known.
        source_name: Option<String>,
    },
}

impl MarcError {
    /// Render the error as a multi-line diagnostic with all populated
    /// positional metadata visible. Callers who want the actionable one-liner
    /// should use the default [`fmt::Display`] format instead.
    ///
    /// When the variant carries a [`BytesNear`] window, a hex dump of the
    /// surrounding bytes is appended: each row renders 16 bytes as `hh hh
    /// ...` plus an ASCII sidecar, with a caret pointing at the offending
    /// byte.
    #[must_use]
    pub fn detailed(&self) -> String {
        let mut out = String::new();
        let kind = self.kind_name();
        let context = self.context_summary();
        if context.is_empty() {
            out.push_str(kind);
        } else {
            out.push_str(kind);
            out.push_str(" at ");
            out.push_str(&context);
        }
        let lines = self.detail_lines();
        let label_width = lines.iter().map(|(l, _)| l.len()).max().unwrap_or(0);
        for (label, value) in &lines {
            out.push_str("\n  ");
            out.push_str(label);
            // Pad each label up to the widest label in this output so columns
            // align even when label lengths vary widely (e.g.,
            // "001:" vs "record-relative:").
            for _ in label.len()..=label_width {
                out.push(' ');
            }
            out.push_str(value);
        }
        if let Some(window) = self.bytes_near() {
            out.push('\n');
            out.push('\n');
            out.push_str(&render_hex_dump(window, self.byte_offset()));
        }
        out
    }

    /// Stable error code for this variant (`E001`–`E4xx`). Forms the
    /// canonical programmatic identifier — callers can match on this rather
    /// than on the variant name to keep handlers stable across enum
    /// restructures. See [`MarcError::slug`] for the human-friendly
    /// counterpart and [`MarcError::help_url`] for the docs URL.
    ///
    /// Codes never get re-purposed: a retired check leaves its docs entry
    /// in place pointing to a replacement. See `CONTRIBUTING.md` for the
    /// full stability policy.
    #[must_use]
    pub fn code(&self) -> &'static str {
        match self {
            MarcError::RecordLengthInvalid { .. } => "E001",
            MarcError::InvalidLeader { .. } => "E002",
            MarcError::BaseAddressInvalid { .. } => "E003",
            MarcError::BaseAddressNotFound { .. } => "E004",
            MarcError::TruncatedRecord { .. } => "E005",
            MarcError::EndOfRecordNotFound { .. } => "E006",
            MarcError::IoError { .. } => "E007",
            MarcError::DirectoryInvalid { .. } => "E101",
            MarcError::FieldNotFound { .. } => "E105",
            MarcError::InvalidField { .. } => "E106",
            MarcError::InvalidIndicator { .. } => "E201",
            MarcError::BadSubfieldCode { .. } => "E202",
            MarcError::EncodingError { .. } => "E301",
            MarcError::XmlError { .. } => "E401",
            MarcError::JsonError { .. } => "E402",
            MarcError::WriterError { .. } => "E404",
            MarcError::FatalReaderError { .. } => "E099",
        }
    }

    /// Human-friendly slug for this variant (e.g., `"invalid_indicator"`).
    /// Stable counterpart to [`MarcError::code`]; both are guaranteed not
    /// to change for an existing variant.
    #[must_use]
    pub fn slug(&self) -> &'static str {
        match self {
            MarcError::RecordLengthInvalid { .. } => "record_length_invalid",
            MarcError::InvalidLeader { .. } => "leader_invalid",
            MarcError::BaseAddressInvalid { .. } => "base_address_invalid",
            MarcError::BaseAddressNotFound { .. } => "base_address_not_found",
            MarcError::TruncatedRecord { .. } => "truncated_record",
            MarcError::EndOfRecordNotFound { .. } => "end_of_record_not_found",
            MarcError::IoError { .. } => "io_error",
            MarcError::DirectoryInvalid { .. } => "directory_invalid",
            MarcError::FieldNotFound { .. } => "field_not_found",
            MarcError::InvalidField { .. } => "invalid_field",
            MarcError::InvalidIndicator { .. } => "invalid_indicator",
            MarcError::BadSubfieldCode { .. } => "bad_subfield_code",
            MarcError::EncodingError { .. } => "utf8_invalid",
            MarcError::XmlError { .. } => "marcxml_invalid",
            MarcError::JsonError { .. } => "marcjson_invalid",
            MarcError::WriterError { .. } => "record_too_large_for_iso2709",
            MarcError::FatalReaderError { .. } => "fatal_reader_error",
        }
    }

    /// Serialize this error as a JSON-ready `serde_json::Value` suitable
    /// for emitting to structured logging platforms (ELK, Datadog, Splunk,
    /// JSON-line pipelines).
    ///
    /// Shape notes:
    /// - Bytes fields (`found`, `bytes_near`) are hex-encoded under a
    ///   `_hex`-suffixed key; the bare key stays `null`.
    /// - `_cause` is a flat string (from [`std::error::Error::source`])
    ///   or `null` — never nested.
    /// - [`SCHEMA_VERSION`] is included so consumers can branch on it if
    ///   the shape changes later. Pre-1.0, the shape may still evolve.
    #[must_use]
    pub fn to_json_value(&self) -> serde_json::Value {
        use serde_json::{json, Map, Value};
        let mut m: Map<String, Value> = Map::new();
        m.insert("schema_version".into(), json!(SCHEMA_VERSION));
        m.insert("class".into(), json!(self.kind_name()));
        m.insert("code".into(), json!(self.code()));
        m.insert("slug".into(), json!(self.slug()));
        m.insert("severity".into(), json!("error"));
        m.insert("help_url".into(), json!(self.help_url()));

        // Positional fields, mirroring the Python _POSITIONAL_FIELDS list.
        // Fields the variant doesn't carry surface as null.
        m.insert("record_index".into(), opt_json(self.record_index()));
        m.insert(
            "record_control_number".into(),
            self.record_control_number()
                .map_or(Value::Null, Value::from),
        );
        m.insert(
            "field_tag".into(),
            self.field_tag().map_or(Value::Null, Value::from),
        );
        m.insert(
            "indicator_position".into(),
            opt_json(self.indicator_position_field()),
        );
        m.insert("subfield_code".into(), opt_json(self.subfield_code_field()));
        m.insert("found".into(), Value::Null);
        if let Some(bytes) = self.found_field() {
            m.insert("found_hex".into(), json!(hex_encode(bytes)));
        }
        m.insert(
            "expected".into(),
            self.expected_field().map_or(Value::Null, Value::from),
        );
        m.insert("byte_offset".into(), opt_json(self.byte_offset()));
        m.insert(
            "record_byte_offset".into(),
            opt_json(self.record_byte_offset()),
        );
        m.insert(
            "source".into(),
            self.source_name().map_or(Value::Null, Value::from),
        );
        // bytes_near is a byte-window surfaced via the `_hex` suffix
        // convention: `bytes_near` always None in the dict, `bytes_near_hex`
        // present only when bytes were captured. `bytes_near_offset` is the
        // absolute stream offset of the window's first byte.
        m.insert("bytes_near".into(), Value::Null);
        if let Some(window) = self.bytes_near() {
            m.insert("bytes_near_hex".into(), json!(hex_encode(&window.bytes)));
            m.insert("bytes_near_offset".into(), json!(window.start_offset));
        } else {
            m.insert("bytes_near_offset".into(), Value::Null);
        }

        // Variant-specific extra fields (mirrors Python's
        // _diagnostic_extra_fields). Surface the message / length pair
        // when applicable so downstream consumers don't lose them.
        if let Some(msg) = self.message_text() {
            m.insert("message".into(), json!(msg));
        }
        if let MarcError::TruncatedRecord {
            expected_length,
            actual_length,
            ..
        } = self
        {
            m.insert("expected_length".into(), opt_json(*expected_length));
            m.insert("actual_length".into(), opt_json(*actual_length));
        }
        if let MarcError::FatalReaderError {
            cap, errors_seen, ..
        } = self
        {
            m.insert("cap".into(), json!(*cap));
            m.insert("errors_seen".into(), json!(*errors_seen));
        }

        // Cause chain: stringified single value, never nested. Walks
        // `std::error::Error::source` so the wrapped underlying error
        // surfaces here for IoError / XmlError / JsonError variants.
        let cause_str = std::error::Error::source(self).map(ToString::to_string);
        m.insert("_cause".into(), cause_str.map_or(Value::Null, Value::from));

        Value::Object(m)
    }

    /// Convenience wrapper: serialize [`MarcError::to_json_value`] to a
    /// JSON string.
    ///
    /// # Errors
    ///
    /// Returns `serde_json::Error` only if the underlying serializer fails
    /// (which should not happen for the well-formed map produced by
    /// `to_json_value`).
    pub fn to_json(&self) -> std::result::Result<String, serde_json::Error> {
        serde_json::to_string(&self.to_json_value())
    }

    /// Helper accessors for variants that carry the shared fields. These
    /// abstract over per-variant field-set differences so `to_json_value`
    /// can be written once.
    fn indicator_position_field(&self) -> Option<u8> {
        match self {
            MarcError::InvalidIndicator {
                indicator_position, ..
            } => *indicator_position,
            _ => None,
        }
    }

    fn subfield_code_field(&self) -> Option<u8> {
        match self {
            MarcError::BadSubfieldCode { subfield_code, .. } => Some(*subfield_code),
            _ => None,
        }
    }

    fn found_field(&self) -> Option<&[u8]> {
        match self {
            MarcError::RecordLengthInvalid { found, .. }
            | MarcError::BaseAddressInvalid { found, .. }
            | MarcError::DirectoryInvalid { found, .. }
            | MarcError::InvalidIndicator { found, .. } => found.as_deref(),
            _ => None,
        }
    }

    fn expected_field(&self) -> Option<&str> {
        match self {
            MarcError::RecordLengthInvalid { expected, .. }
            | MarcError::BaseAddressInvalid { expected, .. }
            | MarcError::DirectoryInvalid { expected, .. }
            | MarcError::InvalidIndicator { expected, .. } => expected.as_deref(),
            _ => None,
        }
    }

    /// Byte window captured near the error offset, when available.
    ///
    /// Returned for the parse-path variants that carry it; returns `None`
    /// for variants without the field (e.g. `IoError`, `FieldNotFound`) or
    /// when the parser did not have access to a buffer at error time.
    #[must_use]
    pub fn bytes_near(&self) -> Option<&BytesNear> {
        match self {
            MarcError::InvalidLeader { bytes_near, .. }
            | MarcError::RecordLengthInvalid { bytes_near, .. }
            | MarcError::BaseAddressInvalid { bytes_near, .. }
            | MarcError::BaseAddressNotFound { bytes_near, .. }
            | MarcError::DirectoryInvalid { bytes_near, .. }
            | MarcError::TruncatedRecord { bytes_near, .. }
            | MarcError::EndOfRecordNotFound { bytes_near, .. }
            | MarcError::InvalidIndicator { bytes_near, .. }
            | MarcError::BadSubfieldCode { bytes_near, .. }
            | MarcError::InvalidField { bytes_near, .. }
            | MarcError::EncodingError { bytes_near, .. } => bytes_near.as_ref(),
            _ => None,
        }
    }

    /// Attach a byte-window to this error after the fact, enriching it for
    /// hex-dump rendering.
    ///
    /// Useful at call sites that construct a `MarcError` without access to
    /// a `ParseContext` (e.g., `Leader::from_bytes`) and want to surface
    /// hex-dump-ready bytes before propagating. The window is centered on
    /// the error's `byte_offset` when set, or on `buffer_start_offset`
    /// otherwise; it is clamped at buffer boundaries.
    ///
    /// `buffer` is the bytes that were being parsed; `buffer_start_offset`
    /// is the absolute stream offset of `buffer[0]`.
    ///
    /// If the variant has a `byte_offset` field that is currently `None`,
    /// it is also populated with `buffer_start_offset` so downstream
    /// renderers have an anchor for the hex-dump caret (points at the
    /// start of the buffer).
    ///
    /// Variants that don't carry `bytes_near` (e.g. `IoError`, `XmlError`,
    /// `JsonError`, `FieldNotFound`, `WriterError`) are returned unchanged.
    /// Variants that already have `bytes_near` set are overwritten.
    #[must_use]
    pub fn with_bytes_near(mut self, buffer: &[u8], buffer_start_offset: usize) -> Self {
        let anchor = self.byte_offset().unwrap_or(buffer_start_offset);
        let Some(window) = BytesNear::capture(buffer, buffer_start_offset, anchor) else {
            return self;
        };
        match &mut self {
            MarcError::InvalidLeader {
                bytes_near,
                byte_offset,
                ..
            }
            | MarcError::RecordLengthInvalid {
                bytes_near,
                byte_offset,
                ..
            }
            | MarcError::BaseAddressInvalid {
                bytes_near,
                byte_offset,
                ..
            }
            | MarcError::BaseAddressNotFound {
                bytes_near,
                byte_offset,
                ..
            }
            | MarcError::DirectoryInvalid {
                bytes_near,
                byte_offset,
                ..
            }
            | MarcError::TruncatedRecord {
                bytes_near,
                byte_offset,
                ..
            }
            | MarcError::EndOfRecordNotFound {
                bytes_near,
                byte_offset,
                ..
            }
            | MarcError::InvalidIndicator {
                bytes_near,
                byte_offset,
                ..
            }
            | MarcError::BadSubfieldCode {
                bytes_near,
                byte_offset,
                ..
            }
            | MarcError::InvalidField {
                bytes_near,
                byte_offset,
                ..
            }
            | MarcError::EncodingError {
                bytes_near,
                byte_offset,
                ..
            } => {
                if byte_offset.is_none() {
                    *byte_offset = Some(buffer_start_offset);
                }
                *bytes_near = Some(window);
            },
            _ => {},
        }
        self
    }

    /// Canonical docs URL for this error code, pointing at the `#Exxx`
    /// anchor on the error-codes reference page.
    ///
    /// The base URL is the GitHub Pages-hosted docs site;
    /// this method appends `/reference/error-codes/#Exxx`.
    #[must_use]
    pub fn help_url(&self) -> String {
        format!("{DOCS_BASE_URL}/reference/error-codes/#{}", self.code())
    }

    /// Short `PascalCase` name for the variant, used in `detailed()` headers
    /// and as the leading token of the underlying-cause-less default `Display`.
    fn kind_name(&self) -> &'static str {
        match self {
            MarcError::InvalidLeader { .. } => "InvalidLeader",
            MarcError::RecordLengthInvalid { .. } => "RecordLengthInvalid",
            MarcError::BaseAddressInvalid { .. } => "BaseAddressInvalid",
            MarcError::BaseAddressNotFound { .. } => "BaseAddressNotFound",
            MarcError::DirectoryInvalid { .. } => "DirectoryInvalid",
            MarcError::TruncatedRecord { .. } => "TruncatedRecord",
            MarcError::EndOfRecordNotFound { .. } => "EndOfRecordNotFound",
            MarcError::InvalidIndicator { .. } => "InvalidIndicator",
            MarcError::BadSubfieldCode { .. } => "BadSubfieldCode",
            MarcError::InvalidField { .. } => "InvalidField",
            MarcError::EncodingError { .. } => "EncodingError",
            MarcError::FieldNotFound { .. } => "FieldNotFound",
            MarcError::IoError { .. } => "IoError",
            MarcError::XmlError { .. } => "XmlError",
            MarcError::JsonError { .. } => "JsonError",
            MarcError::WriterError { .. } => "WriterError",
            MarcError::FatalReaderError { .. } => "FatalReaderError",
        }
    }

    /// Build a "record N, field T" style context summary if those fields are
    /// populated; returns the empty string if neither is available.
    fn context_summary(&self) -> String {
        let mut parts: Vec<String> = Vec::new();
        if let Some(idx) = self.record_index() {
            parts.push(format!("record {idx}"));
        }
        if let Some(tag) = self.field_tag() {
            parts.push(format!("field {tag}"));
        }
        parts.join(", ")
    }

    /// Produce the (label, value) detail lines for `detailed()` output, in
    /// display order. Skips lines whose value is unavailable.
    fn detail_lines(&self) -> Vec<(&'static str, String)> {
        let mut lines: Vec<(&'static str, String)> = Vec::new();
        if let Some(s) = self.source_name() {
            lines.push(("source:", s.to_string()));
        }
        if let Some(cn) = self.record_control_number() {
            lines.push(("001:", cn.to_string()));
        }
        match self {
            MarcError::InvalidIndicator {
                indicator_position,
                found,
                expected,
                ..
            } => {
                if let (Some(pos), Some(exp)) = (indicator_position, expected) {
                    let found_repr = found
                        .as_deref()
                        .map_or_else(|| "?".to_string(), format_found_bytes_python_repr);
                    // Label carries the indicator number + colon; value is
                    // just the found/expected so column alignment in
                    // detailed() matches the Python side byte-for-byte.
                    let label = if *pos == 0 {
                        "indicator 0:"
                    } else {
                        "indicator 1:"
                    };
                    lines.push((label, format!("found {found_repr}, expected {exp}")));
                }
            },
            MarcError::BadSubfieldCode { subfield_code, .. } => {
                lines.push((
                    "subfield:",
                    format!(
                        "invalid code byte 0x{subfield_code:02X} ({:?})",
                        *subfield_code as char
                    ),
                ));
            },
            MarcError::TruncatedRecord {
                expected_length,
                actual_length,
                ..
            } => {
                if let (Some(exp), Some(act)) = (expected_length, actual_length) {
                    lines.push(("length:", format!("expected {exp} bytes, found {act}")));
                }
            },
            MarcError::FatalReaderError {
                cap, errors_seen, ..
            } => {
                lines.push(("cap:", format!("{errors_seen} errors seen, cap {cap}")));
            },
            _ => {},
        }
        if let Some(off) = self.byte_offset() {
            lines.push(("byte offset:", format!("0x{off:X} ({off}) in stream")));
        }
        if let Some(off) = self.record_byte_offset() {
            lines.push(("record-relative:", format!("byte {off}")));
        }
        if let Some(msg) = self.message_text() {
            lines.push(("message:", msg.to_string()));
        }
        lines
    }

    /// Best-effort one-line `Display` rendering: leads with positional context
    /// (when available) and the problem description; appends the byte offset
    /// in hex/decimal as a visually subordinate trailer.
    fn render_oneline(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let mut header_parts: Vec<String> = Vec::new();
        if let Some(idx) = self.record_index() {
            header_parts.push(format!("record {idx}"));
        }
        if let Some(cn) = self.record_control_number() {
            header_parts.push(format!("001 '{cn}'"));
        }
        if let Some(tag) = self.field_tag() {
            header_parts.push(format!("field {tag}"));
        }
        if let MarcError::InvalidIndicator {
            indicator_position: Some(pos),
            ..
        } = self
        {
            header_parts.push(format!("ind{pos}"));
        }
        if header_parts.is_empty() {
            // No positional context available — lead with the variant name so
            // the message at least identifies what kind of error it is.
            write!(f, "{}: ", self.kind_name())?;
        } else {
            write!(f, "[{}] ", header_parts.join(" · "))?;
        }
        write!(f, "{}", self.body_text())?;
        if let Some(off) = self.byte_offset() {
            write!(f, "  (byte 0x{off:X} / {off})")?;
        }
        Ok(())
    }

    /// The "what went wrong" body, distinct from the positional header and the
    /// trailing byte offset.
    fn body_text(&self) -> String {
        match self {
            MarcError::InvalidLeader { message, .. } => format!("invalid leader: {message}"),
            MarcError::RecordLengthInvalid {
                found, expected, ..
            } => match (found, expected) {
                (Some(f), Some(e)) => format!(
                    "invalid record length {} — expected {e}",
                    format_found_bytes_python_repr(f)
                ),
                _ => "invalid record length".to_string(),
            },
            MarcError::BaseAddressInvalid {
                found, expected, ..
            } => match (found, expected) {
                (Some(f), Some(e)) => format!(
                    "invalid base address {} — expected {e}",
                    format_found_bytes_python_repr(f)
                ),
                _ => "invalid base address".to_string(),
            },
            MarcError::BaseAddressNotFound { .. } => "base address not found".to_string(),
            MarcError::DirectoryInvalid {
                found, expected, ..
            } => match (found, expected) {
                (Some(f), Some(e)) => format!(
                    "invalid directory entry {} — expected {e}",
                    format_found_bytes_python_repr(f)
                ),
                _ => "invalid directory entry".to_string(),
            },
            MarcError::TruncatedRecord {
                expected_length,
                actual_length,
                ..
            } => match (expected_length, actual_length) {
                (Some(e), Some(a)) => format!("truncated record: expected {e} bytes, found {a}"),
                _ => "truncated record".to_string(),
            },
            MarcError::EndOfRecordNotFound { .. } => "end-of-record marker not found".to_string(),
            MarcError::InvalidIndicator {
                found, expected, ..
            } => match (found, expected) {
                (Some(f), Some(e)) => format!(
                    "invalid {} — expected {e}",
                    format_found_bytes_python_repr(f)
                ),
                _ => "invalid indicator".to_string(),
            },
            MarcError::BadSubfieldCode { subfield_code, .. } => {
                format!("invalid subfield code 0x{subfield_code:02X}")
            },
            MarcError::InvalidField { message, .. } => format!("invalid field: {message}"),
            MarcError::EncodingError { message, .. } => format!("encoding error: {message}"),
            MarcError::FieldNotFound { field_tag, .. } => {
                format!("field {field_tag} not found")
            },
            MarcError::IoError { cause, .. } => format!("I/O error: {cause}"),
            MarcError::XmlError { cause, .. } => format!("XML parse error: {cause}"),
            MarcError::JsonError { cause, .. } => format!("JSON parse error: {cause}"),
            MarcError::WriterError { message, .. } => format!("writer error: {message}"),
            MarcError::FatalReaderError {
                cap, errors_seen, ..
            } => format!("fatal reader error: recovered-error cap exceeded ({errors_seen} errors, cap {cap})"),
        }
    }

    fn record_index(&self) -> Option<usize> {
        match self {
            MarcError::InvalidLeader { record_index, .. }
            | MarcError::RecordLengthInvalid { record_index, .. }
            | MarcError::BaseAddressInvalid { record_index, .. }
            | MarcError::BaseAddressNotFound { record_index, .. }
            | MarcError::DirectoryInvalid { record_index, .. }
            | MarcError::TruncatedRecord { record_index, .. }
            | MarcError::EndOfRecordNotFound { record_index, .. }
            | MarcError::InvalidIndicator { record_index, .. }
            | MarcError::BadSubfieldCode { record_index, .. }
            | MarcError::InvalidField { record_index, .. }
            | MarcError::EncodingError { record_index, .. }
            | MarcError::FieldNotFound { record_index, .. }
            | MarcError::IoError { record_index, .. }
            | MarcError::XmlError { record_index, .. }
            | MarcError::JsonError { record_index, .. }
            | MarcError::WriterError { record_index, .. }
            | MarcError::FatalReaderError { record_index, .. } => *record_index,
        }
    }

    fn record_control_number(&self) -> Option<&str> {
        match self {
            MarcError::BaseAddressInvalid {
                record_control_number,
                ..
            }
            | MarcError::BaseAddressNotFound {
                record_control_number,
                ..
            }
            | MarcError::DirectoryInvalid {
                record_control_number,
                ..
            }
            | MarcError::TruncatedRecord {
                record_control_number,
                ..
            }
            | MarcError::EndOfRecordNotFound {
                record_control_number,
                ..
            }
            | MarcError::InvalidIndicator {
                record_control_number,
                ..
            }
            | MarcError::BadSubfieldCode {
                record_control_number,
                ..
            }
            | MarcError::InvalidField {
                record_control_number,
                ..
            }
            | MarcError::EncodingError {
                record_control_number,
                ..
            }
            | MarcError::FieldNotFound {
                record_control_number,
                ..
            }
            | MarcError::WriterError {
                record_control_number,
                ..
            } => record_control_number.as_deref(),
            _ => None,
        }
    }

    fn field_tag(&self) -> Option<&str> {
        match self {
            MarcError::DirectoryInvalid { field_tag, .. }
            | MarcError::InvalidIndicator { field_tag, .. }
            | MarcError::BadSubfieldCode { field_tag, .. }
            | MarcError::InvalidField { field_tag, .. }
            | MarcError::EncodingError { field_tag, .. } => field_tag.as_deref(),
            MarcError::FieldNotFound { field_tag, .. } => Some(field_tag.as_str()),
            _ => None,
        }
    }

    fn byte_offset(&self) -> Option<usize> {
        match self {
            MarcError::InvalidLeader { byte_offset, .. }
            | MarcError::RecordLengthInvalid { byte_offset, .. }
            | MarcError::BaseAddressInvalid { byte_offset, .. }
            | MarcError::BaseAddressNotFound { byte_offset, .. }
            | MarcError::DirectoryInvalid { byte_offset, .. }
            | MarcError::TruncatedRecord { byte_offset, .. }
            | MarcError::EndOfRecordNotFound { byte_offset, .. }
            | MarcError::InvalidIndicator { byte_offset, .. }
            | MarcError::BadSubfieldCode { byte_offset, .. }
            | MarcError::InvalidField { byte_offset, .. }
            | MarcError::EncodingError { byte_offset, .. }
            | MarcError::IoError { byte_offset, .. }
            | MarcError::XmlError { byte_offset, .. }
            | MarcError::JsonError { byte_offset, .. } => *byte_offset,
            _ => None,
        }
    }

    fn record_byte_offset(&self) -> Option<usize> {
        match self {
            MarcError::InvalidLeader {
                record_byte_offset, ..
            }
            | MarcError::DirectoryInvalid {
                record_byte_offset, ..
            }
            | MarcError::TruncatedRecord {
                record_byte_offset, ..
            }
            | MarcError::EndOfRecordNotFound {
                record_byte_offset, ..
            }
            | MarcError::InvalidIndicator {
                record_byte_offset, ..
            }
            | MarcError::BadSubfieldCode {
                record_byte_offset, ..
            }
            | MarcError::InvalidField {
                record_byte_offset, ..
            } => *record_byte_offset,
            _ => None,
        }
    }

    fn source_name(&self) -> Option<&str> {
        match self {
            MarcError::InvalidLeader { source_name, .. }
            | MarcError::RecordLengthInvalid { source_name, .. }
            | MarcError::BaseAddressInvalid { source_name, .. }
            | MarcError::BaseAddressNotFound { source_name, .. }
            | MarcError::DirectoryInvalid { source_name, .. }
            | MarcError::TruncatedRecord { source_name, .. }
            | MarcError::EndOfRecordNotFound { source_name, .. }
            | MarcError::InvalidIndicator { source_name, .. }
            | MarcError::BadSubfieldCode { source_name, .. }
            | MarcError::InvalidField { source_name, .. }
            | MarcError::EncodingError { source_name, .. }
            | MarcError::IoError { source_name, .. }
            | MarcError::XmlError { source_name, .. }
            | MarcError::JsonError { source_name, .. }
            | MarcError::FatalReaderError { source_name, .. } => source_name.as_deref(),
            _ => None,
        }
    }

    fn message_text(&self) -> Option<&str> {
        match self {
            MarcError::InvalidField { message, .. }
            | MarcError::EncodingError { message, .. }
            | MarcError::WriterError { message, .. } => Some(message.as_str()),
            _ => None,
        }
    }
}

impl MarcError {
    /// Construct an [`MarcError::InvalidField`] with only a message — used at
    /// call sites that have a textual error description but no positional
    /// metadata available. Subsequent enrichment work attaches positional
    /// fields where they can be derived from a `ParseContext`.
    #[must_use]
    pub(crate) fn invalid_field_msg(msg: impl Into<String>) -> Self {
        MarcError::InvalidField {
            record_index: None,
            byte_offset: None,
            record_byte_offset: None,
            source_name: None,
            record_control_number: None,
            field_tag: None,
            message: msg.into(),
            bytes_near: None,
        }
    }

    /// Construct an [`MarcError::EncodingError`] with only a message.
    #[must_use]
    pub(crate) fn encoding_msg(msg: impl Into<String>) -> Self {
        MarcError::EncodingError {
            record_index: None,
            byte_offset: None,
            source_name: None,
            record_control_number: None,
            field_tag: None,
            message: msg.into(),
            bytes_near: None,
        }
    }

    /// Construct an [`MarcError::InvalidLeader`] from a textual message.
    #[must_use]
    pub(crate) fn leader_msg(message: impl Into<String>) -> Self {
        MarcError::InvalidLeader {
            record_index: None,
            byte_offset: None,
            record_byte_offset: None,
            source_name: None,
            message: message.into(),
            bytes_near: None,
        }
    }
}

impl fmt::Display for MarcError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        if f.alternate() {
            write!(f, "{}", self.detailed())
        } else {
            self.render_oneline(f)
        }
    }
}

/// Format a byte slice as Python-style `b'...'` repr, escaping non-printable
/// bytes. Mirrors what users will see on the Python side via `repr(err.found)`.
fn format_found_bytes_python_repr(bytes: &[u8]) -> String {
    let mut out = String::from("b'");
    for &b in bytes {
        match b {
            b'\\' => out.push_str(r"\\"),
            b'\'' => out.push_str(r"\'"),
            b'\n' => out.push_str(r"\n"),
            b'\r' => out.push_str(r"\r"),
            b'\t' => out.push_str(r"\t"),
            0x20..=0x7E => out.push(b as char),
            _ => {
                use std::fmt::Write;
                let _ = write!(out, "\\x{b:02x}");
            },
        }
    }
    out.push('\'');
    out
}

/// Base URL for the docs site. Used by [`MarcError::help_url`].
pub const DOCS_BASE_URL: &str = "https://dchud.github.io/mrrc";

/// Schema identifier included in [`MarcError::to_json_value`] output so
/// consumers can branch on it if the shape changes later. Pre-1.0, the
/// shape may still evolve.
pub const SCHEMA_VERSION: u32 = 1;

fn opt_json<T: Into<serde_json::Value>>(v: Option<T>) -> serde_json::Value {
    v.map_or(serde_json::Value::Null, Into::into)
}

fn hex_encode(bytes: &[u8]) -> String {
    use std::fmt::Write;
    let mut out = String::with_capacity(bytes.len() * 2);
    for b in bytes {
        let _ = write!(out, "{b:02x}");
    }
    out
}

/// Render a [`BytesNear`] window as a hex + ASCII dump, with an optional
/// caret pointing at the offending byte.
///
/// Format (matches the Python mirror in `mrrc/exceptions.py`):
///
/// ```text
/// bytes near offset 0x1C31:
///   0x1C21:  32 30 32 33 6e 79 75 20  20 20 20 20 20 20 20 20  |2023nyu         |
///   0x1C31:  3a 30 00 30 20 30 20 65  6e 67 20 64 1e 32 34 35  |:0.0 0 eng d.245|
///              ^^ offending byte
/// ```
///
/// `byte_offset` is the absolute stream offset of the offending byte; when
/// `Some` and within the window, a caret line is emitted beneath the
/// matching row. Rows are 16 bytes each with an 8-byte gap for readability.
#[must_use]
pub fn render_hex_dump(window: &BytesNear, byte_offset: Option<usize>) -> String {
    use std::fmt::Write;
    const ROW_WIDTH: usize = 16;
    let mut out = String::new();
    let anchor = byte_offset.unwrap_or(window.start_offset);
    let _ = write!(out, "bytes near offset 0x{anchor:X}:");
    for (row_idx, chunk) in window.bytes.chunks(ROW_WIDTH).enumerate() {
        let row_start = window.start_offset + row_idx * ROW_WIDTH;
        out.push('\n');
        let _ = write!(out, "    0x{row_start:04X}:  ");
        // Hex bytes: 8 + 2 spaces + 8 (or fewer when the last row is short)
        for (i, b) in chunk.iter().enumerate() {
            if i == 8 {
                out.push(' ');
            }
            let _ = write!(out, "{b:02x} ");
        }
        // Pad if this row has fewer than 16 bytes so the ASCII panel aligns
        // with rows above.
        for i in chunk.len()..ROW_WIDTH {
            if i == 8 {
                out.push(' ');
            }
            out.push_str("   ");
        }
        // ASCII sidecar
        out.push('|');
        for &b in chunk {
            if (0x20..=0x7E).contains(&b) {
                out.push(b as char);
            } else {
                out.push('.');
            }
        }
        for _ in chunk.len()..ROW_WIDTH {
            out.push(' ');
        }
        out.push('|');
        // Caret under the offending byte, when it falls in this row.
        if let Some(abs) = byte_offset {
            if abs >= row_start && abs < row_start + chunk.len() {
                let col = abs - row_start;
                // Prefix: 4 spaces + "0x####:  " = 4 + 9 = 13 chars
                //   + 3 chars per byte for `col` bytes
                //   + extra space after 8 bytes
                let caret_col = 13 + col * 3 + usize::from(col >= 8);
                out.push('\n');
                for _ in 0..caret_col {
                    out.push(' ');
                }
                out.push_str("^^ offending byte");
            }
        }
    }
    out
}

/// Convenience type alias for [`std::result::Result`] with [`MarcError`].
pub type Result<T> = std::result::Result<T, MarcError>;

// Backwards-compatible conversion so existing `?` propagation of `io::Error`
// continues to work without surrounding context.
impl From<std::io::Error> for MarcError {
    fn from(cause: std::io::Error) -> Self {
        MarcError::IoError {
            cause,
            record_index: None,
            byte_offset: None,
            source_name: None,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn truncate_bytes_short_input_passes_through() {
        assert_eq!(truncate_bytes(b"hello"), b"hello");
    }

    #[test]
    fn truncate_bytes_long_input_capped() {
        let input = vec![b'x'; 100];
        assert_eq!(truncate_bytes(&input).len(), FOUND_BYTES_CAP);
    }

    #[test]
    fn display_invalid_indicator_produces_actionable_oneliner() {
        let err = MarcError::InvalidIndicator {
            record_index: Some(847),
            byte_offset: Some(7217),
            record_byte_offset: Some(42),
            source_name: Some("harvest.mrc".into()),
            record_control_number: Some("ocm01234567".into()),
            field_tag: Some("245".into()),
            indicator_position: Some(1),
            found: Some(b":".to_vec()),
            expected: Some("digit or space".into()),
            bytes_near: None,
        };
        let s = err.to_string();
        assert!(s.starts_with("[record 847"), "got: {s}");
        assert!(s.contains("001 'ocm01234567'"), "got: {s}");
        assert!(s.contains("field 245"), "got: {s}");
        assert!(s.contains("ind1"), "got: {s}");
        assert!(s.contains("(byte 0x1C31 / 7217)"), "got: {s}");
    }

    #[test]
    fn detailed_invalid_indicator_multiline() {
        let err = MarcError::InvalidIndicator {
            record_index: Some(847),
            byte_offset: Some(7217),
            record_byte_offset: Some(42),
            source_name: Some("harvest.mrc".into()),
            record_control_number: Some("ocm01234567".into()),
            field_tag: Some("245".into()),
            indicator_position: Some(1),
            found: Some(b":".to_vec()),
            expected: Some("digit or space".into()),
            bytes_near: None,
        };
        let d = err.detailed();
        assert!(
            d.starts_with("InvalidIndicator at record 847, field 245"),
            "got: {d}"
        );
        assert!(d.contains("source:"), "got: {d}");
        assert!(d.contains("harvest.mrc"), "got: {d}");
        assert!(d.contains("001:"), "got: {d}");
        assert!(d.contains("indicator"), "got: {d}");
        assert!(d.contains("byte offset:"), "got: {d}");
        assert!(d.contains("0x1C31 (7217)"), "got: {d}");
        assert!(d.contains("record-relative:"), "got: {d}");
    }

    #[test]
    fn io_error_source_chain_walks() {
        let io = std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "boom");
        let err = MarcError::IoError {
            cause: io,
            record_index: Some(1),
            byte_offset: Some(0),
            source_name: None,
        };
        let chain = std::error::Error::source(&err);
        assert!(chain.is_some());
        assert!(chain.unwrap().to_string().contains("boom"));
    }

    #[test]
    fn from_io_error_blanket_conversion() {
        fn returns_io() -> std::io::Result<()> {
            Err(std::io::Error::new(std::io::ErrorKind::Other, "nope"))
        }
        fn wraps() -> Result<()> {
            returns_io()?;
            Ok(())
        }
        let err = wraps().unwrap_err();
        assert!(matches!(err, MarcError::IoError { .. }));
    }

    // -- Snapshot tests for the externally-visible error format ----------
    //
    // These pin the on-the-wire wording of Display (one-liner) and
    // detailed() (multi-line) outputs across representative variants.
    // Run `cargo insta review` to inspect/accept changes when these
    // snapshots drift.

    fn invalid_indicator_full() -> MarcError {
        MarcError::InvalidIndicator {
            record_index: Some(847),
            byte_offset: Some(7217),
            record_byte_offset: Some(42),
            source_name: Some("harvest.mrc".into()),
            record_control_number: Some("ocm01234567".into()),
            field_tag: Some("245".into()),
            indicator_position: Some(1),
            found: Some(b":".to_vec()),
            expected: Some("digit or space".into()),
            bytes_near: None,
        }
    }

    #[test]
    fn snapshot_display_invalid_indicator_full_context() {
        insta::assert_snapshot!(invalid_indicator_full().to_string());
    }

    #[test]
    fn snapshot_detailed_invalid_indicator_full_context() {
        insta::assert_snapshot!(invalid_indicator_full().detailed());
    }

    #[test]
    fn snapshot_display_no_context_falls_back_to_kind_name() {
        let err = MarcError::BaseAddressNotFound {
            record_index: None,
            byte_offset: None,
            source_name: None,
            record_control_number: None,
            bytes_near: None,
        };
        insta::assert_snapshot!(err.to_string());
    }

    #[test]
    fn snapshot_display_directory_invalid_with_truncated_found() {
        let big_input: Vec<u8> = (b'a'..=b'z').cycle().take(60).collect();
        let truncated = truncate_bytes(&big_input);
        let err = MarcError::DirectoryInvalid {
            record_index: Some(3),
            byte_offset: Some(0x100),
            record_byte_offset: Some(24),
            source_name: Some("collection.mrc".into()),
            record_control_number: Some("oc00000003".into()),
            field_tag: Some("245".into()),
            found: Some(truncated),
            expected: Some("12-byte numeric directory entry".into()),
            bytes_near: None,
        };
        insta::assert_snapshot!(err.to_string());
    }

    #[test]
    fn snapshot_detailed_truncated_record() {
        let err = MarcError::TruncatedRecord {
            record_index: Some(12),
            byte_offset: Some(0x4000),
            record_byte_offset: Some(0x80),
            source_name: Some("partial.mrc".into()),
            record_control_number: Some("oc00000012".into()),
            expected_length: Some(1024),
            actual_length: Some(640),
            bytes_near: None,
        };
        insta::assert_snapshot!(err.detailed());
    }

    /// The (code, slug) pairs the public API exposes. New variants must
    /// add an entry here and to the Python mirror in
    /// `tests/python/test_errors.py::_CODE_TABLE`.
    const ERROR_CODES: &[(&str, &str)] = &[
        ("E001", "record_length_invalid"),
        ("E002", "leader_invalid"),
        ("E003", "base_address_invalid"),
        ("E004", "base_address_not_found"),
        ("E005", "truncated_record"),
        ("E006", "end_of_record_not_found"),
        ("E007", "io_error"),
        ("E099", "fatal_reader_error"),
        ("E101", "directory_invalid"),
        ("E105", "field_not_found"),
        ("E106", "invalid_field"),
        ("E201", "invalid_indicator"),
        ("E202", "bad_subfield_code"),
        ("E301", "utf8_invalid"),
        ("E401", "marcxml_invalid"),
        ("E402", "marcjson_invalid"),
        ("E404", "record_too_large_for_iso2709"),
    ];

    #[test]
    fn error_codes_and_slugs_are_unique() {
        let codes: std::collections::HashSet<_> = ERROR_CODES.iter().map(|(c, _)| *c).collect();
        let slugs: std::collections::HashSet<_> = ERROR_CODES.iter().map(|(_, s)| *s).collect();
        assert_eq!(
            codes.len(),
            ERROR_CODES.len(),
            "duplicate code in ERROR_CODES"
        );
        assert_eq!(
            slugs.len(),
            ERROR_CODES.len(),
            "duplicate slug in ERROR_CODES"
        );
    }

    #[test]
    fn help_url_anchors_on_docs_page() {
        // One representative instance is enough; `code()` is exhaustive
        // (no `_` arm), so the compiler enforces every variant has a code.
        let err = MarcError::FieldNotFound {
            record_index: None,
            record_control_number: None,
            field_tag: "245".into(),
        };
        assert_eq!(
            err.help_url(),
            format!("{DOCS_BASE_URL}/reference/error-codes/#E105"),
        );
    }

    #[test]
    fn to_json_value_invalid_indicator_full_schema() {
        let err = invalid_indicator_full();
        let v = err.to_json_value();
        let obj = v.as_object().expect("to_json_value returns an object");

        // Schema fixed-position keys
        assert_eq!(obj["schema_version"], serde_json::json!(1));
        assert_eq!(obj["class"], serde_json::json!("InvalidIndicator"));
        assert_eq!(obj["code"], serde_json::json!("E201"));
        assert_eq!(obj["slug"], serde_json::json!("invalid_indicator"));
        assert_eq!(obj["severity"], serde_json::json!("error"));
        assert!(obj["help_url"].as_str().unwrap().ends_with("#E201"));

        // Positional fields
        assert_eq!(obj["record_index"], serde_json::json!(847));
        assert_eq!(
            obj["record_control_number"],
            serde_json::json!("ocm01234567")
        );
        assert_eq!(obj["field_tag"], serde_json::json!("245"));
        assert_eq!(obj["indicator_position"], serde_json::json!(1));
        assert_eq!(obj["expected"], serde_json::json!("digit or space"));
        assert_eq!(obj["byte_offset"], serde_json::json!(7217));
        assert_eq!(obj["record_byte_offset"], serde_json::json!(42));
        assert_eq!(obj["source"], serde_json::json!("harvest.mrc"));

        // Bytes get hex-encoded under _hex suffix; the original key is null
        assert_eq!(obj["found"], serde_json::Value::Null);
        assert_eq!(obj["found_hex"], serde_json::json!("3a"));

        // No cause chain on InvalidIndicator
        assert_eq!(obj["_cause"], serde_json::Value::Null);
    }

    #[test]
    fn to_json_includes_cause_chain_for_io_error() {
        let io = std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "test eof");
        let err = MarcError::IoError {
            cause: io,
            record_index: Some(5),
            byte_offset: Some(100),
            source_name: None,
        };
        let v = err.to_json_value();
        let obj = v.as_object().unwrap();
        assert_eq!(obj["_cause"], serde_json::json!("test eof"));
        assert_eq!(obj["code"], serde_json::json!("E007"));
    }

    #[test]
    fn to_json_truncated_record_includes_length_extras() {
        let err = MarcError::TruncatedRecord {
            record_index: Some(12),
            byte_offset: Some(0x4000),
            record_byte_offset: Some(0x80),
            source_name: Some("partial.mrc".into()),
            record_control_number: Some("oc00000012".into()),
            expected_length: Some(1024),
            actual_length: Some(640),
            bytes_near: None,
        };
        let v = err.to_json_value();
        let obj = v.as_object().unwrap();
        assert_eq!(obj["expected_length"], serde_json::json!(1024));
        assert_eq!(obj["actual_length"], serde_json::json!(640));
    }

    #[test]
    fn to_json_returns_valid_json_string() {
        let err = invalid_indicator_full();
        let s = err.to_json().expect("serialize");
        // Round-trip parse to verify it's well-formed JSON
        let _parsed: serde_json::Value = serde_json::from_str(&s).expect("parse");
    }

    #[test]
    fn snapshot_display_writer_error() {
        let err = MarcError::WriterError {
            record_index: Some(99),
            record_control_number: Some("oc00000099".into()),
            message: "Record length exceeds 4GB limit (5000000000 bytes)".into(),
        };
        insta::assert_snapshot!(err.to_string());
    }

    // -- bytes_near / hex dump --------------------------------------------

    #[test]
    fn bytes_near_capture_returns_none_outside_buffer() {
        let buf = b"abcdef".to_vec();
        assert!(BytesNear::capture(&buf, 100, 50).is_none());
        assert!(BytesNear::capture(&buf, 100, 107).is_none());
    }

    #[test]
    fn bytes_near_capture_clamps_at_buffer_boundaries() {
        // Offset at buffer[0]: window starts at buffer[0] (not negative), ends 16 in.
        let buf: Vec<u8> = (0..20).collect();
        let window = BytesNear::capture(&buf, 1000, 1000).unwrap();
        assert_eq!(window.start_offset, 1000);
        assert_eq!(window.bytes.len(), 16);
        // Offset near end: window clips at buffer end.
        let window = BytesNear::capture(&buf, 1000, 1018).unwrap();
        assert_eq!(window.bytes.len(), 16 + 2); // 16 before + 2 after (clipped)
        assert_eq!(window.start_offset, 1002);
    }

    #[test]
    fn to_json_bytes_near_surfaces_hex_and_offset() {
        let err = MarcError::InvalidIndicator {
            record_index: Some(1),
            byte_offset: Some(100),
            record_byte_offset: None,
            source_name: None,
            record_control_number: None,
            field_tag: Some("245".into()),
            indicator_position: Some(0),
            found: Some(b":".to_vec()),
            expected: Some("digit or space".into()),
            bytes_near: Some(BytesNear {
                bytes: vec![0x20, 0x3a, 0x30],
                start_offset: 99,
            }),
        };
        let obj = err.to_json_value();
        let obj = obj.as_object().unwrap();
        assert_eq!(obj["bytes_near"], serde_json::Value::Null);
        assert_eq!(obj["bytes_near_hex"], serde_json::json!("203a30"));
        assert_eq!(obj["bytes_near_offset"], serde_json::json!(99));
    }

    #[test]
    fn to_json_bytes_near_is_null_when_absent() {
        // Variant that carries bytes_near but with None populated
        let err = MarcError::InvalidIndicator {
            record_index: None,
            byte_offset: None,
            record_byte_offset: None,
            source_name: None,
            record_control_number: None,
            field_tag: None,
            indicator_position: None,
            found: None,
            expected: None,
            bytes_near: None,
        };
        let obj = err.to_json_value();
        let obj = obj.as_object().unwrap();
        assert_eq!(obj["bytes_near"], serde_json::Value::Null);
        assert!(!obj.contains_key("bytes_near_hex"));
        assert_eq!(obj["bytes_near_offset"], serde_json::Value::Null);
    }

    #[test]
    fn detailed_includes_hex_dump_with_caret_when_bytes_near_set() {
        // Two full rows: 16 bytes before + 16 bytes after the error byte.
        let mut window_bytes = Vec::with_capacity(32);
        window_bytes.extend(b"2023nyu         ");
        window_bytes.extend(b":0\x000 0 eng d\x1e245");
        let err = MarcError::InvalidIndicator {
            record_index: Some(847),
            byte_offset: Some(0x1C31),
            record_byte_offset: Some(42),
            source_name: Some("harvest.mrc".into()),
            record_control_number: Some("ocm01234567".into()),
            field_tag: Some("245".into()),
            indicator_position: Some(0),
            found: Some(b":".to_vec()),
            expected: Some("digit or space".into()),
            bytes_near: Some(BytesNear {
                bytes: window_bytes,
                start_offset: 0x1C21,
            }),
        };
        let d = err.detailed();
        assert!(d.contains("bytes near offset 0x1C31:"), "got:\n{d}");
        // First row header is row-start
        assert!(d.contains("0x1C21:"), "got:\n{d}");
        assert!(d.contains("0x1C31:"), "got:\n{d}");
        // Caret
        assert!(d.contains("^^ offending byte"), "got:\n{d}");
        // ASCII panel shows printable chars
        assert!(d.contains("|2023nyu"), "got:\n{d}");
    }
}
