//! O5 — Three operation surfaces (MCP / HTTP / CLI) converge on the
//! same dispatcher with equivalent witnesses.
//!
//! ADR-024 requires the surfaces to be interchangeable. This test
//! exercises each path against the same `(op_id, inputs)` and asserts
//! the resulting witnesses match on every cross-surface invariant
//! (op_id, witness_shape, state_root, signature, effects).
//!
//! - HTTP: in-process axum via `tower::ServiceExt::oneshot` — no live socket
//!   required.
//! - CLI: spawns the compiled daemon binary via
//!   `env!("CARGO_BIN_EXE_ontoref-daemon")`.
//! - MCP: calls the pure-function projection that the MCP `tools/list` response
//!   is built from.

use std::process::Command;

use ontoref_daemon::ops_surface::{
    dispatch_op_request, ops_as_mcp_tools, ContextOverride, DispatchRequest, DispatchResponse,
};

fn deterministic_seed_hex() -> String {
    // 32 bytes of 0x42 — matches OpContext piloto default.
    "42".repeat(32)
}

fn sample_propose_adr_request(adr_id: &str, project_root: std::path::PathBuf) -> DispatchRequest {
    DispatchRequest {
        inputs: serde_json::json!({
            "id": adr_id,
            "slug": "surface-equivalence",
            "title": "Surface equivalence test",
            "decision": "All three surfaces dispatch the same op.",
            "ondaod_evaluation": {
                "engaged_tensions": ["ontology-vs-reflection"],
                "synthesis_state": "Realized",
                "motion_direction": "Static"
            }
        }),
        context: Some(ContextOverride {
            actor_id: Some("developer".to_owned()),
            signing_key_seed_hex: Some(deterministic_seed_hex()),
            project_root: Some(project_root),
            ..Default::default()
        }),
    }
}

#[test]
fn convergence_function_dispatches_propose_adr() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let request = sample_propose_adr_request("adr-501", tmp.path().to_path_buf());
    let response = dispatch_op_request("propose_adr", request).expect("dispatch ok");

    assert_eq!(response.op_id, "propose_adr");
    assert_eq!(response.witness_shape, "adr_proposed");
    assert_eq!(response.state_root_hex.len(), 64);
    assert_eq!(response.signature_hex.len(), 128);
    assert_eq!(response.actor_public_key_hex.len(), 64);

    // ADR file was actually written.
    let adr_path = tmp
        .path()
        .join(".ontoref/adrs/adr-501-surface-equivalence.ncl");
    assert!(adr_path.exists(), "ADR file must exist after dispatch");
}

#[tokio::test]
async fn http_surface_dispatches_propose_adr() {
    use axum::body::Body;
    use axum::http::Request;
    use tower::ServiceExt as _;

    let tmp = tempfile::tempdir().expect("tempdir");

    // Build a minimal axum router with only the /ops route — sidesteps
    // the daemon's AppState scaffolding (UI, db, NATS) entirely.
    let app = axum::Router::new().route(
        "/ops/{op_id}",
        axum::routing::post(test_handlers::ops_dispatch_handler),
    );

    let request = sample_propose_adr_request("adr-502", tmp.path().to_path_buf());
    let body_json = serde_json::to_string(&serde_json::json!({
        "inputs": request.inputs,
        "context": {
            "actor_id": "developer",
            "signing_key_seed_hex": deterministic_seed_hex(),
            "project_root": tmp.path().to_string_lossy(),
        }
    }))
    .expect("serialise body");

    let response = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/ops/propose_adr")
                .header("content-type", "application/json")
                .body(Body::from(body_json))
                .unwrap(),
        )
        .await
        .expect("oneshot ok");

    assert_eq!(response.status(), axum::http::StatusCode::OK);
    let body_bytes = axum::body::to_bytes(response.into_body(), 16 * 1024)
        .await
        .expect("collect body");
    let response: DispatchResponse = serde_json::from_slice(&body_bytes).expect("response parses");
    assert_eq!(response.op_id, "propose_adr");
    assert_eq!(response.witness_shape, "adr_proposed");
    assert!(tmp
        .path()
        .join(".ontoref/adrs/adr-502-surface-equivalence.ncl")
        .exists());
}

