//! `kura analyze` — the public typed analyze surface
//! (spec `docs/superpowers/specs/2026-04-17-analyze-rebuild-design.md`).
//!
//! This command POSTs one typed envelope to `/v4/agent/analyze/run`.
//! The server resolves semantic fields and runs the analysis in one path.
//!
//!   - `answer`         — terminal result
//!   - `needs_clarification` — terminal machine retry choices or ask-user boundary
//!   - `schema_error`   — terminal invalid envelope
//!   - `unsupported`    — terminal unsupported semantic request
//!   - `timeout`        — terminal server wait budget exhaustion
//!
//! The module is intentionally thin: it validates nothing locally beyond
//! applying the two client-side defaults documented in spec §4.3 (scope_level
//! from entity, budget_class=standard) and prints the server envelope.
//! Discovery of the grammar vocabulary is `GET /v4/agent/analyze/grammar`
//! (spec §5) — the CLI does not ship a hand-authored archetype catalog.

use clap::Args;
use serde_json::{Map, Value, json};

use crate::analyze_flow_state::record_analyze_response;
use crate::util::{
    CliOutputMode, RawApiResponse, cli_runtime_options, dry_run_enabled, emit_dry_run_request,
    exit_error, print_json_stderr, print_json_stdout, raw_api_request_json, read_json_from_file,
};
use kura_core::tool_capabilities::public_tool_capabilities;

pub const ANALYZE_ENDPOINT: &str = "/v4/agent/analyze/run";
pub const ANALYZE_GRAMMAR_ENDPOINT: &str = "/v4/agent/analyze/grammar";
const ANALYZE_WAIT_TIMEOUT_MS_MAX: u64 = 60_000;
const ANALYZE_AGENT_DEFAULT_WAIT_TIMEOUT_MS: u64 = ANALYZE_WAIT_TIMEOUT_MS_MAX;

pub const ANALYZE_HELP: &str = r#"Discovery:
  The server owns request resolution, method selection, evidence loading, and answer shape. Start with one typed analyze envelope; do not prefetch read surfaces.
  Grammar discovery, when genuinely needed: GET /v4/agent/analyze/grammar

Standard path:
  kura analyze --request-json '{"mode":"analysis_spec","analysis_spec":{"schema_version":"analysis_spec.v1","question_type":"explanation","variables":[{"variable_id":"jump_height","role":"outcome","signal":"jump_height","source":"performance_tests","scope":{"protocol_variant":"free_arms"},"time_alignment":{"anchor":"session_date"}},{"variable_id":"power_activity","role":"driver","signal":"presence","source":"activity","scope":{"group":{"metadata_field":"capability_targets","value":"power"}},"time_alignment":{"lookback_days":14}}]}}'
  kura analyze --request-json '{"mode":"analysis_spec","analysis_spec":{"schema_version":"analysis_spec.v1","question_type":"trend","variables":[{"variable_id":"sleep_quality","role":"outcome","signal":"sleep_quality","source":"recovery","scope":{},"time_alignment":{"strategy":"same_date"}}]}}'
  kura analyze --request-file request.json [--wait-timeout-ms 60000]

Envelope grammar:
  schema_version    = analyze_envelope.v1 (added when omitted)
  mode              = analysis_spec | resolve_and_run | run
  analysis_spec     = {schema_version:analysis_spec.v1, question_type, variables:[{variable_id, role, signal, source, scope, time_alignment, transform?, window?, candidate_handle?}], comparison?, comparison_axis?, time_scope_mode?, method_hint?, response_shape?, time_window?, result_page?, budget_class?}
  semantic_request  = {question_type|intent_kind, outcome?:{signal}, subjects?:[{entity_id|canonical_label}], drivers?:[{signal}], candidate_space?, result_page?, comparison_axis?, time_scope_mode?, time_window?, budget_class?}
  canonical_request = {question_type, scope_level, outcome:{signal,source?}, entity?, explanatory_space?, candidate_space?, result_page?, response_shape?, method_hint?, comparison_axis?, time_scope_mode?, budget_class?}
  audit_context     = {raw_user_text?, source_language?, agent_notes?}; raw user text belongs here only
  time_scope_mode   = last_n_days | date_range | all_time | a_vs_b
  time_alignment    = use explicit strategies; auto_reconcile:"coarsest" is opt-in only and reports no-lookahead pair-count diagnostics

Response envelope:
  type=answer               terminal
  type=needs_clarification  terminal with allowed_next_actions
  type=schema_error         terminal invalid envelope
  type=unsupported          terminal unsupported semantic request
  type=timeout              terminal server wait budget exhausted
  method_hint_resolution appears when a requested method_hint falls back, e.g. fell_back_to_X due to Y.
  Broad ranked answers use driver_results.top + driver_results.items and continue with driver_results.continuation.next_request.

