//! 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`
/// In-memory cost rollup register — written by the cost-rollup subscriber.
pub mod cost_rollup;
mod io;
/// Agent selection heuristics — pick the best agent for a story.
pub mod selection;
/// Token usage tracking and budget enforcement.
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 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),
    /// 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::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: crate::pipeline_state::Stage,
    pub name: String,
    pub agent: Option<crate::config::AgentName>,
    /// Origin of the work item (story 1088).  `None` for items that pre-date
    /// the origin register; the web UI renders these as `"unknown"`.
    pub origin: 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<crate::agents::AgentModel>,
    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)
}

/// 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))
}

/// 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> {
    use crate::pipeline_state::Stage;

    let stages = [
        ("1_backlog", Stage::Backlog),
        (
            "2_current",
            Stage::Coding {
                claim: None,
                plan: Default::default(),
                retries: 0,
            },
        ),
        ("3_qa", Stage::Qa),
        (
            "4_merge",
            Stage::from_dir("merge").expect("merge is a valid stage dir"),
        ),
        (
            "5_done",
            Stage::from_dir("done").expect("done is a valid stage dir"),
        ),
        (
            "6_archived",
            Stage::from_dir("archived").expect("archived is a valid stage dir"),
        ),
    ];

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

    let crdt_view = crate::crdt_state::read_item(story_id);
    let crdt_name = crdt_view
        .as_ref()
        .map(|v| v.name().to_string())
        .unwrap_or_default();
    let crdt_agent = crdt_view.as_ref().and_then(|v| v.agent());
    let crdt_origin = crdt_view
        .as_ref()
        .and_then(|v| v.origin().map(str::to_string));

    for (stage_dir, stage) in &stages {
        if let Some(content) = io::read_work_item_from_stage(&work_dir, stage_dir, &filename)? {
            return Ok(WorkItemContent {
                content,
                stage: stage.clone(),
                name: crdt_name.clone(),
                agent: crdt_agent,
                origin: crdt_origin.clone(),
            });
        }
    }

    // CRDT-only fallback
    if let Some(content) = crate::db::read_content(crate::db::ContentKey::Story(story_id)) {
        let item = crate::pipeline_state::read_typed(story_id)
            .map_err(|e| Error::Io(format!("Pipeline read error: {e}")))?;
        let stage = match item.as_ref() {
            Some(i) => i.stage.clone(),
            None => Stage::Upcoming,
        };
        return Ok(WorkItemContent {
            content,
            stage,
            name: crdt_name,
            agent: crdt_agent,
            origin: crdt_origin,
        });
    }

    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 from the rollup register.
///
/// Returns a zero-cost summary when no rollup has been recorded yet (story
/// still in progress or no token records exist).
pub fn get_work_item_token_cost(
    project_root: &Path,
    story_id: &str,
) -> Result<TokenCostSummary, Error> {
    Ok(cost_rollup::get_rollup(project_root, story_id)
        .map(|r| r.as_summary())
        .unwrap_or(TokenCostSummary {
            total_cost_usd: 0.0,
            agents: vec![],
        }))
}

/// 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 io::test_helpers::*;
    use tempfile::TempDir;

    // ── 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(crate::agents::AgentModel::Sonnet));
        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() {
        crate::crdt_state::init_for_test();
        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.",
        );
        // Story 929: name lives in the CRDT register.
        crate::crdt_state::write_item_str(
            "42_story_foo",
            "1_backlog",
            Some("Foo Story"),
            None,
            None,
            None,
        );
        let item = get_work_item_content(tmp.path(), "42_story_foo").unwrap();
        assert!(item.content.contains("Some content."));
        assert_eq!(item.stage, crate::pipeline_state::Stage::Backlog);
        assert_eq!(item.name, "Foo Story");
    }

    #[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");
    }
}
