use std::time::{Duration, Instant};

use clap::{Args, Subcommand};
use serde::Deserialize;
use serde_json::{Map, Value, json};
use tokio::time::sleep;
use uuid::Uuid;

use crate::util::{
    api_request, client, dry_run_enabled, emit_dry_run_request, exit_error, print_json_stderr,
    print_json_stdout, read_json_from_file,
};

const CLI_ANALYSIS_RUN_OVERALL_TIMEOUT_MS_DEFAULT: u64 = 90_000;
const CLI_ANALYSIS_RUN_OVERALL_TIMEOUT_MS_MIN: u64 = 1_000;
const CLI_ANALYSIS_RUN_OVERALL_TIMEOUT_MS_MAX: u64 = 300_000;
const CLI_ANALYSIS_RUN_POLL_INTERVAL_MS_DEFAULT: u64 = 1_000;
const CLI_ANALYSIS_RUN_POLL_INTERVAL_MS_MIN: u64 = 100;
const CLI_ANALYSIS_RUN_POLL_INTERVAL_MS_MAX: u64 = 5_000;
const ANALYZE_HELP: &str = r#"Examples:
  kura analyze --kernel-json '{"mode":"drivers","outcome":{"source":"health","signal":"pain_score","body_area":"right_knee"},"drivers":[{"source":"activity","entity":"exercise_identity","role":"driver"},{"source":"recovery","signal":"sleep_hours","role":"control"},{"source":"performance_tests","signal":"jump_height","role":"overlay"}],"window":{"days":60},"lag":"1d","result_filter":{"min_observations":3},"decision_context":"training_decision"}'
  kura analyze --kernel-json '{"mode":"trend","outcome":{"source":"performance_tests","signal":"jump_height"},"window":{"days":42},"lens":{"group_by":["week"],"aggregate":"mean","top_n":6},"output":{"mode":"timeline"}}'
  kura analyze --request-file analysis_payload.json

Rules:
  Use analyze first for trend, stagnation, driver, influence, relationship, cross-domain explanation, and training-decision questions.
  Do not prefetch every source with read_query before analyze; analyze loads the relevant evidence pack itself.
  Do not invent ad-hoc formulas when backend analysis methods can answer the question; ask for method-guided analysis instead.
  Analyze is kernel-only. Send --kernel-json or a request-file that contains a kernel payload.
  Use read_query first only for exact records, lists, timelines, or when analyze asks for a supporting read.
"#;

pub fn public_analyze_payload_contract() -> Value {
    json!({
        "schema_version": "public_analyze_payload_contract.v1",
        "entrypoint": "kura analyze",
        "truth_namespace": "bounded_deep_analysis",
        "principles": [
            "Use analyze as the first tool for trend, stagnation, driver, influence, relationship, cross-domain explanation, and training-decision questions.",
            "Do not prefetch every source with read_query before analyze; analyze loads the relevant evidence pack itself.",
            "Analyze is kernel-only. Express the request through the compact analyze v2 kernel instead of legacy objective/focus fields.",
            "Keep kernel requests compact and explicit: mode, outcome, optional drivers, window or compare, and only the extra controls that materially matter.",
            "Use drivers.role=control for context you want loaded and reported without ranking it as a driver. Use drivers.role=overlay for supporting backdrop."
        ],
        "examples": [
            {
                "label": "Analyze v2 kernel request",
                "payload": {
                    "kernel": {
                        "mode": "drivers",
                        "outcome": {
                            "source": "health",
                            "signal": "pain_score",
                            "body_area": "right_knee"
                        },
                        "drivers": [
                            {
                                "source": "activity",
                                "entity": "exercise_identity",
                                "role": "driver"
                            },
                            {
                                "source": "recovery",
                                "signal": "sleep_hours",
                                "role": "control"
                            },
                            {
                                "source": "performance_tests",
                                "signal": "jump_height",
                                "role": "overlay"
                            }
                        ],
                        "window": {
                            "days": 60
                        },
                        "lag": "1d",
                        "result_filter": {
                            "min_observations": 3
                        },
                        "decision_context": "training_decision"
                    },
                    "wait_timeout_ms": 15000
                }
            },
            {
                "label": "Timeline kernel with grouping controls",
                "payload": {
                    "kernel": {
                        "mode": "trend",
                        "outcome": {
                            "source": "performance_tests",
                            "signal": "jump_height"
                        },
                        "window": {
                            "days": 42
                        },
                        "lens": {
                            "group_by": ["week"],
                            "aggregate": "mean",
                            "sort": {"by": "label", "direction": "asc"},
                            "top_n": 6
                        },
                        "output": {
                            "mode": "timeline"
                        }
                    },
                    "wait_timeout_ms": 15000
                }
            }
        ]
    })
}

