//! G3 — CLI↔HTTP↔MCP contract-test harness.
//!
//! Provides fixture tools, a three-tier dispatcher, and envelope-normalisation
//! helpers so the invariant "same tool + same params → same payload through all
//! three surfaces" can be asserted in an integration test without touching
//! filesystem, orchestrator, or provider CLIs.

use async_trait::async_trait;
use provisioning_core::{
    Environment, Registry, ToolError,
    protocol::ToolCategory,
    tool::{Context, Tool},
};
use serde_json::{Value, json};
use std::sync::Arc;

// ── Fixture tools ─────────────────────────────────────────────────────────────

/// Returns the input params verbatim — schema allows any object.
pub struct EchoTool;

#[async_trait]
impl Tool for EchoTool {
    fn name(&self) -> &'static str { "ct_echo" }
    fn description(&self) -> &'static str { "Echo the input parameters verbatim" }
    fn schema(&self) -> Value {
        json!({
            "type": "object",
            "properties": {
                "message": { "type": "string" }
            },
            "required": ["message"],
            "additionalProperties": true
        })
    }
    fn category(&self) -> ToolCategory { ToolCategory::Read }

    async fn invoke(&self, params: Value, _ctx: &Context) -> Result<Value, ToolError> {
        let message = params.get("message").and_then(|v| v.as_str()).ok_or_else(|| {
            ToolError::invalid_param("message", "required string field missing")
        })?;
        Ok(json!({
            "items": [{ "message": message }],
            "total": 1
        }))
    }
}

/// Deterministic three-item listing — same output regardless of params.
pub struct ListingTool;

#[async_trait]
impl Tool for ListingTool {
    fn name(&self) -> &'static str { "ct_listing" }
    fn description(&self) -> &'static str { "Return a canonical three-item listing" }
    fn schema(&self) -> Value {
        json!({ "type": "object", "properties": {}, "additionalProperties": false })
    }
    fn category(&self) -> ToolCategory { ToolCategory::Read }

    async fn invoke(&self, _params: Value, _ctx: &Context) -> Result<Value, ToolError> {
        Ok(json!({
            "items": [
                { "id": "a", "value": 1 },
                { "id": "b", "value": 2 },
                { "id": "c", "value": 3 }
            ],
            "total": 3
        }))
    }
}

/// Always fails with `InvalidParam` — used to validate error-path contract.
pub struct FailingTool;

#[async_trait]
impl Tool for FailingTool {
    fn name(&self) -> &'static str { "ct_fail" }
    fn description(&self) -> &'static str { "Always fails with InvalidParam" }
    fn schema(&self) -> Value {
        json!({ "type": "object", "properties": {}, "additionalProperties": true })
    }
    fn category(&self) -> ToolCategory { ToolCategory::Read }

    async fn invoke(&self, _params: Value, _ctx: &Context) -> Result<Value, ToolError> {
        Err(ToolError::invalid_param("expected", "fixture always rejects"))
    }
}

/// Build a Registry populated with the three fixture tools.
pub fn make_fixture_registry() -> Registry {
    let mut reg = Registry::new();
    reg.register(Arc::new(EchoTool)).expect("register echo");
    reg.register(Arc::new(ListingTool)).expect("register listing");
    reg.register(Arc::new(FailingTool)).expect("register fail");
    reg
}

// ── Output-shape schema (the contract) ────────────────────────────────────────

/// JSON Schema that all `Read`-category list-style tools must satisfy.
/// Generated once at build time so each contract-test case can validate
/// without re-parsing.
pub fn listing_output_schema() -> Value {
    json!({
        "type": "object",
        "required": ["items", "total"],
        "properties": {
            "items": { "type": "array" },
            "total": { "type": "integer", "minimum": 0 }
        },
        "additionalProperties": true
    })
}

// ── Three-tier dispatcher ─────────────────────────────────────────────────────

pub struct TierOutcome {
    /// Inner payload after envelope unwrap. `None` on error.
    pub payload: Option<Value>,
    /// Error code on failure, `None` on success. Each tier maps ToolError
    /// to its own code namespace — the normaliser strips that difference.
    pub error_code: Option<i32>,
    /// Error message (trimmed of tier-specific prefixes).
    pub error_message: Option<String>,
}

