//! Agent service — public API for the agent domain.
//!
//! This module orchestrates calls to `io.rs` (side effects) and the pure
//! topic modules (`selection`, `token`) to implement the full agent service
//! surface. HTTP handlers call these functions instead of reaching directly
//! into `AgentPool` or the filesystem.
//!
//! Conventions: `docs/architecture/service-modules.md`
mod io;
pub mod selection;
pub mod token;

use crate::agents::AgentInfo;
use crate::agents::AgentPool;
use crate::agents::token_usage::TokenUsageRecord;
use crate::config::ProjectConfig;
use crate::workflow::StoryTestResults;
use crate::worktree::{WorktreeInfo, WorktreeListEntry};
use std::path::Path;

pub use io::is_archived;
pub use token::TokenCostSummary;

// ── Error type ────────────────────────────────────────────────────────────────

/// Typed errors returned by `service::agents` functions.
///
/// HTTP handlers map these to specific status codes — see the conventions doc
/// for the full mapping table.
#[derive(Debug)]
pub enum Error {
    /// No agent with the given name/story exists in the pool.
    AgentNotFound(String),
    /// No work item found for the requested story ID.
    WorkItemNotFound(String),
    /// A worktree operation failed.
    Worktree(String),
    /// Project configuration could not be loaded.
    Config(String),
    /// A filesystem or I/O operation failed.
    Io(String),
}

impl std::fmt::Display for Error {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::AgentNotFound(msg) => write!(f, "Agent not found: {msg}"),
            Self::WorkItemNotFound(msg) => write!(f, "Work item not found: {msg}"),
            Self::Worktree(msg) => write!(f, "Worktree error: {msg}"),
            Self::Config(msg) => write!(f, "Config error: {msg}"),
            Self::Io(msg) => write!(f, "I/O error: {msg}"),
        }
    }
}

// ── Shared service types ─────────────────────────────────────────────────────

/// Content and metadata for a work-item (story) file.
#[derive(Debug, Clone)]
pub struct WorkItemContent {
    pub content: String,
    pub stage: String,
    pub name: Option<String>,
    pub agent: Option<String>,
}

/// A single entry in the project's configured agent roster.
#[derive(Debug, Clone)]
pub struct AgentConfigEntry {
    pub name: String,
    pub role: String,
    pub stage: Option<String>,
    pub model: Option<String>,
    pub allowed_tools: Option<Vec<String>>,
    pub max_turns: Option<u32>,
    pub max_budget_usd: Option<f64>,
}

// ── Public API ────────────────────────────────────────────────────────────────

/// Start an agent for a story.
///
/// Takes only what it needs: the pool (for spawning) and the project root
/// (for config and worktree creation). Does not touch `AppContext`.
pub async fn start_agent(
    pool: &AgentPool,
    project_root: &Path,
    story_id: &str,
    agent_name: Option<&str>,
    resume_context: Option<&str>,
    session_id_to_resume: Option<String>,
) -> Result<AgentInfo, Error> {
    pool.start_agent(
        project_root,
        story_id,
        agent_name,
        resume_context,
        session_id_to_resume,
    )
    .await
    .map_err(Error::AgentNotFound)
}

/// Stop a running agent.
pub async fn stop_agent(
    pool: &AgentPool,
    project_root: &Path,
    story_id: &str,
    agent_name: &str,
) -> Result<(), Error> {
    pool.stop_agent(project_root, story_id, agent_name)
        .await
        .map_err(Error::AgentNotFound)
}

/// List all agents, optionally filtering out those belonging to archived stories.
///
/// When `project_root` is `None` the archive filter is skipped and all agents
/// are returned (safe default when the server is not yet fully configured).
pub fn list_agents(pool: &AgentPool, project_root: Option<&Path>) -> Result<Vec<AgentInfo>, Error> {
    let agents = pool.list_agents().map_err(Error::Io)?;
    match project_root {
        Some(root) => Ok(selection::filter_non_archived(agents, |id| {
            io::is_archived(root, id)
        })),
        None => Ok(agents),
    }
}

/// Create a git worktree for a story.
pub async fn create_worktree(
    pool: &AgentPool,
    project_root: &Path,
    story_id: &str,
) -> Result<WorktreeInfo, Error> {
    pool.create_worktree(project_root, story_id)
        .await
        .map_err(Error::Worktree)
}

/// List all worktrees under `.huskies/worktrees/`.
pub fn list_worktrees(project_root: &Path) -> Result<Vec<WorktreeListEntry>, Error> {
    io::list_worktrees(project_root)
}

/// Remove the git worktree for a story.
pub async fn remove_worktree(project_root: &Path, story_id: &str) -> Result<(), Error> {
    io::remove_worktree(project_root, story_id).await
}

/// Get the configured agent roster from `project.toml`.
pub fn get_agent_config(project_root: &Path) -> Result<Vec<AgentConfigEntry>, Error> {
    let config = io::load_config(project_root)?;
    Ok(config_to_entries(&config))
}

