//! Iteration-5 integration tests: `export` / `import`
//! (ADR-0015 §11 + ADR-0007 amendment 1).
//!
//! Command parsing is exercised at the App layer (synthetic
//! events). Filesystem-level export and import semantics are
//! tested against the public `archive` helpers without booting
//! a Tokio loop.

use std::fs;
use std::path::PathBuf;

use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};

use rdbms_playground::action::Action;
use rdbms_playground::app::App;
use rdbms_playground::archive::{
    default_export_filename, export_project, extract_into, inspect_zip, next_export_sequence,
    resolve_import_target,
};
use rdbms_playground::event::AppEvent;
use rdbms_playground::project::{HISTORY_LOG, PLAYGROUND_DB, PROJECT_YAML};

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) {
    for c in s.chars() {
        app.update(key(KeyCode::Char(c)));
    }
}

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

fn tempdir() -> tempfile::TempDir {
    tempfile::tempdir().expect("create tempdir")
}

fn make_demo_project(root: &std::path::Path, name: &str) -> PathBuf {
    let p = root.join(name);
    fs::create_dir_all(&p).unwrap();
    fs::write(
        p.join(PROJECT_YAML),
        "version: 1\nproject:\n  created_at: 2026-01-01T00:00:00Z\ntables: []\nrelationships: []\n",
    )
    .unwrap();
    fs::create_dir_all(p.join("data")).unwrap();
    fs::write(p.join("data/Customers.csv"), "Name\nAlice\nBob\n").unwrap();
    fs::write(p.join(HISTORY_LOG), "T|ok|seed\n").unwrap();
    fs::write(p.join(PLAYGROUND_DB), [0u8; 16]).unwrap();
    p
}

// --- Command-parsing tests -------------------------------------

#[test]
fn export_with_no_arg_emits_default_action() {
    let mut app = App::new();
    type_str(&mut app, "export");
    let actions = submit(&mut app);
    assert_eq!(actions.len(), 1);
    match &actions[0] {
        Action::Export { target, source } => {
            assert!(target.is_none());
            assert_eq!(source, "export");
        }
        other => panic!("expected Export, got {other:?}"),
    }
}

#[test]
fn export_with_path_argument_passes_through_target() {
    let mut app = App::new();
    type_str(&mut app, "export backups/MyExport.zip");
    let actions = submit(&mut app);
    assert_eq!(actions.len(), 1);
    match &actions[0] {
        Action::Export { target, .. } => {
            assert_eq!(target.as_deref(), Some("backups/MyExport.zip"));
        }
        other => panic!("expected Export, got {other:?}"),
    }
}

#[test]
fn export_with_only_whitespace_after_keyword_errors() {
    let mut app = App::new();
    type_str(&mut app, "export    ");
    let actions = submit(&mut app);
    // Trailing whitespace is trimmed by submit() before
    // dispatch, so "export    " trims to "export" and emits
    // the default Export action — exactly the same outcome
    // as a bare `export`. That is the desired behaviour.
    assert_eq!(actions.len(), 1);
    match &actions[0] {
        Action::Export { target, .. } => assert!(target.is_none()),
        other => panic!("expected Export, got {other:?}"),
    }
}

#[test]
fn import_without_arg_emits_error() {
    let mut app = App::new();
    type_str(&mut app, "import");
    let actions = submit(&mut app);
    assert!(actions.is_empty());
    let last = app.output.back().unwrap();
    assert!(last.text.contains("usage: import"), "got: {}", last.text);
}

#[test]
fn import_with_zip_path_emits_action_without_target() {
    let mut app = App::new();
    type_str(&mut app, "import some/file.zip");
    let actions = submit(&mut app);
    assert_eq!(actions.len(), 1);
    match &actions[0] {
        Action::Import {
            zip_path,
            as_target,
            ..
        } => {
            assert_eq!(zip_path, "some/file.zip");
            assert!(as_target.is_none());
        }
        other => panic!("expected Import, got {other:?}"),
    }
}

