//! Error types for pgtrickle.
//!
//! All errors that can occur within the extension are represented by [`PgTrickleError`].
//! Errors are propagated via `Result<T, PgTrickleError>` throughout the codebase and
//! converted to PostgreSQL errors at the API boundary using `pgrx::error!()`.
//!
//! # Error Classification
//!
//! Errors are classified into four categories that determine retry behavior:
//! - **User** — invalid queries, type mismatches, cycles. Never retried.
//! - **Schema** — upstream DDL changes. Not retried; triggers reinitialize.
//! - **System** — lock timeouts, slot errors, SPI failures. Retried with backoff.
//! - **Internal** — bugs. Not retried.
//!
//! # Retry Policy
//!
//! The [`RetryPolicy`] struct encapsulates exponential backoff with jitter for
//! system errors. The scheduler uses this to decide whether and when to retry
//! a failed refresh.

use std::fmt;

/// Stable classification for failures from scheduler-owned refreshes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RefreshFailureKind {
    LockTimeout,
    StatementTimeout,
    Deadlock,
    Serialization,
    OutOfMemory,
    Cancelled,
    Permanent,
    UnknownRetryable,
}

impl RefreshFailureKind {
    pub const fn code(self) -> &'static str {
        match self {
            Self::LockTimeout => "LOCK_TIMEOUT",
            Self::StatementTimeout => "STATEMENT_TIMEOUT",
            Self::Deadlock => "DEADLOCK",
            Self::Serialization => "SERIALIZATION",
            Self::OutOfMemory => "OUT_OF_MEMORY",
            Self::Cancelled => "CANCELLED",
            Self::Permanent => "PERMANENT",
            Self::UnknownRetryable => "UNKNOWN_RETRYABLE",
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RefreshFailure {
    pub kind: RefreshFailureKind,
    pub sqlstate: Option<String>,
    pub message: String,
    pub retryable: bool,
    pub counts_toward_suspension: bool,
}

/// Classify a scheduler refresh failure, preferring SQLSTATE over localized text.
pub fn classify_refresh_failure(sqlstate: Option<&str>, message: &str) -> RefreshFailure {
    let lower = message.to_ascii_lowercase();
    let kind = match sqlstate {
        Some("55P03") => RefreshFailureKind::LockTimeout,
        Some("40P01") => RefreshFailureKind::Deadlock,
        Some("40001") => RefreshFailureKind::Serialization,
        Some("53200") => RefreshFailureKind::OutOfMemory,
        Some("57014") if lower.contains("user request") || lower.contains("cancel") => {
            RefreshFailureKind::Cancelled
        }
        Some("57014") => RefreshFailureKind::StatementTimeout,
        _ if lower.contains("canceling statement due to user request")
            || lower.contains("query canceled by user")
            || lower.contains("query cancelled by user") =>
        {
            RefreshFailureKind::Cancelled
        }
        _ if lower.contains("lock timeout")
            || lower.contains("canceling statement due to lock timeout") =>
        {
            RefreshFailureKind::LockTimeout
        }
        _ if lower.contains("statement timeout")
            || lower.contains("canceling statement due to statement timeout") =>
        {
            RefreshFailureKind::StatementTimeout
        }
        _ if lower.contains("out of memory") => RefreshFailureKind::OutOfMemory,
        _ => {
            if classify_error_for_retry(message) {
                RefreshFailureKind::UnknownRetryable
            } else {
                RefreshFailureKind::Permanent
            }
        }
    };

    let retryable = !matches!(
        kind,
        RefreshFailureKind::Permanent | RefreshFailureKind::Cancelled
    );
    RefreshFailure {
        kind,
        sqlstate: sqlstate.map(str::to_owned),
        message: message.to_owned(),
        retryable,
        counts_toward_suspension: !matches!(
            kind,
            RefreshFailureKind::LockTimeout | RefreshFailureKind::Cancelled
        ),
    }
}

/// Primary error type for the extension.
#[derive(Debug, thiserror::Error)]
pub enum PgTrickleError {
    // ── User errors — fail, don't retry ──────────────────────────────────
    /// The defining query could not be parsed or validated.
    #[error("query parse error: {0}")]
    QueryParseError(String),

    /// A type mismatch was detected (e.g., incompatible column types).
    #[error("type mismatch: {0}")]
    TypeMismatch(String),

    /// The defining query contains an operator not supported for differential mode.
    #[error("unsupported operator for DIFFERENTIAL mode: {0}")]
    UnsupportedOperator(String),

    /// Adding this stream table would create a cycle in the dependency DAG.
    #[error("cycle detected in dependency graph: {}", .0.join(" -> "))]
    CycleDetected(Vec<String>),

    /// The specified stream table was not found.
    #[error("stream table not found: {0}")]
    NotFound(String),

    /// The stream table already exists.
    #[error("stream table already exists: {0}")]
    AlreadyExists(String),

    /// An invalid argument was provided to an API function.
    #[error("invalid argument: {0}")]
    InvalidArgument(String),

    /// A fixed-capacity shared-memory registry could not accept a new entry.
    #[error("shared state capacity exhausted: {0}")]
    SharedStateCapacity(String),

    /// A scheduled refresh exceeded its transaction-local lock deadline.
    #[error("scheduled lock timeout: {0}")]
    ScheduledLockTimeout(String),

    /// A scheduled refresh exceeded its transaction-local statement deadline.
    #[error("scheduled statement timeout: {0}")]
    ScheduledStatementTimeout(String),

    /// The defining query exceeds the maximum parse depth (G13-SD).
    #[error("query too complex: {0}")]
    QueryTooComplex(String),

    /// SEC-1: The current role does not own the stream table's storage table.
    #[error("permission denied: {0}")]
    PermissionDenied(String),

    // ── Schema errors — may require reinitialize ─────────────────────────
    /// An upstream (source) table was dropped.
    #[error("upstream table dropped: OID {0}")]
    UpstreamTableDropped(u32),

    /// An upstream table's schema changed (ALTER TABLE).
    #[error("upstream table schema changed: OID {0}")]
    UpstreamSchemaChanged(u32),

    // ── System errors — retry with backoff ───────────────────────────────
    /// A lock could not be acquired within the timeout.
    #[error("lock timeout: {0}")]
    LockTimeout(String),

    /// An error occurred with a logical replication slot.
    #[error("replication slot error: {0}")]
    ReplicationSlotError(String),

    /// An error occurred during WAL-based CDC transition (trigger → WAL).
    #[error("WAL transition error: {0}")]
    WalTransitionError(String),

    /// A safe CDC frontier could not be established.
    #[error("safe frontier unavailable: {context}: {reason}")]
    SafeFrontierUnavailable { context: String, reason: String },

    /// A source snapshot could not be aligned with the CDC protocol.
    #[error("snapshot alignment unavailable for {source_name}: {reason}")]
    SnapshotAlignmentUnavailable { source_name: String, reason: String },

    /// CDC state is missing, malformed, or inaccessible and requires repair.
    #[error(
        "CDC state invalid for pgt_id={pgt_id}, source={source_name}, buffer={buffer}: {reason}"
    )]
    CdcStateInvalid {
        pgt_id: i64,
        source_name: String,
        buffer: String,
        reason: String,
    },

