//! Read-only story query tools: validation, listing, and pipeline status.

use crate::http::context::AppContext;
use crate::http::workflow::{load_pipeline_state, load_upcoming_stories, validate_story_dirs};
use serde_json::{Value, json};

pub(crate) fn tool_validate_stories(ctx: &AppContext) -> Result<String, String> {
    let root = ctx.state.get_project_root()?;
    let results = validate_story_dirs(&root)?;
    serde_json::to_string_pretty(&json!(
        results
            .iter()
            .map(|r| json!({
                "story_id": r.story_id,
                "valid": r.valid,
                "error": r.error,
            }))
            .collect::<Vec<_>>()
    ))
    .map_err(|e| format!("Serialization error: {e}"))
}

pub(crate) fn tool_list_upcoming(ctx: &AppContext) -> Result<String, String> {
    let stories = load_upcoming_stories(ctx)?;
    serde_json::to_string_pretty(&json!(
        stories
            .iter()
            .map(|s| json!({
                "story_id": s.story_id,
                "name": s.name,
                "error": s.error,
            }))
            .collect::<Vec<_>>()
    ))
    .map_err(|e| format!("Serialization error: {e}"))
}

pub(crate) fn tool_get_pipeline_status(ctx: &AppContext) -> Result<String, String> {
    let state = load_pipeline_state(ctx)?;
    let running_merges = ctx.services.agents.list_running_merges()?;

    fn slim_name(name: &str) -> &str {
        crate::chat::util::truncate_at_char_boundary(name, 120)
    }

    fn map_items(items: &[crate::http::workflow::UpcomingStory], stage: &str) -> Vec<Value> {
        items
            .iter()
            .map(|s| {
                let mut item = json!({
                    "story_id": s.story_id,
                    "name": slim_name(&s.name),
                    "stage": stage,
                    "pipeline": s.pipeline.as_str(),
                    "status": s.status.as_str(),
                    "agent": s.agent.as_ref().map(|a| json!({
                        "agent_name": a.agent_name,
                        "model": a.model,
                        "status": a.status,
                    })),
                });
                if let Some(true) = s.blocked {
                    item["blocked"] = json!(true);
                }
                if let Some(rc) = s.retry_count {
                    item["retry_count"] = json!(rc);
                }
                item
            })
            .collect()
    }

    let mut active: Vec<Value> = Vec::new();
    active.extend(map_items(&state.current, "current"));
    active.extend(map_items(&state.qa, "qa"));
    active.extend(map_items(&state.merge, "merge"));
    active.extend(map_items(&state.done, "done"));

    let backlog: Vec<Value> = state
        .backlog
        .iter()
        .map(|s| json!({ "story_id": s.story_id, "name": slim_name(&s.name) }))
        .collect();

    let archived: Vec<Value> = state
        .archived
        .iter()
        .map(|s| {
            json!({
                "story_id": s.story_id,
                "name": slim_name(&s.name),
                "stage": "archived",
                "pipeline": s.pipeline.as_str(),
                "status": s.status.as_str(),
            })
        })
        .collect();

    serde_json::to_string_pretty(&json!({
        "active": active,
        "backlog": backlog,
        "backlog_count": backlog.len(),
        "archived": archived,
        "deterministic_merges_in_flight": running_merges,
    }))
    .map_err(|e| format!("Serialization error: {e}"))
}

/// Valid `stage` filter values for [`tool_pipeline_query`], derived from the
/// [`crate::pipeline_state::Pipeline`] wire-format strings so the error
/// message and filter logic can never drift from the enum.
const PIPELINE_QUERY_VALID_STAGES: &[&str] = &[
    "backlog", "coding", "qa", "merge", "done", "closed", "archived",
];

/// Field names [`tool_pipeline_query`] returns when the caller omits `fields`.
const PIPELINE_QUERY_DEFAULT_FIELDS: &[&str] = &["story_id", "name", "stage"];

