//! Iteration-4b integration tests: `save` / `save as` /
//! `new` / `load` (ADR-0015 §11) and the modal infrastructure
//! that hosts their dialogs.
//!
//! Modal flows are tested at the App layer (synthetic events).
//! Filesystem effects (recursive copy, project switching at
//! runtime) are tested through the public `project` and
//! `runtime` helpers without booting a Tokio loop.

use std::fs;

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

use rdbms_playground::action::Action;
use rdbms_playground::app::{
    App, LoadPickerEntry, LoadPickerModal, LoadPickerSubMode, Modal, PathEntryModal,
    PathEntryPurpose,
};
use rdbms_playground::db::Database;
use rdbms_playground::dsl::{ColumnSpec, Type};
use rdbms_playground::event::AppEvent;
use rdbms_playground::persistence::Persistence;
use rdbms_playground::project::{
    self, Project, ProjectKind, copy_project, safely_delete_temp_project,
};

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")
}

#[test]
fn help_command_lists_supported_commands() {
    let mut app = App::new();
    type_str(&mut app, "help");
    let actions = submit(&mut app);
    assert!(actions.is_empty());
    let body = app
        .output
        .iter()
        .map(|l| l.text.as_str())
        .collect::<Vec<_>>()
        .join("\n");
    for keyword in ["quit", "rebuild", "save", "load", "new", "create table"] {
        assert!(
            body.contains(keyword),
            "help output missing `{keyword}`:\n{body}",
        );
    }
}

#[test]
fn help_describes_auto_generated_type_behaviour() {
    // ADR-0017 / ADR-0018: the in-app help must surface the
    // auto-fill contract for serial / shortid columns and the
    // change-column conversion flags. Captured as a regression
    // check so a future help-text edit doesn't silently drop the
    // pedagogical lines.
    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");
    for keyword in [
        "--force-conversion",
        "--dont-convert",
        "Auto-generated types",
        "auto-filled",
        "UNIQUE",
    ] {
        assert!(
            body.contains(keyword),
            "help output missing `{keyword}`:\n{body}",
        );
    }
}

#[test]
fn save_on_temp_opens_path_entry_modal() {
    let mut app = App::new();
    app.project_is_temp = true;
    type_str(&mut app, "save");
    let actions = submit(&mut app);
    assert!(actions.is_empty());
    match app.modal.as_ref() {
        Some(Modal::PathEntry(PathEntryModal { purpose, title, .. })) => {
            assert_eq!(*purpose, PathEntryPurpose::SaveAs);
            assert_eq!(title, "Save");
        }
        other => panic!("expected PathEntry modal, got {other:?}"),
    }
}

#[test]
fn save_on_named_project_emits_hint_and_no_modal() {
    let mut app = App::new();
    app.project_is_temp = false;
    type_str(&mut app, "save");
    let actions = submit(&mut app);
    assert!(actions.is_empty());
    assert!(app.modal.is_none());
    let last = app.output.iter().last().expect("an output line");
    assert!(
        last.text.contains("already auto-saved"),
        "got: {}",
        last.text,
    );
}

#[test]
fn save_as_always_opens_path_entry_modal() {
    let mut app = App::new();
    app.project_is_temp = false;
    type_str(&mut app, "save as");
    let actions = submit(&mut app);
    assert!(actions.is_empty());
    match app.modal.as_ref() {
        Some(Modal::PathEntry(PathEntryModal { purpose, title, .. })) => {
            assert_eq!(*purpose, PathEntryPurpose::SaveAs);
            assert_eq!(title, "Save as");
        }
        other => panic!("expected PathEntry modal, got {other:?}"),
    }
}

#[test]
fn new_command_emits_action() {
    let mut app = App::new();
    type_str(&mut app, "new");
    let actions = submit(&mut app);
    assert_eq!(
        actions,
        vec![Action::NewProject {
            source: "new".to_string()
        }]
    );
}

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

#[test]
fn path_entry_modal_typing_and_enter_emits_save_as() {
    let mut app = App::new();
    app.project_is_temp = true;
    type_str(&mut app, "save as");
    submit(&mut app);
    // Type a name and press Enter.
    type_str(&mut app, "MyOrders");
    let actions = submit(&mut app);
    assert_eq!(actions.len(), 1);
    let Action::SaveAs { target, source } = &actions[0] else {
        panic!("expected SaveAs, got {:?}", actions[0]);
    };
    assert_eq!(target, "MyOrders");
    assert_eq!(source, "save as");
    assert!(app.modal.is_none());
}

