//! Assign command: pre-assign or re-assign a coder model to a story.
//!
//! `{bot_name} assign {number} {model}` finds the story by number, writes the
//! agent name into the typed CRDT `agent` register, and — when a coder is
//! already running on the story — stops it and starts the newly-assigned one
//! via [`crate::service::work_item::assign_and_start`].
//!
//! When no coder is running (the story has not been started yet), the command
//! persists the assignment in the CRDT register so the next `start` invocation
//! picks it up automatically.

use crate::agents::{AgentPool, AgentStatus};
use crate::chat::util::strip_bot_mention;
use std::path::Path;

/// A parsed assign command from a Matrix message body.
#[derive(Debug, PartialEq)]
pub enum AssignCommand {
    /// Assign the story with this number to the given model.
    Assign { story_number: String, model: String },
    /// The user typed `assign` but without valid arguments.
    BadArgs,
}

/// Parse an assign command from a raw Matrix message body.
///
/// Strips the bot mention prefix and checks whether the first word is `assign`.
/// Returns `None` when the message is not an assign command at all.
pub fn extract_assign_command(
    message: &str,
    bot_name: &str,
    bot_user_id: &str,
) -> Option<AssignCommand> {
    let stripped = strip_bot_mention(message, bot_name, bot_user_id);
    let trimmed = stripped
        .trim()
        .trim_start_matches(|c: char| !c.is_alphanumeric());

    let (cmd, args) = match trimmed.split_once(char::is_whitespace) {
        Some((c, a)) => (c, a.trim()),
        None => (trimmed, ""),
    };

    if !cmd.eq_ignore_ascii_case("assign") {
        return None;
    }

    // Split args into story number and model.
    let (number_str, model_str) = match args.split_once(char::is_whitespace) {
        Some((n, m)) => (n.trim(), m.trim()),
        None => (args, ""),
    };

    if number_str.is_empty()
        || !number_str.chars().all(|c| c.is_ascii_digit())
        || model_str.is_empty()
    {
        return Some(AssignCommand::BadArgs);
    }

    Some(AssignCommand::Assign {
        story_number: number_str.to_string(),
        model: model_str.to_string(),
    })
}

/// Resolve a model name hint (e.g. `"opus"`) to a full agent name
/// (e.g. `"coder-opus"`). If the hint already starts with `"coder-"`,
/// it is returned unchanged to prevent double-prefixing.
pub fn resolve_agent_name(model: &str) -> String {
    if model.starts_with("coder-") {
        model.to_string()
    } else {
        format!("coder-{model}")
    }
}

