//! Translator: classify a [`DbError`] and produce a
//! [`FriendlyError`] (ADR-0019 §2, §3).
//!
//! The translator is the chokepoint that absorbs the previous
//! ad-hoc helpers (`friendly_change_column_engine_error`,
//! `enrich_fk_message`). It pattern-matches on the structured
//! [`SqliteErrorKind`] first; for the constraint-violation
//! family (UNIQUE / FK / NOT NULL / CHECK) it then classifies
//! by message text, since rusqlite folds them all under one
//! kind today.
//!
//! Wording flows entirely through the catalog ([`crate::t!`])
//! and never echoes engine error text verbatim.
//!
//! ## Row pinpointing
//!
//! ADR-0019 §6 calls for re-querying the database after a
//! constraint failure to surface the offending row(s) through
//! ADR-0017's bordered diagnostic-table renderer. This is
//! **implemented** (commit `431645a`): the *runtime* resolves the
//! schema-dependent facts — table/column, parent/child tables, the
//! attempted value, and any pinpointed rows — into a
//! [`FailureContext`], which [`TranslateContext::from_facts`] feeds
//! into the translator. When those facts carry a pinpoint,
//! [`FriendlyError::diagnostic_table`] is populated and the renderer
//! appends the bordered table; when they don't (no live DB handle,
//! re-query failed, or a fallback callsite), it stays `None` and the
//! catalog wording carries the full burden. The translator itself
//! never touches the database — it only renders the facts it is
//! handed — so the two layers stay cleanly separable.

use crate::db::{DbError, SqliteErrorKind};
use crate::dsl::Type;
use crate::friendly::error::{DiagnosticTable, FriendlyError};
use crate::t;

/// Verbosity of the rendered error.
///
/// `Short` → headline only. `Verbose` → headline + hint (when
/// the catalog entry has one) + diagnostic table (when present).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Verbosity {
    /// Headline only.
    Short,
    /// Headline + hint + diagnostic table. Default per ADR-0019 §5.
    #[default]
    Verbose,
}

/// The user-visible operation that produced the error.
///
/// Drives operation-tailored wording (ADR-0019 §4) and is
/// surfaced verbatim in `error.generic.*` as the `{operation}`
/// placeholder.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Operation {
    Insert,
    Update,
    Delete,
    CreateTable,
    DropTable,
    AddColumn,
    DropColumn,
    RenameColumn,
    RenameTable,
    ChangeColumnType,
    AddRelationship,
    DropRelationship,
    AddIndex,
    DropIndex,
    AddConstraint,
    DropConstraint,
    Query,
    Rebuild,
    Replay,
    /// Catch-all for callsites with no specific operation
    /// context (e.g. opening a database, listing tables).
    Other,
}

impl Operation {
    /// Short user-facing label for the operation; appears in
    /// `error.generic.headline`'s `{operation}` placeholder.
    /// Stable wording — anchor-phrase-equivalent for future
    /// migration churn.
    #[must_use]
    pub const fn keyword(self) -> &'static str {
        match self {
            Self::Insert => "insert",
            Self::Update => "update",
            Self::Delete => "delete",
            Self::CreateTable => "create table",
            Self::DropTable => "drop table",
            Self::AddColumn => "add column",
            Self::DropColumn => "drop column",
            Self::RenameColumn => "rename column",
            Self::RenameTable => "rename table",
            Self::ChangeColumnType => "change column",
            Self::AddRelationship => "add relationship",
            Self::DropRelationship => "drop relationship",
            Self::AddIndex => "add index",
            Self::DropIndex => "drop index",
            Self::AddConstraint => "add constraint",
            Self::DropConstraint => "drop constraint",
            Self::Query => "query",
            Self::Rebuild => "rebuild",
            Self::Replay => "replay",
            Self::Other => "operation",
        }
    }
}

/// Schema-resolved facts about a failure (ADR-0019 §6).
///
/// Built by the runtime (where the `Database` handle is
/// available) and passed to the App via
/// [`crate::event::AppEvent::DslFailed`]. The App combines a
/// `FailureContext` with its current verbosity and the
/// operation derived from the originating `Command` to build a
/// [`TranslateContext`].
///
/// Every field is optional — the runtime fills what it can
/// resolve and leaves the rest `None`. The translator falls
/// back to its `{name}`-form placeholders where data is
/// missing.
#[derive(Debug, Clone, Default)]
pub struct FailureContext {
    /// Operation table from the command (may differ from
    /// `parent_table` for FK violations).
    pub table: Option<String>,
    /// Column name resolved from engine error (`UNIQUE
    /// constraint failed: T.col` etc.) or via FK relationship
    /// lookup.
    pub column: Option<String>,
    /// User's attempted value for the offending column.
    pub value: Option<String>,
    /// For child-side FK violations: the parent table the FK
    /// references.
    pub parent_table: Option<String>,
    /// For child-side FK violations: the parent column the
    /// FK references.
    pub parent_column: Option<String>,
    /// For parent-side FK violations: a child table that
    /// references this row.
    pub child_table: Option<String>,
    /// Pinpointed offending row(s) per ADR-0019 §6 / ADR-0017
    /// §7. Rendered through the bordered diagnostic-table
    /// renderer when present.
    pub diagnostic_table: Option<DiagnosticTable>,
    /// For a `CHECK` violation: the column's compiled `CHECK`
    /// expression, resolved from the schema (ADR-0029 §10). Lets
    /// the friendly error name the rule the value broke.
    pub check_rule: Option<String>,
}