#[test]
fn path_entry_modal_esc_cancels() {
    let mut app = App::new();
    app.project_is_temp = true;
    type_str(&mut app, "save as");
    submit(&mut app);
    type_str(&mut app, "TheBest");
    let actions = app.update(key(KeyCode::Esc));
    assert!(actions.is_empty());
    assert!(app.modal.is_none());
}

#[test]
fn path_entry_modal_backspace_edits_input() {
    let mut app = App::new();
    app.project_is_temp = true;
    type_str(&mut app, "save as");
    submit(&mut app);
    type_str(&mut app, "abc");
    app.update(key(KeyCode::Backspace));
    match app.modal.as_ref() {
        Some(Modal::PathEntry(m)) => assert_eq!(m.input, "ab"),
        other => panic!("expected PathEntry, got {other:?}"),
    }
}

#[test]
fn load_picker_renders_entries_and_navigates() {
    let mut app = App::new();
    app.update(AppEvent::LoadPickerReady {
        entries: vec![
            LoadPickerEntry {
                display_name: "Newer".to_string(),
                modified: "2026-05-07 14:30".to_string(),
                path: std::path::PathBuf::from("/tmp/newer"),
                is_temp: true,
            },
            LoadPickerEntry {
                display_name: "Older".to_string(),
                modified: "2026-05-01 09:15".to_string(),
                path: std::path::PathBuf::from("/tmp/older"),
                is_temp: false,
            },
        ],
    });
    let Some(Modal::LoadPicker(picker)) = app.modal.clone() else {
        panic!("expected LoadPicker modal");
    };
    assert_eq!(picker.selected, 0);
    assert!(matches!(picker.sub_mode, LoadPickerSubMode::List));

    // Down → select index 1.
    app.update(key(KeyCode::Down));
    let Some(Modal::LoadPicker(picker)) = app.modal.clone() else {
        panic!("expected LoadPicker still active");
    };
    assert_eq!(picker.selected, 1);

    // Enter → emit LoadProject for entries[1].
    let actions = app.update(key(KeyCode::Enter));
    assert_eq!(actions.len(), 1);
    let Action::LoadProject { path, source } = &actions[0] else {
        panic!("expected LoadProject, got {:?}", actions[0]);
    };
    assert_eq!(path, std::path::Path::new("/tmp/older"));
    assert_eq!(source, "load");
}

/// Build a load picker with three entries for the vi-navigation tests.
fn three_entry_picker() -> App {
    let mut app = App::new();
    app.update(AppEvent::LoadPickerReady {
        entries: vec![
            LoadPickerEntry {
                display_name: "First".to_string(),
                modified: "2026-05-07 14:30".to_string(),
                path: std::path::PathBuf::from("/tmp/first"),
                is_temp: true,
            },
            LoadPickerEntry {
                display_name: "Second".to_string(),
                modified: "2026-05-05 10:00".to_string(),
                path: std::path::PathBuf::from("/tmp/second"),
                is_temp: false,
            },
            LoadPickerEntry {
                display_name: "Third".to_string(),
                modified: "2026-05-01 09:15".to_string(),
                path: std::path::PathBuf::from("/tmp/third"),
                is_temp: false,
            },
        ],
    });
    app
}

fn picker_selected(app: &App) -> usize {
    let Some(Modal::LoadPicker(picker)) = app.modal.as_ref() else {
        panic!("expected LoadPicker modal");
    };
    picker.selected
}

#[test]
fn load_picker_jk_navigates_like_arrows() {
    // vi-style j/k mirror Down/Up so autocast (typeable keys only) can drive
    // the load picker in documentation casts (#24).
    let mut app = three_entry_picker();
    assert_eq!(picker_selected(&app), 0);

    // j moves the selection down.
    app.update(key(KeyCode::Char('j')));
    assert_eq!(picker_selected(&app), 1);
    app.update(key(KeyCode::Char('j')));
    assert_eq!(picker_selected(&app), 2);

    // j at the last entry does not wrap past the end.
    app.update(key(KeyCode::Char('j')));
    assert_eq!(picker_selected(&app), 2);

    // k moves the selection up.
    app.update(key(KeyCode::Char('k')));
    assert_eq!(picker_selected(&app), 1);

    // k at the first entry does not wrap past the start.
    app.update(key(KeyCode::Char('k')));
    assert_eq!(picker_selected(&app), 0);
    app.update(key(KeyCode::Char('k')));
    assert_eq!(picker_selected(&app), 0);
}