/// Reload and return the project's agent configuration.
///
/// Semantically identical to `get_agent_config`; provided as a distinct
/// function so callers can express intent (UI "Reload" button).
pub fn reload_config(project_root: &Path) -> Result<Vec<AgentConfigEntry>, Error> {
    get_agent_config(project_root)
}

/// Get the concatenated output text for an agent's most recent session.
///
/// Returns an empty string when no log file exists yet.
pub fn get_agent_output(
    project_root: &Path,
    story_id: &str,
    agent_name: &str,
) -> Result<String, Error> {
    let entries = io::read_agent_log(project_root, story_id, agent_name)?;
    Ok(selection::collect_output_text(&entries))
}

/// Get the markdown content and metadata for a work item.
///
/// Searches all pipeline stage directories, falling back to the CRDT content
/// store when no file is present on disk. Returns `Error::WorkItemNotFound`
/// when neither source has the item.
pub fn get_work_item_content(
    project_root: &Path,
    story_id: &str,
) -> Result<WorkItemContent, Error> {
    let stages = [
        ("1_backlog", "backlog"),
        ("2_current", "current"),
        ("3_qa", "qa"),
        ("4_merge", "merge"),
        ("5_done", "done"),
        ("6_archived", "archived"),
    ];

    let work_dir = project_root.join(".huskies").join("work");
    let filename = format!("{story_id}.md");

    for (stage_dir, stage_name) in &stages {
        if let Some(content) = io::read_work_item_from_stage(&work_dir, stage_dir, &filename)? {
            let metadata = crate::io::story_metadata::parse_front_matter(&content).ok();
            return Ok(WorkItemContent {
                content,
                stage: stage_name.to_string(),
                name: metadata.as_ref().and_then(|m| m.name.clone()),
                agent: metadata.and_then(|m| m.agent),
            });
        }
    }

    // CRDT-only fallback
    if let Some(content) = crate::db::read_content(story_id) {
        let item = crate::pipeline_state::read_typed(story_id)
            .map_err(|e| Error::Io(format!("Pipeline read error: {e}")))?;
        let stage = item
            .as_ref()
            .map(|i| match &i.stage {
                crate::pipeline_state::Stage::Backlog => "backlog",
                crate::pipeline_state::Stage::Coding => "current",
                crate::pipeline_state::Stage::Qa => "qa",
                crate::pipeline_state::Stage::Merge { .. } => "merge",
                crate::pipeline_state::Stage::Done { .. } => "done",
                crate::pipeline_state::Stage::Archived { .. } => "archived",
            })
            .unwrap_or("unknown")
            .to_string();
        let metadata = crate::io::story_metadata::parse_front_matter(&content).ok();
        return Ok(WorkItemContent {
            content,
            stage,
            name: metadata.as_ref().and_then(|m| m.name.clone()),
            agent: metadata.and_then(|m| m.agent),
        });
    }

    Err(Error::WorkItemNotFound(format!(
        "Work item not found: {story_id}"
    )))
}

/// Get test results for a work item.
///
/// Checks in-memory workflow state first (fast path), then falls back to
/// results persisted in the story file.
pub fn get_test_results(
    project_root: &Path,
    story_id: &str,
    workflow: &crate::workflow::WorkflowState,
) -> Option<StoryTestResults> {
    if let Some(results) = workflow.results.get(story_id) {
        return Some(results.clone());
    }
    io::read_test_results_from_file(project_root, story_id)
}

/// Get the aggregated token cost for a specific story.
pub fn get_work_item_token_cost(
    project_root: &Path,
    story_id: &str,
) -> Result<TokenCostSummary, Error> {
    let records = io::read_token_records(project_root)?;
    Ok(token::aggregate_for_story(&records, story_id))
}

/// Get all token usage records across all stories.
pub fn get_all_token_usage(project_root: &Path) -> Result<Vec<TokenUsageRecord>, Error> {
    io::read_token_records(project_root)
}

// ── Helpers ───────────────────────────────────────────────────────────────────

fn config_to_entries(config: &ProjectConfig) -> Vec<AgentConfigEntry> {
    config
        .agent
        .iter()
        .map(|a| AgentConfigEntry {
            name: a.name.clone(),
            role: a.role.clone(),
            stage: a.stage.clone(),
            model: a.model.clone(),
            allowed_tools: a.allowed_tools.clone(),
            max_turns: a.max_turns,
            max_budget_usd: a.max_budget_usd,
        })
        .collect()
}