/// Context the translator uses to pick catalog keys and fill
/// placeholders. Every field is optional — the translator falls
/// back to its `{name}`-form placeholders where context is
/// missing.
#[derive(Debug, Clone, Default)]
pub struct TranslateContext {
    pub operation: Option<Operation>,
    pub table: Option<String>,
    pub column: Option<String>,
    /// For parent-side FK violations: the child table that
    /// references this row. Surfaced as `{child_table}` in
    /// `error.foreign_key.parent_side.*`.
    pub child_table: Option<String>,
    /// For child-side FK violations: the parent table the FK
    /// references. Surfaced as `{parent_table}` in
    /// `error.foreign_key.child_side.*`.
    pub parent_table: Option<String>,
    /// For child-side FK violations: the parent column the FK
    /// references. Surfaced as `{parent_column}`.
    pub parent_column: Option<String>,
    pub src_type: Option<Type>,
    pub target_type: Option<Type>,
    /// User-attempted value for INSERT/UPDATE.
    pub value: Option<String>,
    /// Pinpointed offending row(s); rendered onto the
    /// `FriendlyError::diagnostic_table` field when present.
    pub diagnostic_table: Option<DiagnosticTable>,
    /// For a `CHECK` violation: the column's compiled `CHECK`
    /// expression (ADR-0029 §10). When present, the friendly
    /// error names the rule the value broke.
    pub check_rule: Option<String>,
    pub verbosity: Verbosity,
}

impl TranslateContext {
    /// Convenience constructor for the common "I just have an
    /// operation" case.
    #[must_use]
    pub fn for_op(operation: Operation) -> Self {
        Self {
            operation: Some(operation),
            ..Self::default()
        }
    }

    /// Combine schema-resolved facts with operation and
    /// verbosity to build the full translator input.
    #[must_use]
    pub fn from_facts(operation: Operation, verbosity: Verbosity, facts: FailureContext) -> Self {
        Self {
            operation: Some(operation),
            table: facts.table,
            column: facts.column,
            child_table: facts.child_table,
            parent_table: facts.parent_table,
            parent_column: facts.parent_column,
            src_type: None,
            target_type: None,
            value: facts.value,
            diagnostic_table: facts.diagnostic_table,
            check_rule: facts.check_rule,
            verbosity,
        }
    }
}

/// Classify `error` and produce a structured [`FriendlyError`].
/// See module docs for the classification flow.
#[must_use]
pub fn translate(error: &DbError, ctx: &TranslateContext) -> FriendlyError {
    let mut fe = match error {
        DbError::Sqlite { message, kind } => translate_sqlite(message, *kind, ctx),
        // Unsupported / InvalidValue carry text that is already
        // engine-neutral and friendly (constructed by our own
        // refusal sites). Catalog entries exist for the typed
        // invalid-value cases but the migration sweep
        // (ADR-0019 §9) is what wires them. For now, passthrough.
        DbError::Unsupported(message) | DbError::InvalidValue(message) => passthrough(message),
        DbError::PersistenceFatal { message, .. }
        | DbError::RebuildRowFailed {
            detail: message, ..
        }
        | DbError::Io(message) => passthrough(message),
        DbError::WorkerGone => {
            passthrough("the database worker is no longer available — the application must restart")
        }
    };
    // Attach the row pinpoint when the runtime resolved one.
    // The translator never builds the table itself — it only
    // forwards what enrichment supplied.
    if fe.diagnostic_table.is_none() {
        fe.diagnostic_table = ctx.diagnostic_table.clone();
    }
    fe
}

/// The tier-3 hint class (`hint.err.<class>`) for an error.
///
/// The same classification [`translate`] performs, surfaced as a
/// stable key for the contextual `hint` (H2 / ADR-0053 D5). Returns
/// `None` for internal / fatal errors that carry no learner-facing
/// hint (persistence, IO, worker-gone).
///
/// **Keep in sync with [`translate`] / `translate_sqlite` /
/// `translate_constraint` / `translate_foreign_key`** — the unit tests
/// below pin each class.
#[must_use]
pub fn error_hint_class(error: &DbError, ctx: &TranslateContext) -> Option<&'static str> {
    match error {
        DbError::Sqlite { message, kind } => sqlite_hint_class(message, *kind, ctx),
        DbError::Unsupported(_) | DbError::InvalidValue(_) => Some("invalid_value"),
        DbError::PersistenceFatal { .. }
        | DbError::RebuildRowFailed { .. }
        | DbError::Io(_)
        | DbError::WorkerGone => None,
    }
}

fn sqlite_hint_class(
    message: &str,
    kind: SqliteErrorKind,
    ctx: &TranslateContext,
) -> Option<&'static str> {
    if matches!(ctx.operation, Some(Operation::ChangeColumnType)) {
        return Some("type_mismatch");
    }
    Some(match kind {
        SqliteErrorKind::NoSuchTable | SqliteErrorKind::NoSuchColumn => "not_found",
        SqliteErrorKind::AlreadyExists => "already_exists",
        SqliteErrorKind::UniqueViolation => constraint_hint_class(message, ctx),
        SqliteErrorKind::Other => "generic",
    })
}

fn constraint_hint_class(message: &str, ctx: &TranslateContext) -> &'static str {
    let lower = message.to_ascii_lowercase();
    if lower.contains("unique constraint failed") {
        "unique"
    } else if lower.contains("foreign key constraint failed") {
        fk_hint_class(ctx)
    } else if lower.contains("not null constraint failed") {
        "not_null"
    } else if lower.contains("check constraint failed") {
        "check"
    } else {
        "generic"
    }
}

const fn fk_hint_class(ctx: &TranslateContext) -> &'static str {
    // Mirrors `translate_foreign_key`'s side disambiguation.
    if ctx.parent_table.is_some() {
        return "foreign_key.child_side";
    }
    if ctx.child_table.is_some() {
        return "foreign_key.parent_side";
    }
    match ctx.operation {
        Some(Operation::Delete) => "foreign_key.parent_side",
        _ => "foreign_key.child_side",
    }
}