#[derive(Subcommand)]
pub enum AnalysisCommands {
    /// Queue and wait (bounded) for deep analysis via /v1/analysis/jobs/run
    Run {
        /// Full JSON request payload (use '-' for stdin)
        #[arg(long, conflicts_with = "kernel_json")]
        request_file: Option<String>,
        /// Analyze v2 kernel JSON object.
        #[arg(long = "kernel-json", alias = "kernel")]
        kernel_json: Option<String>,
        /// Override server-side initial wait timeout in milliseconds (server clamps to safe bounds)
        #[arg(long)]
        wait_timeout_ms: Option<u64>,
        /// Total CLI wait budget including timeout fallback polling
        #[arg(long)]
        overall_timeout_ms: Option<u64>,
        /// Poll interval used after server-side timeout fallback kicks in
        #[arg(long)]
        poll_interval_ms: Option<u64>,
    },
    /// Queue a new async analysis job via /v1/analysis/jobs
    #[command(hide = true)]
    Create {
        /// Full JSON request payload (use '-' for stdin)
        #[arg(long)]
        request_file: String,
    },
    /// Fetch analysis job status by id
    #[command(hide = true)]
    Status {
        /// Analysis job UUID
        #[arg(long)]
        job_id: Uuid,
        /// Optional result section: compact, kernel_execution, highlights, evidence_refs, or planning
        #[arg(long)]
        section: Option<String>,
    },
    /// Validate a draft answer against the authoritative analysis admissibility contract
    #[command(hide = true)]
    ValidateAnswer(ValidateAnswerArgs),
}

#[derive(Args, Clone)]
#[command(after_help = ANALYZE_HELP)]
pub struct AnalyzeArgs {
    /// Full JSON request payload (use '-' for stdin)
    #[arg(long, conflicts_with = "kernel_json")]
    pub request_file: Option<String>,
    /// Analyze v2 kernel JSON object.
    #[arg(long = "kernel-json", alias = "kernel")]
    pub kernel_json: Option<String>,
    /// Override server-side initial wait timeout in milliseconds (server clamps to safe bounds)
    #[arg(long)]
    pub wait_timeout_ms: Option<u64>,
    /// Total CLI wait budget including timeout fallback polling
    #[arg(long)]
    pub overall_timeout_ms: Option<u64>,
    /// Poll interval used after server-side timeout fallback kicks in
    #[arg(long)]
    pub poll_interval_ms: Option<u64>,
}

#[derive(Args)]
pub struct ValidateAnswerArgs {
    /// Current user request, preferably passed verbatim
    #[arg(long)]
    task_intent: String,
    /// Draft user-facing answer to validate
    #[arg(long)]
    draft_answer: String,
}

pub async fn run(api_url: &str, token: Option<&str>, command: AnalysisCommands) -> i32 {
    match command {
        AnalysisCommands::Run {
            request_file,
            kernel_json,
            wait_timeout_ms,
            overall_timeout_ms,
            poll_interval_ms,
        } => {
            run_blocking(
                api_url,
                token,
                request_file.as_deref(),
                kernel_json,
                wait_timeout_ms,
                overall_timeout_ms,
                poll_interval_ms,
            )
            .await
        }
        AnalysisCommands::Create { request_file } => create(api_url, token, &request_file).await,
        AnalysisCommands::Status { job_id, section } => {
            status(api_url, token, job_id, section.as_deref()).await
        }
        AnalysisCommands::ValidateAnswer(args) => {
            validate_answer(api_url, token, args.task_intent, args.draft_answer).await
        }
    }
}

pub async fn analyze(api_url: &str, token: Option<&str>, args: AnalyzeArgs) -> i32 {
    run_blocking(
        api_url,
        token,
        args.request_file.as_deref(),
        args.kernel_json,
        args.wait_timeout_ms,
        args.overall_timeout_ms,
        args.poll_interval_ms,
    )
    .await
}

