//! Auto-assign: scan pipeline stages and dispatch free agents to unassigned stories.

use std::path::Path;

use crate::config::ProjectConfig;
use crate::slog_warn;

use super::super::AgentPool;

impl AgentPool {
    /// Scan all active pipeline stages and start free agents for any unassigned work.
    ///
    /// Order of operations:
    /// 1. Promote backlog stories whose `depends_on` are all satisfied.
    /// 2. Assign coder and QA agents to stories in `2_current/` and `3_qa/`.
    /// 3. Trigger server-side merges (or auto-spawn mergemaster) for `4_merge/`.
    pub async fn auto_assign_available_work(&self, project_root: &Path) {
        // Promote any backlog stories whose dependencies are all done.
        self.promote_ready_backlog_stories();

        let config = match ProjectConfig::load(project_root) {
            Ok(c) => c,
            Err(e) => {
                slog_warn!("[auto-assign] Failed to load project config: {e}");
                return;
            }
        };

        // Process the coder (2_current/) and QA (3_qa/) stages.
        self.assign_pipeline_stages(project_root, &config).await;

        // Process the merge (4_merge/) stage.
        self.assign_merge_stage(project_root, &config).await;
    }
}

// ── Tests ──────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::super::super::AgentPool;
    use crate::agents::AgentStatus;
    use crate::io::watcher::WatcherEvent;
    use tokio::sync::broadcast;

    /// Story 203: auto_assign_available_work must detect a story in 2_current/
    /// with no active agent and start an agent for it.
    #[tokio::test]
    async fn auto_assign_picks_up_story_queued_in_current() {
        let tmp = tempfile::tempdir().unwrap();
        let sk = tmp.path().join(".huskies");
        std::fs::create_dir_all(&sk).unwrap();
        std::fs::write(
            sk.join("project.toml"),
            "[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n",
        )
        .unwrap();
        // Place the story in 2_current/ via CRDT (the only source of truth).
        crate::db::ensure_content_store();
        crate::db::write_item_with_content(
            "story-3",
            "2_current",
            "---\nname: Story 3\n---\n",
            crate::db::ItemMeta::named("Story 3"),
        );

        let pool = AgentPool::new_test(3001);
        // No agents are running — coder-1 is free.

        // auto_assign will try to call start_agent, which will attempt to create
        // a worktree (will fail without a git repo) — that is fine. We only need
        // to verify the agent is registered as Pending before the background
        // task eventually fails.
        pool.auto_assign_available_work(tmp.path()).await;

        let agents = pool.agents.try_lock().unwrap();
        let has_pending = agents.values().any(|a| {
            a.agent_name == "coder-1"
                && matches!(a.status, AgentStatus::Pending | AgentStatus::Running)
        });
        assert!(
            has_pending,
            "auto_assign should have started coder-1 for story-3, but pool is empty"
        );
    }

    /// Story 265: auto_assign_available_work must skip spikes in 3_qa/ that
    /// have review_hold: true set in their front matter.
    #[tokio::test]
    async fn auto_assign_skips_spikes_with_review_hold() {
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path();

        // Create project.toml with a QA agent.
        let sk = root.join(".huskies");
        std::fs::create_dir_all(&sk).unwrap();
        std::fs::write(
            sk.join("project.toml"),
            "[[agents]]\nname = \"qa\"\nrole = \"qa\"\nmodel = \"test\"\nprompt = \"test\"\n",
        )
        .unwrap();

        // Put a spike in 3_qa/ with review_hold: true.
        let qa_dir = root.join(".huskies/work/3_qa");
        std::fs::create_dir_all(&qa_dir).unwrap();
        std::fs::write(
            qa_dir.join("20_spike_test.md"),
            "---\nname: Test Spike\nreview_hold: true\n---\n# Spike\n",
        )
        .unwrap();

        let (watcher_tx, _) = broadcast::channel::<WatcherEvent>(4);
        let pool = AgentPool::new(3001, watcher_tx);

        pool.auto_assign_available_work(root).await;

        // No agent should have been started for the spike.
        let agents = pool.agents.try_lock().unwrap();
        assert!(
            agents.is_empty(),
            "No agents should be assigned to a spike with review_hold"
        );
    }

    // ── Story 279: auto-assign respects agent stage from front matter ──────────

    /// When a story in 3_qa/ has `agent: coder-1` in its front matter but
    /// coder-1 is a coder-stage agent, auto-assign must NOT assign coder-1.
    /// Instead it should fall back to a free QA-stage agent.
    #[tokio::test]
    async fn auto_assign_ignores_coder_preference_when_story_is_in_qa_stage() {
        let tmp = tempfile::tempdir().unwrap();
        let sk = tmp.path().join(".huskies");
        std::fs::create_dir_all(&sk).unwrap();
        std::fs::write(
            sk.join("project.toml"),
            "[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n\n\
             [[agent]]\nname = \"qa-1\"\nstage = \"qa\"\n",
        )
        .unwrap();
        // Story in 3_qa/ with a preferred coder-stage agent — write via CRDT.
        crate::db::ensure_content_store();
        crate::db::write_item_with_content(
            "9930_story_qa1",
            "3_qa",
            "---\nname: QA Story\nagent: coder-1\n---\n",
            crate::db::ItemMeta {
                name: Some("QA Story".into()),
                agent: Some("coder-1".into()),
                ..Default::default()
            },
        );

        let pool = AgentPool::new_test(3001);

        pool.auto_assign_available_work(tmp.path()).await;

        let agents = pool.agents.try_lock().unwrap();
        // coder-1 must NOT have been assigned to the QA story (wrong stage).
        let coder_assigned_to_qa = agents.iter().any(|(key, a)| {
            key.contains("9930_story_qa1")
                && a.agent_name == "coder-1"
                && matches!(a.status, AgentStatus::Pending | AgentStatus::Running)
        });
        assert!(
            !coder_assigned_to_qa,
            "coder-1 should not be assigned to a QA-stage story"
        );
        // qa-1 should have been assigned instead.
        let qa_assigned = agents.iter().any(|(key, a)| {
            key.contains("9930_story_qa1")
                && a.agent_name == "qa-1"
                && matches!(a.status, AgentStatus::Pending | AgentStatus::Running)
        });
        assert!(
            qa_assigned,
            "qa-1 should be assigned as fallback for the QA-stage story"
        );
    }

    /// When a story in 2_current/ has `agent: coder-1` in its front matter and
    /// coder-1 is a coder-stage agent, auto-assign must respect the preference
    /// and assign coder-1 (not fall back to some other coder).
    #[tokio::test]
    async fn auto_assign_respects_coder_preference_when_story_is_in_current_stage() {
        let tmp = tempfile::tempdir().unwrap();
        let sk = tmp.path().join(".huskies");
        std::fs::create_dir_all(sk.join("work/2_current")).unwrap();
        std::fs::write(
            sk.join("project.toml"),
            "[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n\n\
             [[agent]]\nname = \"coder-2\"\nstage = \"coder\"\n",
        )
        .unwrap();
        // Story in 2_current/ with a preferred coder-1 agent.
        crate::db::ensure_content_store();
        crate::db::write_item_with_content(
            "story-pref",
            "2_current",
            "---\nname: Coder Story\nagent: coder-1\n---\n",
            crate::db::ItemMeta {
                name: Some("Coder Story".into()),
                agent: Some("coder-1".into()),
                ..Default::default()
            },
        );

        let pool = AgentPool::new_test(3001);

        pool.auto_assign_available_work(tmp.path()).await;

        let agents = pool.agents.try_lock().unwrap();
        // coder-1 should have been picked (it matches the stage and is preferred).
        let coder1_assigned = agents.values().any(|a| {
            a.agent_name == "coder-1"
                && matches!(a.status, AgentStatus::Pending | AgentStatus::Running)
        });
        assert!(
            coder1_assigned,
            "coder-1 should be assigned when it matches the stage and is preferred"
        );
        // coder-2 must NOT be assigned (not preferred).
        let coder2_assigned = agents.values().any(|a| {
            a.agent_name == "coder-2"
                && matches!(a.status, AgentStatus::Pending | AgentStatus::Running)
        });
        assert!(
            !coder2_assigned,
            "coder-2 should not be assigned when coder-1 is explicitly preferred"
        );
    }

    /// When the preferred agent's stage mismatches and no other agent of the
    /// correct stage is available, auto-assign must not start any agent for that
    /// story (no panic, no error).
    #[tokio::test]
    async fn auto_assign_stage_mismatch_with_no_fallback_starts_no_agent() {
        let tmp = tempfile::tempdir().unwrap();
        let sk = tmp.path().join(".huskies");
        std::fs::create_dir_all(&sk).unwrap();
        // Only a coder agent is configured — no QA agent exists.
        std::fs::write(
            sk.join("project.toml"),
            "[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n",
        )
        .unwrap();
        // Story in 3_qa/ requests coder-1 (wrong stage) and no QA agent exists — write via CRDT.
        crate::db::ensure_content_store();
        crate::db::write_item_with_content(
            "9931_story_noqa",
            "3_qa",
            "---\nname: QA Story No Agent\nagent: coder-1\n---\n",
            crate::db::ItemMeta {
                name: Some("QA Story No Agent".into()),
                agent: Some("coder-1".into()),
                ..Default::default()
            },
        );

        let pool = AgentPool::new_test(3001);

        // Must not panic.
        pool.auto_assign_available_work(tmp.path()).await;

        let agents = pool.agents.try_lock().unwrap();
        // No agent should be assigned to the specific QA story (coder-1 may
        // be assigned to leaked 2_current items from the global CRDT store).
        let assigned_to_qa_story = agents.iter().any(|(key, a)| {
            key.contains("9931_story_noqa")
                && matches!(a.status, AgentStatus::Pending | AgentStatus::Running)
        });
        assert!(
            !assigned_to_qa_story,
            "No agent should be started when no stage-appropriate agent is available"
        );
    }

    /// Story 484: auto_assign must skip stories whose depends_on entries are not
    /// yet in 5_done or 6_archived.
    #[tokio::test]
    async fn auto_assign_skips_stories_with_unmet_dependencies() {
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path();
        let sk = root.join(".huskies");
        std::fs::create_dir_all(&sk).unwrap();
        std::fs::write(
            sk.join("project.toml"),
            "[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n",
        )
        .unwrap();
        // Story 9932 depends on 9999 which is not done — write via CRDT.
        crate::db::ensure_content_store();
        crate::db::write_item_with_content(
            "9932_story_waiting",
            "2_current",
            "# Waiting\n",
            crate::db::ItemMeta::named("Waiting"),
        );
        crate::crdt_state::set_depends_on("9932_story_waiting", &[9999]);

        let pool = AgentPool::new_test(3001);
        pool.auto_assign_available_work(root).await;

        let agents = pool.agents.try_lock().unwrap();
        // Filter to only agents assigned to our specific story to avoid
        // interference from other tests sharing the global CRDT store.
        let assigned_to_our_story = agents.iter().any(|(key, a)| {
            key.contains("9932_story_waiting")
                && matches!(a.status, AgentStatus::Pending | AgentStatus::Running)
        });
        assert!(
            !assigned_to_our_story,
            "story with unmet deps should not be auto-assigned"
        );
    }

    /// Story 484: auto_assign must pick up a story once its dependency lands in 5_done.
    #[tokio::test]
    async fn auto_assign_picks_up_story_after_dep_completes() {
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path();
        let sk = root.join(".huskies");
        std::fs::create_dir_all(&sk).unwrap();
        std::fs::write(
            sk.join("project.toml"),
            "[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n",
        )
        .unwrap();
        // Seed stories via CRDT (the only source of truth).
        crate::db::ensure_content_store();
        // Dep 999 is now done.
        crate::db::write_item_with_content(
            "999_story_dep",
            "5_done",
            "---\nname: Dep\n---\n",
            crate::db::ItemMeta::named("Dep"),
        );
        // Story 10 depends on 999 which is done.
        crate::db::write_item_with_content(
            "10_story_unblocked",
            "2_current",
            "# Unblocked\n",
            crate::db::ItemMeta::named("Unblocked"),
        );
        crate::crdt_state::set_depends_on("10_story_unblocked", &[999]);

        let pool = AgentPool::new_test(3001);
        pool.auto_assign_available_work(root).await;

        let agents = pool.agents.try_lock().unwrap();
        let has_pending = agents.values().any(|a| {
            matches!(
                a.status,
                crate::agents::AgentStatus::Pending | crate::agents::AgentStatus::Running
            )
        });
        assert!(
            has_pending,
            "story with all deps done should be auto-assigned"
        );
    }

    // ── Bug 497: backlog dependency promotion ───────────────────────────────

    /// Stories in backlog with `depends_on` that are all in 5_done must be
    /// promoted to 2_current when auto_assign_available_work runs.
    #[tokio::test]
    async fn auto_assign_promotes_backlog_story_when_all_deps_done() {
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path();
        let sk = root.join(".huskies");
        let backlog = sk.join("work/1_backlog");
        let current = sk.join("work/2_current");
        let done = sk.join("work/5_done");
        std::fs::create_dir_all(&backlog).unwrap();
        std::fs::create_dir_all(&current).unwrap();
        std::fs::create_dir_all(&done).unwrap();
        std::fs::write(
            sk.join("project.toml"),
            "[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n",
        )
        .unwrap();
        // Dep 1 is done.
        crate::db::ensure_content_store();
        let dep_content = "---\nname: Dep\n---\n";
        std::fs::write(done.join("1_story_dep.md"), dep_content).unwrap();
        crate::db::write_content(crate::db::ContentKey::Story("1_story_dep"), dep_content);
        // Story B depends on story 1.
        let story_b_content = "---\nname: B\ndepends_on: [1]\n---\n";
        std::fs::write(backlog.join("2_story_b.md"), story_b_content).unwrap();
        crate::db::write_content(crate::db::ContentKey::Story("2_story_b"), story_b_content);

        let pool = AgentPool::new_test(3001);
        pool.auto_assign_available_work(root).await;

        // The lifecycle function updates the content store (not the filesystem),
        // so verify the move via the DB.
        let content = crate::db::read_content(crate::db::ContentKey::Story("2_story_b"))
            .expect("story B should be in content store after promotion");
        assert!(
            content.contains("name: B"),
            "story B content should be preserved after promotion"
        );
    }

    /// Stories in backlog with unmet dependencies must NOT be promoted.
    #[tokio::test]
    async fn auto_assign_does_not_promote_backlog_story_with_unmet_deps() {
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path();
        let sk = root.join(".huskies");
        let backlog = sk.join("work/1_backlog");
        std::fs::create_dir_all(&backlog).unwrap();
        std::fs::write(
            sk.join("project.toml"),
            "[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n",
        )
        .unwrap();
        // Dep 99 is NOT done.
        std::fs::write(
            backlog.join("5_story_c.md"),
            "---\nname: C\ndepends_on: [99]\n---\n",
        )
        .unwrap();

        let pool = AgentPool::new_test(3001);
        pool.auto_assign_available_work(root).await;

        assert!(
            backlog.join("5_story_c.md").exists(),
            "story C should stay in 1_backlog/ when dep 99 is not done"
        );
    }

    // ── Bug 503: archived-dep promotion visibility ─────────────────────────────

    /// A backlog story whose dep is in 6_archived must still be promoted
    /// (archived = satisfied), but the promotion must not silently skip the warning
    /// path.  This test verifies the promotion itself fires; the warning is a
    /// slog_warn! side-effect that we can't easily assert on in unit tests.
    #[tokio::test]
    async fn auto_assign_promotes_backlog_story_when_dep_is_archived() {
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path();
        let sk = root.join(".huskies");
        let backlog = sk.join("work/1_backlog");
        let current = sk.join("work/2_current");
        let archived = sk.join("work/6_archived");
        std::fs::create_dir_all(&backlog).unwrap();
        std::fs::create_dir_all(&current).unwrap();
        std::fs::create_dir_all(&archived).unwrap();
        std::fs::write(
            sk.join("project.toml"),
            "[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n",
        )
        .unwrap();
        // Dep 490 is in 6_archived (e.g. a CRDT spike that was archived/superseded).
        crate::db::ensure_content_store();
        let dep_content = "---\nname: CRDT Spike\n---\n";
        std::fs::write(archived.join("490_spike_crdt.md"), dep_content).unwrap();
        crate::db::write_content(crate::db::ContentKey::Story("490_spike_crdt"), dep_content);
        // Story 478 depends on 490 (the archived spike).
        let story_content = "---\nname: Dependent\ndepends_on: [490]\n---\n";
        std::fs::write(backlog.join("478_story_dependent.md"), story_content).unwrap();
        crate::db::write_content(
            crate::db::ContentKey::Story("478_story_dependent"),
            story_content,
        );

        let pool = AgentPool::new_test(3001);
        pool.auto_assign_available_work(root).await;

        // Story 478 must be promoted even though dep 490 is only in 6_archived
        // (not in 5_done), because archived = satisfied.  The lifecycle function
        // updates the content store, so verify via the DB.
        let content = crate::db::read_content(crate::db::ContentKey::Story("478_story_dependent"))
            .expect("story 478 should be in content store after promotion");
        assert!(
            content.contains("name: Dependent"),
            "story 478 content should be preserved after promotion"
        );
    }

    /// Stories in backlog with NO depends_on must NOT be auto-promoted.
    #[tokio::test]
    async fn auto_assign_does_not_promote_backlog_story_without_deps() {
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path();
        let sk = root.join(".huskies");
        let backlog = sk.join("work/1_backlog");
        std::fs::create_dir_all(&backlog).unwrap();
        std::fs::write(
            sk.join("project.toml"),
            "[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n",
        )
        .unwrap();
        std::fs::write(
            backlog.join("7_story_nodeps.md"),
            "---\nname: No deps\n---\n",
        )
        .unwrap();

        let pool = AgentPool::new_test(3001);
        pool.auto_assign_available_work(root).await;

        assert!(
            backlog.join("7_story_nodeps.md").exists(),
            "story with no depends_on should stay in 1_backlog/ — human schedules it"
        );
    }

    /// Two concurrent auto_assign_available_work calls must not assign the same
    /// agent to two stories simultaneously.  After both complete, at most one
    /// Pending/Running entry must exist per agent name.
    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn toctou_concurrent_auto_assign_no_duplicate_agent_assignments() {
        use std::fs;
        use std::sync::Arc;

        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path().to_path_buf();

        let sk_dir = root.join(".huskies");
        // Two stories waiting in 2_current, one coder agent.
        fs::create_dir_all(sk_dir.join("work/2_current")).unwrap();
        fs::write(
            sk_dir.join("project.toml"),
            "[[agent]]\nname = \"coder-1\"\n",
        )
        .unwrap();
        fs::write(
            sk_dir.join("work/2_current/86_story_foo.md"),
            "---\nname: Foo\n---\n",
        )
        .unwrap();
        fs::write(
            sk_dir.join("work/2_current/130_story_bar.md"),
            "---\nname: Bar\n---\n",
        )
        .unwrap();

        let pool = Arc::new(AgentPool::new_test(3099));

        // Run two concurrent auto_assign calls.
        let pool1 = pool.clone();
        let root1 = root.clone();
        let t1 = tokio::spawn(async move { pool1.auto_assign_available_work(&root1).await });

        let pool2 = pool.clone();
        let root2 = root.clone();
        let t2 = tokio::spawn(async move { pool2.auto_assign_available_work(&root2).await });

        let _ = tokio::join!(t1, t2);

        // At most one Pending/Running entry should exist for coder-1.
        let agents = pool.agents.try_lock().unwrap();
        let active_coder_count = agents
            .values()
            .filter(|a| {
                a.agent_name == "coder-1"
                    && matches!(a.status, AgentStatus::Pending | AgentStatus::Running)
            })
            .count();

        assert!(
            active_coder_count <= 1,
            "coder-1 must not be assigned to more than one story simultaneously; \
             found {active_coder_count} active entries"
        );
    }

    // ── AC4: startup reconcile + pool reconstruction ──────────────────

    /// AC4: Simulates a server restart by seeding the CRDT with a story in
    /// Coding stage, then running `auto_assign_available_work` (startup no longer
    /// floods the broadcast channel via replay — it calls reconcile functions
    /// directly).  Asserts the pool ends in the expected state: exactly one agent
    /// assigned to the story, and a second pass does not double-spawn.
    #[tokio::test]
    async fn startup_auto_assign_assigns_agent_once() {
        let tmp = tempfile::tempdir().unwrap();
        let sk = tmp.path().join(".huskies");
        std::fs::create_dir_all(&sk).unwrap();
        std::fs::write(
            sk.join("project.toml"),
            "[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n",
        )
        .unwrap();

        crate::db::ensure_content_store();
        let story_id = "9903_restart_replay";
        crate::db::write_item_with_content(
            story_id,
            "2_current",
            "---\nname: Restart Replay\n---\n",
            crate::db::ItemMeta::named("Restart Replay"),
        );

        let pool = AgentPool::new_test(3001);

        // First auto-assign pass.
        pool.auto_assign_available_work(tmp.path()).await;

        let count_after_first = {
            let agents = pool.agents.try_lock().unwrap();
            agents
                .iter()
                .filter(|(key, a)| {
                    key.contains(story_id)
                        && matches!(a.status, AgentStatus::Pending | AgentStatus::Running)
                })
                .count()
        };

        // Second pass (idempotency): must not double-spawn agents.
        pool.auto_assign_available_work(tmp.path()).await;

        let count_after_second = {
            let agents = pool.agents.try_lock().unwrap();
            agents
                .iter()
                .filter(|(key, a)| {
                    key.contains(story_id)
                        && matches!(a.status, AgentStatus::Pending | AgentStatus::Running)
                })
                .count()
        };

        assert!(
            count_after_first <= 1,
            "after first auto-assign at most one agent must be assigned to {story_id}"
        );
        assert_eq!(
            count_after_first, count_after_second,
            "second auto-assign must not spawn additional agents (idempotency)"
        );
    }
}