fn translate_sqlite(message: &str, kind: SqliteErrorKind, ctx: &TranslateContext) -> FriendlyError {
    // `change column ... --dont-convert` lets the engine
    // accept or refuse each cell. Whatever the engine returns
    // (constraint, datatype mismatch, …) means "the new type
    // didn't fit at least one cell". Route to the dedicated
    // change-column wording — subsumes the role
    // `friendly_change_column_engine_error` previously played.
    if matches!(ctx.operation, Some(Operation::ChangeColumnType)) {
        return translate_type_mismatch_change_column(ctx);
    }
    match kind {
        SqliteErrorKind::NoSuchTable => translate_not_found_table(message, ctx),
        SqliteErrorKind::NoSuchColumn => translate_not_found_column(message, ctx),
        SqliteErrorKind::AlreadyExists => translate_already_exists(message, ctx),
        // SqliteErrorKind::UniqueViolation is currently the
        // bucket for *every* constraint violation (UNIQUE, FK,
        // NOT NULL, CHECK) since rusqlite folds them under
        // ConstraintViolation. We split by message text here.
        SqliteErrorKind::UniqueViolation => translate_constraint(message, ctx),
        SqliteErrorKind::Other => translate_generic(message, ctx),
    }
}

fn translate_type_mismatch_change_column(ctx: &TranslateContext) -> FriendlyError {
    let table = ctx_table(ctx);
    let column = ctx_column(ctx);
    let src_type = ctx
        .src_type
        .map_or_else(|| "?".to_string(), |t| t.keyword().to_string());
    let target_type = ctx
        .target_type
        .map_or_else(|| "?".to_string(), |t| t.keyword().to_string());
    fe(
        t!(
            "error.type_mismatch.change_column.headline",
            table = table,
            column = column,
            src_type = src_type,
            target_type = target_type
        ),
        verbose_hint(
            ctx,
            t!(
                "error.type_mismatch.change_column.hint",
                target_type = target_type
            ),
        ),
    )
}

fn translate_constraint(message: &str, ctx: &TranslateContext) -> FriendlyError {
    let lower = message.to_ascii_lowercase();
    if lower.contains("unique constraint failed") {
        translate_unique(message, ctx)
    } else if lower.contains("foreign key constraint failed") {
        translate_foreign_key(ctx)
    } else if lower.contains("not null constraint failed") {
        translate_not_null(message, ctx)
    } else if lower.contains("check constraint failed") {
        translate_check(message, ctx)
    } else {
        translate_generic(message, ctx)
    }
}

// ---- UNIQUE -----------------------------------------------------

fn translate_unique(message: &str, ctx: &TranslateContext) -> FriendlyError {
    let (table, column) =
        parse_qualified_target(message).unwrap_or_else(|| (ctx_table(ctx), ctx_column(ctx)));
    let value = ctx_value(ctx);
    match ctx.operation {
        Some(Operation::Update) => fe(
            t!(
                "error.unique.update.headline",
                table = table,
                column = column,
                value = value
            ),
            verbose_hint(
                ctx,
                t!("error.unique.update.hint", table = table, column = column),
            ),
        ),
        // Default to the INSERT variant — it's the most common
        // path and the wording is symmetric enough that an
        // unknown-operation INSERT-vs-UPDATE confusion isn't
        // misleading.
        _ => fe(
            t!(
                "error.unique.insert.headline",
                table = table,
                column = column,
                value = value
            ),
            verbose_hint(
                ctx,
                t!("error.unique.insert.hint", table = table, column = column),
            ),
        ),
    }
}

// ---- FOREIGN KEY -----------------------------------------------

fn translate_foreign_key(ctx: &TranslateContext) -> FriendlyError {
    // The engine's "FOREIGN KEY constraint failed" carries no
    // detail. Disambiguation is enrichment-driven first
    // (`parent_table` populated → child-side; `child_table`
    // populated → parent-side), with operation as the
    // tiebreaker when enrichment didn't run.
    //
    // - Insert always points "outward" → child-side.
    // - Delete always points "inward" → parent-side.
    // - Update can be either; we let the enrichment payload
    //   choose, defaulting to child-side (the more pedagogically
    //   common case for a learner).
    if ctx.parent_table.is_some() {
        return match ctx.operation {
            Some(Operation::Update) => fk_child_side_update(ctx),
            _ => fk_child_side_insert(ctx),
        };
    }
    if ctx.child_table.is_some() {
        return match ctx.operation {
            Some(Operation::Update) => fk_parent_side_update(ctx),
            _ => fk_parent_side_delete(ctx),
        };
    }
    match ctx.operation {
        Some(Operation::Delete) => fk_parent_side_delete(ctx),
        Some(Operation::Update) => fk_child_side_update(ctx),
        Some(Operation::Insert) => fk_child_side_insert(ctx),
        _ => fk_child_side_insert(ctx),
    }
}

fn fk_child_side_insert(ctx: &TranslateContext) -> FriendlyError {
    let parent_table = ctx_parent_table(ctx);
    let parent_column = ctx_parent_column(ctx);
    let value = ctx_value(ctx);
    fe(
        t!(
            "error.foreign_key.child_side.insert.headline",
            parent_table = parent_table,
            parent_column = parent_column,
            value = value
        ),
        verbose_hint(
            ctx,
            t!(
                "error.foreign_key.child_side.insert.hint",
                parent_table = parent_table,
                parent_column = parent_column
            ),
        ),
    )
}

fn fk_child_side_update(ctx: &TranslateContext) -> FriendlyError {
    let parent_table = ctx_parent_table(ctx);
    let parent_column = ctx_parent_column(ctx);
    let value = ctx_value(ctx);
    fe(
        t!(
            "error.foreign_key.child_side.update.headline",
            parent_table = parent_table,
            parent_column = parent_column,
            value = value
        ),
        verbose_hint(
            ctx,
            t!(
                "error.foreign_key.child_side.update.hint",
                parent_table = parent_table,
                parent_column = parent_column
            ),
        ),
    )
}