async fn run_blocking(
    api_url: &str,
    token: Option<&str>,
    request_file: Option<&str>,
    kernel_json: Option<String>,
    wait_timeout_ms: Option<u64>,
    overall_timeout_ms: Option<u64>,
    poll_interval_ms: Option<u64>,
) -> i32 {
    let base_body = build_run_request_body(
        request_file,
        kernel_json,
    )
    .unwrap_or_else(|e| {
        exit_error(
            &e,
            Some(
                "Use `kura analyze --kernel-json '{...}'` or `--request-file payload.json` with a kernel payload.",
            ),
        )
    });
    let body = apply_wait_timeout_override(base_body, wait_timeout_ms).unwrap_or_else(|e| {
        exit_error(
            &e,
            Some("Analysis run request must be a JSON object with a kernel payload."),
        )
    });

    let overall_timeout_ms = clamp_cli_overall_timeout_ms(overall_timeout_ms);
    let poll_interval_ms = clamp_cli_poll_interval_ms(poll_interval_ms);

    if dry_run_enabled() {
        return emit_dry_run_request(
            &reqwest::Method::POST,
            api_url,
            "/v1/analysis/jobs/run",
            token.is_some(),
            Some(&body),
            &[],
            &[],
            false,
            Some(
                "Dry-run skips server execution and timeout polling. Use `kura analysis status --job-id <id>` on a real run.",
            ),
        );
    }

    let started = Instant::now();

    let (status, run_body) = request_json(
        api_url,
        reqwest::Method::POST,
        "/v1/analysis/jobs/run",
        token,
        Some(body),
    )
    .await
    .unwrap_or_else(|e| exit_error(&e, Some("Check API availability/auth and retry.")));

    if !is_success_status(status) {
        if is_run_endpoint_unsupported_status(status) {
            let blocked = build_run_endpoint_contract_block(status, run_body);
            return print_json_response(status, &blocked);
        }
        return print_json_response(status, &run_body);
    }

    let Some(run_response) = parse_cli_run_response(&run_body) else {
        return print_json_response(status, &run_body);
    };

    if !run_response_needs_cli_poll_fallback(&run_response) {
        return print_json_response(status, &run_body);
    }

    let mut latest_job = run_body.get("job").cloned().unwrap_or(Value::Null);
    let mut polls = 0u32;
    let mut last_retry_after_ms =
        clamp_cli_poll_interval_ms(run_response.retry_after_ms.or(Some(poll_interval_ms)));
    let job_id = run_response.job.job_id;
    let path = format!("/v1/analysis/jobs/{job_id}");

    loop {
        let elapsed_total_ms = elapsed_ms(started);
        if elapsed_total_ms >= overall_timeout_ms {
            let timeout_output = build_cli_poll_fallback_output(
                latest_job,
                elapsed_total_ms,
                false,
                true,
                Some(last_retry_after_ms),
                polls,
                overall_timeout_ms,
                run_response.mode.as_deref(),
                "server_run_timeout_poll",
            );
            return print_json_response(200, &timeout_output);
        }

        let remaining_ms = overall_timeout_ms.saturating_sub(elapsed_total_ms);
        let sleep_ms = min_u64(last_retry_after_ms, remaining_ms);
        sleep(Duration::from_millis(sleep_ms)).await;

        let (poll_status, poll_body) =
            request_json(api_url, reqwest::Method::GET, &path, token, None)
                .await
                .unwrap_or_else(|e| {
                    exit_error(
                        &e,
                        Some(
                            "Blocking analysis fallback polling failed. Retry `kura analysis status --job-id <id>` in the same session if needed.",
                        ),
                    )
                });

        if !is_success_status(poll_status) {
            return print_json_response(poll_status, &poll_body);
        }

        polls = polls.saturating_add(1);
        latest_job = poll_body.clone();

        if let Some(job_status) = parse_cli_job_status(&poll_body) {
            if analysis_job_status_is_terminal(&job_status.status) {
                let final_output = build_cli_poll_fallback_output(
                    poll_body,
                    elapsed_ms(started),
                    true,
                    false,
                    None,
                    polls,
                    overall_timeout_ms,
                    run_response.mode.as_deref(),
                    "server_run_timeout_poll",
                );
                return print_json_response(200, &final_output);
            }
        } else {
            // If shape is unexpected, surface the payload instead of masking it.
            return print_json_response(200, &poll_body);
        }

        last_retry_after_ms = poll_interval_ms;
    }
}