/// Handle an assign command asynchronously.
///
/// Finds the work item by `story_number` across all pipeline stages, writes
/// the agent pin to the CRDT register, and — if a coder is currently running
/// on the story — stops it and starts the newly-assigned agent via
/// [`crate::service::work_item::assign_and_start`].
///
/// When no coder is running the assignment is persisted in the CRDT so the
/// next `start` invocation picks it up automatically.  Returns a
/// markdown-formatted response string.
pub async fn handle_assign(
    bot_name: &str,
    story_number: &str,
    model_str: &str,
    project_root: &Path,
    agents: &AgentPool,
) -> String {
    // Parse: find the story by numeric prefix (CRDT → content store → filesystem).
    let (story_id, _stage_dir, _path, _content) =
        match crate::chat::lookup::find_story_by_number(project_root, story_number) {
            Some(found) => found,
            None => {
                return format!("No story, bug, or spike with number **{story_number}** found.");
            }
        };

    // Story name comes from the CRDT name register (story 929).
    let story_name = crate::crdt_state::read_item(&story_id)
        .map(|w| w.name().to_string())
        .unwrap_or_else(|| story_id.clone());

    let agent_name = resolve_agent_name(model_str);

    // Check whether a coder is already running on this story.
    let running_coders: Vec<_> = agents
        .list_agents()
        .await
        .unwrap_or_default()
        .into_iter()
        .filter(|a| {
            a.story_id == story_id
                && a.agent_name.starts_with("coder")
                && matches!(a.status, AgentStatus::Running | AgentStatus::Pending)
        })
        .collect();

    if running_coders.is_empty() {
        // No coder running — persist the CRDT agent pin for the future start.
        crate::crdt_state::set_agent(
            &story_id,
            agent_name.parse::<crate::config::AgentName>().ok(),
        );
        return format!(
            "Assigned **{agent_name}** to **{story_name}** (story {story_number}). \
             The model will be used when the story starts."
        );
    }

    // Stop each running coder, then assign+start the newly-assigned one.
    let stopped: Vec<String> = running_coders
        .iter()
        .map(|a| a.agent_name.clone())
        .collect();

    for coder in &running_coders {
        if let Err(e) = agents
            .stop_agent(project_root, &story_id, &coder.agent_name)
            .await
        {
            crate::slog!(
                "[matrix-bot] assign: failed to stop agent {} for {}: {e}",
                coder.agent_name,
                story_id
            );
        }
    }

    crate::slog!(
        "[matrix-bot] assign (bot={bot_name}): stopped {:?} for {}; starting {agent_name}",
        stopped,
        story_id
    );

    // Service call: persist CRDT agent pin and start the new agent.
    match crate::service::work_item::assign_and_start(&story_id, &agent_name, project_root, agents)
        .await
    {
        Ok(info) => {
            format!(
                "Reassigned **{story_name}** (story {story_number}): \
                 stopped **{}** and started **{}**.",
                stopped.join(", "),
                info.agent_name
            )
        }
        Err(e) => {
            format!(
                "Assigned **{agent_name}** to **{story_name}** (story {story_number}): \
                 stopped **{}** but failed to start the new agent: {e}",
                stopped.join(", ")
            )
        }
    }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;

    // -- extract_assign_command -----------------------------------------------

    #[test]
    fn extract_with_full_user_id() {
        let cmd = extract_assign_command(
            "@timmy:home.local assign 42 opus",
            "Timmy",
            "@timmy:home.local",
        );
        assert_eq!(
            cmd,
            Some(AssignCommand::Assign {
                story_number: "42".to_string(),
                model: "opus".to_string()
            })
        );
    }

    #[test]
    fn extract_with_display_name() {
        let cmd = extract_assign_command("Timmy assign 42 sonnet", "Timmy", "@timmy:home.local");
        assert_eq!(
            cmd,
            Some(AssignCommand::Assign {
                story_number: "42".to_string(),
                model: "sonnet".to_string()
            })
        );
    }

    #[test]
    fn extract_with_localpart() {
        let cmd = extract_assign_command("@timmy assign 7 opus", "Timmy", "@timmy:home.local");
        assert_eq!(
            cmd,
            Some(AssignCommand::Assign {
                story_number: "7".to_string(),
                model: "opus".to_string()
            })
        );
    }

    #[test]
    fn extract_case_insensitive_command() {
        let cmd = extract_assign_command("Timmy ASSIGN 99 opus", "Timmy", "@timmy:home.local");
        assert_eq!(
            cmd,
            Some(AssignCommand::Assign {
                story_number: "99".to_string(),
                model: "opus".to_string()
            })
        );
    }

    #[test]
    fn extract_no_args_is_bad_args() {
        let cmd = extract_assign_command("Timmy assign", "Timmy", "@timmy:home.local");
        assert_eq!(cmd, Some(AssignCommand::BadArgs));
    }

    #[test]
    fn extract_missing_model_is_bad_args() {
        let cmd = extract_assign_command("Timmy assign 42", "Timmy", "@timmy:home.local");
        assert_eq!(cmd, Some(AssignCommand::BadArgs));
    }

    #[test]
    fn extract_non_numeric_number_is_bad_args() {
        let cmd = extract_assign_command("Timmy assign abc opus", "Timmy", "@timmy:home.local");
        assert_eq!(cmd, Some(AssignCommand::BadArgs));
    }

    #[test]
    fn extract_non_assign_command_returns_none() {
        let cmd = extract_assign_command("Timmy help", "Timmy", "@timmy:home.local");
        assert_eq!(cmd, None);
    }

    #[test]
    fn extract_assign_command_multibyte_prefix_no_panic() {
        // "xxxx⏺ assign 42 opus" — ⏺ (U+23FA) is 3 bytes, starting at byte 4.
        // "@timmy" has len 6 so text[..6] lands inside ⏺ — panics without the fix.
        let cmd =
            extract_assign_command("xxxx\u{23FA} assign 42 opus", "Timmy", "@timmy:home.local");
        assert_eq!(cmd, None);
    }

    // -- resolve_agent_name --------------------------------------------------

    #[test]
    fn resolve_agent_name_prefixes_bare_model() {
        assert_eq!(resolve_agent_name("opus"), "coder-opus");
        assert_eq!(resolve_agent_name("sonnet"), "coder-sonnet");
        assert_eq!(resolve_agent_name("haiku"), "coder-haiku");
    }

    #[test]
    fn resolve_agent_name_does_not_double_prefix() {
        assert_eq!(resolve_agent_name("coder-opus"), "coder-opus");
        assert_eq!(resolve_agent_name("coder-sonnet"), "coder-sonnet");
    }

    // -- handle_assign (no running coder) ------------------------------------

    use crate::chat::test_helpers::write_story_file;

    #[tokio::test]
    async fn handle_assign_returns_not_found_for_unknown_number() {
        let tmp = tempfile::tempdir().unwrap();
        let agents = std::sync::Arc::new(AgentPool::new_test(3000));
        let response = handle_assign("Timmy", "999", "opus", tmp.path(), &agents).await;
        assert!(
            response.contains("No story") && response.contains("999"),
            "unexpected response: {response}"
        );
    }

    #[tokio::test]
    async fn handle_assign_sets_crdt_agent_when_no_coder_running() {
        crate::crdt_state::init_for_test();
        let tmp = tempfile::tempdir().unwrap();
        write_story_file(
            tmp.path(),
            "1_backlog",
            "9972_story_test.md",
            "---\nname: Test Feature\n---\n\n# Story 9972\n",
            None,
        );
        // Seed CRDT so set_agent can write to the item.
        crate::crdt_state::write_item_str(
            "9972_story_test",
            "1_backlog",
            Some("Test Feature"),
            None,
            None,
            None,
        );

        let agents = std::sync::Arc::new(AgentPool::new_test(3000));
        let response = handle_assign("Timmy", "9972", "opus", tmp.path(), &agents).await;

        assert!(
            response.contains("coder-opus"),
            "response should mention agent: {response}"
        );
        assert!(
            response.contains("Test Feature"),
            "response should mention story name: {response}"
        );
        // Should say "will be used when the story starts" (no restart)
        assert!(
            response.contains("start"),
            "response should indicate assignment for future start: {response}"
        );

        // CRDT register should be set (no longer checks YAML front matter).
        let dump = crate::crdt_state::dump_crdt_state(Some("9972_story_test"));
        let item = dump
            .items
            .iter()
            .find(|i| i.story_id.as_deref() == Some("9972_story_test"))
            .expect("item must be in CRDT");
        assert_eq!(
            item.agent.as_deref(),
            Some("coder-opus"),
            "CRDT agent register should be set: {:?}",
            item.agent
        );
    }

    #[tokio::test]
    async fn handle_assign_with_already_prefixed_name_does_not_double_prefix() {
        crate::crdt_state::init_for_test();
        let tmp = tempfile::tempdir().unwrap();
        write_story_file(
            tmp.path(),
            "1_backlog",
            "9973_story_small.md",
            "---\nname: Small Story\n---\n",
            None,
        );
        crate::crdt_state::write_item_str(
            "9973_story_small",
            "1_backlog",
            Some("Small Story"),
            None,
            None,
            None,
        );

        let agents = std::sync::Arc::new(AgentPool::new_test(3000));
        let response = handle_assign("Timmy", "9973", "coder-opus", tmp.path(), &agents).await;

        assert!(
            response.contains("coder-opus"),
            "should not double-prefix: {response}"
        );
        assert!(
            !response.contains("coder-coder-opus"),
            "must not double-prefix: {response}"
        );

        // CRDT must have coder-opus, not coder-coder-opus.
        let dump = crate::crdt_state::dump_crdt_state(Some("9973_story_small"));
        let item = dump
            .items
            .iter()
            .find(|i| i.story_id.as_deref() == Some("9973_story_small"))
            .expect("item must be in CRDT");
        assert_eq!(
            item.agent.as_deref(),
            Some("coder-opus"),
            "must write coder-opus, not coder-coder-opus: {:?}",
            item.agent
        );
    }

    #[tokio::test]
    async fn handle_assign_overwrites_existing_agent_field() {
        crate::crdt_state::init_for_test();
        let tmp = tempfile::tempdir().unwrap();
        write_story_file(
            tmp.path(),
            "1_backlog",
            "9974_story_existing.md",
            "---\nname: Existing\nagent: coder-sonnet\n---\n",
            None,
        );
        crate::crdt_state::write_item_str(
            "9974_story_existing",
            "1_backlog",
            Some("Existing"),
            Some("coder-sonnet"),
            None,
            None,
        );

        let agents = std::sync::Arc::new(AgentPool::new_test(3000));
        handle_assign("Timmy", "9974", "opus", tmp.path(), &agents).await;

        // CRDT agent register must be updated to the new value.
        let dump = crate::crdt_state::dump_crdt_state(Some("9974_story_existing"));
        let item = dump
            .items
            .iter()
            .find(|i| i.story_id.as_deref() == Some("9974_story_existing"))
            .expect("item must be in CRDT");
        assert_eq!(
            item.agent.as_deref(),
            Some("coder-opus"),
            "CRDT agent must be updated to coder-opus: {:?}",
            item.agent
        );
    }

    #[tokio::test]
    async fn handle_assign_finds_story_in_any_stage() {
        let tmp = tempfile::tempdir().unwrap();
        write_story_file(
            tmp.path(),
            "3_qa",
            "99_story_in_qa.md",
            "---\nname: In QA\n---\n",
            None,
        );

        let agents = std::sync::Arc::new(AgentPool::new_test(3000));
        let response = handle_assign("Timmy", "99", "opus", tmp.path(), &agents).await;
        assert!(
            response.contains("coder-opus"),
            "should find story in qa stage: {response}"
        );
    }

    // -- handle_assign (with running coder) ----------------------------------

    #[tokio::test]
    async fn handle_assign_stops_running_coder_and_reports_reassignment() {
        let tmp = tempfile::tempdir().unwrap();
        write_story_file(
            tmp.path(),
            "2_current",
            "10_story_current.md",
            "---\nname: Current Story\nagent: coder-sonnet\n---\n",
            None,
        );

        let agents = std::sync::Arc::new(AgentPool::new_test(3000));
        // Inject a running coder for this story.
        agents.inject_test_agent("10_story_current", "coder-sonnet", AgentStatus::Running);

        let response = handle_assign("Timmy", "10", "opus", tmp.path(), &agents).await;

        // The response should mention both stopped and started agents.
        assert!(
            response.contains("coder-sonnet"),
            "response should mention the stopped agent: {response}"
        );
        // Should indicate a restart occurred (not just "will be used when starts")
        assert!(
            response.to_lowercase().contains("stop")
                || response.to_lowercase().contains("reassign"),
            "response should indicate stop/reassign: {response}"
        );
    }
}