#[test]
fn load_picker_g_jumps_to_first_and_last() {
    // g → first entry, G → last entry (vi convention).
    let mut app = three_entry_picker();

    // G jumps to the last entry from the top.
    app.update(key(KeyCode::Char('G')));
    assert_eq!(picker_selected(&app), 2);

    // G again is idempotent at the end.
    app.update(key(KeyCode::Char('G')));
    assert_eq!(picker_selected(&app), 2);

    // g jumps back to the first entry.
    app.update(key(KeyCode::Char('g')));
    assert_eq!(picker_selected(&app), 0);

    // g again is idempotent at the start.
    app.update(key(KeyCode::Char('g')));
    assert_eq!(picker_selected(&app), 0);
}

#[test]
fn load_picker_b_enters_path_entry_submode() {
    let mut app = App::new();
    app.update(AppEvent::LoadPickerReady {
        entries: vec![LoadPickerEntry {
            display_name: "Foo".to_string(),
            modified: "2026-05-07 14:30".to_string(),
            path: std::path::PathBuf::from("/tmp/foo"),
            is_temp: true,
        }],
    });
    app.update(key(KeyCode::Char('b')));
    let Some(Modal::LoadPicker(LoadPickerModal {
        sub_mode: LoadPickerSubMode::PathEntry { input, .. },
        ..
    })) = app.modal.clone()
    else {
        panic!("expected LoadPicker in PathEntry sub-mode");
    };
    assert_eq!(input, "");

    type_str(&mut app, "/some/path");
    let actions = app.update(key(KeyCode::Enter));
    let Action::LoadProject { path, .. } = &actions[0] else {
        panic!("expected LoadProject");
    };
    assert_eq!(path, std::path::Path::new("/some/path"));
}

#[test]
fn empty_data_root_load_picker_opens_in_path_entry_mode() {
    let mut app = App::new();
    app.update(AppEvent::LoadPickerReady { entries: vec![] });
    match app.modal.as_ref() {
        Some(Modal::LoadPicker(LoadPickerModal {
            sub_mode: LoadPickerSubMode::PathEntry { .. },
            ..
        })) => {}
        other => panic!("expected LoadPicker in PathEntry sub-mode, got {other:?}"),
    }
}

#[test]
fn project_switched_event_updates_state() {
    let mut app = App::new();
    app.project_name = Some("Old".to_string());
    app.project_is_temp = true;
    app.tables = vec!["Stale".to_string()];
    app.update(AppEvent::ProjectSwitched {
        display_name: "New Name".to_string(),
        is_temp: false,
        history_entries: Vec::new(),
        mode: rdbms_playground::mode::Mode::Simple,
    });
    assert_eq!(app.project_name.as_deref(), Some("New Name"));
    assert!(!app.project_is_temp);
    assert!(app.tables.is_empty(), "tables should clear on switch");
}

// === Filesystem-level tests for project::copy_project ===

#[test]
fn copy_project_excludes_lock_file() {
    let data = tempdir();
    let project = project::open_or_create(None, Some(data.path())).unwrap();
    let src = project.path().to_path_buf();

    // Confirm the lock exists in the source.
    assert!(src.join(".rdbms-playground.lock").exists());

    let dst = data.path().join("CopyDestination");
    copy_project(&src, &dst).unwrap();

    // Destination has the project skeleton but not the lock.
    assert!(dst.join("project.yaml").exists());
    assert!(dst.join("data").is_dir());
    assert!(!dst.join(".rdbms-playground.lock").exists());

    drop(project);
}

#[test]
fn copy_project_refuses_existing_destination() {
    let data = tempdir();
    let project = project::open_or_create(None, Some(data.path())).unwrap();
    let src = project.path().to_path_buf();
    let dst = data.path().join("ExistingDir");
    fs::create_dir(&dst).unwrap();
    let err = copy_project(&src, &dst).expect_err("must refuse");
    assert!(format!("{err}").contains("already exists"));
}

