// m9_4_acceptance.rs --- T M9.4 prompt resolution acceptance.

//! Acceptance tests for T M9.4 (`spec/pmacs-tasks.tex:3951`):
//!
//!   1. Prompt resolution returns the expected template-with-args.
//!   2. Required args missing produce a clear error.
//!   3. Prompts with no arguments are also supported.
//!
//! Plus the wire-shape verification called out during the M9.4
//! design review:
//!
//!   4. Empty-args call sends `arguments: {}` on the wire (not
//!      omitted, not `null`). The fake's prompt-record mechanism
//!      writes each request's params to disk; the test parses the
//!      JSON and asserts the shape.

use std::cell::RefCell;
use std::rc::Rc;
use std::time::{Duration, Instant};

use pmacs::async_runtime::{AsyncRuntime, JobId, JobOutcome, JobResult, SharedAsyncRuntime};
use pmacs::lua_bindings::SharedProcessSupervisor;
use pmacs::mcp::{
    McpClientState, McpEvent, McpEventKind, McpManager, McpRestartPolicy, McpServerId,
    McpServerSpec, SharedMcpManager,
};
use pmacs::process::ProcessSupervisor;

fn fake_mcp_path() -> String {
    env!("CARGO_BIN_EXE_pmacs_fake_mcp").to_owned()
}

fn make_test_triple() -> (
    SharedProcessSupervisor,
    SharedAsyncRuntime,
    SharedMcpManager,
) {
    let sup = Rc::new(RefCell::new(ProcessSupervisor::new()));
    let runtime: SharedAsyncRuntime = Rc::new(AsyncRuntime::with_pool_size(1));
    let mgr = Rc::new(RefCell::new(McpManager::new(sup.clone(), runtime.clone())));
    (sup, runtime, mgr)
}

fn fake_spec(label: &str) -> McpServerSpec {
    let mut spec = McpServerSpec::new(label, fake_mcp_path());
    spec.restart = McpRestartPolicy::Never;
    spec
}

fn pump_until<F: FnMut() -> bool>(
    sup: &SharedProcessSupervisor,
    runtime: &SharedAsyncRuntime,
    mgr: &SharedMcpManager,
    deadline: Duration,
    mut pred: F,
) {
    let stop = Instant::now() + deadline;
    while Instant::now() < stop {
        sup.borrow_mut().tick();
        mgr.borrow_mut().tick();
        runtime.tick();
        if pred() {
            return;
        }
        std::thread::sleep(Duration::from_millis(15));
    }
}

fn drain_mcp_until<F: Fn(&[McpEvent]) -> bool>(
    sup: &SharedProcessSupervisor,
    runtime: &SharedAsyncRuntime,
    mgr: &SharedMcpManager,
    sid: McpServerId,
    deadline: Duration,
    pred: F,
) -> Vec<McpEvent> {
    let stop = Instant::now() + deadline;
    let mut all: Vec<McpEvent> = Vec::new();
    while Instant::now() < stop {
        sup.borrow_mut().tick();
        mgr.borrow_mut().tick();
        runtime.tick();
        let mut evs = mgr.borrow_mut().take_events(sid);
        all.append(&mut evs);
        if pred(&all) {
            return all;
        }
        std::thread::sleep(Duration::from_millis(15));
    }
    all
}

fn spawn_initialized(
    sup: &SharedProcessSupervisor,
    runtime: &SharedAsyncRuntime,
    mgr: &SharedMcpManager,
    spec: McpServerSpec,
) -> McpServerId {
    let sid = mgr.borrow_mut().spawn(spec).expect("spawn");
    drain_mcp_until(sup, runtime, mgr, sid, Duration::from_secs(5), |evs| {
        evs.iter()
            .any(|e| matches!(e.kind, McpEventKind::Initialized { .. }))
    });
    assert!(matches!(
        mgr.borrow().state(sid),
        Some(McpClientState::Initialized { .. })
    ));
    sid
}

