//! `lobe explain` — AI-driven analysis of a captured session.
//!
//! Reads a `CaptureSessionExport` JSON file, preprocesses it into a compact summary,
//! and calls the Anthropic Messages API with a USE-method prompt. Returns the
//! rendered markdown report as a String.
//!
//! Key setup: reads `ANTHROPIC_API_KEY` from the process environment. If unset,
//! `explain_session` returns a `TloxError::MissingApiKey` error which the CLI
//! layer converts to a friendly onboarding message.

use std::collections::BTreeMap;
use std::path::Path;
use std::str::FromStr;
use std::time::Duration;

use reqwest::Client;
use serde::{Deserialize, Serialize};

use crate::engine::capture::{CaptureSessionExport, CapturedExchange};
use crate::error::{Result, TloxError};

const ANTHROPIC_API_URL: &str = "https://api.anthropic.com/v1/messages";
const ANTHROPIC_VERSION: &str = "2023-06-01";
const DEFAULT_MAX_TOKENS: u32 = 4096;

/// Which Claude model powers the analysis. Haiku is the default because the
/// task is structured USE-method application over a preprocessed payload, not
/// open-ended reasoning — Haiku handles it well at a fraction of the cost.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExplainModel {
    Haiku,
    Sonnet,
    Opus,
}

impl ExplainModel {
    /// The exact API model identifier sent in the request body.
    pub fn as_api_id(self) -> &'static str {
        match self {
            ExplainModel::Haiku => "claude-haiku-4-5-20251001",
            ExplainModel::Sonnet => "claude-sonnet-4-6",
            ExplainModel::Opus => "claude-opus-4-7",
        }
    }

    pub fn display_name(self) -> &'static str {
        match self {
            ExplainModel::Haiku => "Haiku 4.5",
            ExplainModel::Sonnet => "Sonnet 4.6",
            ExplainModel::Opus => "Opus 4.7",
        }
    }
}

impl FromStr for ExplainModel {
    type Err = String;
    fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
        match value.to_ascii_lowercase().as_str() {
            "haiku" => Ok(ExplainModel::Haiku),
            "sonnet" => Ok(ExplainModel::Sonnet),
            "opus" => Ok(ExplainModel::Opus),
            other => Err(format!(
                "unknown model '{other}' — expected haiku, sonnet, or opus"
            )),
        }
    }
}

#[derive(Debug, Clone)]
pub struct ExplainOptions {
    pub model: ExplainModel,
    /// Cap on how many raw sample requests we ship to the model. Aggregates are
    /// always included; this only controls the "example requests" section.
    pub max_samples: usize,
}

impl Default for ExplainOptions {
    fn default() -> Self {
        Self {
            model: ExplainModel::Haiku,
            max_samples: 15,
        }
    }
}

/// Load a session export from disk. Errors carry the underlying I/O or parse
/// failure so the CLI can print an actionable message.
pub fn load_session(path: &Path) -> Result<CaptureSessionExport> {
    let text = std::fs::read_to_string(path)?;
    let session: CaptureSessionExport = serde_json::from_str(&text)
        .map_err(|error| TloxError::Io(std::io::Error::other(error)))?;
    Ok(session)
}

/// Read the API key from environment. Empty string counts as missing.
pub fn read_api_key() -> Result<String> {
    match std::env::var("ANTHROPIC_API_KEY") {
        Ok(value) if !value.trim().is_empty() => Ok(value),
        _ => Err(TloxError::MissingApiKey),
    }
}

/// Estimate input tokens for cost warning. Very rough — 4 chars per token.
pub fn estimate_input_tokens(system: &str, user: &str) -> usize {
    (system.len() + user.len()) / 4
}

/// Top-level entry point. Reads env, loads session, builds prompt, calls
/// Anthropic, returns the markdown report.
pub async fn explain_session(
    session_path: &Path,
    options: ExplainOptions,
) -> Result<ExplainResult> {
    let api_key = read_api_key()?;
    let session = load_session(session_path)?;

    let summary = summarize_session(&session, &options);
    let user_payload = serde_json::to_string_pretty(&summary)
        .map_err(|error| TloxError::Io(std::io::Error::other(error)))?;
    let system_prompt = system_prompt();

    let estimated_input = estimate_input_tokens(&system_prompt, &user_payload);

    let markdown = call_anthropic(&api_key, options.model, &system_prompt, &user_payload).await?;

    Ok(ExplainResult {
        markdown,
        model: options.model,
        summary_stats: summary.session_meta,
        estimated_input_tokens: estimated_input,
    })
}

