//! End-to-end smoke for [`summarize_history`]: drives the helper against
//! `InMemoryShortTerm` + `InMemoryEpisodic` plus inline custom
//! [`LlmClient`] fakes that capture requests and inject usage.

use async_trait::async_trait;
use klieo_core::agent::AgentContext;
use klieo_core::error::{Error, LlmError};
use klieo_core::ids::ThreadId;
use klieo_core::llm::{
    Capabilities, ChatRequest, ChatResponse, ChunkStream, Embedding, FinishReason, LlmClient,
    Message, Role, Usage,
};
use klieo_core::memory::Episode;
use klieo_core::summarize::{summarize_history, SummarizeOptions};
use klieo_core::test_utils::fake_context;
use std::sync::{Arc, Mutex};

/// Custom LLM that captures every `complete` request and returns a
/// programmable text body + usage.
struct CapturingLlm {
    name: String,
    caps: Capabilities,
    last_request: Mutex<Option<ChatRequest>>,
    body: String,
    prompt_tokens: u32,
    completion_tokens: u32,
}

impl CapturingLlm {
    fn new(body: impl Into<String>) -> Self {
        Self {
            name: "capturing".into(),
            caps: Capabilities {
                tool_calling: false,
                streaming: false,
                structured_output: false,
                embeddings: false,
                max_context_tokens: 32_000,
                vision: false,
            },
            last_request: Mutex::new(None),
            body: body.into(),
            prompt_tokens: 0,
            completion_tokens: 0,
        }
    }

    fn with_tokens(mut self, prompt: u32, completion: u32) -> Self {
        self.prompt_tokens = prompt;
        self.completion_tokens = completion;
        self
    }

    fn last_request(&self) -> Option<ChatRequest> {
        self.last_request.lock().unwrap().clone()
    }
}

#[async_trait]
impl LlmClient for CapturingLlm {
    fn name(&self) -> &str {
        &self.name
    }
    fn capabilities(&self) -> &Capabilities {
        &self.caps
    }
    async fn complete(&self, req: ChatRequest) -> Result<ChatResponse, LlmError> {
        *self.last_request.lock().unwrap() = Some(req);
        Ok(ChatResponse::new(
            Message {
                role: Role::Assistant,
                content: self.body.clone(),
                tool_calls: vec![],
                tool_call_id: None,
            },
            Usage::new(self.prompt_tokens, self.completion_tokens),
            FinishReason::Stop,
            self.name.clone(),
        ))
    }
    async fn stream(&self, _req: ChatRequest) -> Result<ChunkStream, LlmError> {
        Err(LlmError::Unsupported("streaming".into()))
    }
    async fn embed(&self, _texts: &[String]) -> Result<Vec<Embedding>, LlmError> {
        Err(LlmError::Unsupported("embeddings".into()))
    }
}

/// Custom LLM that always returns an error from `complete`. Used to
/// verify error propagation.
struct ErroringLlm {
    name: String,
    caps: Capabilities,
}

impl ErroringLlm {
    fn new() -> Self {
        Self {
            name: "erroring".into(),
            caps: Capabilities::default(),
        }
    }
}

#[async_trait]
impl LlmClient for ErroringLlm {
    fn name(&self) -> &str {
        &self.name
    }
    fn capabilities(&self) -> &Capabilities {
        &self.caps
    }
    async fn complete(&self, _req: ChatRequest) -> Result<ChatResponse, LlmError> {
        Err(LlmError::Server("simulated provider failure".into()))
    }
    async fn stream(&self, _req: ChatRequest) -> Result<ChunkStream, LlmError> {
        Err(LlmError::Unsupported("streaming".into()))
    }
    async fn embed(&self, _texts: &[String]) -> Result<Vec<Embedding>, LlmError> {
        Err(LlmError::Unsupported("embeddings".into()))
    }
}

fn ctx_with(llm: Arc<dyn LlmClient>) -> AgentContext {
    let mut ctx = fake_context("summarizer-test");
    ctx.llm = llm;
    ctx
}