fn await_job(
    sup: &SharedProcessSupervisor,
    runtime: &SharedAsyncRuntime,
    mgr: &SharedMcpManager,
    job_id: JobId,
    deadline: Duration,
) -> JobOutcome {
    pump_until(sup, runtime, mgr, deadline, || runtime.is_complete(job_id));
    runtime
        .take_result(job_id)
        .unwrap_or_else(|| panic!("job {job_id} did not settle within deadline"))
}

// ===========================================================================
// Bullet 1: round-trip with args
// ===========================================================================

/// Bullet 1: `get_prompt` with required args returns messages
/// referencing those args (the fake threads them through so the
/// test can verify they were transmitted intact).
#[test]
fn m9_4_get_prompt_round_trip_with_args() {
    let (sup, runtime, mgr) = make_test_triple();
    let sid = spawn_initialized(&sup, &runtime, &mgr, fake_spec("prompt-args"));
    let job = mgr
        .borrow_mut()
        .get_prompt(
            sid,
            "code_review",
            serde_json::json!({
                "language": "rust",
                "source": "fn main() {}",
            }),
        )
        .expect("get_prompt");
    let outcome = await_job(&sup, &runtime, &mgr, job, Duration::from_secs(5));
    let result = match outcome {
        JobOutcome::Complete(JobResult::Json(v)) => v,
        other => panic!("expected Complete(Json(...)), got {other:?}"),
    };
    let description = result
        .get("description")
        .and_then(|v| v.as_str())
        .expect("description field");
    assert!(
        description.contains("rust"),
        "description must contain the language arg; got {description:?}"
    );
    let text = result
        .get("messages")
        .and_then(|m| m.as_array())
        .and_then(|arr| arr.first())
        .and_then(|m| m.get("content"))
        .and_then(|c| c.get("text"))
        .and_then(|t| t.as_str())
        .expect("messages[0].content.text");
    assert!(
        text.contains("rust") && text.contains("fn main() {}"),
        "message text must contain both args; got {text:?}"
    );
    let _ = mgr.borrow_mut().stop(sid);
}

// ===========================================================================
// Bullet 2: missing required arg
// ===========================================================================

/// Bullet 2: `get_prompt` without a required arg produces a Lua
/// error via the standard JSON-RPC `Failed` path. The error
/// message includes the server's `-32602 missing required argument:
/// <name>` text.
#[test]
fn m9_4_missing_required_arg_produces_jsonrpc_error() {
    let (sup, runtime, mgr) = make_test_triple();
    let sid = spawn_initialized(&sup, &runtime, &mgr, fake_spec("prompt-missing"));
    // code_review requires {language, source}; omit source.
    let job = mgr
        .borrow_mut()
        .get_prompt(
            sid,
            "code_review",
            serde_json::json!({ "language": "rust" }),
        )
        .expect("get_prompt");
    let outcome = await_job(&sup, &runtime, &mgr, job, Duration::from_secs(5));
    let msg = match outcome {
        JobOutcome::Failed(m) => m,
        other => panic!("expected Failed; got {other:?}"),
    };
    assert!(
        msg.contains("missing required argument: source"),
        "Failed message must contain the server's missing-arg text; got {msg:?}"
    );
    assert!(
        msg.contains("-32602"),
        "Failed message should include the JSON-RPC error code; got {msg:?}"
    );
    let _ = mgr.borrow_mut().stop(sid);
}

// ===========================================================================
// Bullet 3: no-args prompt
// ===========================================================================

/// Bullet 3: prompts with no required arguments resolve cleanly
/// when called with an empty args object.
#[test]
fn m9_4_no_args_prompt_resolves() {
    let (sup, runtime, mgr) = make_test_triple();
    let sid = spawn_initialized(&sup, &runtime, &mgr, fake_spec("prompt-noargs"));
    let job = mgr
        .borrow_mut()
        .get_prompt(sid, "simple", serde_json::json!({}))
        .expect("get_prompt");
    let outcome = await_job(&sup, &runtime, &mgr, job, Duration::from_secs(5));
    let result = match outcome {
        JobOutcome::Complete(JobResult::Json(v)) => v,
        other => panic!("expected Complete(Json(...)), got {other:?}"),
    };
    let text = result
        .get("messages")
        .and_then(|m| m.as_array())
        .and_then(|arr| arr.first())
        .and_then(|m| m.get("content"))
        .and_then(|c| c.get("text"))
        .and_then(|t| t.as_str())
        .expect("messages[0].content.text");
    assert_eq!(text, "no-args prompt body");
    let _ = mgr.borrow_mut().stop(sid);
}

