//! MCP diagnostic tools — server logs, CRDT dump, version, line counting, story movement.

use crate::agents::move_story_to_stage;
use crate::http::context::AppContext;
use crate::log_buffer;
use serde_json::{Value, json};

mod permission;
mod usage;

pub(crate) use permission::tool_prompt_permission;
pub(crate) use usage::tool_get_token_usage;

pub(crate) fn tool_get_server_logs(args: &Value) -> Result<String, String> {
    let lines_count = args
        .get("lines")
        .and_then(|v| v.as_u64())
        .map(|n| n.min(10_000) as usize)
        .unwrap_or(100);
    let filter = args.get("filter").and_then(|v| v.as_str());
    let from_file = args
        .get("from_file")
        .and_then(|v| v.as_bool())
        .unwrap_or(false);

    if from_file {
        let offset = args.get("offset").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
        let lines = log_buffer::global().read_from_disk(filter, offset, lines_count);
        return Ok(lines.join("\n"));
    }

    let severity = args
        .get("severity")
        .and_then(|v| v.as_str())
        .and_then(log_buffer::LogLevel::from_str_ci);

    let recent = log_buffer::global().get_recent(lines_count, filter, severity.as_ref());
    let joined = recent.join("\n");
    // Clamp to lines_count actual lines in case any entry contains embedded newlines.
    let all_lines: Vec<&str> = joined.lines().collect();
    let start = all_lines.len().saturating_sub(lines_count);
    Ok(all_lines[start..].join("\n"))
}

/// MCP tool called by Claude Code via `--permission-prompt-tool`.
///
/// Forwards the permission request through the shared channel to the active
/// WebSocket session, which presents a dialog to the user. Blocks until the
/// user approves or denies (with a 5-minute timeout).
pub(crate) fn tool_move_story(args: &Value, _ctx: &AppContext) -> Result<String, String> {
    let req = crate::validation::MoveStoryRequest::from_json(args)?;
    let story_id = req.story_id.as_str();
    let target_stage = req.target_stage.as_str();

    let (from_stage, to_stage) = move_story_to_stage(story_id, target_stage)?;

    serde_json::to_string_pretty(&json!({
        "story_id": story_id,
        "from_stage": from_stage,
        "to_stage": to_stage,
        "message": format!("Work item '{story_id}' moved from '{from_stage}' to '{to_stage}'.")
    }))
    .map_err(|e| format!("Serialization error: {e}"))
}

/// MCP tool: dump the raw in-memory CRDT state for debugging.
///
/// **Debug tool only** — for normal pipeline introspection use `get_pipeline_status`.
pub(crate) fn tool_dump_crdt(args: &Value) -> Result<String, String> {
    let story_id_filter = args.get("story_id").and_then(|v| v.as_str());
    let dump = crate::crdt_state::dump_crdt_state(story_id_filter);

    let items: Vec<Value> = dump
        .items
        .into_iter()
        .map(|item| {
            // Story 1087: emit `pipeline` and `status` alongside `stage` so
            // crdt-dump consumers can route by column/badge without re-deriving
            // the projection from the stage string.
            let (pipeline, status) = item
                .stage
                .as_deref()
                .and_then(crate::pipeline_state::Stage::from_dir)
                .map(|s| (s.pipeline().as_str(), s.status().as_str()))
                .unwrap_or(("", ""));
            json!({
                "story_id": item.story_id,
                "stage": item.stage,
                "pipeline": pipeline,
                "status": status,
                "name": item.name,
                "agent": item.agent,
                "retry_count": item.retry_count,
                "depends_on": item.depends_on,
                "claimed_by": item.claim_agent,
                "claimed_at": item.claim_ts,
                "content_index": item.content_index,
                "is_deleted": item.is_deleted,
                "origin": item.origin,
                "item_type": item.item_type,
            })
        })
        .collect();

    serde_json::to_string_pretty(&json!({
        "metadata": {
            "in_memory_state_loaded": dump.in_memory_state_loaded,
            "total_items": dump.total_items,
            "total_ops_in_list": dump.total_ops_in_list,
            "max_seq_in_list": dump.max_seq_in_list,
            "persisted_ops_count": dump.persisted_ops_count,
            "pending_persist_ops_count": dump.pending_persist_ops_count,
        },
        "items": items,
    }))
    .map_err(|e| format!("Serialization error: {e}"))
}