impl TierOutcome {
    pub fn ok(payload: Value) -> Self {
        Self { payload: Some(payload), error_code: None, error_message: None }
    }
    pub fn err(code: i32, message: impl Into<String>) -> Self {
        Self { payload: None, error_code: Some(code), error_message: Some(message.into()) }
    }
}

/// Tier A — direct Registry invocation. The reference tier: what the other
/// two surfaces must agree with after envelope unwrapping.
pub async fn tier_registry(
    registry: &Registry,
    env: Arc<Environment>,
    tool: &str,
    params: Value,
) -> TierOutcome {
    let ctx = Context::new(env);
    match registry.invoke(tool, params, &ctx).await {
        Ok(v) => TierOutcome::ok(v),
        Err(e) => {
            let code = match &e {
                ToolError::NotFound(_) => -32001,
                ToolError::InvalidParam { .. } => -32602,
                ToolError::Unauthorized(_) => -32003,
                _ => -32000,
            };
            TierOutcome::err(code, e.to_string())
        }
    }
}

/// Tier B — HTTP daemon. The axum router is reused directly on a TcpListener
/// bound to 127.0.0.1:0 for deterministic port isolation.
pub async fn tier_http(base_url: &str, tool: &str, params: Value) -> TierOutcome {
    let client = reqwest::Client::new();
    let url = format!("{}/api/v1/tools/{}", base_url, tool);
    let body = json!({ "params": params });

    let resp = match client.post(&url).json(&body).send().await {
        Ok(r) => r,
        Err(e) => return TierOutcome::err(-32000, format!("http transport: {e}")),
    };

    let status = resp.status();
    let body: Value = match resp.json().await {
        Ok(v) => v,
        Err(e) => return TierOutcome::err(-32000, format!("http body: {e}")),
    };

    if status.is_success() {
        match body.get("result").cloned() {
            Some(v) => TierOutcome::ok(v),
            None => TierOutcome::err(-32000, "missing result field in http response"),
        }
    } else {
        let code = body
            .get("code")
            .and_then(|v| v.as_i64())
            .map(|c| c as i32)
            .unwrap_or(-32000);
        let msg = body
            .get("error")
            .and_then(|v| v.as_str())
            .unwrap_or("<missing error>")
            .to_string();
        TierOutcome::err(code, msg)
    }
}

/// Tier C — MCP surface via McpServer::handle_request.
/// MCP wraps success payloads in `{content: [{type:"text", text: "<json>"}]}`;
/// the unwrap parses the text field back into JSON.
pub async fn tier_mcp(server: &provisioning_mcp_server::registry_server::McpServer, tool: &str, params: Value) -> TierOutcome {
    let rpc = json!({
        "jsonrpc": "2.0",
        "id": 1,
        "method": "tools/call",
        "params": { "name": tool, "arguments": params }
    });

    let resp = server.handle_request(rpc).await;

    if let Some(err) = resp.get("error") {
        let code = err.get("code").and_then(|v| v.as_i64()).map(|c| c as i32).unwrap_or(-32000);
        let msg  = err.get("message").and_then(|v| v.as_str()).unwrap_or("<missing>").to_string();
        return TierOutcome::err(code, msg);
    }

    let content = match resp.get("result").and_then(|r| r.get("content")) {
        Some(c) => c,
        None => return TierOutcome::err(-32000, "mcp response missing result.content"),
    };

    let text = content
        .get(0)
        .and_then(|c| c.get("text"))
        .and_then(|t| t.as_str());
    let text = match text {
        Some(t) => t,
        None => return TierOutcome::err(-32000, "mcp content[0].text missing"),
    };

    match serde_json::from_str::<Value>(text) {
        Ok(v) => TierOutcome::ok(v),
        Err(e) => TierOutcome::err(-32000, format!("mcp text parse: {e}")),
    }
}

// ── Payload normalisation ─────────────────────────────────────────────────────

/// Strip fields that are allowed to diverge between tiers: trace ids, timestamps,
/// uuids. The contract is on semantic payload, not byte equality.
pub fn normalise(v: &Value) -> Value {
    match v {
        Value::Object(map) => {
            let mut out = serde_json::Map::new();
            for (k, val) in map {
                if matches!(k.as_str(), "trace_id" | "span_id" | "timestamp" | "generated_at" | "request_id")
                {
                    continue;
                }
                out.insert(k.clone(), normalise(val));
            }
            Value::Object(out)
        }
        Value::Array(arr) => Value::Array(arr.iter().map(normalise).collect()),
        _ => v.clone(),
    }
}
