//! Tier 3 integration tests for the walking skeleton (per ADR-0008).
//!
//! These tests drive synthetic crossterm events through `App::update`
//! and assert on the resulting state and rendered buffer. They
//! exercise the full input → state → render path without a real
//! terminal, so they run on every commit and catch regressions in
//! the wiring between modules.

use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::Terminal;
use ratatui::backend::TestBackend;

use rdbms_playground::action::Action;
use rdbms_playground::app::{App, OutputKind};
use rdbms_playground::db::{
    ColumnDescription, DataResult, InsertResult, RelationshipEnd, TableDescription,
};
use rdbms_playground::dsl::{ColumnSpec, Command, ReferentialAction, RowFilter, Type, Value};
use rdbms_playground::event::AppEvent;
use rdbms_playground::mode::Mode;
use rdbms_playground::theme::Theme;
use rdbms_playground::ui;

const fn key(code: KeyCode) -> AppEvent {
    AppEvent::Key(KeyEvent {
        code,
        modifiers: KeyModifiers::NONE,
        kind: KeyEventKind::Press,
        state: crossterm::event::KeyEventState::NONE,
    })
}

fn type_str(app: &mut App, s: &str) -> Vec<Action> {
    let mut actions = Vec::new();
    for c in s.chars() {
        actions.extend(app.update(key(KeyCode::Char(c))));
    }
    actions
}

fn submit(app: &mut App) -> Vec<Action> {
    app.update(key(KeyCode::Enter))
}

/// Assert that `actions` is exactly one `Action::ExecuteDsl`
/// whose parsed command equals `expected`. The original source
/// text carried alongside the command is allowed to be
/// anything — tests construct the expected `Command` directly
/// and don't care about the verbatim user input.
#[track_caller]
fn assert_one_execute_dsl(actions: &[Action], expected: &Command) {
    assert_eq!(
        actions.len(),
        1,
        "expected exactly one action; got {actions:?}"
    );
    match &actions[0] {
        Action::ExecuteDsl { command, .. } => assert_eq!(command, expected),
        other => panic!("expected ExecuteDsl, got {other:?}"),
    }
}

fn rendered_text(app: &mut App, theme: &Theme, width: u16, height: u16) -> String {
    let backend = TestBackend::new(width, height);
    let mut terminal = Terminal::new(backend).expect("create terminal");
    terminal
        .draw(|f| ui::render(app, theme, f))
        .expect("draw frame");
    let buffer = terminal.backend().buffer().clone();
    let mut out = String::new();
    for y in 0..buffer.area.height {
        for x in 0..buffer.area.width {
            out.push_str(buffer[(x, y)].symbol());
        }
        out.push('\n');
    }
    out
}

#[test]
fn typing_then_submitting_a_dsl_command_emits_execute_action() {
    let mut app = App::new();
    let theme = Theme::dark();

    type_str(&mut app, "create table Customers with pk");
    let pre_render = rendered_text(&mut app, &theme, 80, 24);
    assert!(
        pre_render.contains("create table Customers"),
        "input field should display the typed text:\n{pre_render}"
    );

    let actions = submit(&mut app);
    assert_one_execute_dsl(
        &actions,
        &Command::CreateTable {
            name: "Customers".to_string(),
            columns: vec![ColumnSpec::new("id".to_string(), Type::Serial)],
            primary_key: vec!["id".to_string()],
        },
    );
    assert!(app.input.is_empty(), "input buffer cleared on submit");
    let post_render = rendered_text(&mut app, &theme, 80, 24);
    assert!(
        post_render.contains("running:"),
        "output panel should show the running notice:\n{post_render}"
    );
}

#[test]
fn typing_invalid_simple_input_shows_a_parse_error_not_an_echo() {
    let mut app = App::new();
    let theme = Theme::dark();
    type_str(&mut app, "hello world");
    let actions = submit(&mut app);
    // The failed line journals `err` (ADR-0034) but does not echo
    // or dispatch a command.
    assert!(
        matches!(actions.as_slice(), [Action::JournalFailure { .. }]),
        "expected only a JournalFailure; got {actions:?}",
    );
    let rendered = rendered_text(&mut app, &theme, 80, 24);
    assert!(
        rendered.contains("parse error"),
        "output panel should show the parse error:\n{rendered}"
    );
}

