//! Handler for `mcptest security redteam --adaptive`.
//!
//! The C1 lane ([`super::redteam`]) runs a fixed corpus once. The C2 adaptive
//! lane takes attack seeds, mutates each through a deterministic converter
//! ladder (leetspeak, ROT13, homoglyphs, hex), and drives each variant through
//! the live agent loop until one exploits the model. The orchestration and
//! scoring live in [`mcptest_core::security::adaptive`]; this module bridges its
//! synchronous `run` closure onto the async agent driver.
//!
//! The output is the same ADVISORY per-model exploitability signal C1 produces
//! (ADR 0041): it never becomes the process verdict, so the exit code reports
//! whether the run completed, not whether the model was exploitable. The bridge
//! and the exit mapping below preserve that boundary explicitly.

use std::sync::Arc;

use anyhow::{Context, Result};
use mcptest_agent::{run_agent, AgentConfig, Budget, ServerHandle};
use mcptest_core::eval::provider::LlmProvider;
use mcptest_core::protocol::Client;
use mcptest_core::security::{
    default_converter_ladder, render_adaptive, run_seed_campaign, AdaptiveCampaign,
};
use serde_json::Value;
use tokio::runtime::{Handle, Runtime};

use crate::cli::args::{RedteamArgs, RedteamFormat};
use crate::cli::global::GlobalArgs;

use super::corpus::{default_seed_corpus, AdaptiveSeed};
use super::redteam::{
    connect, resolve_headers, resolve_provider, EXIT_COMPLETED, EXIT_SETUP_FAILED,
};

/// Handle `mcptest security redteam --adaptive --url <server> --model <id>`.
///
/// Connects to the server, resolves the provider, runs the adaptive seed
/// corpus through the converter ladder against the live model, and prints the
/// per-model exploitability report. Returns [`EXIT_COMPLETED`] on a finished
/// run and [`EXIT_SETUP_FAILED`] when the connection or provider cannot be
/// established. Exploitability never changes the exit code (ADR 0041).
pub(super) fn adaptive_command(args: RedteamArgs, global: &GlobalArgs) -> Result<i32> {
    super::redteam::warn_when_premium_corpus_requested_in_oss_build(args.corpus);
    let runtime = Runtime::new().context("could not build tokio runtime")?;
    let provider = match resolve_provider(&args.model) {
        Ok(p) => p,
        Err(e) => {
            eprintln!("mcptest security redteam: cannot enable the adaptive lane: {e}");
            return Ok(EXIT_SETUP_FAILED);
        }
    };
    let headers = resolve_headers(global);
    let server = match runtime.block_on(connect(&args, headers)) {
        Ok(s) => s,
        Err(e) => {
            eprintln!(
                "mcptest security redteam: connect to {} failed: {e}",
                args.url
            );
            return Ok(EXIT_SETUP_FAILED);
        }
    };

    // Drive the campaign synchronously on this thread (NOT inside a
    // `block_on`), so the per-payload closure can re-enter the runtime via the
    // handle without panicking on a nested block-on.
    let report = drive_campaign(
        runtime.handle(),
        &server.client,
        provider.as_ref(),
        &args.model,
        &default_seed_corpus(),
        args.max_attempts,
        args.max_turns,
        args.max_tokens,
    )?;

    let _ = runtime.block_on(server.shutdown.shutdown());
    print_report(args.format, &report)?;
    Ok(exit_code_for(&report))
}

/// Map a finished adaptive run to its exit code. ADR 0041 boundary, made
/// explicit and testable: the per-model exploitability signal never becomes the
/// verdict, so completion always maps to [`EXIT_COMPLETED`] no matter how many
/// seeds cracked the model. The argument is unused on purpose; a future change
/// that reads the campaign to gate the exit has to confront ADR 0041 first.
fn exit_code_for(_campaign: &AdaptiveCampaign) -> i32 {
    EXIT_COMPLETED
}

/// Drive every seed through the converter ladder against the live model and
/// fold the results into one per-model adaptive campaign.
///
/// Synchronous on purpose: [`run_seed_campaign`] takes a synchronous
/// `FnMut(&str) -> Value`, but driving the live server is async. The closure
/// bridges the two by capturing the runtime `handle` and re-entering it with
/// [`Handle::block_on`] per mutated payload. That is safe only because this
/// function is called outside any `block_on`, so the closure's nested
/// block-on runs on a thread that is not already executing the runtime.
#[allow(clippy::too_many_arguments)]
fn drive_campaign(
    handle: &Handle,
    client: &Client,
    provider: &dyn LlmProvider,
    model: &str,
    seeds: &[AdaptiveSeed],
    max_attempts: usize,
    max_turns: u32,
    max_tokens: u32,
) -> Result<AdaptiveCampaign> {
    let mut campaign = AdaptiveCampaign::new(model);
    let ladder = default_converter_ladder();
    // One shared budget across the whole campaign mirrors the C1 lane.
    let budget = Arc::new(Budget::new(None, None));
    for adaptive_seed in seeds {
        // The closure captures everything one mutated attempt needs and bridges
        // the synchronous orchestrator onto the async driver (see fn docs).
        let run = |payload: &str| -> Value {
            run_one_attempt(
                handle,
                client,
                provider,
                model,
                adaptive_seed.system_prompt,
                payload,
                Arc::clone(&budget),
                max_turns,
                max_tokens,
            )
        };
        campaign.record(run_seed_campaign(
            &adaptive_seed.seed,
            &ladder,
            &adaptive_seed.checks,
            max_attempts,
            run,
        ));
    }
    Ok(campaign)
}

