//! Tests for the filesystem config watcher.
use super::*;

// ── extract_story_id_from_plan_path ──────────────────────────────────────────

#[test]
fn extracts_story_id_from_plan_path() {
    let git_root = PathBuf::from("/proj");
    let plan = PathBuf::from("/proj/.huskies/worktrees/42_story_foo/PLAN.md");
    assert_eq!(
        extract_story_id_from_plan_path(&plan, &git_root),
        Some("42_story_foo".to_string())
    );
}

#[test]
fn plan_path_wrong_filename_returns_none() {
    let git_root = PathBuf::from("/proj");
    let other = PathBuf::from("/proj/.huskies/worktrees/42_story_foo/README.md");
    assert!(extract_story_id_from_plan_path(&other, &git_root).is_none());
}

#[test]
fn plan_path_not_in_worktrees_returns_none() {
    let git_root = PathBuf::from("/proj");
    let nested = PathBuf::from("/proj/.huskies/worktrees/42_story_foo/sub/PLAN.md");
    assert!(extract_story_id_from_plan_path(&nested, &git_root).is_none());
}

#[test]
fn plan_path_wrong_root_returns_none() {
    let git_root = PathBuf::from("/proj");
    let other_root = PathBuf::from("/other/.huskies/worktrees/42_story_foo/PLAN.md");
    assert!(extract_story_id_from_plan_path(&other_root, &git_root).is_none());
}

// ── plan_state_for_path ──────────────────────────────────────────────────────

#[test]
fn plan_state_missing_when_file_absent() {
    use crate::pipeline_state::PlanState;
    let path = PathBuf::from("/nonexistent/PLAN.md");
    assert_eq!(plan_state_for_path(&path), PlanState::Missing);
}

#[test]
fn plan_state_drafted_when_file_contains_tbd() {
    use crate::pipeline_state::PlanState;
    use std::io::Write;
    let tmp = tempfile::NamedTempFile::new().unwrap();
    writeln!(tmp.as_file(), "# Plan\n- step 1 <TBD>\n- step 2").unwrap();
    assert_eq!(plan_state_for_path(tmp.path()), PlanState::Drafted);
}

#[test]
fn plan_state_confirmed_when_file_has_no_tbd() {
    use crate::pipeline_state::PlanState;
    use std::io::Write;
    let tmp = tempfile::NamedTempFile::new().unwrap();
    writeln!(tmp.as_file(), "# Plan\n- step 1\n- step 2").unwrap();
    assert_eq!(plan_state_for_path(tmp.path()), PlanState::Confirmed);
}

// ── is_config_file ────────────────────────────────────────────────────────

#[test]
fn is_config_file_identifies_root_project_toml() {
    let git_root = PathBuf::from("/proj");
    let config = git_root.join(".huskies").join("project.toml");
    assert!(is_config_file(&config, &git_root));
}

#[test]
fn is_config_file_identifies_root_agents_toml() {
    let git_root = PathBuf::from("/proj");
    let agents = git_root.join(".huskies").join("agents.toml");
    assert!(is_config_file(&agents, &git_root));
}

#[test]
fn is_config_file_rejects_worktree_copies() {
    let git_root = PathBuf::from("/proj");
    // project.toml inside a worktree must NOT be treated as the root config.
    let worktree_config =
        PathBuf::from("/proj/.huskies/worktrees/42_story_foo/.huskies/project.toml");
    assert!(!is_config_file(&worktree_config, &git_root));
}

#[test]
fn is_config_file_rejects_other_files() {
    let git_root = PathBuf::from("/proj");
    // Random files must not match.
    assert!(!is_config_file(
        &PathBuf::from("/proj/.huskies/work/2_current/42_story_foo.md"),
        &git_root
    ));
    assert!(!is_config_file(
        &PathBuf::from("/proj/.huskies/README.md"),
        &git_root
    ));
}

#[test]
fn is_config_file_rejects_wrong_root() {
    let git_root = PathBuf::from("/proj");
    let other_root_config = PathBuf::from("/other/.huskies/project.toml");
    assert!(!is_config_file(&other_root_config, &git_root));
}

// ── stage_metadata ────────────────────────────────────────────────────────