#[test]
fn mode_switch_changes_label_and_subsequent_echoes() {
    let mut app = App::new();
    let theme = Theme::dark();

    let initial = rendered_text(&mut app, &theme, 80, 24);
    assert!(initial.contains("SIMPLE"));
    assert!(!initial.contains("ADVANCED"));

    type_str(&mut app, "mode advanced");
    submit(&mut app);
    assert_eq!(app.mode, Mode::Advanced);

    let after_switch = rendered_text(&mut app, &theme, 80, 24);
    assert!(after_switch.contains("ADVANCED"));

    type_str(&mut app, "select 1");
    submit(&mut app);
    let last = app.output.back().expect("output present");
    assert_eq!(last.mode_at_submission, Mode::Advanced);
    assert_eq!(last.kind, OutputKind::Echo);
}

#[test]
fn colon_escape_in_simple_mode_is_one_shot() {
    let mut app = App::new();
    type_str(&mut app, ":select 1");
    submit(&mut app);
    assert_eq!(app.mode, Mode::Simple);
    // The line ran under the one-shot effective Advanced mode
    // (ADR-0030 §2): the `:` is stripped, the SQL grammar
    // dispatches `select 1`, and the echoed line carries the
    // submission's effective mode.
    let echoed = app
        .output
        .iter()
        .rfind(|l| l.kind == OutputKind::Echo)
        .expect("echo output present");
    assert_eq!(echoed.mode_at_submission, Mode::Advanced);
    assert!(
        echoed.text.contains("select 1") && !echoed.text.contains(":select"),
        "echo carries the stripped input: {:?}",
        echoed.text,
    );

    // Issue #39: dispatching `:select 1` arms the schema-refresh gate (a
    // cache refresh is conceptually in flight). In the real runtime that
    // refresh lands before the user types the next line; model it here so
    // the follow-up submission is processed rather than held.
    app.update(AppEvent::SchemaCacheRefreshed(app.schema_cache.clone()));

    // Subsequent submission (unrecognised in simple mode) parse-errors,
    // not echoes — confirming the mode reverted.
    type_str(&mut app, "list things");
    submit(&mut app);
    let last = app.output.back().unwrap();
    assert_eq!(last.kind, OutputKind::Error);
    assert_eq!(last.mode_at_submission, Mode::Simple);
}

#[test]
fn quit_command_returns_quit_action() {
    let mut app = App::new();
    type_str(&mut app, "quit");
    let actions = submit(&mut app);
    assert_eq!(actions, vec![Action::Quit]);
}

#[test]
fn rendering_works_at_minimum_useful_size() {
    // Sanity check that the layout does not panic at small sizes.
    let mut app = App::new();
    let theme = Theme::dark();
    let _ = rendered_text(&mut app, &theme, 40, 12);
}

#[test]
fn typing_colon_in_simple_mode_flips_prompt_to_advanced() {
    let mut app = App::new();
    let theme = Theme::dark();

    // No `:` yet — prompt shows SIMPLE.
    type_str(&mut app, "sel");
    let before = rendered_text(&mut app, &theme, 80, 24);
    assert!(before.contains("SIMPLE"));
    assert!(!before.contains("Advanced:"));

    // Reset and type `:` first — prompt should flip immediately.
    app.input.clear();
    type_str(&mut app, ":");
    let after_colon = rendered_text(&mut app, &theme, 80, 24);
    assert!(
        after_colon.contains("Advanced:"),
        "input panel should show 'Advanced:' once `:` is typed:\n{after_colon}"
    );
    assert!(!after_colon.contains("SIMPLE"));

    // Backspace through both the auto-inserted space and the `:`
    // itself reverts the prompt.
    while !app.input.is_empty() {
        app.update(key(KeyCode::Backspace));
    }
    let after_revert = rendered_text(&mut app, &theme, 80, 24);
    assert!(after_revert.contains("SIMPLE"));
    assert!(!after_revert.contains("Advanced:"));
}