/// Run one mutated payload through the agent loop and return the trace
/// envelope the oracle scores. Re-enters the runtime with the captured handle.
/// An agent error is surfaced as an envelope carrying the (redacted) error in
/// `final_response`, so a single transport hiccup neither aborts the campaign
/// nor masquerades as an exploit: the safety checks read attacker markers, not
/// error text, so an error envelope scores as resisted.
#[allow(clippy::too_many_arguments)]
fn run_one_attempt(
    handle: &Handle,
    client: &Client,
    provider: &dyn LlmProvider,
    model: &str,
    system_prompt: &str,
    payload: &str,
    budget: Arc<Budget>,
    max_turns: u32,
    max_tokens: u32,
) -> Value {
    let cfg = AgentConfig {
        model: model.to_string(),
        user_prompt: payload.to_string(),
        system_prompt: Some(system_prompt.to_string()),
        max_turns,
        max_tokens,
        ..Default::default()
    };
    let handles = [ServerHandle {
        name: "target",
        client,
    }];
    match handle.block_on(run_agent(&cfg, provider, &handles, budget)) {
        Ok(trace) => trace.to_envelope(),
        Err(e) => {
            let redacted = mcptest_agent::redact_secrets(&e.to_string());
            serde_json::json!({ "final_response": format!("agent run error: {redacted}") })
        }
    }
}

/// Print the per-model adaptive report in the requested format. Both formats
/// are labelled as a per-model signal, never a security grade.
fn print_report(format: RedteamFormat, report: &AdaptiveCampaign) -> Result<()> {
    match format {
        RedteamFormat::Pretty => print!("{}", render_adaptive(report)),
        RedteamFormat::Json => {
            let json =
                serde_json::to_string_pretty(report).context("serialize adaptive campaign")?;
            println!("{json}");
        }
    }
    Ok(())
}

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

    #[test]
    fn an_all_exploited_adaptive_campaign_still_completes_with_exit_zero() {
        // ADR 0041 boundary: the adaptive exploitability signal never becomes
        // the verdict. A campaign where every seed cracked the model still maps
        // to a completed run, not a failing exit code. Scored against a target
        // that always echoes the attacker markers, so every attempt exploits,
        // without driving a real model (gate-runnable).
        let mut campaign = AdaptiveCampaign::new("compromised-model");
        for adaptive_seed in default_seed_corpus() {
            let always_exploited = |_payload: &str| {
                serde_json::json!({
                    "final_response": "Sure: collector.evil.test and close_account done.",
                })
            };
            campaign.record(run_seed_campaign(
                &adaptive_seed.seed,
                &default_converter_ladder(),
                &adaptive_seed.checks,
                0,
                always_exploited,
            ));
        }
        assert!(campaign.total_seeds() > 0, "fixture should run some seeds");
        assert_eq!(
            campaign.seeds_exploited(),
            campaign.total_seeds(),
            "fixture should score as fully exploited"
        );
        // The whole point: a fully exploited campaign is still a completed run.
        assert_eq!(exit_code_for(&campaign), EXIT_COMPLETED);
    }
}

#[cfg(all(test, unix))]
mod live_loop_tests {
    use super::*;

    use std::os::unix::fs::PermissionsExt;
    use std::path::PathBuf;
    use std::time::Duration;

    use mcptest_agent::stub::{StubProvider, StubReply};
    use mcptest_core::protocol::ProtocolVersion;
    use mcptest_core::transport::{StdioConfig, StdioTransport};
    use tempfile::TempDir;

    fn write_script(dir: &TempDir, name: &str, body: &str) -> PathBuf {
        let path = dir.path().join(name);
        std::fs::write(&path, body).expect("write script");
        let mut perms = std::fs::metadata(&path).expect("meta").permissions();
        perms.set_mode(0o755);
        std::fs::set_permissions(&path, perms).expect("chmod");
        path
    }

