use super::*;

use std::cell::RefCell;
use std::sync::Arc;

use quicksearch_core::coordinator::RootCount;
use quicksearch_core::db::repo::RootCounts;

// The widgets under test report themselves here so their identity can be
// checked across frames.
thread_local! {
    static WIDGETS: RefCell<Vec<(&'static str, egui::Id, egui::Rect)>> =
        const { RefCell::new(Vec::new()) };
}

pub(super) fn record_widget(tag: &'static str, response: &egui::Response) {
    WIDGETS.with(|w| w.borrow_mut().push((tag, response.id, response.rect)));
}

fn widget(tag: &str) -> (egui::Id, egui::Rect) {
    WIDGETS.with(|w| {
        w.borrow()
            .iter()
            .find(|(t, _, _)| *t == tag)
            .map(|(_, id, rect)| (*id, *rect))
            .unwrap_or_else(|| panic!("{} widget was not drawn", tag))
    })
}

fn idle_state() -> IndexerState {
    IndexerState {
        mode: IndexMode::Auto,
        activity: IndexingStatus::Idle,
        last_full_index: Some(0),
        files: Some(0),
        queued_events: 0,
        watcher: WatcherStatus::Active { dirs: 10 },
        reconcile: None,
        root_counts: Arc::new(Vec::new()),
    }
}

/// An idle state carrying figures for `/data`, the root `cfg_with_root`
/// configures.
fn counted_state(files: i64, fts: i64) -> IndexerState {
    IndexerState {
        root_counts: Arc::new(vec![RootCount {
            root: "/data".into(),
            counts: RootCounts { files, fts },
        }]),
        ..idle_state()
    }
}

/// A run in progress. `current_file` and the number of roots are the
/// parts that come and go from frame to frame in a real run.
fn running_state(roots: &[&str], current_file: Option<&str>) -> IndexerState {
    state_with(
        roots
            .iter()
            .map(|root| RootProgress {
                root: (*root).to_string(),
                phase: RootPhase::Walking,
                walked: 100,
                walk_total: Some(1000),
                extracted: 0,
                extract_total: None,
                current_file: current_file.map(str::to_string),
                active_workers: 4,
                total_workers: 4,
            })
            .collect(),
    )
}

/// A run whose roots are described one by one, for the rows whose
/// contents — not just their widget ids — are under test.
fn state_with(roots: Vec<RootProgress>) -> IndexerState {
    IndexerState {
        activity: IndexingStatus::Running {
            start_time: std::time::Instant::now(),
            roots,
        },
        ..idle_state()
    }
}

/// A run still in its prologue, before the walk has produced anything.
fn preparing_state(step: PrepStep) -> IndexerState {
    IndexerState {
        activity: IndexingStatus::Preparing {
            start_time: std::time::Instant::now(),
            step,
        },
        ..idle_state()
    }
}

use crate::test_ui::{click_at, painted_text};

fn raw_input(events: Vec<egui::Event>) -> egui::RawInput {
    crate::test_ui::raw_input(egui::vec2(1000.0, 900.0), events)
}

/// One frame of the real tab, with `events` delivered to it.
fn frame(
    ctx: &egui::Context,
    tab: &mut ManageTab,
    cfg: &Config,
    state: &IndexerState,
    events: Vec<egui::Event>,
) -> ManageActions {
    WIDGETS.with(|w| w.borrow_mut().clear());
    let mut actions = ManageActions::default();
    let out = ctx.run(raw_input(events), |ctx| {
        egui::CentralPanel::default().show(ctx, |ui| {
            actions = tab.ui(ui, state, cfg);
        });
    });
    crate::test_ui::assert_no_tofu(ctx, &out);
    actions
}

/// Every string the tab actually painted this frame, read back off the
/// shapes.
fn frame_text(ctx: &egui::Context, tab: &mut ManageTab, state: &IndexerState) -> Vec<String> {
    frame_text_with(ctx, tab, &cfg_with_root(), state)
}

fn frame_text_with(
    ctx: &egui::Context,
    tab: &mut ManageTab,
    cfg: &Config,
    state: &IndexerState,
) -> Vec<String> {
    WIDGETS.with(|w| w.borrow_mut().clear());
    let out = ctx.run(raw_input(vec![]), |ctx| {
        egui::CentralPanel::default().show(ctx, |ui| {
            tab.ui(ui, state, cfg);
        });
    });
    painted_text(&out)
}

fn pointer(pos: egui::Pos2, pressed: bool) -> egui::Event {
    egui::Event::PointerButton {
        pos,
        button: egui::PointerButton::Primary,
        pressed,
        modifiers: egui::Modifiers::NONE,
    }
}

fn cfg_with_root() -> Config {
    let mut cfg = Config::default();
    cfg.paths.indexing_paths = vec!["/data".into()];
    cfg
}

fn staged_workers(tab: &ManageTab) -> Option<usize> {
    tab.draft
        .as_ref()
        .unwrap()
        .indexing
        .root_workers
        .get("/data")
        .copied()
}

/// The per-root worker field must keep the same widget id however the
/// status above it changes: egui hangs focus and in-progress text off
/// that id, so a field that is renamed mid-run silently drops the edit.
#[test]
fn the_worker_field_keeps_its_identity_as_the_status_changes() {
    let ctx = crate::test_ui::ctx();
    let mut tab = ManageTab::new();
    let cfg = cfg_with_root();

    frame(
        &ctx,
        &mut tab,
        &cfg,
        &running_state(&["/data"], None),
        vec![],
    );
    let (baseline, _) = widget("workers");

    for state in [
        running_state(&["/data"], Some("/data/file")),
        running_state(&["/data", "/other"], None),
        idle_state(),
        // The per-root figures appear and disappear on the same row as the
        // field, which is the case a conditionally-drawn label would break.
        counted_state(1_234_567, 456_789),
        IndexerState {
            watcher: WatcherStatus::Off,
            ..idle_state()
        },
        IndexerState {
            activity: IndexingStatus::Error("boom".into()),
            ..idle_state()
        },
    ] {
        frame(&ctx, &mut tab, &cfg, &state, vec![]);
        assert_eq!(
            widget("workers").0,
            baseline,
            "status change moved the worker field"
        );
    }
}

/// Click the field, type a count, click Apply & Save — while a run is
/// reporting progress the whole time.
#[test]
fn a_typed_worker_count_reaches_the_applied_config() {
    let ctx = crate::test_ui::ctx();
    let mut tab = ManageTab::new();
    let cfg = cfg_with_root();

    frame(
        &ctx,
        &mut tab,
        &cfg,
        &running_state(&["/data"], None),
        vec![],
    );
    let field = widget("workers").1.center();
    frame(
        &ctx,
        &mut tab,
        &cfg,
        &running_state(&["/data"], None),
        click_at(field),
    );
    // The run starts reporting a file: one more label above the field.
    let busy = running_state(&["/data"], Some("/data/file"));
    frame(&ctx, &mut tab, &cfg, &busy, vec![]);
    frame(
        &ctx,
        &mut tab,
        &cfg,
        &busy,
        vec![egui::Event::Text("8".into())],
    );
    assert_eq!(staged_workers(&tab), Some(8), "typed count was not staged");

    let apply = widget("apply").1.center();
    let mut actions = frame(&ctx, &mut tab, &cfg, &busy, click_at(apply));
    if actions.apply_config.is_none() {
        // egui fires a click on release; give it the follow-up frame.
        actions = frame(&ctx, &mut tab, &cfg, &busy, vec![]);
    }
    let applied = actions
        .apply_config
        .expect("Apply & Save produced a config");
    assert_eq!(applied.indexing.root_workers.get("/data"), Some(&8));
}

/// The other way to set the field: drag it.
#[test]
fn a_dragged_worker_count_is_staged() {
    let ctx = crate::test_ui::ctx();
    let mut tab = ManageTab::new();
    let cfg = cfg_with_root();
    let busy = running_state(&["/data"], None);

    frame(&ctx, &mut tab, &cfg, &busy, vec![]);
    let field = widget("workers").1.center();
    frame(
        &ctx,
        &mut tab,
        &cfg,
        &busy,
        vec![egui::Event::PointerMoved(field), pointer(field, true)],
    );
    // Drag right across frames, with the status changing underneath.
    let mut pos = field;
    for state in [
        running_state(&["/data"], Some("/data/a")),
        running_state(&["/data"], None),
        running_state(&["/data"], Some("/data/b")),
    ] {
        pos.x += 4.0;
        frame(
            &ctx,
            &mut tab,
            &cfg,
            &state,
            vec![egui::Event::PointerMoved(pos)],
        );
    }
    frame(&ctx, &mut tab, &cfg, &busy, vec![pointer(pos, false)]);
    assert!(
        staged_workers(&tab).is_some_and(|w| w > 0),
        "dragging staged nothing: {:?}",
        staged_workers(&tab)
    );
}

fn root_progress(phase: RootPhase, walked: usize, walk_total: Option<usize>) -> RootProgress {
    RootProgress {
        root: "/data".to_string(),
        phase,
        walked,
        walk_total,
        extracted: 0,
        extract_total: None,
        current_file: None,
        active_workers: 4,
        total_workers: 4,
    }
}

/// The walk-total estimate counts *tree entries* and runs far ahead of
/// the files a walk emits; a finished root must show the exact count.
#[test]
fn a_finished_root_reports_its_exact_count_not_the_estimate() {
    let ctx = crate::test_ui::ctx();
    let mut tab = ManageTab::new();
    let mut done = root_progress(RootPhase::Done, 261_088, Some(6_677_062));
    done.extracted = 238_929;
    done.active_workers = 0;
    done.total_workers = 0;

    let text = frame_text(&ctx, &mut tab, &state_with(vec![done])).join(" | ");
    assert!(
        text.contains("indexed 261,088, extracted 238,929"),
        "finished row: {}",
        text
    );
    assert!(
        !text.contains("6,677,062"),
        "the stale estimate is still on screen: {}",
        text
    );
}

/// The folder list carries the last completed run's figures, so what a root
/// holds survives the run that counted it.
#[test]
fn a_configured_root_shows_what_the_last_run_counted() {
    let ctx = crate::test_ui::ctx();
    let mut tab = ManageTab::new();

    let text = frame_text(&ctx, &mut tab, &counted_state(1_234_567, 456_789)).join(" | ");
    assert!(
        text.contains("indexed 1,234,567 · extracted 456,789"),
        "folder row: {}",
        text
    );
}

/// A root with no stored figures says so. Zero would be a claim — that the
/// folder is empty — where the truth is that nothing has counted it yet.
#[test]
fn a_root_the_index_has_never_counted_says_so() {
    let ctx = crate::test_ui::ctx();
    let mut tab = ManageTab::new();

    let text = frame_text(&ctx, &mut tab, &idle_state()).join(" | ");
    assert!(text.contains("not yet indexed"), "folder row: {}", text);
    assert!(
        !text.contains("indexed 0 · extracted 0"),
        "an uncounted root must not read as an empty one: {}",
        text
    );
}

/// Figures are matched to the root by the spelling the config uses, so a
/// root the coordinator has not published anything for keeps the placeholder
/// rather than borrowing another root's numbers.
#[test]
fn figures_belong_to_the_root_they_were_counted_for() {
    let ctx = crate::test_ui::ctx();
    let mut tab = ManageTab::new();
    let state = IndexerState {
        root_counts: Arc::new(vec![RootCount {
            root: "/somewhere-else".into(),
            counts: RootCounts { files: 99, fts: 9 },
        }]),
        ..idle_state()
    };

    let text = frame_text(&ctx, &mut tab, &state).join(" | ");
    assert!(text.contains("not yet indexed"), "folder row: {}", text);
    assert!(
        !text.contains("99"),
        "borrowed another root's count: {}",
        text
    );
}

/// The estimate is shown while the walk runs — but never below what has
/// already been walked, or the row would read as a hang at 100%.
#[test]
fn a_walking_root_shows_the_estimate_raised_to_what_it_has_walked() {
    let ctx = crate::test_ui::ctx();
    let mut tab = ManageTab::new();

    let honest = frame_text(
        &ctx,
        &mut tab,
        &state_with(vec![root_progress(RootPhase::Walking, 100, Some(1000))]),
    )
    .join(" | ");
    assert!(honest.contains("100 / 1,000 (10%)"), "{}", honest);

    let overtaken = frame_text(
        &ctx,
        &mut tab,
        &state_with(vec![root_progress(RootPhase::Walking, 1500, Some(1000))]),
    )
    .join(" | ");
    assert!(
        overtaken.contains("1,500 / 1,500 (100%)"),
        "an overtaken estimate must be raised, not shown: {}",
        overtaken
    );
}

/// Like [`frame_text`], but keeping the color each run of text was
/// painted in — the only way to check a hint.
fn frame_spans(
    ctx: &egui::Context,
    tab: &mut ManageTab,
    state: &IndexerState,
) -> Vec<(String, egui::Color32)> {
    WIDGETS.with(|w| w.borrow_mut().clear());
    let cfg = cfg_with_root();
    let out = ctx.run(raw_input(vec![]), |ctx| {
        egui::CentralPanel::default().show(ctx, |ui| {
            tab.ui(ui, state, &cfg);
        });
    });
    crate::test_ui::painted_spans(&out)
}

/// The phase word carries a color hint, and it has to hold up in both
/// themes.
#[test]
fn every_phase_word_is_painted_in_its_hint_color() {
    for theme in [egui::Theme::Dark, egui::Theme::Light] {
        let ctx = crate::test_ui::ctx();
        ctx.set_theme(theme);
        let mut tab = ManageTab::new();
        let colors = crate::color::palette(theme == egui::Theme::Dark);

        for (phase, word, want) in [
            (RootPhase::Walking, "indexing", colors.yellow),
            (RootPhase::Extracting, "extracting text", colors.green),
            (RootPhase::Done, "done", colors.blue),
        ] {
            let state = state_with(vec![root_progress(phase, 100, Some(1000))]);
            let spans = frame_spans(&ctx, &mut tab, &state);
            let hint = spans.iter().find(|(text, _)| text == word).map(|(_, c)| *c);
            assert_eq!(hint, Some(want), "{:?}: {:?} in {:?}", theme, word, spans);
        }
    }
}

/// No count has landed yet: an indeterminate row, not a fabricated one.
#[test]
fn a_walking_root_without_a_count_shows_no_denominator() {
    let ctx = crate::test_ui::ctx();
    let mut tab = ManageTab::new();
    let text = frame_text(
        &ctx,
        &mut tab,
        &state_with(vec![root_progress(RootPhase::Walking, 100, None)]),
    )
    .join(" | ");
    assert!(text.contains("100 files"), "{}", text);
    assert!(!text.contains(" / "), "invented a denominator: {}", text);
}

/// Every step of the prologue names itself and carries a clock.
#[test]
fn each_prologue_step_says_what_it_is_waiting_on() {
    let ctx = crate::test_ui::ctx();
    let mut tab = ManageTab::new();

    for (step, expected) in [
        (PrepStep::PreviousRun, "Finishing the previous run…"),
        (PrepStep::OpeningIndex, "Opening the index…"),
    ] {
        let text = frame_text(&ctx, &mut tab, &preparing_state(step)).join(" | ");
        assert!(text.contains(expected), "{}", text);
        // The clock is the point of the row.
        assert!(text.contains("0:00"), "no elapsed time shown: {}", text);
    }
}

/// The reconcile is the long one, and the only prologue step with
/// something to count. It reports its position in the scan.
#[test]
fn a_reconcile_reports_how_far_through_the_index_it_is() {
    let ctx = crate::test_ui::ctx();
    let mut tab = ManageTab::new();
    let text = frame_text(
        &ctx,
        &mut tab,
        &preparing_state(PrepStep::Reconciling(ReconcileProgress {
            examined: 2_500_000,
            total: Some(8_000_000),
            deleted: 1_204,
            recontented: 0,
        })),
    )
    .join(" | ");

    assert!(text.contains("Applying configuration change"), "{}", text);
    assert!(
        text.contains("2,500,000 / 8,000,000 (31%) entries checked"),
        "{}",
        text
    );
    assert!(text.contains("1,204 entries removed"), "{}", text);
}

/// Whole-range deletions read no rows, so the scan can reach the display
/// with nothing to divide by; it must not invent a denominator.
#[test]
fn a_reconcile_without_a_row_count_shows_no_denominator() {
    let ctx = crate::test_ui::ctx();
    let mut tab = ManageTab::new();
    let text = frame_text(
        &ctx,
        &mut tab,
        &preparing_state(PrepStep::Reconciling(ReconcileProgress::default())),
    )
    .join(" | ");
    assert!(text.contains("0 entries checked"), "{}", text);
    assert!(!text.contains(" / "), "invented a denominator: {}", text);
}

/// A prune between runs is reported, even though the activity really is
/// `Idle` while the thread scans every row.
#[test]
fn a_prune_between_runs_is_reported_instead_of_idle() {
    let ctx = crate::test_ui::ctx();
    let mut tab = ManageTab::new();
    let state = IndexerState {
        reconcile: Some(ReconcileState::Running(ReconcileProgress {
            examined: 40_000,
            total: Some(80_000),
            deleted: 0,
            recontented: 0,
        })),
        ..idle_state()
    };

    let text = frame_text(&ctx, &mut tab, &state).join(" | ");
    assert!(
        text.contains("Applying configuration change"),
        "the scan is invisible: {}",
        text
    );
    assert!(text.contains("40,000 / 80,000 (50%)"), "{}", text);
    assert!(
        !text.contains("Idle; last full index"),
        "reported idle while scanning: {}",
        text
    );
}

/// The pass itself can be over between two frames; the lingering summary
/// is the only evidence it happened.
#[test]
fn a_finished_prune_reports_what_it_did() {
    let ctx = crate::test_ui::ctx();
    let mut tab = ManageTab::new();
    let state = IndexerState {
        reconcile: Some(ReconcileState::Finished(ReconcileProgress {
            examined: 80_000,
            total: Some(80_000),
            deleted: 1_204,
            recontented: 7,
        })),
        ..idle_state()
    };

    let text = frame_text(&ctx, &mut tab, &state).join(" | ");
    assert!(
        text.contains("Configuration change applied"),
        "the finished pass left no trace: {}",
        text
    );
    assert!(text.contains("1,204 entries removed"), "{}", text);
    assert!(text.contains("7 entries re-examined"), "{}", text);
    assert!(
        !text.contains("Idle; last full index"),
        "the summary was replaced by the idle line: {}",
        text
    );
}

/// The static "Starting…" placeholder must not reappear.
#[test]
fn the_starting_placeholder_is_gone() {
    let ctx = crate::test_ui::ctx();
    let mut tab = ManageTab::new();
    for state in [
        preparing_state(PrepStep::PreviousRun),
        preparing_state(PrepStep::OpeningIndex),
        preparing_state(PrepStep::Reconciling(ReconcileProgress::default())),
        idle_state(),
    ] {
        let text = frame_text(&ctx, &mut tab, &state).join(" | ");
        assert!(!text.contains("Starting"), "{}", text);
    }
}

use quicksearch_core::testutil::scratch_dir;

fn write_bytes(path: &Path, len: usize) {
    std::fs::write(path, vec![b'x'; len]).expect("write");
}

/// A config whose database is `path`, for the probe and the rendered row.
fn cfg_with_db(path: &Path) -> Config {
    let mut cfg = cfg_with_root();
    cfg.paths.database_path = path.to_string_lossy().into_owned();
    cfg
}

/// Each of the three files SQLite keeps for one database is added, and
/// nothing else that happens to sit beside them is.
#[test]
fn db_size_counts_the_database_and_both_sidecars() {
    let dir = scratch_dir("parts");
    let db = dir.join("index.sqlite");
    write_bytes(&db, 4096);
    assert_eq!(measure_db_size(&db), 4096, "the database itself");
    write_bytes(&dir.join("index.sqlite-wal"), 1024);
    assert_eq!(measure_db_size(&db), 5120, "-wal was not added");
    write_bytes(&dir.join("index.sqlite-shm"), 32);
    assert_eq!(measure_db_size(&db), 5152, "-shm was not added");

    // Decoys: a rollback journal (never present in WAL mode) and an
    // unrelated neighbour.
    write_bytes(&dir.join("index.sqlite-journal"), 999);
    write_bytes(&dir.join("index.sqlite.bak"), 777);
    assert_eq!(measure_db_size(&db), 5152, "a decoy was counted");
    let _ = std::fs::remove_dir_all(&dir);
}

/// Before the first indexing run there is no database at all, and a
/// closed database has no sidecars — neither is an error.
#[test]
fn a_missing_database_measures_zero() {
    let dir = scratch_dir("missing");
    let db = dir.join("index.sqlite");
    assert_eq!(measure_db_size(&db), 0);
    assert_eq!(measure_db_size(&dir), 0, "a directory");

    write_bytes(&db, 100);
    assert_eq!(measure_db_size(&db), 100, "sidecars are optional");
    let _ = std::fs::remove_dir_all(&dir);
}

/// The probe answers from cache until the interval is up.
#[test]
fn the_probe_caches_until_the_refresh_interval_is_up() {
    let dir = scratch_dir("cache");
    let db = dir.join("index.sqlite");
    write_bytes(&db, 100);
    let cfg = cfg_with_db(&db);
    let mut probe = DbSizeProbe::default();
    let t0 = Instant::now();

    assert_eq!(probe.size(&cfg, t0), 100);
    write_bytes(&db, 5000);
    assert_eq!(
        probe.size(&cfg, t0 + DB_SIZE_REFRESH / 2),
        100,
        "restatted before the interval was up"
    );
    assert_eq!(
        probe.size(&cfg, t0 + DB_SIZE_REFRESH),
        5000,
        "the refresh never happened"
    );
    let _ = std::fs::remove_dir_all(&dir);
}

/// A database path edited on the Settings tab must not keep showing the old
/// database's size for the rest of the interval.
#[test]
fn the_probe_follows_a_changed_database_path() {
    let dir = scratch_dir("moved");
    let first = dir.join("index.sqlite");
    let second = dir.join("other.sqlite");
    write_bytes(&first, 100);
    write_bytes(&second, 7000);
    let mut probe = DbSizeProbe::default();
    let t0 = Instant::now();

    assert_eq!(probe.size(&cfg_with_db(&first), t0), 100);
    assert_eq!(
        probe.size(&cfg_with_db(&second), t0),
        7000,
        "a new path must restat at once, not wait out the interval"
    );
    let _ = std::fs::remove_dir_all(&dir);
}

/// The number reaches the screen, and it is the total of all three files
/// rather than the database on its own.
#[test]
fn the_status_row_shows_the_total_index_size() {
    let dir = scratch_dir("render");
    let db = dir.join("index.sqlite");
    write_bytes(&db, 3_000_000);
    write_bytes(&dir.join("index.sqlite-wal"), 200_000);
    write_bytes(&dir.join("index.sqlite-shm"), 32_768);

    let ctx = crate::test_ui::ctx();
    let mut tab = ManageTab::new();
    let text = frame_text_with(&ctx, &mut tab, &cfg_with_db(&db), &idle_state()).join(" | ");
    assert!(
        text.contains("Index size: 3.2 MB"),
        "the size is not on screen: {}",
        text
    );
    let _ = std::fs::remove_dir_all(&dir);
}

/// The size tooltip's levers each have to keep matching a control that
/// exists.
#[test]
fn hovering_the_size_explains_how_to_shrink_the_index() {
    let dir = scratch_dir("hover");
    let db = dir.join("index.sqlite");
    write_bytes(&db, 2048);

    let ctx = crate::test_ui::ctx();
    // egui holds tooltips back for a third of a second, and frames here
    // are only 1/60 s of simulated time apart.
    ctx.style_mut(|s| s.interaction.tooltip_delay = 0.0);
    let mut tab = ManageTab::new();
    let cfg = cfg_with_db(&db);

    frame_text_with(&ctx, &mut tab, &cfg, &idle_state());
    let at = widget("db-size").1.center();
    frame(
        &ctx,
        &mut tab,
        &cfg,
        &idle_state(),
        vec![egui::Event::PointerMoved(at)],
    );
    // The tooltip is painted in the frame after the pointer lands.
    let text = frame_text_with(&ctx, &mut tab, &cfg, &idle_state()).join(" | ");

    assert!(
        text.contains("To reduce the index size"),
        "no tooltip: {}",
        text
    );
    for lever in [
        "ignore filters",
        "Indexed folders",
        "whitelist",
        "Store text for snippets",
        "Settings tab",
    ] {
        assert!(text.contains(lever), "tooltip never mentions {}", lever);
    }
    let _ = std::fs::remove_dir_all(&dir);
}

fn synced_tab(config: &Config) -> ManageTab {
    let mut tab = ManageTab::new();
    tab.sync_editors(config);
    tab
}

#[test]
fn identical_config_leaves_draft_untouched() {
    let cfg = Config::default();
    let mut tab = synced_tab(&cfg);
    // Stage an edit, then sync against the unchanged config.
    tab.draft
        .as_mut()
        .unwrap()
        .indexing
        .ignore_patterns
        .push("*.log".into());
    tab.sync_editors(&cfg);
    assert!(tab
        .draft
        .as_ref()
        .unwrap()
        .indexing
        .ignore_patterns
        .contains(&"*.log".to_string()));
}

#[test]
fn clean_draft_adopts_external_changes_wholesale() {
    let cfg = Config::default();
    let mut tab = synced_tab(&cfg);
    let mut external = cfg.clone();
    external.indexing.ignore_patterns.push("*.log".into());
    external.search.fuzzy_default = !external.search.fuzzy_default;
    tab.sync_editors(&external);
    assert_eq!(tab.draft.as_ref().unwrap(), &external);
    assert_eq!(tab.baseline.as_ref().unwrap(), &external);
}

#[test]
fn staged_edits_survive_an_external_persist() {
    let cfg = Config::default();
    let mut tab = synced_tab(&cfg);
    // Stage a removal of the first default pattern.
    let removed = tab
        .draft
        .as_mut()
        .unwrap()
        .indexing
        .ignore_patterns
        .remove(0);
    // Meanwhile the Search tab persists a new filter.
    let mut external = cfg.clone();
    external.indexing.ignore_patterns.push("*.log".into());
    tab.sync_editors(&external);
    let draft = tab.draft.as_ref().unwrap();
    assert!(!draft.indexing.ignore_patterns.contains(&removed));
    assert!(draft
        .indexing
        .ignore_patterns
        .contains(&"*.log".to_string()));
    assert_eq!(tab.baseline.as_ref().unwrap(), &external);
}

#[test]
fn dirty_draft_adopts_sections_owned_elsewhere() {
    let cfg = Config::default();
    let mut tab = synced_tab(&cfg);
    tab.draft
        .as_mut()
        .unwrap()
        .indexing
        .ignore_patterns
        .push("*.bak".into());
    // The fuzzy toggle saves the config directly, outside this tab.
    let mut external = cfg.clone();
    external.search.fuzzy_default = !cfg.search.fuzzy_default;
    tab.sync_editors(&external);
    let draft = tab.draft.as_ref().unwrap();
    assert_eq!(draft.search.fuzzy_default, external.search.fuzzy_default);
    assert!(draft
        .indexing
        .ignore_patterns
        .contains(&"*.bak".to_string()));
}

#[test]
fn external_pattern_is_not_duplicated_into_a_draft_that_has_it() {
    let cfg = Config::default();
    let mut tab = synced_tab(&cfg);
    tab.draft
        .as_mut()
        .unwrap()
        .indexing
        .ignore_patterns
        .push("*.log".into());
    let mut external = cfg.clone();
    external.indexing.ignore_patterns.push("*.log".into());
    tab.sync_editors(&external);
    let count = tab
        .draft
        .as_ref()
        .unwrap()
        .indexing
        .ignore_patterns
        .iter()
        .filter(|p| p.as_str() == "*.log")
        .count();
    assert_eq!(count, 1);
}

#[test]
fn a_fresh_tab_is_not_dirty_before_or_after_its_first_sync() {
    let mut tab = ManageTab::new();
    assert!(!tab.is_dirty(), "no draft yet");
    tab.sync_editors(&cfg_with_root());
    assert!(!tab.is_dirty(), "a fresh sync stages nothing");
}

#[test]
fn a_staged_edit_reads_dirty_and_discard_reverts_it() {
    let cfg = cfg_with_root();
    let mut tab = synced_tab(&cfg);
    tab.draft
        .as_mut()
        .unwrap()
        .indexing
        .ignore_patterns
        .push("*.jpg".into());
    tab.new_ignore = "half-typ".into();
    tab.root_error = Some("bad".into());
    assert!(tab.is_dirty());

    tab.discard();
    assert!(!tab.is_dirty());
    assert!(tab.new_ignore.is_empty(), "scratch boxes are cleared too");
    assert!(tab.root_error.is_none());
    tab.sync_editors(&cfg);
    assert_eq!(tab.draft.as_ref(), Some(&cfg), "resynced from live");
    assert!(!tab.is_dirty());
}

/// `parse_lines` drops blank lines and trims entries, so cosmetic
/// whitespace in the extension editor must not read as an edit.
#[test]
fn a_trailing_newline_in_the_extension_editor_is_not_dirty() {
    let mut cfg = cfg_with_root();
    cfg.indexing.content_extensions = vec!["txt".into(), "md".into()];
    let mut tab = synced_tab(&cfg);
    tab.ext_filter_text.push('\n');
    assert!(!tab.is_dirty(), "a blank line is not an edit");
    tab.ext_filter_text.push_str("pdf");
    assert!(tab.is_dirty(), "a real entry is");
}

/// The mode buttons write `auto_index` straight to the live config; a
/// stale copy frozen into the draft must not read dirty or revert the
/// mode on apply.
#[test]
fn a_live_mode_flip_does_not_read_as_dirty() {
    let mut cfg = cfg_with_root();
    cfg.indexing.auto_index = true;
    let mut tab = synced_tab(&cfg);

    // Stop clicked, nothing staged: the tab resyncs and stays clean.
    let mut stopped = cfg.clone();
    stopped.indexing.auto_index = false;
    tab.sync_editors(&stopped);
    assert!(!tab.is_dirty());

    // Staged edit, then Return to Automatic: dirty because of the edit
    // only, and the draft adopts the live mode.
    tab.draft
        .as_mut()
        .unwrap()
        .indexing
        .ignore_patterns
        .push("*.jpg".into());
    let mut auto_again = stopped.clone();
    auto_again.indexing.auto_index = true;
    tab.sync_editors(&auto_again);
    assert!(tab.is_dirty(), "the staged pattern is still pending");
    let applied = tab.take_apply_config(&auto_again).expect("a config");
    assert!(
        applied.indexing.auto_index,
        "applying must not revert the live mode"
    );

    // Un-staging the edit reads clean again — not permanently dirty on
    // a stale mode copy.
    tab.draft.as_mut().unwrap().indexing.ignore_patterns.pop();
    assert!(!tab.is_dirty());
}

/// `take_apply_config` must leave the editors intact, so a rejected
/// config keeps the user's staged edits on screen.
#[test]
fn a_rejected_apply_keeps_the_draft() {
    let cfg = cfg_with_root();
    let mut tab = synced_tab(&cfg);
    tab.draft
        .as_mut()
        .unwrap()
        .paths
        .indexing_paths
        .push("/data/nested".into());

    let staged = tab.take_apply_config(&cfg).expect("a config to apply");
    assert!(staged
        .paths
        .indexing_paths
        .contains(&"/data/nested".to_string()));
    assert!(tab.baseline.is_some(), "baseline survives the attempt");
    assert!(tab.is_dirty(), "the staged root is still pending");

    tab.mark_applied();
    assert!(tab.baseline.is_none(), "a landed apply forces a resync");
    assert!(!tab.is_dirty());
}

/// The dirty label's coming and going must never rename the Apply button,
/// which egui hangs interaction state off.
#[test]
fn the_unsaved_label_appears_without_renaming_the_apply_button() {
    let ctx = crate::test_ui::ctx();
    let mut tab = ManageTab::new();
    let cfg = cfg_with_root();

    let clean = frame_text_with(&ctx, &mut tab, &cfg, &idle_state());
    assert!(
        !clean.iter().any(|t| t.contains("Unsaved changes")),
        "a clean tab must not claim unsaved changes"
    );
    let (clean_id, _) = widget("apply");

    tab.draft
        .as_mut()
        .unwrap()
        .indexing
        .ignore_patterns
        .push("*.jpg".into());
    let dirty = frame_text_with(&ctx, &mut tab, &cfg, &idle_state());
    assert!(
        dirty.iter().any(|t| t.contains("Unsaved changes")),
        "painted: {:?}",
        dirty
    );
    assert_eq!(widget("apply").0, clean_id, "the label renamed the button");
}

/// The status area gains and loses rows as a prune runs; every frame must
/// leave the widgets below it with the ids they had — an editor whose id
/// changes mid-edit loses its buffer.
#[test]
fn the_prune_rows_come_and_go_without_renaming_anything_below() {
    let ctx = crate::test_ui::ctx();
    let mut tab = ManageTab::new();
    let cfg = cfg_with_root();
    let progress = ReconcileProgress {
        examined: 40_000,
        total: Some(80_000),
        deleted: 12,
        recontented: 0,
    };

    frame_text_with(&ctx, &mut tab, &cfg, &idle_state());
    let (apply, _) = widget("apply");
    let (workers, _) = widget("workers");

    for reconcile in [
        Some(ReconcileState::Running(progress)),
        Some(ReconcileState::Finished(progress)),
        None,
    ] {
        let state = IndexerState {
            reconcile,
            ..idle_state()
        };
        frame_text_with(&ctx, &mut tab, &cfg, &state);
        assert_eq!(widget("apply").0, apply, "renamed by {reconcile:?}");
        assert_eq!(widget("workers").0, workers, "renamed by {reconcile:?}");
    }
}