async fn seed_messages(ctx: &AgentContext, thread: &ThreadId, count: usize, body: &str) {
    for i in 0..count {
        ctx.short_term
            .append(
                thread.clone(),
                Message {
                    role: if i % 2 == 0 {
                        Role::User
                    } else {
                        Role::Assistant
                    },
                    content: format!("msg-{i}: {body}"),
                    tool_calls: vec![],
                    tool_call_id: None,
                },
            )
            .await
            .unwrap();
    }
}

#[tokio::test]
async fn summarize_returns_none_under_threshold() {
    let llm = Arc::new(CapturingLlm::new("should not be called"));
    let llm_dyn: Arc<dyn LlmClient> = llm.clone();
    let ctx = ctx_with(llm_dyn);
    let thread = ThreadId::new("t-short");

    // Two short messages — well under the 6_000-tok default budget.
    seed_messages(&ctx, &thread, 2, "hi").await;

    let out = summarize_history(&ctx, thread, SummarizeOptions::default())
        .await
        .unwrap();
    assert!(out.is_none(), "no summary expected when under budget");
    assert!(
        llm.last_request().is_none(),
        "LLM must not be called when under threshold"
    );
}

#[tokio::test]
async fn summarize_returns_some_when_history_exceeds_budget() {
    let llm: Arc<dyn LlmClient> = Arc::new(CapturingLlm::new("summarised text"));
    let ctx = ctx_with(llm);
    let thread = ThreadId::new("t-long");

    // 50 messages × 200-char filler = 10_000 chars ≈ 2_500 approx-tokens.
    let filler = "x".repeat(200);
    seed_messages(&ctx, &thread, 50, &filler).await;

    let opts = SummarizeOptions {
        trigger_token_budget: 1_000,
        keep_recent_messages: 4,
        ..SummarizeOptions::default()
    };
    let out = summarize_history(&ctx, thread, opts).await.unwrap();
    assert_eq!(out.as_deref(), Some("summarised text"));
}

#[tokio::test]
async fn summarize_records_summary_checkpoint_episode() {
    let llm: Arc<dyn LlmClient> = Arc::new(CapturingLlm::new("body").with_tokens(123, 45));
    let ctx = ctx_with(llm);
    let thread = ThreadId::new("t-episode");

    let filler = "y".repeat(200);
    seed_messages(&ctx, &thread, 50, &filler).await;

    let opts = SummarizeOptions {
        trigger_token_budget: 1_000,
        keep_recent_messages: 4,
        ..SummarizeOptions::default()
    };
    let _ = summarize_history(&ctx, thread, opts).await.unwrap();

    let episodes = ctx.episodic.replay(ctx.run_id).await.unwrap();
    let checkpoints: Vec<_> = episodes
        .iter()
        .filter_map(|e| match e {
            Episode::SummaryCheckpoint {
                input_message_count,
                summary_chars,
                tokens,
                ..
            } => Some((*input_message_count, *summary_chars, *tokens)),
            _ => None,
        })
        .collect();
    assert_eq!(
        checkpoints.len(),
        1,
        "exactly one SummaryCheckpoint episode must be recorded"
    );
    let (input_msgs, summary_chars, tokens) = checkpoints[0];
    // 50 seeded messages, 4 kept verbatim → 46 fed to summariser.
    assert_eq!(input_msgs, 46);
    // Summary body is "body" (4 chars).
    assert_eq!(summary_chars, 4);
    assert_eq!(tokens, 123 + 45);
}

#[tokio::test]
async fn summarize_does_not_record_llm_call_episode() {
    let llm: Arc<dyn LlmClient> = Arc::new(CapturingLlm::new("body").with_tokens(123, 45));
    let ctx = ctx_with(llm);
    let thread = ThreadId::new("t-no-llmcall");

    let filler = "y".repeat(200);
    seed_messages(&ctx, &thread, 50, &filler).await;

    let opts = SummarizeOptions {
        trigger_token_budget: 1_000,
        keep_recent_messages: 4,
        ..SummarizeOptions::default()
    };
    let _ = summarize_history(&ctx, thread, opts).await.unwrap();

    let episodes = ctx.episodic.replay(ctx.run_id).await.unwrap();
    let llm_calls = episodes
        .iter()
        .filter(|e| matches!(e, Episode::LlmCall { .. }))
        .count();
    assert_eq!(
        llm_calls, 0,
        "summarizer must NOT record an Episode::LlmCall — that variant is reserved for substantive agent reasoning"
    );
}