#[test]
fn status_bar_is_keystroke_only_and_state_aware() {
    // ADR-0051 (issue #27): the bottom strip is keystrokes-only and
    // tracks the interaction state. Typed-command words (`:` advanced
    // once, `mode advanced`/`mode simple` switch) and `Ctrl-C quit`
    // leave the strip; mode discovery moves to the hint (locked by the
    // ui.rs unit tests). This test exercises the real render path.
    let mut app = App::new();
    let theme = Theme::dark();

    // Default (empty input): nav / complete / history / run keystrokes.
    let default_view = rendered_text(&mut app, &theme, 80, 24);
    assert!(
        default_view.contains("Ctrl-O sidebar"),
        "strip lists sidebar:\n{default_view}"
    );
    assert!(
        default_view.contains("Enter run"),
        "strip lists run:\n{default_view}"
    );
    assert!(
        !default_view.contains("Ctrl-C"),
        "quit dropped from the strip:\n{default_view}"
    );
    assert!(
        !default_view.contains("advanced once"),
        "`:` command word dropped from the strip:\n{default_view}",
    );

    // Editing (input has text): the #29 readline edit keys appear.
    type_str(&mut app, "create");
    let editing = rendered_text(&mut app, &theme, 80, 24);
    assert!(
        editing.contains("Esc clear"),
        "editing strip lists clear:\n{editing}"
    );
    assert!(
        editing.contains("Ctrl-W del word"),
        "editing strip lists del word:\n{editing}"
    );
}

// ---------------------------------------------------------------
// Full DSL flow tests.
//
// These tests simulate the runtime by feeding the AppEvent::Dsl*
// events that the runtime would post after dispatching a command
// to the database. That keeps these tests deterministic and runtime
// agnostic — the actual database is exercised in the db module's
// own #[tokio::test] suite.
// ---------------------------------------------------------------

fn fake_table(name: &str, columns: &[(&str, Type, bool)]) -> TableDescription {
    TableDescription {
        name: name.to_string(),
        columns: columns
            .iter()
            .map(|(n, t, pk)| ColumnDescription {
                name: (*n).to_string(),
                user_type: Some(*t),
                sqlite_type: t.sqlite_strict_type().to_string(),
                notnull: false,
                primary_key: *pk,
                unique: false,
                default: None,
                check: None,
            })
            .collect(),
        outbound_relationships: Vec::new(),
        inbound_relationships: Vec::new(),
        indexes: Vec::new(),
        unique_constraints: Vec::new(),
        check_constraints: Vec::new(),
    }
}

#[test]
fn create_table_flow_updates_tables_list_and_structure_view() {
    let mut app = App::new();
    let theme = Theme::dark();

    // User types and submits.
    type_str(&mut app, "create table Customers with pk");
    let actions = submit(&mut app);
    let expected_cmd = Command::CreateTable {
        name: "Customers".to_string(),
        columns: vec![ColumnSpec::new("id".to_string(), Type::Serial)],
        primary_key: vec!["id".to_string()],
    };
    assert_one_execute_dsl(&actions, &expected_cmd);

    // Runtime would now dispatch and feed back DslSucceeded + TablesRefreshed.
    let desc = fake_table("Customers", &[("id", Type::Serial, true)]);
    app.update(AppEvent::DslSucceeded {
        command: expected_cmd,
        description: Some(desc.clone()),
        echo: None,
    });
    app.update(AppEvent::TablesRefreshed(vec!["Customers".to_string()]));

    assert_eq!(app.tables, vec!["Customers".to_string()]);
    assert_eq!(app.current_table, Some(desc));

    // Width > 90 so the sidebar (items panel) is shown (ADR-0046 DB1).
    let rendered = rendered_text(&mut app, &theme, 110, 24);
    assert!(
        rendered.contains("Customers"),
        "items panel should list Customers:\n{rendered}"
    );
    // ADR-0040: success is the ✓ marker on the command's echo line
    // (the `[ok] create table Customers` summary line was retired).
    assert!(
        rendered.contains("create table Customers with pk ✓"),
        "the command echo should resolve to a success marker:\n{rendered}"
    );
    // The structure table renders one line per column; the
    // `id` row shows both the name and its `serial` type
    // separated by box-drawing characters.
    assert!(
        rendered
            .lines()
            .any(|l| l.contains("id") && l.contains("serial")),
        "output should show the id/serial column row:\n{rendered}"
    );
}