    /// A CDC cutover could not be proven safe.
    #[error(
        "CDC cutover to {target} unproven for source OID {source_oid}: required LSN {required_lsn}, confirmed LSN {confirmed_lsn:?}"
    )]
    CdcCutoverUnproven {
        source_oid: u32,
        target: String,
        required_lsn: String,
        confirmed_lsn: Option<String>,
    },

    /// Refresh finalization failed after execution.
    #[error("refresh finalization failed for pgt_id={pgt_id} at {stage}: {reason}")]
    RefreshFinalizationFailed {
        pgt_id: i64,
        stage: String,
        reason: String,
    },

    /// An SPI (Server Programming Interface) error occurred.
    #[error("SPI error: {0}")]
    SpiError(String),

    /// SCAL-1 (v0.30.0): An SPI error with SQLSTATE code preserved.
    ///
    /// Used when `pg_trickle.use_sqlstate_classification = true` to classify
    /// retryability by 5-character SQLSTATE code instead of English message text.
    /// The first field is the PostgreSQL integer error code (`pg_sys::ErrorData.sqlerrcode`);
    /// the second is the human-readable message (may be in any locale).
    #[error("SPI error [{0}]: {1}")]
    SpiErrorCode(u32, String),

    /// An SPI permission error (SQLSTATE 42xxx) — not retryable.
    ///
    /// F34 (G3.4): Surfaces clear error message when the background worker's
    /// role lacks SELECT on a source table or INSERT on the stream table.
    #[error("SPI permission error: {0}")]
    SpiPermissionError(String),

    // ── Watermark errors ─────────────────────────────────────────────────
    /// A watermark advancement was rejected because the new value is older
    /// than the current watermark (monotonicity violation).
    #[error("watermark moved backward: {0}")]
    WatermarkBackwardMovement(String),

    /// A watermark group was not found.
    #[error("watermark group not found: {0}")]
    WatermarkGroupNotFound(String),

    /// A watermark group with this name already exists.
    #[error("watermark group already exists: {0}")]
    WatermarkGroupAlreadyExists(String),

    // ── Transient errors — always retry ──────────────────────────────────
    /// A refresh was skipped because a previous one is still running.
    #[error("refresh skipped: {0}")]
    RefreshSkipped(String),

    // ── Publication errors ───────────────────────────────────────────────
    /// The stream table already has a downstream publication.
    #[error("publication already exists for stream table: {0}")]
    PublicationAlreadyExists(String),

    /// The stream table does not have a downstream publication.
    #[error("no publication found for stream table: {0}")]
    PublicationNotFound(String),

    /// A downstream publication no longer matches its immutable binding.
    #[error("publication binding '{publication_name}' is stale ({reason}): {detail}")]
    PublicationBindingMismatch {
        publication_name: String,
        reason: String,
        detail: String,
    },

    // ── SLA errors ───────────────────────────────────────────────────────
    /// The SLA interval is too small for any available tier.
    #[error("SLA interval too small for available tiers: {0}")]
    SlaTooSmall(String),

    // ── CDC errors ───────────────────────────────────────────────────────
    /// CDC-1 (v0.24.0): Failed to build the changed-columns bitmask expression.
    /// This indicates a table structure that prevents column-change tracking
    /// (e.g., all columns are part of the primary key).
    #[error("failed to build changed-columns bitmask: {0}")]
    ChangedColsBitmaskFailed(String),

    /// CDC-2 (v0.24.0): Publication rebuild failed for a partitioned source.
    #[error("publication rebuild failed: {0}")]
    PublicationRebuildFailed(String),

    // ── Diagnostic errors — failures in the diagnostics/monitoring API ──────
    /// ERR-1 (v0.26.0): An error occurred inside a diagnostic or monitoring
    /// function (e.g., `explain_refresh_mode`, `source_gates`, `watermarks`).
    /// These surface as user-visible PostgreSQL errors with context info.
    #[error("diagnostic error: {0}")]
    DiagnosticError(String),

    // ── Internal errors — should not happen ──────────────────────────────
    /// An unexpected internal error. Indicates a bug.
    #[error("internal error: {0}")]
    InternalError(String),

    // ── Snapshot errors (SNAP, v0.27.0) ──────────────────────────────────
    /// SNAP-1 (v0.27.0): A snapshot with the given target name already exists.
    #[error("snapshot already exists: {0}")]
    SnapshotAlreadyExists(String),

    /// SNAP-2 (v0.27.0): The specified snapshot source table was not found.
    #[error("snapshot source not found: {0}")]
    SnapshotSourceNotFound(String),

    /// SNAP-2 (v0.27.0): The snapshot schema version does not match the current extension version.
    #[error("snapshot schema version mismatch: {0}")]
    SnapshotSchemaVersionMismatch(String),

    // ── Outbox/pg_tide integration errors (v0.46.0) ──────────────────────
    /// v0.46.0: Outbox already attached for this stream table.
    #[error("outbox already attached for stream table: {0}")]
    OutboxAlreadyEnabled(String),

    /// v0.46.0: Outbox not attached for this stream table.
    #[error("outbox not attached for stream table: {0}")]
    OutboxNotEnabled(String),

    /// v0.46.0: `pg_tide` extension is not installed.
    #[error(
        "attach_outbox() requires the pg_tide extension. \
         Install it with: CREATE EXTENSION pg_tide; \
         See https://github.com/trickle-labs/pg-tide"
    )]
    PgTideMissing,

    /// v0.87.13: The installed pg_tide version is outside the supported range.
    #[error("pg_tide version {installed} is unsupported; supported range is {supported}")]
    PgTideUnsupportedVersion {
        installed: String,
        supported: String,
    },

    /// v0.87.13: pg_tide is in the supported range but its API/catalog is
    /// incomplete, which is expected during an extension upgrade.
    #[error("pg_tide {installed} is not ready for outbox integration; missing: {missing}")]
    PgTideUpgradeInProgress { installed: String, missing: String },

    /// v0.87.13: pg_tide rejected an operation under the original caller.
    #[error("pg_tide denied {operation}: {detail}")]
    PgTideOperationDenied { operation: String, detail: String },

    /// v0.87.13: the private mapping no longer identifies the same pg_tide
    /// outbox and must never be followed silently.
    #[error("stale pg_tide outbox binding for {outbox_name}: {detail}")]
    PgTideBindingMismatch { outbox_name: String, detail: String },

    // ── A41-2: Placeholder validation errors ─────────────────────────────
    /// A41-2: A delta SQL template still contains unresolved placeholder
    /// tokens after all substitution passes have completed.
    ///
    /// This indicates a bug where a `__PGS_*__` or `__PGT_*__` token was
    /// not mapped to any known source OID or stream table ID.  Executing
    /// the SQL with a raw token would cause a PostgreSQL syntax/type error
    /// that is hard to diagnose.  Raising this error early gives the
    /// caller a deterministic, actionable message.
    #[error("unresolved placeholder '{token}' in SQL for {context}")]
    UnresolvedPlaceholder {
        /// The unresolved token (e.g. `__PGS_PREV_LSN_99999__`).
        token: String,
        /// Contextual description (stream table name or function name).
        context: String,
    },

    // ── v0.54.0: DVM engine hardening errors ─────────────────────────────
    /// C-7 (v0.54.0): The `diff_node()` recursion depth exceeded the configured limit.
    ///
    /// Triggered when a differentially refreshed query has more than
    /// `pg_trickle.max_parse_depth` nested subquery / operator levels.
    /// Prevents stack-overflow on pathological deeply-nested queries.
    #[error(
        "differential query depth exceeded limit of {0} levels; \
         reduce query nesting or raise pg_trickle.max_parse_depth"
    )]
    DiffDepthExceeded(usize),

    /// R-7 (v0.54.0): The number of CTEs generated during differentiation exceeded
    /// the configured limit (`pg_trickle.max_diff_ctes`).
    ///
    /// Prevents unbounded memory growth for pathological queries that
    /// produce thousands of intermediate CTEs.
    #[error(
        "differential query CTE count exceeded limit of {0}; \
         simplify the query or raise pg_trickle.max_diff_ctes"
    )]
    DiffCteCountExceeded(usize),

    /// C-4 (v0.54.0): An ST-to-ST source frontier entry is missing from the
    /// refresh frontier, indicating the upstream stream table was dropped
    /// while a downstream ST still references it.
    ///
    /// The downstream ST must be reinitialized or dropped to recover.
    #[error(
        "upstream stream table (pgt_id={0}) not found in refresh frontier; \
         the source stream table may have been dropped — \
         call pgtrickle.reinitialize_stream_table() to recover"
    )]
    StSourceFrontierMissing(i64),
}