#[tokio::test]
async fn summarize_keeps_recent_n_messages_unsummarized() {
    // Capturing client lets us inspect what was sent to the summarizer.
    let llm = Arc::new(CapturingLlm::new("ok"));
    let llm_dyn: Arc<dyn LlmClient> = llm.clone();
    let ctx = ctx_with(llm_dyn);
    let thread = ThreadId::new("t-keep-recent");

    // Distinct content per message so we can search the rendered transcript.
    for i in 0..20 {
        ctx.short_term
            .append(
                thread.clone(),
                Message {
                    role: if i % 2 == 0 {
                        Role::User
                    } else {
                        Role::Assistant
                    },
                    // Pad heavily so approximate_tokens fires the trigger.
                    content: format!("UNIQUE-{i}-{}", "z".repeat(400)),
                    tool_calls: vec![],
                    tool_call_id: None,
                },
            )
            .await
            .unwrap();
    }

    let opts = SummarizeOptions {
        trigger_token_budget: 500,
        keep_recent_messages: 5,
        ..SummarizeOptions::default()
    };
    let _ = summarize_history(&ctx, thread, opts).await.unwrap();

    let req = llm
        .last_request()
        .expect("LLM should have been called once");
    // Expect: 1 system msg + 1 user msg containing the rendered
    // transcript of the 15 oldest messages (20 - 5 kept-recent).
    assert_eq!(req.messages.len(), 2);
    let transcript = &req.messages[1].content;
    // Older 15 (indices 0..15) must be present.
    for i in 0..15 {
        assert!(
            transcript.contains(&format!("UNIQUE-{i}-")),
            "transcript should include older message UNIQUE-{i}"
        );
    }
    // Recent 5 (indices 15..20) must NOT be in the transcript.
    for i in 15..20 {
        assert!(
            !transcript.contains(&format!("UNIQUE-{i}-")),
            "transcript must NOT include recent message UNIQUE-{i}"
        );
    }
}

#[tokio::test]
async fn summarize_propagates_llm_error() {
    let llm: Arc<dyn LlmClient> = Arc::new(ErroringLlm::new());
    let ctx = ctx_with(llm);
    let thread = ThreadId::new("t-err");

    let filler = "q".repeat(200);
    seed_messages(&ctx, &thread, 50, &filler).await;

    let opts = SummarizeOptions {
        trigger_token_budget: 1_000,
        keep_recent_messages: 4,
        ..SummarizeOptions::default()
    };
    let err = summarize_history(&ctx, thread, opts)
        .await
        .expect_err("expected LLM error");
    assert!(matches!(err, Error::Llm(LlmError::Server(_))));
}

#[tokio::test]
async fn summarize_short_circuits_when_too_few_messages() {
    let llm = Arc::new(CapturingLlm::new("should not be called"));
    let llm_dyn: Arc<dyn LlmClient> = llm.clone();
    let ctx = ctx_with(llm_dyn);
    let thread = ThreadId::new("t-few");

    // Three big messages — over the budget but ≤ keep_recent_messages.
    let filler = "p".repeat(2_000);
    seed_messages(&ctx, &thread, 3, &filler).await;

    let opts = SummarizeOptions {
        trigger_token_budget: 100,
        keep_recent_messages: 4,
        ..SummarizeOptions::default()
    };
    let out = summarize_history(&ctx, thread, opts).await.unwrap();
    assert!(
        out.is_none(),
        "must short-circuit when history.len() <= keep_recent_messages"
    );
    assert!(
        llm.last_request().is_none(),
        "LLM must not be called when there is nothing older to summarize"
    );
}