#[test]
fn stage_metadata_returns_correct_actions() {
    use crate::pipeline_state::{GitSha, Stage};
    use chrono::Utc;

    let (action, msg) = stage_metadata(
        &Stage::Coding {
            claim: None,
            plan: Default::default(),
            retries: 0,
        },
        "42_story_foo",
    );
    assert_eq!(action, "start");
    assert_eq!(msg, "huskies: start 42_story_foo");

    let (action, msg) = stage_metadata(
        &Stage::Done {
            merged_at: Utc::now(),
            merge_commit: GitSha(String::new()),
        },
        "42_story_foo",
    );
    assert_eq!(action, "done");
    assert_eq!(msg, "huskies: done 42_story_foo");

    let (action, msg) = stage_metadata(
        &Stage::Archived {
            archived_at: Utc::now(),
            reason: crate::pipeline_state::ArchiveReason::Completed,
        },
        "42_story_foo",
    );
    assert_eq!(action, "accept");
    assert_eq!(msg, "huskies: accept 42_story_foo");
}

// ── sweep_done_to_archived (CRDT-based) ─────────────────────────────────
//
// The sweep function reads from `read_all_typed()` and checks
// `Stage::Done { merged_at, .. }`.  Items created via
// `write_item_with_content("5_done")` project `merged_at = Utc::now()`,
// so we test with Duration::ZERO to sweep immediately and with a long
// retention to verify items are kept.  No filesystem access is involved.

#[test]
fn sweep_moves_old_items_to_archived() {
    crate::db::ensure_content_store();
    crate::db::write_item_with_content(
        "9880_story_sweep_old",
        "5_done",
        "---\nname: old\n---\n",
        crate::db::ItemMeta::named("old"),
    );

    // With ZERO retention, any Done item should be swept.
    sweep_done_to_archived(Duration::ZERO);

    // Verify the item was moved to 6_archived in the CRDT.
    let items = crate::pipeline_state::read_all_typed();
    let item = items
        .iter()
        .find(|i| i.story_id.0 == "9880_story_sweep_old");
    assert!(
        item.is_some_and(|i| matches!(i.stage, crate::pipeline_state::Stage::Archived { .. })),
        "item should be archived after sweep"
    );
}

#[test]
fn sweep_keeps_recent_items_in_done() {
    crate::db::ensure_content_store();
    crate::db::write_item_with_content(
        "9881_story_sweep_new",
        "5_done",
        "---\nname: new\n---\n",
        crate::db::ItemMeta::named("new"),
    );

    // With a very long retention, the item (merged_at ≈ now) should stay.
    sweep_done_to_archived(Duration::from_secs(999_999));

    let items = crate::pipeline_state::read_all_typed();
    let item = items
        .iter()
        .find(|i| i.story_id.0 == "9881_story_sweep_new");
    assert!(
        item.is_some_and(|i| matches!(i.stage, crate::pipeline_state::Stage::Done { .. })),
        "item should remain in Done with long retention"
    );
}

#[test]
fn sweep_respects_custom_retention() {
    crate::db::ensure_content_store();
    crate::db::write_item_with_content(
        "9882_story_sweep_custom",
        "5_done",
        "---\nname: custom\n---\n",
        crate::db::ItemMeta::named("custom"),
    );

    // With ZERO retention, sweep should promote.
    sweep_done_to_archived(Duration::ZERO);

    let items = crate::pipeline_state::read_all_typed();
    let item = items
        .iter()
        .find(|i| i.story_id.0 == "9882_story_sweep_custom");
    assert!(
        item.is_some_and(|i| matches!(i.stage, crate::pipeline_state::Stage::Archived { .. })),
        "item should be archived with zero retention"
    );
}

/// Prove that the sweep reads `merged_at` from the CRDT (not `Utc::now()`).
///
/// This test sets `merged_at` to 10 seconds in the past and uses a 5-second
/// retention.  If the sweep were still using `Utc::now()` as the start time
/// (the original bug), the elapsed time would be ~0 and the item would NOT
/// be swept.  With the fix, the item is swept because 10s > 5s retention.
#[test]
fn sweep_uses_crdt_merged_at_not_utc_now() {
    crate::db::ensure_content_store();

    let ten_seconds_ago = (chrono::Utc::now() - chrono::Duration::seconds(10)).timestamp() as f64;

    // Write item in 5_done with an explicit past merged_at timestamp.
    crate::crdt_state::write_item_str(
        "9883_story_sweep_merged_at",
        "5_done",
        Some("merged_at test"),
        None,
        None,
        Some(ten_seconds_ago),
    );

    // 5-second retention: item is 10s old → should be swept.
    sweep_done_to_archived(Duration::from_secs(5));

    let items = crate::pipeline_state::read_all_typed();
    let item = items
        .iter()
        .find(|i| i.story_id.0 == "9883_story_sweep_merged_at");
    assert!(
        item.is_some_and(|i| matches!(i.stage, crate::pipeline_state::Stage::Archived { .. })),
        "item with merged_at 10s ago should be archived with 5s retention"
    );
}