#[test]
fn project_kind_recovered_from_dirname_on_open() {
    let data = tempdir();
    // Create a temp project. Its dirname will contain `[temp]`.
    let temp = project::open_or_create(None, Some(data.path())).unwrap();
    let temp_path = temp.path().to_path_buf();
    drop(temp);

    // Reopen — should still report Temp.
    let reopened = Project::open(&temp_path).unwrap();
    assert_eq!(reopened.kind(), ProjectKind::Temp);
    drop(reopened);

    // Now copy to a named directory.
    let named_dir = data.path().join("MyProject");
    copy_project(&temp_path, &named_dir).unwrap();
    let opened_named = Project::open(&named_dir).unwrap();
    assert_eq!(opened_named.kind(), ProjectKind::Named);
    assert_eq!(opened_named.display_name(), "My Project");
}

#[test]
fn fresh_temp_is_unmodified() {
    let data = tempdir();
    let project = project::open_or_create(None, Some(data.path())).unwrap();
    assert!(project.is_unmodified_temp());
}

#[test]
fn temp_with_a_table_is_no_longer_unmodified() {
    let data = tempdir();
    let project = project::open_or_create(None, Some(data.path())).unwrap();
    let path = project.path().to_path_buf();
    let db =
        Database::open_with_persistence(project.db_path(), Persistence::new(path.clone())).unwrap();
    let rt = tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .unwrap();
    rt.block_on(async {
        db.create_table(
            "T".to_string(),
            vec![ColumnSpec::new("id".to_string(), Type::Serial)],
            vec!["id".to_string()],
            Some("create".to_string()),
        )
        .await
        .unwrap();
    });
    drop(db);
    drop(project);

    let reopened = Project::open(&path).unwrap();
    assert!(
        !reopened.is_unmodified_temp(),
        "a temp with a table should not be considered unmodified",
    );
}

#[test]
fn named_project_is_never_unmodified_temp() {
    let data = tempdir();
    let temp = project::open_or_create(None, Some(data.path())).unwrap();
    let temp_path = temp.path().to_path_buf();
    drop(temp);

    let named = data.path().join("MyOrders");
    copy_project(&temp_path, &named).unwrap();
    let opened = Project::open(&named).unwrap();
    // Even though the schema is empty, kind is Named.
    assert_eq!(opened.kind(), ProjectKind::Named);
    assert!(!opened.is_unmodified_temp());
}

#[test]
fn safely_delete_removes_genuine_unmodified_temp() {
    let data = tempdir();
    let project = project::open_or_create(None, Some(data.path())).unwrap();
    let path = project.path().to_path_buf();
    drop(project); // release lock so we can delete
    assert!(path.exists());
    safely_delete_temp_project(&path, data.path()).expect("should delete");
    assert!(!path.exists());
}

#[test]
fn safely_delete_refuses_path_outside_data_root() {
    let data = tempdir();
    let other = tempdir();
    // Construct a directory outside the data root that LOOKS
    // like a temp project (has [temp] marker + project.yaml).
    let foreign = other.path().join("20260507-[temp]-fake-fake-fake");
    fs::create_dir_all(&foreign).unwrap();
    fs::write(
        foreign.join("project.yaml"),
        "version: 1\nproject:\n  created_at: x\ntables: []\nrelationships: []\n",
    )
    .unwrap();

    let err = safely_delete_temp_project(&foreign, data.path()).expect_err("must refuse");
    assert!(format!("{err}").contains("not inside"), "got: {err}");
    assert!(foreign.exists(), "foreign dir must still exist");
}

#[test]
fn safely_delete_refuses_directory_without_temp_marker() {
    let data = tempdir();
    // Create a project directory under the data root that
    // doesn't carry the [temp] marker.
    let projects_dir = data.path().join(project::PROJECTS_SUBDIR);
    fs::create_dir_all(&projects_dir).unwrap();
    let named = projects_dir.join("MyOrders");
    fs::create_dir(&named).unwrap();
    fs::write(named.join("project.yaml"), "version: 1\n").unwrap();

    let err = safely_delete_temp_project(&named, data.path()).expect_err("must refuse");
    assert!(format!("{err}").contains("[temp]"), "got: {err}");
    assert!(named.exists());
}