Rules:
  Normal new analysis requests should use mode=analysis_spec with role-scoped variables. Legacy semantic_request/canonical_request paths are compatibility-only.
  Use analyze first for trend, stagnation, driver, influence, relationship, cross-domain explanation, comparison, or training-decision questions.
  Translate user language into canonical server-language fields before calling analyze. If the exact signal, source, or scope name is unknown, use read_query source_id=analyze_catalog subject=vocabulary_search as bounded vocabulary discovery, then build analysis_spec from returned analyze_fill/candidate handles.
  Do not call analyze_catalog first as a routine preflight; use it only for bounded vocabulary discovery when exact server-language fields are unknown.
  Do not call read_query, read_query --help, performance_tests, or activity_session_history to prefetch raw sources. Do not use read_query, analyze_catalog, or vocabulary_search as routine preflight.
  Timeout is terminal. Do not sleep, resume, poll, or use read_query/analyze_catalog to continue the same analysis.
  On answer, if agent_usage.answer_complete=true, stop tool use for that analysis.
  Exit codes: 0 on 2xx, 1 on 4xx, 2 on 5xx.
"#;

/// Public payload contract for `kura analyze` (typed envelope surface).
///
/// Consumed by `kura schema analyze` and the public shell contract tests.
pub fn public_analyze_payload_contract() -> Value {
    json!({
        "schema_version": "public_analyze_payload_contract.v4",
        "entrypoint": "kura analyze",
        "backend_endpoint": ANALYZE_ENDPOINT,
        "grammar_endpoint": ANALYZE_GRAMMAR_ENDPOINT,
        "tool_capabilities": public_tool_capabilities("analyze"),
        "truth_namespace": "bounded_analyze",
        "surface": "typed_analyze_envelope_surface",
        "principles": [
            "Send exactly one typed analyze envelope per analysis question. Normal new requests use analysis_spec.v1 with role-scoped variables; semantic_request and canonical_request are compatibility-only.",
            "For analysis intents, the first data tool should be analyze unless the exact server-language signal/source/scope is unknown. In that case use analyze_catalog vocabulary_search as bounded vocabulary discovery, not as raw data prefetch.",
            "Agents must translate user language into observed server-language candidates and then build analysis_spec variables from analyze_fill/candidate handles. Raw user text is trace context only and belongs in audit_context.raw_user_text.",
            "Use time_alignment.auto_reconcile=\"coarsest\" only when the agent explicitly wants no-lookahead coarser anchoring for sparse exploratory driver scans; it is not a silent default and temporal_direction refuses it.",
            "Do not pre-pick a method. Fetch the grammar from GET /v4/agent/analyze/grammar when in doubt; do not ship a hand-authored method list.",
            "Timeout is terminal on the public surface. Do not sleep, resume, poll, or continue through read_query/analyze_catalog for the same analysis.",
            "If an answer includes agent_usage.answer_complete=true, stop tool use for that analysis and write the final user answer from the analyze payload."
        ],
        "envelope": {
            "source_of_truth": ANALYZE_GRAMMAR_ENDPOINT,
            "schema_version": "analyze_envelope.v1",
            "modes": ["analysis_spec", "resolve_and_run", "run"],
            "request_fields": ["analysis_spec", "semantic_request", "canonical_request", "audit_context", "wait_timeout_ms"],
            "legacy_field_policy": "legacy intent/request/resume payload shapes are rejected",
            "analysis_spec": {
                "schema_version": "analysis_spec.v1",
                "role": "normal role-scoped analysis plan",
                "forbidden_root_fields": ["entity", "subject", "subjects", "outcome", "drivers", "explanatory_space", "candidate_space"],
                "required": ["question_type", "variables"],
                "variable_required": ["variable_id", "role", "signal", "source", "scope", "time_alignment"],
                "variable_roles": ["outcome", "driver", "control", "comparison_member", "stratifier"],
                "time_alignment": "object; explicit strategies include same_date, trailing_window/lookback_window, and opt-in auto_reconcile:\"coarsest\" with lookahead_policy:\"forbid\"",
                "method_hint": "optional deterministic method selection hint, e.g. temporal_direction",
                "comparison_axis": ["entity", "time_window", "group", "threshold"],
                "time_scope_mode": ["last_n_days", "date_range", "all_time", "a_vs_b"],
                "response_shape": ["page", "rank_summary"],
                "result_page": "same-tool cursor continuation for broad ranked result pages"
            },
            "semantic_request": {
                "role": "legacy compatibility adapter for server-language analysis meaning before canonical binding",
                "raw_user_text_policy": "audit_context.raw_user_text_only",
                "minimum": "question_type or intent_kind plus any known outcome/subjects/drivers/candidate_space/window",
                "result_page": "same-tool cursor continuation for broad ranked result pages"
            },
            "canonical_request": {
                "role": "legacy compatibility adapter for already resolved canonical analysis request",
                "minimum": "question_type, scope_level, outcome.signal",
                "result_page": "same-tool cursor continuation for broad ranked result pages"
            },
            "fields": {
                "question_type": ["trend", "explanation", "comparison", "association", "forecast", "decision"],
                "scope_level": ["exact_subject", "subject_group", "domain_overview", "representative_bundle"],
                "budget_class": ["fast", "standard", "broad"],
                "comparison_axis": ["entity", "time_window", "group", "threshold"],
                "time_scope_mode": ["last_n_days", "date_range", "all_time", "a_vs_b"]
            }
        },
        "response_envelope": {
            "shape": "flat_top_level_type_plus_business_fields",
            "terminal_types": ["answer", "needs_clarification", "schema_error", "unsupported", "timeout"],
            "non_terminal_types": [],
            "clarification_fields": ["allowed_next_actions", "missing_fields", "message"],
            "timeout_policy": "terminal_no_resume"
        },
        "answer_completion_contract": {
            "answer_complete_means_stop_tool_use_for_same_question": true,
            "post_answer_read_query_enrichment_forbidden": true,
            "allowed_after_answer_only_for": [
                "new_user_request",
                "user_explicitly_asks_for_raw_saved_records",
                "answer_payload_requests_a_concrete_follow_up"
            ]
        },
        "examples": [
            {
                "title": "Role-scoped analysis_spec question",
                "payload": {
                    "schema_version": "analyze_envelope.v1",
                    "mode": "analysis_spec",
                    "analysis_spec": {
                        "schema_version": "analysis_spec.v1",
                        "question_type": "explanation",
                        "variables": [
                            {
                                "variable_id": "jump_height",
                                "role": "outcome",
                                "signal": "jump_height",
                                "source": "performance_tests",
                                "scope": {"protocol_variant": "free_arms"},
                                "time_alignment": {"anchor": "session_date"}
                            },
                            {
                                "variable_id": "power_activity",
                                "role": "driver",
                                "signal": "presence",
                                "source": "activity",
                                "scope": {"group": {"metadata_field": "capability_targets", "value": "power"}},
                                "time_alignment": {"lookback_days": 14},
                                "candidate_handle": "candidate:activity.group.capability_targets.power"
                            }
                        ]
                    }
                }
            },
            {
                "title": "Role-scoped recovery trend question",
                "payload": {
                    "schema_version": "analyze_envelope.v1",
                    "mode": "analysis_spec",
                    "analysis_spec": {
                        "schema_version": "analysis_spec.v1",
                        "question_type": "trend",
                        "variables": [
                            {
                                "variable_id": "sleep_quality",
                                "role": "outcome",
                                "signal": "sleep_quality",
                                "source": "recovery",
                                "scope": {},
                                "time_alignment": {"strategy": "same_date"}
                            }
                        ]
                    }
                }
            },
            {
                "title": "Role-scoped strength trend question",
                "payload": {
                    "schema_version": "analyze_envelope.v1",
                    "mode": "analysis_spec",
                    "analysis_spec": {
                        "schema_version": "analysis_spec.v1",
                        "question_type": "trend",
                        "variables": [
                            {
                                "variable_id": "e1rm",
                                "role": "outcome",
                                "signal": "estimated_1rm",
                                "source": "strength_inference",
                                "scope": {"comparison_stream": "hang_power_snatch"},
                                "time_alignment": {"strategy": "same_date"}
                            }
                        ]
                    }
                }
            },
            {
                "title": "Trace original user text without binding from it",
                "payload": {
                    "schema_version": "analyze_envelope.v1",
                    "mode": "analysis_spec",
                    "analysis_spec": {
                        "schema_version": "analysis_spec.v1",
                        "question_type": "association",
                        "variables": [
                            {
                                "variable_id": "e1rm",
                                "role": "outcome",
                                "signal": "estimated_1rm",
                                "source": "strength_inference",
                                "scope": {"comparison_stream": "barbell_back_squat"},
                                "time_alignment": {"strategy": "same_date"}
                            },
                            {
                                "variable_id": "sleep_quality",
                                "role": "driver",
                                "signal": "sleep_quality",
                                "source": "recovery",
                                "scope": {},
                                "time_alignment": {"lookback_days": 1}
                            },
                            {
                                "variable_id": "soreness",
                                "role": "driver",
                                "signal": "soreness",
                                "source": "recovery",
                                "scope": {},
                                "time_alignment": {"lookback_days": 1}
                            }
                        ]
                    },
                    "audit_context": {"raw_user_text": "Any user-language text goes here only."}
                }
            },
            {
                "title": "Analyze an outcome against a source-bound metadata group",
                "payload": {
                    "schema_version": "analyze_envelope.v1",
                    "mode": "analysis_spec",
                    "analysis_spec": {
                        "schema_version": "analysis_spec.v1",
                        "question_type": "explanation",
                        "variables": [
                            {
                                "variable_id": "jump_height",
                                "role": "outcome",
                                "signal": "jump_height",
                                "source": "performance_tests",
                                "scope": {"protocol_variant": "free_arms"},
                                "time_alignment": {"strategy": "same_date"}
                            },
                            {
                                "variable_id": "power_activity",
                                "role": "driver",
                                "signal": "presence",
                                "source": "activity",
                                "scope": {"group": {"metadata_field": "capability_targets", "value": "power"}},
                                "time_alignment": {"lookback_days": 14}
                            }
                        ]
                    }
                }
            }
        ],
        "exit_codes": {
            "2xx": 0,
            "4xx": 1,
            "5xx": 2,
            "network_error": 3,
            "usage_error": 4
        }
    })
}