impl PgTrickleError {
    /// ERR-1/ERR-2 (v0.26.0): Convert this error into a PostgreSQL-level error.
    ///
    /// Call **only** at the `#[pg_extern]` SQL API boundary. Internal code must
    /// propagate via `Result<T, PgTrickleError>` using `?`.
    pub fn into_pg_error(self) -> ! {
        pgrx::error!("{}", self)
    }

    /// Whether this error is retryable by the scheduler.
    ///
    /// System errors and skipped refreshes are retryable.
    /// User errors, schema errors, and internal errors are not.
    pub fn is_retryable(&self) -> bool {
        match self {
            PgTrickleError::LockTimeout(_)
            | PgTrickleError::ReplicationSlotError(_)
            | PgTrickleError::WalTransitionError(_)
            | PgTrickleError::SafeFrontierUnavailable { .. }
            | PgTrickleError::CdcCutoverUnproven { .. }
            | PgTrickleError::RefreshSkipped(_) => true,
            // F29 (G8.6): Classify SPI errors by SQLSTATE for retry decisions.
            // Only truly transient errors (serialization, lock, connection) are
            // retryable. Permission errors (42xxx), constraint violations (23xxx),
            // and division-by-zero are NOT retryable.
            PgTrickleError::SpiError(msg) => classify_spi_error_retryable(msg),
            // SCAL-1 (v0.30.0): SpiErrorCode uses SQLSTATE for classification —
            // locale-safe and works with any lc_messages setting.
            PgTrickleError::SpiErrorCode(code, _msg) => classify_spi_sqlstate_retryable(*code),
            // Permission errors are never retryable.
            PgTrickleError::SpiPermissionError(_) => false,
            _ => false,
        }
    }

    /// Whether this error requires the ST to be reinitialized.
    pub fn requires_reinitialize(&self) -> bool {
        matches!(
            self,
            PgTrickleError::UpstreamSchemaChanged(_)
                | PgTrickleError::UpstreamTableDropped(_)
                | PgTrickleError::StSourceFrontierMissing(_)
                | PgTrickleError::CdcStateInvalid { .. }
        )
    }

    /// Whether this error should count toward the consecutive error limit.
    ///
    /// Skipped refreshes and some transient errors don't count because the
    /// ST itself isn't broken — the scheduler just couldn't run it this time.
    pub fn counts_toward_suspension(&self) -> bool {
        match self {
            PgTrickleError::LockTimeout(_) | PgTrickleError::ScheduledLockTimeout(_) => false,
            PgTrickleError::SpiError(message) => !is_lock_conflict_message(message),
            PgTrickleError::SpiErrorCode(code, _) => sqlstate_to_string(*code) != "55P03",
            _ => !matches!(
                self,
                PgTrickleError::RefreshSkipped(_)
                    | PgTrickleError::SpiPermissionError(_)
                    | PgTrickleError::PermissionDenied(_)
                    | PgTrickleError::SafeFrontierUnavailable { .. }
                    | PgTrickleError::CdcStateInvalid { .. }
                    | PgTrickleError::CdcCutoverUnproven { .. }
            ),
        }
    }
}

fn is_lock_conflict_message(message: &str) -> bool {
    let message = message.to_ascii_lowercase();
    message.contains("lock timeout")
        || message.contains("lock not available")
        || message.contains("could not obtain lock")
}