#[derive(Debug, Clone)]
pub struct ExplainResult {
    pub markdown: String,
    pub model: ExplainModel,
    pub summary_stats: SessionMeta,
    pub estimated_input_tokens: usize,
}

// ---------------------------------------------------------------------------
// Prompt building
// ---------------------------------------------------------------------------

fn system_prompt() -> String {
    r#"You are a senior performance engineer analyzing an HTTP capture produced by the `lobe` tool.

The user gives you a JSON summary of a captured session. Apply the **USE method** (Utilization, Saturation, Errors) — but investigate in this priority order for triage:

1. **Errors** — non-2xx responses and failed requests. Are they scoped to specific endpoints or global?
2. **Saturation** — signs the upstream is overloaded: high tail latencies (P99 much greater than P50), long TTFB values, download-phase dominance, connection reuse patterns.
3. **Utilization** — which endpoints consume the most cumulative wall-clock time across the session?

**Baseline thresholds** (rule of thumb — anything meaningfully over these is worth investigating):
- **Loopback** (localhost, 127.x): TTFB ≤ 50ms, total ≤ 80ms
- **LAN** (RFC1918 or *.local): TTFB ≤ 100ms, total ≤ 200ms
- **Remote** (public internet): TTFB ≤ 500ms, total ≤ 1.5s

**Phase interpretation cheatsheet:**
- DNS excess → resolver problem, cold DNS, custom /etc/hosts
- TCP excess → saturated network path, upstream overloaded
- TLS excess → old TLS version, deep certificate chain, slow ALPN
- TTFB excess → almost always slow DB queries, missing indexes, N+1, or CPU-bound work before the response starts writing (this is the most common finding)
- Download excess → large response body relative to bandwidth, or upstream streaming slowly

**Return format** — markdown, with these sections in this exact order:

## Summary
2-3 sentences of the headline findings. Lead with the number that matters most.

## Errors
Top error patterns. If zero errors, write "No errors observed." on a single line.

## Utilization
Which endpoints are burning the most cumulative time? Give specific method + path + numbers.

## Saturation
Tail behavior (P99 >> P50), TTFB dominance, bimodal patterns, phase excess. Cite specific numbers from the payload.

## Recommendations
Exactly 3–5 specific, actionable items ranked by expected impact. Each item: a concrete change, not general advice. Prefer "add an index on users.email (GET /api/users P99=340ms, TTFB dominates)" over "look at your database".

Be concrete. Cite specific endpoints and numbers. If evidence is thin, say so — do not fabricate causes."#
        .to_string()
}

// ---------------------------------------------------------------------------
// Session summarization — the compact JSON we ship to the model
// ---------------------------------------------------------------------------

#[derive(Debug, Clone, Serialize)]
pub struct SessionSummary {
    pub session_meta: SessionMeta,
    pub errors: ErrorsBlock,
    pub routes: Vec<RouteAggregate>,
    pub top_slowest_samples: Vec<SampleRequest>,
    pub top_error_samples: Vec<SampleRequest>,
}

#[derive(Debug, Clone, Serialize)]
pub struct SessionMeta {
    pub upstream: String,
    pub listen_addr: String,
    pub exported_at_ms: i64,
    pub total_requests: usize,
    pub error_requests: usize,
    pub error_rate_percent: f64,
    pub duration_seconds: f64,
    pub distinct_routes: usize,
    pub distinct_hosts: usize,
}

#[derive(Debug, Clone, Serialize)]
pub struct ErrorsBlock {
    pub total_errors: usize,
    pub top_status_codes: Vec<StatusCodeCount>,
}

#[derive(Debug, Clone, Serialize)]
pub struct StatusCodeCount {
    pub status_code: u16,
    pub count: usize,
}

#[derive(Debug, Clone, Serialize)]
pub struct RouteAggregate {
    pub method: String,
    pub path: String,
    pub host: String,
    pub baseline_category: &'static str,
    pub count: usize,
    pub errors: usize,
    pub total_ms_p50: u64,
    pub total_ms_p90: u64,
    pub total_ms_p99: u64,
    pub total_ms_max: u64,
    pub cumulative_ms: u64,
    pub phase_median_ms: PhaseMedians,
}