#[test]
fn add_column_flow_updates_structure_view() {
    let mut app = App::new();
    // Simulate the prior create_table state.
    app.tables = vec!["Customers".to_string()];
    app.current_table = Some(fake_table("Customers", &[("id", Type::Serial, true)]));

    type_str(&mut app, "add column to table Customers: Name (text)");
    let actions = submit(&mut app);
    assert_one_execute_dsl(
        &actions,
        &Command::AddColumn {
            table: "Customers".to_string(),
            column: "Name".to_string(),
            ty: Type::Text,
            not_null: false,
            unique: false,
            default: None,
            check: None,
        },
    );

    let updated = fake_table(
        "Customers",
        &[("id", Type::Serial, true), ("Name", Type::Text, false)],
    );
    app.update(AppEvent::DslSucceeded {
        command: Command::AddColumn {
            table: "Customers".to_string(),
            column: "Name".to_string(),
            ty: Type::Text,
            not_null: false,
            unique: false,
            default: None,
            check: None,
        },
        description: Some(updated.clone()),
        echo: None,
    });
    assert_eq!(app.current_table, Some(updated));
    let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
    assert!(
        rendered
            .lines()
            .any(|l| l.contains("Name") && l.contains("text")),
        "expected the Name/text column row:\n{rendered}",
    );
}

#[test]
fn drop_table_flow_clears_items_list() {
    let mut app = App::new();
    app.tables = vec!["Customers".to_string()];
    app.current_table = Some(fake_table("Customers", &[("id", Type::Serial, true)]));

    type_str(&mut app, "drop table Customers");
    let actions = submit(&mut app);
    assert_one_execute_dsl(
        &actions,
        &Command::DropTable {
            name: "Customers".to_string(),
        },
    );

    app.update(AppEvent::DslSucceeded {
        command: Command::DropTable {
            name: "Customers".to_string(),
        },
        description: None,
        echo: None,
    });
    app.update(AppEvent::TablesRefreshed(Vec::new()));

    assert!(app.tables.is_empty());
    assert!(app.current_table.is_none());
    // Width > 90 so the (now-empty) sidebar is shown (ADR-0046 DB1).
    let rendered = rendered_text(&mut app, &Theme::dark(), 110, 24);
    assert!(rendered.contains("(none yet)"));
    // ADR-0040: `drop table` is content-less, so the echo's ✓ marker
    // is the entire success signal (replacing `[ok] drop table …`).
    assert!(
        rendered.contains("drop table Customers ✓"),
        "the drop echo should resolve to a success marker:\n{rendered}"
    );
}

