//! Story creation and CRDT eviction tools.

use crate::http::context::AppContext;
use crate::http::workflow::create_story_file;
use crate::slog_warn;
use crate::validation::CreateStoryRequest;
use serde_json::Value;

/// Create a new story in the backlog.
///
/// Deserialises the JSON arguments into a `CreateStoryRequest`, runs the full
/// validation pipeline (field-level newtypes + cross-field garde rules), then
/// delegates to `create_story_file` / `create_item_in_backlog`.  All ad-hoc
/// string checks have been removed; `CreateStoryRequest` is now the sole gate.
pub(crate) fn tool_create_story(args: &Value, ctx: &AppContext) -> Result<String, String> {
    let req = CreateStoryRequest::from_json(args)?;

    // Bug 1102: resolve and validate origin BEFORE creating the story file so
    // a missing-attribution call leaves no half-state behind.
    let (origin, origin_label) = super::super::build_origin(args)?;

    let root = ctx.state.get_project_root()?;
    let depends_on_ids = req.depends_on_ids();

    let story_id = create_story_file(
        &root,
        req.name.as_ref(),
        req.user_story.as_ref().map(|d| d.as_ref()),
        req.description.as_ref().map(|d| d.as_ref()),
        &req.acceptance_criteria
            .iter()
            .map(|ac| ac.as_ref().to_string())
            .collect::<Vec<_>>(),
        depends_on_ids.as_deref(),
        false,
    )?;

    crate::crdt_state::set_origin(&story_id, &origin);

    let _ = ctx
        .watcher_tx
        .send(crate::io::watcher::WatcherEvent::NewItemCreated {
            item_id: story_id.clone(),
            item_type: "story".to_string(),
            name: req.name.as_ref().to_string(),
            origin: origin_label,
        });

    // Bug 503: warn at creation time if any depends_on points at an already-archived story.
    let archived_deps: Vec<u32> = depends_on_ids
        .as_deref()
        .map(|deps| {
            deps.iter()
                .copied()
                .filter(|&d| crate::crdt_state::dep_is_archived_crdt(d))
                .collect()
        })
        .unwrap_or_default();
    if !archived_deps.is_empty() {
        slog_warn!(
            "[create-story] Story '{story_id}' depends_on {archived_deps:?} which \
             are already in 6_archived. The dep will be treated as satisfied on the \
             next promotion tick. If these deps were abandoned (not cleanly completed), \
             consider removing the depends_on or keeping the story in backlog manually."
        );
        return Ok(format!(
            "Created story: {story_id}\n\n\
             WARNING: depends_on {archived_deps:?} point at stories already in \
             6_archived. These deps are treated as satisfied (archived = satisfied \
             semantics), so this story may be auto-promoted from backlog immediately. \
             If the archived deps were abandoned rather than completed, remove the \
             depends_on or move the story back to backlog manually after promotion."
        ));
    }

    Ok(format!("Created story: {story_id}"))
}