// ===========================================================================
// Wire-shape verification (design-review addition)
// ===========================================================================

/// The MCP spec requires `arguments` even when there are no
/// arguments — sending it as `{}` (empty object), not `null` and
/// not omitting the field. This test verifies the wire shape by
/// having the fake record each prompts/get's params to disk;
/// the test reads the recorded JSON and asserts `arguments` is
/// present and an empty object.
#[test]
fn m9_4_no_args_wire_shape_is_empty_object() {
    let tmp = tempfile::tempdir().expect("tmpdir");
    let record_dir = tmp.path().to_owned();
    let (sup, runtime, mgr) = make_test_triple();
    let mut spec = fake_spec("prompt-wire");
    spec.env = vec![(
        "PMACS_FAKE_MCP_PROMPT_RECORD_DIR".into(),
        record_dir.to_string_lossy().into_owned(),
    )];
    let sid = spawn_initialized(&sup, &runtime, &mgr, spec);
    // No-args call.
    let job = mgr
        .borrow_mut()
        .get_prompt(sid, "simple", serde_json::json!({}))
        .expect("get_prompt");
    let _ = await_job(&sup, &runtime, &mgr, job, Duration::from_secs(5));

    // Find the recorded params file.
    let mut entries: Vec<_> = std::fs::read_dir(&record_dir)
        .expect("read record dir")
        .flatten()
        .filter(|e| {
            e.file_name()
                .to_str()
                .is_some_and(|n| n.starts_with("prompt-"))
        })
        .collect();
    entries.sort_by_key(std::fs::DirEntry::file_name);
    assert_eq!(
        entries.len(),
        1,
        "fake should have recorded exactly one prompts/get; got {} files",
        entries.len()
    );
    let bytes = std::fs::read(entries[0].path()).expect("read recorded params");
    let params: serde_json::Value = serde_json::from_slice(&bytes).expect("parse recorded params");
    let arguments = params
        .get("arguments")
        .expect("params.arguments must be present (not omitted)");
    assert!(
        arguments.is_object(),
        "params.arguments must be an object, not null/array/string; got {arguments:?}"
    );
    assert_eq!(
        arguments.as_object().unwrap().len(),
        0,
        "params.arguments must be empty {{}}; got {arguments:?}"
    );
    let _ = mgr.borrow_mut().stop(sid);
}

// ===========================================================================
// Lua surface
// ===========================================================================