#[tokio::test]
async fn summarize_redacts_tool_content_by_default() {
    let llm = Arc::new(CapturingLlm::new("ok"));
    let llm_dyn: Arc<dyn LlmClient> = llm.clone();
    let ctx = ctx_with(llm_dyn);
    let thread = ThreadId::new("t-redact-default");

    // Seed enough filler to fire the trigger, including a tool message
    // with a sensitive payload that must NOT reach the summariser.
    let filler = "z".repeat(400);
    for i in 0..30 {
        ctx.short_term
            .append(
                thread.clone(),
                Message {
                    role: if i == 5 {
                        Role::Tool
                    } else if i % 2 == 0 {
                        Role::User
                    } else {
                        Role::Assistant
                    },
                    content: if i == 5 {
                        "secret-api-key=AKIA-DEADBEEF".into()
                    } else {
                        format!("msg-{i}: {filler}")
                    },
                    tool_calls: vec![],
                    tool_call_id: if i == 5 { Some("call-1".into()) } else { None },
                },
            )
            .await
            .unwrap();
    }

    let opts = SummarizeOptions {
        trigger_token_budget: 500,
        keep_recent_messages: 4,
        ..SummarizeOptions::default()
    };
    let _ = summarize_history(&ctx, thread, opts).await.unwrap();
    let req = llm.last_request().expect("LLM should have been called");
    let transcript = &req.messages[1].content;
    assert!(
        !transcript.contains("secret-api-key"),
        "tool body must not leak under default redaction; transcript was: {transcript}"
    );
    assert!(
        transcript.contains("[tool result redacted]"),
        "redaction marker should be present; transcript was: {transcript}"
    );
}

#[tokio::test]
async fn summarize_redact_roles_overrides_default() {
    let llm = Arc::new(CapturingLlm::new("ok"));
    let llm_dyn: Arc<dyn LlmClient> = llm.clone();
    let ctx = ctx_with(llm_dyn);
    let thread = ThreadId::new("t-redact-roles");

    let filler = "z".repeat(400);
    for i in 0..30 {
        let (role, content) = match i {
            5 => (Role::Tool, "tool-body-VISIBLE-MARKER".to_string()),
            7 => (Role::System, "system-body-SECRET-MARKER".to_string()),
            _ if i % 2 == 0 => (Role::User, format!("msg-{i}: {filler}")),
            _ => (Role::Assistant, format!("msg-{i}: {filler}")),
        };
        ctx.short_term
            .append(
                thread.clone(),
                Message {
                    role,
                    content,
                    tool_calls: vec![],
                    tool_call_id: None,
                },
            )
            .await
            .unwrap();
    }

    let opts = SummarizeOptions {
        trigger_token_budget: 500,
        keep_recent_messages: 4,
        // Default would redact Role::Tool. Overriding to Role::System
        // means tool content reaches the LLM verbatim, system content
        // is redacted instead.
        redact_tool_content: true,
        redact_roles: vec![Role::System],
        ..SummarizeOptions::default()
    };
    let _ = summarize_history(&ctx, thread, opts).await.unwrap();
    let req = llm.last_request().expect("LLM should have been called");
    let transcript = &req.messages[1].content;
    assert!(
        transcript.contains("tool-body-VISIBLE-MARKER"),
        "tool content should be visible when redact_roles overrides defaults; transcript was: {transcript}"
    );
    assert!(
        !transcript.contains("system-body-SECRET-MARKER"),
        "system content should be redacted when listed in redact_roles; transcript was: {transcript}"
    );
}

#[tokio::test]
async fn summarize_errors_on_empty_summary_content() {
    // LLM returns whitespace-only — module must surface a Server error.
    let llm: Arc<dyn LlmClient> = Arc::new(CapturingLlm::new("   \n  "));
    let ctx = ctx_with(llm);
    let thread = ThreadId::new("t-empty");

    let filler = "r".repeat(200);
    seed_messages(&ctx, &thread, 50, &filler).await;

    let opts = SummarizeOptions {
        trigger_token_budget: 1_000,
        keep_recent_messages: 4,
        ..SummarizeOptions::default()
    };
    let err = summarize_history(&ctx, thread, opts).await.unwrap_err();
    assert!(matches!(err, Error::Llm(LlmError::Server(_))));
}