/// Purge a story from the in-memory CRDT by writing a tombstone op (story 521).
///
/// This is the eviction primitive for the four-state-machine drift problem
/// we hit on 2026-04-09 — when a story gets stuck in the running server's
/// in-memory CRDT and can't be cleared by sqlite deletes alone (because the
/// in-memory state outlives any pipeline_items / crdt_ops manipulation),
/// this tool writes a proper CRDT delete op via `crdt_state::evict_item`.
///
/// The tombstone op:
///   - Marks the in-memory CRDT item as `is_deleted = true` immediately
///     (so subsequent `read_all_items` / `read_item` calls skip it)
///   - Is persisted to `crdt_ops` so the eviction survives a server restart
///   - Drops the in-memory `CONTENT_STORE` entry for the story
///
/// Half-written items (content store has a row but the CRDT has no entry —
/// the bug 1001 split-brain scenario) are also handled: if `evict_item` fails
/// because the CRDT entry is absent, the tool checks the content store and
/// removes the orphaned row, returning success instead of an error.
///
/// This tool does NOT touch: running agents, worktrees, the `pipeline_items`
/// shadow table, `timers.json`, or filesystem shadows. Compose with
/// `stop_agent`, `remove_worktree`, etc. as needed for a full purge — or
/// use `delete_story` for a complete cleanup sequence.
pub(crate) fn tool_purge_story(args: &Value, _ctx: &AppContext) -> Result<String, String> {
    let story_id = args
        .get("story_id")
        .and_then(|v| v.as_str())
        .ok_or("Missing required argument: story_id")?;

    if let Err(evict_err) = crate::crdt_state::evict_item(story_id) {
        // evict_item failed — either the CRDT is not initialised or the item
        // has no live CRDT entry.  Handle the half-written-item case: the
        // content store has the row but the CRDT doesn't (bug 1001 remnant).
        let in_content_store =
            crate::db::read_content(crate::db::ContentKey::Story(story_id)).is_some();
        if in_content_store {
            crate::db::delete_item(story_id);
            return Ok(format!(
                "'{story_id}' had no CRDT entry but existed in the content store \
                 (half-written item — likely a pre-fix bug 1001 artifact). \
                 Removed the orphaned content-store row."
            ));
        }
        return Err(format!(
            "'{story_id}' not found in CRDT or content store: {evict_err}"
        ));
    }

    Ok(format!(
        "Evicted '{story_id}' from in-memory CRDT (tombstone op persisted to crdt_ops; CONTENT_STORE entry dropped)."
    ))
}

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

    /// Regression for bug 1001: `purge_story` must succeed on a half-written
    /// item (content store has a row but the CRDT has no entry).
    #[test]
    fn tool_purge_story_cleans_half_written_item() {
        crate::crdt_state::init_for_test();
        crate::db::ensure_content_store();

        // Seed a content-store row without a corresponding CRDT entry
        // by writing then tombstoning, then re-writing the content store
        // directly — this mimics the pre-fix half-write scenario.
        let story_id = "9960_story_purge_halfwrite";
        crate::db::write_item_with_content(
            story_id,
            "1_backlog",
            "---\nname: Half Written\n---\n",
            crate::db::ItemMeta::named("Half Written"),
        );
        crate::crdt_state::evict_item(story_id).expect("evict must succeed for setup");
        // Re-inject only the content row (simulating the bug 1001 half-write).
        crate::db::write_content(
            crate::db::ContentKey::Story(story_id),
            "---\nname: Half Written\n---\n",
        );
        // CRDT must have no entry at this point.
        assert!(
            crate::crdt_state::read_item(story_id).is_none(),
            "setup: CRDT must not have the item"
        );
        // But content store must have it.
        assert!(
            crate::db::read_content(crate::db::ContentKey::Story(story_id)).is_some(),
            "setup: content store must have the item"
        );

        let tmp = tempfile::tempdir().unwrap();
        let ctx = test_ctx(tmp.path());
        let result = tool_purge_story(&json!({"story_id": story_id}), &ctx);
        assert!(
            result.is_ok(),
            "purge_story must succeed on a half-written item: {result:?}"
        );
        let msg = result.unwrap();
        assert!(
            msg.contains("half-written") || msg.contains("orphaned"),
            "result should mention the half-write: {msg}"
        );

        // Content store must be clean after purge.
        assert!(
            crate::db::read_content(crate::db::ContentKey::Story(story_id)).is_none(),
            "content store must be empty after purge of half-written item"
        );
    }

    /// AC4 (story 1201): a create call must succeed even when the
    /// notification pipeline can't deliver — `test_ctx`'s `watcher_tx` has
    /// zero receivers, so the `send` inside `tool_create_story` returns
    /// `Err`, which must be swallowed rather than failing the create call.
    #[test]
    fn tool_create_story_succeeds_when_no_notification_listener_present() {
        crate::db::ensure_content_store();
        let tmp = tempfile::tempdir().unwrap();
        let ctx = test_ctx(tmp.path());
        let result = tool_create_story(
            &json!({
                "name": "No Listener Story",
                "acceptance_criteria": ["It works"],
                "origin": {"kind": "test", "id": "test-suite"}
            }),
            &ctx,
        );
        assert!(
            result.is_ok(),
            "create must succeed even when notification delivery fails: {result:?}"
        );
    }

    #[test]
    fn tool_create_story_missing_name() {
        let tmp = tempfile::tempdir().unwrap();
        let ctx = test_ctx(tmp.path());
        let result = tool_create_story(&json!({}), &ctx);
        assert!(result.is_err());
        let err = result.unwrap_err();
        assert!(
            err.contains("FieldMissing") || err.contains("name"),
            "expected FieldMissing/name in: {err}"
        );
    }

    #[test]
    fn tool_create_story_rejects_empty_name() {
        let tmp = tempfile::tempdir().unwrap();
        let ctx = test_ctx(tmp.path());
        let result = tool_create_story(
            &json!({"name": "!!!", "acceptance_criteria": ["AC1"]}),
            &ctx,
        );
        assert!(result.is_err());
    }

    #[test]
    fn tool_create_story_rejects_whitespace_only_name() {
        let tmp = tempfile::tempdir().unwrap();
        let ctx = test_ctx(tmp.path());
        let err = tool_create_story(
            &json!({"name": "   ", "acceptance_criteria": ["AC1"]}),
            &ctx,
        )
        .unwrap_err();
        assert!(
            err.contains("EmptyAfterTrim") || err.contains("empty") || err.contains("whitespace"),
            "error should mention EmptyAfterTrim/empty/whitespace, got: {err}"
        );
    }

    #[test]
    fn tool_create_story_rejects_missing_acceptance_criteria() {
        let tmp = tempfile::tempdir().unwrap();
        let ctx = test_ctx(tmp.path());
        let result = tool_create_story(&json!({"name": "My Story"}), &ctx);
        assert!(result.is_err());
        assert!(
            result.unwrap_err().contains("acceptance_criteria"),
            "error should mention acceptance_criteria"
        );
    }

    #[test]
    fn tool_create_story_rejects_empty_acceptance_criteria() {
        let tmp = tempfile::tempdir().unwrap();
        let ctx = test_ctx(tmp.path());
        let result = tool_create_story(
            &json!({"name": "My Story", "acceptance_criteria": []}),
            &ctx,
        );
        assert!(result.is_err());
        assert!(
            result.unwrap_err().contains("acceptance_criteria"),
            "error should mention acceptance_criteria"
        );
    }

    #[test]
    fn tool_create_story_accepts_single_criterion() {
        crate::db::ensure_content_store();
        let tmp = tempfile::tempdir().unwrap();
        let ctx = test_ctx(tmp.path());
        let result = tool_create_story(
            &json!({
                "name": "Single Criterion Story",
                "acceptance_criteria": ["It works"],
                "origin": {"kind": "test", "id": "test-suite"}
            }),
            &ctx,
        );
        assert!(result.is_ok(), "expected ok: {result:?}");
        assert!(result.unwrap().contains("Created story:"));
    }

    #[test]
    fn tool_create_story_rejects_all_junk_acceptance_criteria() {
        let tmp = tempfile::tempdir().unwrap();
        let ctx = test_ctx(tmp.path());
        let result = tool_create_story(
            &json!({"name": "Junk Story", "acceptance_criteria": ["TODO", "TBD"]}),
            &ctx,
        );
        assert!(result.is_err());
    }

    #[test]
    fn tool_create_story_accepts_mixed_junk_and_real_acceptance_criteria() {
        crate::db::ensure_content_store();
        let tmp = tempfile::tempdir().unwrap();
        let ctx = test_ctx(tmp.path());
        let result = tool_create_story(
            &json!({
                "name": "Mixed Story",
                "acceptance_criteria": ["TODO", "Real AC"],
                "origin": {"kind": "test", "id": "test-suite"}
            }),
            &ctx,
        );
        assert!(result.is_ok(), "expected ok for mixed AC: {result:?}");
        assert!(result.unwrap().contains("Created story:"));
    }

    #[test]
    fn tool_create_story_description_is_written_to_file() {
        crate::db::ensure_content_store();
        let tmp = tempfile::tempdir().unwrap();
        let ctx = test_ctx(tmp.path());

        let result = tool_create_story(
            &json!({
                "name": "Story With Description",
                "description": "This is the background context.",
                "acceptance_criteria": ["Described well"],
                "origin": {"kind": "test", "id": "test-suite"}
            }),
            &ctx,
        )
        .unwrap();
        assert!(result.contains("Created story:"));

        let story_id = result
            .trim_start_matches("Created story: ")
            .trim()
            .to_string();
        let content = crate::db::read_content(crate::db::ContentKey::Story(&story_id))
            .expect("story content should exist");
        assert!(
            content.contains("## Description"),
            "Description section missing from story: {content}"
        );
        assert!(
            content.contains("This is the background context."),
            "Description text missing from story: {content}"
        );
    }

    #[test]
    fn tool_create_story_rejects_grammar_token_in_name() {
        let tmp = tempfile::tempdir().unwrap();
        let ctx = test_ctx(tmp.path());
        let err = tool_create_story(
            &json!({
                "name": "Bad </description> Story",
                "acceptance_criteria": ["AC1"]
            }),
            &ctx,
        )
        .unwrap_err();
        assert!(
            err.contains("AntiGrammarToken") || err.contains("grammar"),
            "expected grammar-token error, got: {err}"
        );
    }

    #[test]
    fn tool_create_story_rejects_grammar_token_in_ac() {
        let tmp = tempfile::tempdir().unwrap();
        let ctx = test_ctx(tmp.path());
        let err = tool_create_story(
            &json!({
                "name": "Valid Story",
                "acceptance_criteria": ["<thinking>bad output</thinking>"]
            }),
            &ctx,
        )
        .unwrap_err();
        assert!(
            err.contains("AntiGrammarToken") || err.contains("grammar"),
            "expected grammar-token error, got: {err}"
        );
    }

    #[test]
    fn tool_create_story_html_sanitised_in_name() {
        crate::db::ensure_content_store();
        let tmp = tempfile::tempdir().unwrap();
        let ctx = test_ctx(tmp.path());
        // HTML in name is sanitised (not rejected)
        let result = tool_create_story(
            &json!({
                "name": "Story with <b>bold</b> name",
                "acceptance_criteria": ["AC1"],
                "origin": {"kind": "test", "id": "test-suite"}
            }),
            &ctx,
        );
        // Should succeed (HTML is sanitised, not rejected)
        assert!(
            result.is_ok(),
            "HTML in name should be sanitised: {result:?}"
        );
    }
}
