//! Claude API client for LLM-generated function summaries.
//!
//! Uses `reqwest::blocking` to avoid nested tokio runtime issues
//! (the Store already uses `rt.block_on()`).
//!
//! The summary pass uses the Batches API for throughput (no RPM limit, 50% discount).
//! Individual summarize_chunk() is available for single-chunk fallback.
//!
//! Split into submodules by concern:
//! - `prompts` - prompt construction (summary, doc, HyDE)
//! - `batch` - batch submission, polling, result fetching
//! - `summary` - llm_summary_pass orchestration
//! - `doc_comments` - doc comment generation pass + needs_doc_comment
//! - `hyde` - HyDE query prediction pass

mod batch;
mod doc_comments;
mod hyde;
pub mod local;
mod prompts;
pub mod provider;
pub mod redirect;
mod summary;
pub mod validation;

/// Module-wide test mutex for serializing access to the `CQS_LLM_*` family of
/// process-global env vars. Each test that mutates any of `CQS_LLM_PROVIDER`,
/// `CQS_LLM_API_BASE`, `CQS_LLM_MODEL`, `CQS_LLM_API_KEY`, or
/// `CQS_LLM_ALLOW_INSECURE` must acquire this lock before its set_var calls
/// and hold it through the read-back so a sibling test can't see partial
/// state.
///
/// A single module-wide lock is required because multiple submodules
/// (`hyde::tests`, `doc_comments::tests`, `local::tests`) mutate the
/// `CQS_LLM_*` vars. Per-file `Mutex<()>` instances don't serialize across
/// files, so a test in one submodule could read a different
/// `CQS_LLM_API_BASE` than the one it set and panic on the read-back under
/// the parallel test runner.
///
/// Intentionally public-to-the-crate (`pub(crate)`) and `#[cfg(test)]`-gated
/// rather than scoped to a `tests` submodule so submodules can `use
/// crate::llm::LLM_ENV_LOCK` directly.
#[cfg(test)]
pub(crate) static LLM_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());

use std::time::Duration;

use serde::{Deserialize, Serialize};

// Re-export public API
pub use doc_comments::needs_doc_comment;
pub use hyde::hyde_query_pass;
pub use local::LocalProvider;
pub use provider::BatchProvider;
pub use summary::llm_summary_pass;

use provider::ProviderRegistry;

use crate::Store;

/// An eligible chunk ready for LLM batch processing.
///
/// Contains the fields needed by both summary and HyDE passes:
/// content_hash (dedup key), content, chunk_type string, and language string.
pub(crate) struct EligibleChunk {
    pub content_hash: String,
    pub content: String,
    pub chunk_type: String,
    pub language: String,
    pub signature: String,
    pub name: String,
}

/// Scan the store for callable chunks eligible for LLM processing.
///
/// Shared between `llm_summary_pass` and `hyde_query_pass` (and `find_contrastive_neighbors`).
/// Applies the 4-condition filter: skip cached (by `purpose`), skip non-callable,
/// skip short content, skip windowed (window_idx > 0). Deduplicates by content_hash.
///
/// Returns at most `max_items` chunks. Pass `0` for unlimited.
pub(crate) fn collect_eligible_chunks(
    store: &Store,
    purpose: &str,
    max_items: usize,
) -> Result<(Vec<EligibleChunk>, usize, usize), LlmError> {
    let _span = tracing::info_span!("collect_eligible_chunks", purpose, max_items).entered();

    let mut cached = 0usize;
    let mut skipped = 0usize;
    let mut cursor = 0i64;
    // Operator-tunable via `CQS_LLM_PASS_PAGE_SIZE`. Smaller page (50-100)
    // reduces peak heap on large repos; larger (1000+) reduces SQLite
    // round-trip overhead on fast SSDs.
    let page_size = std::env::var("CQS_LLM_PASS_PAGE_SIZE")
        .ok()
        .and_then(|v| v.parse::<usize>().ok())
        .filter(|n| *n > 0)
        .unwrap_or(500);
    let mut items: Vec<EligibleChunk> = Vec::new();
    let mut queued_hashes: std::collections::HashSet<String> = std::collections::HashSet::new();
    let effective_limit = if max_items == 0 {
        usize::MAX
    } else {
        max_items
    };

    let mut batch_full = false;
    loop {
        let (chunks, next) = store.chunks_paged(cursor, page_size)?;
        if chunks.is_empty() {
            break;
        }
        cursor = next;

        let hashes: Vec<&str> = chunks.iter().map(|c| c.content_hash.as_str()).collect();
        let existing = store.get_summaries_by_hashes(&hashes, purpose)?;

        for cs in &chunks {
            if existing.contains_key(&cs.content_hash) {
                cached += 1;
                continue;
            }
            // Summarize all code chunk types, not just callable ones.
            // Structs, enums, traits, impls, classes, constants etc. all
            // benefit from a one-line summary because the dual-index router
            // routes conceptual/behavioral queries to the base (non-summary)
            // index — and that routing has zero effect when the gold answer is
            // a struct that doesn't have a summary to strip in the first place.
            //
            // NOTE: this filter is shared with hyde_query_pass. The HyDE pass
            // is currently disabled (--hyde-queries default is false) and
            // research data (training-data/research/enrichment.md) shows HyDE
            // is net-negative across most categories, so we don't worry about
            // it here. If HyDE is ever re-enabled, split this gate per-purpose
            // because HyDE should NOT broaden to type definitions.
            if !cs.chunk_type.is_code() {
                skipped += 1;
                continue;
            }
            if cs.content.len() < MIN_CONTENT_CHARS {
                skipped += 1;
                continue;
            }
            if cs.window_idx.is_some_and(|idx| idx > 0) {
                skipped += 1;
                continue;
            }

            if queued_hashes.insert(cs.content_hash.clone()) {
                items.push(EligibleChunk {
                    content_hash: cs.content_hash.clone(),
                    content: cs.content.clone(),
                    chunk_type: cs.chunk_type.to_string(),
                    language: cs.language.to_string(),
                    signature: cs.signature.clone(),
                    name: cs.name.clone(),
                });
                if items.len() >= effective_limit {
                    batch_full = true;
                    break;
                }
            }
        }
        if batch_full {
            break;
        }
    }

    Ok((items, cached, skipped))
}

// doc_comment_pass returns Vec<crate::doc_writer::DocCommentResult>
pub use doc_comments::doc_comment_pass;