async fn create(api_url: &str, token: Option<&str>, request_file: &str) -> i32 {
    let body = match read_json_from_file(request_file) {
        Ok(v) => v,
        Err(e) => {
            crate::util::exit_error(&e, Some("Provide a valid JSON analysis request payload."))
        }
    };

    if dry_run_enabled() {
        return emit_dry_run_request(
            &reqwest::Method::POST,
            api_url,
            "/v1/analysis/jobs",
            token.is_some(),
            Some(&body),
            &[],
            &[],
            false,
            None,
        );
    }

    api_request(
        api_url,
        reqwest::Method::POST,
        "/v1/analysis/jobs",
        token,
        Some(body),
        &[],
        &[],
        false,
        false,
    )
    .await
}

async fn status(api_url: &str, token: Option<&str>, job_id: Uuid, section: Option<&str>) -> i32 {
    let path = format!("/v1/analysis/jobs/{job_id}");
    let query = section
        .map(|value| vec![("section".to_string(), value.to_string())])
        .unwrap_or_default();
    api_request(
        api_url,
        reqwest::Method::GET,
        &path,
        token,
        None,
        &query,
        &[],
        false,
        false,
    )
    .await
}

async fn validate_answer(
    api_url: &str,
    token: Option<&str>,
    task_intent: String,
    draft_answer: String,
) -> i32 {
    let body = build_validate_answer_body(task_intent, draft_answer);
    api_request(
        api_url,
        reqwest::Method::POST,
        "/v1/agent/answer-admissibility",
        token,
        Some(body),
        &[],
        &[],
        false,
        false,
    )
    .await
}

fn build_run_request_body(
    request_file: Option<&str>,
    kernel_json: Option<String>,
) -> Result<Value, String> {
    if let Some(path) = request_file {
        if kernel_json.is_some() {
            return Err("`--request-file` cannot be combined with --kernel-json".to_string());
        }
        return normalize_analysis_request_file_payload(read_json_from_file(path)?);
    }

    if let Some(kernel) = parse_optional_json_object_arg(kernel_json.as_deref(), "--kernel-json")? {
        return Ok(json!({ "kernel": Value::Object(kernel) }));
    }

    Err("Missing analysis kernel. Provide `--kernel-json '{...}'` or `--request-file` with a kernel payload.".to_string())
}

fn normalize_analysis_request_file_payload(value: Value) -> Result<Value, String> {
    if value.get("kernel").is_some() {
        return Ok(value);
    }
    for pointer in [
        "/analyze_payload",
        "/analysis_handoff/analyze_payload",
        "/analysis_handoff/next_request/payload",
        "/next_request/payload",
    ] {
        if let Some(candidate) = value.pointer(pointer) {
            if candidate.get("kernel").is_some() {
                return Ok(candidate.clone());
            }
        }
    }
    Err("Analysis request file must be an analyze payload with kernel, or a read_query analysis_handoff containing analyze_payload/next_request.payload.".to_string())
}

fn build_validate_answer_body(task_intent: String, draft_answer: String) -> Value {
    json!({
        "task_intent": task_intent,
        "draft_answer": draft_answer,
    })
}

fn parse_optional_json_object_arg(
    raw: Option<&str>,
    flag_name: &str,
) -> Result<Option<Map<String, Value>>, String> {
    let Some(raw) = raw.map(str::trim).filter(|value| !value.is_empty()) else {
        return Ok(None);
    };
    let parsed: Value = serde_json::from_str(raw)
        .map_err(|error| format!("Could not parse {flag_name} as JSON: {error}"))?;
    let object = parsed
        .as_object()
        .cloned()
        .ok_or_else(|| format!("{flag_name} must be a JSON object"))?;
    Ok(Some(object))
}

fn apply_wait_timeout_override(
    mut body: Value,
    wait_timeout_ms: Option<u64>,
) -> Result<Value, String> {
    if let Some(timeout_ms) = wait_timeout_ms {
        let obj = body
            .as_object_mut()
            .ok_or_else(|| "analysis request body must be a JSON object".to_string())?;
        obj.insert("wait_timeout_ms".to_string(), json!(timeout_ms));
    }
    Ok(body)
}