#[derive(Debug, Clone, Serialize)]
pub struct PhaseMedians {
    pub dns: u64,
    pub tcp: u64,
    pub tls: u64,
    pub ttfb: u64,
    pub download: u64,
}

#[derive(Debug, Clone, Serialize)]
pub struct SampleRequest {
    pub method: String,
    pub path: String,
    pub host: String,
    pub status: Option<u16>,
    pub total_ms: u64,
    pub ttfb_ms: u64,
    pub download_ms: u64,
    pub error: Option<String>,
}

pub fn summarize_session(session: &CaptureSessionExport, options: &ExplainOptions) -> SessionSummary {
    let events = &session.events;

    // Group by (method, path, host) — a "route" key.
    let mut groups: BTreeMap<(String, String, String), Vec<&CapturedExchange>> = BTreeMap::new();
    let mut status_counts: BTreeMap<u16, usize> = BTreeMap::new();
    let mut error_count = 0usize;
    let mut hosts: BTreeMap<String, ()> = BTreeMap::new();

    let mut earliest_ms = i64::MAX;
    let mut latest_ms = i64::MIN;

    for event in events {
        let key = (
            event.request_method.clone(),
            event.request_path.clone(),
            event.request_host.clone(),
        );
        groups.entry(key).or_default().push(event);
        hosts.insert(event.request_host.clone(), ());

        earliest_ms = earliest_ms.min(event.created_at_ms);
        latest_ms = latest_ms.max(event.created_at_ms);

        match event.response_status_code {
            Some(code) if !(200..300).contains(&code) => {
                *status_counts.entry(code).or_insert(0) += 1;
                error_count += 1;
            }
            None => {
                error_count += 1;
            }
            _ => {}
        }
    }

    let duration_seconds = if earliest_ms == i64::MAX || latest_ms == i64::MIN {
        0.0
    } else {
        ((latest_ms - earliest_ms).max(0) as f64) / 1000.0
    };

    let mut top_status_codes: Vec<StatusCodeCount> = status_counts
        .into_iter()
        .map(|(status_code, count)| StatusCodeCount { status_code, count })
        .collect();
    top_status_codes.sort_by(|a, b| b.count.cmp(&a.count));
    top_status_codes.truncate(6);

    let mut routes: Vec<RouteAggregate> = groups
        .iter()
        .map(|((method, path, host), items)| aggregate_route(method, path, host, items))
        .collect();

    // Rank routes by cumulative wall-clock time (utilization).
    routes.sort_by(|a, b| b.cumulative_ms.cmp(&a.cumulative_ms));
    routes.truncate(20);

    let top_slowest_samples = pick_samples(events, options.max_samples, SampleKind::Slowest);
    let top_error_samples = pick_samples(events, options.max_samples, SampleKind::Errors);

    SessionSummary {
        session_meta: SessionMeta {
            upstream: session.upstream.clone(),
            listen_addr: session.listen_addr.clone(),
            exported_at_ms: session.exported_at_ms,
            total_requests: events.len(),
            error_requests: error_count,
            error_rate_percent: if events.is_empty() {
                0.0
            } else {
                (error_count as f64 / events.len() as f64) * 100.0
            },
            duration_seconds,
            distinct_routes: groups.len(),
            distinct_hosts: hosts.len(),
        },
        errors: ErrorsBlock {
            total_errors: error_count,
            top_status_codes,
        },
        routes,
        top_slowest_samples,
        top_error_samples,
    }
}