/// Typed error for LLM operations.
///
/// CLI callers convert to `anyhow::Error` at the boundary via the blanket `From`.
#[derive(Debug, thiserror::Error)]
pub enum LlmError {
    #[error("API key missing: {0}")]
    ApiKeyMissing(String),
    #[error("HTTP error: {0}")]
    Http(#[from] reqwest::Error),
    #[error("API error ({status}): {message}")]
    Api { status: u16, message: String },
    #[error("Batch failed: {0}")]
    BatchFailed(String),
    #[error("Invalid batch ID: {0}")]
    InvalidBatchId(String),
    /// Client-side rejection of a model name that doesn't match the
    /// provider's expected shape. Surfaced before any API roundtrip so
    /// wrong-provider/model combos fail fast.
    #[error("Invalid model for provider: {0}")]
    InvalidModel(String),
    /// Provider-side configuration error (empty model, missing endpoint, etc.).
    #[error("LLM configuration error: {message}")]
    Configuration { message: String },
    /// A `fetch_batch_results` call could not locate the requested
    /// `batch_id` in its in-memory stash. Distinguished from transient
    /// `Http`/`Api` errors so callers can decide whether to retry.
    #[error("batch not found: {0}")]
    BatchNotFound(String),
    #[error("JSON parse error: {0}")]
    Json(#[from] serde_json::Error),
    #[error("Store error: {0}")]
    Store(#[from] crate::store::StoreError),
    #[error("IO error: {0}")]
    Io(#[from] std::io::Error),
}

const API_BASE: &str = "https://api.anthropic.com/v1";
const API_VERSION: &str = "2023-06-01";
const MODEL: &str = "claude-haiku-4-5";
const MAX_TOKENS: u32 = 100;
/// Max content chars for LLM prompts.
/// Configurable via `CQS_LLM_MAX_CONTENT_CHARS` environment variable.
fn max_content_chars() -> usize {
    static SIZE: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
    *SIZE.get_or_init(|| {
        match std::env::var("CQS_LLM_MAX_CONTENT_CHARS") {
            Ok(val) => match val.parse::<usize>() {
                Ok(n) if n > 0 => {
                    tracing::info!(max_chars = n, "CQS_LLM_MAX_CONTENT_CHARS override");
                    n
                }
                _ => {
                    tracing::warn!(value = %val, "Invalid CQS_LLM_MAX_CONTENT_CHARS, using default 8000");
                    8000
                }
            },
            Err(_) => 8000,
        }
    })
}
const MIN_CONTENT_CHARS: usize = 50;
// Batch size comes from `crate::limits::llm_max_batch_size()` so the env
// override `CQS_LLM_MAX_BATCH_SIZE` takes effect.
/// Max tokens for HyDE query predictions (3-5 short queries).
const HYDE_MAX_TOKENS: u32 = 150;
/// Poll interval for batch completion
const BATCH_POLL_INTERVAL: Duration = Duration::from_secs(10);

/// Default LLM provider when `CQS_LLM_PROVIDER` is unset or unknown.
const DEFAULT_PROVIDER: &str = "anthropic";

/// Resolved LLM configuration (env vars > config file > constants).
///
/// `provider` is the canonical name of one of the registries in
/// [`PROVIDERS`]. The factory [`create_client`] looks the entry up by
/// name; tests compare it as a plain `&str` (e.g. `"anthropic"`,
/// `"local"`).
///
/// The hand-written `Debug` impl redacts `api_base` userinfo before
/// emitting, so logging `LlmConfig` / `?config` never echoes a
/// `https://user:pass@host` shape to journald.
pub struct LlmConfig {
    pub provider: &'static str,
    pub api_base: String,
    pub model: String,
    pub max_tokens: u32,
    /// Max tokens for HyDE query predictions (default: 150).
    pub hyde_max_tokens: u32,
}

impl LlmConfig {
    /// Render `api_base` with any `user[:pass]@host` userinfo segment
    /// stripped. Use this in every `tracing::*` site instead of
    /// `%self.api_base` so userinfo never lands in journald.
    ///
    /// Returns `"[redacted]"` for unparseable URLs (so a misformatted
    /// env var doesn't accidentally leak the raw bytes through this
    /// helper either).
    pub fn redacted_api_base(&self) -> String {
        redact_userinfo(&self.api_base)
    }
}

impl std::fmt::Debug for LlmConfig {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("LlmConfig")
            .field("provider", &self.provider)
            .field("api_base", &self.redacted_api_base())
            .field("model", &self.model)
            .field("max_tokens", &self.max_tokens)
            .field("hyde_max_tokens", &self.hyde_max_tokens)
            .finish()
    }
}

/// Strip `user[:pass]@` userinfo from a URL, leaving scheme + host + path.
/// Returns `[redacted]` for inputs that don't look like a URL.
///
/// Per RFC 3986, userinfo precedes the FIRST `/` after the authority
/// (`scheme://userinfo@host/path`). The boundary search is confined to the
/// authority component, otherwise a URL like `https://api.com/path/@anchor`
/// (path-component `@`) would silently rewrite the redacted form's host to
/// the path fragment after `@`.
fn redact_userinfo(url: &str) -> String {
    if let Some(scheme_end) = url.find("://") {
        let after_scheme = &url[scheme_end + 3..];
        // Authority component ends at the first `/` (start of path),
        // `?` (start of query), or end of string.
        let auth_end = after_scheme.find(['/', '?']).unwrap_or(after_scheme.len());
        let authority = &after_scheme[..auth_end];
        let rest = &after_scheme[auth_end..];
        if let Some(at_pos) = authority.find('@') {
            // Strip userinfo from authority; preserve path/query unchanged.
            let host = &authority[at_pos + 1..];
            format!("{}://{}{}", &url[..scheme_end], host, rest)
        } else {
            // No userinfo in authority — return URL unchanged.
            url.to_string()
        }
    } else {
        "[redacted]".to_string()
    }
}

impl LlmConfig {
    /// Resolve config with priority: env vars > config file > hardcoded constants.
    ///
    /// Returns `Err` if `CQS_LLM_API_BASE` uses cleartext `http://` and the
    /// explicit opt-in `CQS_LLM_ALLOW_INSECURE=1` is not set. Prevents the
    /// API key from being sent in the clear on every call.
    ///
    /// This is the *single* gate for the cleartext-auth trust decision. Any
    /// `http://` base — loopback, RFC1918, or public — is refused here without
    /// `CQS_LLM_ALLOW_INSECURE=1`. Setting that flag means "I accept cleartext
    /// auth, send the header": downstream providers (`LocalProvider::new`)
    /// then honor the resolved config unconditionally and attach the bearer
    /// token, rather than re-deciding host trust and silently dropping it.
    pub fn resolve(config: &crate::config::Config) -> Result<Self, LlmError> {
        let _span = tracing::info_span!("resolve_llm_config").entered();

        let (api_base, api_base_source) = if let Ok(val) = std::env::var("CQS_LLM_API_BASE") {
            (val, "env:CQS_LLM_API_BASE")
        } else if let Ok(val) = std::env::var("CQS_API_BASE") {
            (val, "env:CQS_API_BASE")
        } else if let Some(val) = config.llm_api_base.clone() {
            (val, "config")
        } else {
            (API_BASE.to_string(), "default")
        };
        tracing::debug!(source = api_base_source, "api_base resolved");

        if !api_base.starts_with("https://") {
            // Strip userinfo from URL before logging to avoid leaking credentials
            let safe_url = api_base
                .find("://")
                .and_then(|scheme_end| {
                    let after_scheme = &api_base[scheme_end + 3..];
                    after_scheme.find('@').map(|at| {
                        format!(
                            "{}://***@{}",
                            &api_base[..scheme_end],
                            &after_scheme[at + 1..]
                        )
                    })
                })
                .unwrap_or_else(|| api_base.clone());
            // Cleartext http:// leaks the API key on every call. A `warn!`
            // alone is not enough — demand explicit opt-in via
            // CQS_LLM_ALLOW_INSECURE=1 for localhost testing.
            if api_base.starts_with("http://") {
                if std::env::var("CQS_LLM_ALLOW_INSECURE").as_deref() != Ok("1") {
                    tracing::error!(
                        api_base = %safe_url,
                        "CQS_LLM_API_BASE uses http:// (cleartext); refusing to send API key in clear. \
                         Set CQS_LLM_ALLOW_INSECURE=1 to override (e.g. for localhost testing)."
                    );
                    return Err(LlmError::Api {
                        status: 0,
                        message: format!(
                            "CQS_LLM_API_BASE uses http:// (cleartext): {}. API key would be sent in clear. \
                             Set CQS_LLM_ALLOW_INSECURE=1 to override (e.g. for localhost testing).",
                            safe_url
                        ),
                    });
                }
                tracing::warn!(
                    api_base = %safe_url,
                    "CQS_LLM_API_BASE is http://; CQS_LLM_ALLOW_INSECURE acknowledged — API key sent in clear"
                );
            } else {
                tracing::warn!(
                    api_base = %safe_url,
                    "LLM API base does not use HTTPS — API key will be sent in cleartext"
                );
            }
        }

        let provider: &'static str = match std::env::var("CQS_LLM_PROVIDER").ok().as_deref() {
            None => {
                tracing::debug!(
                    source = "default",
                    provider = DEFAULT_PROVIDER,
                    "provider resolved"
                );
                DEFAULT_PROVIDER
            }
            Some(name) => match PROVIDERS.iter().find(|p| p.name() == name) {
                Some(reg) => {
                    tracing::debug!(source = "env", provider = reg.name(), "provider resolved");
                    reg.name()
                }
                None => {
                    tracing::warn!(
                        provider = name,
                        "Unknown CQS_LLM_PROVIDER, defaulting to {}",
                        DEFAULT_PROVIDER
                    );
                    DEFAULT_PROVIDER
                }
            },
        };

        let (model, model_source) = if let Ok(val) = std::env::var("CQS_LLM_MODEL") {
            (val, "env:CQS_LLM_MODEL")
        } else if let Some(val) = config.llm_model.clone() {
            (val, "config")
        } else {
            (MODEL.to_string(), "default")
        };
        tracing::debug!(source = model_source, "model resolved");

        let (max_tokens, max_tokens_source) = match std::env::var("CQS_LLM_MAX_TOKENS") {
            Ok(s) => match s.parse::<u32>() {
                Ok(v) => (v, "env:CQS_LLM_MAX_TOKENS"),
                Err(e) => {
                    tracing::warn!(
                        value = %s,
                        error = %e,
                        "CQS_LLM_MAX_TOKENS is set but not a valid u32, falling back"
                    );
                    if let Some(v) = config.llm_max_tokens {
                        (v, "config")
                    } else {
                        (MAX_TOKENS, "default")
                    }
                }
            },
            Err(_) => {
                if let Some(v) = config.llm_max_tokens {
                    (v, "config")
                } else {
                    (MAX_TOKENS, "default")
                }
            }
        };
        tracing::debug!(source = max_tokens_source, "max_tokens resolved");

        let hyde_max_tokens = std::env::var("CQS_HYDE_MAX_TOKENS")
            .ok()
            .and_then(|v| v.parse().ok())
            .or(config.llm_hyde_max_tokens)
            .unwrap_or(HYDE_MAX_TOKENS);

        Ok(Self {
            provider,
            api_base,
            model,
            max_tokens,
            hyde_max_tokens,
        })
    }
}

/// Create a batch provider from the resolved config.
///
/// Single factory, provider-aware. Returns a boxed trait object so callers
/// don't need to care which provider is in use — all operations go through
/// the [`BatchProvider`] trait.
///
/// Looks up the matching [`ProviderRegistry`] in [`PROVIDERS`] and delegates
/// construction to its `build` method. Adding a provider is one impl + one
/// slice row — `resolve` and `create_client` stay untouched.
pub fn create_client(
    llm_config: LlmConfig,
    on_item: Option<provider::OnItemCallback>,
) -> Result<Box<dyn BatchProvider>, LlmError> {
    let _span = tracing::info_span!("create_client", provider = llm_config.provider).entered();
    let registry = PROVIDERS
        .iter()
        .find(|p| p.name() == llm_config.provider)
        .ok_or_else(|| {
            // `LlmConfig::resolve` always sets `provider` to a known
            // registry name (it falls back to DEFAULT_PROVIDER on an
            // unknown env var), so this branch only fires if a caller
            // hand-builds a `LlmConfig` with an unknown name.
            LlmError::Api {
                status: 0,
                message: format!("unknown LLM provider: {}", llm_config.provider),
            }
        })?;
    registry.build(llm_config, on_item)
}

/// Static registry of available LLM providers.
///
/// Adding a new provider means writing an impl for the trait and adding one
/// row here. `resolve()` and `create_client()` dispatch from this slice
/// without code changes elsewhere.
static PROVIDERS: &[&dyn ProviderRegistry] = &[&AnthropicRegistry, &LocalRegistry];

struct AnthropicRegistry;
impl ProviderRegistry for AnthropicRegistry {
    fn name(&self) -> &'static str {
        "anthropic"
    }
    fn build(
        &self,
        cfg: LlmConfig,
        _on_item: Option<provider::OnItemCallback>,
    ) -> Result<Box<dyn BatchProvider>, LlmError> {
        // Anthropic's Batches API uses fetch-at-end semantics; the per-item
        // streaming-persist callback only fires for the LocalProvider's
        // worker-pool fanout. Drop silently here.
        let api_key = std::env::var("ANTHROPIC_API_KEY").map_err(|_| {
            LlmError::ApiKeyMissing(
                "ANTHROPIC_API_KEY environment variable required for LLM features".to_string(),
            )
        })?;
        Ok(Box::new(LlmClient::new(&api_key, cfg)?))
    }
}

struct LocalRegistry;
impl ProviderRegistry for LocalRegistry {
    fn name(&self) -> &'static str {
        "local"
    }
    fn build(
        &self,
        cfg: LlmConfig,
        on_item: Option<provider::OnItemCallback>,
    ) -> Result<Box<dyn BatchProvider>, LlmError> {
        // Local provider needs an explicit endpoint URL and model name —
        // the Anthropic defaults do not apply. Validate here (before any
        // HTTP traffic) so misconfig surfaces with an actionable message.
        //
        // We validate against the *resolved* config: when `CQS_LLM_API_BASE`
        // or `CQS_LLM_MODEL` is left at the Anthropic default, that's a
        // misconfig for local use. Users must override both.
        if cfg.api_base == API_BASE {
            return Err(LlmError::ApiKeyMissing(
                "CQS_LLM_PROVIDER=local requires CQS_LLM_API_BASE. \
                 Set CQS_LLM_API_BASE=http://localhost:8080/v1 (or your server's URL)"
                    .to_string(),
            ));
        }
        if cfg.model == MODEL {
            return Err(LlmError::ApiKeyMissing(format!(
                "CQS_LLM_PROVIDER=local requires CQS_LLM_MODEL. \
                 Set CQS_LLM_MODEL=<your-model-name>; try curl {}/models to list available",
                cfg.api_base
            )));
        }
        let provider = local::LocalProvider::new(cfg)?;
        let provider = match on_item {
            Some(cb) => provider.with_on_item_complete(cb),
            None => provider,
        };
        Ok(Box::new(provider))
    }
}

