//! Tokio-based event loop.
//!
//! A blocking task reads crossterm events and forwards them onto
//! an `mpsc` channel as `AppEvent`s. The main loop awaits events,
//! feeds them to `App::update`, enacts any returned `Action`s,
//! and redraws the terminal. DSL execution is dispatched onto
//! the database worker (see `db::Database`), and its result is
//! posted back as a new `AppEvent`. Future async work (snapshot
//! capture, auto-save) joins the same event channel as
//! additional producers.

use std::io;
use std::path::PathBuf;
use std::time::{Duration, Instant};

use anyhow::{Context, Result};
use crossterm::event::{Event as CtEvent, EventStream};
use crossterm::execute;
use crossterm::terminal::{
    EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use futures_util::StreamExt;
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use tokio::sync::mpsc;
use tracing::{debug, error, info, warn};

use crate::action::Action;
use crate::app::App;
use crate::cli::Args;
use crate::db::{
    AddColumnResult, ChangeColumnTypeResult, CreateIndexOutcome, CreateOutcome, DataResult,
    Database, DbError, DeleteResult, DropColumnResult, DropIndexOutcome, DropOutcome, InsertResult,
    QueryPlan, TableDescription, UpdateResult,
};
use crate::dsl::command::{
    Constraint, ConstraintKind, IndexSelector, RelationshipSelector, TableConstraint,
};
use crate::dsl::walker::Severity;
use crate::dsl::{AlterTableAction, ChangeColumnMode, ColumnSpec, Command};
use crate::event::AppEvent;
use crate::project::{
    Project, ProjectKind, copy_project, list_projects, open_or_create, projects_dir,
    read_last_project, resolve_data_root, safely_delete_temp_project, write_last_project,
};
use crate::theme::Theme;
use crate::ui;

const EVENT_CHANNEL_CAPACITY: usize = 64;
const SHUTDOWN_GRACE: Duration = Duration::from_millis(100);

/// Quiet interval before the input-validity indicator
/// reappears once typing stops (ADR-0027 §3).
const INDICATOR_DEBOUNCE: Duration = Duration::from_millis(1000);

/// How long a demo-mode keystroke badge stays on screen before it
/// fades on its own (ADR-0047 D5). Long enough to read in a screencast
/// or in front of a class; short enough that a trailing `wait` in a
/// cast ends on a clean frame.
const DEMO_BADGE_TTL: Duration = Duration::from_millis(1500);

/// The nearest (soonest) of two optional deadlines (ADR-0047 D5) — the
/// instant the event loop should next wake to service a timer. `None`
/// when neither is set (the loop then blocks on `recv`). Pure, so the
/// scheduling decision is unit-testable without the loop.
fn nearest_deadline(a: Option<Instant>, b: Option<Instant>) -> Option<Instant> {
    match (a, b) {
        (Some(a), Some(b)) => Some(a.min(b)),
        (Some(a), None) => Some(a),
        (None, b) => b,
    }
}

/// The input-validity indicator's debounce state machine
/// (ADR-0027 §3, step E).
///
/// A keystroke hides the indicator and arms the debounce; the
/// event loop time-boxes `recv` while `is_armed`, so once
/// typing has paused for `INDICATOR_DEBOUNCE` it calls `settle`
/// with the freshly computed verdict. The `tokio` timer and the
/// terminal live in the loop — this owns only the decision
/// logic, keeping the debounce contract unit-testable without
/// an event loop. `App::input_indicator` mirrors `visible` for
/// the renderer.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
struct IndicatorDebounce {
    /// What the indicator currently shows; `None` is hidden.
    visible: Option<Severity>,
    /// True while a recompute is owed — only then does the
    /// event loop time-box `recv`.
    armed: bool,
}

impl IndicatorDebounce {
    /// The indicator value the renderer should mirror.
    const fn visible(self) -> Option<Severity> {
        self.visible
    }

    /// Whether the event loop should time-box `recv` with the
    /// debounce window.
    const fn is_armed(self) -> bool {
        self.armed
    }

    /// Record a processed event. A keystroke hides the
    /// indicator and (re)arms the debounce; any other event
    /// leaves the indicator and the armed state untouched (a
    /// background result arriving mid-typing must not cancel
    /// the pending recompute).
    const fn note_event(&mut self, is_key: bool) {
        if is_key {
            self.visible = None;
            self.armed = true;
        }
    }

    /// The debounce window elapsed — typing has paused. Show
    /// `verdict` and disarm.
    const fn settle(&mut self, verdict: Option<Severity>) {
        self.visible = verdict;
        self.armed = false;
    }
}

/// Run the application until a `Quit` action is enacted or the
/// terminal closes.
pub async fn run(args: Args) -> Result<()> {
    // Resolve data root explicitly so run_loop can refer back
    // to it for `new` (creates a temp) and `load` (lists
    // projects). We can't easily recover this from the
    // Project alone, so we keep it ourselves.
    let data_root = resolve_data_root(args.data_dir.as_deref()).context("resolve data root")?;

    // Resolve the initial project path: --resume reads it from
    // <data-root>/last_project; otherwise an explicit positional
    // arg, falling back to a fresh auto-named temp.
    //
    // ADR-0015 §7: --resume errors out cleanly when the path is
    // missing or the recorded project no longer exists. We
    // surface those failures to stderr before booting the
    // terminal so the message lands directly in the user's
    // shell.
    let initial_path: Option<PathBuf> = if args.resume {
        match read_last_project(&data_root).context("read last_project")? {
            Some(p) if p.exists() => Some(p),
            Some(p) => {
                eprintln!(
                    "rdbms-playground: {}",
                    crate::t!("project.resume_recorded_missing", path = p.display(),),
                );
                return Ok(());
            }
            None => {
                eprintln!(
                    "rdbms-playground: {}",
                    crate::t!(
                        "project.resume_no_previous",
                        data_root = data_root.display(),
                    ),
                );
                return Ok(());
            }
        }
    } else {
        args.project_path.clone()
    };
    let project = open_or_create(initial_path.as_deref(), Some(data_root.as_path()))
        .context("open or create project")?;

    // Run any pending project.yaml migrations before the
    // database opens (so the rebuild path only ever sees the
    // latest schema). The registry is empty in v1; future
    // versions register their migrators here. A migration
    // that runs is recorded in tracing and leaves a
    // `project.yaml.v<N>.bak` breadcrumb on disk; that's
    // sufficient v1 UX and lets us defer dedicated event
    // plumbing until a real migrator demands it.
    // ADR-0005 Amendment 2: note a pre-migration `blob` column so we can
    // force a `.db` rebuild after the v1→v2 migration converts it to text
    // (the stale `.db` keeps a `STRICT … BLOB` engine column + `"blob"`
    // metadata that load otherwise uses as-is).
    let had_blob_column =
        std::fs::read_to_string(project.path().join(crate::project::PROJECT_YAML))
            .map(|b| crate::persistence::migrations::body_declares_blob_column(&b))
            .unwrap_or(false);
    let migrate_registry = crate::persistence::migrations::MigratorRegistry::production();
    let migration_outcome = crate::persistence::migrations::ensure_project_yaml_migrated(
        project.path(),
        &migrate_registry,
    )
    .context("migrate project.yaml")?;
    if let Some(from) = migration_outcome.migrated_from {
        info!(
            from_version = from,
            to_version = migrate_registry.latest_version(),
            "migrated project.yaml",
        );
    }

    // Record the just-opened project as the `--resume` target —
    // but only if it is worth resuming. A freshly-created empty
    // temp project is NOT recorded: it is auto-deleted on quit
    // while still empty (ADR-0015), and a `last_project`
    // pointing at a since-deleted directory makes the next
    // `--resume` fail confusingly. A named project, or a temp
    // that already carries content, is recorded. A temp that
    // *gains* content this session is recorded on quit instead.
    // Write failures are non-fatal.
    if !project.is_unmodified_temp()
        && let Err(e) = write_last_project(&data_root, project.path())
    {
        warn!(error = %e, "could not update last_project");
    }
    let db_path = project.db_path();
    let display_name = project.display_name().to_string();
    let project_path = project.path().to_path_buf();
    let project_is_temp = matches!(project.kind(), ProjectKind::Temp);
    // Resolve the startup input mode (ADR-0015 mode-restore
    // amendment, issue #14). Precedence: `--mode` flag > the
    // project's stored mode > the default (`Simple`). A pre-#14
    // project, or one with no `mode:` field, reads as `None` and
    // falls through to the default. The resolved mode is given to
    // `Persistence` so every `project.yaml` write records it, and
    // set on the `App` so the first render shows the right mode.
    let resolved_mode = crate::mode::Mode::resolve_startup(
        args.mode,
        crate::persistence::Persistence::read_stored_mode(&project_path),
    );
    info!(mode = %resolved_mode, "resolved startup input mode");
    let persistence =
        crate::persistence::Persistence::new(project_path.clone()).with_mode(resolved_mode);
    // Capture whether the .db file existed BEFORE we open it —
    // sqlite creates it on connect, so this is the only honest
    // signal that we need to rebuild from text (ADR-0015 §7).
    let db_existed = db_path.exists();
    // Undo is on unless `--no-undo` (ADR-0006 Amendment 1).
    let undo_enabled = !args.no_undo;
    // Demonstration mode under `--demo` / `RDBMS_PLAYGROUND_DEMO`
    // (ADR-0047). Off by default; threaded onto the `App` in run_loop.
    let demo_mode = args.demo;
    let database =
        Database::open_with_persistence_and_undo(db_path.as_path(), persistence, undo_enabled)
            .context("open database")?;
    let mut initial_events: Vec<AppEvent> = Vec::new();
    // ADR-0005 Amendment 2: a blob→text migration leaves the existing
    // `.db` with a stale `STRICT … BLOB` engine column, so rebuild it from
    // the migrated text. A pure version bump with no schema change does not.
    let force_rebuild_after_migration =
        migration_outcome.migrated_from.is_some() && had_blob_column;
    if !db_existed || force_rebuild_after_migration {
        match database.rebuild_from_text(project_path.clone(), None).await {
            Ok(()) => {
                // Surface the silent rebuild as a system note
                // *only* when there was something material to
                // reconstruct. A fresh-launch temp project
                // hits this branch with zero tables and zero
                // rows — the rebuild ran, but nothing
                // reconstructable existed, so there's nothing
                // worth telling the user. The explicit
                // `rebuild` command still always reports its
                // summary (see spawn_rebuild) since the user
                // asked.
                if project_has_content(&project_path) {
                    let summary = summarize_project(&project_path).unwrap_or_else(|_| {
                        "rebuilt playground.db from project.yaml + data/".to_string()
                    });
                    initial_events.push(AppEvent::RebuildSucceeded { summary });
                }
            }
            Err(e) => {
                // The terminal is still in cooked mode here
                // (we haven't entered the alternate screen
                // yet), so writing to stderr lands directly
                // in the user's shell. Drop the project to
                // release the lock first.
                drop(project);
                if matches!(
                    e,
                    DbError::PersistenceFatal { .. } | DbError::RebuildRowFailed { .. }
                ) {
                    eprintln!("rdbms-playground: {}", e.friendly_message());
                    return Ok(());
                }
                return Err(anyhow::anyhow!(e.friendly_message())).context("rebuild from text");
            }
        }
    }

    let mut terminal = setup_terminal().context("setup terminal")?;
    let result = run_loop(
        &mut terminal,
        args.theme,
        Session {
            project: Some(project),
            database: Some(database),
            data_root,
        },
        display_name,
        project_is_temp,
        initial_events,
        undo_enabled,
        resolved_mode,
        demo_mode,
    )
    .await;
    if let Err(e) = teardown_terminal(&mut terminal) {
        // Teardown failures should not mask the primary error.
        warn!(error = %e, "terminal teardown failed");
    }

    // ADR-0015 §8: a fatal persistence failure makes its
    // banner visible above the shell prompt by writing to
    // stderr after the alternate screen has been left.
    if let Ok(Some(banner)) = &result {
        eprintln!("{banner}");
    }
    result.map(|_| ())
}

/// Mutable state owned by `run_loop` that survives project
/// switches: the live `Project` (with its lock), the live
/// `Database` (with its worker), and the active data root
/// for resolving relative paths in `save as` / `new` / load
/// picker listings.
///
/// `project` and `database` are wrapped in `Option` so a
/// project switch can `take()` the old (dropping its lock
/// and worker) before opening the new — required for the
/// "switch to my own current project" case where the new
/// open would otherwise see a self-held lock.
struct Session {
    project: Option<Project>,
    database: Option<Database>,
    data_root: std::path::PathBuf,
}

impl Session {
    const fn project(&self) -> &Project {
        match self.project.as_ref() {
            Some(p) => p,
            None => panic!("project always set during run_loop"),
        }
    }
    const fn database(&self) -> &Database {
        match self.database.as_ref() {
            Some(d) => d,
            None => panic!("database always set during run_loop"),
        }
    }
}

#[allow(clippy::too_many_arguments)] // boot params; all inherent to one session
async fn run_loop(
    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
    theme: Theme,
    mut session: Session,
    project_display_name: String,
    project_is_temp: bool,
    initial_events: Vec<AppEvent>,
    undo_enabled: bool,
    initial_mode: crate::mode::Mode,
    demo_mode: bool,
) -> Result<Option<String>> {
    let (event_tx, mut event_rx) = mpsc::channel::<AppEvent>(EVENT_CHANNEL_CAPACITY);
    let reader_handle = spawn_event_reader(event_tx.clone());

    let mut app = App::new();
    app.project_name = Some(project_display_name);
    app.project_is_temp = project_is_temp;
    app.undo_enabled = undo_enabled;
    // ADR-0047: enable the demo overlays for this session under `--demo`.
    app.demo_mode = demo_mode;
    // Start in the resolved input mode (ADR-0015 mode-restore
    // amendment, issue #14): `--mode` > stored project mode >
    // default. `Persistence` already carries the same value, so the
    // worker records it on the next write.
    app.mode = initial_mode;
    // Seed the in-memory navigable history from the
    // initial project's history.log (I2-persist, ADR-0015
    // §12). Subsequent project switches re-seed via the
    // `ProjectSwitched` event payload.
    app.seed_history(read_history_seed(session.project().path()));

    // Send any startup events (e.g., the system-message form
    // of "rebuilt from text on missing .db") so they're
    // dispatched through the normal event path and end up in
    // the output panel before the user types anything.
    for event in initial_events {
        let _ = event_tx.send(event).await;
    }

    // Seed the table list with whatever the database currently
    // shows. For a fresh in-memory DB this is empty, but doing
    // it explicitly means file-backed databases (track 2) will
    // show their tables on launch without changes here.
    seed_initial_tables(session.database(), &event_tx).await;

    terminal
        .draw(|f| ui::render(&mut app, &theme, f))
        .context("initial draw")?;

    info!("entering main event loop");
    // ADR-0027 §3: the validity indicator is debounced — hidden
    // on every keystroke, recomputed and shown once typing has
    // paused for `INDICATOR_DEBOUNCE`. `recv` is time-boxed only
    // while the debounce is armed, so an idle session still does
    // no wake-ups. See `IndicatorDebounce` for the decision
    // logic; `app.input_indicator` mirrors it for the renderer.
    let mut debounce = IndicatorDebounce::default();
    // ADR-0027 §3 + ADR-0047 D5: absolute deadlines for the two timed
    // wake-ups — the indicator debounce and the demo keystroke-badge
    // expiry. The loop time-boxes `recv` on the *nearest* of them and,
    // on elapse, services whichever actually fired. Tracking them as
    // `Instant`s (rather than one fixed `timeout` duration) lets the
    // shorter badge timer fire without prematurely settling the longer
    // debounce, and vice-versa. Both `None` ⇒ block on `recv` (no idle
    // wake-ups).
    let mut debounce_deadline: Option<Instant> = None;
    let mut badge_deadline: Option<Instant> = None;
    let mut last_badge_seq: u64 = app.demo_badge_seq;
    // Long-lived native clipboard for the `copy` command (ADR-0041).
    // Created lazily on first copy (so an OSC-52-only session never
    // opens an X11 connection) and kept alive for the session — the
    // X11 backend serves the selection from a thread owned by this
    // handle, so it must outlive each write.
    let mut native_clipboard = crate::clipboard::SystemClipboard::new();
    loop {
        let event = match nearest_deadline(debounce_deadline, badge_deadline) {
            None => match event_rx.recv().await {
                Some(event) => event,
                None => break,
            },
            Some(deadline) => {
                let wait = deadline.saturating_duration_since(Instant::now());
                match tokio::time::timeout(wait, event_rx.recv()).await {
                    Ok(Some(event)) => event,
                    Ok(None) => break,
                    Err(_elapsed) => {
                        let now = Instant::now();
                        // ADR-0047 D5: the keystroke badge has aged out.
                        if badge_deadline.is_some_and(|d| d <= now) {
                            app.demo_badge = None;
                            badge_deadline = None;
                        }
                        // ADR-0027 §3: typing has paused for the debounce
                        // interval — settle the validity indicator.
                        if debounce_deadline.is_some_and(|d| d <= now) {
                            debounce.settle(app.input_validity_verdict());
                            app.input_indicator = debounce.visible();
                            debounce_deadline = None;
                        }
                        terminal
                            .draw(|f| ui::render(&mut app, &theme, f))
                            .context("redraw")?;
                        continue;
                    }
                }
            }
        };
        let is_key = matches!(event, AppEvent::Key(_));
        let actions = app.update(event);
        let mut should_quit = false;
        for action in actions {
            match action {
                Action::Quit => {
                    debug!("quit action received");
                    // Persist the mode we're leaving in, so it is
                    // restored next time the project opens (ADR-0015
                    // mode-restore amendment, issue #14 — persist on
                    // unload). Best-effort: a write failure must not
                    // block quitting.
                    if let Err(e) = session.database().set_mode(app.mode).await {
                        tracing::warn!(error = %e, "could not persist input mode on quit");
                    }
                    should_quit = true;
                }
                Action::ExecuteDsl {
                    command,
                    source,
                    submission_mode,
                } => {
                    spawn_dsl_dispatch(
                        session.database().clone(),
                        event_tx.clone(),
                        command,
                        source,
                        submission_mode,
                        session.project().path().to_path_buf(),
                    );
                }
                Action::JournalFailure { source, advanced } => {
                    // ADR-0034 §1/§4: record a failed command as an
                    // `err` record (ADR-0052: `err:adv` when advanced).
                    // Best-effort — a failure to record a failure must
                    // never escalate a user error into a fatal, so the
                    // result is logged and ignored.
                    if let Err(e) =
                        crate::persistence::Persistence::new(session.project().path().to_path_buf())
                            .append_history_failure(&source, advanced)
                    {
                        tracing::warn!(error = %e, "failed to journal err record (ignored)");
                    }
                }
                Action::PrepareRebuild => {
                    spawn_prepare_rebuild(session.project().path().to_path_buf(), event_tx.clone());
                }
                Action::Rebuild { source } => {
                    spawn_rebuild(
                        session.database().clone(),
                        session.project().path().to_path_buf(),
                        event_tx.clone(),
                        source,
                    );
                }
                Action::OpenLoadPicker => {
                    let entries: Vec<crate::app::LoadPickerEntry> =
                        list_projects(&session.data_root)
                            .into_iter()
                            .map(|p| crate::app::LoadPickerEntry {
                                display_name: p.display_name,
                                modified: p.modified,
                                path: p.path,
                                is_temp: matches!(p.kind, ProjectKind::Temp),
                            })
                            .collect();
                    let _ = event_tx.send(AppEvent::LoadPickerReady { entries }).await;
                }
                Action::LoadProject { path, source } => {
                    handle_project_switch(
                        &mut session,
                        SwitchRequest::Load { path },
                        source,
                        &event_tx,
                        undo_enabled,
                        app.mode,
                    )
                    .await;
                }
                Action::SaveAs { target, source } => {
                    handle_project_switch(
                        &mut session,
                        SwitchRequest::SaveAs { target },
                        source,
                        &event_tx,
                        undo_enabled,
                        app.mode,
                    )
                    .await;
                }
                Action::NewProject { source } => {
                    handle_project_switch(
                        &mut session,
                        SwitchRequest::NewTemp,
                        source,
                        &event_tx,
                        undo_enabled,
                        app.mode,
                    )
                    .await;
                }
                Action::Export { target, source } => {
                    spawn_export(
                        session.project().path().to_path_buf(),
                        directory_basename(session.project().path()),
                        session.data_root.clone(),
                        target,
                        source,
                        event_tx.clone(),
                    );
                }
                Action::Import {
                    zip_path,
                    as_target,
                    source,
                } => {
                    handle_project_switch(
                        &mut session,
                        SwitchRequest::Import {
                            zip_path: std::path::PathBuf::from(&zip_path),
                            as_target,
                        },
                        source,
                        &event_tx,
                        undo_enabled,
                        app.mode,
                    )
                    .await;
                }
                Action::PrepareUndo => {
                    spawn_prepare_undo(session.database().clone(), event_tx.clone(), false);
                }
                Action::PrepareRedo => {
                    spawn_prepare_undo(session.database().clone(), event_tx.clone(), true);
                }
                Action::Undo => {
                    spawn_undo(session.database().clone(), event_tx.clone(), false);
                }
                Action::Redo => {
                    spawn_undo(session.database().clone(), event_tx.clone(), true);
                }
                Action::Replay { path } => {
                    spawn_replay(
                        session.database().clone(),
                        session.project().path().to_path_buf(),
                        path,
                        event_tx.clone(),
                    );
                }
                Action::PersistMode(mode) => {
                    // Best-effort: the in-memory mode already changed;
                    // a failure to record it must not fatal a UI toggle
                    // (ADR-0015 mode-restore amendment, issue #14).
                    if let Err(e) = session.database().set_mode(mode).await {
                        tracing::warn!(error = %e, "could not persist input mode");
                    }
                }
                Action::CopyToClipboard(text) => {
                    // OSC 52 to the terminal + a best-effort native
                    // write (ADR-0041). Both always fire; an OSC 52
                    // write error is logged but never fatal.
                    let mut out = std::io::stdout();
                    if let Err(e) = crate::clipboard::deliver(
                        &mut out,
                        &mut native_clipboard,
                        &text,
                        crate::clipboard::in_tmux(),
                    ) {
                        tracing::warn!(error = %e, "could not emit clipboard OSC 52 escape");
                    }
                }
            }
        }
        // A keystroke hides the indicator and re-arms the
        // debounce (ADR-0027 §3) — it reappears once typing
        // pauses; non-key events leave it untouched.
        debounce.note_event(is_key);
        app.input_indicator = debounce.visible();
        // Keep the debounce deadline in lock-step with `is_armed()`,
        // restarting it on every event while armed (preserving the prior
        // behaviour) and clearing it once the indicator is visible again.
        debounce_deadline = debounce
            .is_armed()
            .then(|| Instant::now() + INDICATOR_DEBOUNCE);
        // ADR-0047 D5: (re)arm the badge timer whenever `update()` set a
        // fresh badge. `demo_badge_seq` bumps even for the same label
        // twice, so a repeated key restarts the timer rather than letting
        // a stale deadline expire it early.
        if app.demo_badge_seq != last_badge_seq {
            last_badge_seq = app.demo_badge_seq;
            badge_deadline = app
                .demo_badge
                .is_some()
                .then(|| Instant::now() + DEMO_BADGE_TTL);
        }
        terminal
            .draw(|f| ui::render(&mut app, &theme, f))
            .context("redraw")?;
        if should_quit {
            break;
        }
    }

    let _ = tokio::time::timeout(SHUTDOWN_GRACE, reader_handle).await;

    // Decide the active project's fate on quit, before dropping
    // it. An unmodified empty temp is auto-deleted — same rule
    // as on project switch (see perform_switch). Anything else —
    // a named project, or a temp that *gained* content this
    // session — is recorded as the `--resume` target: this is
    // the point at which a launch-temp the user filled with
    // content finally gets remembered (the `run()` startup
    // write skipped it while it was still empty). The two are
    // mutually exclusive (one needs an unmodified temp, the
    // other anything else).
    let project_at_quit = session.project.as_ref();
    let cleanup_on_quit: Option<std::path::PathBuf> =
        project_at_quit.and_then(|p| p.is_unmodified_temp().then(|| p.path().to_path_buf()));
    let resume_target_on_quit: Option<std::path::PathBuf> = project_at_quit
        .filter(|p| !p.is_unmodified_temp())
        .map(|p| p.path().to_path_buf());
    let _ = session.database.take();
    let _ = session.project.take();
    if let Some(target) = resume_target_on_quit
        && let Err(e) = write_last_project(&session.data_root, &target)
    {
        tracing::warn!(error = %e, "could not update last_project on quit");
    }
    if let Some(stale) = cleanup_on_quit {
        match safely_delete_temp_project(&stale, &session.data_root) {
            Ok(()) => tracing::info!(
                path = %stale.display(),
                "cleaned up unmodified temp project on quit",
            ),
            Err(e) => tracing::warn!(
                path = %stale.display(),
                error = %e,
                "did not clean up unmodified temp project on quit",
            ),
        }
    }

    info!("event loop exited");
    Ok(app.fatal_message.clone())
}

/// What kind of project switch the user requested.
enum SwitchRequest {
    /// `load` (picker or browse-to-path) — open an existing
    /// project at `path`.
    Load { path: std::path::PathBuf },
    /// `save as` — copy the current project to a new
    /// location, then open that copy. `target` is a name
    /// (resolved under `<data-root>/projects/`) or an
    /// absolute path.
    SaveAs { target: String },
    /// `new` — close current, create a fresh auto-named temp.
    NewTemp,
    /// `import` — extract a zip into a new project under the
    /// data root's projects dir, then switch to it. The
    /// destination basename is taken from the zip's
    /// top-level folder by default (`as_target` is `None`),
    /// or from the user-supplied override; collisions
    /// auto-suffix `-NN` (ADR-0015 §11 amendment).
    Import {
        zip_path: std::path::PathBuf,
        as_target: Option<String>,
    },
}

/// Common project-switch path. Drops the current project +
/// database (releasing the lock and stopping the worker),
/// opens the new one, runs a rebuild if the .db is missing,
/// appends history.log, and sends a `ProjectSwitched` event
/// so App refreshes its display.
///
/// Errors are surfaced as `ProjectSwitchFailed` (non-fatal):
/// the current project remains active.
async fn handle_project_switch(
    session: &mut Session,
    req: SwitchRequest,
    source: String,
    event_tx: &mpsc::Sender<AppEvent>,
    undo_enabled: bool,
    outgoing_mode: crate::mode::Mode,
) {
    // Persist the outgoing project's mode before it is unloaded
    // (ADR-0015 mode-restore amendment, issue #14 — persist on
    // unload). Best-effort, and before `perform_switch` drops the
    // outgoing database. The switched-to project's own stored mode
    // is restored separately, via the `ProjectSwitched` event.
    if let Err(e) = session.database().set_mode(outgoing_mode).await {
        tracing::warn!(error = %e, "could not persist input mode on switch");
    }
    match perform_switch(session, req, source, undo_enabled).await {
        Ok((display_name, is_temp, mode)) => {
            let history_entries = read_history_seed(session.project().path());
            let _ = event_tx
                .send(AppEvent::ProjectSwitched {
                    display_name,
                    is_temp,
                    history_entries,
                    mode,
                })
                .await;
            if let Ok(tables) = session.database().list_tables().await {
                let _ = event_tx.send(AppEvent::TablesRefreshed(tables)).await;
            }
            refresh_schema_cache(session.database(), event_tx).await;
        }
        Err(e) => {
            let _ = event_tx
                .send(AppEvent::ProjectSwitchFailed { error: e })
                .await;
        }
    }
}

/// Read the most-recent `HISTORY_HYDRATION_CAP` source lines
/// out of the project's `history.log` for input-history
/// seeding. Failures are logged and swallowed — an empty
/// hydration is the right fallback when the file is unreadable.
fn read_history_seed(project_path: &std::path::Path) -> Vec<String> {
    let p = crate::persistence::Persistence::new(project_path.to_path_buf());
    match p.read_recent_history(HISTORY_HYDRATION_CAP) {
        Ok(entries) => entries,
        Err(e) => {
            tracing::warn!(error = %e, "history hydration failed; starting empty");
            Vec::new()
        }
    }
}

/// Maximum number of `history.log` entries to seed the
/// in-memory navigable history with on project open. Matches
/// the in-memory cap (`app::HISTORY_CAPACITY`) per ADR-0015
/// §12: "latest N entries, where N is the same in-memory
/// cap as today."
const HISTORY_HYDRATION_CAP: usize = 1000;

async fn perform_switch(
    session: &mut Session,
    req: SwitchRequest,
    source: String,
    undo_enabled: bool,
) -> Result<(String, bool, crate::mode::Mode), String> {
    use crate::persistence::Persistence;

    // For SaveAs we need a resolved target path up front
    // (so the existence check happens before we drop the
    // current project). For NewTemp we'll let create_temp
    // pick the path. For Load it's the user-supplied path.
    // For Import we inspect the zip and resolve the target
    // (auto-suffixing on collision per ADR-0015 §11
    // amendment) before touching anything else.
    let resolved_target: Option<std::path::PathBuf> = match &req {
        SwitchRequest::Load { path } => {
            if !path.exists() {
                return Err(crate::t!(
                    "project.load_path_missing",
                    path = path.display()
                ));
            }
            Some(path.clone())
        }
        SwitchRequest::SaveAs { target } => {
            let p = resolve_save_target(target, &session.data_root);
            if p.exists() {
                return Err(crate::t!(
                    "project.saveas_target_exists",
                    path = p.display()
                ));
            }
            Some(p)
        }
        SwitchRequest::NewTemp => None,
        SwitchRequest::Import {
            zip_path,
            as_target,
        } => {
            if !zip_path.exists() {
                return Err(crate::t!(
                    "project.import_zip_missing",
                    path = zip_path.display()
                ));
            }
            // Validate the zip up front so we don't drop the
            // current project for an unimportable file.
            let inspection = crate::archive::inspect_zip(zip_path).map_err(|e| e.to_string())?;
            let resolved = resolve_import_destination(
                as_target.as_deref(),
                &inspection.top_folder,
                &session.data_root,
            )?;
            Some(resolved)
        }
    };

    // For SaveAs: copy current project to the target while
    // the source is still on disk (auto-save guarantees its
    // state matches the in-memory db).
    if let SwitchRequest::SaveAs { .. } = &req {
        let src = session.project().path().to_path_buf();
        let dst = resolved_target
            .as_ref()
            .expect("SaveAs has resolved target");
        copy_project(&src, dst).map_err(|e| e.to_string())?;
    }
    // For Import: extract the zip into the resolved target.
    // We do this *before* dropping the current project so
    // a failure here leaves the user where they were.
    if let SwitchRequest::Import { zip_path, .. } = &req {
        let dst = resolved_target
            .as_ref()
            .expect("Import has resolved target");
        let inspection = crate::archive::inspect_zip(zip_path).map_err(|e| e.to_string())?;
        crate::archive::extract_into(zip_path, dst, &inspection.top_folder)
            .map_err(|e| e.to_string())?;
    }

    // Capture cleanup info from the OUTGOING project before
    // we drop it: if it was an unmodified empty temp, we
    // delete its directory after the switch so the data dir
    // doesn't accumulate empty scratch projects.
    let outgoing_cleanup_path: Option<std::path::PathBuf> = session
        .project
        .as_ref()
        .and_then(|p| p.is_unmodified_temp().then(|| p.path().to_path_buf()));

    // Drop current project + database BEFORE opening the new
    // ones, releasing the old lock and stopping the old
    // worker. Required for the "load my own current project"
    // case (otherwise the new open would see a self-held
    // lock on this PID).
    let _ = session.database.take();
    let _ = session.project.take();

    // The outgoing project's lock is now released; it's
    // safe to remove its directory if it was unmodified.
    // The safely_delete_temp_project helper layers multiple
    // guards (containment under data root, [temp] marker,
    // contents allowlist, no symlinks) so a bug elsewhere
    // can't escalate into deleting the wrong directory.
    if let Some(stale) = outgoing_cleanup_path {
        match safely_delete_temp_project(&stale, &session.data_root) {
            Ok(()) => tracing::info!(
                path = %stale.display(),
                "cleaned up unmodified temp project on switch",
            ),
            Err(e) => tracing::warn!(
                path = %stale.display(),
                error = %e,
                "did not clean up unmodified temp project on switch",
            ),
        }
    }

    // Open the destination project. Load / SaveAs / Import
    // all open a path that already has the on-disk skeleton
    // (either because it pre-existed or because we just put
    // it there); NewTemp asks the project module for a fresh
    // auto-named one.
    let new_project = match &req {
        SwitchRequest::Load { .. }
        | SwitchRequest::SaveAs { .. }
        | SwitchRequest::Import { .. } => {
            let path = resolved_target.expect("Load/SaveAs/Import have resolved target");
            Project::open(&path).map_err(|e| e.to_string())?
        }
        SwitchRequest::NewTemp => {
            Project::create_temp(&session.data_root).map_err(|e| e.to_string())?
        }
    };
    let new_path = new_project.path().to_path_buf();

    // Run any pending project.yaml migrations before the
    // database opens. Same registry as `run()`. A failed
    // migration aborts the switch (the old project has
    // already been dropped — user lands in a "no project"
    // state momentarily, but the next user action will
    // surface the error and they can retry).
    let migrate_registry = crate::persistence::migrations::MigratorRegistry::production();
    // ADR-0005 Amendment 2: see the matching note in `run()` — a blob→text
    // migration needs the `.db` rebuilt from the migrated text.
    let had_blob_column =
        std::fs::read_to_string(new_project.path().join(crate::project::PROJECT_YAML))
            .map(|b| crate::persistence::migrations::body_declares_blob_column(&b))
            .unwrap_or(false);
    let migration_outcome = crate::persistence::migrations::ensure_project_yaml_migrated(
        new_project.path(),
        &migrate_registry,
    )
    .map_err(|e| e.to_string())?;

    // Open the new database (rebuild from text if .db is
    // missing — applies to NewTemp's just-created project,
    // and to Load when the user opened a project whose .db
    // had been deleted).
    let db_path = new_project.db_path();
    let db_existed = db_path.exists();
    // Restore the switched-to project's stored input mode (ADR-0015
    // mode-restore amendment, issue #14): "loading triggers the mode
    // switch each time." A switch uses the target's stored mode
    // directly — the startup `--mode` override applies only at boot,
    // not to subsequent loads. Absent/pre-#14 → default.
    let restored_mode = Persistence::read_stored_mode(&new_path).unwrap_or_default();
    let persistence = Persistence::new(new_path.clone()).with_mode(restored_mode);
    let new_database =
        Database::open_with_persistence_and_undo(&db_path, persistence, undo_enabled)
            .map_err(|e| e.to_string())?;
    let force_rebuild_after_migration =
        migration_outcome.migrated_from.is_some() && had_blob_column;
    if (!db_existed || force_rebuild_after_migration)
        && let Err(e) = new_database.rebuild_from_text(new_path.clone(), None).await
    {
        return Err(e.friendly_message());
    }

    let display_name = new_project.display_name().to_string();
    let is_temp = matches!(new_project.kind(), ProjectKind::Temp);
    // Worth recording as the resume target? A switch to a fresh
    // empty temp (`new`) is not — see the gate in `run()`.
    let new_worth_recording = !new_project.is_unmodified_temp();

    session.project = Some(new_project);
    session.database = Some(new_database);

    // Append the user-issued command to the destination's
    // history.log. The worker's persistence is wired but not
    // directly addressable from here, so we use a fresh
    // Persistence handle for this single line.
    // App-lifecycle command (save-as/load/new): journalled simple
    // (ADR-0052 — app commands run in any mode, so no `:` on recall).
    let _ = Persistence::new(new_path.clone()).append_history(&source, false);

    // Update the resume pointer so the next `--resume` launch
    // reopens the project we just switched to — unless it is a
    // fresh empty temp (a `new` command), which must not be
    // recorded (see the gate in `run()`). Write failures are
    // non-fatal.
    if new_worth_recording && let Err(e) = write_last_project(&session.data_root, &new_path) {
        tracing::warn!(error = %e, "could not update last_project after switch");
    }

    Ok((display_name, is_temp, restored_mode))
}

/// Resolve the destination directory for an `import`:
///
/// - **No `as <target>`:** use the zip's top-level folder name
///   under `<data-root>/projects/`. Auto-suffix `-NN` on
///   collision (ADR-0015 §11 amendment).
/// - **`as <relative-name>`:** under `<data-root>/projects/`,
///   auto-suffix on collision.
/// - **`as <absolute-path>`:** use the path verbatim. Refuse
///   if it already exists (no auto-suffix on absolute paths —
///   we don't second-guess what the user typed).
fn resolve_import_destination(
    as_target: Option<&str>,
    zip_top_folder: &str,
    data_root: &std::path::Path,
) -> Result<std::path::PathBuf, String> {
    if let Some(t) = as_target {
        let p = std::path::Path::new(t);
        if p.is_absolute() {
            if p.exists() {
                return Err(format!(
                    "`{}` already exists; pick a different target",
                    p.display(),
                ));
            }
            return Ok(p.to_path_buf());
        }
    }
    let basename: &str = as_target.unwrap_or(zip_top_folder);
    let parent = projects_dir(data_root);
    std::fs::create_dir_all(&parent).map_err(|e| e.to_string())?;
    let (resolved, _) =
        crate::archive::resolve_import_target(&parent, basename).map_err(|e| e.to_string())?;
    Ok(resolved)
}

/// Spawn a blocking task to write an export zip and forward
/// the outcome via the event channel.
///
/// The current project's auto-save semantics mean
/// `<project_path>` already reflects every successful command,
/// so the export reads from disk without coordinating with the
/// db worker. The `history.log` entry for this command is
/// appended directly here (we already hold the project path
/// and don't need to wait for the export to finish before
/// recording the user-issued command).
fn spawn_export(
    project_path: std::path::PathBuf,
    project_name: String,
    data_root: std::path::PathBuf,
    target: Option<String>,
    source: String,
    event_tx: mpsc::Sender<AppEvent>,
) {
    // `export` app command: journalled simple (ADR-0052).
    let _ =
        crate::persistence::Persistence::new(project_path.clone()).append_history(&source, false);
    tokio::spawn(async move {
        let outcome = tokio::task::spawn_blocking(move || {
            do_export(&project_path, &project_name, &data_root, target.as_deref())
        })
        .await;
        let event = match outcome {
            Ok(Ok(path)) => AppEvent::ExportSucceeded { path },
            Ok(Err(e)) => AppEvent::ExportFailed { error: e },
            Err(join_err) => AppEvent::ExportFailed {
                error: join_err.to_string(),
            },
        };
        let _ = event_tx.send(event).await;
    });
}

/// Synchronous body of the export pipeline.
fn do_export(
    project_path: &std::path::Path,
    project_name: &str,
    data_root: &std::path::Path,
    target: Option<&str>,
) -> Result<std::path::PathBuf, String> {
    let final_path: std::path::PathBuf = match target {
        Some(t) => {
            let p = std::path::Path::new(t);
            if p.is_absolute() {
                p.to_path_buf()
            } else {
                data_root.join(t)
            }
        }
        None => {
            std::fs::create_dir_all(data_root).map_err(|e| e.to_string())?;
            let (filename, _) = crate::archive::next_export_sequence(data_root, project_name)
                .map_err(|e| e.to_string())?;
            data_root.join(filename)
        }
    };

    if final_path.exists() {
        return Err(format!(
            "`{}` already exists; pick a different name or remove it first",
            final_path.display(),
        ));
    }

    crate::archive::export_project(project_path, project_name, &final_path)
        .map_err(|e| e.to_string())?;
    Ok(final_path)
}

/// The basename of `path` as a `String`. Falls back to the
/// full display string when the path has no terminal
/// component (e.g. `/`).
fn directory_basename(path: &std::path::Path) -> String {
    path.file_name()
        .map(|s| s.to_string_lossy().into_owned())
        .unwrap_or_else(|| path.display().to_string())
}

/// Resolve a `save as` target path against the data root.
///
/// Absolute paths pass through; relative paths join under
/// `<data-root>/projects/` per the user's stated preference
/// in ADR-0015 §1 ("named projects right alongside the temp
/// ones is the easiest workflow").
fn resolve_save_target(target: &str, data_root: &std::path::Path) -> std::path::PathBuf {
    let p = std::path::Path::new(target);
    if p.is_absolute() {
        p.to_path_buf()
    } else {
        projects_dir(data_root).join(p)
    }
}

async fn seed_initial_tables(database: &Database, event_tx: &mpsc::Sender<AppEvent>) {
    match database.list_tables().await {
        Ok(tables) => {
            let _ = event_tx.send(AppEvent::TablesRefreshed(tables)).await;
        }
        Err(e) => {
            error!(error = %e, "failed to seed initial table list");
        }
    }
    refresh_schema_cache(database, event_tx).await;
}

/// Fetch the three identifier lists (tables / columns /
/// relationships) and post them as `SchemaCacheRefreshed`
/// (ADR-0022 §9 + stage 8d). Always sends an event, even on
/// partial failure — best-effort completion is better than
/// no completion. Called wherever `TablesRefreshed` is sent
/// today; the schema cache lives on the App and feeds Tab
/// completion for identifier slots.
async fn refresh_schema_cache(database: &Database, event_tx: &mpsc::Sender<AppEvent>) {
    let cache = build_schema_cache(database).await;
    let _ = event_tx.send(AppEvent::SchemaCacheRefreshed(cache)).await;
    // ADR-0046 DB2: full relationship records for the sidebar panel.
    // Best-effort — a failed read leaves the panel empty.
    if let Ok(relationships) = database.read_all_relationships().await {
        let _ = event_tx
            .send(AppEvent::RelationshipsRefreshed(relationships))
            .await;
    }
}

/// Build a `SchemaCache` snapshot from the live database.
///
/// Shared by `refresh_schema_cache` (interactive path — wraps
/// the result in a `SchemaCacheRefreshed` event) and the replay
/// path (which re-snapshots per line because the schema mutates
/// as replayed `create table` / `add column` commands run).
/// Best-effort: a failed query leaves that list empty and the
/// walker falls back to schemaless behaviour.
async fn build_schema_cache(database: &Database) -> crate::completion::SchemaCache {
    use crate::completion::{SchemaCache, TableColumn};
    use crate::dsl::grammar::IdentSource;
    let mut cache = SchemaCache::default();
    if let Ok(tables) = database.list_names_for(IdentSource::Tables).await {
        cache.tables = tables;
    }
    if let Ok(columns) = database.list_names_for(IdentSource::Columns).await {
        cache.columns = columns;
    }
    if let Ok(rels) = database.list_names_for(IdentSource::Relationships).await {
        cache.relationships = rels;
    }
    if let Ok(indexes) = database.list_names_for(IdentSource::Indexes).await {
        cache.indexes = indexes;
    }
    // Phase D (ADR-0024 §Phase D): per-table column metadata
    // with user-facing types. The walker's
    // `DynamicSubgrammar(column_value_list)` reads this to
    // unfold typed value slots per column at `insert into T
    // values (...)` positions. Best-effort: a `describe_table`
    // miss leaves that table's columns unpopulated and the
    // walker falls back to the schemaless value-literal list.
    for name in cache.tables.clone() {
        if let Ok(desc) = database.describe_table(name.clone()).await {
            // Per-table indexes for the items panel (S2, ADR-0025).
            // Carry uniqueness so the panel can mark a UNIQUE index
            // (ADR-0035 §4d). Captured before `desc.columns` is
            // consumed below.
            let index_entries: Vec<crate::completion::IndexEntry> = desc
                .indexes
                .iter()
                .map(|i| crate::completion::IndexEntry {
                    name: i.name.clone(),
                    unique: i.unique,
                })
                .collect();
            let cols: Vec<TableColumn> = desc
                .columns
                .into_iter()
                .filter_map(|c| {
                    c.user_type.map(|ty| TableColumn {
                        name: c.name,
                        user_type: ty,
                        // Only a column the engine reports as NOT NULL
                        // (ADR-0033 §8.3 "declared NOT NULL"). We do
                        // NOT treat a PK as implicitly not-null: an
                        // `int` PK is an `INTEGER PRIMARY KEY` rowid
                        // alias that auto-fills, so flagging it omitted
                        // would be a false positive (SQLite reports
                        // notnull=0 for it anyway). serial/shortid are
                        // excluded by type in the not_null_missing pass.
                        not_null: c.notnull,
                        has_default: c.default.is_some(),
                    })
                })
                .collect();
            cache.table_columns.insert(name.clone(), cols);
            cache.table_indexes.insert(name, index_entries);
        }
    }
    cache
}

/// Read `project.yaml` + `data/` to compute the rebuild
/// summary that the confirmation modal shows. Runs off the
/// event loop so the brief I/O doesn't stall input handling
/// even on slow filesystems.
fn spawn_prepare_rebuild(project_path: std::path::PathBuf, event_tx: mpsc::Sender<AppEvent>) {
    tokio::spawn(async move {
        let summary = match summarize_project(&project_path) {
            Ok(s) => s,
            Err(e) => format!("(could not read project sources: {e})"),
        };
        let _ = event_tx.send(AppEvent::RebuildPrepared { summary }).await;
    });
}

/// Does the project at `project_path` actually have any
/// schema or data?
///
/// "Has content" means at least one table is declared in
/// `project.yaml` OR at least one CSV row exists under
/// `data/`. A brand-new auto-named temp project, having
/// neither, returns `false`. Errors reading the project
/// (corrupt YAML, missing dir) also return `false` —
/// suppressing a misleading "0 tables reconstructed"
/// message for a project we can't read is the right default.
fn project_has_content(project_path: &std::path::Path) -> bool {
    let yaml_path = project_path.join(crate::project::PROJECT_YAML);
    let Ok(yaml) = std::fs::read_to_string(&yaml_path) else {
        return false;
    };
    let Ok(snapshot) = crate::persistence::parse_schema(&yaml) else {
        return false;
    };
    !snapshot.tables.is_empty()
}

fn summarize_project(project_path: &std::path::Path) -> Result<String, String> {
    let yaml_path = project_path.join(crate::project::PROJECT_YAML);
    let yaml = std::fs::read_to_string(&yaml_path).map_err(|e| e.to_string())?;
    let snapshot = crate::persistence::parse_schema(&yaml).map_err(|e| e.to_string())?;
    let table_count = snapshot.tables.len();
    let data_dir = project_path.join(crate::project::DATA_DIR);
    let mut row_count: usize = 0;
    for table in &snapshot.tables {
        let csv_path = data_dir.join(format!("{}.csv", table.name));
        let Ok(body) = std::fs::read_to_string(&csv_path) else {
            continue;
        };
        // Header line + one line per row (per Iteration 2's
        // "no CSV when empty" rule, this is exact).
        row_count += body.lines().count().saturating_sub(1);
    }
    Ok(format!(
        "{table_count} table{} and {row_count} row{} will be reconstructed; \
         the existing playground.db will be replaced",
        if table_count == 1 { "" } else { "s" },
        if row_count == 1 { "" } else { "s" },
    ))
}

/// Spawn the actual rebuild and forward the typed outcome
/// back as an `AppEvent`.
fn spawn_rebuild(
    database: Database,
    project_path: std::path::PathBuf,
    event_tx: mpsc::Sender<AppEvent>,
    source: String,
) {
    tokio::spawn(async move {
        let source_for_journal = source.clone();
        match database
            .rebuild_from_text(project_path.clone(), Some(source))
            .await
        {
            Ok(()) => {
                // ADR-0052: journal `rebuild` at the dispatch layer (the
                // worker no longer journals); simple (app command),
                // best-effort.
                if let Err(e) = crate::persistence::Persistence::new(project_path.clone())
                    .append_history(&source_for_journal, false)
                {
                    warn!(error = %e, "failed to journal rebuild (ignored)");
                }
                let summary = summarize_project(&project_path)
                    .unwrap_or_else(|_| "rebuild complete".to_string());
                let _ = event_tx.send(AppEvent::RebuildSucceeded { summary }).await;
                // Refresh the table list so the items panel
                // reflects whatever the rebuild produced.
                if let Ok(tables) = database.list_tables().await {
                    let _ = event_tx.send(AppEvent::TablesRefreshed(tables)).await;
                }
                refresh_schema_cache(&database, &event_tx).await;
            }
            Err(DbError::PersistenceFatal {
                operation,
                path,
                message,
            }) => {
                let _ = event_tx
                    .send(AppEvent::PersistenceFatal {
                        operation: operation.to_string(),
                        path,
                        message,
                    })
                    .await;
            }
            Err(other) => {
                let _ = event_tx
                    .send(AppEvent::RebuildFailed {
                        error: other.friendly_message(),
                    })
                    .await;
            }
        }
    });
}

/// Peek the snapshot `undo`/`redo` would restore and post
/// `UndoPrepared` (open the confirmation modal) or
/// `UndoUnavailable` (ADR-0006 Amendment 1).
fn spawn_prepare_undo(database: Database, event_tx: mpsc::Sender<AppEvent>, is_redo: bool) {
    tokio::spawn(async move {
        let peek = if is_redo {
            database.peek_redo().await
        } else {
            database.peek_undo().await
        };
        let event = match peek {
            Ok(Some(meta)) => AppEvent::UndoPrepared {
                command: meta.command,
                timestamp: meta.timestamp,
                is_redo,
            },
            Ok(None) => AppEvent::UndoUnavailable { is_redo },
            Err(e) => AppEvent::UndoFailed {
                error: e.friendly_message(),
                is_redo,
            },
        };
        let _ = event_tx.send(event).await;
    });
}

/// Restore the snapshot `undo`/`redo` selects, then refresh the
/// table list + schema cache so the TUI shows the restored state.
fn spawn_undo(database: Database, event_tx: mpsc::Sender<AppEvent>, is_redo: bool) {
    tokio::spawn(async move {
        let result = if is_redo {
            database.redo().await
        } else {
            database.undo().await
        };
        match result {
            Ok(Some(meta)) => {
                let _ = event_tx
                    .send(AppEvent::UndoSucceeded {
                        command: meta.command,
                        is_redo,
                    })
                    .await;
                if let Ok(tables) = database.list_tables().await {
                    let _ = event_tx.send(AppEvent::TablesRefreshed(tables)).await;
                }
                refresh_schema_cache(&database, &event_tx).await;
            }
            Ok(None) => {
                let _ = event_tx.send(AppEvent::UndoUnavailable { is_redo }).await;
            }
            Err(e) => {
                let _ = event_tx
                    .send(AppEvent::UndoFailed {
                        error: e.friendly_message(),
                        is_redo,
                    })
                    .await;
            }
        }
    });
}

/// Spawn a task that runs a DSL command against the database
/// and forwards the result back as an `AppEvent`.
fn spawn_dsl_dispatch(
    database: Database,
    event_tx: mpsc::Sender<AppEvent>,
    command: Command,
    source: String,
    submission_mode: crate::app::EffectiveMode,
    project_path: std::path::PathBuf,
) {
    tokio::spawn(async move {
        // Retain the source for journaling (ADR-0034 §1/§2; ADR-0052
        // moved success journaling here, next to the failure path).
        let source_for_journal = source.clone();
        // ADR-0038: the DSL → SQL teaching echo fires for a DSL-form
        // command submitted in an advanced effective mode (ADR-0037).
        // `replay` bypasses this spawn (it calls `execute_command_typed`
        // directly), so replayed lines never echo.
        //
        // Two echo sources converge here. The Bucket A pre-execution
        // path (`echo_for` → `command_to_sql`) handles every echo that is
        // a pure function of the `Command` — wired into the non-Schema
        // arms below (Update / Delete / AddColumn / DropColumn /
        // ChangeColumn). The Schema arm uses `build_schema_echo`, which
        // subsumes the Bucket A pure-Command schema cases *and* adds the
        // Bucket B resolved-name cases that need the post-exec
        // description (`add index` / `add relationship`) or a pre-exec
        // lookup (`drop index` / `drop relationship` — the dropped thing
        // is gone after execution, hence `collect_drop_lookups` runs
        // first).
        let lookups = collect_echo_lookups(&database, &command, submission_mode).await;
        let echo = crate::echo::echo_for(&command, submission_mode);
        let outcome = execute_command_typed(&database, command.clone(), source).await;
        // ADR-0052 (issue #30): journal a SUCCESSFUL command here, at the
        // top of the chain — the canonical source + submission mode are
        // both in scope, so no mode-plumbing into the worker is needed.
        // Best-effort (ADR-0040 amended): the command is already committed;
        // a journal-write failure is logged, never fatal. Failures stay on
        // the `JournalFailure` path (Ok/Err are exclusive — no double
        // journal). `:adv` tags an advanced submission (ADR-0052).
        if outcome.is_ok()
            && let Err(e) = crate::persistence::Persistence::new(project_path)
                .append_history(&source_for_journal, submission_mode.is_advanced())
        {
            warn!(error = %e, "failed to journal ok record (ignored)");
        }
        let event = match outcome {
            Ok(CommandOutcome::Schema(description)) => {
                let schema_echo =
                    build_schema_echo(&command, submission_mode, description.as_ref(), &lookups);
                AppEvent::DslSucceeded {
                    command: command.clone(),
                    description,
                    echo: schema_echo,
                }
            }
            Ok(CommandOutcome::SchemaSkipped(description)) => AppEvent::DslCreateSkipped {
                command: command.clone(),
                description,
            },
            Ok(CommandOutcome::SchemaDropSkipped) => AppEvent::DslDropSkipped {
                command: command.clone(),
            },
            Ok(CommandOutcome::SchemaDropIndexSkipped) => AppEvent::DslDropIndexSkipped {
                command: command.clone(),
            },
            Ok(CommandOutcome::SchemaCreateIndexSkipped(name)) => AppEvent::DslCreateIndexSkipped {
                command: command.clone(),
                name,
            },
            Ok(CommandOutcome::Query(data)) => {
                // ADR-0038: `show data` is the only DSL-form query that
                // echoes; its limited form orders by the table's primary
                // key, which is not on the Command — so the echo is built
                // post-execution from the schema (handoff §5). A
                // SQL-entered `SELECT` (also a Query outcome) has no echo.
                let echo = build_show_data_echo(&database, &command, submission_mode).await;
                AppEvent::DslDataSucceeded {
                    command: command.clone(),
                    data,
                    echo,
                }
            }
            Ok(CommandOutcome::ShowList(lines)) => AppEvent::DslShowListSucceeded {
                command: command.clone(),
                lines,
            },
            Ok(CommandOutcome::ShowRelationship(data)) => AppEvent::DslShowRelationshipSucceeded {
                command: command.clone(),
                data: data.map(|b| *b),
            },
            Ok(CommandOutcome::QueryPlan(plan)) => AppEvent::DslExplainSucceeded {
                command: command.clone(),
                plan,
            },
            Ok(CommandOutcome::Insert(result)) => AppEvent::DslInsertSucceeded {
                command: command.clone(),
                result,
            },
            Ok(CommandOutcome::Seed(result)) => AppEvent::DslSeedSucceeded {
                command: command.clone(),
                result,
            },
            Ok(CommandOutcome::Update(result)) => AppEvent::DslUpdateSucceeded {
                command: command.clone(),
                result,
                echo,
            },
            Ok(CommandOutcome::Delete(result)) => AppEvent::DslDeleteSucceeded {
                command: command.clone(),
                result,
                echo,
            },
            Ok(CommandOutcome::ChangeColumn(result)) => {
                // ADR-0038 §6 category 3 caveat: `--dont-convert` skips
                // the client-side layer entirely, so the headline echo
                // (`ALTER TABLE … SET DATA TYPE …`) is the *nearest*
                // SQL but not equivalent. The caveat only makes sense
                // next to the echo, so gate on advanced mode (the line
                // references "the line above").
                let dont_convert_caveat = submission_mode.is_advanced()
                    && matches!(
                        &command,
                        Command::ChangeColumnType {
                            mode: ChangeColumnMode::DontConvert,
                            ..
                        }
                    );
                AppEvent::DslChangeColumnSucceeded {
                    command: command.clone(),
                    result,
                    echo,
                    dont_convert_caveat,
                }
            }
            Ok(CommandOutcome::AddColumn(result)) => AppEvent::DslAddColumnSucceeded {
                command: command.clone(),
                result,
                echo,
            },
            Ok(CommandOutcome::DropColumn(result)) => {
                // `drop column --cascade` is the only DropColumn shape
                // whose echo needs the execution result (the names of
                // the covering indexes the rebuild removed — Bucket B
                // category 2, ADR-0038 §7 Slice 2b). Non-cascade falls
                // through to the pre-execution `echo` from `echo_for`.
                let cascade_echo =
                    build_drop_column_cascade_echo(&command, submission_mode, &result);
                AppEvent::DslDropColumnSucceeded {
                    command: command.clone(),
                    result,
                    echo: cascade_echo.or(echo),
                }
            }
            Err(DbError::PersistenceFatal {
                operation,
                path,
                message,
            }) => AppEvent::PersistenceFatal {
                operation: operation.to_string(),
                path,
                message,
            },
            Err(error) => {
                // Schema-resolved enrichment per ADR-0019 §6.
                // The runtime owns DB access; the App stays
                // presentation-only.
                let facts = enrich_dsl_failure(&database, &command, &error).await;
                AppEvent::DslFailed {
                    command: command.clone(),
                    error,
                    facts,
                    source: source_for_journal,
                    advanced: submission_mode.is_advanced(),
                }
            }
        };
        if event_tx.send(event).await.is_err() {
            return;
        }
        // Refresh the table list after every DDL operation so
        // the items panel reflects reality. A failed list_tables
        // here is logged but not surfaced to the user — they
        // already saw the primary outcome.
        match database.list_tables().await {
            Ok(tables) => {
                let _ = event_tx.send(AppEvent::TablesRefreshed(tables)).await;
            }
            Err(e) => warn!(error = %e, "post-list_tables failed"),
        }
        // Refresh the schema cache feeding Tab completion
        // (ADR-0022 §9). Same timing as TablesRefreshed.
        refresh_schema_cache(&database, &event_tx).await;
    });
}

/// Build the `show data` DSL → SQL teaching echo (ADR-0038).
///
/// `show data` is the one Bucket A row whose echo needs schema info beyond
/// the `Command`: the limited form (`show data T limit n`) orders by the
/// table's primary key for a stable "first n" (the worker's
/// `build_query_data_sql`), and that column list is not on the `Command`.
/// So when — and only when — the query is limited, this resolves the
/// primary key via `describe_table` (the same best-effort schema lookup
/// `enrich_dsl_failure` uses) and feeds it to [`crate::echo::echo_for_query`].
///
/// Silent in simple mode (gated before any lookup) and for a SQL-entered
/// `SELECT` (not a `ShowData`). A describe failure or a primary-key-less
/// table simply drops the `ORDER BY`, exactly as the worker does.
async fn build_show_data_echo(
    database: &Database,
    command: &Command,
    submission_mode: crate::app::EffectiveMode,
) -> Option<Vec<String>> {
    if !submission_mode.is_advanced() {
        return None;
    }
    // The primary key is needed only for the `ORDER BY` of a limited query;
    // skip the lookup otherwise so the common case stays round-trip-free.
    let primary_key = match command {
        Command::ShowData {
            name,
            limit: Some(_),
            ..
        } => database
            .describe_table(name.clone())
            .await
            .map(|desc| {
                desc.columns
                    .iter()
                    .filter(|c| c.primary_key)
                    .map(|c| c.name.clone())
                    .collect::<Vec<_>>()
            })
            .unwrap_or_default(),
        _ => Vec::new(),
    };
    crate::echo::echo_for_query(command, submission_mode, &primary_key)
}

/// Pre-execution lookups captured for the teaching echo (ADR-0038 §7
/// Bucket B).
///
/// Two classes of echo need information that the `Command` alone doesn't
/// carry and that may not be recoverable from the post-execution
/// `description`:
///
/// - **Drops** of resolved-name things (`drop index` positional,
///   `drop relationship`): the thing is *gone* post-execution, so the
///   runtime resolves the name (and for `drop relationship Named`, the
///   owning child table) **before** calling the worker.
/// - **`add relationship --create-fk`**: the multi-line echo (category
///   2, Slice 2b) emits an `ADD COLUMN` line *only when the child column
///   was newly created*; the runtime resolves both the pre-state
///   (existed?) and the new column type (from the parent's PK via
///   `Type::fk_target_type`) up front, so the post-exec builder is a
///   pure formatter.
///
/// Empty (`None` on each field) in simple mode, or when the command does
/// not need a lookup, or when the lookup didn't find anything (defensive
/// — the executor will then refuse with its own error and the echo
/// simply doesn't fire).
#[derive(Default)]
struct EchoLookups {
    /// For `Command::DropIndex { IndexSelector::Columns }` — the resolved
    /// index name. The positional form `drop index on T(cols)` reaches
    /// this; the SQL `DROP INDEX <name>` is `Command::SqlDropIndex` and
    /// is already SQL (no echo).
    drop_index_name: Option<String>,
    /// For `Command::DropRelationship` — `(resolved name, child_table)`.
    /// For `Endpoints`, name is resolved + child_table is from the
    /// command (captured here uniformly so the post-exec builder uses one
    /// shape). For `Named`, name is from the command + child_table is
    /// resolved via a scan of user tables (small schemas — fine for a
    /// teaching playground).
    drop_relationship: Option<(String, String)>,
    /// For `Command::AddRelationship { create_fk: true, .. }` — the
    /// child columns the `--create-fk` flag will newly create, each with
    /// its type (ADR-0043: one per child column that did **not** already
    /// exist, typed to the matching parent PK column's `fk_target_type` —
    /// ADR-0011: `serial → int`, `shortid → text`, others identity). An
    /// **empty** vec means every child column already existed →
    /// single-line echo; a non-empty vec → multi-line (one `ADD COLUMN`
    /// per element). The outer `Option` is `None` for not-applicable
    /// commands (not a `--create-fk` add, simple mode, or a
    /// pre-execution lookup failed).
    add_rel_create_fk_new_columns: Option<Vec<(String, crate::dsl::types::Type)>>,
}

/// Resolve drop-target names and `--create-fk` pre-state **before**
/// execution, for the Bucket B echoes that need them (ADR-0038 §7).
/// Best-effort: an unresolved lookup yields `None` and the echo for that
/// command silently doesn't fire — the executor's own error path
/// surfaces any real problem.
async fn collect_echo_lookups(
    database: &Database,
    command: &Command,
    submission_mode: crate::app::EffectiveMode,
) -> EchoLookups {
    let mut out = EchoLookups::default();
    if !submission_mode.is_advanced() {
        return out;
    }
    match command {
        Command::DropIndex {
            selector: IndexSelector::Columns { table, columns },
        } => {
            if let Ok(desc) = database.describe_table(table.clone()).await
                && let Some(idx) = desc.indexes.iter().find(|i| i.columns == *columns)
            {
                out.drop_index_name = Some(idx.name.clone());
            }
        }
        Command::DropRelationship {
            selector:
                RelationshipSelector::Endpoints {
                    parent_table,
                    parent_column,
                    child_table,
                    child_column,
                },
        } => {
            if let Ok(desc) = database.describe_table(child_table.clone()).await
                && let Some(rel) = desc.outbound_relationships.iter().find(|r| {
                    // The Endpoints drop selector is single-column
                    // (ADR-0043 keeps DROP by-endpoints single-column;
                    // compound relationships drop by name) — match a
                    // one-column relationship by its sole columns.
                    r.other_table == *parent_table
                        && r.other_columns.as_slice() == std::slice::from_ref(parent_column)
                        && r.local_columns.as_slice() == std::slice::from_ref(child_column)
                })
            {
                out.drop_relationship = Some((rel.name.clone(), child_table.clone()));
            }
        }
        Command::DropRelationship {
            selector: RelationshipSelector::Named { name },
        } => {
            // The named selector doesn't carry the child table — the
            // worker resolves it from the relationships metadata. Mirror
            // that with a small scan of user tables. For a teaching
            // playground (small schemas) this is cheap; a dedicated
            // resolver API would be the next step if schemas grow.
            if let Ok(tables) = database.list_tables().await {
                for table in tables {
                    if let Ok(desc) = database.describe_table(table.clone()).await
                        && desc.outbound_relationships.iter().any(|r| r.name == *name)
                    {
                        out.drop_relationship = Some((name.clone(), table.clone()));
                        break;
                    }
                }
            }
        }
        Command::AddRelationship {
            create_fk: true,
            parent_table,
            parent_columns,
            child_table,
            child_columns,
            ..
        } => {
            // Pre-state for the multi-line `--create-fk` echo (ADR-0038
            // §7 Bucket B, category 2 / ADR-0043): the subset of child
            // columns that do NOT already exist, each typed to the
            // matching parent PK column's `fk_target_type`. Needed
            // *before* execution to know which `ADD COLUMN` lines to
            // emit. The parent columns here are the explicit DSL list,
            // paired positionally with the child list.
            let parent_desc = database.describe_table(parent_table.clone()).await;
            let child_desc = database.describe_table(child_table.clone()).await;
            if let (Ok(parent_desc), Ok(child_desc)) = (parent_desc, child_desc) {
                let mut new_columns: Vec<(String, crate::dsl::types::Type)> = Vec::new();
                for (child_col, parent_col) in child_columns.iter().zip(parent_columns) {
                    let already = child_desc.columns.iter().any(|c| c.name == *child_col);
                    if already {
                        continue;
                    }
                    if let Some(parent_ty) = parent_desc
                        .columns
                        .iter()
                        .find(|c| c.name == *parent_col)
                        .and_then(|c| c.user_type)
                    {
                        new_columns.push((child_col.clone(), parent_ty.fk_target_type()));
                    }
                }
                out.add_rel_create_fk_new_columns = Some(new_columns);
            }
        }
        _ => {}
    }
    out
}

/// Build the teaching echo for a `Schema`-outcome command (ADR-0038).
///
/// Subsumes both the Bucket A pure-`Command` echoes (`create table`,
/// `rename column`, `add`/`drop constraint` — for which it delegates to
/// `echo::command_to_sql`) **and** the Bucket B resolved-name echoes
/// (`add`/`drop index`, `add`/`drop relationship`), which read the
/// post-execution `description` (for adds) or `drop_lookups` (for drops).
/// Returns `None` for non-advanced mode, for Bucket C / `Sql*` variants
/// that don't echo, and for the `--create-fk` form (Slice 2b — Phase 2
/// next slice).
fn build_schema_echo(
    command: &Command,
    submission_mode: crate::app::EffectiveMode,
    description: Option<&TableDescription>,
    lookups: &EchoLookups,
) -> Option<Vec<String>> {
    if !submission_mode.is_advanced() {
        return None;
    }
    match command {
        Command::AddIndex {
            name,
            table,
            columns,
        } => {
            // The post-exec description carries the new index with its
            // stored name (user-given `as N` or worker auto-generated).
            // Always sourcing from the description (rather than command
            // when `name = Some`) keeps the runtime in one path and
            // matches whatever the worker actually wrote.
            let resolved = description
                .and_then(|d| d.indexes.iter().find(|i| i.columns == *columns))
                .map(|i| i.name.clone())
                .or_else(|| name.clone());
            resolved.map(|n| vec![crate::echo::render_create_index(&n, table, columns)])
        }
        Command::DropIndex {
            selector: IndexSelector::Columns { .. },
        } => lookups
            .drop_index_name
            .as_ref()
            .map(|n| vec![crate::echo::render_drop_index(n)]),
        Command::AddRelationship {
            name,
            parent_table,
            parent_columns,
            child_table,
            child_columns,
            on_delete,
            on_update,
            create_fk,
        } => {
            // Resolve the relationship name from the parent's inbound
            // relationships (target_table for AddRelationship is the
            // parent — `database.add_relationship` returns the parent's
            // description per ADR-0013), falling back to the command's
            // explicit `name` when the description is unavailable. Match
            // on the column lists (ADR-0043), the child as `other` and
            // the parent as `local` from the parent's perspective.
            let resolved = description
                .and_then(|d| {
                    d.inbound_relationships.iter().find(|r| {
                        r.other_table == *child_table
                            && r.other_columns == *child_columns
                            && r.local_columns == *parent_columns
                    })
                })
                .map(|r| r.name.clone())
                .or_else(|| name.clone())?;
            if *create_fk {
                // The pre-execution lookup captured which child columns
                // `--create-fk` newly creates (ADR-0043). An empty list
                // → every column existed → single-line FK echo; a
                // non-empty list → one `ADD COLUMN` per new column then
                // the FK line.
                let new_columns = lookups.add_rel_create_fk_new_columns.as_ref()?;
                if new_columns.is_empty() {
                    Some(vec![crate::echo::render_add_relationship(
                        &resolved,
                        parent_table,
                        parent_columns,
                        child_table,
                        child_columns,
                        *on_delete,
                        *on_update,
                    )])
                } else {
                    Some(crate::echo::render_add_relationship_create_fk(
                        &resolved,
                        parent_table,
                        parent_columns,
                        child_table,
                        child_columns,
                        *on_delete,
                        *on_update,
                        new_columns,
                    ))
                }
            } else {
                Some(vec![crate::echo::render_add_relationship(
                    &resolved,
                    parent_table,
                    parent_columns,
                    child_table,
                    child_columns,
                    *on_delete,
                    *on_update,
                )])
            }
        }
        Command::DropRelationship { .. } => {
            lookups
                .drop_relationship
                .as_ref()
                .map(|(name, child_table)| {
                    vec![crate::echo::render_drop_relationship(name, child_table)]
                })
        }
        // `create m:n relationship` (ADR-0045): the resolved junction
        // columns/FKs only exist on the post-exec description, so the
        // teaching echo is rendered from it (not `command_to_sql`).
        Command::CreateM2nRelationship { .. } => description.map(|desc| {
            let columns: Vec<(String, crate::dsl::types::Type)> = desc
                .columns
                .iter()
                .filter_map(|c| c.user_type.map(|ty| (c.name.clone(), ty)))
                .collect();
            let primary_key: Vec<String> = desc
                .columns
                .iter()
                .filter(|c| c.primary_key)
                .map(|c| c.name.clone())
                .collect();
            let foreign_keys: Vec<(Vec<String>, String, Vec<String>)> = desc
                .outbound_relationships
                .iter()
                .map(|r| {
                    (
                        r.local_columns.clone(),
                        r.other_table.clone(),
                        r.other_columns.clone(),
                    )
                })
                .collect();
            vec![crate::echo::render_create_m2n(
                &desc.name,
                &columns,
                &primary_key,
                &foreign_keys,
            )]
        }),
        // Everything else (Bucket A pure-Command, plus the no-echo Bucket C
        // variants like `Sql*` / `ShowTable`) routes through the existing
        // `echo::command_to_sql` — wrapping its `Option<String>` to fit the
        // multi-line `Option<Vec<String>>` payload uniformly.
        _ => crate::echo::command_to_sql(command).map(|s| vec![s]),
    }
}

/// Build the `drop column --cascade` multi-line teaching echo (ADR-0038
/// §7 Bucket B, category 2). Returns `None` for non-`--cascade` drops
/// (the pre-execution `echo_for` already produced the single-line plain
/// `DROP COLUMN` echo for Bucket A) and for simple mode. Reads
/// `DropColumnResult::dropped_indexes` for the index names the rebuild
/// removed.
fn build_drop_column_cascade_echo(
    command: &Command,
    submission_mode: crate::app::EffectiveMode,
    result: &DropColumnResult,
) -> Option<Vec<String>> {
    if !submission_mode.is_advanced() {
        return None;
    }
    match command {
        Command::DropColumn {
            table,
            column,
            cascade: true,
        } => Some(crate::echo::render_drop_column_cascade(
            table,
            column,
            &result.dropped_indexes,
        )),
        _ => None,
    }
}

/// Build schema-resolved enrichment for a DSL failure (ADR-0019 §6).
///
/// Best-effort: every lookup is independently fallible and a
/// missing piece just leaves the corresponding
/// `FailureContext` field `None`. The translator falls back to
/// catalog `{name}` placeholders for unfilled fields.
///
/// What we resolve, by classification:
///
/// - **UNIQUE / NOT NULL violation** (engine reports `T.col`):
///   - `table`, `column` from the engine message.
///   - `value` from the originating Command (explicit columns
///     or single-value short form, with schema lookup as a
///     last resort for natural-order multi-value INSERT).
///   - For UNIQUE only: `diagnostic_table` from a pinpoint
///     `SELECT * FROM T WHERE col = value LIMIT N` showing
///     the existing row that conflicts.
/// - **FK INSERT/UPDATE** (child-side): outbound relationship
///   lookup picks the FK column the user set; resolves
///   `parent_table`, `parent_column`, and the attempted
///   `value`.
/// - **FK DELETE/UPDATE** (parent-side): inbound relationship
///   lookup picks a `child_table` that references this row.
/// - Anything else: `FailureContext::default()`.
pub async fn enrich_dsl_failure(
    database: &Database,
    command: &Command,
    error: &DbError,
) -> crate::friendly::FailureContext {
    let DbError::Sqlite { message, .. } = error else {
        return crate::friendly::FailureContext::default();
    };
    let lower = message.to_ascii_lowercase();
    if lower.contains("unique constraint failed") {
        enrich_unique_violation(database, command, message).await
    } else if lower.contains("not null constraint failed") {
        enrich_not_null_violation(command, message)
    } else if lower.contains("check constraint failed") {
        enrich_check_violation(database, command, message).await
    } else if lower.contains("foreign key constraint failed") {
        enrich_fk_violation(database, command).await
    } else {
        crate::friendly::FailureContext::default()
    }
}

/// Enrich a `CHECK` violation (ADR-0029 §10). The engine
/// reports `CHECK constraint failed: <column>` — the column the
/// constraint sits on, unqualified. We pair that with the
/// command's table, the value the user supplied for the column,
/// and the column's compiled `CHECK` expression so the friendly
/// error can name the rule that was broken.
async fn enrich_check_violation(
    database: &Database,
    command: &Command,
    message: &str,
) -> crate::friendly::FailureContext {
    let mut facts = crate::friendly::FailureContext::default();
    let Some((_, after)) = message.split_once(':') else {
        return facts;
    };
    let column = after.trim();
    let table = command.target_table();
    if column.is_empty() || table.is_empty() {
        return facts;
    }
    facts.column = Some(column.to_string());
    facts.table = Some(table.to_string());
    // The value the user supplied for the constrained column.
    facts.value = user_value_for_column_with_schema(database, command, table, column)
        .await
        .map(|v| v.to_string());
    // The rule itself — the column's compiled CHECK expression.
    if let Ok(desc) = database.describe_table(table.to_string()).await
        && let Some(col) = desc.columns.iter().find(|c| c.name == column)
    {
        facts.check_rule.clone_from(&col.check);
    }
    facts
}

async fn enrich_unique_violation(
    database: &Database,
    command: &Command,
    message: &str,
) -> crate::friendly::FailureContext {
    let mut facts = crate::friendly::FailureContext::default();
    let Some((table, column)) = parse_qualified_target(message) else {
        return facts;
    };
    facts.table = Some(table.clone());
    facts.column = Some(column.clone());

    // Resolve the user's attempted value.
    let raw_value = user_value_for_column_with_schema(database, command, &table, &column).await;
    facts.value = raw_value.as_ref().map(ToString::to_string);

    // Pinpoint the existing conflicting row, capped per
    // ADR-0017 §7's `DIAGNOSTIC_ROW_CAP` (we use a tighter cap
    // here — a single conflicting row is the typical case for
    // UNIQUE since the constraint enforces it).
    if let Some(value) = raw_value
        && let Ok(data) = database
            .find_rows_matching(table.clone(), column.clone(), value, 5)
            .await
        && !data.rows.is_empty()
    {
        facts.diagnostic_table = Some(diagnostic_from_data_result(&data));
    }
    facts
}

fn enrich_not_null_violation(command: &Command, message: &str) -> crate::friendly::FailureContext {
    let mut facts = crate::friendly::FailureContext::default();
    let Some((table, column)) = parse_qualified_target(message) else {
        return facts;
    };
    facts.table = Some(table);
    facts.column = Some(column);
    // The "attempted value" for NOT NULL is by definition null —
    // surfacing it doesn't add information. Skip the value
    // resolution; the catalog headline reads "X cannot be null"
    // and stands on its own.
    let _ = command;
    facts
}

async fn enrich_fk_violation(
    database: &Database,
    command: &Command,
) -> crate::friendly::FailureContext {
    let mut facts = crate::friendly::FailureContext::default();
    match command {
        Command::Insert { table, .. } | Command::Update { table, .. } => {
            // Child-side: outbound FK lookup. Find the FK
            // column the user is setting / updating. Use the
            // schema-aware lookup so natural-order multi-value
            // INSERT (which `user_value_for_column` alone can't
            // resolve) gets handled too.
            let Ok((outbound, _)) = database.read_relationships(table.clone()).await else {
                return facts;
            };
            facts.table = Some(table.clone());
            for rel in outbound {
                // Identify the violated FK by the first local column the
                // user supplied a value for (SQLite names no column in the
                // error). The single-column facts slots then carry the
                // comma-joined lists so a compound FK (ADR-0043) names
                // *every* child->parent column pair, not just the first.
                let Some(first_local) = rel.local_columns.first().cloned() else {
                    continue;
                };
                let Some(first_val) =
                    user_value_for_column_with_schema(database, command, table, &first_local).await
                else {
                    continue;
                };
                // Matched. Gather the remaining pairs' values in order.
                let mut values = vec![first_val.to_string()];
                for local_col in rel.local_columns.iter().skip(1) {
                    if let Some(v) =
                        user_value_for_column_with_schema(database, command, table, local_col).await
                    {
                        values.push(v.to_string());
                    }
                }
                facts.column = Some(rel.local_columns.join(", "));
                facts.parent_table = Some(rel.other_table);
                facts.parent_column = Some(rel.other_columns.join(", "));
                facts.value = Some(values.join(", "));
                break;
            }
            // For UPDATE, if no outbound match was found we may
            // be in the parent-side case (updating a column
            // children reference). Check inbound as a fallback.
            if facts.parent_table.is_none()
                && matches!(command, Command::Update { .. })
                && let Ok((_, inbound)) = database.read_relationships(table.clone()).await
                && let Some(rel) = inbound.first()
            {
                facts.child_table = Some(rel.other_table.clone());
            }
        }
        Command::Delete { table, .. } => {
            // Parent-side: inbound FK lookup. Surface a child
            // table that still references the row(s) being
            // deleted.
            let Ok((_, inbound)) = database.read_relationships(table.clone()).await else {
                return facts;
            };
            facts.table = Some(table.clone());
            if let Some(rel) = inbound.first() {
                facts.child_table = Some(rel.other_table.clone());
            }
        }
        _ => {}
    }
    facts
}

/// Find the user's attempted value for `column` directly from
/// the originating Command. Handles INSERT (explicit columns,
/// single-value short form) and UPDATE (assignments). Returns
/// `None` for natural-order multi-value INSERT — that case
/// needs a schema lookup, see
/// [`user_value_for_column_with_schema`].
fn user_value_for_column(command: &Command, column: &str) -> Option<crate::dsl::Value> {
    match command {
        Command::Insert {
            columns, values, ..
        } => {
            if let Some(cols) = columns {
                let idx = cols.iter().position(|c| c == column)?;
                values.get(idx).cloned()
            } else if values.len() == 1 {
                values.first().cloned()
            } else {
                None
            }
        }
        Command::Update { assignments, .. } => assignments
            .iter()
            .find(|(c, _)| c == column)
            .map(|(_, v)| v.clone()),
        // ADR-0036 Phase 1: a single-row literal SQL INSERT retains its
        // captured literals, so a constraint error can name the real
        // value. Explicit column list resolves here (sync, no schema);
        // the natural-order (no-column-list) form is resolved in
        // `user_value_for_column_with_schema` (it needs the schema to map
        // position → column). Multi-row is skipped (which row conflicted
        // is ambiguous) — it degrades to the neutral "that value".
        Command::SqlInsert {
            listed_columns,
            literal_rows,
            ..
        } if literal_rows.len() == 1 && !listed_columns.is_empty() => {
            let idx = listed_columns.iter().position(|c| c == column)?;
            literal_rows[0].get(idx).cloned().flatten()
        }
        // ADR-0036 Phase 2: a SQL UPDATE retains its captured `SET`
        // literals, so a constraint error can name the real value.
        // Assignments are explicitly named, so (unlike SqlInsert) there is
        // no positional/multi-row ambiguity — mirror the DSL `Update` case.
        Command::SqlUpdate { set_literals, .. } => set_literals
            .iter()
            .find(|(c, _)| c == column)
            .and_then(|(_, v)| v.clone()),
        _ => None,
    }
}

/// Same as [`user_value_for_column`] but handles natural-order
/// multi-value INSERT by reading the schema to learn which
/// position belongs to which column. Mirrors `do_insert`'s
/// position-mapping rule (auto-generated columns — serial,
/// shortid — are skipped, since the user doesn't supply
/// values for them in the natural-order short form).
async fn user_value_for_column_with_schema(
    database: &Database,
    command: &Command,
    table: &str,
    column: &str,
) -> Option<crate::dsl::Value> {
    if let Some(v) = user_value_for_column(command, column) {
        return Some(v);
    }
    if let Command::Insert {
        columns: None,
        values,
        ..
    } = command
    {
        let desc = database.describe_table(table.to_string()).await.ok()?;
        // Build the natural-order column list the same way
        // `do_insert` does: filter out serial / shortid columns
        // because the engine auto-fills them and the user's
        // positional values map onto the remainder.
        let natural_cols: Vec<&str> = desc
            .columns
            .iter()
            .filter(|c| {
                !matches!(
                    c.user_type,
                    Some(crate::dsl::Type::Serial) | Some(crate::dsl::Type::ShortId)
                )
            })
            .map(|c| c.name.as_str())
            .collect();
        let idx = natural_cols.iter().position(|c| *c == column)?;
        return values.get(idx).cloned();
    }
    // ADR-0036 Phase 1 follow-up: a no-column-list (natural-order) SQL
    // INSERT also names the offending value. Each VALUES position maps to
    // the schema's columns in declaration order — and unlike the DSL
    // `Insert` short form above, ALL columns are mapped (advanced-mode
    // Form B auto-fills nothing, so the user supplies a value for every
    // column; `do_sql_insert` validates against the same full mapping).
    // Single-row only — which row of a multi-row insert conflicted is
    // ambiguous, so it degrades to the neutral "that value".
    if let Command::SqlInsert {
        listed_columns,
        literal_rows,
        ..
    } = command
        && listed_columns.is_empty()
        && literal_rows.len() == 1
    {
        let desc = database.describe_table(table.to_string()).await.ok()?;
        let idx = desc.columns.iter().position(|c| c.name == column)?;
        return literal_rows[0].get(idx).cloned().flatten();
    }
    None
}

/// Render a `DataResult` as a `DiagnosticTable` for the
/// friendly-error layer's bordered renderer (ADR-0019 §7,
/// reusing ADR-0017 §7's renderer).
fn diagnostic_from_data_result(data: &DataResult) -> crate::friendly::DiagnosticTable {
    use crate::output_render::{Alignment, numeric_alignment_for};
    let alignments: Vec<Alignment> = data
        .column_types
        .iter()
        .map(|t| t.map_or(Alignment::Left, numeric_alignment_for))
        .collect();
    let rows: Vec<Vec<String>> = data
        .rows
        .iter()
        .map(|r| {
            r.iter()
                .map(|c| c.clone().unwrap_or_else(|| "NULL".to_string()))
                .collect()
        })
        .collect();
    crate::friendly::DiagnosticTable {
        headers: data.columns.clone(),
        rows,
        alignments,
    }
}

/// Extract `(table, column)` from a qualified target like
/// `"UNIQUE constraint failed: T.col"`. Mirrors the helper in
/// `friendly::translate` (the translator does its own parse as
/// a fallback when enrichment didn't run).
fn parse_qualified_target(message: &str) -> Option<(String, String)> {
    let after = message.split_once(':').map(|(_, r)| r.trim())?;
    let first = after.split(',').next()?.trim();
    let mut parts = first.splitn(2, '.');
    let table = parts.next()?.trim();
    let column = parts.next()?.trim();
    if table.is_empty() || column.is_empty() {
        None
    } else {
        Some((table.to_string(), column.to_string()))
    }
}

enum CommandOutcome {
    Schema(Option<TableDescription>),
    /// A SQL `CREATE TABLE IF NOT EXISTS` that matched an existing
    /// table — a no-op (ADR-0035 §4). Carries the existing structure
    /// so the App can render it alongside the "already exists —
    /// skipped" note.
    SchemaSkipped(TableDescription),
    /// A SQL `DROP TABLE IF EXISTS` that matched no table — a no-op
    /// (ADR-0035 §4, 4c). Carries no structure (there is none); the App
    /// renders the "doesn't exist — skipped" note from the command.
    SchemaDropSkipped,
    /// A SQL `DROP INDEX IF EXISTS` that matched no index — a no-op
    /// (ADR-0035 §4d). The App renders the "doesn't exist — skipped"
    /// note from the command's index name.
    SchemaDropIndexSkipped,
    /// A SQL `CREATE INDEX IF NOT EXISTS` that matched an existing index
    /// name — a no-op (ADR-0035 §4d). Carries the resolved index name
    /// (the auto-name is unknown to the command) for the "already exists
    /// — skipped" note.
    SchemaCreateIndexSkipped(String),
    Query(DataResult),
    /// A `show <kind>` list (V5) — pre-formatted display lines from
    /// the worker (table / relationship / index names). Pure
    /// display, no schema change.
    ShowList(Vec<String>),
    /// Structured data for one relationship's diagram (ADR-0044),
    /// rendered App-side; `None` when the named relationship is absent.
    /// Boxed — two full `TableDescription`s dwarf the other variants.
    ShowRelationship(Option<Box<crate::db::RelationshipDiagramData>>),
    QueryPlan(QueryPlan),
    Insert(InsertResult),
    Seed(crate::db::SeedResult),
    Update(UpdateResult),
    Delete(DeleteResult),
    ChangeColumn(ChangeColumnTypeResult),
    AddColumn(AddColumnResult),
    DropColumn(DropColumnResult),
}

/// Spawn a task that reads a script file and dispatches each
/// non-blank, non-comment line through the same `execute_command_typed`
/// pipeline as interactive input.
///
/// `path` is the literal user-typed argument; relative paths are
/// resolved against `project_root`. On the first per-line failure
/// the task posts `AppEvent::ReplayFailed` carrying the line
/// number and stops — previously dispatched commands stay applied
/// (no rollback). On clean completion the task posts
/// `AppEvent::ReplayCompleted` with the count of commands that
/// were actually run (blanks/comments excluded).
///
/// The replay invocation itself is NOT written to `history.log`
/// (that happens at the App level, where Replay is dispatched as
/// `Action::Replay` rather than `Action::ExecuteDsl`); only the
/// individual sub-commands land there, since they are what
/// actually mutate state.
fn spawn_replay(
    database: Database,
    project_root: PathBuf,
    path: String,
    event_tx: mpsc::Sender<AppEvent>,
) {
    tokio::spawn(async move {
        let events = run_replay(&database, &project_root, &path).await;
        for event in events {
            if event_tx.send(event).await.is_err() {
                return;
            }
        }
        // Refresh the table list once at the end — every command
        // dispatched through `execute_command_typed` may have
        // altered the schema, but we don't want to flicker the
        // panel mid-replay (and the table list is a derived view
        // anyway).
        match database.list_tables().await {
            Ok(tables) => {
                let _ = event_tx.send(AppEvent::TablesRefreshed(tables)).await;
            }
            Err(e) => warn!(error = %e, "post-replay list_tables failed"),
        }
        refresh_schema_cache(&database, &event_tx).await;
    });
}

/// Inner replay loop, separated from the spawn wrapper so tests
/// can exercise the file-iteration / parse / dispatch logic
/// without an mpsc channel and a spawned task.
///
/// Returns the `AppEvent`s that the spawn wrapper would emit, in
/// order. Always returns at least one terminal event
/// (`ReplayCompleted`, `ReplayFailed`, or `PersistenceFatal`).
pub async fn run_replay(
    database: &Database,
    project_root: &std::path::Path,
    path: &str,
) -> Vec<AppEvent> {
    let mut events: Vec<AppEvent> = Vec::new();
    let resolved = resolve_replay_path(project_root, path);
    let body = match tokio::fs::read_to_string(&resolved).await {
        Ok(b) => b,
        Err(e) => {
            events.push(AppEvent::ReplayFailed {
                path: path.to_string(),
                line_number: 0,
                command: String::new(),
                error: crate::t!(
                    "replay.error_could_not_open",
                    path = resolved.display(),
                    detail = e
                ),
            });
            return events;
        }
    };

    let mut count: usize = 0;
    let mut warnings: Vec<String> = Vec::new();
    for (idx, raw) in body.lines().enumerate() {
        let line_number = idx + 1;
        let trimmed = raw.trim();
        if trimmed.is_empty() || trimmed.starts_with('#') {
            continue;
        }
        // ADR-0034 §3: a journal record (`<ts>|<status>|<source>`)
        // contributes its extracted source when `ok` and is skipped
        // otherwise; any other line is a bare command run verbatim.
        // This is what makes `replay history.log` work without
        // breaking hand-written `.commands` scripts.
        let command_text = match crate::persistence::classify_replay_line(trimmed) {
            crate::persistence::ReplayLine::Skip => continue,
            crate::persistence::ReplayLine::Run(text) => text,
        };
        // ADR-0034 Amendment 1: classify by entry word BEFORE parsing.
        // App-lifecycle commands and a nested `replay` are skipped
        // during replay — they are session navigation, not schema/data
        // reconstruction (and the worker dispatch cannot run them).
        // Classifying by the leading word handles the modal forms
        // (`save as` / `load` / `new`) and any incomplete app form
        // uniformly: ALL such skips continue, never aborting the
        // replay. `import` and a nested `replay` warn, because skipping
        // them can leave the replayed state incomplete (imported data /
        // the nested file's commands are not reconstructed); the rest
        // skip silently. Skipping a nested `replay` also closes the
        // infinite-loop footgun by construction.
        let entry = command_text
            .split_whitespace()
            .next()
            .unwrap_or("")
            .to_ascii_lowercase();
        if entry == "import" {
            warnings.push(crate::t!(
                "replay.skipped_import",
                line = line_number,
                command = command_text
            ));
            continue;
        }
        if entry == "replay" {
            warnings.push(crate::t!(
                "replay.skipped_replay",
                line = line_number,
                command = command_text
            ));
            continue;
        }
        if is_app_lifecycle_entry_word(&entry) {
            continue;
        }
        // A schema/data write (or read, or a genuine typo). Parse with
        // the live schema — re-snapshotted every line because earlier
        // replayed commands mutate it (so Phase D typed-slot rejections
        // fire at parse time, matching the interactive path). A parse
        // failure here is a genuine malformed command (not an app
        // command, which was skipped above) — report it with the line
        // number and stop.
        let schema = build_schema_cache(database).await;
        let command = match crate::dsl::parser::parse_command_with_schema(&command_text, &schema) {
            Ok(c) => c,
            Err(e) => {
                events.push(AppEvent::ReplayFailed {
                    path: path.to_string(),
                    line_number,
                    command: command_text.clone(),
                    error: crate::t!("replay.error_parse", detail = e),
                });
                return events;
            }
        };

        // Dispatch through the same path as interactive input so
        // per-command persistence (history.log, project.yaml,
        // CSVs) fires as if the user had typed each line. The
        // source re-journalled is the *extracted* command, not the
        // raw `<ts>|ok|…` record (ADR-0034 §3).
        // Retain a clone for failure enrichment (the command is moved into
        // dispatch). ADR-0035 Amendment 1, F2 follow-up.
        let command_for_ctx = command.clone();
        let outcome = execute_command_typed(database, command, command_text.clone()).await;
        match outcome {
            Ok(_) => {
                // ADR-0052: journal the replayed line at the dispatch
                // layer (the worker no longer journals). Replay is
                // mode-agnostic, so the re-written record is tagged
                // simple; best-effort, like the interactive path.
                if let Err(e) = crate::persistence::Persistence::new(project_root.to_path_buf())
                    .append_history(&command_text, false)
                {
                    warn!(error = %e, "failed to journal replayed line (ignored)");
                }
                count += 1;
            }
            Err(DbError::PersistenceFatal {
                operation,
                path: pf_path,
                message,
            }) => {
                // Persistence-fatal escalates through the existing
                // fatal-banner channel; the runtime tears down on
                // it, so further replay progress is moot.
                events.push(AppEvent::PersistenceFatal {
                    operation: operation.to_string(),
                    path: pf_path,
                    message,
                });
                return events;
            }
            Err(e) => {
                // Enrich like the interactive path (ADR-0019 §6) so a
                // replayed failing command shows the real table/column/
                // value instead of a contextless, `{name}`-leaking message
                // (ADR-0035 Amendment 1, F2 follow-up). Verbose to match
                // the prior `friendly_message()` rendering.
                let facts = enrich_dsl_failure(database, &command_for_ctx, &e).await;
                let ctx = crate::app::App::translate_context_for(
                    &command_for_ctx,
                    facts,
                    crate::friendly::Verbosity::default(),
                );
                events.push(AppEvent::ReplayFailed {
                    path: path.to_string(),
                    line_number,
                    command: command_text.clone(),
                    error: crate::friendly::translate_error(&e, &ctx).render(),
                });
                return events;
            }
        }
    }

    events.push(AppEvent::ReplayCompleted {
        path: path.to_string(),
        count,
        warnings,
    });
    events
}

/// True when `entry` (a lowercased leading command word) is an
/// app-lifecycle command that replay skips (ADR-0034 Amendment 1).
/// `import` and `replay` are handled separately by the caller
/// (they warn); this is the silent-skip set. Mirrors the
/// `AppCommand` entry words (see `src/completion.rs`'s
/// `empty_input_offers_app_command_entry_keywords`); both must list
/// the same words. A new `AppCommand` must be added here so replay
/// skips it rather than aborting. `q` is intentionally absent: it is
/// not a recognised command, so a `q` line is a genuine error.
fn is_app_lifecycle_entry_word(entry: &str) -> bool {
    matches!(
        entry,
        "save"
            | "load"
            | "new"
            | "export"
            | "mode"
            | "messages"
            | "rebuild"
            | "help"
            | "quit"
            | "undo"
            | "redo"
    )
}

/// Resolve a `replay <path>` argument: absolute paths pass
/// through unchanged; relative paths are joined under the active
/// project's root so `replay history.log` works without ceremony
/// from inside any project.
fn resolve_replay_path(project_root: &std::path::Path, path: &str) -> PathBuf {
    let p = PathBuf::from(path);
    if p.is_absolute() {
        p
    } else {
        project_root.join(p)
    }
}

/// Execute a parsed user command and return either a typed
/// `CommandOutcome` or the raw `DbError`. Keeping the typed
/// error here lets us distinguish persistence-fatal failures
/// from ordinary user errors at dispatch time (ADR-0015 §8).
async fn execute_command_typed(
    database: &Database,
    command: Command,
    source: String,
) -> Result<CommandOutcome, DbError> {
    debug!(verb = command.verb(), "execute command (routing to worker)");
    let src = Some(source);
    match command {
        Command::CreateTable {
            name,
            columns,
            primary_key,
        } => database
            .create_table(name, columns, primary_key, src)
            .await
            .map(|d| CommandOutcome::Schema(Some(d))),
        Command::SqlCreateTable {
            name,
            columns,
            primary_key,
            unique_constraints,
            check_constraints,
            foreign_keys,
            if_not_exists,
        } => database
            .sql_create_table(
                name,
                columns,
                primary_key,
                unique_constraints,
                check_constraints,
                foreign_keys,
                if_not_exists,
                src,
            )
            .await
            .map(|outcome| match outcome {
                CreateOutcome::Created(d) => CommandOutcome::Schema(Some(d)),
                CreateOutcome::Skipped(d) => CommandOutcome::SchemaSkipped(d),
            }),
        Command::DropTable { name } => database
            .drop_table(name, src)
            .await
            .map(|()| CommandOutcome::Schema(None)),
        Command::SqlDropTable { name, if_exists } => database
            .sql_drop_table(name, if_exists, src)
            .await
            .map(|outcome| match outcome {
                DropOutcome::Dropped => CommandOutcome::Schema(None),
                DropOutcome::Skipped => CommandOutcome::SchemaDropSkipped,
            }),
        Command::AddColumn {
            table,
            column,
            ty,
            not_null,
            unique,
            default,
            check,
        } => database
            .add_column(
                table,
                ColumnSpec {
                    name: column,
                    ty,
                    not_null,
                    unique,
                    default,
                    check,
                    default_sql: None,
                    check_sql: None,
                },
                src,
            )
            .await
            .map(CommandOutcome::AddColumn),
        Command::DropColumn {
            table,
            column,
            cascade,
        } => database
            .drop_column(table, column, cascade, src)
            .await
            .map(CommandOutcome::DropColumn),
        Command::RenameColumn { table, old, new } => database
            .rename_column(table, old, new, src)
            .await
            .map(|d| CommandOutcome::Schema(Some(d))),
        Command::ChangeColumnType {
            table,
            column,
            ty,
            mode,
        } => database
            .change_column_type(table, column, ty, mode, src)
            .await
            .map(CommandOutcome::ChangeColumn),
        Command::AddRelationship {
            name,
            parent_table,
            parent_columns,
            child_table,
            child_columns,
            on_delete,
            on_update,
            create_fk,
        } => database
            .add_relationship(
                name,
                parent_table,
                parent_columns,
                child_table,
                child_columns,
                on_delete,
                on_update,
                create_fk,
                src,
            )
            .await
            .map(|d| CommandOutcome::Schema(Some(d))),
        Command::CreateM2nRelationship { t1, t2, name } => database
            .create_m2n_relationship(t1, t2, name, src)
            .await
            .map(|d| CommandOutcome::Schema(Some(d))),
        Command::DropRelationship { selector } => database
            .drop_relationship(selector, src)
            .await
            .map(CommandOutcome::Schema),
        Command::AddIndex {
            name,
            table,
            columns,
        } => database
            .add_index(name, table, columns, src)
            .await
            .map(|d| CommandOutcome::Schema(Some(d))),
        Command::DropIndex { selector } => database
            .drop_index(selector, src)
            .await
            .map(|d| CommandOutcome::Schema(Some(d))),
        Command::SqlDropIndex { name, if_exists } => database
            .sql_drop_index(name, if_exists, src)
            .await
            .map(|outcome| match outcome {
                // Auto-show the now de-indexed table (ADR-0014), unlike
                // SQL DROP TABLE whose table is gone.
                DropIndexOutcome::Dropped(d) => CommandOutcome::Schema(Some(d)),
                DropIndexOutcome::Skipped => CommandOutcome::SchemaDropIndexSkipped,
            }),
        Command::SqlCreateIndex {
            name,
            table,
            columns,
            unique,
            if_not_exists,
        } => database
            .sql_create_index(name, table, columns, unique, if_not_exists, src)
            .await
            .map(|outcome| match outcome {
                CreateIndexOutcome::Created(d) => CommandOutcome::Schema(Some(d)),
                CreateIndexOutcome::Skipped(n) => CommandOutcome::SchemaCreateIndexSkipped(n),
            }),
        // `ALTER TABLE` (ADR-0035 §4e) decomposes to the existing column
        // executors — each already one snapshot (one undo step) and
        // journalled. No new worker layer; the outcomes reuse the
        // simple-mode add/drop/rename column paths.
        Command::SqlAlterTable { table, action } => match action {
            AlterTableAction::AddColumn(spec) => database
                .add_column(table, *spec, src)
                .await
                .map(CommandOutcome::AddColumn),
            // cascade = false: an index-covered column is refused (no SQL
            // `--cascade` spelling), matching SQLite + the simple default.
            AlterTableAction::DropColumn { column } => database
                .drop_column(table, column, false, src)
                .await
                .map(CommandOutcome::DropColumn),
            AlterTableAction::RenameColumn { old, new } => database
                .rename_column(table, old, new, src)
                .await
                .map(|d| CommandOutcome::Schema(Some(d))),
            // `ALTER COLUMN … TYPE` reuses the simple `change column`
            // executor with `ForceConversion` — the ADR-0035 §7
            // advanced policy (lossy converts with a note; no force
            // flag; static-refused / incompatible still refuse). The
            // ChangeColumn outcome surfaces the client-side lossy note,
            // shared with simple mode.
            AlterTableAction::AlterColumnType { column, ty } => database
                .change_column_type(table, column, ty, ChangeColumnMode::ForceConversion, src)
                .await
                .map(CommandOutcome::ChangeColumn),
            // ADR-0035 Amendment 2: ALTER COLUMN constraint gap-fill.
            // SET/DROP NOT NULL and DROP DEFAULT reuse the ADR-0029
            // executors; SET DEFAULT needs the raw-SQL path (sql_expr has
            // no typed Value).
            AlterTableAction::SetColumnNotNull { column } => database
                .add_constraint(table, column, Constraint::NotNull, src)
                .await
                .map(|d| CommandOutcome::Schema(Some(d))),
            AlterTableAction::DropColumnNotNull { column } => database
                .drop_constraint(table, column, ConstraintKind::NotNull, src)
                .await
                .map(|d| CommandOutcome::Schema(Some(d))),
            AlterTableAction::SetColumnDefault {
                column,
                default_sql,
            } => database
                .set_column_default(table, column, default_sql, src)
                .await
                .map(|d| CommandOutcome::Schema(Some(d))),
            AlterTableAction::DropColumnDefault { column } => database
                .drop_constraint(table, column, ConstraintKind::Default, src)
                .await
                .map(|d| CommandOutcome::Schema(Some(d))),
            // `ADD [CONSTRAINT <name>] (CHECK | UNIQUE | FOREIGN KEY)`
            // (ADR-0035 §4g) — each reuses an existing low-level executor
            // (the FK via the relationship machinery `add 1:n
            // relationship` uses); one undo step each.
            AlterTableAction::AddTableConstraint { name, constraint } => match *constraint {
                TableConstraint::Check { expr_sql } => database
                    .alter_add_table_check(table, name, expr_sql, src)
                    .await
                    .map(|d| CommandOutcome::Schema(Some(d))),
                TableConstraint::Unique { columns } => database
                    .alter_add_unique(table, columns, src)
                    .await
                    .map(|d| CommandOutcome::Schema(Some(d))),
                TableConstraint::ForeignKey(fk) => database
                    .alter_add_foreign_key(table, name, fk, src)
                    .await
                    .map(|d| CommandOutcome::Schema(Some(d))),
            },
            // `DROP CONSTRAINT <name>` — a named table-CHECK or a named
            // FK, resolved by the executor (ADR-0035 §4g).
            AlterTableAction::DropConstraint { name } => database
                .alter_drop_constraint(table, name, src)
                .await
                .map(CommandOutcome::Schema),
            // `RENAME TO <new>` — the one genuinely new low-level op
            // (ADR-0035 §6, 4h): native table rename + CSV + metadata
            // reconciliation, one undo step.
            AlterTableAction::RenameTable { new } => database
                .rename_table(table, new, src)
                .await
                .map(|d| CommandOutcome::Schema(Some(d))),
        },
        Command::AddConstraint {
            table,
            column,
            constraint,
        } => database
            .add_constraint(table, column, constraint, src)
            .await
            .map(|d| CommandOutcome::Schema(Some(d))),
        Command::DropConstraint {
            table,
            column,
            kind,
        } => database
            .drop_constraint(table, column, kind, src)
            .await
            .map(|d| CommandOutcome::Schema(Some(d))),
        Command::ShowTable { name } => database
            .describe_table(name)
            .await
            .map(|d| CommandOutcome::Schema(Some(d))),
        // ADR-0044: a named relationship renders as a diagram (App-side),
        // so it returns structured data; every other `show <kind>` form
        // stays the worker-formatted prose list.
        Command::ShowList {
            kind: crate::dsl::command::ShowListKind::Relationships,
            name: Some(name),
        } => database
            .show_relationship(name)
            .await
            .map(|opt| CommandOutcome::ShowRelationship(opt.map(Box::new))),
        Command::ShowList { kind, name } => database
            .show_list(kind, name)
            .await
            .map(CommandOutcome::ShowList),
        Command::Insert {
            table,
            columns,
            values,
        } => database
            .insert(table, columns, values, src)
            .await
            .map(CommandOutcome::Insert),
        // ADR-0048 (SD1/SD2 Phase 2).
        Command::Seed {
            table,
            target_column,
            count,
            overrides,
            rng_seed,
        } => database
            .seed(table, target_column, count, overrides, rng_seed, src)
            .await
            .map(CommandOutcome::Seed),
        Command::Update {
            table,
            assignments,
            filter,
        } => database
            .update(table, assignments, filter, src)
            .await
            .map(CommandOutcome::Update),
        Command::Delete { table, filter } => database
            .delete(table, filter, src)
            .await
            .map(CommandOutcome::Delete),
        Command::ShowData {
            name,
            filter,
            limit,
        } => database
            .query_data(name, filter, limit)
            .await
            .map(CommandOutcome::Query),
        // A SQL `SELECT` (advanced mode; ADR-0030 §6, ADR-0031).
        // The grammar walker has already validated `sql` is in
        // the supported subset; the worker runs it as text.
        Command::Select { sql } => database.run_select(sql).await.map(CommandOutcome::Query),
        // A SQL `INSERT` (advanced mode; ADR-0033 §1). Grammar-as-
        // text: the worker runs the validated `sql` and re-persists
        // the parsed `target_table`'s CSV. Reuses the DSL insert
        // outcome (affected-row count + auto-show).
        Command::SqlInsert {
            sql,
            target_table,
            listed_columns,
            row_source,
            returning,
            literal_rows,
        } => database
            .run_sql_insert_with_literals(
                sql,
                src,
                target_table,
                listed_columns,
                row_source,
                returning,
                literal_rows,
            )
            .await
            .map(CommandOutcome::Insert),
        // A SQL `UPDATE` (advanced mode; ADR-0033 §2). Grammar-as-
        // text: the worker runs the validated `sql` and re-persists
        // the parsed `target_table`'s CSV. Reuses the DSL update
        // outcome (affected-row count).
        Command::SqlUpdate {
            sql,
            target_table,
            returning,
            set_literals,
        } => database
            .run_sql_update_with_literals(sql, src, target_table, returning, set_literals)
            .await
            .map(CommandOutcome::Update),
        // A SQL `DELETE` (advanced mode; ADR-0033 §1/§7). Grammar-
        // as-text: the worker runs the validated `sql`, detects FK
        // cascade by row-count diffing, and re-persists the target
        // plus every cascade-affected child. Reuses the DSL delete
        // outcome (affected-row count + per-relationship cascade
        // summary).
        Command::SqlDelete {
            sql,
            target_table,
            returning,
        } => database
            .run_sql_delete(sql, src, target_table, returning)
            .await
            .map(CommandOutcome::Delete),
        // `EXPLAIN QUERY PLAN` never executes the wrapped
        // statement (ADR-0028 §2), so explaining a destructive
        // command is safe. `src` is unused here — explain is a
        // diagnostic and is not written to `history.log`.
        Command::Explain { query } => database
            .explain_query_plan(*query)
            .await
            .map(CommandOutcome::QueryPlan),
        // `replay` is parsed as a DSL command but routed by
        // App::dispatch_dsl as `Action::Replay` rather than
        // `Action::ExecuteDsl`; it never reaches the worker
        // thread. Hitting this arm would mean the dispatch
        // routing was bypassed.
        Command::Replay { .. } => unreachable!(
            "Command::Replay is dispatched as Action::Replay; \
             reaching execute_command_typed indicates a routing bug"
        ),
        // App-lifecycle commands are dispatched in App, not by
        // the database worker. Hitting this arm would mean the
        // dispatch routing was bypassed.
        Command::App(_) => unreachable!(
            "Command::App is dispatched via App::dispatch_app_command; \
             reaching execute_command_typed indicates a routing bug"
        ),
    }
}

fn spawn_event_reader(tx: mpsc::Sender<AppEvent>) -> tokio::task::JoinHandle<()> {
    tokio::spawn(async move {
        let mut stream = EventStream::new();
        while let Some(maybe_event) = stream.next().await {
            match maybe_event {
                Ok(CtEvent::Key(key)) => {
                    if tx.send(AppEvent::Key(key)).await.is_err() {
                        break;
                    }
                }
                Ok(CtEvent::Resize(cols, rows)) => {
                    if tx.send(AppEvent::Resize { cols, rows }).await.is_err() {
                        break;
                    }
                }
                Ok(_) => {
                    // Ignore other event kinds (paste, focus, mouse) for now.
                }
                Err(e) => {
                    error!(error = %e, "crossterm event stream error");
                    break;
                }
            }
        }
        debug!("event reader exiting");
    })
}

fn setup_terminal() -> Result<Terminal<CrosstermBackend<io::Stdout>>> {
    enable_raw_mode().context("enable raw mode")?;
    let mut stdout = io::stdout();
    // Mouse capture is intentionally NOT enabled: it would prevent the
    // host terminal's native text selection (the cost of capturing every
    // mouse event), which we don't currently use for anything in-app.
    // If we ever want click-to-select panes or scroll wheel handling,
    // we'll need a different strategy than blanket capture.
    execute!(stdout, EnterAlternateScreen).context("enter alternate screen")?;
    let backend = CrosstermBackend::new(stdout);
    let terminal = Terminal::new(backend).context("construct terminal")?;
    Ok(terminal)
}

fn teardown_terminal(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
    disable_raw_mode().context("disable raw mode")?;
    execute!(terminal.backend_mut(), LeaveAlternateScreen).context("leave alternate screen")?;
    terminal.show_cursor().context("show cursor")?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::{IndicatorDebounce, nearest_deadline};
    use crate::dsl::walker::Severity;
    use std::time::{Duration, Instant};

    #[test]
    fn nearest_deadline_picks_the_soonest_or_none() {
        let now = Instant::now();
        let soon = now + Duration::from_millis(100);
        let later = now + Duration::from_millis(500);
        // Neither armed ⇒ block (None).
        assert_eq!(nearest_deadline(None, None), None);
        // One armed ⇒ that one, regardless of order.
        assert_eq!(nearest_deadline(Some(soon), None), Some(soon));
        assert_eq!(nearest_deadline(None, Some(soon)), Some(soon));
        // Both armed ⇒ the soonest, regardless of order.
        assert_eq!(nearest_deadline(Some(soon), Some(later)), Some(soon));
        assert_eq!(nearest_deadline(Some(later), Some(soon)), Some(soon));
    }

    #[test]
    fn starts_hidden_and_disarmed() {
        let d = IndicatorDebounce::default();
        assert_eq!(d.visible(), None);
        assert!(!d.is_armed());
    }

    #[test]
    fn a_keystroke_hides_the_indicator_and_arms_the_debounce() {
        let mut d = IndicatorDebounce::default();
        d.settle(Some(Severity::Error));
        assert_eq!(d.visible(), Some(Severity::Error));
        d.note_event(true);
        assert_eq!(d.visible(), None, "a keystroke hides the indicator");
        assert!(d.is_armed(), "a keystroke arms the debounce");
    }

    #[test]
    fn settling_shows_the_verdict_and_disarms() {
        let mut d = IndicatorDebounce::default();
        d.note_event(true);
        assert!(d.is_armed());
        d.settle(Some(Severity::Warning));
        assert_eq!(d.visible(), Some(Severity::Warning));
        assert!(!d.is_armed(), "a settled indicator owes no recompute");
    }

    #[test]
    fn settling_a_clean_verdict_shows_nothing() {
        let mut d = IndicatorDebounce::default();
        d.note_event(true);
        d.settle(None);
        assert_eq!(d.visible(), None);
        assert!(!d.is_armed());
    }

    #[test]
    fn a_non_key_event_does_not_disturb_a_shown_indicator() {
        let mut d = IndicatorDebounce::default();
        d.settle(Some(Severity::Error));
        d.note_event(false);
        assert_eq!(
            d.visible(),
            Some(Severity::Error),
            "a non-key event leaves the indicator shown",
        );
        assert!(!d.is_armed(), "a non-key event does not arm the debounce");
    }

    #[test]
    fn a_non_key_event_while_armed_keeps_the_debounce_armed() {
        // A background event (a DSL result, a tables refresh)
        // arriving mid-typing must not cancel the pending
        // recompute.
        let mut d = IndicatorDebounce::default();
        d.note_event(true);
        assert!(d.is_armed());
        d.note_event(false);
        assert!(d.is_armed(), "the owed recompute survives a non-key event");
        assert_eq!(d.visible(), None, "and the indicator stays hidden");
    }

    #[test]
    fn typing_resumes_after_a_settle() {
        // The full cycle: type → settle → type again → settle.
        let mut d = IndicatorDebounce::default();
        d.note_event(true);
        d.settle(Some(Severity::Warning));
        assert_eq!(d.visible(), Some(Severity::Warning));
        d.note_event(true);
        assert_eq!(d.visible(), None, "new typing hides the indicator again");
        assert!(d.is_armed());
        d.settle(None);
        assert_eq!(d.visible(), None);
        assert!(!d.is_armed());
    }

    // --- ADR-0038: the `show data` teaching echo's primary-key sourcing ---

    /// End-to-end cover for `build_show_data_echo` against a real worker:
    /// the limited `show data` echo orders by the table's primary key,
    /// resolved from the schema post-execution (handoff §5 / ADR-0038 §4).
    /// The pure renderer is unit-tested in `echo`; this pins the describe →
    /// PK → `ORDER BY` glue, plus the simple-mode gate and the
    /// unlimited-no-lookup path.
    #[tokio::test]
    async fn show_data_echo_orders_by_resolved_primary_key_when_limited() {
        use crate::app::EffectiveMode;
        use crate::db::Database;
        use crate::dsl::Command;
        use crate::dsl::command::ColumnSpec;
        use crate::dsl::types::Type;

        let db = Database::open(":memory:").expect("open in-memory");
        db.create_table(
            "Customers".to_string(),
            vec![
                ColumnSpec::new("id", Type::Serial),
                ColumnSpec::new("name", Type::Text),
            ],
            vec!["id".to_string()],
            None,
        )
        .await
        .expect("create table");

        let limited = Command::ShowData {
            name: "Customers".to_string(),
            filter: None,
            limit: Some(5),
        };
        // Limited → ORDER BY the resolved primary key.
        assert_eq!(
            super::build_show_data_echo(&db, &limited, EffectiveMode::AdvancedPersistent).await,
            Some(vec![
                "SELECT * FROM Customers ORDER BY id LIMIT 5".to_string()
            ]),
        );
        // Simple mode → silent, gated before any lookup.
        assert_eq!(
            super::build_show_data_echo(&db, &limited, EffectiveMode::Simple).await,
            None,
        );
        // Unlimited → no describe, no ORDER BY.
        let unlimited = Command::ShowData {
            name: "Customers".to_string(),
            filter: None,
            limit: None,
        };
        assert_eq!(
            super::build_show_data_echo(&db, &unlimited, EffectiveMode::AdvancedPersistent).await,
            Some(vec!["SELECT * FROM Customers".to_string()]),
        );
    }

    /// End-to-end cover for the Bucket B resolved-name echoes (ADR-0038
    /// §7) against a real worker: `add`/`drop index` (auto-named) and
    /// `add`/`drop relationship`. The pure renderers are unit-tested in
    /// `echo`; this pins the runtime glue — `collect_drop_lookups`
    /// (pre-execution, for drops) and `build_schema_echo` (post-execution
    /// for adds, post-pre-exec for drops) — both for adds (description
    /// lookup) and drops (pre-execution lookup including the named-
    /// selector child-table scan).
    #[tokio::test]
    async fn bucket_b_resolved_name_echoes_against_real_worker() {
        use crate::app::EffectiveMode;
        use crate::db::Database;
        use crate::dsl::Command;
        use crate::dsl::ReferentialAction;
        use crate::dsl::command::{ColumnSpec, IndexSelector, RelationshipSelector};
        use crate::dsl::types::Type;

        let db = Database::open(":memory:").expect("open in-memory");
        db.create_table(
            "Customers".to_string(),
            vec![
                ColumnSpec::new("id", Type::Serial),
                ColumnSpec::new("Email", Type::Text),
            ],
            vec!["id".to_string()],
            None,
        )
        .await
        .expect("create Customers");
        db.create_table(
            "Orders".to_string(),
            vec![
                ColumnSpec::new("id", Type::Serial),
                ColumnSpec::new("CustId", Type::Int),
            ],
            vec!["id".to_string()],
            None,
        )
        .await
        .expect("create Orders");

        // --- add index (auto-named) ----------------------------------
        let desc_after_add_index = db
            .add_index(
                None,
                "Customers".to_string(),
                vec!["Email".to_string()],
                None,
            )
            .await
            .expect("add index");
        let add_idx_cmd = Command::AddIndex {
            name: None,
            table: "Customers".to_string(),
            columns: vec!["Email".to_string()],
        };
        assert_eq!(
            super::build_schema_echo(
                &add_idx_cmd,
                EffectiveMode::AdvancedPersistent,
                Some(&desc_after_add_index),
                &super::EchoLookups::default(),
            ),
            Some(vec![
                "CREATE INDEX Customers_Email_idx ON Customers (Email)".to_string()
            ]),
            "auto-named index resolved from post-exec description",
        );

        // --- drop index (positional) — pre-exec lookup ---------------
        let drop_idx_cmd = Command::DropIndex {
            selector: IndexSelector::Columns {
                table: "Customers".to_string(),
                columns: vec!["Email".to_string()],
            },
        };
        let drop_idx_lookups =
            super::collect_echo_lookups(&db, &drop_idx_cmd, EffectiveMode::AdvancedPersistent)
                .await;
        assert_eq!(
            drop_idx_lookups.drop_index_name.as_deref(),
            Some("Customers_Email_idx"),
            "drop-index pre-exec lookup finds the index by column set",
        );
        let desc_after_drop_idx = db
            .drop_index(
                IndexSelector::Columns {
                    table: "Customers".to_string(),
                    columns: vec!["Email".to_string()],
                },
                None,
            )
            .await
            .expect("drop index");
        assert_eq!(
            super::build_schema_echo(
                &drop_idx_cmd,
                EffectiveMode::AdvancedPersistent,
                Some(&desc_after_drop_idx),
                &drop_idx_lookups,
            ),
            Some(vec!["DROP INDEX Customers_Email_idx".to_string()]),
        );

        // Simple mode → no lookup, no echo.
        assert!(
            super::collect_echo_lookups(&db, &drop_idx_cmd, EffectiveMode::Simple)
                .await
                .drop_index_name
                .is_none(),
            "simple-mode gate skips the pre-exec describe",
        );

        // --- add relationship (auto-named) ---------------------------
        let desc_after_add_rel = db
            .add_relationship(
                None,
                "Customers".to_string(),
                vec!["id".to_string()],
                "Orders".to_string(),
                vec!["CustId".to_string()],
                ReferentialAction::Cascade,
                ReferentialAction::NoAction,
                false,
                None,
            )
            .await
            .expect("add relationship");
        let add_rel_cmd = Command::AddRelationship {
            name: None,
            parent_table: "Customers".to_string(),
            parent_columns: vec!["id".to_string()],
            child_table: "Orders".to_string(),
            child_columns: vec!["CustId".to_string()],
            on_delete: ReferentialAction::Cascade,
            on_update: ReferentialAction::NoAction,
            create_fk: false,
        };
        assert_eq!(
            super::build_schema_echo(
                &add_rel_cmd,
                EffectiveMode::AdvancedPersistent,
                Some(&desc_after_add_rel),
                &super::EchoLookups::default(),
            ),
            Some(vec![
                "ALTER TABLE Orders ADD CONSTRAINT Customers_id_to_Orders_CustId FOREIGN KEY (CustId) REFERENCES Customers (id) ON DELETE CASCADE".to_string()
            ]),
            "auto-named relationship resolved from parent's inbound_relationships",
        );

        // --- drop relationship by endpoints — pre-exec lookup --------
        let drop_rel_endpoints = Command::DropRelationship {
            selector: RelationshipSelector::Endpoints {
                parent_table: "Customers".to_string(),
                parent_column: "id".to_string(),
                child_table: "Orders".to_string(),
                child_column: "CustId".to_string(),
            },
        };
        let endpoints_lookups = super::collect_echo_lookups(
            &db,
            &drop_rel_endpoints,
            EffectiveMode::AdvancedPersistent,
        )
        .await;
        assert_eq!(
            endpoints_lookups.drop_relationship,
            Some((
                "Customers_id_to_Orders_CustId".to_string(),
                "Orders".to_string()
            )),
            "endpoints selector resolves name via child describe",
        );

        // --- drop relationship by name — child-table scan ------------
        let drop_rel_named = Command::DropRelationship {
            selector: RelationshipSelector::Named {
                name: "Customers_id_to_Orders_CustId".to_string(),
            },
        };
        let named_lookups =
            super::collect_echo_lookups(&db, &drop_rel_named, EffectiveMode::AdvancedPersistent)
                .await;
        assert_eq!(
            named_lookups.drop_relationship,
            Some((
                "Customers_id_to_Orders_CustId".to_string(),
                "Orders".to_string()
            )),
            "named selector scans user tables to find the child",
        );

        // Either selector → same echo.
        for (cmd, lookups) in [
            (&drop_rel_endpoints, &endpoints_lookups),
            (&drop_rel_named, &named_lookups),
        ] {
            assert_eq!(
                super::build_schema_echo(
                    cmd,
                    EffectiveMode::AdvancedPersistent,
                    None, // description not needed for drops
                    lookups,
                ),
                Some(vec![
                    "ALTER TABLE Orders DROP CONSTRAINT Customers_id_to_Orders_CustId".to_string()
                ]),
            );
        }
    }

    /// End-to-end cover for the Bucket B multi-statement echoes (ADR-0038
    /// §7 / §6 category 2) against a real worker: `drop column --cascade`
    /// (post-exec `DropColumnResult.dropped_indexes`) and `add
    /// relationship --create-fk` (pre-exec lookup of the parent PK type +
    /// whether the child column existed; the multi-line shape fires only
    /// when the column was newly created).
    #[tokio::test]
    async fn bucket_b_multi_statement_echoes_against_real_worker() {
        use crate::app::EffectiveMode;
        use crate::db::Database;
        use crate::dsl::Command;
        use crate::dsl::ReferentialAction;
        use crate::dsl::command::ColumnSpec;
        use crate::dsl::types::Type;

        // --- drop column --cascade -----------------------------------
        let db = Database::open(":memory:").expect("open in-memory");
        db.create_table(
            "Customers".to_string(),
            vec![
                ColumnSpec::new("id", Type::Serial),
                ColumnSpec::new("Email", Type::Text),
            ],
            vec!["id".to_string()],
            None,
        )
        .await
        .expect("create Customers");
        db.add_index(
            None,
            "Customers".to_string(),
            vec!["Email".to_string()],
            None,
        )
        .await
        .expect("index Email");

        let drop_cmd = Command::DropColumn {
            table: "Customers".to_string(),
            column: "Email".to_string(),
            cascade: true,
        };
        let drop_result = db
            .drop_column("Customers".to_string(), "Email".to_string(), true, None)
            .await
            .expect("drop column --cascade");
        assert_eq!(
            super::build_drop_column_cascade_echo(
                &drop_cmd,
                EffectiveMode::AdvancedPersistent,
                &drop_result,
            ),
            Some(vec![
                "DROP INDEX Customers_Email_idx".to_string(),
                "ALTER TABLE Customers DROP COLUMN Email".to_string(),
            ]),
        );
        // Simple mode → silent.
        assert!(
            super::build_drop_column_cascade_echo(&drop_cmd, EffectiveMode::Simple, &drop_result,)
                .is_none(),
        );

        // --- add relationship --create-fk (column newly created) ----
        let db = Database::open(":memory:").expect("open in-memory");
        db.create_table(
            "Customers".to_string(),
            vec![ColumnSpec::new("id", Type::Serial)],
            vec!["id".to_string()],
            None,
        )
        .await
        .expect("create Customers");
        // Orders WITHOUT CustId — `--create-fk` will add it.
        db.create_table(
            "Orders".to_string(),
            vec![ColumnSpec::new("id", Type::Serial)],
            vec!["id".to_string()],
            None,
        )
        .await
        .expect("create Orders");

        let add_fk_cmd = Command::AddRelationship {
            name: None,
            parent_table: "Customers".to_string(),
            parent_columns: vec!["id".to_string()],
            child_table: "Orders".to_string(),
            child_columns: vec!["CustId".to_string()],
            on_delete: ReferentialAction::Cascade,
            on_update: ReferentialAction::NoAction,
            create_fk: true,
        };
        // Pre-exec lookup: parent PK is `serial` → child type = `int`;
        // child column did not exist → newly created.
        let pre_lookups =
            super::collect_echo_lookups(&db, &add_fk_cmd, EffectiveMode::AdvancedPersistent).await;
        assert_eq!(
            pre_lookups.add_rel_create_fk_new_columns,
            Some(vec![("CustId".to_string(), Type::Int)]),
            "pre-exec captures `serial → int` for the newly-created child column",
        );
        let parent_desc = db
            .add_relationship(
                None,
                "Customers".to_string(),
                vec!["id".to_string()],
                "Orders".to_string(),
                vec!["CustId".to_string()],
                ReferentialAction::Cascade,
                ReferentialAction::NoAction,
                true,
                None,
            )
            .await
            .expect("add --create-fk");
        assert_eq!(
            super::build_schema_echo(
                &add_fk_cmd,
                EffectiveMode::AdvancedPersistent,
                Some(&parent_desc),
                &pre_lookups,
            ),
            Some(vec![
                "ALTER TABLE Orders ADD COLUMN CustId int".to_string(),
                "ALTER TABLE Orders ADD CONSTRAINT Customers_id_to_Orders_CustId FOREIGN KEY (CustId) REFERENCES Customers (id) ON DELETE CASCADE".to_string(),
            ]),
            "multi-line echo fires when the child column was newly created",
        );

        // --- add relationship --create-fk (column already existed) --
        let db = Database::open(":memory:").expect("open in-memory");
        db.create_table(
            "Customers".to_string(),
            vec![ColumnSpec::new("id", Type::Serial)],
            vec!["id".to_string()],
            None,
        )
        .await
        .expect("create Customers");
        db.create_table(
            "Orders".to_string(),
            vec![
                ColumnSpec::new("id", Type::Serial),
                ColumnSpec::new("CustId", Type::Int),
            ],
            vec!["id".to_string()],
            None,
        )
        .await
        .expect("create Orders");

        let pre_lookups =
            super::collect_echo_lookups(&db, &add_fk_cmd, EffectiveMode::AdvancedPersistent).await;
        assert_eq!(
            pre_lookups.add_rel_create_fk_new_columns,
            Some(vec![]),
            "pre-exec records the child column already existed → single-line echo",
        );
        let parent_desc = db
            .add_relationship(
                None,
                "Customers".to_string(),
                vec!["id".to_string()],
                "Orders".to_string(),
                vec!["CustId".to_string()],
                ReferentialAction::Cascade,
                ReferentialAction::NoAction,
                true,
                None,
            )
            .await
            .expect("add --create-fk (existing column)");
        assert_eq!(
            super::build_schema_echo(
                &add_fk_cmd,
                EffectiveMode::AdvancedPersistent,
                Some(&parent_desc),
                &pre_lookups,
            ),
            Some(vec![
                "ALTER TABLE Orders ADD CONSTRAINT Customers_id_to_Orders_CustId FOREIGN KEY (CustId) REFERENCES Customers (id) ON DELETE CASCADE".to_string()
            ]),
            "single-line FK echo when the child column already existed",
        );
    }

    #[tokio::test]
    async fn switch_persists_the_outgoing_projects_mode() {
        // ADR-0015 mode-restore amendment (issue #14), persist on
        // unload — exercised through the real `handle_project_switch`
        // path, not just the db-level `set_mode`. Proves the wiring
        // calls `set_mode` on the outgoing project before it is
        // dropped. The outgoing project is *named* so it survives the
        // switch (an unmodified temp would be cleaned up, taking its
        // project.yaml with it). Without the unload persist the
        // outgoing skeleton carries no `mode:` → `None`.
        use super::{Session, SwitchRequest, handle_project_switch};
        use crate::db::Database;
        use crate::mode::Mode;
        use crate::persistence::Persistence;
        use crate::project::{Project, projects_dir};
        use tokio::sync::mpsc;

        let data_root = tempfile::tempdir().unwrap();
        let projects = projects_dir(data_root.path());
        std::fs::create_dir_all(&projects).unwrap();
        let outgoing_path = projects.join("Outgoing");
        let outgoing = Project::create_named(&outgoing_path).unwrap();
        let db_path = outgoing.db_path();
        let persistence = Persistence::new(outgoing.path().to_path_buf()).with_mode(Mode::Advanced);
        let database =
            Database::open_with_persistence_and_undo(&db_path, persistence, true).unwrap();
        let mut session = Session {
            project: Some(outgoing),
            database: Some(database),
            data_root: data_root.path().to_path_buf(),
        };
        // Keep the receiver alive so the switch's events aren't sent
        // into a closed channel.
        let (tx, _rx) = mpsc::channel(64);

        // Switch away to a fresh temp; the App's live mode here is
        // Advanced (the outgoing project's mode).
        handle_project_switch(
            &mut session,
            SwitchRequest::NewTemp,
            "new".to_string(),
            &tx,
            true,
            Mode::Advanced,
        )
        .await;

        assert_eq!(
            Persistence::read_stored_mode(&outgoing_path),
            Some(Mode::Advanced),
            "switching away must persist the outgoing project's mode",
        );
    }
}