#[test]
fn add_relationship_flow_shows_parent_side_with_inbound_section() {
    let mut app = App::new();
    type_str(
        &mut app,
        "add 1:n relationship from Customers.Id to Orders.CustId on delete cascade",
    );
    let actions = submit(&mut app);
    assert_one_execute_dsl(
        &actions,
        &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,
        },
    );

    // The runtime now feeds back the parent (Customers) so the
    // user sees the new relationship via the "Referenced by"
    // section — same direction as the command's `from <Parent>`
    // reading.
    let customers = TableDescription {
        name: "Customers".to_string(),
        columns: vec![ColumnDescription {
            name: "Id".to_string(),
            user_type: Some(Type::Serial),
            sqlite_type: "INTEGER".to_string(),
            notnull: false,
            primary_key: true,
            unique: false,
            default: None,
            check: None,
        }],
        outbound_relationships: Vec::new(),
        inbound_relationships: vec![RelationshipEnd {
            name: "Customers_Id_to_Orders_CustId".to_string(),
            other_table: "Orders".to_string(),
            other_columns: vec!["CustId".to_string()],
            local_columns: vec!["Id".to_string()],
            on_delete: ReferentialAction::Cascade,
            on_update: ReferentialAction::NoAction,
        }],
        indexes: Vec::new(),
        unique_constraints: Vec::new(),
        check_constraints: Vec::new(),
    };
    app.update(AppEvent::DslSucceeded {
        command: 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,
        },
        description: Some(customers),
        echo: None,
    });

    // Tall viewport so the [ok] echo line stays visible above the
    // (taller-than-prose) diagram for the endpoint-subject assertion.
    let rendered = rendered_text(&mut app, &Theme::dark(), 80, 40);
    // ADR-0044: `add relationship` is relationship-relevant, so its echo
    // renders the relationship as a compact diagram, not the prose block.
    assert!(rendered.contains("Relationships"), "heading: {rendered}");
    assert!(rendered.contains("Orders"), "neighbour box: {rendered}");
    assert!(rendered.contains("CustId"), "FK column: {rendered}");
    assert!(rendered.contains('▶'), "connector: {rendered}");
    assert!(rendered.contains("on delete cascade"), "{rendered}");
    // The [ok] subject lists the endpoints. Long lines wrap in
    // the panel, so we check the first half of the phrase only.
    assert!(rendered.contains("from Customers.Id"), "{rendered}");
}

#[test]
fn add_column_confirmation_omits_relationship_prose() {
    // ADR-0050 (issue #28): an incidental-DDL confirmation echo (here
    // `add column`) renders the structure only — never the
    // `References:` / `Referenced by:` relationship block — even when
    // the table carries relationships the user did not touch. The
    // relationships remain one `show table` away.
    let mut app = App::new();
    let customers = TableDescription {
        name: "Customers".to_string(),
        columns: vec![ColumnDescription {
            name: "Id".to_string(),
            user_type: Some(Type::Serial),
            sqlite_type: "INTEGER".to_string(),
            notnull: false,
            primary_key: true,
            unique: false,
            default: None,
            check: None,
        }],
        outbound_relationships: Vec::new(),
        inbound_relationships: vec![RelationshipEnd {
            name: "Customers_Id_to_Orders_CustId".to_string(),
            other_table: "Orders".to_string(),
            other_columns: vec!["CustId".to_string()],
            local_columns: vec!["Id".to_string()],
            on_delete: ReferentialAction::Cascade,
            on_update: ReferentialAction::NoAction,
        }],
        indexes: Vec::new(),
        unique_constraints: Vec::new(),
        check_constraints: Vec::new(),
    };
    app.update(AppEvent::DslSucceeded {
        command: Command::AddColumn {
            table: "Customers".to_string(),
            column: "extra".to_string(),
            ty: Type::Text,
            not_null: false,
            unique: false,
            default: None,
            check: None,
        },
        description: Some(customers),
        echo: None,
    });
    let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
    // The structure box still renders (table name + the column box from
    // the returned description).
    assert!(
        rendered.contains("Customers"),
        "structure header:\n{rendered}"
    );
    assert!(
        rendered.contains("Constraints"),
        "structure box:\n{rendered}"
    );
    // The relationship block is gone — neither prose heading nor line.
    assert!(
        !rendered.contains("Referenced by:"),
        "no prose heading:\n{rendered}"
    );
    assert!(
        !rendered.contains("References:"),
        "no prose heading:\n{rendered}"
    );
    assert!(
        !rendered.contains("Orders.CustId → Id"),
        "no prose line:\n{rendered}",
    );
}