#[derive(Debug, Deserialize)]
struct CliRunAnalysisResponse {
    #[allow(dead_code)]
    mode: Option<String>,
    terminal: bool,
    timed_out: bool,
    #[serde(default)]
    retry_after_ms: Option<u64>,
    job: CliJobStatusRef,
}

#[derive(Debug, Deserialize)]
struct CliJobStatusRef {
    job_id: Uuid,
    status: String,
}

#[derive(Debug, Deserialize)]
struct CliJobStatusEnvelope {
    status: String,
}

fn parse_cli_run_response(value: &Value) -> Option<CliRunAnalysisResponse> {
    serde_json::from_value(value.clone()).ok()
}

fn parse_cli_job_status(value: &Value) -> Option<CliJobStatusEnvelope> {
    serde_json::from_value(value.clone()).ok()
}

fn run_response_needs_cli_poll_fallback(response: &CliRunAnalysisResponse) -> bool {
    response.timed_out
        && !response.terminal
        && !analysis_job_status_is_terminal(&response.job.status)
}

fn analysis_job_status_is_terminal(status: &str) -> bool {
    matches!(status, "completed" | "failed")
}

fn is_run_endpoint_unsupported_status(status: u16) -> bool {
    matches!(status, 404 | 405)
}

fn clamp_cli_overall_timeout_ms(timeout_ms: Option<u64>) -> u64 {
    timeout_ms
        .unwrap_or(CLI_ANALYSIS_RUN_OVERALL_TIMEOUT_MS_DEFAULT)
        .clamp(
            CLI_ANALYSIS_RUN_OVERALL_TIMEOUT_MS_MIN,
            CLI_ANALYSIS_RUN_OVERALL_TIMEOUT_MS_MAX,
        )
}

fn clamp_cli_poll_interval_ms(timeout_ms: Option<u64>) -> u64 {
    timeout_ms
        .unwrap_or(CLI_ANALYSIS_RUN_POLL_INTERVAL_MS_DEFAULT)
        .clamp(
            CLI_ANALYSIS_RUN_POLL_INTERVAL_MS_MIN,
            CLI_ANALYSIS_RUN_POLL_INTERVAL_MS_MAX,
        )
}

fn elapsed_ms(started: Instant) -> u64 {
    let ms = started.elapsed().as_millis();
    if ms > u128::from(u64::MAX) {
        u64::MAX
    } else {
        ms as u64
    }
}

fn min_u64(a: u64, b: u64) -> u64 {
    if a < b { a } else { b }
}

fn build_cli_poll_fallback_output(
    job: Value,
    waited_ms: u64,
    terminal: bool,
    timed_out: bool,
    retry_after_ms: Option<u64>,
    polls: u32,
    overall_timeout_ms: u64,
    initial_mode: Option<&str>,
    fallback_kind: &str,
) -> Value {
    let mut out = json!({
        "mode": if terminal {
            format!("blocking_cli_poll_fallback_completed:{fallback_kind}")
        } else {
            format!("blocking_cli_poll_fallback_timeout:{fallback_kind}")
        },
        "terminal": terminal,
        "timed_out": timed_out,
        "waited_ms": waited_ms,
        "retry_after_ms": retry_after_ms,
        "job": job,
        "cli_fallback": {
            "used": true,
            "kind": fallback_kind,
            "polls": polls,
            "overall_timeout_ms": overall_timeout_ms,
            "initial_mode": initial_mode,
        }
    });
    if retry_after_ms.is_none() {
        if let Some(obj) = out.as_object_mut() {
            obj.insert("retry_after_ms".to_string(), Value::Null);
        }
    }
    out
}

fn build_run_endpoint_contract_block(status: u16, details: Value) -> Value {
    json!({
        "error": "agent_mode_blocked",
        "reason_code": "analysis_run_contract_missing",
        "message": "Public `kura analyze` is blocked because the server did not honor the `/v1/analysis/jobs/run` contract.",
        "blocked_command": "kura analyze",
        "required_endpoint": "/v1/analysis/jobs/run",
        "legacy_fallback_disabled": true,
        "next_action": "Update the CLI/server pair until `/v1/analysis/jobs/run` is supported, then retry `kura analyze`.",
        "status": status,
        "details": details,
    })
}