#[derive(Args, Default, Debug, Clone)]
#[command(after_help = ANALYZE_HELP)]
pub struct AnalyzeInvokeArgs {
    /// Inline JSON for the public agent-facing request object
    #[arg(long = "request-json", conflicts_with = "request_file")]
    pub request_json: Option<String>,

    /// Path to a JSON file containing the public request object (use '-' for stdin)
    #[arg(long = "request-file", conflicts_with = "request_json")]
    pub request_file: Option<String>,

    /// Optional wait-timeout milliseconds for the server-side poll (250..60000)
    #[arg(long = "wait-timeout-ms")]
    pub wait_timeout_ms: Option<u64>,
}

pub async fn run(api_url: &str, token: Option<&str>, args: AnalyzeInvokeArgs) -> i32 {
    let mut body = resolve_request_body(&args)
        .unwrap_or_else(|(message, hint)| exit_error(&message, hint.as_deref()));
    prepare_agent_analyze_body(&mut body);

    if dry_run_enabled() {
        return emit_dry_run_request(
            &reqwest::Method::POST,
            api_url,
            ANALYZE_ENDPOINT,
            token.is_some(),
            Some(&body),
            &[],
            &[],
            false,
            Some("Dispatch one public analyze request to /v4/agent/analyze/run."),
        );
    }

    let response = raw_api_request_json(
        api_url,
        reqwest::Method::POST,
        ANALYZE_ENDPOINT,
        token,
        Some(body),
        &[],
        &[],
    )
    .await
    .unwrap_or_else(|error| {
        exit_error(
            &format!("Failed to reach {ANALYZE_ENDPOINT}: {error}"),
            Some("Check API availability/auth and retry."),
        )
    });

    emit_analyze_response(api_url, response)
}