/// `pmacs.mcp.get_prompt(server, name, args):await()` resolves to
/// the response's `result` table. Three call patterns (no third
/// arg, nil third arg, explicit empty table) all produce the same
/// wire request and identical result.
#[test]
#[allow(
    clippy::too_many_lines,
    reason = "linear pump-coroutine-then-verify pattern; splitting fragments the test's narrative"
)]
fn m9_4_lua_get_prompt_returns_awaitable_handle() {
    use pmacs::editor::EditorState;

    let mut state = EditorState::new();
    let fake = fake_mcp_path();

    state
        .lua_host
        .lua()
        .load(format!(
            "
            _G._mcp_test_server = pmacs.mcp.spawn({{
                label = 'lua-prompt',
                command = '{fake}',
                restart = 'never',
            }})
            ",
        ))
        .exec()
        .expect("spawn via Lua");

    // Pump until Initialized.
    let stop = Instant::now() + Duration::from_secs(5);
    let mut initialized = false;
    while Instant::now() < stop && !initialized {
        state.tick_processes();
        state.tick_mcp();
        state.tick_async();
        let kinds: Vec<String> = state
            .lua_host
            .lua()
            .load(
                "
                local out = {}
                for _, row in ipairs(pmacs.mcp.list()) do
                    out[#out+1] = row.state.kind
                end
                return out
                ",
            )
            .eval()
            .expect("list");
        if kinds.iter().any(|k| k == "initialized") {
            initialized = true;
        }
        if !initialized {
            std::thread::sleep(Duration::from_millis(15));
        }
    }
    assert!(initialized, "server must reach Initialized");

    // Three call forms, captured into separate globals. All three
    // should produce identical result tables.
    state
        .lua_host
        .lua()
        .load(
            "
            _G._mcp_prompt_done = 0
            _G._mcp_prompt_text_a = nil
            _G._mcp_prompt_text_b = nil
            _G._mcp_prompt_text_c = nil
            pmacs.async(function()
                local r = pmacs.mcp.get_prompt(_G._mcp_test_server, 'simple'):await()
                _G._mcp_prompt_text_a = r.messages[1].content.text
                _G._mcp_prompt_done = _G._mcp_prompt_done + 1
            end)
            pmacs.async(function()
                local r = pmacs.mcp.get_prompt(_G._mcp_test_server, 'simple', nil):await()
                _G._mcp_prompt_text_b = r.messages[1].content.text
                _G._mcp_prompt_done = _G._mcp_prompt_done + 1
            end)
            pmacs.async(function()
                local r = pmacs.mcp.get_prompt(_G._mcp_test_server, 'simple', {}):await()
                _G._mcp_prompt_text_c = r.messages[1].content.text
                _G._mcp_prompt_done = _G._mcp_prompt_done + 1
            end)
            ",
        )
        .exec()
        .expect("dispatch coroutines");

    let stop = Instant::now() + Duration::from_secs(5);
    let mut done = 0i64;
    while Instant::now() < stop && done < 3 {
        state.tick_processes();
        state.tick_mcp();
        state.tick_async();
        done = state
            .lua_host
            .lua()
            .load("return _G._mcp_prompt_done")
            .eval::<i64>()
            .unwrap_or(0);
        if done < 3 {
            std::thread::sleep(Duration::from_millis(15));
        }
    }
    assert_eq!(done, 3, "all three coroutines must complete");

    let texts: Vec<Option<String>> = state
        .lua_host
        .lua()
        .load("return { _G._mcp_prompt_text_a, _G._mcp_prompt_text_b, _G._mcp_prompt_text_c }")
        .eval()
        .expect("read texts");
    assert_eq!(
        texts,
        vec![
            Some("no-args prompt body".to_owned()),
            Some("no-args prompt body".to_owned()),
            Some("no-args prompt body".to_owned())
        ],
        "all three call forms must produce the same result"
    );

    // Failure path: missing required arg must raise a Lua error
    // visible through pcall.
    state
        .lua_host
        .lua()
        .load(
            "
            _G._mcp_prompt_done2 = false
            _G._mcp_prompt_failed2 = nil
            pmacs.async(function()
                local ok, err = pcall(function()
                    return pmacs.mcp.get_prompt(_G._mcp_test_server, 'code_review',
                                                { language = 'rust' }):await()
                end)
                _G._mcp_prompt_done2 = true
                _G._mcp_prompt_failed2 = (not ok) and err or nil
            end)
            ",
        )
        .exec()
        .expect("dispatch failure-path coroutine");

    let stop = Instant::now() + Duration::from_secs(5);
    let mut done2 = false;
    while Instant::now() < stop && !done2 {
        state.tick_processes();
        state.tick_mcp();
        state.tick_async();
        done2 = state
            .lua_host
            .lua()
            .load("return _G._mcp_prompt_done2")
            .eval::<bool>()
            .unwrap_or(false);
        if !done2 {
            std::thread::sleep(Duration::from_millis(15));
        }
    }
    assert!(done2, "failure-path coroutine must complete");

    let message: String = state
        .lua_host
        .lua()
        .load(
            "
            local err = _G._mcp_prompt_failed2
            if err == nil then return '<no error>' end
            if type(err) == 'table' then return tostring(err.message or '<no message>') end
            return tostring(err)
            ",
        )
        .eval()
        .expect("read error message");
    assert!(
        message.contains("missing required argument: source"),
        "failure-path coroutine must observe the server's missing-arg text; got {message:?}"
    );

    let _ = state
        .lua_host
        .lua()
        .load("pmacs.mcp.stop(_G._mcp_test_server)")
        .exec();
}