async fn request_json(
    api_url: &str,
    method: reqwest::Method,
    path: &str,
    token: Option<&str>,
    body: Option<Value>,
) -> Result<(u16, Value), String> {
    let url = reqwest::Url::parse(&format!("{api_url}{path}"))
        .map_err(|e| format!("Invalid URL: {api_url}{path}: {e}"))?;

    let mut req = client().request(method, url);
    if let Some(t) = token {
        req = req.header("Authorization", format!("Bearer {t}"));
    }
    if let Some(b) = body {
        req = req.json(&b);
    }

    let resp = req.send().await.map_err(|e| format!("{e}"))?;
    let status = resp.status().as_u16();
    let body: Value = match resp.bytes().await {
        Ok(bytes) => {
            if bytes.is_empty() {
                Value::Null
            } else {
                serde_json::from_slice(&bytes)
                    .unwrap_or_else(|_| Value::String(String::from_utf8_lossy(&bytes).to_string()))
            }
        }
        Err(e) => json!({"raw_error": format!("Failed to read response body: {e}")}),
    };
    Ok((status, body))
}

fn is_success_status(status: u16) -> bool {
    (200..=299).contains(&status)
}

fn http_status_exit_code(status: u16) -> i32 {
    match status {
        200..=299 => 0,
        400..=499 => 1,
        _ => 2,
    }
}

fn print_json_response(status: u16, body: &Value) -> i32 {
    let exit_code = http_status_exit_code(status);
    if exit_code == 0 {
        print_json_stdout(body);
    } else {
        print_json_stderr(body);
    }
    exit_code
}

#[cfg(test)]
mod tests {
    use super::{
        analysis_job_status_is_terminal, apply_wait_timeout_override,
        build_run_endpoint_contract_block, build_run_request_body, build_validate_answer_body,
        clamp_cli_overall_timeout_ms, clamp_cli_poll_interval_ms,
        is_run_endpoint_unsupported_status, parse_cli_job_status,
        run_response_needs_cli_poll_fallback,
    };
    use serde_json::json;

    #[test]
    fn apply_wait_timeout_override_merges_field_into_request_object() {
        let body = json!({
            "kernel": {
                "mode": "trend",
                "outcome": {"source": "recovery", "signal": "readiness"},
                "window": {"days": 90}
            }
        });
        let patched = apply_wait_timeout_override(body, Some(2500)).unwrap();
        assert_eq!(patched["wait_timeout_ms"], json!(2500));
        assert_eq!(patched["kernel"]["mode"], json!("trend"));
    }

    #[test]
    fn apply_wait_timeout_override_rejects_non_object_when_override_present() {
        let err = apply_wait_timeout_override(json!(["bad"]), Some(1000)).unwrap_err();
        assert!(err.contains("JSON object"));
    }

    #[test]
    fn build_run_request_body_accepts_kernel_json() {
        let body = build_run_request_body(
            None,
            Some(
                r#"{"mode":"trend","outcome":{"source":"performance_tests","signal":"jump_height"},"window":{"days":90},"filters":{"performance_tests":{"test_types":["cmj"]}}}"#
                    .to_string(),
            ),
        )
        .unwrap();
        assert_eq!(body["kernel"]["mode"], json!("trend"));
        assert_eq!(
            body["kernel"]["filters"]["performance_tests"]["test_types"],
            json!(["cmj"])
        );
    }

    #[test]
    fn build_run_request_body_rejects_missing_input() {
        let err = build_run_request_body(None, None).unwrap_err();
        assert!(err.contains("Missing analysis kernel"));
    }

    #[test]
    fn build_run_request_body_accepts_kernel_json_without_objective() {
        let body = build_run_request_body(
            None,
            Some(
                r#"{"mode":"drivers","outcome":{"source":"health","signal":"pain_score"},"window":{"days":60}}"#
                    .to_string(),
            ),
        )
        .unwrap();

        assert_eq!(body["kernel"]["mode"], json!("drivers"));
        assert_eq!(body["kernel"]["outcome"]["source"], json!("health"));
    }

    #[test]
    fn build_run_request_body_rejects_request_file_with_kernel_flag() {
        let err = build_run_request_body(
            Some("payload.json"),
            Some(
                r#"{"mode":"trend","outcome":{"source":"performance_tests","signal":"cmj"},"window":{"days":60}}"#
                    .to_string(),
            ),
        )
        .unwrap_err();

        assert!(err.contains("--request-file"));
    }