fn fk_parent_side_delete(ctx: &TranslateContext) -> FriendlyError {
    let table = ctx_table(ctx);
    let child_table = ctx_child_table(ctx);
    fe(
        t!(
            "error.foreign_key.parent_side.delete.headline",
            table = table,
            child_table = child_table
        ),
        verbose_hint(ctx, t!("error.foreign_key.parent_side.delete.hint")),
    )
}

fn fk_parent_side_update(ctx: &TranslateContext) -> FriendlyError {
    let table = ctx_table(ctx);
    let child_table = ctx_child_table(ctx);
    fe(
        t!(
            "error.foreign_key.parent_side.update.headline",
            table = table,
            child_table = child_table
        ),
        verbose_hint(ctx, t!("error.foreign_key.parent_side.update.hint")),
    )
}

// ---- NOT NULL --------------------------------------------------

fn translate_not_null(message: &str, ctx: &TranslateContext) -> FriendlyError {
    let (table, column) =
        parse_qualified_target(message).unwrap_or_else(|| (ctx_table(ctx), ctx_column(ctx)));
    match ctx.operation {
        Some(Operation::Update) => fe(
            t!(
                "error.not_null.update.headline",
                table = table,
                column = column
            ),
            verbose_hint(ctx, t!("error.not_null.update.hint", column = column)),
        ),
        _ => fe(
            t!(
                "error.not_null.insert.headline",
                table = table,
                column = column
            ),
            verbose_hint(ctx, t!("error.not_null.insert.hint", column = column)),
        ),
    }
}

// ---- CHECK -----------------------------------------------------

fn translate_check(_message: &str, ctx: &TranslateContext) -> FriendlyError {
    // The engine reports a `CHECK` failure by the column the
    // constraint sits on; the runtime's enrichment resolves the
    // table, the offending value, and — the teaching moment —
    // the rule itself (ADR-0029 §10). When the rule could not
    // be resolved, the plain hint stands on its own.
    let table = ctx_table(ctx);
    let column = ctx_column(ctx);
    let is_update = matches!(ctx.operation, Some(Operation::Update));
    let headline = if is_update {
        t!(
            "error.check.update.headline",
            table = table,
            column = column
        )
    } else {
        t!(
            "error.check.insert.headline",
            table = table,
            column = column
        )
    };
    let hint = ctx.check_rule.as_ref().map_or_else(
        || {
            if is_update {
                t!("error.check.update.hint", column = column)
            } else {
                t!("error.check.insert.hint", column = column)
            }
        },
        |rule| {
            let value = ctx_value(ctx);
            if is_update {
                t!(
                    "error.check.update.hint_with_rule",
                    value = value,
                    rule = rule,
                    column = column
                )
            } else {
                t!(
                    "error.check.insert.hint_with_rule",
                    value = value,
                    rule = rule,
                    column = column
                )
            }
        },
    );
    fe(headline, verbose_hint(ctx, hint))
}

// ---- not_found / already_exists --------------------------------

fn translate_not_found_table(message: &str, ctx: &TranslateContext) -> FriendlyError {
    let name = parse_after_colon(message).map_or_else(|| ctx_table(ctx), str::to_string);
    headline_only(t!("error.not_found.table.headline", name = name))
}

fn translate_not_found_column(message: &str, ctx: &TranslateContext) -> FriendlyError {
    let name = parse_after_colon(message).unwrap_or("");
    if let Some((table, column)) = name.split_once('.') {
        headline_only(t!(
            "error.not_found.column.headline",
            table = table,
            column = column
        ))
    } else if !name.is_empty() {
        headline_only(t!(
            "error.not_found.column_unqualified.headline",
            column = name
        ))
    } else {
        headline_only(t!(
            "error.not_found.column.headline",
            table = ctx_table(ctx),
            column = ctx_column(ctx)
        ))
    }
}

fn translate_already_exists(message: &str, ctx: &TranslateContext) -> FriendlyError {
    // Three shapes feed in:
    //   - Engine: "table T already exists"
    //   - Our own: "column `T.col` already exists; pick a different name."
    //   - Our own: "a relationship named `name` already exists. ..."
    // The catalog has typed entries; if we can't classify, fall
    // back to passthrough (the existing wording is already
    // engine-neutral).
    if let Some(name) = extract_quoted(message) {
        if let Some((table, column)) = name.split_once('.') {
            return headline_only(t!(
                "error.already_exists.column.headline",
                table = table,
                column = column
            ));
        }
        return headline_only(t!("error.already_exists.table.headline", name = name));
    }
    // No backticks — engine-style "table T already exists".
    if let Some(name) = parse_after_word(message, "table") {
        return headline_only(t!("error.already_exists.table.headline", name = name));
    }
    if let Some(name) = parse_after_word(message, "relationship") {
        return headline_only(t!(
            "error.already_exists.relationship.headline",
            name = name
        ));
    }
    // Fall back to context.
    let _ = ctx;
    passthrough(message)
}

// ---- Generic catch-all -----------------------------------------