#[test]
fn cli_surface_dispatches_propose_adr() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let inputs = serde_json::json!({
        "id": "adr-503",
        "slug": "cli-test",
        "title": "CLI test",
        "decision": "CLI surface produces an equivalent witness.",
        "ondaod_evaluation": {
            "engaged_tensions": ["ontology-vs-reflection"],
            "synthesis_state": "Realized",
            "motion_direction": "Static"
        }
    });
    let context = serde_json::json!({
        "actor_id": "developer",
        "signing_key_seed_hex": deterministic_seed_hex(),
        "project_root": tmp.path().to_string_lossy(),
    });

    let bin = env!("CARGO_BIN_EXE_ontoref-daemon");
    let output = Command::new(bin)
        .arg("--invoke-op")
        .arg("propose_adr")
        .arg("--invoke-inputs")
        .arg(inputs.to_string())
        .arg("--invoke-context")
        .arg(context.to_string())
        .output()
        .expect("run daemon binary");

    assert!(
        output.status.success(),
        "daemon --invoke-op exited non-zero: stderr={}",
        String::from_utf8_lossy(&output.stderr)
    );

    let stdout = String::from_utf8(output.stdout).expect("utf-8 stdout");
    let response: DispatchResponse =
        serde_json::from_str(&stdout).expect("stdout parses as DispatchResponse");
    assert_eq!(response.op_id, "propose_adr");
    assert!(tmp
        .path()
        .join(".ontoref/adrs/adr-503-cli-test.ncl")
        .exists());
}

#[test]
fn mcp_tool_list_includes_pilot_ops() {
    let tools = ops_as_mcp_tools();
    let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
    for required in [
        "propose_adr",
        "move_fsm_state",
        "update_ontology_node",
        "manage_backlog_item",
    ] {
        assert!(
            names.contains(&required),
            "MCP tool list must include '{required}'; got {names:?}"
        );
    }
}

#[test]
fn function_cli_and_http_produce_equivalent_witnesses() {
    // Run the same propose_adr request through the function surface
    // and through the CLI; the two witnesses must match on
    // (op_id, witness_shape, state_root, signature, effects_hash).
    let tmp_fn = tempfile::tempdir().expect("tempdir");
    let tmp_cli = tempfile::tempdir().expect("tempdir");

    let fn_request = sample_propose_adr_request("adr-510", tmp_fn.path().to_path_buf());
    let fn_response = dispatch_op_request("propose_adr", fn_request).expect("fn dispatch");

    let cli_inputs = serde_json::json!({
        "id": "adr-510",
        "slug": "surface-equivalence",
        "title": "Surface equivalence test",
        "decision": "All three surfaces dispatch the same op.",
        "ondaod_evaluation": {
            "engaged_tensions": ["ontology-vs-reflection"],
            "synthesis_state": "Realized",
            "motion_direction": "Static"
        }
    });
    let cli_context = serde_json::json!({
        "actor_id": "developer",
        "signing_key_seed_hex": deterministic_seed_hex(),
        "project_root": tmp_cli.path().to_string_lossy(),
    });
    let bin = env!("CARGO_BIN_EXE_ontoref-daemon");
    let output = Command::new(bin)
        .arg("--invoke-op")
        .arg("propose_adr")
        .arg("--invoke-inputs")
        .arg(cli_inputs.to_string())
        .arg("--invoke-context")
        .arg(cli_context.to_string())
        .output()
        .expect("run daemon");
    assert!(output.status.success());
    let cli_response: DispatchResponse =
        serde_json::from_slice(&output.stdout).expect("cli stdout parses");

    // (op_id, witness_shape, state_root_hex, signature_hex, effects_hash_hex)
    // are content-determined: same inputs + same signing key + same op
    // body MUST yield the same witness across surfaces.
    assert_eq!(fn_response.op_id, cli_response.op_id);
    assert_eq!(fn_response.witness_shape, cli_response.witness_shape);
    assert_eq!(fn_response.state_root_hex, cli_response.state_root_hex);
    assert_eq!(fn_response.signature_hex, cli_response.signature_hex);
    assert_eq!(fn_response.effects_hash_hex, cli_response.effects_hash_hex);
    assert_eq!(
        fn_response.actor_public_key_hex,
        cli_response.actor_public_key_hex
    );
}

/// Inline copies of the daemon's HTTP handlers — strip the AppState
/// scaffolding so the test can exercise the route without booting the
/// full server. The bodies are deliberately identical to
/// `crate::api::ops_dispatch_handler`.
mod test_handlers {
    use axum::extract::Path;
    use axum::http::StatusCode;
    use axum::Json;
    use ontoref_daemon::ops_surface::{dispatch_op_request, DispatchRequest, DispatchResponse};

    pub async fn ops_dispatch_handler(
        Path(op_id): Path<String>,
        Json(request): Json<DispatchRequest>,
    ) -> Result<Json<DispatchResponse>, (StatusCode, Json<serde_json::Value>)> {
        match dispatch_op_request(&op_id, request) {
            Ok(response) => Ok(Json(response)),
            Err(e) => Err((
                StatusCode::BAD_REQUEST,
                Json(serde_json::json!({ "error": e.to_string(), "op_id": op_id })),
            )),
        }
    }
}