// ── spawn_done_to_archived_subscriber (reactive) ─────────────────────────
//
// AC4: tests that the TransitionFired subscriber archives Done stories
// on transition rather than on a periodic tick.

/// Moving a story to Done fires the subscriber; with zero retention the
/// subscriber archives it immediately.
#[tokio::test]
async fn done_to_archived_subscriber_archives_on_transition() {
    crate::crdt_state::init_for_test();
    crate::db::ensure_content_store();

    let story_id = "9886_sub_archive_reactive";
    crate::db::write_item_with_content(
        story_id,
        "1_backlog",
        "---\nname: Reactive archive test\n---\n",
        crate::db::ItemMeta::named("Reactive archive test"),
    );

    // Zero retention: archive immediately after the Done transition.
    spawn_done_to_archived_subscriber(Duration::ZERO);

    // Trigger Done via Close event (valid from Backlog → Done).
    crate::pipeline_state::apply_transition(
        story_id,
        crate::pipeline_state::PipelineEvent::Close,
        None,
    )
    .expect("Close transition must succeed from Backlog");

    // Give the subscriber task time to process and archive.
    tokio::time::sleep(std::time::Duration::from_millis(300)).await;

    let items = crate::pipeline_state::read_all_typed();
    let item = items.iter().find(|i| i.story_id.0 == story_id);
    assert!(
        item.is_some_and(|i| matches!(i.stage, crate::pipeline_state::Stage::Archived { .. })),
        "story should be archived after Done transition with zero retention"
    );
}

/// Regression: simulates a server restart occurring between move-to-done and
/// the configured retention window expiry.
///
/// Before the fix the archive-deadline was held only in the reactive
/// subscriber's volatile sleep task; a restart would lose that task and the
/// item would never be archived.  The fix is that `sweep_done_to_archived`
/// reads `merged_at` from the CRDT (durable across restarts) and archives any
/// item whose age exceeds the retention, so the next periodic reconcile tick
/// after restart picks it up regardless of whether a sleep task existed.
#[test]
fn restart_scenario_sweep_archives_past_retention_after_sweep_tick() {
    crate::crdt_state::init_for_test();
    crate::db::ensure_content_store();

    let story_id = "9885_sweep_restart_regression";

    // Simulate: item moved to Done 10 seconds before the restart.
    // The reactive subscriber would have had a sleep task for the remaining
    // retention time; that task is now gone (process restarted).
    let ten_seconds_ago = (chrono::Utc::now() - chrono::Duration::seconds(10)).timestamp() as f64;
    crate::crdt_state::write_item_str(
        story_id,
        "5_done",
        Some("Restart regression test"),
        None,
        None,
        Some(ten_seconds_ago),
    );

    // The next periodic reconcile tick after restart calls sweep_done_to_archived
    // directly.  With 5-second retention and merged_at 10s ago, the item must
    // be archived even though no reactive subscriber sleep task exists.
    sweep_done_to_archived(Duration::from_secs(5));

    let items = crate::pipeline_state::read_all_typed();
    let item = items.iter().find(|i| i.story_id.0 == story_id);
    assert!(
        item.is_some_and(|i| matches!(i.stage, crate::pipeline_state::Stage::Archived { .. })),
        "item past retention must be archived on the next sweep tick after a server restart"
    );
}

/// Prove that an item with merged_at NEWER than done_retention is NOT swept.
#[test]
fn sweep_keeps_item_newer_than_retention() {
    crate::db::ensure_content_store();

    let one_second_ago = (chrono::Utc::now() - chrono::Duration::seconds(1)).timestamp() as f64;

    crate::crdt_state::write_item_str(
        "9884_story_sweep_recent",
        "5_done",
        Some("recent merged_at test"),
        None,
        None,
        Some(one_second_ago),
    );

    // 1-hour retention: item is only 1s old → should NOT be swept.
    sweep_done_to_archived(Duration::from_secs(3600));

    let items = crate::pipeline_state::read_all_typed();
    let item = items
        .iter()
        .find(|i| i.story_id.0 == "9884_story_sweep_recent");
    assert!(
        item.is_some_and(|i| matches!(i.stage, crate::pipeline_state::Stage::Done { .. })),
        "item with merged_at 1s ago should stay in Done with 1-hour retention"
    );
}