#[test]
fn import_with_zip_and_as_target_emits_both() {
    let mut app = App::new();
    type_str(&mut app, "import some/file.zip as MyImported");
    let actions = submit(&mut app);
    assert_eq!(actions.len(), 1);
    match &actions[0] {
        Action::Import {
            zip_path,
            as_target,
            ..
        } => {
            assert_eq!(zip_path, "some/file.zip");
            assert_eq!(as_target.as_deref(), Some("MyImported"));
        }
        other => panic!("expected Import, got {other:?}"),
    }
}

#[test]
fn import_grammar_only_splits_on_space_around_as() {
    // A zip path that *contains* the substring "as" without
    // surrounding spaces must NOT be split — the separator
    // is " as " (space-as-space) only.
    let mut app = App::new();
    type_str(&mut app, "import path/asfile.zip");
    let actions = submit(&mut app);
    assert_eq!(actions.len(), 1);
    match &actions[0] {
        Action::Import {
            zip_path,
            as_target,
            ..
        } => {
            assert_eq!(zip_path, "path/asfile.zip");
            assert!(as_target.is_none());
        }
        other => panic!("expected Import, got {other:?}"),
    }
}

#[test]
fn import_with_empty_target_after_as_errors() {
    let mut app = App::new();
    type_str(&mut app, "import foo.zip as ");
    let actions = submit(&mut app);
    // "as " trailing whitespace is trimmed by .split_once + .trim,
    // making the as-target empty. We surface this as a usage
    // error rather than silently importing without a target. The
    // failed line is journalled `err` (ADR-0034) but no import
    // dispatches.
    assert!(
        matches!(actions.as_slice(), [Action::JournalFailure { .. }]),
        "expected only a JournalFailure, no import dispatch; got {actions:?}",
    );
    // The friendly parse-error rendering produces multiple
    // output lines (caret, message, usage). Scan for the anchor
    // phrase rather than asserting on the final line. The
    // round-5 refactor moved this error from `handle_import_command`
    // (single note) into the parser's pre-chumsky path (multi-
    // line rendering via dispatch_dsl).
    let anywhere = app
        .output
        .iter()
        .any(|l| l.text.contains("import") && l.text.contains("target"));
    assert!(
        anywhere,
        "expected 'import' + 'target' somewhere in output: {:?}",
        app.output.iter().map(|l| &l.text).collect::<Vec<_>>(),
    );
}

#[test]
fn help_lists_export_and_import() {
    let mut app = App::new();
    type_str(&mut app, "help");
    submit(&mut app);
    let body = app
        .output
        .iter()
        .map(|l| l.text.as_str())
        .collect::<Vec<_>>()
        .join("\n");
    assert!(body.contains("export"), "help missing export: {body}");
    assert!(body.contains("import"), "help missing import: {body}");
}

// --- Filesystem-level export/import semantics ------------------

#[test]
fn full_round_trip_export_then_extract() {
    let tmp = tempdir();
    let project = make_demo_project(tmp.path(), "MyDemo");
    let zip = tmp.path().join("MyDemo-export-01.zip");
    export_project(&project, "MyDemo", &zip).unwrap();

    let inspect = inspect_zip(&zip).unwrap();
    assert_eq!(inspect.top_folder, "MyDemo");

    let target = tmp.path().join("imported");
    extract_into(&zip, &target, &inspect.top_folder).unwrap();
    assert!(target.join(PROJECT_YAML).exists());
    assert!(target.join("data").join("Customers.csv").exists());
    // history.log and playground.db were excluded from the zip,
    // so neither lands in the imported project.
    assert!(!target.join(HISTORY_LOG).exists());
    assert!(!target.join(PLAYGROUND_DB).exists());
}

#[test]
fn next_export_sequence_increments_per_existing_file() {
    let tmp = tempdir();
    let date = rdbms_playground::project::naming::today_local();

    let (n1_name, n1) = next_export_sequence(tmp.path(), "Demo").unwrap();
    assert_eq!(n1, 1);
    fs::write(tmp.path().join(&n1_name), "").unwrap();

    let (n2_name, n2) = next_export_sequence(tmp.path(), "Demo").unwrap();
    assert_eq!(n2, 2);
    assert_eq!(n2_name, default_export_filename(&date, "Demo", 2));
}