#[test]
fn insert_flow_emits_action_and_renders_data() {
    let mut app = App::new();

    type_str(&mut app, "insert into Customers values ('Alice')");
    let actions = submit(&mut app);
    assert_one_execute_dsl(
        &actions,
        &Command::Insert {
            table: "Customers".to_string(),
            columns: None,
            values: vec![Value::Text("Alice".to_string())],
        },
    );

    // Simulate the runtime feeding back an InsertResult.
    let data = DataResult {
        table_name: "Customers".to_string(),
        columns: vec!["id".to_string(), "Name".to_string()],
        column_types: vec![Some(Type::Serial), Some(Type::Text)],
        rows: vec![vec![Some("1".to_string()), Some("Alice".to_string())]],
    };
    app.update(AppEvent::DslInsertSucceeded {
        command: Command::Insert {
            table: "Customers".to_string(),
            columns: None,
            values: vec![Value::Text("Alice".to_string())],
        },
        result: InsertResult {
            rows_affected: 1,
            data,
        },
    });
    let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
    assert!(
        rendered.contains("1 row(s) inserted"),
        "should show row count:\n{rendered}"
    );
    assert!(
        rendered.contains("Alice"),
        "should auto-show new row:\n{rendered}"
    );
    assert!(
        rendered.contains("id") && rendered.contains("Name"),
        "should show column headers:\n{rendered}"
    );
}

#[test]
fn delete_with_all_rows_emits_correct_action() {
    let mut app = App::new();
    type_str(&mut app, "delete from Customers --all-rows");
    let actions = submit(&mut app);
    assert_one_execute_dsl(
        &actions,
        &Command::Delete {
            table: "Customers".to_string(),
            filter: RowFilter::AllRows,
        },
    );
}

#[test]
fn show_data_for_empty_table_renders_placeholder() {
    let mut app = App::new();
    let data = DataResult {
        table_name: "Customers".to_string(),
        columns: vec!["id".to_string(), "Name".to_string()],
        column_types: vec![Some(Type::Serial), Some(Type::Text)],
        rows: Vec::new(),
    };
    app.update(AppEvent::DslDataSucceeded {
        command: Command::ShowData {
            name: "Customers".to_string(),
            filter: None,
            limit: None,
        },
        data,
        echo: None,
    });
    let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
    assert!(rendered.contains("(no rows)"), "{rendered}");
}

#[test]
fn dsl_failure_shows_friendly_error_in_output() {
    let mut app = App::new();
    type_str(&mut app, "drop table Ghost");
    submit(&mut app);
    app.update(AppEvent::DslFailed {
        command: Command::DropTable {
            name: "Ghost".to_string(),
        },
        error: rdbms_playground::db::DbError::Sqlite {
            message: "no such table: Ghost".to_string(),
            kind: rdbms_playground::db::SqliteErrorKind::NoSuchTable,
        },
        facts: rdbms_playground::friendly::FailureContext::default(),
        source: String::new(),
        advanced: false,
    });
    let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
    assert!(
        rendered.contains("Ghost"),
        "error should mention the table:\n{rendered}"
    );
    assert!(
        rendered.contains("no such table"),
        "error should include the friendly message:\n{rendered}"
    );
}

#[test]
fn validity_indicator_renders_err_and_wrn_labels() {
    // ADR-0027 §4: the input row shows a `[ERR]` / `[WRN]`
    // label at its right edge, or nothing when clean.
    use rdbms_playground::dsl::walker::Severity;
    let mut app = App::new();

    let clean = rendered_text(&mut app, &Theme::dark(), 80, 24);
    assert!(
        !clean.contains("[ERR]"),
        "clean input shows no label:\n{clean}"
    );
    assert!(
        !clean.contains("[WRN]"),
        "clean input shows no label:\n{clean}"
    );

    app.input_indicator = Some(Severity::Error);
    let err = rendered_text(&mut app, &Theme::dark(), 80, 24);
    assert!(err.contains("[ERR]"), "ERROR verdict shows [ERR]:\n{err}");

    app.input_indicator = Some(Severity::Warning);
    let wrn = rendered_text(&mut app, &Theme::dark(), 80, 24);
    assert!(wrn.contains("[WRN]"), "WARNING verdict shows [WRN]:\n{wrn}");
}