/// MCP tool: return the server version, build hash, and running port.
pub(crate) fn tool_get_version(ctx: &AppContext) -> Result<String, String> {
    let build_hash = option_env!("BUILD_GIT_HASH").unwrap_or("unknown");
    serde_json::to_string_pretty(&json!({
        "version": env!("CARGO_PKG_VERSION"),
        "build_hash": build_hash,
        "port": ctx.services.agents.port(),
    }))
    .map_err(|e| format!("Serialization error: {e}"))
}

/// MCP tool: return read-only peer mesh status.
///
/// Returns a JSON object with `local_node_id` and a `peers` array.  Each peer
/// has `node_id`, `pubkey` (same value — the hex-encoded Ed25519 public key),
/// `last_seen` (Unix timestamp), and `is_self` (true for the local node).
///
/// This tool is read-only and does not mutate any state.
pub(crate) fn tool_mesh_status(_args: &Value) -> Result<String, String> {
    let local_id = crate::crdt_state::our_node_id().unwrap_or_default();
    let nodes = crate::crdt_state::read_all_node_presence().unwrap_or_default();

    let peers: Vec<serde_json::Value> = nodes
        .into_iter()
        .map(|n| {
            let is_self = n.node_id == local_id;
            json!({
                "node_id": n.node_id,
                "pubkey": n.node_id,
                "last_seen": n.last_seen,
                "is_self": is_self,
            })
        })
        .collect();

    serde_json::to_string_pretty(&json!({
        "local_node_id": local_id,
        "peers": peers,
    }))
    .map_err(|e| format!("Serialization error: {e}"))
}

/// MCP tool: find half-written (orphaned) pipeline items.
///
/// Scans the in-memory content store for story IDs that have no corresponding
/// live CRDT entry.  These are items that were partially written before bug
/// 1001 was fixed — the content store (and optionally the SQLite shadow)
/// accepted the write, but the CRDT silently rejected it (because the ID was
/// tombstoned).  They are invisible to every normal read path.
pub(crate) fn tool_find_orphaned_items(_args: &Value) -> Result<String, String> {
    let orphans = crate::db::recover::find_half_written_items();
    serde_json::to_string_pretty(&serde_json::json!({
        "orphan_count": orphans.len(),
        "orphans": orphans,
    }))
    .map_err(|e| format!("Serialization error: {e}"))
}

/// MCP tool: recover half-written (orphaned) pipeline items.
///
/// For each orphaned item (content store row with no live CRDT entry), lifts
/// the content onto a fresh non-tombstoned ID so it becomes addressable again.
/// Pass `only` to restrict recovery to specific orphan IDs; omit to recover all.
/// Returns a mapping of `old_id → new_id` for every successful recovery.
pub(crate) fn tool_recover_half_written_items(args: &Value) -> Result<String, String> {
    let only: Option<Vec<String>> = args
        .get("only")
        .and_then(|v| serde_json::from_value(v.clone()).ok());
    let results = crate::db::recover::recover_half_written_items(only.as_deref());
    serde_json::to_string_pretty(&serde_json::json!({
        "recovered_count": results.len(),
        "mappings": results,
    }))
    .map_err(|e| format!("Serialization error: {e}"))
}