// ── Integration tests ─────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;
    use crate::agents::AgentStatus;
    use io::test_helpers::*;
    use std::sync::Arc;
    use tempfile::TempDir;

    fn make_pool(tmp: &TempDir) -> Arc<AgentPool> {
        let (tx, _) = tokio::sync::broadcast::channel(64);
        let pool = AgentPool::new(3001, tx);
        let state = crate::state::SessionState::default();
        *state.project_root.lock().unwrap() = Some(tmp.path().to_path_buf());
        Arc::new(pool)
    }

    // ── list_agents ───────────────────────────────────────────────────────────

    #[tokio::test]
    async fn list_agents_excludes_archived_stories() {
        let tmp = TempDir::new().unwrap();
        make_work_dirs(&tmp);
        write_story_file(
            &tmp,
            ".huskies/work/6_archived/79_story_archived.md",
            "---\nname: archived\n---\n",
        );

        let pool = make_pool(&tmp);
        pool.inject_test_agent("79_story_archived", "coder-1", AgentStatus::Completed);
        pool.inject_test_agent("80_story_active", "coder-1", AgentStatus::Running);

        let agents = list_agents(&pool, Some(tmp.path())).unwrap();
        assert!(!agents.iter().any(|a| a.story_id == "79_story_archived"));
        assert!(agents.iter().any(|a| a.story_id == "80_story_active"));
    }

    #[tokio::test]
    async fn list_agents_includes_all_when_no_project_root() {
        let tmp = TempDir::new().unwrap();
        let pool = make_pool(&tmp);
        pool.inject_test_agent("42_story_whatever", "coder-1", AgentStatus::Completed);

        let agents = list_agents(&pool, None).unwrap();
        assert!(agents.iter().any(|a| a.story_id == "42_story_whatever"));
    }

    // ── get_agent_config ──────────────────────────────────────────────────────

    #[test]
    fn get_agent_config_returns_default_when_no_toml() {
        let tmp = TempDir::new().unwrap();
        make_huskies_dir(&tmp);
        let entries = get_agent_config(tmp.path()).unwrap();
        assert_eq!(entries.len(), 1);
        assert_eq!(entries[0].name, "default");
    }

    #[test]
    fn get_agent_config_returns_configured_agents() {
        let tmp = TempDir::new().unwrap();
        make_project_toml(
            &tmp,
            r#"
[[agent]]
name = "coder-1"
role = "Full-stack engineer"
model = "sonnet"
max_turns = 30
max_budget_usd = 5.0
"#,
        );
        let entries = get_agent_config(tmp.path()).unwrap();
        assert_eq!(entries.len(), 1);
        assert_eq!(entries[0].name, "coder-1");
        assert_eq!(entries[0].model, Some("sonnet".to_string()));
        assert_eq!(entries[0].max_turns, Some(30));
    }

    // ── get_agent_output ──────────────────────────────────────────────────────

    #[test]
    fn get_agent_output_returns_empty_when_no_log() {
        let tmp = TempDir::new().unwrap();
        let output = get_agent_output(tmp.path(), "42_story_foo", "coder-1").unwrap();
        assert_eq!(output, "");
    }

    // ── get_work_item_content ─────────────────────────────────────────────────

    #[test]
    fn get_work_item_content_reads_from_backlog() {
        let tmp = TempDir::new().unwrap();
        make_stage_dirs(&tmp);
        write_story_file(
            &tmp,
            ".huskies/work/1_backlog/42_story_foo.md",
            "---\nname: \"Foo Story\"\n---\n\nSome content.",
        );
        let item = get_work_item_content(tmp.path(), "42_story_foo").unwrap();
        assert!(item.content.contains("Some content."));
        assert_eq!(item.stage, "backlog");
        assert_eq!(item.name, Some("Foo Story".to_string()));
    }

    #[test]
    fn get_work_item_content_returns_not_found_for_absent_story() {
        let tmp = TempDir::new().unwrap();
        make_stage_dirs(&tmp);
        let result = get_work_item_content(tmp.path(), "99_story_nonexistent");
        assert!(matches!(result, Err(Error::WorkItemNotFound(_))));
    }

    // ── get_work_item_token_cost ──────────────────────────────────────────────

    #[test]
    fn get_work_item_token_cost_returns_zero_when_no_records() {
        let tmp = TempDir::new().unwrap();
        let summary = get_work_item_token_cost(tmp.path(), "42_story_foo").unwrap();
        assert_eq!(summary.total_cost_usd, 0.0);
        assert!(summary.agents.is_empty());
    }

    // ── get_all_token_usage ───────────────────────────────────────────────────

    #[test]
    fn get_all_token_usage_returns_empty_when_no_file() {
        let tmp = TempDir::new().unwrap();
        let records = get_all_token_usage(tmp.path()).unwrap();
        assert!(records.is_empty());
    }

    // ── get_test_results ──────────────────────────────────────────────────────

    #[test]
    fn get_test_results_returns_none_when_no_results() {
        let tmp = TempDir::new().unwrap();
        let workflow = crate::workflow::WorkflowState::default();
        let result = get_test_results(tmp.path(), "42_story_foo", &workflow);
        assert!(result.is_none());
    }

    #[test]
    fn get_test_results_returns_in_memory_results_first() {
        let tmp = TempDir::new().unwrap();
        let mut workflow = crate::workflow::WorkflowState::default();
        workflow
            .record_test_results_validated(
                "42_story_foo".to_string(),
                vec![crate::workflow::TestCaseResult {
                    name: "test1".to_string(),
                    status: crate::workflow::TestStatus::Pass,
                    details: None,
                }],
                vec![],
            )
            .unwrap();
        let result =
            get_test_results(tmp.path(), "42_story_foo", &workflow).expect("should have results");
        assert_eq!(result.unit.len(), 1);
        assert_eq!(result.unit[0].name, "test1");
    }
}