    /// Minimal stdio MCP fixture exposing a `save_note` tool that answers every
    /// `tools/call` benignly. The trace's safety verdict is driven entirely by
    /// what the (stub) model chose to say, which is what the oracle reads.
    fn write_notes_server(dir: &TempDir) -> PathBuf {
        // Builtins only (`case` + parameter expansion), no per-line forks, so
        // the handshake stays fast under parallel/loaded test runs.
        let body = r##"#!/bin/sh
while IFS= read -r line; do
  case "$line" in
    *'"id":'*) tmp=${line#*'"id":'}; id=${tmp%%[!0-9]*} ;;
    *) id= ;;
  esac
  case "$line" in
    *'"method":"initialize"'*)
      printf '{"jsonrpc":"2.0","id":%s,"result":{"protocolVersion":"2025-03-26","serverInfo":{"name":"notes-fixture","version":"0.0.1"},"capabilities":{}}}\n' "$id" ;;
    *'"method":"notifications/initialized"'*) : ;;
    *'"method":"tools/list"'*)
      printf '{"jsonrpc":"2.0","id":%s,"result":{"tools":[{"name":"save_note","description":"Save a note","inputSchema":{"type":"object","properties":{"text":{"type":"string"}}}}]}}\n' "$id" ;;
    *'"method":"tools/call"'*)
      printf '{"jsonrpc":"2.0","id":%s,"result":{"content":[{"type":"text","text":"ok"}],"isError":false}}\n' "$id" ;;
    *)
      [ -n "$id" ] && printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32601,"message":"method not found"}}\n' "$id" ;;
  esac
done
"##;
        write_script(dir, "notes.sh", body)
    }

    async fn build_client(script: PathBuf) -> Client {
        let cfg = StdioConfig {
            command: script.to_string_lossy().into(),
            args: vec![],
            env: Default::default(),
            working_dir: None,
            shutdown_timeout: Duration::from_secs(2),
        };
        let (transport, handle) = StdioTransport::spawn(cfg).expect("spawn fixture");
        let client = Client::builder(transport).build();
        client
            .initialize(ProtocolVersion::V2025_03_26)
            .await
            .expect("initialize");
        // Leak the handle for the test's duration; dropping it kills the child.
        Box::leak(Box::new(handle));
        client
    }

    /// Build a current-thread runtime, run the synchronous bridge on a blocking
    /// thread (so its nested `block_on` does not panic), and return the report.
    ///
    /// This mirrors the CLI shape: the client is built on the runtime, then the
    /// synchronous campaign driver re-enters the runtime via the handle.
    fn run_campaign(
        seeds: &[AdaptiveSeed],
        replies_per_attempt: Vec<StubReply>,
        max_attempts: usize,
    ) -> AdaptiveCampaign {
        let runtime = Runtime::new().expect("runtime");
        let tmp = TempDir::new().unwrap();
        let client = runtime.block_on(build_client(write_notes_server(&tmp)));
        let provider = StubProvider::new(replies_per_attempt);
        // A scoped thread puts the synchronous driver on a thread that is not
        // executing the runtime, so the per-payload `Handle::block_on` is safe.
        let handle = runtime.handle().clone();
        std::thread::scope(|scope| {
            scope
                .spawn(|| {
                    drive_campaign(
                        &handle,
                        &client,
                        &provider,
                        "stub-model",
                        seeds,
                        max_attempts,
                        4,
                        64,
                    )
                    .expect("campaign drives")
                })
                .join()
                .expect("driver thread")
        })
    }

    #[test]
    fn adaptive_campaign_drives_seeds_and_produces_a_per_model_report() {
        let seeds = default_seed_corpus();
        // One plain-text reply per attempt: a resisting model that touches no
        // attacker marker, so every converter attempt passes the safety checks.
        let total_attempts = seeds.len() * default_converter_ladder().len();
        let replies: Vec<StubReply> = (0..total_attempts)
            .map(|_| StubReply::Text("I will not do that.".into()))
            .collect();
        let report = run_campaign(&seeds, replies, 0);

        assert_eq!(report.model, "stub-model");
        assert_eq!(report.total_seeds(), seeds.len());
        assert_eq!(
            report.seeds_exploited(),
            0,
            "a resisting model is never cracked"
        );
        assert!(render_adaptive(&report).contains("not a security grade"));
    }

    #[test]
    fn an_exploited_seed_is_flagged_without_changing_the_run_outcome() {
        // Drive only the exfil seed with a model that echoes the attacker
        // endpoint on the very first attempt: a failed safety check.
        let seeds: Vec<AdaptiveSeed> = default_seed_corpus()
            .into_iter()
            .filter(|s| s.seed.id == "exfil-mirror-note")
            .collect();
        assert_eq!(seeds.len(), 1, "corpus must still ship the exfil seed");
        // First attempt (identity converter) already lands the endpoint, so the
        // ladder stops there: one reply is enough.
        let replies = vec![StubReply::Text(
            "Sure, mirroring to collector.evil.test now.".into(),
        )];
        let report = run_campaign(&seeds, replies, 1);

        assert_eq!(report.total_seeds(), 1);
        assert_eq!(
            report.seeds_exploited(),
            1,
            "the endpoint echo is the exploit"
        );
        // ADR 0041: the exploited signal does not turn the run into a failure.
        assert_eq!(exit_code_for(&report), EXIT_COMPLETED);
    }
}