/// Resolve `CQS_LOCAL_LLM_CONCURRENCY` from env, clamped to `[1, 16]`.
///
/// Default 4. Values ≤0 clamp to 1; values >16 clamp to 16.
///
/// The ceiling is 16 because local LLM endpoints (vLLM, llama.cpp, ollama,
/// lmstudio) bottleneck on GPU memory and KV cache scheduling well below 16
/// in-flight requests; values above that burn worker thread stacks
/// (default 2 MB × N) without any throughput gain.
///
/// Not memoised: local-provider knobs are read once at `LocalProvider::new`
/// time, not hot-path; tests that flip env vars need fresh reads.
pub(crate) fn local_concurrency() -> usize {
    match std::env::var("CQS_LOCAL_LLM_CONCURRENCY") {
        Ok(raw) => match raw.parse::<i64>() {
            Ok(n) => {
                let clamped = n.clamp(1, 16) as usize;
                if n != clamped as i64 {
                    tracing::warn!(
                        raw = n,
                        clamped,
                        "CQS_LOCAL_LLM_CONCURRENCY out of range [1,16], clamped"
                    );
                }
                clamped
            }
            Err(e) => {
                tracing::warn!(value = %raw, error = %e, "Invalid CQS_LOCAL_LLM_CONCURRENCY, using default 4");
                4
            }
        },
        Err(_) => 4,
    }
}