fn resolve_public_request(
    args: &AnalyzeInvokeArgs,
) -> Result<Option<Value>, (String, Option<String>)> {
    match (args.request_json.as_deref(), args.request_file.as_deref()) {
        (Some(inline), _) => serde_json::from_str::<Value>(inline)
            .map(Some)
            .map_err(|err| {
                (
                    format!("--request-json is not valid JSON: {err}"),
                    Some(
                        "Pass a JSON object like {\"mode\":\"analysis_spec\",\"analysis_spec\":{\"schema_version\":\"analysis_spec.v1\",\"question_type\":\"trend\",\"variables\":[{\"variable_id\":\"sleep_quality\",\"role\":\"outcome\",\"signal\":\"sleep_quality\",\"source\":\"recovery\",\"scope\":{},\"time_alignment\":{\"strategy\":\"same_date\"}}]}}."
                            .to_string(),
                    ),
                )
            }),
        (None, Some(path)) => read_json_from_file(path).map(Some).map_err(|err| {
            (
                err,
                Some("Pass a readable JSON file or use '-' to read the public request from stdin.".to_string()),
            )
        }),
        (None, None) => Ok(None),
    }
}

fn resolve_request_body(args: &AnalyzeInvokeArgs) -> Result<Value, (String, Option<String>)> {
    if let Some(request) = resolve_public_request(args)? {
        return Ok(build_analyze_request_body(request, args.wait_timeout_ms));
    }
    Err((
        "kura analyze requires --request-json or --request-file".to_string(),
        Some(
            "Pass a public request, e.g. --request-json '{\"mode\":\"analysis_spec\",\"analysis_spec\":{\"schema_version\":\"analysis_spec.v1\",\"question_type\":\"trend\",\"variables\":[{\"variable_id\":\"sleep_quality\",\"role\":\"outcome\",\"signal\":\"sleep_quality\",\"source\":\"recovery\",\"scope\":{},\"time_alignment\":{\"strategy\":\"same_date\"}}]}}'."
                .to_string(),
        ),
    ))
}

fn build_analyze_request_body(request: Value, wait_timeout_ms: Option<u64>) -> Value {
    let mut body = if request.get("mode").is_some()
        || request.get("canonical_request").is_some()
        || request.get("semantic_request").is_some()
        || request.get("analysis_spec").is_some()
    {
        request.as_object().cloned().unwrap_or_default()
    } else if request.get("variables").is_some() {
        let mut wrapped = Map::new();
        wrapped.insert("schema_version".to_string(), json!("analyze_envelope.v1"));
        wrapped.insert("mode".to_string(), json!("analysis_spec"));
        wrapped.insert("analysis_spec".to_string(), request);
        wrapped
    } else {
        let mut wrapped = Map::new();
        wrapped.insert("schema_version".to_string(), json!("analyze_envelope.v1"));
        wrapped.insert("mode".to_string(), json!("resolve_and_run"));
        wrapped.insert("semantic_request".to_string(), request);
        wrapped
    };
    if let Some(ms) = wait_timeout_ms {
        body.insert("wait_timeout_ms".to_string(), json!(ms));
    }
    Value::Object(body)
}

fn prepare_agent_analyze_body(body: &mut Value) {
    let Some(obj) = body.as_object_mut() else {
        return;
    };
    obj.entry("wait_timeout_ms".to_string())
        .or_insert_with(|| Value::Number(ANALYZE_AGENT_DEFAULT_WAIT_TIMEOUT_MS.into()));
}

