//! `/v1/respond` — the **execution** API (distinct from `/v1/chat`, the
//! completion API).
//!
//! This is the consolidation move called out in the 2026-04-23 session:
//! lift the proven pipeline from `tests/multi-agent/orchestrator.ts`
//! (executor → reviewer → escalate → validate → seal playbook →
//! write-through to KB) into the gateway, so the production path has
//! the intelligence the tests already proved.
//!
//! `/v1/chat` stays a naive completion proxy for callers that want one.
//! `/v1/respond` is where the loop lives. Every orchestrator-style
//! caller migrates here and the TS harnesses become thin clients.
//!
//! This file holds the HTTP surface + request/response shapes. The loop
//! itself lives in `execution_loop::ExecutionLoop`.

use axum::{extract::State, http::StatusCode, Json};
use serde::{Deserialize, Serialize};

use super::V1State;
use crate::execution_loop::{ExecutionLoop, LogEntry, RespondOutcome};

/// A structured task — mirrors `TaskSpec` in `tests/multi-agent/agent.ts`.
/// Kept deliberately open so non-staffing task classes (code-gen,
/// DevOps-long-horizon) can land without a schema fight.
#[derive(Deserialize, Debug, Clone)]
pub struct RespondRequest {
    /// Task class — routes to the right truth rules + validator. For the
    /// staffing substrate: `staffing.fill`, `staffing.rescue`,
    /// `staffing.sms_draft`. Truth-layer lookup is a no-op until a rule
    /// set is registered for the class.
    pub task_class: String,

    /// Human-readable operation description — becomes the playbook
    /// `operation` field on seal, and the primary signal for
    /// playbook_memory embedding.
    pub operation: String,

    /// Free-form structured context. Passed to the executor prompt and
    /// to the playbook seeder. Staffing tasks expect
    /// `{target_role, target_count, target_city, target_state, approach_hint}`
    /// but nothing here validates that — the validator crate will (Phase 43).
    #[serde(default)]
    pub spec: serde_json::Value,

    /// Executor model. Defaults to the hot-path local model if omitted.
    /// See orchestrator.ts:28 (`EXECUTOR_MODEL = "qwen3.5:latest"`).
    #[serde(default)]
    pub executor_model: Option<String>,

    /// Reviewer model. Defaults to the hot-path local reviewer.
    /// See orchestrator.ts:29 (`REVIEWER_MODEL = "qwen3:latest"`).
    #[serde(default)]
    pub reviewer_model: Option<String>,

    /// Hard cap on executor turns. Default matches orchestrator.ts:30
    /// (`MAX_TURNS = 12`). Cloud escalation counts as a turn.
    #[serde(default)]
    pub max_turns: Option<u32>,
}

#[derive(Serialize)]
pub struct RespondResponse {
    /// `ok` = consensus reached, playbook sealed. `failed` = loop ran
    /// out of turns or hit the drift cap. `blocked` = truth-layer
    /// veto (Phase 42 rule citation in `error`).
    pub status: &'static str,

    /// The final artifact — for staffing fills, `{fills: [{candidate_id, name}]}`.
    /// Empty on failure / block.
    pub artifact: serde_json::Value,

    /// Structured cross-turn log. Same shape as orchestrator.ts LogEntry
    /// so existing tooling (kb extractors, fact_extractor.ts) reads it
    /// without change.
    pub log: Vec<LogEntry>,

    /// Iteration count actually used. ≤ max_turns. Stamped on
    /// outcomes.jsonl per the indicator audit (2026-04-23).
    pub iterations: u32,

    /// Error message on non-ok status. Truth-rule citations land here
    /// when `status == "blocked"`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub error: Option<String>,
}

pub async fn respond(
    State(state): State<V1State>,
    Json(req): Json<RespondRequest>,
) -> Result<Json<RespondResponse>, (StatusCode, String)> {
    if req.operation.is_empty() {
        return Err((StatusCode::BAD_REQUEST, "operation must be non-empty".into()));
    }
    if req.task_class.is_empty() {
        return Err((StatusCode::BAD_REQUEST, "task_class must be non-empty".into()));
    }

    let mut loop_runner = ExecutionLoop::new(state, req);
    let outcome = loop_runner.run().await.map_err(|e| {
        (StatusCode::INTERNAL_SERVER_ERROR, format!("execution loop: {e}"))
    })?;

    let (status, error) = match &outcome {
        RespondOutcome::Ok { .. } => ("ok", None),
        RespondOutcome::Failed { reason, .. } => ("failed", Some(reason.clone())),
        RespondOutcome::Blocked { reason, .. } => ("blocked", Some(reason.clone())),
    };

    Ok(Json(RespondResponse {
        status,
        artifact: outcome.artifact(),
        log: outcome.into_log(),
        iterations: loop_runner.turns_used(),
        error,
    }))
}

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

    #[test]
    fn respond_request_parses_minimal() {
        let raw = r#"{
            "task_class": "staffing.fill",
            "operation": "fill: Welder x2 in Toledo, OH"
        }"#;
        let r: RespondRequest = serde_json::from_str(raw).unwrap();
        assert_eq!(r.task_class, "staffing.fill");
        assert_eq!(r.executor_model, None);
        assert_eq!(r.max_turns, None);
    }

    #[test]
    fn respond_request_parses_full() {
        let raw = r#"{
            "task_class": "staffing.fill",
            "operation": "fill: Welder x2 in Toledo, OH",
            "spec": {"target_role": "Welder", "target_count": 2, "target_city": "Toledo", "target_state": "OH"},
            "executor_model": "qwen3.5:latest",
            "reviewer_model": "qwen3:latest",
            "max_turns": 12
        }"#;
        let r: RespondRequest = serde_json::from_str(raw).unwrap();
        assert_eq!(r.executor_model.as_deref(), Some("qwen3.5:latest"));
        assert_eq!(r.max_turns, Some(12));
        assert_eq!(r.spec["target_count"], 2);
    }
}