/// F29 (G8.6): Classify an SPI error message for retry eligibility.
///
/// Heuristic: looks for patterns in the human-readable error message text
/// returned by pgrx from PostgreSQL SPI. SQLSTATE codes are NOT included in
/// the SPI error string, so all patterns here are text-based rather than
/// code-based.
///
/// Non-retryable (permanent) errors:
/// - Permission denied
/// - Schema errors: column/relation/function does not exist, syntax error
/// - Constraint violations
/// - Division by zero / data exceptions
/// - Duplicate objects
///
/// Retryable (transient) errors:
/// - Serialization failures
/// - Deadlocks
/// - Lock timeouts
/// - Connection errors
///
/// If no pattern matches, defaults to retryable (safe for unknown errors).
pub fn classify_spi_error_retryable(msg: &str) -> bool {
    let msg_lower = msg.to_lowercase();

    // Non-retryable patterns — text as it appears in PostgreSQL error messages.
    // SQLSTATE codes (42703, 42p01, ...) do NOT appear in pgrx SPI error strings,
    // so all matches are against the human-readable message text.
    let non_retryable_patterns = [
        // Schema / object errors — permanent until schema is fixed
        "does not exist", // column X does not exist, relation X does not exist, etc.
        "syntax error",   // SQL syntax error — permanent until query is fixed
        "column of relation", // column-level schema error
        // Permission errors
        "permission denied",
        "insufficient_privilege",
        // Constraint / data errors
        "unique constraint",
        "foreign key constraint",
        "not-null constraint",
        "check constraint",
        "violates", // "violates unique constraint", "violates check constraint"
        "division by zero",
        // Duplicate object errors
        "already exists",
        // Dead legacy SQLSTATE code patterns (not in message text, kept as comments):
        // "42501", "42000", "42601", "42p01", "42703", "23xxx", "22xxx"
    ];

    for pat in &non_retryable_patterns {
        if msg_lower.contains(pat) {
            return false;
        }
    }

    // Explicitly retryable patterns
    let retryable_patterns = [
        "serialization",
        "deadlock",
        "could not obtain lock",
        "canceling statement due to lock timeout",
        "connection",
        "server closed the connection",
    ];

    for pat in &retryable_patterns {
        if msg_lower.contains(pat) {
            return true;
        }
    }

    // Default: retry unknown SPI errors (conservative — better to retry
    // a non-retryable error once than to permanently fail a retryable one)
    true
}

/// SCAL-1 (v0.30.0): Classify an SPI error by PostgreSQL integer SQLSTATE code.
///
/// This function is locale-safe: it classifies by the numeric error code
/// (`pg_sys::ErrorData.sqlerrcode`) rather than the human-readable message text,
/// so it works correctly regardless of `lc_messages` setting.
///
/// Non-retryable SQLSTATE classes (first 2 chars):
/// - 42xxx — syntax error or access rule violation (undefined table, column, etc.)
/// - 23xxx — integrity constraint violation (unique, FK, not-null, check)
/// - 22xxx — data exception (division by zero, numeric overflow, etc.)
/// - 28xxx — invalid authorization specification
///
/// Retryable SQLSTATE codes:
/// - 40001 — serialization_failure
/// - 40P01 — deadlock_detected
/// - 55P03 — lock_not_available (lock timeout)
/// - 57014 — query_canceled (statement_timeout)
/// - 08xxx — connection exception class
///
/// Unknown codes default to retryable (conservative).
///
/// Used when `PgTrickleError::SpiErrorCode` is constructed (SCAL-1 active).
pub fn classify_spi_sqlstate_retryable(sqlstate_code: u32) -> bool {
    // PostgreSQL SQLSTATE codes use MAKE_SQLSTATE('C1','C2','C3','C4','C5').
    // The numeric value encodes the 5-character code. The class is the first 2 chars.
    // pgrx/pg_sys exposes integer error codes; we check against known constants.
    //
    // The ERRCODE macros below are defined in PostgreSQL's errcodes.h and available
    // via pg_sys. For SCAL-1 these are replicated as integer literals to avoid
    // build-time dependencies on the pg_sys bindings for every variant.
    //
    // Format: MAKE_SQLSTATE(c1,c2,c3,c4,c5) where each char is 6-bit encoded.
    // Class = first 2 chars. Non-retryable classes: 42 (syntax/access), 23 (constraint),
    //         22 (data exception), 28 (auth).  Retryable: 40 (transaction rollback), 08 (conn).

    // Both the non-test and test paths use the class-based helper so that the
    // classification is locale-independent and doesn't rely on pg_sys::ERRCODE_*
    // constants (which are not reliably available across all pgrx versions/targets).
    classify_spi_sqlstate_retryable_for_test(sqlstate_code)
}

/// Test-only SQLSTATE classification using raw integer codes computed from
/// PostgreSQL's MAKE_SQLSTATE macro, so tests don't need a live PG backend.
///
/// MAKE_SQLSTATE(c1,c2,c3,c4,c5) = ((c1-'A')<<24)|((c2-'A')<<18)|...
/// For 5 chars c[0..5], each encoded in 6 bits.
pub fn classify_spi_sqlstate_retryable_for_test(sqlstate_code: u32) -> bool {
    // Well-known integer codes for tests (from PostgreSQL errcodes.h):
    // 40001 → serialization_failure   → MAKE_SQLSTATE('4','0','0','0','1')
    // 40P01 → deadlock_detected       → MAKE_SQLSTATE('4','0','P','0','1')
    // 55P03 → lock_not_available      → MAKE_SQLSTATE('5','5','P','0','3')
    // 57014 → query_canceled          → MAKE_SQLSTATE('5','7','0','1','4')
    // 42xxx → syntax/access errors    → many codes starting with '4','2'
    // 23xxx → constraint violations   → codes starting with '2','3'
    // 22xxx → data exceptions         → codes starting with '2','2'
    //
    // We use a helper to extract the 5-char string from the integer.
    let class = sqlstate_class(sqlstate_code);
    match class.as_str() {
        "40" => true, // transaction rollback (serialization, deadlock)
        "08" => true, // connection exception
        "55" => {
            // 55P03 = lock_not_available: retryable; others are not
            let code_str = sqlstate_to_string(sqlstate_code);
            code_str == "55P03"
        }
        "57" => {
            // 57014 = query_canceled: retryable; others (57P01 admin abort) not
            let code_str = sqlstate_to_string(sqlstate_code);
            code_str == "57014"
        }
        "42" | "23" | "22" | "28" => false, // permanent errors
        _ => true,                          // conservative default: retry unknown codes
    }
}

/// O39-6 (v0.39.0): SQLSTATE-first unified retry classifier for scheduler and
/// refresh paths.
///
/// When `pg_trickle.use_sqlstate_classification = true` (default), this routes to
/// `classify_spi_sqlstate_retryable` if a SQLSTATE code is available (i.e., the
/// error message begins with a bracketed code like `[40001]`). Otherwise it falls
/// back to `classify_spi_error_retryable`.
///
/// When the GUC is `false`, always uses the text-based classifier.
///
/// This is the single entry point that all scheduler, refresh, and catalog
/// hot paths should use instead of calling `classify_spi_error_retryable`
/// directly, so retry behaviour is locale-independent everywhere once the
/// GUC is enabled.
pub fn classify_error_for_retry(error_msg: &str) -> bool {
    // Try to parse a leading SQLSTATE code of the form "[XXXXX]: message".
    // `PgTrickleError::SpiErrorCode` formats as "SPI error [<code>]: <msg>"
    // but raw panic messages may also carry codes in this bracket form.
    if crate::config::pg_trickle_use_sqlstate_classification()
        && let Some(code) = extract_sqlstate_code_from_message(error_msg)
    {
        return classify_spi_sqlstate_retryable(code);
    }
    classify_spi_error_retryable(error_msg)
}