fn translate_generic(message: &str, ctx: &TranslateContext) -> FriendlyError {
    // Engine message is intentionally NOT surfaced — ADR-0002
    // posture. The catalog provides the abstract wording.
    //
    // ADR-0032 §11.5 engine-error translations: pattern-match
    // the engine's message text for the four Phase-2 cases that
    // arrive as `SqliteErrorKind::Other` and route each to its
    // engine-neutral catalog entry. The classifier
    // intentionally doesn't grow new SqliteErrorKind variants
    // for these — they share a single fallback bucket and are
    // distinguished by text pattern at translation time.
    let lower = message.to_ascii_lowercase();
    if lower.contains("misuse of aggregate") {
        return headline_only(t!("engine.aggregate_misuse", name = "?"));
    }
    if lower.contains("group by") || lower.contains("must appear in") {
        return headline_only(t!("engine.group_by_required"));
    }
    if (lower.contains("union") || lower.contains("intersect") || lower.contains("except"))
        && lower.contains("result columns")
    {
        // Last-resort safety net — the pre-flight pass in 2d.1
        // catches this in most cases; if the engine surfaces it
        // anyway, route it through the engine-neutral key.
        return headline_only(t!("engine.compound_arity_mismatch", op = "set operator"));
    }
    if lower.contains("scalar subquery") || lower.contains("more than one row") {
        return headline_only(t!("engine.scalar_subquery_too_many_rows"));
    }
    if lower.contains("recursive") && (lower.contains("cte") || lower.contains("union")) {
        return headline_only(t!("engine.recursive_cte_malformed"));
    }

    let operation = ctx.operation.map_or("operation", Operation::keyword);
    // F2 (ADR-0035 Amendment 1): when no table is in context, use the
    // table-less hint so a contextless `friendly_message()` (replay, undo,
    // rebuild, export) never renders a literal `{table}` placeholder.
    let hint = if ctx.table.is_some() {
        t!("error.generic.hint", table = ctx_table(ctx))
    } else {
        t!("error.generic.hint_no_table")
    };
    fe(
        t!("error.generic.headline", operation = operation),
        verbose_hint(ctx, hint),
    )
}

// ---- Helpers ---------------------------------------------------

fn passthrough<S: Into<String>>(s: S) -> FriendlyError {
    FriendlyError {
        headline: s.into(),
        hint: None,
        diagnostic_table: None,
    }
}

const fn headline_only(headline: String) -> FriendlyError {
    FriendlyError {
        headline,
        hint: None,
        diagnostic_table: None,
    }
}

const fn fe(headline: String, hint: Option<String>) -> FriendlyError {
    FriendlyError {
        headline,
        hint,
        diagnostic_table: None,
    }
}

fn verbose_hint(ctx: &TranslateContext, hint: String) -> Option<String> {
    if ctx.verbosity == Verbosity::Verbose {
        Some(hint)
    } else {
        None
    }
}

// Neutral-prose fallbacks when context can't supply a value
// (ADR-0035 Amendment 1, F2 follow-up — the safety net). Runtime-side
// enrichment (ADR-0019 §6) fills `FailureContext` on the interactive and
// replay paths, so these rarely render; but the few contextless
// `friendly_message()` callsites (undo / rebuild / export) must NOT
// surface a raw `{name}` placeholder, which reads like a bug. The earlier
// `{name}`-marker form was a developer-facing tell that predated those
// callsites rendering in practice; neutral prose degrades gracefully
// instead.

fn ctx_table(ctx: &TranslateContext) -> String {
    ctx.table.clone().unwrap_or_else(|| "the table".to_string())
}

fn ctx_column(ctx: &TranslateContext) -> String {
    ctx.column
        .clone()
        .unwrap_or_else(|| "the column".to_string())
}

fn ctx_value(ctx: &TranslateContext) -> String {
    ctx.value
        .clone()
        .unwrap_or_else(|| "that value".to_string())
}

fn ctx_parent_table(ctx: &TranslateContext) -> String {
    ctx.parent_table
        .clone()
        .unwrap_or_else(|| "the referenced table".to_string())
}

fn ctx_parent_column(ctx: &TranslateContext) -> String {
    ctx.parent_column
        .clone()
        .unwrap_or_else(|| "the referenced column".to_string())
}

fn ctx_child_table(ctx: &TranslateContext) -> String {
    ctx.child_table
        .clone()
        .unwrap_or_else(|| "the referencing table".to_string())
}

/// Extract `T.col` from a message like
/// `"UNIQUE constraint failed: T.col"`. Returns `(T, col)` or
/// `None` if the message doesn't have the expected shape.
fn parse_qualified_target(message: &str) -> Option<(String, String)> {
    let after = parse_after_colon(message)?;
    let first_target = after.split(',').next()?.trim();
    let mut parts = first_target.splitn(2, '.');
    let table = parts.next()?.trim();
    let column = parts.next()?.trim();
    if table.is_empty() || column.is_empty() {
        return None;
    }
    Some((table.to_string(), column.to_string()))
}

/// Return the substring after the first `:`, trimmed. None if
/// no colon present.
fn parse_after_colon(message: &str) -> Option<&str> {
    message.split_once(':').map(|(_, rest)| rest.trim())
}

/// Find a backtick-quoted substring in `message` (the wording
/// our own refusal sites use for object names). Returns the
/// content of the first quoted run, or None.
fn extract_quoted(message: &str) -> Option<&str> {
    let start = message.find('`')? + 1;
    let rest = &message[start..];
    let end = rest.find('`')?;
    Some(&rest[..end])
}