    #[test]
    fn request_file_payload_can_be_read_query_analysis_handoff() {
        let normalized = super::normalize_analysis_request_file_payload(json!({
            "analysis_handoff": {
                "next_request": {
                    "payload": {
                        "kernel": {
                            "mode": "trend",
                            "outcome": {"source": "performance_tests", "signal": "jump_height"},
                            "window": {"days": 60}
                        }
                    }
                }
            }
        }))
        .unwrap();

        assert_eq!(normalized["kernel"]["mode"], json!("trend"));
    }

    #[test]
    fn request_file_payload_can_be_kernel_only() {
        let normalized = super::normalize_analysis_request_file_payload(json!({
            "kernel": {
                "mode": "compare",
                "outcome": {
                    "source": "performance_tests",
                    "signal": "jump_height"
                },
                "compare": {
                    "a": {"days": 7},
                    "b": {"days": 14}
                }
            }
        }))
        .unwrap();

        assert_eq!(normalized["kernel"]["mode"], json!("compare"));
    }

    #[test]
    fn build_validate_answer_body_serializes_expected_shape() {
        let body = build_validate_answer_body(
            "How has my squat progressed?".to_string(),
            "Your squat is clearly up 15%.".to_string(),
        );
        assert_eq!(body["task_intent"], json!("How has my squat progressed?"));
        assert_eq!(body["draft_answer"], json!("Your squat is clearly up 15%."));
    }

    #[test]
    fn clamp_cli_timeouts_apply_bounds() {
        assert_eq!(clamp_cli_overall_timeout_ms(None), 90_000);
        assert_eq!(clamp_cli_overall_timeout_ms(Some(1)), 1_000);
        assert_eq!(clamp_cli_overall_timeout_ms(Some(999_999)), 300_000);
        assert_eq!(clamp_cli_poll_interval_ms(None), 1_000);
        assert_eq!(clamp_cli_poll_interval_ms(Some(1)), 100);
        assert_eq!(clamp_cli_poll_interval_ms(Some(50_000)), 5_000);
    }

    #[test]
    fn analysis_terminal_status_matches_api_contract() {
        assert!(analysis_job_status_is_terminal("completed"));
        assert!(analysis_job_status_is_terminal("failed"));
        assert!(!analysis_job_status_is_terminal("queued"));
        assert!(!analysis_job_status_is_terminal("processing"));
    }

    #[test]
    fn run_response_fallback_detection_requires_timeout_and_non_terminal_job() {
        let timed_out = serde_json::from_value(json!({
            "terminal": false,
            "timed_out": true,
            "retry_after_ms": 500,
            "job": { "job_id": "00000000-0000-0000-0000-000000000000", "status": "queued" }
        }))
        .unwrap();
        assert!(run_response_needs_cli_poll_fallback(&timed_out));

        let completed = serde_json::from_value(json!({
            "terminal": true,
            "timed_out": false,
            "job": { "job_id": "00000000-0000-0000-0000-000000000000", "status": "completed" }
        }))
        .unwrap();
        assert!(!run_response_needs_cli_poll_fallback(&completed));
    }

    #[test]
    fn parse_cli_job_status_reads_status_field() {
        let parsed = parse_cli_job_status(&json!({
            "job_id": "00000000-0000-0000-0000-000000000000",
            "status": "queued"
        }))
        .unwrap();
        assert_eq!(parsed.status, "queued");
    }

    #[test]
    fn run_endpoint_unsupported_status_detection_matches_hard_block_cases() {
        assert!(is_run_endpoint_unsupported_status(404));
        assert!(is_run_endpoint_unsupported_status(405));
        assert!(!is_run_endpoint_unsupported_status(400));
        assert!(!is_run_endpoint_unsupported_status(401));
        assert!(!is_run_endpoint_unsupported_status(500));
    }

    #[test]
    fn run_endpoint_contract_block_disables_legacy_async_escape() {
        let output = build_run_endpoint_contract_block(
            404,
            json!({
                "error": "not_found",
            }),
        );

        assert_eq!(output["error"], json!("agent_mode_blocked"));
        assert_eq!(
            output["reason_code"],
            json!("analysis_run_contract_missing")
        );
        assert_eq!(output["required_endpoint"], json!("/v1/analysis/jobs/run"));
        assert_eq!(output["legacy_fallback_disabled"], json!(true));
        assert_eq!(output["status"], json!(404));
    }
}