fn emit_analyze_response(api_url: &str, response: RawApiResponse) -> i32 {
    let success = (200..=299).contains(&response.status);
    let text_mode = matches!(cli_runtime_options().output_mode, CliOutputMode::Text);

    if success {
        record_analyze_response(api_url, &response.body);
        if text_mode {
            render_envelope_as_text(&response.body);
        }
        print_json_stdout(&response.body);
        0
    } else {
        if text_mode {
            render_envelope_as_text(&response.body);
        }
        print_json_stderr(&response.body);
        if (400..=499).contains(&response.status) {
            1
        } else {
            2
        }
    }
}

fn render_envelope_as_text(body: &Value) {
    let obj = match body.as_object() {
        Some(obj) => obj,
        None => {
            eprintln!("[analyze] non-object response body; see JSON below for details.");
            return;
        }
    };

    let envelope_type = obj
        .get("type")
        .and_then(|v| v.as_str())
        .unwrap_or("<missing>");

    match envelope_type {
        "answer" => render_answer(obj),
        "needs_clarification" => render_needs_clarification(obj),
        "schema_error" => render_terminal_status("schema_error", obj),
        "unsupported" => render_terminal_status("unsupported", obj),
        "timeout" => render_timeout(obj),
        "refusal" => render_refusal(obj),
        "clarification" => render_clarification(obj),
        "error" => render_error(obj),
        other => eprintln!("[analyze] type={other}; rendering raw JSON below."),
    }
}

fn render_answer(obj: &Map<String, Value>) {
    eprintln!("[analyze] type=answer");
    if obj.get("agent_usage").and_then(|v| v.as_object()).is_some() {
        eprintln!("  ANSWER_COMPLETE: yes");
        eprintln!("  STOP_TOOL_USE: write the final answer from this analyze payload now.");
        eprintln!("  MUST_USE_ANALYZE_ANSWER: use this payload for the final answer.");
        eprintln!(
            "  DO_NOT: call read_query or analyze_catalog to enrich or re-answer the same question."
        );
    }
    if let Some(summary) = obj.get("summary").and_then(|v| v.as_str()) {
        eprintln!("  summary: {summary}");
    }
    if let Some(findings) = obj.get("key_findings").and_then(|v| v.as_array()) {
        for finding in findings.iter().filter_map(|v| v.as_str()).take(5) {
            eprintln!("  finding: {finding}");
        }
    }
    if let Some(findings) = obj.get("findings").and_then(|v| v.as_array()) {
        eprintln!(
            "  findings: {} entr{}",
            findings.len(),
            if findings.len() == 1 { "y" } else { "ies" }
        );
    }
}

fn render_refusal(obj: &Map<String, Value>) {
    let reason_code = obj
        .get("reason_code")
        .and_then(|v| v.as_str())
        .unwrap_or("<unknown>");
    let reason_detail = obj
        .get("reason_detail")
        .and_then(|v| v.as_str())
        .unwrap_or("<unknown>");
    eprintln!("[analyze] type=refusal");
    render_refusal_next_action(obj);
    eprintln!("  reason_code:   {reason_code}");
    eprintln!("  reason_detail: {reason_detail}");
    if let Some(guidance) = obj.get("guidance").and_then(|v| v.as_str()) {
        eprintln!("  guidance: {guidance}");
    }
    if let Some(diagnostics) = obj.get("diagnostics").and_then(|v| v.as_object()) {
        if !diagnostics.is_empty() {
            eprintln!("  diagnostics:");
            for (k, v) in diagnostics {
                eprintln!("    {k}: {v}");
            }
        }
    }
}

fn render_refusal_next_action(obj: &Map<String, Value>) {
    let Some(next_request) = executable_next_request(obj) else {
        return;
    };
    let action = next_request
        .get("action")
        .and_then(|v| v.as_str())
        .unwrap_or("retry_analyze");
    if action != "retry_analyze" {
        return;
    }
    eprintln!("  NEXT_ACTION_REQUIRED: retry_analyze");
    eprintln!("  MUST: call the next_request on `kura analyze` before using read_query.");
    eprintln!("  DO_NOT: switch to read_query before this analyze retry.");
    if let Some(command) = retry_analyze_command(next_request) {
        eprintln!("  command: {command}");
    }
}

fn executable_next_request<'a>(obj: &'a Map<String, Value>) -> Option<&'a Value> {
    obj.get("next_request").or_else(|| {
        obj.get("suggested_next_action")
            .and_then(|v| v.get("next_request"))
    })
}

fn retry_analyze_command(next_request: &Value) -> Option<String> {
    let request = next_request
        .get("request")
        .and_then(public_retry_request_from_value)
        .or_else(|| next_request.get("intent").cloned())?;
    let request_json = serde_json::to_string(&request).ok()?;
    Some(format!(
        "kura analyze --request-json {}",
        shell_single_quote(&request_json)
    ))
}

fn public_retry_request_from_value(value: &Value) -> Option<Value> {
    if let Some(intent) = value.get("intent") {
        return Some(intent.clone());
    }
    Some(value.clone())
}

fn shell_single_quote(value: &str) -> String {
    format!("'{}'", value.replace('\'', "'\"'\"'"))
}