fn aggregate_route(
    method: &str,
    path: &str,
    host: &str,
    items: &[&CapturedExchange],
) -> RouteAggregate {
    let mut totals: Vec<u64> = items.iter().map(|item| item.report.total_ms).collect();
    let mut dns: Vec<u64> = items.iter().map(|item| item.report.dns_ms).collect();
    let mut tcp: Vec<u64> = items.iter().map(|item| item.report.tcp_ms).collect();
    let mut tls: Vec<u64> = items.iter().map(|item| item.report.tls_ms).collect();
    let mut ttfb: Vec<u64> = items.iter().map(|item| item.report.ttfb_ms).collect();
    let mut download: Vec<u64> = items.iter().map(|item| item.report.download_ms).collect();

    totals.sort_unstable();
    dns.sort_unstable();
    tcp.sort_unstable();
    tls.sort_unstable();
    ttfb.sort_unstable();
    download.sort_unstable();

    let cumulative_ms = totals.iter().sum();
    let errors = items
        .iter()
        .filter(|item| match item.response_status_code {
            Some(code) => !(200..300).contains(&code),
            None => true,
        })
        .count();

    RouteAggregate {
        method: method.to_string(),
        path: path.to_string(),
        host: host.to_string(),
        baseline_category: classify_host(host),
        count: items.len(),
        errors,
        total_ms_p50: percentile(&totals, 50),
        total_ms_p90: percentile(&totals, 90),
        total_ms_p99: percentile(&totals, 99),
        total_ms_max: *totals.last().unwrap_or(&0),
        cumulative_ms,
        phase_median_ms: PhaseMedians {
            dns: percentile(&dns, 50),
            tcp: percentile(&tcp, 50),
            tls: percentile(&tls, 50),
            ttfb: percentile(&ttfb, 50),
            download: percentile(&download, 50),
        },
    }
}

/// Nearest-rank percentile on a *sorted* slice.
fn percentile(sorted: &[u64], p: u8) -> u64 {
    if sorted.is_empty() {
        return 0;
    }
    let rank = ((p as usize * sorted.len()).div_ceil(100)).max(1);
    sorted[rank.min(sorted.len()) - 1]
}

fn classify_host(host: &str) -> &'static str {
    if host == "localhost" || host == "::1" {
        return "loopback";
    }
    if let Some((a, b)) = ipv4_prefix(host) {
        if a == 127 {
            return "loopback";
        }
        if a == 10 {
            return "lan";
        }
        if a == 192 && b == 168 {
            return "lan";
        }
        if a == 172 && (16..=31).contains(&b) {
            return "lan";
        }
        return "remote";
    }
    if host.ends_with(".local") {
        return "lan";
    }
    "remote"
}

fn ipv4_prefix(host: &str) -> Option<(u8, u8)> {
    let mut parts = host.split('.');
    let a = parts.next()?.parse::<u8>().ok()?;
    let b = parts.next()?.parse::<u8>().ok()?;
    parts.next()?.parse::<u8>().ok()?;
    parts.next()?.parse::<u8>().ok()?;
    if parts.next().is_some() {
        return None;
    }
    Some((a, b))
}

enum SampleKind {
    Slowest,
    Errors,
}

fn pick_samples(events: &[CapturedExchange], limit: usize, kind: SampleKind) -> Vec<SampleRequest> {
    let mut filtered: Vec<&CapturedExchange> = match kind {
        SampleKind::Slowest => events.iter().collect(),
        SampleKind::Errors => events
            .iter()
            .filter(|item| match item.response_status_code {
                Some(code) => !(200..300).contains(&code),
                None => true,
            })
            .collect(),
    };
    match kind {
        SampleKind::Slowest => {
            filtered.sort_by(|a, b| b.report.total_ms.cmp(&a.report.total_ms));
        }
        SampleKind::Errors => {
            filtered.sort_by(|a, b| b.report.total_ms.cmp(&a.report.total_ms));
        }
    }
    filtered.truncate(limit);
    filtered.into_iter().map(sample_from_exchange).collect()
}

fn sample_from_exchange(event: &CapturedExchange) -> SampleRequest {
    SampleRequest {
        method: event.request_method.clone(),
        path: event.request_path.clone(),
        host: event.request_host.clone(),
        status: event.response_status_code,
        total_ms: event.report.total_ms,
        ttfb_ms: event.report.ttfb_ms,
        download_ms: event.report.download_ms,
        error: event.error_message.clone(),
    }
}

// ---------------------------------------------------------------------------
// Anthropic API call
// ---------------------------------------------------------------------------

#[derive(Debug, Serialize)]
struct AnthropicRequest<'a> {
    model: &'a str,
    max_tokens: u32,
    system: &'a str,
    messages: Vec<AnthropicMessage<'a>>,
}

#[derive(Debug, Serialize)]
struct AnthropicMessage<'a> {
    role: &'a str,
    content: &'a str,
}

#[derive(Debug, Deserialize)]
struct AnthropicResponse {
    content: Vec<AnthropicContentBlock>,
}

#[derive(Debug, Deserialize)]
#[serde(tag = "type")]
enum AnthropicContentBlock {
    #[serde(rename = "text")]
    Text { text: String },
    #[serde(other)]
    Other,
}