#[test]
fn safely_delete_refuses_directory_with_unexpected_file() {
    let data = tempdir();
    let project = project::open_or_create(None, Some(data.path())).unwrap();
    let path = project.path().to_path_buf();
    // Drop a stranger file into the project dir.
    fs::write(path.join("notes.md"), "user notes\n").unwrap();
    drop(project);

    let err = safely_delete_temp_project(&path, data.path()).expect_err("must refuse");
    assert!(format!("{err}").contains("unexpected file"), "got: {err}");
    assert!(path.exists());
    assert!(path.join("notes.md").exists());
}

#[test]
fn safely_delete_allows_migration_backups_and_tmp_files() {
    let data = tempdir();
    let project = project::open_or_create(None, Some(data.path())).unwrap();
    let path = project.path().to_path_buf();
    fs::write(path.join("project.yaml.v1.bak"), "old\n").unwrap();
    fs::write(path.join("project.yaml.tmp"), "stage\n").unwrap();
    drop(project);

    safely_delete_temp_project(&path, data.path()).expect("should delete");
    assert!(!path.exists());
}

#[test]
fn safely_delete_allows_undo_snapshot_ring() {
    // A temp that was modified then undone back to empty can still
    // carry the `.snapshots/` ring; it must remain auto-deletable
    // (ADR-0006 Amendment 1).
    let data = tempdir();
    let project = project::open_or_create(None, Some(data.path())).unwrap();
    let path = project.path().to_path_buf();
    let snaps = path.join(".snapshots");
    fs::create_dir_all(snaps.join("3")).unwrap();
    fs::write(snaps.join("index.yaml"), "next_id: 4\nundo: []\nredo: []\n").unwrap();
    fs::write(snaps.join("3").join("playground.db"), [0u8; 16]).unwrap();
    drop(project);

    safely_delete_temp_project(&path, data.path()).expect("should delete");
    assert!(!path.exists());
}

#[cfg(unix)]
#[test]
fn safely_delete_refuses_symlink_top_level() {
    use std::os::unix::fs::symlink;
    let data = tempdir();
    let real_target = tempdir();
    let projects_dir = data.path().join(project::PROJECTS_SUBDIR);
    fs::create_dir_all(&projects_dir).unwrap();
    let link = projects_dir.join("20260507-[temp]-aaa-bbb-ccc");
    symlink(real_target.path(), &link).unwrap();

    let err = safely_delete_temp_project(&link, data.path()).expect_err("must refuse");
    assert!(format!("{err}").contains("symbolic link"), "got: {err}");
    // Real target untouched.
    assert!(real_target.path().exists());
    // Symlink itself untouched.
    assert!(link.exists());
}

#[test]
fn unmodified_temp_with_residual_csv_in_data_dir_is_not_unmodified() {
    let data = tempdir();
    let project = project::open_or_create(None, Some(data.path())).unwrap();
    // Hand-drop a CSV into the data dir without going through
    // the persistence layer. Schema in yaml is still empty.
    let csv = project.path().join("data").join("Stranger.csv");
    fs::write(&csv, "id\n1\n").unwrap();
    assert!(
        !project.is_unmodified_temp(),
        "non-empty data dir must disqualify the unmodified-temp check",
    );
}

#[test]
fn list_projects_sorts_by_mtime() {
    let data = tempdir();

    // Create two projects in succession; the second has a
    // newer mtime on its project.yaml.
    let _first = project::open_or_create(None, Some(data.path())).unwrap();
    let _first_path = _first.path().to_path_buf();
    drop(_first);

    // Sleep a hair to ensure different mtimes on filesystems
    // with second-resolution timestamps.
    std::thread::sleep(std::time::Duration::from_millis(1100));

    let _second = project::open_or_create(None, Some(data.path())).unwrap();
    let _second_path = _second.path().to_path_buf();
    drop(_second);

    let listings = project::list_projects(data.path());
    assert!(listings.len() >= 2, "got {} listings", listings.len());
    // Newer first.
    assert!(listings[0].path > listings[1].path || listings[0].modified >= listings[1].modified);
    for l in &listings {
        // Both are temp projects (auto-named with [temp]).
        assert_eq!(l.kind, ProjectKind::Temp);
    }
}