/// Resolve `CQS_LOCAL_LLM_TIMEOUT_SECS` from env.
///
/// Default 120 (Anthropic uses 60). Must be a positive integer.
///
/// Not memoised: see [`local_concurrency`].
pub(crate) fn local_timeout() -> Duration {
    match std::env::var("CQS_LOCAL_LLM_TIMEOUT_SECS") {
        Ok(raw) => match raw.parse::<u64>() {
            Ok(n) if n > 0 => Duration::from_secs(n),
            _ => {
                tracing::warn!(
                    value = %raw,
                    "Invalid CQS_LOCAL_LLM_TIMEOUT_SECS, using default 120"
                );
                Duration::from_secs(120)
            }
        },
        Err(_) => Duration::from_secs(120),
    }
}

/// Claude API client for generating summaries.
pub struct LlmClient {
    http: reqwest::blocking::Client,
    api_key: String,
    llm_config: LlmConfig,
}

impl LlmClient {
    /// Creates a new LLM client instance with the specified API key and configuration.
    ///
    /// Initializes an HTTP client with a 60-second timeout and disables automatic redirect following. The API key is stored for use in subsequent requests.
    ///
    /// # Arguments
    ///
    /// * `api_key` - The API key for authenticating requests to the LLM service
    /// * `llm_config` - Configuration settings for the LLM client behavior
    ///
    /// # Returns
    ///
    /// A new `Self` instance ready to make LLM requests, or an `LlmError` if the HTTP client initialization fails.
    ///
    /// # Errors
    ///
    /// Returns an error if the underlying HTTP client cannot be constructed.
    pub fn new(api_key: &str, llm_config: LlmConfig) -> Result<Self, LlmError> {
        Ok(Self {
            http: reqwest::blocking::Client::builder()
                .timeout(Duration::from_secs(60))
                .redirect(reqwest::redirect::Policy::none())
                .build()?,
            api_key: api_key.to_string(),
            llm_config,
        })
    }
}

/// Validates whether a string is a properly formatted Anthropic batch ID.
///
/// Anthropic batch IDs start with `msgbatch_`, contain only ASCII
/// alphanumeric characters or underscores, and are under 100 characters.
///
/// Other providers may have different ID formats; this function is
/// Anthropic-specific. The [`BatchProvider::is_valid_batch_id`] trait
/// method dispatches to the correct provider validation.
fn is_valid_anthropic_batch_id(id: &str) -> bool {
    id.starts_with("msgbatch_")
        && id.len() < 100
        && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
}

// --- Messages API types ---

#[derive(Serialize)]
struct MessagesRequest {
    model: String,
    max_tokens: u32,
    messages: Vec<ChatMessage>,
}

#[derive(Serialize)]
struct ChatMessage {
    role: String,
    content: String,
}

#[derive(Deserialize)]
struct MessagesResponse {
    content: Vec<ContentBlock>,
}

#[derive(Deserialize)]
struct ContentBlock {
    #[serde(rename = "type")]
    block_type: String,
    text: Option<String>,
}

// --- Batches API types ---

#[derive(Serialize)]
struct BatchRequest {
    requests: Vec<BatchItem>,
}

#[derive(Serialize)]
struct BatchItem {
    custom_id: String,
    params: MessagesRequest,
}

#[derive(Deserialize)]
struct BatchResponse {
    id: String,
    processing_status: String,
}

#[derive(Deserialize)]
struct BatchResult {
    custom_id: String,
    result: BatchResultInner,
}

#[derive(Deserialize)]
struct BatchResultInner {
    #[serde(rename = "type")]
    result_type: String,
    message: Option<MessagesResponse>,
}

#[derive(Deserialize)]
struct ApiError {
    error: ApiErrorDetail,
}

#[derive(Deserialize)]
struct ApiErrorDetail {
    /// Anthropic's `error.message` is intentionally NOT logged anywhere — it
    /// can echo prompt fragments. Field still deserialized to keep
    /// parse-or-skip semantics if Anthropic ships a response missing `type`.
    #[allow(dead_code)]
    message: String,
    /// Structural error type (`invalid_request_error`,
    /// `authentication_error`, …). Safe to log; does not echo input
    /// fragments. Optional because not every upstream populates it.
    #[serde(default)]
    r#type: String,
}