#[derive(Debug, Deserialize)]
struct AnthropicErrorEnvelope {
    error: AnthropicErrorPayload,
}

#[derive(Debug, Deserialize)]
struct AnthropicErrorPayload {
    #[serde(rename = "type")]
    kind: String,
    message: String,
}

async fn call_anthropic(
    api_key: &str,
    model: ExplainModel,
    system_prompt: &str,
    user_payload: &str,
) -> Result<String> {
    let request = AnthropicRequest {
        model: model.as_api_id(),
        max_tokens: DEFAULT_MAX_TOKENS,
        system: system_prompt,
        messages: vec![AnthropicMessage {
            role: "user",
            content: user_payload,
        }],
    };

    let body = serde_json::to_string(&request)
        .map_err(|error| TloxError::Io(std::io::Error::other(error)))?;

    let client = Client::builder()
        .timeout(Duration::from_secs(120))
        .build()
        .map_err(|error| TloxError::Io(std::io::Error::other(error)))?;

    let response = client
        .post(ANTHROPIC_API_URL)
        .header("x-api-key", api_key)
        .header("anthropic-version", ANTHROPIC_VERSION)
        .header("content-type", "application/json")
        .body(body)
        .send()
        .await
        .map_err(|error| TloxError::Io(std::io::Error::other(error)))?;

    let status = response.status();
    let text = response
        .text()
        .await
        .map_err(|error| TloxError::Io(std::io::Error::other(error)))?;

    if !status.is_success() {
        return Err(TloxError::AnthropicApi(format_api_error(status.as_u16(), &text)));
    }

    let parsed: AnthropicResponse = serde_json::from_str(&text).map_err(|error| {
        TloxError::AnthropicApi(format!(
            "unable to parse Anthropic response: {error}. Raw body: {}",
            truncate(&text, 500)
        ))
    })?;

    let joined = parsed
        .content
        .into_iter()
        .filter_map(|block| match block {
            AnthropicContentBlock::Text { text } => Some(text),
            AnthropicContentBlock::Other => None,
        })
        .collect::<Vec<_>>()
        .join("\n\n");

    if joined.trim().is_empty() {
        return Err(TloxError::AnthropicApi(
            "Anthropic returned an empty response".to_string(),
        ));
    }

    Ok(joined)
}

fn format_api_error(status: u16, body: &str) -> String {
    if let Ok(envelope) = serde_json::from_str::<AnthropicErrorEnvelope>(body) {
        return format!(
            "Anthropic API error {status} ({}): {}",
            envelope.error.kind, envelope.error.message
        );
    }
    format!("Anthropic API error {status}: {}", truncate(body, 500))
}

fn truncate(text: &str, limit: usize) -> String {
    if text.len() <= limit {
        text.to_string()
    } else {
        format!("{}…(truncated)", &text[..limit])
    }
}

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

    #[test]
    fn percentile_uses_nearest_rank() {
        let sorted = vec![1, 2, 3, 4, 5];
        assert_eq!(percentile(&sorted, 50), 3);
        assert_eq!(percentile(&sorted, 99), 5);
    }

    #[test]
    fn percentile_handles_empty() {
        let sorted: Vec<u64> = vec![];
        assert_eq!(percentile(&sorted, 50), 0);
    }

    #[test]
    fn classify_host_recognises_loopback_lan_and_remote() {
        assert_eq!(classify_host("localhost"), "loopback");
        assert_eq!(classify_host("127.0.0.1"), "loopback");
        assert_eq!(classify_host("192.168.1.169"), "lan");
        assert_eq!(classify_host("10.0.0.5"), "lan");
        assert_eq!(classify_host("172.16.5.1"), "lan");
        assert_eq!(classify_host("172.32.5.1"), "remote");
        assert_eq!(classify_host("api.example.com"), "remote");
        assert_eq!(classify_host("mymac.local"), "lan");
    }

    #[test]
    fn model_parses_from_string() {
        assert!(matches!(
            ExplainModel::from_str("haiku"),
            Ok(ExplainModel::Haiku)
        ));
        assert!(matches!(
            ExplainModel::from_str("Sonnet"),
            Ok(ExplainModel::Sonnet)
        ));
        assert!(ExplainModel::from_str("gpt-5").is_err());
    }
}