/// Find the token immediately after `keyword` in `message`.
/// Used for parsing engine-style "table T already exists" and
/// "relationship name already exists".
fn parse_after_word<'a>(message: &'a str, keyword: &str) -> Option<&'a str> {
    let pos = message.find(keyword)? + keyword.len();
    let rest = message[pos..].trim_start();
    let token_end = rest.find(|c: char| c.is_whitespace()).unwrap_or(rest.len());
    let token = rest[..token_end].trim_matches(|c: char| c == '`' || c == '\'');
    if token.is_empty() { None } else { Some(token) }
}

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

    fn ctx_with(op: Operation) -> TranslateContext {
        TranslateContext {
            operation: Some(op),
            ..TranslateContext::default()
        }
    }

    // ── H2 / ADR-0053: error → tier-3 hint class ────────────────

    #[test]
    fn hint_class_maps_runtime_error_kinds() {
        use crate::db::{DbError, SqliteErrorKind};
        let sqlite = |kind, msg: &str| DbError::Sqlite {
            message: msg.to_string(),
            kind,
        };
        let d = TranslateContext::default;
        assert_eq!(
            error_hint_class(
                &sqlite(SqliteErrorKind::NoSuchTable, "no such table: X"),
                &d()
            ),
            Some("not_found")
        );
        assert_eq!(
            error_hint_class(
                &sqlite(SqliteErrorKind::NoSuchColumn, "no such column: X"),
                &d()
            ),
            Some("not_found")
        );
        assert_eq!(
            error_hint_class(
                &sqlite(SqliteErrorKind::AlreadyExists, "already exists"),
                &d()
            ),
            Some("already_exists")
        );
        assert_eq!(
            error_hint_class(&sqlite(SqliteErrorKind::Other, "boom"), &d()),
            Some("generic")
        );
        // Constraint-violation message splitting.
        let cv = |msg: &str| sqlite(SqliteErrorKind::UniqueViolation, msg);
        assert_eq!(
            error_hint_class(&cv("UNIQUE constraint failed: T.c"), &d()),
            Some("unique")
        );
        assert_eq!(
            error_hint_class(&cv("NOT NULL constraint failed: T.c"), &d()),
            Some("not_null")
        );
        assert_eq!(
            error_hint_class(&cv("CHECK constraint failed: T"), &d()),
            Some("check")
        );
        // change-column op routes any engine error to type_mismatch.
        assert_eq!(
            error_hint_class(
                &sqlite(SqliteErrorKind::Other, "x"),
                &ctx_with(Operation::ChangeColumnType)
            ),
            Some("type_mismatch")
        );
        // App-level refusals and internal/fatal errors.
        assert_eq!(
            error_hint_class(&DbError::InvalidValue("bad".to_string()), &d()),
            Some("invalid_value")
        );
        assert_eq!(error_hint_class(&DbError::WorkerGone, &d()), None);
    }

    #[test]
    fn hint_class_resolves_foreign_key_sides() {
        use crate::db::{DbError, SqliteErrorKind};
        let fk = || DbError::Sqlite {
            message: "FOREIGN KEY constraint failed".to_string(),
            kind: SqliteErrorKind::UniqueViolation,
        };
        // Enrichment: parent_table populated → child-side.
        let ctx = TranslateContext {
            parent_table: Some("Parent".to_string()),
            ..TranslateContext::default()
        };
        assert_eq!(
            error_hint_class(&fk(), &ctx),
            Some("foreign_key.child_side")
        );
        // child_table populated → parent-side.
        let ctx = TranslateContext {
            child_table: Some("Child".to_string()),
            ..TranslateContext::default()
        };
        assert_eq!(
            error_hint_class(&fk(), &ctx),
            Some("foreign_key.parent_side")
        );
        // No enrichment: operation is the tiebreaker.
        assert_eq!(
            error_hint_class(&fk(), &ctx_with(Operation::Delete)),
            Some("foreign_key.parent_side")
        );
        assert_eq!(
            error_hint_class(&fk(), &ctx_with(Operation::Insert)),
            Some("foreign_key.child_side")
        );
    }

    fn sqlite(message: &str, kind: SqliteErrorKind) -> DbError {
        DbError::Sqlite {
            message: message.to_string(),
            kind,
        }
    }

    // ---- UNIQUE ----

    #[test]
    fn unique_insert_uses_anchor_phrase_and_picks_insert_template() {
        let err = sqlite(
            "UNIQUE constraint failed: Customers.id",
            SqliteErrorKind::UniqueViolation,
        );
        let mut ctx = ctx_with(Operation::Insert);
        ctx.value = Some("5".to_string());
        let f = translate(&err, &ctx);
        // Anchor phrase ADR-0019 §10.
        assert!(
            f.headline.contains("already has the value"),
            "missing anchor: {}",
            f.headline
        );
        assert!(f.headline.contains("Customers"));
        assert!(f.headline.contains("id"));
        assert!(f.headline.contains("`5`"));
        // Verbose mode → hint present.
        let hint = f.hint.expect("verbose default → hint");
        assert!(hint.contains("pick a different value"));
        assert!(hint.contains("update the existing row"));
    }

    #[test]
    fn unique_update_picks_update_template() {
        let err = sqlite(
            "UNIQUE constraint failed: Customers.id",
            SqliteErrorKind::UniqueViolation,
        );
        let f = translate(&err, &ctx_with(Operation::Update));
        let hint = f.hint.expect("verbose default → hint");
        // Update wording differs from insert — operation-tailored.
        assert!(
            hint.contains("would create a duplicate"),
            "expected update wording: {hint}"
        );
    }

    #[test]
    fn short_mode_omits_hint() {
        let err = sqlite(
            "UNIQUE constraint failed: Customers.id",
            SqliteErrorKind::UniqueViolation,
        );
        let mut ctx = ctx_with(Operation::Insert);
        ctx.verbosity = Verbosity::Short;
        let f = translate(&err, &ctx);
        assert!(f.hint.is_none(), "short mode → no hint, got {:?}", f.hint);
        // Headline still present.
        assert!(f.headline.contains("already has the value"));
    }

    // ---- FK ----

    #[test]
    fn fk_with_insert_op_renders_child_side_wording() {
        let err = sqlite(
            "FOREIGN KEY constraint failed",
            SqliteErrorKind::UniqueViolation,
        );
        let mut ctx = ctx_with(Operation::Insert);
        ctx.parent_table = Some("Customers".to_string());
        ctx.parent_column = Some("id".to_string());
        ctx.value = Some("99".to_string());
        let f = translate(&err, &ctx);
        assert!(
            f.headline.contains("no parent row"),
            "expected child-side phrasing: {}",
            f.headline
        );
        assert!(f.headline.contains("Customers"));
        assert!(f.headline.contains("`99`"));
    }

    #[test]
    fn fk_child_side_renders_every_column_of_a_compound_key() {
        // ADR-0043 residual: a compound-FK violation carries the
        // comma-joined column + value lists in the single-column facts
        // slots, so the headline names every pair, not just the first.
        let err = sqlite(
            "FOREIGN KEY constraint failed",
            SqliteErrorKind::UniqueViolation,
        );
        let mut ctx = ctx_with(Operation::Insert);
        ctx.parent_table = Some("Region".to_string());
        ctx.parent_column = Some("country, code".to_string());
        ctx.value = Some("7, 8".to_string());
        let f = translate(&err, &ctx);
        assert!(
            f.headline.contains("no parent row"),
            "child-side: {}",
            f.headline
        );
        assert!(f.headline.contains("Region"));
        assert!(
            f.headline.contains("country, code"),
            "both parent columns must appear: {}",
            f.headline
        );
        assert!(
            f.headline.contains("`7, 8`"),
            "joined value: {}",
            f.headline
        );
    }

    #[test]
    fn fk_with_delete_op_renders_parent_side_wording() {
        let err = sqlite(
            "FOREIGN KEY constraint failed",
            SqliteErrorKind::UniqueViolation,
        );
        let mut ctx = ctx_with(Operation::Delete);
        ctx.table = Some("Customers".to_string());
        ctx.child_table = Some("Orders".to_string());
        let f = translate(&err, &ctx);
        // Anchor phrase: "referenced by".
        assert!(
            f.headline.contains("referenced by"),
            "expected parent-side phrasing with anchor: {}",
            f.headline
        );
        assert!(f.headline.contains("Customers"));
        assert!(f.headline.contains("Orders"));
    }

    // ---- NOT NULL ----

    #[test]
    fn not_null_uses_typed_template() {
        let err = sqlite(
            "NOT NULL constraint failed: Customers.email",
            SqliteErrorKind::UniqueViolation,
        );
        let f = translate(&err, &ctx_with(Operation::Insert));
        assert!(f.headline.contains("cannot be null"));
        assert!(f.headline.contains("Customers"));
        assert!(f.headline.contains("email"));
    }

    // ---- CHECK ----

    #[test]
    fn check_uses_typed_template_and_falls_back_to_context() {
        let err = sqlite(
            "CHECK constraint failed: chk_age",
            SqliteErrorKind::UniqueViolation,
        );
        let mut ctx = ctx_with(Operation::Insert);
        ctx.table = Some("People".to_string());
        ctx.column = Some("age".to_string());
        let f = translate(&err, &ctx);
        assert!(f.headline.contains("check constraint refused"));
        assert!(f.headline.contains("People"));
        assert!(f.headline.contains("age"));
    }

    #[test]
    fn check_with_a_resolved_rule_names_the_rule_and_value() {
        // ADR-0029 §10: when enrichment resolves the column's
        // compiled CHECK expression and the offending value,
        // the hint names both.
        let err = sqlite(
            "CHECK constraint failed: score",
            SqliteErrorKind::UniqueViolation,
        );
        let mut ctx = ctx_with(Operation::Insert);
        ctx.table = Some("T".to_string());
        ctx.column = Some("score".to_string());
        ctx.value = Some("-5".to_string());
        ctx.check_rule = Some("\"score\" >= 0".to_string());
        let f = translate(&err, &ctx);
        assert!(f.headline.contains("check constraint refused"));
        let hint = f.hint.expect("a verbose hint");
        assert!(hint.contains("-5"), "the offending value: {hint}");
        assert!(hint.contains("\"score\" >= 0"), "the rule: {hint}");
    }

    // ---- not_found ----

    #[test]
    fn no_such_table_uses_anchor_phrase() {
        let err = sqlite("no such table: Ghost", SqliteErrorKind::NoSuchTable);
        let f = translate(&err, &TranslateContext::default());
        // Anchor phrase ADR-0019 §10.
        assert!(
            f.headline.contains("no such table"),
            "missing anchor: {}",
            f.headline
        );
        assert!(f.headline.contains("Ghost"));
    }

    #[test]
    fn no_such_column_qualified() {
        let err = sqlite("no such column: T.zip", SqliteErrorKind::NoSuchColumn);
        let f = translate(&err, &TranslateContext::default());
        assert!(f.headline.contains("no such column"));
        assert!(f.headline.contains("T"));
        assert!(f.headline.contains("zip"));
    }

    #[test]
    fn no_such_column_unqualified_uses_unqualified_key() {
        let err = sqlite("no such column: zip", SqliteErrorKind::NoSuchColumn);
        let f = translate(&err, &TranslateContext::default());
        assert!(f.headline.contains("no such column"));
        assert!(f.headline.contains("zip"));
    }

    // ---- already_exists ----

    #[test]
    fn already_exists_quoted_column_uses_column_template() {
        let err = sqlite(
            "column `T.x` already exists; pick a different name.",
            SqliteErrorKind::AlreadyExists,
        );
        let f = translate(&err, &TranslateContext::default());
        assert!(f.headline.contains("already exists"));
        assert!(f.headline.contains("T"));
        assert!(f.headline.contains("x"));
    }

    #[test]
    fn already_exists_quoted_table_uses_table_template() {
        let err = sqlite(
            "table `Customers` already exists",
            SqliteErrorKind::AlreadyExists,
        );
        let f = translate(&err, &TranslateContext::default());
        assert!(f.headline.contains("already exists"));
        assert!(f.headline.contains("Customers"));
    }

    // ---- generic ----

    #[test]
    fn generic_path_uses_operation_keyword() {
        let err = sqlite(
            "some unrecognised constraint failure",
            SqliteErrorKind::UniqueViolation,
        );
        let f = translate(&err, &ctx_with(Operation::AddRelationship));
        // Falls into translate_constraint → generic since no
        // recognised constraint keyword.
        assert!(
            f.headline.contains("add relationship") || f.headline.contains("refused"),
            "got: {}",
            f.headline
        );
    }

    #[test]
    fn generic_engine_message_is_not_surfaced() {
        // ADR-0002 posture: the engine's text never reaches the
        // user verbatim. Check explicitly.
        let secret = "SQLite-flavoured detail with engine internals";
        let err = sqlite(secret, SqliteErrorKind::Other);
        let f = translate(&err, &ctx_with(Operation::Insert));
        let rendered = f.render();
        assert!(
            !rendered.contains(secret),
            "engine message leaked into translated output:\n{rendered}"
        );
    }

    #[test]
    fn generic_hint_has_no_unsubstituted_table_without_context() {
        // F2 (ADR-0035 Amendment 1 plan): `friendly_message()` renders
        // with a default, table-less context at the default Verbose
        // verbosity, so a generic-bucket error must not leak a literal
        // `{table}` in its hint.
        let err = sqlite("some unclassified engine failure", SqliteErrorKind::Other);
        let rendered = translate(&err, &TranslateContext::default()).render();
        assert!(
            !rendered.contains("{table}"),
            "no unsubstituted placeholder in the table-less generic hint; got:\n{rendered}"
        );
        // The table-ful path is unchanged: a table in context still names it.
        let mut ctx = TranslateContext::for_op(Operation::Delete);
        ctx.table = Some("Orders".to_string());
        let with_table = translate(&err, &ctx).render();
        assert!(
            with_table.contains("Orders"),
            "the table-ful generic hint still names the table; got:\n{with_table}"
        );
    }

    #[test]
    fn constraint_templates_degrade_to_prose_without_context() {
        // F2 follow-up safety net: a constraint error rendered via a
        // contextless `friendly_message()` (no facts) degrades to neutral
        // prose, never a raw `{name}` marker.
        for kind in [
            SqliteErrorKind::UniqueViolation,
            SqliteErrorKind::Other,
            SqliteErrorKind::NoSuchColumn,
        ] {
            let err = sqlite("constraint failed", kind);
            let rendered = translate(&err, &TranslateContext::default()).render();
            assert!(
                !rendered.contains('{'),
                "placeholder marker leaked for {kind:?}:\n{rendered}"
            );
        }
    }

    // ---- passthrough variants ----

    #[test]
    fn unsupported_passes_through() {
        let err = DbError::Unsupported(
            "cannot drop primary-key column `T.id`. Drop the table or change the primary key first."
                .to_string(),
        );
        let f = translate(&err, &TranslateContext::default());
        assert!(f.headline.contains("primary-key"));
        assert!(f.hint.is_none());
    }

    #[test]
    fn invalid_value_passes_through() {
        let err = DbError::InvalidValue("expected 3 value(s), got 2".to_string());
        let f = translate(&err, &TranslateContext::default());
        assert_eq!(f.headline, "expected 3 value(s), got 2");
    }

    // ---- internal helpers ----

    #[test]
    fn parse_qualified_target_works_for_unique_message() {
        assert_eq!(
            parse_qualified_target("UNIQUE constraint failed: T.col"),
            Some(("T".to_string(), "col".to_string()))
        );
    }

    #[test]
    fn parse_qualified_target_returns_first_for_compound() {
        assert_eq!(
            parse_qualified_target("UNIQUE constraint failed: T.a, T.b"),
            Some(("T".to_string(), "a".to_string()))
        );
    }

    #[test]
    fn parse_qualified_target_none_when_no_dot() {
        assert_eq!(
            parse_qualified_target("FOREIGN KEY constraint failed"),
            None
        );
    }

    #[test]
    fn extract_quoted_pulls_first_backtick_run() {
        assert_eq!(extract_quoted("column `T.x` already exists"), Some("T.x"));
        assert_eq!(extract_quoted("no backticks here"), None);
    }

    // ---- ADR-0032 §11.5 engine.* keys ----

    #[test]
    fn aggregate_misuse_engine_message_routes_through_catalog() {
        let err = sqlite(
            "misuse of aggregate function COUNT()",
            SqliteErrorKind::Other,
        );
        let f = translate(&err, &TranslateContext::default());
        assert!(
            f.headline.contains("aggregate"),
            "expected engine.aggregate_misuse wording; got {}",
            f.headline,
        );
        // Engine name (SQLite) must not appear (ADR-0002 posture).
        assert!(
            !f.headline.to_lowercase().contains("sqlite"),
            "headline leaks engine name: {}",
            f.headline,
        );
    }

    #[test]
    fn group_by_required_engine_message_routes_through_catalog() {
        let err = sqlite(
            "column must appear in the GROUP BY clause or be used in an aggregate function",
            SqliteErrorKind::Other,
        );
        let f = translate(&err, &TranslateContext::default());
        assert!(
            f.headline.contains("GROUP BY"),
            "expected engine.group_by_required wording; got {}",
            f.headline,
        );
    }

    #[test]
    fn compound_arity_engine_message_routes_through_catalog() {
        let err = sqlite(
            "SELECTs to the left and right of UNION do not have the same number of result columns",
            SqliteErrorKind::Other,
        );
        let f = translate(&err, &TranslateContext::default());
        assert!(
            f.headline.contains("number of columns"),
            "expected engine.compound_arity_mismatch wording; got {}",
            f.headline,
        );
    }

    #[test]
    fn scalar_subquery_too_many_rows_routes_through_catalog() {
        let err = sqlite(
            "scalar subquery returned more than one row",
            SqliteErrorKind::Other,
        );
        let f = translate(&err, &TranslateContext::default());
        assert!(
            f.headline.contains("more than one row"),
            "expected engine.scalar_subquery_too_many_rows wording; got {}",
            f.headline,
        );
    }
}