/// A summary entry ready for storage.
pub struct SummaryEntry {
    pub content_hash: String,
    pub summary: String,
    pub model: String,
}

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

    /// Verify both `LlmConfig::redacted_api_base()` and the hand-rolled
    /// `Debug` impl strip `user[:pass]@host` userinfo. A regression that
    /// exposed the userinfo would surface here AND in every downstream
    /// `tracing::*` site at once (those all route through `redacted_api_base`).
    #[test]
    fn redacted_api_base_strips_userinfo() {
        let cfg = LlmConfig {
            provider: "anthropic",
            api_base: "https://user:supersecret@vllm.internal/v1".to_string(),
            model: "test".to_string(),
            max_tokens: 100,
            hyde_max_tokens: 50,
        };
        let redacted = cfg.redacted_api_base();
        assert!(
            !redacted.contains("supersecret") && !redacted.contains("user:"),
            "redacted_api_base must strip userinfo; got {redacted}"
        );
        assert!(
            redacted.contains("vllm.internal"),
            "redacted_api_base must keep host; got {redacted}"
        );
        let debug_str = format!("{cfg:?}");
        assert!(
            !debug_str.contains("supersecret") && !debug_str.contains("user:"),
            "LlmConfig Debug must redact userinfo; got {debug_str}"
        );
    }

    /// `redact_userinfo` returns `[redacted]` for inputs that don't look
    /// like URLs so a misformatted env var doesn't accidentally leak its
    /// raw bytes through the helper.
    #[test]
    fn redact_userinfo_handles_non_url_inputs() {
        assert_eq!(redact_userinfo("not-a-url"), "[redacted]");
        assert_eq!(redact_userinfo(""), "[redacted]");
    }

    /// Happy path: a URL with userinfo in the authority has the userinfo
    /// stripped, but the path/query are preserved.
    #[test]
    fn redact_userinfo_strips_authority_userinfo() {
        assert_eq!(
            redact_userinfo("https://user:pass@host.com/path"),
            "https://host.com/path"
        );
        assert_eq!(
            redact_userinfo("https://user@host.com/foo?bar=1"),
            "https://host.com/foo?bar=1"
        );
        assert_eq!(redact_userinfo("https://user@host.com"), "https://host.com");
    }

    /// A URL with `@` in the path component (e.g. an HTTPS gateway routing
    /// pattern, or a URL pointing at a fragment anchor) must NOT have its
    /// host silently rewritten to the path fragment after `@`. The URL
    /// passes through unchanged because the `@` is in the path, not the
    /// authority.
    #[test]
    fn redact_userinfo_preserves_at_in_path() {
        assert_eq!(
            redact_userinfo("https://api.com/path/@anchor"),
            "https://api.com/path/@anchor"
        );
        assert_eq!(
            redact_userinfo("https://host.com/foo@bar"),
            "https://host.com/foo@bar"
        );
        // Mixed: userinfo in authority + `@` in path — only authority
        // userinfo gets stripped, path `@` stays put.
        assert_eq!(
            redact_userinfo("https://user@host.com/foo@bar"),
            "https://host.com/foo@bar"
        );
    }

    /// A URL with `@` in the query string likewise must not be
    /// misinterpreted as userinfo.
    #[test]
    fn redact_userinfo_preserves_at_in_query() {
        assert_eq!(
            redact_userinfo("https://host.com/path?email=user@example.com"),
            "https://host.com/path?email=user@example.com"
        );
    }

    // These tests use the module-wide `LLM_ENV_LOCK` so they serialize
    // against `hyde::tests`, `doc_comments::tests`, and `local::tests` under
    // cargo's parallel test runner.

    type SavedEnv = [Option<String>; 4];

    fn save_llm_env_vars() -> SavedEnv {
        [
            std::env::var("CQS_LLM_MODEL").ok(),
            std::env::var("CQS_API_BASE").ok(),
            std::env::var("CQS_LLM_API_BASE").ok(),
            std::env::var("CQS_LLM_MAX_TOKENS").ok(),
        ]
    }

    fn restore_llm_env_vars(saved: SavedEnv) {
        let names = [
            "CQS_LLM_MODEL",
            "CQS_API_BASE",
            "CQS_LLM_API_BASE",
            "CQS_LLM_MAX_TOKENS",
        ];
        for (name, val) in names.iter().zip(saved) {
            match val {
                Some(v) => std::env::set_var(name, v),
                None => std::env::remove_var(name),
            }
        }
    }

    #[test]
    fn is_valid_batch_id_accepts_real_ids() {
        assert!(is_valid_anthropic_batch_id("msgbatch_abc123"));
        assert!(is_valid_anthropic_batch_id(
            "msgbatch_0123456789abcdef_ABCDEF"
        ));
    }

    #[test]
    fn is_valid_batch_id_rejects_crafted() {
        assert!(!is_valid_anthropic_batch_id("../../v1/complete"));
        assert!(!is_valid_anthropic_batch_id(
            "msgbatch_abc?redirect=evil.com"
        ));
        assert!(!is_valid_anthropic_batch_id(""));
        assert!(!is_valid_anthropic_batch_id("not_a_batch"));
        assert!(!is_valid_anthropic_batch_id(
            &("msgbatch_".to_string() + &"a".repeat(200))
        ));
    }

    #[test]
    fn llm_config_defaults_from_empty_config() {
        let _lock = crate::llm::LLM_ENV_LOCK
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        let saved = save_llm_env_vars();
        std::env::remove_var("CQS_LLM_MODEL");
        std::env::remove_var("CQS_API_BASE");
        std::env::remove_var("CQS_LLM_API_BASE");
        std::env::remove_var("CQS_LLM_MAX_TOKENS");

        let config = crate::config::Config::default();
        let llm = LlmConfig::resolve(&config).unwrap();

        restore_llm_env_vars(saved);

        assert_eq!(llm.api_base, API_BASE);
        assert_eq!(llm.model, MODEL);
        assert_eq!(llm.max_tokens, MAX_TOKENS);
    }

    #[test]
    fn llm_config_from_config_file_fields() {
        let _lock = crate::llm::LLM_ENV_LOCK
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        let config = crate::config::Config {
            llm_model: Some("claude-sonnet-4-20250514".to_string()),
            llm_api_base: Some("https://custom.api/v1".to_string()),
            llm_max_tokens: Some(200),
            ..Default::default()
        };
        let llm = LlmConfig::resolve(&config).unwrap();
        assert_eq!(llm.model, "claude-sonnet-4-20250514");
        assert_eq!(llm.api_base, "https://custom.api/v1");
        assert_eq!(llm.max_tokens, 200);
    }

    #[test]
    fn llm_config_env_overrides_config_file() {
        let _lock = crate::llm::LLM_ENV_LOCK
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        let config = crate::config::Config {
            llm_model: Some("from-config".to_string()),
            llm_api_base: Some("https://from-config/v1".to_string()),
            llm_max_tokens: Some(200),
            ..Default::default()
        };

        // Set env vars (scoped to this test via unsafe + cleanup)
        std::env::set_var("CQS_LLM_MODEL", "from-env");
        std::env::set_var("CQS_API_BASE", "https://from-env/v1");
        std::env::remove_var("CQS_LLM_API_BASE"); // ensure primary is clear
        std::env::set_var("CQS_LLM_MAX_TOKENS", "500");

        let llm = LlmConfig::resolve(&config).unwrap();

        // Clean up env vars
        std::env::remove_var("CQS_LLM_MODEL");
        std::env::remove_var("CQS_API_BASE");
        std::env::remove_var("CQS_LLM_MAX_TOKENS");

        assert_eq!(llm.model, "from-env");
        assert_eq!(llm.api_base, "https://from-env/v1");
        assert_eq!(llm.max_tokens, 500);
    }

    // CQS_LLM_API_BASE takes priority over CQS_API_BASE
    #[test]
    fn llm_config_llm_api_base_takes_precedence() {
        let _lock = crate::llm::LLM_ENV_LOCK
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        // Save all env vars that LlmConfig::resolve reads
        let saved = save_llm_env_vars();

        std::env::set_var("CQS_LLM_API_BASE", "https://primary/v1");
        std::env::set_var("CQS_API_BASE", "https://fallback/v1");
        std::env::remove_var("CQS_LLM_MODEL");
        std::env::remove_var("CQS_LLM_MAX_TOKENS");

        let config = crate::config::Config::default();
        let llm = LlmConfig::resolve(&config).unwrap();

        restore_llm_env_vars(saved);

        assert_eq!(
            llm.api_base, "https://primary/v1",
            "CQS_LLM_API_BASE should take precedence over CQS_API_BASE"
        );
    }

    // CQS_API_BASE still works as fallback
    #[test]
    fn llm_config_api_base_fallback_still_works() {
        let _lock = crate::llm::LLM_ENV_LOCK
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        let saved = save_llm_env_vars();

        std::env::remove_var("CQS_LLM_API_BASE");
        std::env::set_var("CQS_API_BASE", "https://legacy/v1");
        std::env::remove_var("CQS_LLM_MODEL");
        std::env::remove_var("CQS_LLM_MAX_TOKENS");

        let config = crate::config::Config::default();
        let llm = LlmConfig::resolve(&config).unwrap();

        restore_llm_env_vars(saved);

        assert_eq!(
            llm.api_base, "https://legacy/v1",
            "CQS_API_BASE should work as fallback when CQS_LLM_API_BASE is not set"
        );
    }

    // http:// is rejected unless CQS_LLM_ALLOW_INSECURE=1 is also set.
    #[test]
    fn llm_config_rejects_http_without_allow_insecure() {
        let _lock = crate::llm::LLM_ENV_LOCK
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        let saved = save_llm_env_vars();
        let saved_allow = std::env::var("CQS_LLM_ALLOW_INSECURE").ok();

        std::env::set_var("CQS_LLM_API_BASE", "http://localhost:8080/v1");
        std::env::remove_var("CQS_LLM_ALLOW_INSECURE");
        std::env::remove_var("CQS_API_BASE");
        std::env::remove_var("CQS_LLM_MODEL");
        std::env::remove_var("CQS_LLM_MAX_TOKENS");

        let config = crate::config::Config::default();
        let result = LlmConfig::resolve(&config);

        restore_llm_env_vars(saved);
        match saved_allow {
            Some(v) => std::env::set_var("CQS_LLM_ALLOW_INSECURE", v),
            None => std::env::remove_var("CQS_LLM_ALLOW_INSECURE"),
        }

        assert!(
            result.is_err(),
            "http:// LLM API base must be rejected without CQS_LLM_ALLOW_INSECURE=1"
        );
        let err_msg = result.unwrap_err().to_string();
        assert!(
            err_msg.contains("cleartext") || err_msg.contains("http://"),
            "error should mention cleartext http://, got: {}",
            err_msg
        );
    }

    // With CQS_LLM_ALLOW_INSECURE=1 the http:// override is allowed.
    #[test]
    fn llm_config_allows_http_with_allow_insecure() {
        let _lock = crate::llm::LLM_ENV_LOCK
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        let saved = save_llm_env_vars();
        let saved_allow = std::env::var("CQS_LLM_ALLOW_INSECURE").ok();

        std::env::set_var("CQS_LLM_API_BASE", "http://localhost:8080/v1");
        std::env::set_var("CQS_LLM_ALLOW_INSECURE", "1");
        std::env::remove_var("CQS_API_BASE");
        std::env::remove_var("CQS_LLM_MODEL");
        std::env::remove_var("CQS_LLM_MAX_TOKENS");

        let config = crate::config::Config::default();
        let result = LlmConfig::resolve(&config);

        restore_llm_env_vars(saved);
        match saved_allow {
            Some(v) => std::env::set_var("CQS_LLM_ALLOW_INSECURE", v),
            None => std::env::remove_var("CQS_LLM_ALLOW_INSECURE"),
        }

        assert!(
            result.is_ok(),
            "CQS_LLM_ALLOW_INSECURE=1 should permit http:// base"
        );
        assert_eq!(result.unwrap().api_base, "http://localhost:8080/v1");
    }

    #[test]
    fn llm_config_invalid_max_tokens_env_falls_through() {
        let _lock = crate::llm::LLM_ENV_LOCK
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        let config = crate::config::Config {
            llm_max_tokens: Some(300),
            ..Default::default()
        };

        std::env::set_var("CQS_LLM_MAX_TOKENS", "not_a_number");
        let llm = LlmConfig::resolve(&config).unwrap();
        std::env::remove_var("CQS_LLM_MAX_TOKENS");

        // Invalid env var should fall through to config value
        assert_eq!(llm.max_tokens, 300);
    }

    // ===== JSONL parsing tests =====

    /// Helper: parse JSONL body into a HashMap<custom_id, text>, replicating
    /// the inline logic from `LlmClient::fetch_batch_results`.
    fn parse_batch_results_jsonl(body: &str) -> std::collections::HashMap<String, String> {
        let mut results = std::collections::HashMap::new();
        for line in body.lines() {
            let line = line.trim();
            if line.is_empty() {
                continue;
            }
            if let Ok(result) = serde_json::from_str::<BatchResult>(line) {
                if result.result.result_type == "succeeded" {
                    if let Some(msg) = result.result.message {
                        let text = msg
                            .content
                            .into_iter()
                            .find(|b| b.block_type == "text")
                            .and_then(|b| b.text);
                        if let Some(s) = text {
                            let trimmed = s.trim().to_string();
                            if !trimmed.is_empty() {
                                results.insert(result.custom_id, trimmed);
                            }
                        }
                    }
                }
            }
        }
        results
    }

    #[test]
    fn parse_jsonl_succeeded_result() {
        let jsonl = r#"{"custom_id":"hash_abc","result":{"type":"succeeded","message":{"content":[{"type":"text","text":"Parses configuration files."}]}}}"#;
        let results = parse_batch_results_jsonl(jsonl);
        assert_eq!(results.len(), 1);
        assert_eq!(
            results.get("hash_abc").unwrap(),
            "Parses configuration files."
        );
    }

    #[test]
    fn parse_jsonl_filters_non_succeeded() {
        let jsonl = r#"{"custom_id":"hash_fail","result":{"type":"errored","message":null}}"#;
        let results = parse_batch_results_jsonl(jsonl);
        assert!(
            results.is_empty(),
            "Non-succeeded results should be filtered out"
        );
    }

    #[test]
    fn parse_jsonl_multiple_lines() {
        let jsonl = concat!(
            r#"{"custom_id":"h1","result":{"type":"succeeded","message":{"content":[{"type":"text","text":"First summary."}]}}}"#,
            "\n",
            r#"{"custom_id":"h2","result":{"type":"succeeded","message":{"content":[{"type":"text","text":"Second summary."}]}}}"#,
            "\n",
            r#"{"custom_id":"h3","result":{"type":"errored","message":null}}"#,
        );
        let results = parse_batch_results_jsonl(jsonl);
        assert_eq!(results.len(), 2);
        assert_eq!(results.get("h1").unwrap(), "First summary.");
        assert_eq!(results.get("h2").unwrap(), "Second summary.");
        assert!(!results.contains_key("h3"));
    }

    #[test]
    fn parse_jsonl_skips_empty_lines() {
        let jsonl = concat!(
            "\n",
            r#"{"custom_id":"h1","result":{"type":"succeeded","message":{"content":[{"type":"text","text":"Summary."}]}}}"#,
            "\n",
            "\n",
            "   \n",
        );
        let results = parse_batch_results_jsonl(jsonl);
        assert_eq!(results.len(), 1);
        assert_eq!(results.get("h1").unwrap(), "Summary.");
    }

    #[test]
    fn parse_jsonl_skips_invalid_json() {
        let jsonl = concat!(
            "not valid json\n",
            r#"{"custom_id":"h1","result":{"type":"succeeded","message":{"content":[{"type":"text","text":"Valid."}]}}}"#,
        );
        let results = parse_batch_results_jsonl(jsonl);
        assert_eq!(results.len(), 1);
        assert_eq!(results.get("h1").unwrap(), "Valid.");
    }

    #[test]
    fn parse_jsonl_trims_whitespace_text() {
        let jsonl = r#"{"custom_id":"h1","result":{"type":"succeeded","message":{"content":[{"type":"text","text":"  Trimmed summary.  "}]}}}"#;
        let results = parse_batch_results_jsonl(jsonl);
        assert_eq!(results.get("h1").unwrap(), "Trimmed summary.");
    }

    #[test]
    fn parse_jsonl_skips_empty_text() {
        let jsonl = r#"{"custom_id":"h1","result":{"type":"succeeded","message":{"content":[{"type":"text","text":"   "}]}}}"#;
        let results = parse_batch_results_jsonl(jsonl);
        assert!(results.is_empty(), "Whitespace-only text should be skipped");
    }

    #[test]
    fn parse_jsonl_finds_text_block_among_others() {
        // Content has a non-text block followed by a text block
        let jsonl = r#"{"custom_id":"h1","result":{"type":"succeeded","message":{"content":[{"type":"tool_use","text":null},{"type":"text","text":"Found it."}]}}}"#;
        let results = parse_batch_results_jsonl(jsonl);
        assert_eq!(results.get("h1").unwrap(), "Found it.");
    }

    #[test]
    fn parse_jsonl_no_message_on_succeeded() {
        // Succeeded but message is null — should produce no result
        let jsonl = r#"{"custom_id":"h1","result":{"type":"succeeded","message":null}}"#;
        let results = parse_batch_results_jsonl(jsonl);
        assert!(results.is_empty());
    }

    #[test]
    fn parse_jsonl_truncated_json() {
        // First line valid, second line truncated mid-JSON → only first result returned
        let jsonl = concat!(
            r#"{"custom_id":"h1","result":{"type":"succeeded","message":{"content":[{"type":"text","text":"Valid line."}]}}}"#,
            "\n",
            r#"{"custom_id":"h2","result":{"type":"succeeded","message":{"content":[{"type":"te"#,
        );
        let results = parse_batch_results_jsonl(jsonl);
        assert_eq!(
            results.len(),
            1,
            "Only the complete first line should parse"
        );
        assert_eq!(results.get("h1").unwrap(), "Valid line.");
        assert!(!results.contains_key("h2"));
    }

    #[test]
    fn parse_jsonl_unicode_in_summary() {
        // Summary contains CJK characters and emoji — should be preserved exactly
        let summary = "代码解析模块 🦀 parses Rust source files";
        let jsonl = format!(
            r#"{{"custom_id":"h1","result":{{"type":"succeeded","message":{{"content":[{{"type":"text","text":"{}"}}]}}}}}}"#,
            summary
        );
        let results = parse_batch_results_jsonl(&jsonl);
        assert_eq!(results.len(), 1);
        assert_eq!(results.get("h1").unwrap(), summary);
    }

    #[test]
    fn parse_jsonl_very_long_summary() {
        // 100k char summary → stored without truncation
        let long_text: String = "x".repeat(100_000);
        let jsonl = format!(
            r#"{{"custom_id":"h1","result":{{"type":"succeeded","message":{{"content":[{{"type":"text","text":"{}"}}]}}}}}}"#,
            long_text
        );
        let results = parse_batch_results_jsonl(&jsonl);
        assert_eq!(results.len(), 1);
        assert_eq!(results.get("h1").unwrap().len(), 100_000);
    }

    #[test]
    fn parse_jsonl_duplicate_custom_ids() {
        // Two lines with the same custom_id → HashMap keeps last (1 result)
        let jsonl = concat!(
            r#"{"custom_id":"same","result":{"type":"succeeded","message":{"content":[{"type":"text","text":"First."}]}}}"#,
            "\n",
            r#"{"custom_id":"same","result":{"type":"succeeded","message":{"content":[{"type":"text","text":"Second."}]}}}"#,
        );
        let results = parse_batch_results_jsonl(jsonl);
        assert_eq!(
            results.len(),
            1,
            "Duplicate custom_ids collapse to one entry"
        );
        assert_eq!(
            results.get("same").unwrap(),
            "Second.",
            "HashMap last-write-wins keeps the second entry"
        );
    }

    #[test]
    fn parse_jsonl_null_message_on_succeeded() {
        // "message":null on a succeeded result → no result stored
        // (This is the explicit adversarial variant — succeeded type but null message)
        let jsonl = r#"{"custom_id":"h1","result":{"type":"succeeded","message":null}}"#;
        let results = parse_batch_results_jsonl(jsonl);
        assert!(
            results.is_empty(),
            "succeeded + null message should produce no result"
        );
    }

    // ===== Local provider config sad-paths =====

    /// Helper to snapshot and restore the full local-provider env surface.
    type LocalEnv = [Option<String>; 6];

    fn save_local_env() -> LocalEnv {
        [
            std::env::var("CQS_LLM_PROVIDER").ok(),
            std::env::var("CQS_LLM_API_BASE").ok(),
            std::env::var("CQS_LLM_MODEL").ok(),
            std::env::var("CQS_LLM_ALLOW_INSECURE").ok(),
            std::env::var("CQS_API_BASE").ok(),
            std::env::var("CQS_LLM_API_KEY").ok(),
        ]
    }

    fn restore_local_env(saved: LocalEnv) {
        let names = [
            "CQS_LLM_PROVIDER",
            "CQS_LLM_API_BASE",
            "CQS_LLM_MODEL",
            "CQS_LLM_ALLOW_INSECURE",
            "CQS_API_BASE",
            "CQS_LLM_API_KEY",
        ];
        for (name, val) in names.iter().zip(saved) {
            match val {
                Some(v) => std::env::set_var(name, v),
                None => std::env::remove_var(name),
            }
        }
    }

    /// `CQS_LLM_PROVIDER=local` without `CQS_LLM_API_BASE` → `create_client`
    /// returns an actionable error before any HTTP traffic.
    #[test]
    fn local_provider_missing_api_base_errors() {
        let _lock = crate::llm::LLM_ENV_LOCK
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        let saved = save_local_env();

        std::env::set_var("CQS_LLM_PROVIDER", "local");
        std::env::remove_var("CQS_LLM_API_BASE");
        std::env::remove_var("CQS_API_BASE");
        std::env::set_var("CQS_LLM_MODEL", "my-model");

        let cfg = crate::config::Config::default();
        let llm_config = LlmConfig::resolve(&cfg).unwrap();
        let err = match create_client(llm_config, None) {
            Ok(_) => panic!("should reject missing API_BASE"),
            Err(e) => e,
        };

        restore_local_env(saved);

        let msg = err.to_string();
        assert!(
            msg.contains("CQS_LLM_API_BASE"),
            "error should name the missing var, got: {}",
            msg
        );
        assert!(
            msg.contains("localhost") || msg.contains("http"),
            "error should include actionable URL example, got: {}",
            msg
        );
    }

    /// `CQS_LLM_PROVIDER=local` without `CQS_LLM_MODEL` → actionable error.
    #[test]
    fn local_provider_missing_model_errors() {
        let _lock = crate::llm::LLM_ENV_LOCK
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        let saved = save_local_env();

        std::env::set_var("CQS_LLM_PROVIDER", "local");
        std::env::set_var("CQS_LLM_API_BASE", "https://localhost:8080/v1");
        std::env::remove_var("CQS_LLM_MODEL");

        let cfg = crate::config::Config::default();
        let llm_config = LlmConfig::resolve(&cfg).unwrap();
        let err = match create_client(llm_config, None) {
            Ok(_) => panic!("should reject missing MODEL"),
            Err(e) => e,
        };

        restore_local_env(saved);

        let msg = err.to_string();
        assert!(
            msg.contains("CQS_LLM_MODEL"),
            "error should name the missing var, got: {}",
            msg
        );
        assert!(
            msg.contains("/models") || msg.contains("model-name"),
            "error should include actionable guidance, got: {}",
            msg
        );
    }

    /// Local provider inherits the http:// rejection — opt-in required for
    /// cleartext bases.
    #[test]
    fn local_provider_rejects_http_without_allow_insecure() {
        let _lock = crate::llm::LLM_ENV_LOCK
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        let saved = save_local_env();

        std::env::set_var("CQS_LLM_PROVIDER", "local");
        std::env::set_var("CQS_LLM_API_BASE", "http://localhost:8080/v1");
        std::env::set_var("CQS_LLM_MODEL", "my-model");
        std::env::remove_var("CQS_LLM_ALLOW_INSECURE");
        std::env::remove_var("CQS_API_BASE");

        let cfg = crate::config::Config::default();
        let err = match LlmConfig::resolve(&cfg) {
            Ok(_) => panic!("should reject http:// without opt-in"),
            Err(e) => e,
        };

        restore_local_env(saved);

        let msg = err.to_string();
        assert!(
            msg.contains("cleartext") || msg.contains("http://"),
            "error should flag cleartext http, got: {}",
            msg
        );
    }

    /// Provider resolution: `CQS_LLM_PROVIDER=local` actually sets the variant.
    #[test]
    fn provider_resolves_local() {
        let _lock = crate::llm::LLM_ENV_LOCK
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        let saved = save_local_env();

        std::env::set_var("CQS_LLM_PROVIDER", "local");
        std::env::set_var("CQS_LLM_API_BASE", "https://example.com/v1");
        std::env::set_var("CQS_LLM_MODEL", "my-model");

        let cfg = crate::config::Config::default();
        let llm_config = LlmConfig::resolve(&cfg).unwrap();

        restore_local_env(saved);

        assert_eq!(llm_config.provider, "local");
    }

    /// Unknown CQS_LLM_PROVIDER falls back to the default with a
    /// `tracing::warn!`. Validates the fallback path drives off the registry,
    /// not a hand-coded match.
    #[test]
    fn provider_unknown_falls_back_to_default() {
        let _lock = crate::llm::LLM_ENV_LOCK
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        let saved = save_local_env();

        std::env::set_var("CQS_LLM_PROVIDER", "definitely-not-a-real-provider");
        std::env::remove_var("CQS_LLM_API_BASE");
        std::env::remove_var("CQS_API_BASE");
        std::env::remove_var("CQS_LLM_MODEL");

        let cfg = crate::config::Config::default();
        let llm_config = LlmConfig::resolve(&cfg).unwrap();

        restore_local_env(saved);

        assert_eq!(
            llm_config.provider, DEFAULT_PROVIDER,
            "unknown provider must fall back to DEFAULT_PROVIDER"
        );
    }

    /// PROVIDERS slice contains both built-in registries. Guards against
    /// accidental row deletion.
    #[test]
    fn providers_slice_has_anthropic_and_local() {
        let names: Vec<&'static str> = PROVIDERS.iter().map(|p| p.name()).collect();
        assert!(names.contains(&"anthropic"), "anthropic registry missing");
        assert!(names.contains(&"local"), "local registry missing");
    }
}