#[test]
fn resolve_import_target_auto_suffixes_on_collision() {
    let tmp = tempdir();
    fs::create_dir(tmp.path().join("Imported")).unwrap();
    let (resolved, suffix) = resolve_import_target(tmp.path(), "Imported").unwrap();
    assert_eq!(resolved, tmp.path().join("Imported-02"));
    assert_eq!(suffix, 2);
}

#[test]
fn resolve_import_target_uses_direct_name_when_free() {
    let tmp = tempdir();
    let (resolved, suffix) = resolve_import_target(tmp.path(), "Fresh").unwrap();
    assert_eq!(resolved, tmp.path().join("Fresh"));
    assert_eq!(suffix, 0);
}

// --- End-to-end: real Project → export → import → rebuild ----

#[test]
fn end_to_end_export_then_import_real_project() {
    use rdbms_playground::db::Database;
    use rdbms_playground::dsl::{ColumnSpec, Type, Value};
    use rdbms_playground::persistence::Persistence;
    use rdbms_playground::project;

    fn rt() -> tokio::runtime::Runtime {
        tokio::runtime::Builder::new_current_thread()
            .enable_all()
            .build()
            .expect("tokio rt")
    }

    let data = tempdir();

    // Build a populated source project.
    let src_path = {
        let p = project::Project::create_named(&data.path().join("Source")).unwrap();
        let db =
            Database::open_with_persistence(p.db_path(), Persistence::new(p.path().to_path_buf()))
                .unwrap();
        rt().block_on(async {
            db.create_table(
                "Customers".to_string(),
                vec![
                    ColumnSpec::new("id".to_string(), Type::Serial),
                    ColumnSpec::new("Name".to_string(), Type::Text),
                ],
                vec!["id".to_string()],
                Some("create table Customers with pk id(serial)".to_string()),
            )
            .await
            .unwrap();
            db.insert(
                "Customers".to_string(),
                None,
                // Serial id auto-fills, so the values list
                // covers the non-serial columns only.
                vec![Value::Text("Alice".to_string())],
                Some("insert into Customers values ('Alice')".to_string()),
            )
            .await
            .unwrap();
        });
        let path = p.path().to_path_buf();
        drop(db);
        drop(p);
        path
    };

    // Export.
    let zip_path = data.path().join("Source-export.zip");
    export_project(&src_path, "Source", &zip_path).unwrap();
    assert!(zip_path.exists());

    // Inspect: top folder is the project name we exported with.
    let inspect = inspect_zip(&zip_path).unwrap();
    assert_eq!(inspect.top_folder, "Source");

    // Import to a fresh location and rebuild from text.
    let dst = data.path().join("Imported");
    extract_into(&zip_path, &dst, &inspect.top_folder).unwrap();
    assert!(dst.join(PROJECT_YAML).exists());
    // playground.db is excluded from the export, so the
    // imported project starts without one — exactly the
    // scenario rebuild_from_text is designed for.
    assert!(!dst.join(PLAYGROUND_DB).exists());

    let imported = project::Project::open(&dst).unwrap();
    let imported_db = Database::open_with_persistence(
        imported.db_path(),
        Persistence::new(imported.path().to_path_buf()),
    )
    .unwrap();
    rt().block_on(async {
        imported_db
            .rebuild_from_text(imported.path().to_path_buf(), None)
            .await
            .expect("rebuild");
    });

    // Round-trip: the inserted row is back.
    let data_view = rt()
        .block_on(async {
            imported_db
                .query_data("Customers".to_string(), None, None)
                .await
        })
        .expect("query data");
    assert_eq!(data_view.rows.len(), 1);
    // Serial id auto-filled to 1; Name was the inserted value.
    let cells: Vec<Option<&str>> = data_view.rows[0].iter().map(|c| c.as_deref()).collect();
    assert_eq!(cells, vec![Some("1"), Some("Alice")]);
}