/// O39-6 (v0.39.0): Extract a PostgreSQL SQLSTATE integer code embedded in an
/// error message string of the form `"SPI error [<decimal>]: <text>"`.
///
/// Returns `None` if the message does not contain a parseable bracketed code.
fn extract_sqlstate_code_from_message(msg: &str) -> Option<u32> {
    // Match the format produced by `PgTrickleError::SpiErrorCode`:
    // "SPI error [<u32>]: <message>"
    let start = msg.find('[')? + 1;
    let end = msg[start..].find(']')? + start;
    msg[start..end].parse::<u32>().ok()
}

/// Extract the 2-character SQLSTATE class from a PostgreSQL integer error code.
fn sqlstate_class(code: u32) -> String {
    let s = sqlstate_to_string(code);
    s.chars().take(2).collect()
}

/// Convert a PostgreSQL integer SQLSTATE code to its 5-character string representation.
///
/// PostgreSQL uses `MAKE_SQLSTATE(c1,c2,c3,c4,c5)` which encodes each character
/// in 6 bits. Characters 'A'..'Z' map to 1..26; '0'..'9' map to values offset
/// by the alphabet. Digit characters use an offset that differs from letter chars.
/// This reverses that encoding.
pub fn sqlstate_to_string(code: u32) -> String {
    // PostgreSQL MAKE_SQLSTATE packs chars as:
    //   result = 0
    //   for each char c (left to right):
    //       result = (result << 6) | encode(c)
    // where encode('A'..='Z') = 1..26, encode('0'..='9') = 27..36.
    // Unpack: extract 6-bit groups from MSB to LSB.
    let mut chars = Vec::with_capacity(5);
    let mut v = code;
    for _ in 0..5 {
        let c6 = (v >> 24) & 0x3F;
        v <<= 6;
        let ch = if (1..=26).contains(&c6) {
            (b'A' + (c6 as u8 - 1)) as char
        } else if (27..=36).contains(&c6) {
            (b'0' + (c6 as u8 - 27)) as char
        } else {
            '?'
        };
        chars.push(ch);
    }
    chars.into_iter().collect()
}

/// Classification of error severity/kind for monitoring.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PgTrickleErrorKind {
    User,
    Schema,
    System,
    Internal,
}

impl fmt::Display for PgTrickleErrorKind {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            PgTrickleErrorKind::User => write!(f, "USER"),
            PgTrickleErrorKind::Schema => write!(f, "SCHEMA"),
            PgTrickleErrorKind::System => write!(f, "SYSTEM"),
            PgTrickleErrorKind::Internal => write!(f, "INTERNAL"),
        }
    }
}

impl PgTrickleError {
    /// Classify the error for monitoring and alerting.
    pub fn kind(&self) -> PgTrickleErrorKind {
        match self {
            PgTrickleError::QueryParseError(_)
            | PgTrickleError::TypeMismatch(_)
            | PgTrickleError::UnsupportedOperator(_)
            | PgTrickleError::CycleDetected(_)
            | PgTrickleError::NotFound(_)
            | PgTrickleError::AlreadyExists(_)
            | PgTrickleError::InvalidArgument(_)
            | PgTrickleError::QueryTooComplex(_)
            | PgTrickleError::PermissionDenied(_)
            | PgTrickleError::WatermarkBackwardMovement(_)
            | PgTrickleError::WatermarkGroupNotFound(_)
            | PgTrickleError::WatermarkGroupAlreadyExists(_)
            | PgTrickleError::PublicationAlreadyExists(_)
            | PgTrickleError::PublicationNotFound(_)
            | PgTrickleError::PublicationBindingMismatch { .. }
            | PgTrickleError::SlaTooSmall(_) => PgTrickleErrorKind::User,

            PgTrickleError::SharedStateCapacity(_) => PgTrickleErrorKind::Internal,

            PgTrickleError::UpstreamTableDropped(_) | PgTrickleError::UpstreamSchemaChanged(_) => {
                PgTrickleErrorKind::Schema
            }

            PgTrickleError::LockTimeout(_)
            | PgTrickleError::ReplicationSlotError(_)
            | PgTrickleError::WalTransitionError(_)
            | PgTrickleError::SafeFrontierUnavailable { .. }
            | PgTrickleError::CdcCutoverUnproven { .. }
            | PgTrickleError::SpiError(_)
            | PgTrickleError::SpiErrorCode(_, _)
            | PgTrickleError::ScheduledLockTimeout(_)
            | PgTrickleError::ScheduledStatementTimeout(_)
            | PgTrickleError::RefreshSkipped(_) => PgTrickleErrorKind::System,

            // F34: Permission errors are user-facing, not system-level.
            PgTrickleError::SpiPermissionError(_) => PgTrickleErrorKind::User,

            PgTrickleError::InternalError(_) => PgTrickleErrorKind::Internal,

            PgTrickleError::ChangedColsBitmaskFailed(_)
            | PgTrickleError::PublicationRebuildFailed(_) => PgTrickleErrorKind::System,

            // ERR-1 (v0.26.0): Diagnostic errors are system-level (SPI failures,
            // catalog query issues). They are not retried but are surfaced to the user.
            PgTrickleError::DiagnosticError(_) => PgTrickleErrorKind::System,

            // SNAP-1/2/3 (v0.27.0): Snapshot errors are user-facing.
            PgTrickleError::SnapshotAlreadyExists(_)
            | PgTrickleError::SnapshotSourceNotFound(_)
            | PgTrickleError::SnapshotSchemaVersionMismatch(_) => PgTrickleErrorKind::User,

            // v0.46.0: Outbox/pg_tide integration errors.
            PgTrickleError::OutboxAlreadyEnabled(_)
            | PgTrickleError::OutboxNotEnabled(_)
            | PgTrickleError::PgTideMissing
            | PgTrickleError::PgTideUnsupportedVersion { .. }
            | PgTrickleError::PgTideUpgradeInProgress { .. }
            | PgTrickleError::PgTideOperationDenied { .. }
            | PgTrickleError::PgTideBindingMismatch { .. }
            | PgTrickleError::UnresolvedPlaceholder { .. } => PgTrickleErrorKind::Internal,

            // v0.54.0: DVM engine hardening errors.
            PgTrickleError::DiffDepthExceeded(_) | PgTrickleError::DiffCteCountExceeded(_) => {
                PgTrickleErrorKind::User
            }

            // C-4: Dropped ST source is a schema-level error (requires reinitialize).
            PgTrickleError::StSourceFrontierMissing(_) => PgTrickleErrorKind::Schema,

            PgTrickleError::SnapshotAlignmentUnavailable { .. }
            | PgTrickleError::RefreshFinalizationFailed { .. } => PgTrickleErrorKind::System,

            PgTrickleError::CdcStateInvalid { .. } => PgTrickleErrorKind::Schema,
        }
    }
}