/// MCP tool: count lines in a specific file relative to the project root.
pub(crate) fn tool_loc_file(args: &Value, ctx: &AppContext) -> Result<String, String> {
    let file_path = args
        .get("file_path")
        .and_then(|v| v.as_str())
        .ok_or_else(|| "Missing required argument: file_path".to_string())?;

    let project_root = ctx.state.get_project_root()?;
    Ok(crate::chat::commands::loc::loc_single_file(
        &project_root,
        file_path,
    ))
}

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

    #[test]
    fn tool_mesh_status_returns_expected_shape_with_local_node_flagged() {
        // Initialise a test CRDT so our_node_id() and write_node_presence() work.
        crate::crdt_state::init_for_test();

        let local_id = crate::crdt_state::our_node_id()
            .expect("CRDT must be initialised before calling tool_mesh_status");

        // Write the local node's presence so it appears in read_all_node_presence().
        crate::crdt_state::write_node_presence(
            &local_id,
            "ws://127.0.0.1:3001/crdt-sync",
            1_700_000_000.0,
            true,
        );

        let result = tool_mesh_status(&json!({})).expect("tool_mesh_status must not fail");
        let parsed: serde_json::Value =
            serde_json::from_str(&result).expect("result must be valid JSON");

        // local_node_id field present
        assert_eq!(
            parsed["local_node_id"].as_str(),
            Some(local_id.as_str()),
            "local_node_id must match our_node_id"
        );

        // peers array present
        let peers = parsed["peers"].as_array().expect("peers must be an array");
        assert!(!peers.is_empty(), "peers must include the local node");

        // Find the local peer and verify it is flagged is_self: true
        let self_peer = peers
            .iter()
            .find(|p| p["node_id"].as_str() == Some(&local_id))
            .expect("local node must appear in peers list");

        assert_eq!(
            self_peer["is_self"].as_bool(),
            Some(true),
            "local node must have is_self: true"
        );
        assert_eq!(
            self_peer["pubkey"].as_str(),
            Some(local_id.as_str()),
            "pubkey must equal node_id"
        );
        assert!(
            self_peer["last_seen"].is_number(),
            "last_seen must be a number"
        );
    }

    #[test]
    fn tool_mesh_status_is_read_only_no_panic_without_crdt() {
        // When CRDT is not initialised (different process / test order),
        // the tool must return a valid (empty) result rather than panicking.
        let result = tool_mesh_status(&json!({}));
        // Must succeed — empty peers list is acceptable
        assert!(
            result.is_ok(),
            "tool_mesh_status must not error without CRDT"
        );
        let parsed: serde_json::Value =
            serde_json::from_str(&result.unwrap()).expect("must be valid JSON");
        assert!(parsed["peers"].is_array(), "peers field must be an array");
    }

    #[test]
    fn tool_get_server_logs_no_args_returns_string() {
        let result = tool_get_server_logs(&json!({})).unwrap();
        // Returns recent log lines (possibly empty in tests) — just verify no panic
        let _ = result;
    }

    #[test]
    fn tool_get_server_logs_with_filter_returns_matching_lines() {
        let result = tool_get_server_logs(&json!({"filter": "xyz_unlikely_match_999"})).unwrap();
        assert_eq!(
            result, "",
            "filter with no matches should return empty string"
        );
    }

    #[test]
    fn tool_get_server_logs_with_line_limit() {
        let result = tool_get_server_logs(&json!({"lines": 5})).unwrap();
        assert!(result.lines().count() <= 5);
    }

    #[test]
    fn tool_get_server_logs_max_cap_is_1000() {
        // Lines > 1000 are capped — just verify it returns without error
        let result = tool_get_server_logs(&json!({"lines": 9999})).unwrap();
        let _ = result;
    }

    #[test]
    fn tool_get_version_ignores_build_hash_file_and_reports_compile_time_value() {
        // Regression: get_version must NOT read .huskies/build_hash at runtime.
        // Write a deliberately wrong value to the file and assert get_version
        // returns the compile-time hash, not the file content.
        let dir = tempfile::tempdir().expect("tempdir");
        let huskies_dir = dir.path().join(".huskies");
        std::fs::create_dir_all(&huskies_dir).unwrap();
        std::fs::write(huskies_dir.join("build_hash"), "wrong_hash_sentinel_xyz").unwrap();

        let ctx = crate::http::test_helpers::test_ctx(dir.path());
        let result = tool_get_version(&ctx).expect("tool_get_version must not fail");
        let parsed: serde_json::Value = serde_json::from_str(&result).expect("must be valid JSON");

        let returned_hash = parsed["build_hash"]
            .as_str()
            .expect("build_hash must be a string");
        assert_ne!(
            returned_hash, "wrong_hash_sentinel_xyz",
            "get_version must not read .huskies/build_hash; got '{returned_hash}'"
        );
        // The returned hash must equal the compile-time value.
        let compile_time_hash = option_env!("BUILD_GIT_HASH").unwrap_or("unknown");
        assert_eq!(
            returned_hash, compile_time_hash,
            "get_version must return compile-time BUILD_GIT_HASH"
        );
    }
}