fn render_clarification(obj: &Map<String, Value>) {
    eprintln!("[analyze] type=clarification");
    if let Some(question) = obj.get("question").and_then(|v| v.as_str()) {
        eprintln!("  question: {question}");
    }
    if let Some(options) = obj.get("options").and_then(|v| v.as_array()) {
        eprintln!("  options: {}", options.len());
        for (idx, option) in options.iter().enumerate() {
            let label = option
                .get("label")
                .and_then(|v| v.as_str())
                .unwrap_or("<no label>");
            eprintln!("    [{idx}] {label}");
        }
    }
    eprintln!(
        "  retry_hint: next_intent = {{...baseline_intent, ...options[selected].fill}} — re-call `kura analyze` with it."
    );
}

fn render_needs_clarification(obj: &Map<String, Value>) {
    eprintln!("[analyze] type=needs_clarification");
    render_message_field(obj);
    if let Some(actions) = obj.get("allowed_next_actions").and_then(|v| v.as_array()) {
        eprintln!("  allowed_next_actions: {}", actions.len());
    }
}

fn render_timeout(obj: &Map<String, Value>) {
    eprintln!("[analyze] type=timeout");
    render_message_field(obj);
    if let Some(waited_ms) = obj.get("waited_ms").and_then(|v| v.as_u64()) {
        eprintln!("  waited_ms: {waited_ms}");
    }
    eprintln!("  terminal: yes");
    eprintln!("  retry_hint: start a fresh typed analyze request only if the user asks again.");
}

fn render_terminal_status(kind: &str, obj: &Map<String, Value>) {
    eprintln!("[analyze] type={kind}");
    render_message_field(obj);
}

fn render_message_field(obj: &Map<String, Value>) {
    if let Some(message) = obj.get("message").and_then(|v| v.as_str()) {
        eprintln!("  message: {message}");
    }
}