// ── Retry Policy ───────────────────────────────────────────────────────────

/// Retry policy with exponential backoff for system errors.
///
/// Used by the scheduler to decide whether a failed ST should be retried
/// immediately, deferred, or given up on.
#[derive(Debug, Clone)]
pub struct RetryPolicy {
    /// Base delay in milliseconds (doubled each attempt).
    pub base_delay_ms: u64,
    /// Maximum delay in milliseconds (cap for backoff).
    pub max_delay_ms: u64,
    /// Maximum number of retry attempts before giving up.
    pub max_attempts: u32,
}

impl Default for RetryPolicy {
    fn default() -> Self {
        Self {
            base_delay_ms: 1_000, // 1 second initial
            max_delay_ms: 60_000, // 1 minute cap
            max_attempts: 5,      // 5 retries before counting as a real failure
        }
    }
}

impl RetryPolicy {
    /// Calculate the backoff delay in milliseconds for the given attempt number (0-based).
    ///
    /// Uses exponential backoff: `base_delay * 2^attempt`, capped at `max_delay`.
    /// Adds simple jitter by varying ±25%.
    pub fn backoff_ms(&self, attempt: u32) -> u64 {
        let delay = self.base_delay_ms.saturating_mul(1u64 << attempt.min(16));
        let capped = delay.min(self.max_delay_ms);

        // Simple deterministic jitter: vary by ±25% based on attempt parity
        if attempt.is_multiple_of(2) {
            capped.saturating_mul(3) / 4 // -25%
        } else {
            capped.saturating_mul(5) / 4 // +25%
        }
    }

    /// Whether the given attempt (0-based) is within the retry limit.
    pub fn should_retry(&self, attempt: u32) -> bool {
        attempt < self.max_attempts
    }
}

// ── Per-ST Retry State ─────────────────────────────────────────────────────

/// Tracks retry state for a single stream table in the scheduler.
///
/// Stored in-memory by the scheduler (not persisted). Reset when a refresh
/// succeeds or the scheduler restarts.
#[derive(Debug, Clone)]
pub struct RetryState {
    /// Number of consecutive retryable failures.
    pub attempts: u32,
    /// Timestamp (epoch millis) when the next retry is allowed.
    pub next_retry_at_ms: u64,
}

impl Default for RetryState {
    fn default() -> Self {
        Self::new()
    }
}

impl RetryState {
    pub fn new() -> Self {
        Self {
            attempts: 0,
            next_retry_at_ms: 0,
        }
    }

    /// Record a retryable failure and compute the next retry time.
    ///
    /// Returns `true` if another retry is allowed, `false` if max attempts exhausted.
    pub fn record_failure(&mut self, policy: &RetryPolicy, now_ms: u64) -> bool {
        self.attempts += 1;
        if policy.should_retry(self.attempts) {
            self.next_retry_at_ms = now_ms + policy.backoff_ms(self.attempts - 1);
            true
        } else {
            false
        }
    }

    /// Reset retry state after a successful refresh.
    pub fn reset(&mut self) {
        self.attempts = 0;
        self.next_retry_at_ms = 0;
    }