/// Slim, filterable projection of pipeline items straight from the CRDT
/// typed projection ([`crate::pipeline_state::read_all_typed`]) — never
/// surfaces markdown-migration rows that aren't live CRDT state. Supports
/// filtering by `stage`, an opt-in `include_archived` flag (archived items
/// are excluded by default), and a `fields` allowlist so callers can keep
/// responses far smaller than `get_pipeline_status`.
pub(crate) fn tool_pipeline_query(args: &Value) -> Result<String, String> {
    use crate::pipeline_state::Pipeline;

    let stage_filter = args.get("stage").and_then(|v| v.as_str());
    if let Some(s) = stage_filter
        && !PIPELINE_QUERY_VALID_STAGES.contains(&s)
    {
        return Err(format!(
            "Unknown stage '{s}'. Valid stages: {}",
            PIPELINE_QUERY_VALID_STAGES.join(", ")
        ));
    }

    let include_archived = args
        .get("include_archived")
        .and_then(|v| v.as_bool())
        .unwrap_or(false);

    let fields: Vec<&str> = match args.get("fields").and_then(|v| v.as_array()) {
        Some(arr) => arr.iter().filter_map(|v| v.as_str()).collect(),
        None => PIPELINE_QUERY_DEFAULT_FIELDS.to_vec(),
    };

    fn slim_name(name: &str) -> &str {
        crate::chat::util::truncate_at_char_boundary(name, 120)
    }

    let items: Vec<Value> = crate::pipeline_state::read_all_typed()
        .into_iter()
        .filter(|item| include_archived || item.stage.pipeline() != Pipeline::Archived)
        .filter(|item| stage_filter.is_none_or(|s| item.stage.pipeline().as_str() == s))
        .map(|item| {
            let mut obj = serde_json::Map::new();
            for field in &fields {
                let value = match *field {
                    "story_id" => json!(item.story_id.0),
                    "name" => json!(slim_name(&item.name)),
                    "stage" => json!(item.stage.pipeline().as_str()),
                    "status" => json!(item.stage.status().as_str()),
                    "depends_on" => json!(
                        item.depends_on
                            .iter()
                            .map(|d| d.0.as_str())
                            .collect::<Vec<_>>()
                    ),
                    _ => continue,
                };
                obj.insert((*field).to_string(), value);
            }
            Value::Object(obj)
        })
        .collect();

    serde_json::to_string_pretty(&items).map_err(|e| format!("Serialization error: {e}"))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::http::test_helpers::test_ctx;
    use serde_json::json;

    #[test]
    fn tool_validate_stories_empty_project() {
        let tmp = tempfile::tempdir().unwrap();
        let ctx = test_ctx(tmp.path());
        let result = tool_validate_stories(&ctx).unwrap();
        // CRDT is global; other tests may have inserted items.
        // Just verify it parses without error.
        let _parsed: Vec<Value> = serde_json::from_str(&result).unwrap();
    }

    #[test]
    fn tool_create_story_and_list_upcoming() {
        crate::db::ensure_content_store();
        let tmp = tempfile::tempdir().unwrap();
        // No git repo needed: spike 61 — create_story just writes the file;
        // the filesystem watcher handles the commit asynchronously.
        let ctx = test_ctx(tmp.path());

        let result = super::super::tool_create_story(
            &json!({
                "name": "Test Story",
                "acceptance_criteria": ["AC1", "AC2"],
                "origin": {"kind": "test", "id": "test-suite"}
            }),
            &ctx,
        )
        .unwrap();
        assert!(result.contains("Created story:"));

        // List should return it (CRDT is global, so filter for our story)
        let list = tool_list_upcoming(&ctx).unwrap();
        let parsed: Vec<Value> = serde_json::from_str(&list).unwrap();
        assert!(
            parsed.iter().any(|s| s["name"] == "Test Story"),
            "expected 'Test Story' in upcoming list: {parsed:?}"
        );
    }

    #[test]
    fn tool_get_pipeline_status_returns_structured_response() {
        let tmp = tempfile::tempdir().unwrap();

        crate::db::ensure_content_store();
        for (stage, id, name) in &[
            ("1_backlog", "9910_story_upcoming", "Upcoming Story"),
            ("2_current", "9920_story_current", "Current Story"),
            ("3_qa", "9930_story_qa", "QA Story"),
            ("4_merge", "9940_story_merge", "Merge Story"),
            ("5_done", "9950_story_done", "Done Story"),
        ] {
            crate::db::write_item_with_content(
                id,
                stage,
                &format!("---\nname: \"{name}\"\n---\n"),
                crate::db::ItemMeta {
                    name: Some(name.to_string()),
                    ..Default::default()
                },
            );
        }

        let ctx = test_ctx(tmp.path());
        let result = tool_get_pipeline_status(&ctx).unwrap();
        let parsed: Value = serde_json::from_str(&result).unwrap();

        // Active stages include current, qa, merge, done
        let active = parsed["active"].as_array().unwrap();
        let stages: Vec<&str> = active
            .iter()
            .map(|i| i["stage"].as_str().unwrap())
            .collect();
        assert!(stages.contains(&"current"));
        assert!(stages.contains(&"qa"));
        assert!(stages.contains(&"merge"));
        assert!(stages.contains(&"done"));

        // Backlog should contain our item
        let backlog = parsed["backlog"].as_array().unwrap();
        assert!(
            backlog
                .iter()
                .any(|b| b["story_id"] == "9910_story_upcoming"),
            "expected 9910_story_upcoming in backlog: {backlog:?}"
        );
    }

    #[test]
    fn tool_get_pipeline_status_includes_agent_assignment() {
        let tmp = tempfile::tempdir().unwrap();

        crate::db::ensure_content_store();
        crate::db::write_item_with_content(
            "9921_story_active",
            "2_current",
            "---\nname: \"Active Story\"\n---\n",
            crate::db::ItemMeta::named("Active Story"),
        );

        let ctx = test_ctx(tmp.path());
        ctx.services.agents.inject_test_agent(
            "9921_story_active",
            "coder-1",
            crate::agents::AgentStatus::Running,
        );

        let result = tool_get_pipeline_status(&ctx).unwrap();
        let parsed: Value = serde_json::from_str(&result).unwrap();

        let active = parsed["active"].as_array().unwrap();
        let item = active
            .iter()
            .find(|i| i["story_id"] == "9921_story_active")
            .expect("expected 9921_story_active in active items");
        assert_eq!(item["stage"], "current");
        assert!(!item["agent"].is_null(), "agent should be present");
        assert_eq!(item["agent"]["agent_name"], "coder-1");
        assert_eq!(item["agent"]["status"], "running");
    }

    #[test]
    fn tool_validate_stories_with_valid_story() {
        let tmp = tempfile::tempdir().unwrap();

        crate::db::ensure_content_store();
        crate::db::write_item_with_content(
            "9907_test",
            "2_current",
            "---\nname: \"Valid Story\"\n---\n## AC\n- [ ] First\n",
            crate::db::ItemMeta::named("Valid Story"),
        );

        let ctx = test_ctx(tmp.path());
        let result = tool_validate_stories(&ctx).unwrap();
        let parsed: Vec<Value> = serde_json::from_str(&result).unwrap();
        let item = parsed
            .iter()
            .find(|v| v["story_id"] == "9907_test")
            .expect("expected 9907_test in validation results");
        assert_eq!(item["valid"], true);
    }

    #[test]
    fn pipeline_status_50_items_under_10kb() {
        crate::db::ensure_content_store();
        let stages = [
            ("1_backlog", "backlog"),
            ("2_current", "current"),
            ("3_qa", "qa"),
            ("4_merge", "merge"),
            ("5_done", "done"),
        ];
        for (i, (dir, _)) in stages.iter().enumerate() {
            for j in 0..10 {
                let id = format!("99{i}{j}0_story_size_test");
                let name = format!("Pipeline Size Test Story {i}-{j}");
                crate::db::write_item_with_content(
                    &id,
                    dir,
                    &format!("---\nname: \"{name}\"\n---\n"),
                    crate::db::ItemMeta {
                        name: Some(name),
                        ..Default::default()
                    },
                );
            }
        }
        let tmp = tempfile::tempdir().unwrap();
        let ctx = test_ctx(tmp.path());
        let result = tool_get_pipeline_status(&ctx).unwrap();
        assert!(
            result.len() < 10 * 1024,
            "50-item response must be under 10 KB; got {} bytes",
            result.len()
        );
    }

    #[test]
    fn pipeline_status_per_item_under_500_bytes() {
        crate::db::ensure_content_store();
        // Insert one item per active stage with a moderately long name.
        let stages = [
            ("2_current", "9995_story_peritem_current"),
            ("3_qa", "9996_story_peritem_qa"),
            ("4_merge", "9997_story_peritem_merge"),
            ("5_done", "9998_story_peritem_done"),
        ];
        for (dir, id) in &stages {
            let name = "A Reasonably Named Story For Size Testing";
            crate::db::write_item_with_content(
                id,
                dir,
                &format!("---\nname: \"{name}\"\n---\n"),
                crate::db::ItemMeta {
                    name: Some(name.to_string()),
                    ..Default::default()
                },
            );
        }
        let tmp = tempfile::tempdir().unwrap();
        let ctx = test_ctx(tmp.path());
        let result = tool_get_pipeline_status(&ctx).unwrap();
        let parsed: Value = serde_json::from_str(&result).unwrap();
        let active = parsed["active"].as_array().unwrap();
        for item in active {
            if stages.iter().any(|(_, id)| item["story_id"] == *id) {
                let item_json = serde_json::to_string(item).unwrap();
                assert!(
                    item_json.len() < 500,
                    "per-item payload must be under 500 bytes; story_id={} got {} bytes: {}",
                    item["story_id"],
                    item_json.len(),
                    item_json
                );
            }
        }
    }

    #[test]
    fn tool_validate_stories_with_invalid_front_matter() {
        let tmp = tempfile::tempdir().unwrap();

        crate::db::ensure_content_store();
        // Story 946 AC 5: nameless items are invisible at the CRDT layer.
        // `extract_item_view` returns `None` for items with no name register set,
        // so they are filtered from all read paths including `validate_story_dirs`.
        crate::db::write_item_with_content(
            "9908_test",
            "2_current",
            "## No front matter at all\n",
            crate::db::ItemMeta::default(),
        );

        let ctx = test_ctx(tmp.path());
        let result = tool_validate_stories(&ctx).unwrap();
        let parsed: Vec<Value> = serde_json::from_str(&result).unwrap();
        assert!(
            parsed.iter().all(|v| v["story_id"] != "9908_test"),
            "nameless items must be invisible to tool_validate_stories"
        );
    }

    #[test]
    fn pipeline_query_filters_by_stage() {
        crate::db::ensure_content_store();
        crate::db::write_item_with_content(
            "9960_story_pq_current",
            "2_current",
            "---\nname: \"PQ Current\"\n---\n",
            crate::db::ItemMeta::named("PQ Current"),
        );
        crate::db::write_item_with_content(
            "9961_story_pq_qa",
            "3_qa",
            "---\nname: \"PQ QA\"\n---\n",
            crate::db::ItemMeta::named("PQ QA"),
        );

        let result = tool_pipeline_query(&json!({"stage": "coding"})).unwrap();
        let parsed: Vec<Value> = serde_json::from_str(&result).unwrap();
        assert!(
            parsed
                .iter()
                .any(|i| i["story_id"] == "9960_story_pq_current")
        );
        assert!(
            parsed.iter().all(|i| i["story_id"] != "9961_story_pq_qa"),
            "stage=coding must exclude qa items: {parsed:?}"
        );
    }

    #[test]
    fn pipeline_query_excludes_archived_unless_opted_in() {
        crate::db::ensure_content_store();
        crate::db::write_item_with_content(
            "9962_story_pq_archived",
            "6_archived",
            "---\nname: \"PQ Archived\"\n---\n",
            crate::db::ItemMeta::named("PQ Archived"),
        );

        let default_result = tool_pipeline_query(&json!({})).unwrap();
        let default_parsed: Vec<Value> = serde_json::from_str(&default_result).unwrap();
        assert!(
            default_parsed
                .iter()
                .all(|i| i["story_id"] != "9962_story_pq_archived"),
            "archived items must be excluded by default: {default_parsed:?}"
        );

        let opted_in_result = tool_pipeline_query(&json!({"include_archived": true})).unwrap();
        let opted_in_parsed: Vec<Value> = serde_json::from_str(&opted_in_result).unwrap();
        assert!(
            opted_in_parsed
                .iter()
                .any(|i| i["story_id"] == "9962_story_pq_archived"),
            "include_archived=true must surface archived items: {opted_in_parsed:?}"
        );
    }

    #[test]
    fn pipeline_query_projects_only_requested_fields() {
        crate::db::ensure_content_store();
        crate::db::write_item_with_content(
            "9963_story_pq_fields",
            "2_current",
            "---\nname: \"PQ Fields\"\n---\n",
            crate::db::ItemMeta::named("PQ Fields"),
        );

        // Default slim field set.
        let result = tool_pipeline_query(&json!({"stage": "coding"})).unwrap();
        let parsed: Vec<Value> = serde_json::from_str(&result).unwrap();
        let item = parsed
            .iter()
            .find(|i| i["story_id"] == "9963_story_pq_fields")
            .expect("expected item in default field projection");
        let obj = item.as_object().unwrap();
        assert_eq!(
            obj.keys()
                .map(String::as_str)
                .collect::<std::collections::BTreeSet<_>>(),
            ["story_id", "name", "stage"].into_iter().collect()
        );

        // Explicit narrower field set.
        let result =
            tool_pipeline_query(&json!({"stage": "coding", "fields": ["story_id", "status"]}))
                .unwrap();
        let parsed: Vec<Value> = serde_json::from_str(&result).unwrap();
        let item = parsed
            .iter()
            .find(|i| i["story_id"] == "9963_story_pq_fields")
            .expect("expected item in narrow field projection");
        let obj = item.as_object().unwrap();
        assert_eq!(
            obj.keys()
                .map(String::as_str)
                .collect::<std::collections::BTreeSet<_>>(),
            ["story_id", "status"].into_iter().collect()
        );
    }

    #[test]
    fn pipeline_query_caps_item_names() {
        crate::db::ensure_content_store();
        let long_name = "N".repeat(500);
        crate::db::write_item_with_content(
            "9964_story_pq_longname",
            "2_current",
            &format!("---\nname: \"{long_name}\"\n---\n"),
            crate::db::ItemMeta::named(&long_name),
        );

        let result = tool_pipeline_query(&json!({"stage": "coding"})).unwrap();
        let parsed: Vec<Value> = serde_json::from_str(&result).unwrap();
        let item = parsed
            .iter()
            .find(|i| i["story_id"] == "9964_story_pq_longname")
            .expect("expected long-named item");
        assert!(
            item["name"].as_str().unwrap().len() <= 120,
            "name must be capped at 120 bytes: {} bytes",
            item["name"].as_str().unwrap().len()
        );
    }

    #[test]
    fn pipeline_query_unknown_stage_lists_valid_stages() {
        let err = tool_pipeline_query(&json!({"stage": "not-a-real-stage"})).unwrap_err();
        assert!(err.contains("not-a-real-stage"), "{err}");
        for stage in PIPELINE_QUERY_VALID_STAGES {
            assert!(err.contains(stage), "error should list '{stage}': {err}");
        }
    }

    #[test]
    fn pipeline_query_zero_matches_returns_empty_array_not_error() {
        let result = tool_pipeline_query(&json!({"stage": "done", "fields": ["story_id"]}));
        assert!(result.is_ok(), "zero matches must not be an error");
        let parsed: Vec<Value> = serde_json::from_str(&result.unwrap()).unwrap();
        assert!(parsed.iter().all(|i| i["story_id"] != "no-such-story"));
    }

    #[test]
    fn pipeline_query_omits_ghost_items_not_in_crdt() {
        crate::db::ensure_content_store();
        crate::db::write_item_with_content(
            "9965_story_pq_real",
            "2_current",
            "---\nname: \"PQ Real\"\n---\n",
            crate::db::ItemMeta::named("PQ Real"),
        );

        let result = tool_pipeline_query(&json!({})).unwrap();
        let parsed: Vec<Value> = serde_json::from_str(&result).unwrap();
        assert!(
            parsed.iter().any(|i| i["story_id"] == "9965_story_pq_real"),
            "real CRDT item must appear"
        );
        // A story_id that was never written via the CRDT (no filesystem
        // markdown-scan path exists in pipeline_query) must never appear —
        // this is the regression guard for the ghost-item bug class.
        assert!(
            parsed
                .iter()
                .all(|i| i["story_id"] != "9999_story_never_written_ghost"),
            "ghost item not present in CRDT must not appear: {parsed:?}"
        );
    }

    #[test]
    fn pipeline_query_response_is_smaller_than_get_pipeline_status() {
        crate::db::ensure_content_store();
        let tmp = tempfile::tempdir().unwrap();
        for i in 0..10 {
            let id = format!("997{i}0_story_pq_size");
            let name = "A Reasonably Named Story For Size Comparison";
            crate::db::write_item_with_content(
                &id,
                "2_current",
                &format!("---\nname: \"{name}\"\n---\n"),
                crate::db::ItemMeta {
                    name: Some(name.to_string()),
                    ..Default::default()
                },
            );
        }
        let ctx = test_ctx(tmp.path());
        let full = tool_get_pipeline_status(&ctx).unwrap();
        let slim = tool_pipeline_query(&json!({})).unwrap();
        assert!(
            slim.len() < full.len(),
            "slim pipeline_query response ({} bytes) must be smaller than \
             get_pipeline_status ({} bytes)",
            slim.len(),
            full.len()
        );
    }
}