fn render_error(obj: &Map<String, Value>) {
    let code = obj
        .get("error_code")
        .and_then(|v| v.as_str())
        .unwrap_or("<unknown>");
    let message = obj
        .get("error_message")
        .and_then(|v| v.as_str())
        .unwrap_or("<unknown>");
    eprintln!("[analyze] type=error");
    eprintln!("  error_code:    {code}");
    eprintln!("  error_message: {message}");
}

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

    #[test]
    fn resolve_public_request_accepts_inline_json() {
        let args = AnalyzeInvokeArgs {
            request_json: Some(
                r#"{"question_type":"trend","target":{"signal":"sleep_quality"}}"#.to_string(),
            ),
            ..AnalyzeInvokeArgs::default()
        };
        let request = resolve_public_request(&args)
            .expect("public request should parse")
            .expect("request present");
        assert_eq!(request["question_type"], json!("trend"));
        assert_eq!(request["target"]["signal"], json!("sleep_quality"));
    }

    #[test]
    fn resolve_request_body_rejects_missing_public_request() {
        let args = AnalyzeInvokeArgs::default();
        let err = resolve_request_body(&args).expect_err("no request should fail");
        assert!(err.0.contains("--request-json"));
        assert!(!err.0.contains("--intent-json"));
    }

    #[test]
    fn resolve_request_body_prefers_public_request_shape() {
        let args = AnalyzeInvokeArgs {
            request_json: Some(
                r#"{"question_type":"trend","target":{"signal":"estimated_1rm"},"subject":{"label":"Hang Power Snatch"}}"#
                    .to_string(),
            ),
            wait_timeout_ms: Some(8000),
            ..AnalyzeInvokeArgs::default()
        };
        let body = resolve_request_body(&args).expect("public request body");
        assert_eq!(body["schema_version"], json!("analyze_envelope.v1"));
        assert_eq!(body["mode"], json!("resolve_and_run"));
        assert_eq!(body["semantic_request"]["question_type"], json!("trend"));
        assert_eq!(body["wait_timeout_ms"], json!(8000));
        assert!(body.get("intent").is_none());
        assert!(body.get("request").is_none());
    }

    #[test]
    fn build_request_body_omits_wait_timeout_when_none() {
        let body = build_analyze_request_body(json!({"question_type": "trend"}), None);
        assert_eq!(body["mode"], json!("resolve_and_run"));
        assert_eq!(body["semantic_request"]["question_type"], json!("trend"));
        assert!(body.get("wait_timeout_ms").is_none());
    }

    #[test]
    fn build_request_body_includes_wait_timeout_when_present() {
        let body = build_analyze_request_body(json!({"question_type": "trend"}), Some(8000));
        assert_eq!(body["wait_timeout_ms"], json!(8000));
    }

    #[test]
    fn build_request_body_wraps_analysis_spec_shape() {
        let body = build_analyze_request_body(
            json!({
                "schema_version": "analysis_spec.v1",
                "question_type": "explanation",
                "variables": [
                    {
                        "variable_id": "jump_height",
                        "role": "outcome",
                        "signal": "jump_height",
                        "source": "performance_tests",
                        "scope": {"protocol_variant": "free_arms"},
                        "time_alignment": {"anchor": "session_date"}
                    }
                ]
            }),
            None,
        );

        assert_eq!(body["schema_version"], json!("analyze_envelope.v1"));
        assert_eq!(body["mode"], json!("analysis_spec"));
        assert_eq!(body["analysis_spec"]["question_type"], json!("explanation"));
        assert!(body.get("semantic_request").is_none());
    }

    #[test]
    fn build_request_body_preserves_explicit_typed_envelope() {
        let body = build_analyze_request_body(
            json!({
                "schema_version": "analyze_envelope.v1",
                "mode": "run",
                "canonical_request": {
                    "question_type": "trend",
                    "scope_level": "domain_overview",
                    "outcome": {"signal": "sleep_quality"}
                }
            }),
            None,
        );

        assert_eq!(body["mode"], json!("run"));
        assert_eq!(
            body["canonical_request"]["outcome"]["signal"],
            json!("sleep_quality")
        );
        assert!(body.get("semantic_request").is_none());
    }

    #[test]
    fn analyze_agent_runtime_adds_default_wait_timeout_without_changing_body_builder() {
        let mut body = build_analyze_request_body(json!({"question_type": "trend"}), None);
        assert!(body.get("wait_timeout_ms").is_none());

        prepare_agent_analyze_body(&mut body);

        assert_eq!(
            body["wait_timeout_ms"],
            json!(ANALYZE_AGENT_DEFAULT_WAIT_TIMEOUT_MS)
        );
    }

    #[test]
    fn retry_analyze_command_renders_executable_next_request() {
        let next_request = json!({
            "surface": "analyze",
            "action": "retry_analyze",
            "intent": {
                "question_type": "trend",
                "scope_level": "exact_subject",
                "outcome": {"signal": "estimated_1rm"},
                "entity": {"entity_id": "hang_muscle_snatch_bk"}
            },
            "must_call_before_read_query": true
        });

        let command = retry_analyze_command(&next_request).expect("retry command");

        assert!(command.starts_with("kura analyze --request-json '"));
        assert!(command.contains("\"entity_id\":\"hang_muscle_snatch_bk\""));
        assert!(command.contains("\"signal\":\"estimated_1rm\""));
    }

    #[test]
    fn executable_next_request_accepts_legacy_nested_action_location() {
        let body = json!({
            "type": "refusal",
            "suggested_next_action": {
                "next_request": {
                    "action": "retry_analyze",
                    "intent": {"question_type": "trend"}
                }
            }
        });
        let obj = body.as_object().expect("object");

        let next_request = executable_next_request(obj).expect("next request");

        assert_eq!(next_request["action"], json!("retry_analyze"));
    }

    /// Contract: the public analyze help must not steer agents to a hand-
    /// authored capability catalog. The server-side grammar endpoint is the
    /// single source of truth — listing `analyze_capabilities` or similar in
    /// help text fires the `full_capability_catalog_dump_as_primary_routing_ui`
    /// forbidden shortcut pinned by `analysis_semantic_routing_contract_v1`.
    #[test]
    fn analyze_help_does_not_recommend_capabilities_command() {
        assert!(
            !ANALYZE_HELP.contains("analyze_capabilities"),
            "ANALYZE_HELP must not reference `analyze_capabilities` — \
             the grammar endpoint is the server-owned discovery hop."
        );
        assert!(
            !ANALYZE_HELP.contains("capability catalog"),
            "ANALYZE_HELP must not steer agents to a local capability catalog."
        );
    }

    /// Contract: the public analyze help must lead with the routing grammar
    /// (intent axes), not with archetype recommendations. An archetype-first
    /// framing trains agents to pre-pick methods instead of sending routing
    /// axes — exactly the anti-pattern the request-first rebuild cuts.
    #[test]
    fn analyze_help_leads_with_routing_axes_before_archetype() {
        assert!(
            !ANALYZE_HELP.contains("--archetype"),
            "ANALYZE_HELP must not ship the archetype-era `--archetype` flag."
        );
        assert!(
            !ANALYZE_HELP.to_lowercase().contains("archetype"),
            "ANALYZE_HELP must not mention `archetype` in any form; the server \
             compiles the method from the intent grammar."
        );
        assert!(
            ANALYZE_HELP.contains("question_type"),
            "ANALYZE_HELP must lead with the intent grammar axes; `question_type` \
             must appear before any method-specific guidance."
        );
        assert!(
            ANALYZE_HELP.contains("/v4/agent/analyze/grammar"),
            "ANALYZE_HELP must publish the grammar discovery endpoint as the \
             discovery hop."
        );
    }

    #[test]
    fn payload_contract_is_typed_envelope_first() {
        let contract = public_analyze_payload_contract();
        assert_eq!(contract["entrypoint"], json!("kura analyze"));
        assert_eq!(contract["backend_endpoint"], json!(ANALYZE_ENDPOINT));
        assert_eq!(
            contract["grammar_endpoint"],
            json!(ANALYZE_GRAMMAR_ENDPOINT)
        );
        assert_eq!(contract["surface"], json!("typed_analyze_envelope_surface"));
        assert_eq!(
            contract["envelope"]["schema_version"],
            json!("analyze_envelope.v1")
        );
        let contract_str = contract.to_string();
        assert!(
            !contract_str.contains("\"slots\""),
            "payload contract must not teach the deleted public slots shape"
        );
        assert!(
            !contract_str.contains("\"intent\":"),
            "payload contract examples must not expose direct intent payloads"
        );
        assert!(
            !contract_str.contains("analysis_job"),
            "payload contract must not expose deleted job polling handles"
        );
        assert!(
            !contract_str.contains("analysis_archetype_request"),
            "payload contract must not mention the deleted archetype-era schema"
        );
        assert!(
            !contract_str.contains("kernel_json"),
            "payload contract must not surface the deleted --kernel-json flag"
        );
        assert!(
            !contract_str.contains("analysis_json"),
            "payload contract must not surface the deleted --analysis-json flag"
        );
    }

    /// Regression pin for the 2026-04-19 grammar-drift discovery.
    /// Pre-fix, the CLI documentation recommended a nested
    /// `time_scope = {mode: acute_window | long_term_window, days: N}`
    /// shape the Python worker has never spoken. Any retry of those
    /// legacy values here would silently re-teach agents the dead
    /// grammar.
    #[test]
    fn payload_contract_time_scope_axis_matches_python_grammar() {
        let contract = public_analyze_payload_contract();
        let axis = contract["envelope"]["fields"]["time_scope_mode"]
            .as_array()
            .expect("time_scope_mode axis must be an array");
        let values: Vec<&str> = axis.iter().filter_map(|v| v.as_str()).collect();
        assert_eq!(
            values,
            vec!["last_n_days", "date_range", "all_time", "a_vs_b"],
            "time_scope_mode axis must mirror Python grammar.TIME_SCOPE_MODES; \
             parity is enforced end-to-end by test_457"
        );
        let contract_str = contract.to_string();
        for banned in ["acute_window", "long_term_window"] {
            assert!(
                !contract_str.contains(banned),
                "payload contract contains the dead archetype-era value {banned:?} — \
                 the Python worker has never accepted it"
            );
        }
        assert!(
            !contract_str.contains("\"time_scope\""),
            "payload contract must use the flat `time_scope_mode` key, \
             never the legacy nested `time_scope` object"
        );
    }

    #[test]
    fn analyze_help_uses_flat_time_scope_mode_grammar() {
        for keyword in [
            "time_scope_mode",
            "last_n_days",
            "date_range",
            "all_time",
            "a_vs_b",
        ] {
            assert!(
                ANALYZE_HELP.contains(keyword),
                "ANALYZE_HELP must document the flat time_scope_mode \
                 grammar term {keyword:?}"
            );
        }
        for dead in ["acute_window", "long_term_window"] {
            assert!(
                !ANALYZE_HELP.contains(dead),
                "ANALYZE_HELP must not recommend the dead archetype-era \
                 vocabulary {dead:?}"
            );
        }
        assert!(
            !ANALYZE_HELP.contains("{mode:"),
            "ANALYZE_HELP must not document the legacy nested \
             `time_scope = {{mode, days}}` shape"
        );
    }

    #[test]
    fn analyze_help_blocks_read_query_prefetch_detours() {
        assert!(
            ANALYZE_HELP.contains("Start with one typed analyze envelope"),
            "analyze help must tell agents to start with the typed analyze surface"
        );
        for phrase in [
            "Do not call read_query",
            "read_query --help",
            "performance_tests",
            "activity_session_history",
            "vocabulary_search",
            "analyze_catalog first",
            "Timeout is terminal",
            "agent_usage.answer_complete=true",
        ] {
            assert!(
                ANALYZE_HELP.contains(phrase),
                "ANALYZE_HELP must block pre-analyze detour phrase {phrase:?}"
            );
        }
    }

    #[test]
    fn payload_contract_describes_flat_envelope() {
        let contract = public_analyze_payload_contract();
        assert_eq!(
            contract["response_envelope"]["shape"],
            json!("flat_top_level_type_plus_business_fields")
        );
        assert_eq!(
            contract["response_envelope"]["timeout_policy"],
            json!("terminal_no_resume")
        );
        let terminal_types: Vec<String> = contract["response_envelope"]["terminal_types"]
            .as_array()
            .expect("terminal_types array")
            .iter()
            .map(|v| v.as_str().unwrap_or_default().to_string())
            .collect();
        assert!(terminal_types.contains(&"answer".to_string()));
        assert!(terminal_types.contains(&"needs_clarification".to_string()));
        assert!(terminal_types.contains(&"schema_error".to_string()));
        assert!(terminal_types.contains(&"unsupported".to_string()));
        assert!(terminal_types.contains(&"timeout".to_string()));
        assert!(
            contract["response_envelope"]["non_terminal_types"]
                .as_array()
                .expect("non_terminal_types array")
                .is_empty()
        );
        assert_eq!(
            contract["answer_completion_contract"]["answer_complete_means_stop_tool_use_for_same_question"],
            json!(true)
        );
        assert_eq!(
            contract["answer_completion_contract"]["post_answer_read_query_enrichment_forbidden"],
            json!(true)
        );
    }
}