    /// Whether the ST is currently in a retry-backoff period.
    pub fn is_in_backoff(&self, now_ms: u64) -> bool {
        self.attempts > 0 && now_ms < self.next_retry_at_ms
    }
}

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

    #[test]
    fn test_error_classification() {
        assert_eq!(
            PgTrickleError::QueryParseError("x".into()).kind(),
            PgTrickleErrorKind::User
        );
        assert_eq!(
            PgTrickleError::UpstreamSchemaChanged(1).kind(),
            PgTrickleErrorKind::Schema
        );
        assert_eq!(
            PgTrickleError::LockTimeout("x".into()).kind(),
            PgTrickleErrorKind::System
        );
        assert_eq!(
            PgTrickleError::InternalError("x".into()).kind(),
            PgTrickleErrorKind::Internal
        );
        assert_eq!(
            PgTrickleError::RefreshSkipped("x".into()).kind(),
            PgTrickleErrorKind::System
        );
        // F34: SpiPermissionError is classified as User, not System
        assert_eq!(
            PgTrickleError::SpiPermissionError("x".into()).kind(),
            PgTrickleErrorKind::User
        );
    }

    #[test]
    fn test_retryable_errors() {
        assert!(PgTrickleError::LockTimeout("x".into()).is_retryable());
        assert!(PgTrickleError::ReplicationSlotError("x".into()).is_retryable());
        // Transient errors — retryable
        assert!(PgTrickleError::SpiError("connection lost".into()).is_retryable());
        assert!(PgTrickleError::SpiError("serialization failure".into()).is_retryable());
        assert!(PgTrickleError::SpiError("deadlock detected".into()).is_retryable());
        assert!(PgTrickleError::SpiError("could not obtain lock".into()).is_retryable());
        assert!(PgTrickleError::RefreshSkipped("x".into()).is_retryable());

        // Permanent schema errors — NOT retryable (human-readable PG messages)
        assert!(
            !PgTrickleError::SpiError(r#"column "extra" of relation "src" does not exist"#.into())
                .is_retryable()
        );
        assert!(
            !PgTrickleError::SpiError(r#"relation "missing_table" does not exist"#.into())
                .is_retryable()
        );
        assert!(
            !PgTrickleError::SpiError(r#"syntax error at or near "SELEC""#.into()).is_retryable()
        );
        assert!(!PgTrickleError::SpiError("permission denied for table foo".into()).is_retryable());
        assert!(!PgTrickleError::SpiError("unique constraint violated".into()).is_retryable());
        assert!(!PgTrickleError::SpiError("violates not-null constraint".into()).is_retryable());
        assert!(!PgTrickleError::SpiError("division by zero".into()).is_retryable());

        // F34: SpiPermissionError is never retryable
        assert!(!PgTrickleError::SpiPermissionError("x".into()).is_retryable());

        assert!(!PgTrickleError::QueryParseError("x".into()).is_retryable());
        assert!(!PgTrickleError::CycleDetected(vec![]).is_retryable());
        assert!(!PgTrickleError::InternalError("x".into()).is_retryable());
    }

    #[test]
    fn test_requires_reinitialize() {
        assert!(PgTrickleError::UpstreamSchemaChanged(1).requires_reinitialize());
        assert!(PgTrickleError::UpstreamTableDropped(1).requires_reinitialize());
        assert!(
            PgTrickleError::CdcStateInvalid {
                pgt_id: 1,
                source_name: "source".into(),
                buffer: "buffer".into(),
                reason: "missing".into(),
            }
            .requires_reinitialize()
        );
        assert!(!PgTrickleError::SpiError("x".into()).requires_reinitialize());
    }

    #[test]
    fn test_counts_toward_suspension() {
        assert!(PgTrickleError::SpiError("x".into()).counts_toward_suspension());
        assert!(!PgTrickleError::LockTimeout("x".into()).counts_toward_suspension());
        assert!(!PgTrickleError::ScheduledLockTimeout("x".into()).counts_toward_suspension());
        assert!(
            !PgTrickleError::SpiError("canceling statement due to lock timeout".into())
                .counts_toward_suspension()
        );
        assert!(
            !PgTrickleError::SpiErrorCode(545_326_814, "lock_not_available".into())
                .counts_toward_suspension()
        );
        assert!(!PgTrickleError::RefreshSkipped("x".into()).counts_toward_suspension());
        // F34: SpiPermissionError does not count toward suspension
        assert!(!PgTrickleError::SpiPermissionError("x".into()).counts_toward_suspension());
        assert!(
            !PgTrickleError::SafeFrontierUnavailable {
                context: "tick".into(),
                reason: "probe failed".into(),
            }
            .counts_toward_suspension()
        );
        assert!(
            !PgTrickleError::CdcStateInvalid {
                pgt_id: 1,
                source_name: "source".into(),
                buffer: "buffer".into(),
                reason: "missing".into(),
            }
            .counts_toward_suspension()
        );
    }

    #[test]
    fn test_v082_error_classification() {
        let frontier = PgTrickleError::SafeFrontierUnavailable {
            context: "tick".into(),
            reason: "probe failed".into(),
        };
        assert!(frontier.is_retryable());
        assert_eq!(frontier.kind(), PgTrickleErrorKind::System);

        let alignment = PgTrickleError::SnapshotAlignmentUnavailable {
            source_name: "orders".into(),
            reason: "snapshot unavailable".into(),
        };
        assert!(!alignment.is_retryable());
        assert_eq!(alignment.kind(), PgTrickleErrorKind::System);

        let cutover = PgTrickleError::CdcCutoverUnproven {
            source_oid: 42,
            target: "WAL".into(),
            required_lsn: "0/10".into(),
            confirmed_lsn: Some("0/9".into()),
        };
        assert!(cutover.is_retryable());
        assert!(!cutover.requires_reinitialize());

        let finalization = PgTrickleError::RefreshFinalizationFailed {
            pgt_id: 1,
            stage: "frontier".into(),
            reason: "rollback".into(),
        };
        assert!(!finalization.is_retryable());
        assert_eq!(finalization.kind(), PgTrickleErrorKind::System);
    }

    #[test]
    fn test_classify_spi_error_retryable() {
        // Non-retryable: permanent schema errors (human-readable PG message text)
        assert!(!classify_spi_error_retryable(
            "permission denied for table orders"
        ));
        assert!(!classify_spi_error_retryable(
            r#"column "extra" of relation "src" does not exist"#
        ));
        assert!(!classify_spi_error_retryable(
            r#"relation "missing_table" does not exist"#
        ));
        assert!(!classify_spi_error_retryable(
            r#"syntax error at or near "SELEC""#
        ));
        assert!(!classify_spi_error_retryable(
            "violates unique constraint \"orders_pkey\""
        ));
        assert!(!classify_spi_error_retryable("division by zero"));
        assert!(!classify_spi_error_retryable("already exists"));

        // Non-retryable: old SQLSTATE-in-message style still works where present
        assert!(!classify_spi_error_retryable(
            "ERROR: 42501 insufficient_privilege"
        ));

        // Retryable: transient errors
        assert!(classify_spi_error_retryable("serialization failure"));
        assert!(classify_spi_error_retryable("deadlock detected"));
        assert!(classify_spi_error_retryable(
            "server closed the connection unexpectedly"
        ));
        assert!(classify_spi_error_retryable(
            "could not obtain lock on relation"
        ));

        // Unknown error: default retryable
        assert!(classify_spi_error_retryable("something weird happened"));
    }

    #[test]
    fn test_retry_policy_backoff() {
        let policy = RetryPolicy {
            base_delay_ms: 1000,
            max_delay_ms: 10_000,
            max_attempts: 5,
        };

        // Attempt 0: 1000 * 2^0 = 1000, -25% = 750
        assert_eq!(policy.backoff_ms(0), 750);
        // Attempt 1: 1000 * 2^1 = 2000, +25% = 2500
        assert_eq!(policy.backoff_ms(1), 2500);
        // Attempt 2: 1000 * 2^2 = 4000, -25% = 3000
        assert_eq!(policy.backoff_ms(2), 3000);
        // Attempt 3: 1000 * 2^3 = 8000, +25% = 10000
        assert_eq!(policy.backoff_ms(3), 10_000);
        // Attempt 4: 1000 * 2^4 = 16000, capped at 10000, -25% = 7500
        assert_eq!(policy.backoff_ms(4), 7500);
    }

    #[test]
    fn test_retry_policy_should_retry() {
        let policy = RetryPolicy {
            base_delay_ms: 1000,
            max_delay_ms: 60_000,
            max_attempts: 3,
        };

        assert!(policy.should_retry(0));
        assert!(policy.should_retry(1));
        assert!(policy.should_retry(2));
        assert!(!policy.should_retry(3));
        assert!(!policy.should_retry(4));
    }

    #[test]
    fn test_retry_state_lifecycle() {
        let policy = RetryPolicy::default();
        let mut state = RetryState::new();

        // Fresh state: not in backoff
        assert!(!state.is_in_backoff(1000));
        assert_eq!(state.attempts, 0);

        // First failure
        let now = 10_000;
        assert!(state.record_failure(&policy, now));
        assert_eq!(state.attempts, 1);
        assert!(state.is_in_backoff(now + 100)); // still in backoff
        assert!(!state.is_in_backoff(now + 100_000)); // backoff passed

        // Second failure
        let now2 = 20_000;
        assert!(state.record_failure(&policy, now2));
        assert_eq!(state.attempts, 2);

        // Reset on success
        state.reset();
        assert_eq!(state.attempts, 0);
        assert!(!state.is_in_backoff(0));
    }

    #[test]
    fn test_retry_state_max_attempts_exhausted() {
        let policy = RetryPolicy {
            base_delay_ms: 100,
            max_delay_ms: 1000,
            max_attempts: 2,
        };
        let mut state = RetryState::new();

        // First failure — retries allowed (attempt 1 < max 2)
        assert!(state.record_failure(&policy, 1000));
        assert_eq!(state.attempts, 1);
        // Second failure — max attempts exhausted (attempt 2 >= max 2)
        assert!(!state.record_failure(&policy, 2000));
        assert_eq!(state.attempts, 2);
    }

    // ── G: Additional pure function tests ───────────────────────────

    #[test]
    fn test_error_kind_display_all_variants() {
        assert_eq!(format!("{}", PgTrickleErrorKind::User), "USER");
        assert_eq!(format!("{}", PgTrickleErrorKind::Schema), "SCHEMA");
        assert_eq!(format!("{}", PgTrickleErrorKind::System), "SYSTEM");
        assert_eq!(format!("{}", PgTrickleErrorKind::Internal), "INTERNAL");
    }

    #[test]
    fn test_retry_policy_default_values() {
        let policy = RetryPolicy::default();
        assert_eq!(
            policy.base_delay_ms, 1_000,
            "default base delay should be 1s"
        );
        assert_eq!(
            policy.max_delay_ms, 60_000,
            "default max delay should be 60s"
        );
        assert_eq!(policy.max_attempts, 5, "default max attempts should be 5");
    }

    #[test]
    fn test_query_too_complex_is_user_error() {
        let err = PgTrickleError::QueryTooComplex("depth exceeded".into());
        assert_eq!(err.kind(), PgTrickleErrorKind::User);
        assert!(!err.is_retryable());
        assert!(err.counts_toward_suspension());
        assert!(!err.requires_reinitialize());
        assert!(
            err.to_string().contains("query too complex"),
            "error message: {}",
            err
        );
    }

    // ── O39-13 (v0.39.0): SQLSTATE classifier reliability property tests ───

    /// O39-13-SQLSTATE-1: SQLSTATE-first classifier property tests.
    ///
    /// Verifies that the SQLSTATE integer classifier produces correct,
    /// locale-independent results for the well-known PostgreSQL error classes
    /// used in retry decisions.
    #[test]
    fn test_sqlstate_classifier_retryable_classes() {
        // Retryable: 40xxx = transaction rollback (serialization, deadlock)
        // Approximate MAKE_SQLSTATE('4','0','0','0','1') for 40001
        // We test by constructing known codes symbolically.
        // Instead of deriving MAKE_SQLSTATE (which needs PG headers),
        // we verify round-trip: sqlstate_to_string → classify.

        // Test that "40" class (transaction rollback) is retryable.
        // We construct by setting the first two 6-bit chars to encode '4','0'.
        // MAKE_SQLSTATE encodes: c1='4'→25+('4'-'A')... actually '4' is not A-Z.
        // PostgreSQL uses A=1..Z=26, 0=27..9=36 for MAKE_SQLSTATE.
        // '4' = 27 + 4 = 31; '0' = 27 + 0 = 27.
        // MAKE_SQLSTATE('4','0','x','x','x') = (31<<24)|(27<<18)|...
        // For the test we just verify the text-based fallback for known strings.

        // Known non-retryable SQLSTATE classes via text-based classifier:
        assert!(!classify_spi_error_retryable("permission denied"));
        assert!(!classify_spi_error_retryable("does not exist"));
        assert!(!classify_spi_error_retryable("violates unique constraint"));
        assert!(!classify_spi_error_retryable("division by zero"));

        // Known retryable:
        assert!(classify_spi_error_retryable("serialization failure"));
        assert!(classify_spi_error_retryable("deadlock detected"));
    }

    /// O39-13-SQLSTATE-2: classify_error_for_retry extracts bracket codes.
    ///
    /// Verify the O39-6 bracket-extraction path handles well-formed and
    /// malformed bracket patterns without panicking.
    #[test]
    fn test_classify_error_for_retry_bracket_extraction() {
        // extract_sqlstate_code_from_message is private, but we test via the
        // public classify_spi_error_retryable (the text-based path).
        // Well-formed bracket (not a real SQLSTATE code — just tests parsing).
        let with_bracket = "SPI error [99999]: some message";
        // Must not panic regardless of the bracket content.
        let _ = classify_spi_error_retryable(with_bracket);

        // Malformed brackets:
        let _ = classify_spi_error_retryable("[not-a-number]");
        let _ = classify_spi_error_retryable("[]");
        let _ = classify_spi_error_retryable("[");
        let _ = classify_spi_error_retryable("]");
        let _ = classify_spi_error_retryable("[[nested]]");
        let _ = classify_spi_error_retryable(&"[".repeat(1000));
    }

    #[test]
    fn test_refresh_failure_prefers_sqlstate_and_preserves_cancellation() {
        let lock =
            classify_refresh_failure(Some("55P03"), "canceling statement due to lock timeout");
        assert_eq!(lock.kind, RefreshFailureKind::LockTimeout);
        assert!(lock.retryable);
        assert!(!lock.counts_toward_suspension);
        assert_eq!(lock.sqlstate.as_deref(), Some("55P03"));

        let cancelled =
            classify_refresh_failure(Some("57014"), "canceling statement due to user request");
        assert_eq!(cancelled.kind, RefreshFailureKind::Cancelled);
        assert!(!cancelled.retryable);
        assert!(!cancelled.counts_toward_suspension);
    }

    /// O39-13-SQLSTATE-3: sqlstate_to_string is total and stable.
    ///
    /// For any u32, sqlstate_to_string must return a non-empty string and
    /// the same string for identical inputs (determinism).
    #[test]
    fn test_sqlstate_to_string_total_and_deterministic() {
        let test_codes: &[u32] = &[0, 1, u32::MAX, 0xFFFF_FFFF, 0x0001_0001, 12345678];
        for &code in test_codes {
            let s1 = sqlstate_to_string(code);
            let s2 = sqlstate_to_string(code);
            assert!(
                !s1.is_empty(),
                "sqlstate_to_string({code}) must be non-empty"
            );
            assert_eq!(s1, s2, "sqlstate_to_string must be deterministic");
        }
    }
}
