//! MCP Resources catalog for algocline.
//!
//! Implements a `ResourceCatalog` that dispatches `alc://<service>/<path>`
//! URIs to the appropriate backing store. Fixed resources (static files) are
//! fully implemented here; template dispatch stubs will be filled in by
//! Subtask 2.

use std::collections::HashMap;
use std::sync::Arc;

use algocline_app::EngineApi;
use algocline_core::AppDir;
use rmcp::model::{
    Annotated, ListResourceTemplatesResult, ListResourcesResult, RawResource, RawResourceTemplate,
    ReadResourceResult, Resource, ResourceContents, ResourceTemplate,
};
use rmcp::ErrorData as McpError;

// ─── Known services ──────────────────────────────────────────────────────────

const KNOWN_SERVICES: &[&str] = &[
    "types",
    "packages",
    "cards",
    "scenarios",
    "eval",
    "logs",
    "hub",
];

// ─── URI parser ──────────────────────────────────────────────────────────────

/// Parsed representation of an `alc://` URI.
#[derive(Debug, PartialEq)]
pub struct ParsedUri {
    /// Service name (the first path component, e.g. `"types"`, `"cards"`).
    pub service: String,
    /// Remaining path segments after the service component.
    pub segments: Vec<String>,
    /// Parsed query parameters (key=value pairs, no URL-decoding needed for V1).
    pub query: HashMap<String, String>,
}

/// Errors produced when parsing an `alc://` URI.
#[derive(Debug, thiserror::Error)]
pub enum UriParseError {
    #[error("invalid scheme: expected alc://, got {0}")]
    Scheme(String),
    #[error("unknown service: {0}")]
    UnknownService(String),
    #[error("missing path segment in {uri}")]
    MissingSegment { uri: String },
    #[error("invalid query: {0}")]
    Query(String),
    #[error("path traversal segment rejected: {0}")]
    TraversalSegment(String),
}

/// Parse an `alc://<service>/<path>?<query>` URI.
///
/// V1 constraints:
/// - Scheme must be exactly `alc://`
/// - Service must be one of the known services
/// - At least one path segment after the service is required
/// - Query values are treated as raw strings (no URL-decoding)
pub fn parse_uri(s: &str) -> Result<ParsedUri, UriParseError> {
    // Strip the "alc://" scheme.
    let rest = s
        .strip_prefix("alc://")
        .ok_or_else(|| UriParseError::Scheme(s.to_string()))?;

    // Split path from optional query string.
    let (path_part, query_part) = match rest.split_once('?') {
        Some((p, q)) => (p, Some(q)),
        None => (rest, None),
    };

    // Split path into segments, filtering out empty strings produced by
    // trailing slashes (e.g. "cards/" → ["cards"]).
    let mut segments: Vec<String> = path_part
        .split('/')
        .filter(|s| !s.is_empty())
        .map(|s| s.to_string())
        .collect();

    // Reject path-traversal segments (defense-in-depth for `read_types` and
    // future template dispatch that joins segments into filesystem paths).
    for seg in &segments {
        if seg == ".." || seg == "." {
            return Err(UriParseError::TraversalSegment(seg.clone()));
        }
    }

    // First segment is the service name.
    if segments.is_empty() {
        return Err(UriParseError::MissingSegment { uri: s.to_string() });
    }
    let service = segments.remove(0);

    // Validate service.
    if !KNOWN_SERVICES.contains(&service.as_str()) {
        return Err(UriParseError::UnknownService(service));
    }

    // After removing the service, at least one more segment is required.
    if segments.is_empty() {
        return Err(UriParseError::MissingSegment { uri: s.to_string() });
    }

    // Parse the query string (if present).
    let query = parse_query(query_part.unwrap_or(""), s)?;

    Ok(ParsedUri {
        service,
        segments,
        query,
    })
}

/// Parse a raw query string (`key=value&key2=value2`).
///
/// Empty query strings produce an empty map. A key without a value
/// (e.g. `?=bad`) is rejected as malformed.
fn parse_query(qs: &str, full_uri: &str) -> Result<HashMap<String, String>, UriParseError> {
    let mut map = HashMap::new();
    if qs.is_empty() {
        return Ok(map);
    }
    for pair in qs.split('&') {
        if pair.is_empty() {
            continue;
        }
        match pair.split_once('=') {
            None => {
                // key without '=' — treat as bare key with empty value
                map.insert(pair.to_string(), String::new());
            }
            Some(("", _)) => {
                return Err(UriParseError::Query(format!(
                    "empty key in query of {full_uri}"
                )));
            }
            Some((k, v)) => {
                map.insert(k.to_string(), v.to_string());
            }
        }
    }
    Ok(map)
}

// ─── ResourceCatalog ─────────────────────────────────────────────────────────

/// Catalog that maps `alc://` URIs to MCP resource responses.
///
/// Fixed resources (e.g. `alc://types/alc.d.lua`) are backed by on-disk
/// files under `AppDir::types_dir()`. Template resources are dispatched via
/// the `EngineApi` trait and will be added in Subtask 2.
pub struct ResourceCatalog {
    app: Arc<dyn EngineApi>,
    app_dir: Arc<AppDir>,
}

impl ResourceCatalog {
    /// Construct a new catalog.
    ///
    /// `app` is retained for template dispatch (Subtask 2).
    /// `app_dir` is used for fixed file reads (types stubs).
    pub fn new(app: Arc<dyn EngineApi>, app_dir: Arc<AppDir>) -> Self {
        Self { app, app_dir }
    }

    /// Return the list of fixed (static) resources.
    ///
    /// Fixed resources are always listed even if the underlying file happens
    /// to be absent at list-time; a subsequent `read` for a missing file will
    /// return `McpError::invalid_params`. This matches MCP spec semantics.
    pub fn list_fixed(&self) -> Vec<Resource> {
        vec![
            make_resource(
                "alc://types/alc.d.lua",
                "alc.d.lua",
                Some("Lua Type Stub (alc.d.lua)"),
                "Lua type stubs for alc.* StdLib",
                "text/x-lua",
                None,
            ),
            make_resource(
                "alc://types/alc_shapes.d.lua",
                "alc_shapes.d.lua",
                Some("Lua Type Stub (alc_shapes.d.lua)"),
                "Lua type stubs for alc shapes",
                "text/x-lua",
                None,
            ),
            make_resource(
                "alc://hub/index",
                "hub-index",
                Some("Hub Aggregate Index"),
                "Aggregated package catalog merged across all installed hub sources.",
                "application/json",
                None,
            ),
        ]
    }

    /// Return the list of resource templates (URI template notation, RFC 6570 level 1).
    ///
    /// Returns the V1 templates plus the V2-bundled-narrative template
    /// (`packages/{name}/narrative`, #1777052474). Personal-package
    /// gendoc-output narrative is intentionally not included here —
    /// per-pkg type completion is provided by `alc_pkgs.d.lua`
    /// (#1777032565), and external narrative indexing belongs to
    /// services like Context7 rather than the algocline Resource scope.
    pub fn list_templates(&self) -> Vec<ResourceTemplate> {
        vec![
            make_template(
                "alc://packages/{name}/init.lua",
                "package-init-lua",
                Some("Package Lua Source"),
                "Lua source of an installed package",
                Some("text/x-lua"),
            ),
            make_template(
                "alc://packages/{name}/meta",
                "package-meta",
                Some("Package Metadata"),
                "Package metadata JSON (description, category, alc_shapes_compat)",
                Some("application/json"),
            ),
            make_template(
                "alc://packages/{name}/narrative",
                "package-narrative",
                Some("Package Narrative"),
                "Markdown narrative for a bundled package (Stdpkg reference)",
                Some("text/markdown"),
            ),
            make_template(
                "alc://cards/{card_id}",
                "card",
                Some("Card Snapshot"),
                "Immutable Card snapshot",
                Some("application/json"),
            ),
            make_template(
                "alc://cards/{card_id}/samples",
                "card-samples",
                Some("Card Samples"),
                "Per-case sample rows (paginate with ?offset=N&limit=M)",
                Some("application/json"),
            ),
            make_template(
                "alc://scenarios/{name}",
                "scenario",
                Some("Scenario Source"),
                "Scenario Lua source",
                Some("text/x-lua"),
            ),
            make_template(
                "alc://eval/{result_id}",
                "eval-result",
                Some("Eval Result"),
                "Eval result detail ({strategy}_{timestamp_secs} id)",
                Some("application/json"),
            ),
            make_template(
                "alc://logs/{session_id}",
                "session-log",
                Some("Session Log"),
                "Session log (paginate with ?limit=N&max_chars=M)",
                Some("application/json"),
            ),
        ]
    }

    /// Read a resource by URI.
    ///
    /// Fixed resources (`alc://types/...`) are backed by `AppDir::types_dir()`.
    /// Template resources are dispatched via `EngineApi`.
    pub async fn read(&self, uri: &str) -> Result<ReadResourceResult, McpError> {
        let parsed = parse_uri(uri).map_err(|e| McpError::invalid_params(e.to_string(), None))?;

        match parsed.service.as_str() {
            "types" => self.read_types(uri, &parsed),
            "packages" => self.read_packages(uri, &parsed).await,
            "cards" => self.read_cards(uri, &parsed).await,
            "scenarios" => self.read_scenarios(uri, &parsed).await,
            "eval" => self.read_eval(uri, &parsed).await,
            "logs" => self.read_logs(uri, &parsed).await,
            "hub" => self.read_hub(&parsed).await,
            other => Err(McpError::invalid_params(
                format!("unknown service: {other}"),
                None,
            )),
        }
    }

    // ── Private dispatch helpers ──────────────────────────────────────────

    fn read_types(&self, uri: &str, parsed: &ParsedUri) -> Result<ReadResourceResult, McpError> {
        if !parsed.query.is_empty() {
            return Err(McpError::invalid_params(
                format!("query params not supported on {uri}"),
                None,
            ));
        }
        let file_name = match parsed.segments.as_slice() {
            [name] if name == "alc.d.lua" || name == "alc_shapes.d.lua" => name.as_str(),
            _ => {
                return Err(McpError::resource_not_found(
                    format!("resource not found: {uri}"),
                    None,
                ));
            }
        };
        let path = self.app_dir.types_dir().join(file_name);

        match std::fs::read_to_string(&path) {
            Ok(text) => {
                let contents = ResourceContents::TextResourceContents {
                    uri: uri.to_string(),
                    mime_type: Some("text/x-lua".to_string()),
                    text,
                    meta: None,
                };
                Ok(ReadResourceResult::new(vec![contents]))
            }
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(
                McpError::resource_not_found(format!("resource not found: {uri}"), None),
            ),
            Err(e) => Err(McpError::internal_error(e.to_string(), None)),
        }
    }

    async fn read_packages(
        &self,
        uri: &str,
        parsed: &ParsedUri,
    ) -> Result<ReadResourceResult, McpError> {
        // Accepted: alc://packages/{name}/init.lua  or  alc://packages/{name}/meta
        match parsed.segments.as_slice() {
            [name, sub] if sub == "init.lua" => {
                validate_id("package name", name, uri)?;
                if !parsed.query.is_empty() {
                    return Err(McpError::invalid_params(
                        format!("query params not supported on {uri}"),
                        None,
                    ));
                }
                let text = self.app.pkg_read_init_lua(name).await.map_err(err_to_mcp)?;
                Ok(text_result(uri, text, "text/x-lua"))
            }
            [name, sub] if sub == "meta" => {
                validate_id("package name", name, uri)?;
                if !parsed.query.is_empty() {
                    return Err(McpError::invalid_params(
                        format!("query params not supported on {uri}"),
                        None,
                    ));
                }
                let json_str = self.app.pkg_meta(name).await.map_err(|e| {
                    if e.starts_with("pkg not found") {
                        McpError::resource_not_found(format!("resource not found: {uri}"), None)
                    } else {
                        err_to_mcp(e)
                    }
                })?;
                Ok(text_result(uri, json_str, "application/json"))
            }
            [name, sub] if sub == "narrative" => {
                // Stdpkg reference: bundled-packages narrative
                // (#1777052474, V2 cluster). Personal pkg gendoc
                // output is intentionally not surfaced here — see
                // list_templates() doc.
                //
                // Serving strategy (#1778221491-39903):
                // Extract narrative content on-the-fly from the pkg's
                // init.lua docstring H2 sections via the embedded gendoc
                // pipeline (extract.build_pkg_info + projections.narrative_md).
                // The M.docs.narrative file-based SSOT path has been removed.
                validate_id("package name", name, uri)?;
                if !parsed.query.is_empty() {
                    return Err(McpError::invalid_params(
                        format!("query params not supported on {uri}"),
                        None,
                    ));
                }
                match self.app.pkg_get_narrative_md(name).await {
                    Ok(Some(text)) => Ok(text_result(uri, text, "text/markdown")),
                    Ok(None) => Err(McpError::resource_not_found(
                        format!("resource not found: {uri}"),
                        None,
                    )),
                    Err(e) => Err(err_to_mcp(format!(
                        "narrative render failed for {name}: {e}"
                    ))),
                }
            }
            _ => Err(McpError::resource_not_found(
                format!("resource not found: {uri}"),
                None,
            )),
        }
    }

    async fn read_cards(
        &self,
        uri: &str,
        parsed: &ParsedUri,
    ) -> Result<ReadResourceResult, McpError> {
        match parsed.segments.as_slice() {
            [card_id] => {
                validate_id("card_id", card_id, uri)?;
                // alc://cards/{card_id} — no query params allowed
                if !parsed.query.is_empty() {
                    return Err(McpError::invalid_params(
                        format!("query params not supported on {uri}"),
                        None,
                    ));
                }
                let json_str = self.app.card_get(card_id).await.map_err(err_to_mcp)?;
                Ok(text_result(uri, json_str, "application/json"))
            }
            [card_id, sub] if sub == "samples" => {
                validate_id("card_id", card_id, uri)?;
                // alc://cards/{card_id}/samples?offset=N&limit=M
                let offset = parse_capped_usize_param(
                    &parsed.query,
                    "offset",
                    uri,
                    MAX_CARD_SAMPLES_OFFSET,
                )?;
                let limit =
                    parse_capped_usize_param(&parsed.query, "limit", uri, MAX_CARD_SAMPLES_LIMIT)?
                        .or(Some(DEFAULT_CARD_SAMPLES_LIMIT));
                // Reject unknown query keys (only offset and limit are valid)
                for key in parsed.query.keys() {
                    if key != "offset" && key != "limit" {
                        return Err(McpError::invalid_params(
                            format!("unsupported query param '{key}' on {uri}"),
                            None,
                        ));
                    }
                }
                let json_str = self
                    .app
                    .card_samples(card_id, offset, limit, None)
                    .await
                    .map_err(err_to_mcp)?;
                Ok(text_result(uri, json_str, "application/json"))
            }
            _ => Err(McpError::resource_not_found(
                format!("resource not found: {uri}"),
                None,
            )),
        }
    }

    async fn read_scenarios(
        &self,
        uri: &str,
        parsed: &ParsedUri,
    ) -> Result<ReadResourceResult, McpError> {
        match parsed.segments.as_slice() {
            [name] => {
                validate_id("scenario name", name, uri)?;
                if !parsed.query.is_empty() {
                    return Err(McpError::invalid_params(
                        format!("query params not supported on {uri}"),
                        None,
                    ));
                }
                let text = self.app.scenario_show(name).await.map_err(err_to_mcp)?;
                Ok(text_result(uri, text, "text/x-lua"))
            }
            _ => Err(McpError::resource_not_found(
                format!("resource not found: {uri}"),
                None,
            )),
        }
    }

    async fn read_eval(
        &self,
        uri: &str,
        parsed: &ParsedUri,
    ) -> Result<ReadResourceResult, McpError> {
        match parsed.segments.as_slice() {
            [result_id] => {
                validate_id("eval result_id", result_id, uri)?;
                if !parsed.query.is_empty() {
                    return Err(McpError::invalid_params(
                        format!("query params not supported on {uri}"),
                        None,
                    ));
                }
                if !is_valid_eval_id(result_id) {
                    return Err(McpError::invalid_params(
                        format!(
                            "invalid eval_id: must be {{strategy}}_{{timestamp}}, got '{result_id}'"
                        ),
                        None,
                    ));
                }
                let json_str = self.app.eval_detail(result_id).await.map_err(err_to_mcp)?;
                Ok(text_result(uri, json_str, "application/json"))
            }
            _ => Err(McpError::resource_not_found(
                format!("resource not found: {uri}"),
                None,
            )),
        }
    }

    async fn read_logs(
        &self,
        uri: &str,
        parsed: &ParsedUri,
    ) -> Result<ReadResourceResult, McpError> {
        match parsed.segments.as_slice() {
            [session_id] => {
                validate_id("session_id", session_id, uri)?;
                // alc://logs/{session_id}?limit=N&max_chars=M
                let limit = parse_capped_usize_param(&parsed.query, "limit", uri, MAX_LOGS_LIMIT)?
                    .or(Some(DEFAULT_LOGS_LIMIT));
                let max_chars =
                    parse_capped_usize_param(&parsed.query, "max_chars", uri, MAX_LOGS_MAX_CHARS)?
                        .or(Some(DEFAULT_LOGS_MAX_CHARS));
                // Reject unknown query keys (only limit and max_chars are valid)
                for key in parsed.query.keys() {
                    if key != "limit" && key != "max_chars" {
                        return Err(McpError::invalid_params(
                            format!("unsupported query param '{key}' on {uri}"),
                            None,
                        ));
                    }
                }
                let json_str = self
                    .app
                    .log_view(Some(session_id), limit, max_chars)
                    .await
                    .map_err(err_to_mcp)?;
                Ok(text_result(uri, json_str, "application/json"))
            }
            _ => Err(McpError::resource_not_found(
                format!("resource not found: {uri}"),
                None,
            )),
        }
    }

    /// Dispatch a `alc://hub/<path>` resource read.
    ///
    /// Currently only `alc://hub/index` (segments `["index"]`) is supported.
    /// Any other path segment returns `invalid_params`.
    ///
    /// # Arguments
    /// - `parsed` — already-validated URI parsed from the `alc://hub/...` request.
    ///
    /// # Returns
    /// - `Ok(ReadResourceResult)` with `application/json` content on success.
    /// - `Err(McpError::invalid_params)` for unrecognised hub paths.
    /// - `Err(McpError::internal_error)` when the engine call or JSON serialisation fails.
    async fn read_hub(&self, parsed: &ParsedUri) -> Result<ReadResourceResult, McpError> {
        match parsed.segments.as_slice() {
            [s] if s == "index" => {
                let json = self
                    .app
                    .hub_index_aggregate()
                    .await
                    .map_err(|e| McpError::internal_error(e, None))?;
                Ok(text_result("alc://hub/index", json, "application/json"))
            }
            _ => Err(McpError::invalid_params(
                format!("unknown hub resource: {}", parsed.segments.join("/")),
                None,
            )),
        }
    }
}

// ─── Pagination defaults ──────────────────────────────────────────────────────

/// Default `limit` for `alc://cards/{id}/samples` when `?limit=` is absent.
pub const DEFAULT_CARD_SAMPLES_LIMIT: usize = 100;
/// Default `limit` for `alc://logs/{session_id}` when `?limit=` is absent.
pub const DEFAULT_LOGS_LIMIT: usize = 50;
/// Default `max_chars` for `alc://logs/{session_id}` when `?max_chars=` is absent.
pub const DEFAULT_LOGS_MAX_CHARS: usize = 20_000;

// ─── Pagination caps (MCP-DoS defense) ───────────────────────────────────────

/// Hard cap for `?limit=` on `alc://cards/{id}/samples`.
pub const MAX_CARD_SAMPLES_LIMIT: usize = 10_000;
/// Hard cap for `?offset=` on `alc://cards/{id}/samples`.
pub const MAX_CARD_SAMPLES_OFFSET: usize = 10_000_000;
/// Hard cap for `?limit=` on `alc://logs/{session_id}`.
pub const MAX_LOGS_LIMIT: usize = 10_000;
/// Hard cap for `?max_chars=` on `alc://logs/{session_id}`.
pub const MAX_LOGS_MAX_CHARS: usize = 1_000_000;

// ─── Helpers ─────────────────────────────────────────────────────────────────

/// Build a `ListResourcesResult` from the catalog's fixed list.
pub fn build_list_resources_result(catalog: &ResourceCatalog) -> ListResourcesResult {
    ListResourcesResult::with_all_items(catalog.list_fixed())
}

/// Build a `ListResourceTemplatesResult` from the catalog's template list.
pub fn build_list_templates_result(catalog: &ResourceCatalog) -> ListResourceTemplatesResult {
    ListResourceTemplatesResult::with_all_items(catalog.list_templates())
}

/// Convert an `EngineApi` `Err(String)` to a `McpError`.
pub fn err_to_mcp(s: String) -> McpError {
    McpError::internal_error(s, None)
}

/// Parse a single usize query parameter by key.
///
/// Returns `Ok(None)` when the key is absent, `Err` when the value is present
/// but cannot be parsed as `usize`. Never silently coerces parse failures to 0.
fn parse_usize_param(
    query: &HashMap<String, String>,
    key: &str,
    uri: &str,
) -> Result<Option<usize>, McpError> {
    match query.get(key) {
        None => Ok(None),
        Some(s) => s.parse::<usize>().map(Some).map_err(|e| {
            McpError::invalid_params(
                format!("invalid query param '{key}={s}' on {uri}: {e}"),
                None,
            )
        }),
    }
}

/// Reject IDs that contain URI-reserved characters.
///
/// Since `parse_query` does not URL-decode, IDs with `& = ? / %` would
/// either be split mid-stream or silently mis-attributed. We reject at
/// the MCP boundary instead of letting downstream stores see corrupted IDs.
fn validate_id(kind: &str, id: &str, uri: &str) -> Result<(), McpError> {
    for c in id.chars() {
        if matches!(c, '&' | '=' | '?' | '/' | '%' | ' ') {
            return Err(McpError::invalid_params(
                format!("{kind} '{id}' on {uri} contains reserved character '{c}'"),
                None,
            ));
        }
    }
    Ok(())
}

/// Parse a usize query param with an upper bound.
///
/// Returns `Err(invalid_params)` when the value exceeds `max`. Caps are
/// a MCP-DoS defense: unbounded values flow into `card_samples(offset, limit)`
/// and `log_view(limit, max_chars)` which allocate proportionally. Same shape
/// as [`parse_usize_param`] otherwise.
fn parse_capped_usize_param(
    query: &HashMap<String, String>,
    key: &str,
    uri: &str,
    max: usize,
) -> Result<Option<usize>, McpError> {
    match parse_usize_param(query, key, uri)? {
        Some(v) if v > max => Err(McpError::invalid_params(
            format!("query param '{key}={v}' on {uri} exceeds cap of {max}"),
            None,
        )),
        other => Ok(other),
    }
}

/// Validate `^[A-Za-z0-9-]+_\d+$` without the `regex` crate.
///
/// `rsplit_once('_')` finds the last `_`, splitting into `strategy` and `ts`.
/// Both parts must be non-empty; strategy chars are `[A-Za-z0-9-]`; ts chars
/// are `[0-9]`.
fn is_valid_eval_id(s: &str) -> bool {
    let Some((strategy, ts)) = s.rsplit_once('_') else {
        return false;
    };
    if strategy.is_empty() || ts.is_empty() {
        return false;
    }
    strategy
        .chars()
        .all(|c| c.is_ascii_alphanumeric() || c == '-')
        && ts.chars().all(|c| c.is_ascii_digit())
}

/// Produce a single-content `ReadResourceResult` with text content.
fn text_result(uri: &str, text: String, mime_type: &str) -> ReadResourceResult {
    ReadResourceResult::new(vec![ResourceContents::TextResourceContents {
        uri: uri.to_string(),
        mime_type: Some(mime_type.to_string()),
        text,
        meta: None,
    }])
}

fn make_resource(
    uri: &str,
    name: &str,
    title: Option<&str>,
    description: &str,
    mime_type: &str,
    size: Option<u32>,
) -> Resource {
    let raw = RawResource {
        uri: uri.to_string(),
        name: name.to_string(),
        title: title.map(|s| s.to_string()),
        description: Some(description.to_string()),
        mime_type: Some(mime_type.to_string()),
        size,
        icons: None,
        meta: None,
    };
    Annotated::new(raw, None)
}

fn make_template(
    uri_template: &str,
    name: &str,
    title: Option<&str>,
    description: &str,
    mime_type: Option<&str>,
) -> ResourceTemplate {
    let raw = RawResourceTemplate {
        uri_template: uri_template.to_string(),
        name: name.to_string(),
        title: title.map(|s| s.to_string()),
        description: Some(description.to_string()),
        mime_type: mime_type.map(|s| s.to_string()),
        icons: None,
    };
    Annotated::new(raw, None)
}

// ─── Completion API ──────────────────────────────────────────────────────────

/// Candidate result for a `completion/complete` request.
///
/// Returned by [`ResourceCatalog::complete_resource_arg`] for use in the
/// `CompleteResult.completion` field on the MCP wire.
pub struct CompletionCandidates {
    /// Matching candidate strings (capped at [`COMPLETION_CAP`]).
    pub values: Vec<String>,
    /// Total number of matches before truncation.
    pub total: u32,
    /// `true` when `values` was truncated (i.e. `values.len() < total`).
    pub has_more: bool,
}

/// Maximum number of completion candidates returned per request.
const COMPLETION_CAP: usize = 100;

/// Extract all RFC 6570 Level-1 variable names from a URI template string.
///
/// Scans the template for `{var}` patterns (no operator support — only
/// Level-1 simple expansion is used by algocline templates). Variables appear
/// in the order they are encountered; duplicates are preserved.
///
/// # Arguments
///
/// * `template` — a URI template string, e.g. `"alc://packages/{name}/init.lua"`.
///
/// # Returns
///
/// A `Vec<String>` of variable names found between `{` and `}`.
///
/// # Examples
///
/// `extract_template_vars("alc://packages/{name}/init.lua")` returns `["name"]`.
/// `extract_template_vars("alc://types/alc.d.lua")` returns `[]`.
pub fn extract_template_vars(template: &str) -> Vec<String> {
    let mut vars = Vec::new();
    let mut rest = template;
    while let Some(start) = rest.find('{') {
        rest = &rest[start + 1..];
        if let Some(end) = rest.find('}') {
            let var = &rest[..end];
            if !var.is_empty() {
                vars.push(var.to_string());
            }
            rest = &rest[end + 1..];
        } else {
            break;
        }
    }
    vars
}

/// Extract the service component from a URI template string.
///
/// Strips the `alc://` prefix and returns the first path component before
/// any `/`. Does not invoke `parse_uri` (which rejects template variables).
///
/// Returns `None` when the template does not start with `alc://` or has no
/// path component.
fn service_from_template(template: &str) -> Option<&str> {
    let rest = template.strip_prefix("alc://")?;
    let component = rest.split('/').next()?;
    if component.is_empty() {
        None
    } else {
        Some(component)
    }
}

impl ResourceCatalog {
    /// Return completion candidates for a single template argument.
    ///
    /// Dispatches to the appropriate `EngineApi` list method based on the
    /// `(service, arg_name)` pair extracted from `uri_template`. Candidate
    /// strings are filtered to those that start with `prefix` (case-sensitive,
    /// best-effort per spec). Results are capped at [`COMPLETION_CAP`] entries;
    /// if the list exceeds the cap, `has_more` is set to `true` and `total`
    /// reflects the untruncated count.
    ///
    /// Unknown `(service, arg_name)` pairs return an empty candidate list
    /// rather than an error — empty completion is a valid MCP response.
    ///
    /// # Arguments
    ///
    /// * `uri_template` — a URI template string such as `"alc://packages/{name}/init.lua"`.
    /// * `arg_name`     — the template variable being completed, e.g. `"name"`.
    /// * `prefix`       — the partial value typed so far; used for prefix filtering.
    ///
    /// # Returns
    ///
    /// `Ok(CompletionCandidates)` on success. Errors from the backing
    /// `EngineApi` are logged but result in an empty candidate list so that
    /// completion failures never surface as MCP errors.
    pub async fn complete_resource_arg(
        &self,
        uri_template: &str,
        arg_name: &str,
        prefix: &str,
    ) -> CompletionCandidates {
        let service = match service_from_template(uri_template) {
            Some(s) => s,
            None => return CompletionCandidates::empty(),
        };

        let candidates = match (service, arg_name) {
            ("packages", "name") => self.list_pkg_names(prefix).await,
            ("cards", "card_id") => self.list_card_ids(prefix).await,
            ("scenarios", "name") => self.list_scenario_names(prefix).await,
            ("eval", "result_id") => self.list_eval_ids(prefix).await,
            // logs domain: no session-list API available; return empty per subtask spec §2.3(a)
            ("logs", _) => Vec::new(),
            _ => Vec::new(),
        };

        CompletionCandidates::from_all(candidates)
    }

    /// List installed package names, prefix-filtered.
    async fn list_pkg_names(&self, prefix: &str) -> Vec<String> {
        let json_str = match self
            .app
            .pkg_list(None, Some(0), None, None, None, None)
            .await
        {
            Ok(s) => s,
            Err(e) => {
                tracing::warn!(error = %e, "completion: pkg_list failed, returning empty candidates");
                return Vec::new();
            }
        };
        let parsed: serde_json::Value = match serde_json::from_str(&json_str) {
            Ok(v) => v,
            Err(e) => {
                tracing::warn!(error = %e, "completion: pkg_list response is not valid JSON");
                return Vec::new();
            }
        };
        parsed
            .get("packages")
            .and_then(|p| p.as_array())
            .map(|arr| {
                arr.iter()
                    .filter_map(|e| e.get("name").and_then(|n| n.as_str()))
                    .filter(|n| n.starts_with(prefix))
                    .map(|n| n.to_string())
                    .collect()
            })
            .unwrap_or_default()
    }

    /// List card IDs, prefix-filtered.
    async fn list_card_ids(&self, prefix: &str) -> Vec<String> {
        let json_str = match self.app.card_list(None).await {
            Ok(s) => s,
            Err(e) => {
                tracing::warn!(error = %e, "completion: card_list failed, returning empty candidates");
                return Vec::new();
            }
        };
        let parsed: serde_json::Value = match serde_json::from_str(&json_str) {
            Ok(v) => v,
            Err(e) => {
                tracing::warn!(error = %e, "completion: card_list response is not valid JSON");
                return Vec::new();
            }
        };
        // card_list returns a JSON array directly
        parsed
            .as_array()
            .map(|arr| {
                arr.iter()
                    .filter_map(|e| e.get("card_id").and_then(|id| id.as_str()))
                    .filter(|id| id.starts_with(prefix))
                    .map(|id| id.to_string())
                    .collect()
            })
            .unwrap_or_default()
    }

    /// List scenario names, prefix-filtered.
    async fn list_scenario_names(&self, prefix: &str) -> Vec<String> {
        let json_str = match self.app.scenario_list().await {
            Ok(s) => s,
            Err(e) => {
                tracing::warn!(error = %e, "completion: scenario_list failed, returning empty candidates");
                return Vec::new();
            }
        };
        let parsed: serde_json::Value = match serde_json::from_str(&json_str) {
            Ok(v) => v,
            Err(e) => {
                tracing::warn!(error = %e, "completion: scenario_list response is not valid JSON");
                return Vec::new();
            }
        };
        parsed
            .get("scenarios")
            .and_then(|p| p.as_array())
            .map(|arr| {
                arr.iter()
                    .filter_map(|e| e.get("name").and_then(|n| n.as_str()))
                    .filter(|n| n.starts_with(prefix))
                    .map(|n| n.to_string())
                    .collect()
            })
            .unwrap_or_default()
    }

    /// List eval result IDs, prefix-filtered.
    ///
    /// Fetches all entries (unbounded), applies prefix filter, then caps at
    /// `COMPLETION_CAP`. Fetching before filtering ensures older matches
    /// beyond the cap are not silently dropped.
    async fn list_eval_ids(&self, prefix: &str) -> Vec<String> {
        // usize::MAX acts as "no limit" — eval_store truncates to limit, so
        // passing the maximum ensures we get every entry before filtering.
        let json_str = match self.app.eval_history(None, usize::MAX).await {
            Ok(s) => s,
            Err(e) => {
                tracing::warn!(error = %e, "completion: eval_history failed, returning empty candidates");
                return Vec::new();
            }
        };
        let parsed: serde_json::Value = match serde_json::from_str(&json_str) {
            Ok(v) => v,
            Err(e) => {
                tracing::warn!(error = %e, "completion: eval_history response is not valid JSON");
                return Vec::new();
            }
        };
        parsed
            .get("evals")
            .and_then(|p| p.as_array())
            .map(|arr| {
                arr.iter()
                    .filter_map(|e| e.get("eval_id").and_then(|id| id.as_str()))
                    .filter(|id| id.starts_with(prefix))
                    .map(|id| id.to_string())
                    .collect()
            })
            .unwrap_or_default()
    }
}

impl CompletionCandidates {
    /// Create an empty result (no candidates, no more results).
    pub fn empty() -> Self {
        Self {
            values: Vec::new(),
            total: 0,
            has_more: false,
        }
    }

    /// Build from a full (potentially large) candidate list, applying the cap.
    ///
    /// If `all.len() > COMPLETION_CAP`, the list is truncated and `has_more`
    /// is set. `total` always reflects the untruncated count (clamped to
    /// `u32::MAX`).
    pub fn from_all(mut all: Vec<String>) -> Self {
        let total = all.len().min(u32::MAX as usize) as u32;
        let has_more = all.len() > COMPLETION_CAP;
        all.truncate(COMPLETION_CAP);
        Self {
            values: all,
            total,
            has_more,
        }
    }
}

// ─── Unit tests ──────────────────────────────────────────────────────────────

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

    /// Generates a complete `impl EngineApi for $ty` where every method returns
    /// `Err($err.into())`. Use for test stubs that need no per-method
    /// customisation.
    ///
    /// Drift safety: adding a new trait method causes a compile error here,
    /// forcing the macro definition to be updated in one place instead of
    /// touching every stub individually.
    ///
    /// Limitation: stubs that need to override specific methods must remain
    /// handwritten — Rust does not allow two `impl Trait for T` blocks.
    macro_rules! engine_default_err {
        ($ty:ty, $err:expr) => {
            #[async_trait::async_trait]
            impl algocline_app::EngineApi for $ty {
                async fn run(
                    &self,
                    _code: Option<String>,
                    _code_file: Option<String>,
                    _ctx: Option<serde_json::Value>,
                    _project_root: Option<String>,
                    _host_mode: Option<bool>,
                ) -> Result<String, String> {
                    Err($err.into())
                }
                async fn advice(
                    &self,
                    _strategy: &str,
                    _task: Option<String>,
                    _opts: Option<serde_json::Value>,
                    _project_root: Option<String>,
                ) -> Result<String, String> {
                    Err($err.into())
                }
                async fn continue_single(
                    &self,
                    _session_id: &str,
                    _response: String,
                    _query_id: Option<&str>,
                    _usage: Option<algocline_core::TokenUsage>,
                ) -> Result<String, String> {
                    Err($err.into())
                }
                async fn continue_batch(
                    &self,
                    _session_id: &str,
                    _responses: Vec<algocline_core::QueryResponse>,
                ) -> Result<String, String> {
                    Err($err.into())
                }
                async fn status(
                    &self,
                    _session_id: Option<&str>,
                    _pending_filter: Option<serde_json::Value>,
                    _include_history: bool,
                ) -> Result<String, String> {
                    Err($err.into())
                }
                async fn eval(
                    &self,
                    _scenario: Option<String>,
                    _scenario_file: Option<String>,
                    _scenario_name: Option<String>,
                    _strategy: &str,
                    _strategy_opts: Option<serde_json::Value>,
                    _auto_card: bool,
                ) -> Result<String, String> {
                    Err($err.into())
                }
                async fn eval_history(
                    &self,
                    _strategy: Option<&str>,
                    _limit: usize,
                ) -> Result<String, String> {
                    Err($err.into())
                }
                async fn eval_detail(&self, _eval_id: &str) -> Result<String, String> {
                    Err($err.into())
                }
                async fn eval_compare(
                    &self,
                    _eval_id_a: &str,
                    _eval_id_b: &str,
                ) -> Result<String, String> {
                    Err($err.into())
                }
                async fn scenario_list(&self) -> Result<String, String> {
                    Err($err.into())
                }
                async fn scenario_show(&self, _name: &str) -> Result<String, String> {
                    Err($err.into())
                }
                async fn scenario_install(&self, _url: String) -> Result<String, String> {
                    Err($err.into())
                }
                async fn pkg_link(
                    &self,
                    _path: String,
                    _name: Option<String>,
                    _force: Option<bool>,
                    _scope: Option<String>,
                    _project_root: Option<String>,
                ) -> Result<String, String> {
                    Err($err.into())
                }
                #[allow(clippy::too_many_arguments)]
                async fn pkg_list(
                    &self,
                    _project_root: Option<String>,
                    _limit: Option<i32>,
                    _sort: Option<String>,
                    _filter: Option<serde_json::Value>,
                    _fields: Option<Vec<String>>,
                    _verbose: Option<String>,
                ) -> Result<String, String> {
                    Err($err.into())
                }
                async fn pkg_install(
                    &self,
                    _url: String,
                    _name: Option<String>,
                    _force: Option<bool>,
                ) -> Result<String, String> {
                    Err($err.into())
                }
                async fn pkg_unlink(&self, _name: String) -> Result<String, String> {
                    Err($err.into())
                }
                async fn pkg_remove(
                    &self,
                    _name: &str,
                    _project_root: Option<String>,
                    _version: Option<String>,
                    _scope: Option<String>,
                ) -> Result<String, String> {
                    Err($err.into())
                }
                async fn pkg_repair(
                    &self,
                    _name: Option<String>,
                    _project_root: Option<String>,
                ) -> Result<String, String> {
                    Err($err.into())
                }
                async fn pkg_doctor(
                    &self,
                    _name: Option<String>,
                    _project_root: Option<String>,
                ) -> Result<String, String> {
                    Err($err.into())
                }
                async fn add_note(
                    &self,
                    _session_id: &str,
                    _content: &str,
                    _title: Option<&str>,
                ) -> Result<String, String> {
                    Err($err.into())
                }
                async fn log_view(
                    &self,
                    _session_id: Option<&str>,
                    _limit: Option<usize>,
                    _max_chars: Option<usize>,
                ) -> Result<String, String> {
                    Err($err.into())
                }
                async fn stats(
                    &self,
                    _strategy_filter: Option<&str>,
                    _days: Option<u64>,
                ) -> Result<String, String> {
                    Err($err.into())
                }
                async fn init(&self, _project_root: Option<String>) -> Result<String, String> {
                    Err($err.into())
                }
                async fn update(&self, _project_root: Option<String>) -> Result<String, String> {
                    Err($err.into())
                }
                async fn migrate(&self, _project_root: Option<String>) -> Result<String, String> {
                    Err($err.into())
                }
                async fn session_new(
                    &self,
                    _project_root: Option<String>,
                    _mode: Option<String>,
                ) -> Result<String, String> {
                    Err($err.into())
                }
                async fn pkg_get_narrative_md(
                    &self,
                    _name: &str,
                ) -> Result<Option<String>, String> {
                    Err($err.into())
                }
                async fn card_list(&self, _pkg: Option<String>) -> Result<String, String> {
                    Err($err.into())
                }
                async fn card_get(&self, _card_id: &str) -> Result<String, String> {
                    Err($err.into())
                }
                async fn card_find(
                    &self,
                    _pkg: Option<String>,
                    _where_: Option<serde_json::Value>,
                    _order_by: Option<serde_json::Value>,
                    _limit: Option<usize>,
                    _offset: Option<usize>,
                ) -> Result<String, String> {
                    Err($err.into())
                }
                async fn card_alias_list(&self, _pkg: Option<String>) -> Result<String, String> {
                    Err($err.into())
                }
                async fn card_get_by_alias(&self, _name: &str) -> Result<String, String> {
                    Err($err.into())
                }
                async fn card_alias_set(
                    &self,
                    _name: &str,
                    _card_id: &str,
                    _pkg: Option<String>,
                    _note: Option<String>,
                ) -> Result<String, String> {
                    Err($err.into())
                }
                async fn card_append(
                    &self,
                    _card_id: &str,
                    _fields: serde_json::Value,
                ) -> Result<String, String> {
                    Err($err.into())
                }
                async fn card_install(&self, _url: String) -> Result<String, String> {
                    Err($err.into())
                }
                async fn card_samples(
                    &self,
                    _card_id: &str,
                    _offset: Option<usize>,
                    _limit: Option<usize>,
                    _where_: Option<serde_json::Value>,
                ) -> Result<String, String> {
                    Err($err.into())
                }
                async fn card_lineage(
                    &self,
                    _card_id: &str,
                    _direction: Option<String>,
                    _depth: Option<usize>,
                    _include_stats: Option<bool>,
                    _relation_filter: Option<Vec<String>>,
                ) -> Result<String, String> {
                    Err($err.into())
                }
                async fn hub_reindex(
                    &self,
                    _output_path: Option<String>,
                    _source_dir: Option<String>,
                ) -> Result<String, String> {
                    Err($err.into())
                }
                async fn hub_gendoc(
                    &self,
                    _source_dir: String,
                    _out_dir: Option<String>,
                    _projections: Option<Vec<String>>,
                    _config_path: Option<String>,
                    _lint_strict: Option<bool>,
                ) -> Result<String, String> {
                    Err($err.into())
                }
                #[allow(clippy::too_many_arguments)]
                async fn hub_dist(
                    &self,
                    _source_dir: String,
                    _output_path: Option<String>,
                    _out_dir: Option<String>,
                    _preset: Option<String>,
                    _project_root: Option<String>,
                    _projections: Option<Vec<String>>,
                    _config_path: Option<String>,
                    _lint_strict: Option<bool>,
                ) -> Result<String, String> {
                    Err($err.into())
                }
                async fn hub_info(&self, _pkg: String) -> Result<String, String> {
                    Err($err.into())
                }
                #[allow(clippy::too_many_arguments)]
                async fn hub_search(
                    &self,
                    _query: Option<String>,
                    _category: Option<String>,
                    _installed_only: Option<bool>,
                    _limit: Option<i32>,
                    _sort: Option<String>,
                    _filter: Option<serde_json::Value>,
                    _fields: Option<Vec<String>>,
                    _verbose: Option<String>,
                    _local_indices: Option<Vec<String>>,
                ) -> Result<String, String> {
                    Err($err.into())
                }
                async fn pkg_scaffold(
                    &self,
                    _name: String,
                    _target_dir: Option<String>,
                    _category: Option<String>,
                    _description: Option<String>,
                ) -> Result<String, String> {
                    Err($err.into())
                }
                async fn pkg_read_init_lua(&self, _name: &str) -> Result<String, String> {
                    Err($err.into())
                }
                async fn pkg_meta(&self, _name: &str) -> Result<String, String> {
                    Err($err.into())
                }
                async fn info(&self) -> String {
                    format!("default-err stub: {}", stringify!($ty))
                }
                async fn hub_index_aggregate(&self) -> Result<String, String> {
                    Err($err.into())
                }
                async fn pool_ensure(&self) -> Result<String, String> {
                    Err($err.into())
                }
                async fn pool_status(&self, _sid: Option<String>) -> Result<String, String> {
                    Err($err.into())
                }
                async fn pool_stop(&self, _sid: Option<String>) -> Result<String, String> {
                    Err($err.into())
                }
                #[allow(clippy::too_many_arguments)]
                async fn pkg_test(
                    &self,
                    _pkg: Option<String>,
                    _code_file: Option<String>,
                    _code: Option<String>,
                    _spec_dir: Option<String>,
                    _filter: Option<String>,
                    _search_paths: Option<Vec<String>>,
                    _project_root: Option<String>,
                    _auto_search_paths: Option<bool>,
                ) -> Result<String, String> {
                    Err($err.into())
                }
                async fn setting_resolve(&self, _target: Option<String>) -> Result<String, String> {
                    Err($err.into())
                }
            }
        };
    }

    // ── URI parser tests ──────────────────────────────────────────────────

    #[test]
    fn parse_valid_fixed_uri() {
        let parsed = parse_uri("alc://types/alc.d.lua").unwrap();
        assert_eq!(parsed.service, "types");
        assert_eq!(parsed.segments, vec!["alc.d.lua"]);
        assert!(parsed.query.is_empty());
    }

    #[test]
    fn parse_valid_template_uri() {
        let parsed = parse_uri("alc://cards/xyz-123").unwrap();
        assert_eq!(parsed.service, "cards");
        assert_eq!(parsed.segments, vec!["xyz-123"]);
        assert!(parsed.query.is_empty());
    }

    #[test]
    fn parse_with_query() {
        let parsed = parse_uri("alc://cards/xyz/samples?offset=10&limit=50").unwrap();
        assert_eq!(parsed.service, "cards");
        assert_eq!(parsed.segments, vec!["xyz", "samples"]);
        assert_eq!(parsed.query.get("offset").map(|s| s.as_str()), Some("10"));
        assert_eq!(parsed.query.get("limit").map(|s| s.as_str()), Some("50"));
    }

    #[test]
    fn parse_missing_scheme() {
        let err = parse_uri("types/alc.d.lua").unwrap_err();
        assert!(matches!(err, UriParseError::Scheme(_)));
    }

    #[test]
    fn parse_wrong_scheme() {
        let err = parse_uri("https://foo").unwrap_err();
        assert!(matches!(err, UriParseError::Scheme(_)));
    }

    #[test]
    fn parse_unknown_service() {
        let err = parse_uri("alc://unknown/x").unwrap_err();
        assert!(matches!(err, UriParseError::UnknownService(_)));
    }

    #[test]
    fn parse_missing_segment() {
        let err = parse_uri("alc://cards/").unwrap_err();
        assert!(matches!(err, UriParseError::MissingSegment { .. }));
    }

    #[test]
    fn parse_bad_query_empty_key() {
        let err = parse_uri("alc://cards/x?=bad").unwrap_err();
        assert!(matches!(err, UriParseError::Query(_)));
    }

    #[test]
    fn parse_empty_query_is_ok() {
        let parsed = parse_uri("alc://cards/x?").unwrap();
        assert!(parsed.query.is_empty());
    }

    #[test]
    fn parse_shapes_uri() {
        let parsed = parse_uri("alc://types/alc_shapes.d.lua").unwrap();
        assert_eq!(parsed.service, "types");
        assert_eq!(parsed.segments, vec!["alc_shapes.d.lua"]);
    }

    // T1: hub/index parses correctly
    #[test]
    fn parse_hub_index_uri() {
        let parsed = parse_uri("alc://hub/index").unwrap();
        assert_eq!(parsed.service, "hub");
        assert_eq!(parsed.segments, vec!["index"]);
        assert!(parsed.query.is_empty());
    }

    // T2: hub service is in KNOWN_SERVICES
    #[test]
    fn known_services_contains_hub() {
        assert!(
            KNOWN_SERVICES.contains(&"hub"),
            "KNOWN_SERVICES must include \"hub\""
        );
    }

    // T3: unknown hub path → invalid_params (via read dispatch)
    #[tokio::test]
    async fn read_hub_unknown_path_returns_invalid_params() {
        let engine = FakeEngine::noop();
        let (cat, _tmp) = make_fake_catalog(engine);
        let err = cat.read("alc://hub/unknown").await.unwrap_err();
        let msg = err.to_string();
        assert!(
            msg.contains("unknown hub resource") || msg.contains("invalid"),
            "expected invalid_params error, got: {msg}"
        );
    }

    // T1: read_hub_index returns JSON from engine
    #[tokio::test]
    async fn read_hub_index_returns_json() {
        let index_json = r#"{"schema_version":"hub_index/v0","packages":[]}"#;
        let engine = FakeEngine::ok_hub_index(index_json);
        let (cat, _tmp) = make_fake_catalog(engine);
        let result = cat.read("alc://hub/index").await.unwrap();
        assert_eq!(result.contents.len(), 1);
        match &result.contents[0] {
            ResourceContents::TextResourceContents {
                text,
                mime_type,
                uri,
                ..
            } => {
                assert_eq!(mime_type.as_deref(), Some("application/json"));
                assert_eq!(uri, "alc://hub/index");
                assert!(
                    text.contains("hub_index/v0"),
                    "response must contain schema_version"
                );
            }
            _ => panic!("expected TextResourceContents"),
        }
    }

    // T3: read_hub_index when engine errors → internal_error
    #[tokio::test]
    async fn read_hub_index_engine_error_returns_internal_error() {
        let mut engine = FakeEngine::noop();
        engine.hub_index_aggregate = Some(Err("registries corrupt".into()));
        let (cat, _tmp) = make_fake_catalog(engine);
        let err = cat.read("alc://hub/index").await.unwrap_err();
        let msg = err.to_string();
        assert!(
            msg.contains("registries corrupt") || msg.contains("internal"),
            "expected internal_error, got: {msg}"
        );
    }

    // ── ResourceCatalog helpers ───────────────────────────────────────────

    fn make_test_catalog(root: PathBuf) -> ResourceCatalog {
        let app_dir = Arc::new(AppDir::new(root));
        ResourceCatalog::new(Arc::new(NoopEngine), app_dir)
    }

    // Minimal no-op EngineApi implementation for unit tests.
    struct NoopEngine;
    engine_default_err!(NoopEngine, "noop");

    // ── ResourceCatalog read tests (types — sync-converted to async) ─────

    #[tokio::test]
    async fn read_types_alc_d_lua_ok() {
        let tmp = tempfile::tempdir().unwrap();
        let types_dir = tmp.path().join("types");
        std::fs::create_dir_all(&types_dir).unwrap();
        std::fs::write(types_dir.join("alc.d.lua"), "-- stub content").unwrap();

        let catalog = make_test_catalog(tmp.path().to_path_buf());
        let result = catalog.read("alc://types/alc.d.lua").await.unwrap();
        assert_eq!(result.contents.len(), 1);
        match &result.contents[0] {
            ResourceContents::TextResourceContents {
                text, mime_type, ..
            } => {
                assert_eq!(text, "-- stub content");
                assert_eq!(mime_type.as_deref(), Some("text/x-lua"));
            }
            _ => panic!("expected TextResourceContents"),
        }
    }

    #[tokio::test]
    async fn read_types_missing_file() {
        let tmp = tempfile::tempdir().unwrap();
        let catalog = make_test_catalog(tmp.path().to_path_buf());
        let err = catalog.read("alc://types/alc.d.lua").await.unwrap_err();
        let msg = err.to_string();
        assert!(msg.contains("resource not found"), "got: {msg}");
    }

    #[test]
    fn list_fixed_returns_three_entries() {
        let tmp = tempfile::tempdir().unwrap();
        let catalog = make_test_catalog(tmp.path().to_path_buf());
        let fixed = catalog.list_fixed();
        assert_eq!(
            fixed.len(),
            3,
            "expected 3 fixed resources (types x2 + hub/index)"
        );
        assert_eq!(fixed[0].raw.uri, "alc://types/alc.d.lua");
        assert_eq!(fixed[1].raw.uri, "alc://types/alc_shapes.d.lua");
        assert_eq!(fixed[2].raw.uri, "alc://hub/index");
    }

    #[test]
    fn list_templates_returns_8() {
        // V1 = 7 templates; V2 added `packages/{name}/narrative`
        // (#1777052474, Stdpkg reference for bundled packages),
        // bringing the total to 8.
        let tmp = tempfile::tempdir().unwrap();
        let catalog = make_test_catalog(tmp.path().to_path_buf());
        let templates = catalog.list_templates();
        assert_eq!(templates.len(), 8, "expected exactly 8 templates");
        // Spot-check uri_template and name fields.
        assert_eq!(
            templates[0].raw.uri_template,
            "alc://packages/{name}/init.lua"
        );
        assert_eq!(
            templates[2].raw.uri_template,
            "alc://packages/{name}/narrative"
        );
        assert_eq!(templates[7].raw.uri_template, "alc://logs/{session_id}");
    }

    // ── Template dispatch tests ───────────────────────────────────────────
    //
    // For these tests we use a custom `FakeEngine` that returns controlled
    // responses for the specific methods under test.

    type FakeEngineExpected = Option<(Option<usize>, Option<usize>, Result<String, String>)>;

    struct FakeEngine {
        pkg_init_lua: Option<Result<String, String>>,
        pkg_narrative_md: Option<Result<Option<String>, String>>,
        pkg_meta: Option<Result<String, String>>,
        pkg_list: Option<Result<String, String>>,
        card_get: Option<Result<String, String>>,
        card_list: Option<Result<String, String>>,
        card_samples: FakeEngineExpected,
        scenario_show: Option<Result<String, String>>,
        scenario_list: Option<Result<String, String>>,
        eval_detail: Option<Result<String, String>>,
        eval_history: Option<Result<String, String>>,
        log_view: FakeEngineExpected,
        hub_index_aggregate: Option<Result<String, String>>,
    }

    impl FakeEngine {
        fn ok_init_lua(src: &str) -> Self {
            Self {
                pkg_init_lua: Some(Ok(src.to_string())),
                ..Self::noop()
            }
        }
        fn ok_narrative_md(md: &str) -> Self {
            Self {
                pkg_narrative_md: Some(Ok(Some(md.to_string()))),
                ..Self::noop()
            }
        }
        fn ok_meta(json: &str) -> Self {
            Self {
                pkg_meta: Some(Ok(json.to_string())),
                ..Self::noop()
            }
        }
        fn ok_card_get(json: &str) -> Self {
            Self {
                card_get: Some(Ok(json.to_string())),
                ..Self::noop()
            }
        }
        fn ok_card_samples(
            expected_offset: Option<usize>,
            expected_limit: Option<usize>,
            json: &str,
        ) -> Self {
            Self {
                card_samples: Some((expected_offset, expected_limit, Ok(json.to_string()))),
                ..Self::noop()
            }
        }
        fn ok_scenario_show(src: &str) -> Self {
            Self {
                scenario_show: Some(Ok(src.to_string())),
                ..Self::noop()
            }
        }
        fn ok_eval_detail(json: &str) -> Self {
            Self {
                eval_detail: Some(Ok(json.to_string())),
                ..Self::noop()
            }
        }
        fn ok_log_view(
            expected_limit: Option<usize>,
            expected_max_chars: Option<usize>,
            json: &str,
        ) -> Self {
            Self {
                log_view: Some((expected_limit, expected_max_chars, Ok(json.to_string()))),
                ..Self::noop()
            }
        }
        fn ok_hub_index(json: &str) -> Self {
            Self {
                hub_index_aggregate: Some(Ok(json.to_string())),
                ..Self::noop()
            }
        }
        fn noop() -> Self {
            Self {
                pkg_init_lua: None,
                pkg_narrative_md: None,
                pkg_meta: None,
                pkg_list: None,
                card_get: None,
                card_list: None,
                card_samples: None,
                scenario_show: None,
                scenario_list: None,
                eval_detail: None,
                eval_history: None,
                log_view: None,
                hub_index_aggregate: None,
            }
        }
    }

    #[async_trait::async_trait]
    impl EngineApi for FakeEngine {
        async fn pkg_read_init_lua(&self, _name: &str) -> Result<String, String> {
            self.pkg_init_lua
                .clone()
                .unwrap_or(Err("not configured".into()))
        }
        async fn pkg_meta(&self, _name: &str) -> Result<String, String> {
            self.pkg_meta
                .clone()
                .unwrap_or(Err("not configured".into()))
        }
        async fn pkg_list(
            &self,
            _project_root: Option<String>,
            _limit: Option<i32>,
            _sort: Option<String>,
            _filter: Option<serde_json::Value>,
            _fields: Option<Vec<String>>,
            _verbose: Option<String>,
        ) -> Result<String, String> {
            self.pkg_list
                .clone()
                .unwrap_or(Err("not configured".into()))
        }
        async fn card_get(&self, _card_id: &str) -> Result<String, String> {
            self.card_get
                .clone()
                .unwrap_or(Err("not configured".into()))
        }
        async fn card_samples(
            &self,
            _card_id: &str,
            offset: Option<usize>,
            limit: Option<usize>,
            _where_: Option<serde_json::Value>,
        ) -> Result<String, String> {
            if let Some((exp_offset, exp_limit, ref result)) = self.card_samples {
                assert_eq!(offset, exp_offset, "card_samples offset mismatch");
                assert_eq!(limit, exp_limit, "card_samples limit mismatch");
                result.clone()
            } else {
                Err("not configured".into())
            }
        }
        async fn scenario_show(&self, _name: &str) -> Result<String, String> {
            self.scenario_show
                .clone()
                .unwrap_or(Err("not configured".into()))
        }
        async fn eval_detail(&self, _eval_id: &str) -> Result<String, String> {
            self.eval_detail
                .clone()
                .unwrap_or(Err("not configured".into()))
        }
        async fn log_view(
            &self,
            _session_id: Option<&str>,
            limit: Option<usize>,
            max_chars: Option<usize>,
        ) -> Result<String, String> {
            if let Some((exp_limit, exp_max_chars, ref result)) = self.log_view {
                assert_eq!(limit, exp_limit, "log_view limit mismatch");
                assert_eq!(max_chars, exp_max_chars, "log_view max_chars mismatch");
                result.clone()
            } else {
                Err("not configured".into())
            }
        }
        // ── All other methods are noop stubs ────────────────────────────────
        async fn run(
            &self,
            _: Option<String>,
            _: Option<String>,
            _: Option<serde_json::Value>,
            _: Option<String>,
            _: Option<bool>,
        ) -> Result<String, String> {
            Err("noop".into())
        }
        async fn advice(
            &self,
            _: &str,
            _: Option<String>,
            _: Option<serde_json::Value>,
            _: Option<String>,
        ) -> Result<String, String> {
            Err("noop".into())
        }
        async fn continue_single(
            &self,
            _: &str,
            _: String,
            _: Option<&str>,
            _: Option<algocline_core::TokenUsage>,
        ) -> Result<String, String> {
            Err("noop".into())
        }
        async fn continue_batch(
            &self,
            _: &str,
            _: Vec<algocline_core::QueryResponse>,
        ) -> Result<String, String> {
            Err("noop".into())
        }
        async fn status(
            &self,
            _: Option<&str>,
            _: Option<serde_json::Value>,
            _: bool,
        ) -> Result<String, String> {
            Err("noop".into())
        }
        async fn eval(
            &self,
            _: Option<String>,
            _: Option<String>,
            _: Option<String>,
            _: &str,
            _: Option<serde_json::Value>,
            _: bool,
        ) -> Result<String, String> {
            Err("noop".into())
        }
        async fn eval_history(&self, _: Option<&str>, _: usize) -> Result<String, String> {
            self.eval_history
                .clone()
                .unwrap_or(Err("not configured".into()))
        }
        async fn eval_compare(&self, _: &str, _: &str) -> Result<String, String> {
            Err("noop".into())
        }
        async fn scenario_list(&self) -> Result<String, String> {
            self.scenario_list
                .clone()
                .unwrap_or(Err("not configured".into()))
        }
        async fn scenario_install(&self, _: String) -> Result<String, String> {
            Err("noop".into())
        }
        async fn pkg_link(
            &self,
            _: String,
            _: Option<String>,
            _: Option<bool>,
            _: Option<String>,
            _: Option<String>,
        ) -> Result<String, String> {
            Err("noop".into())
        }
        async fn pkg_install(
            &self,
            _: String,
            _: Option<String>,
            _: Option<bool>,
        ) -> Result<String, String> {
            Err("noop".into())
        }
        async fn pkg_unlink(&self, _: String) -> Result<String, String> {
            Err("noop".into())
        }
        async fn pkg_remove(
            &self,
            _: &str,
            _: Option<String>,
            _: Option<String>,
            _: Option<String>,
        ) -> Result<String, String> {
            Err("noop".into())
        }
        async fn pkg_repair(&self, _: Option<String>, _: Option<String>) -> Result<String, String> {
            Err("noop".into())
        }
        async fn pkg_doctor(&self, _: Option<String>, _: Option<String>) -> Result<String, String> {
            Err("noop".into())
        }
        async fn add_note(&self, _: &str, _: &str, _: Option<&str>) -> Result<String, String> {
            Err("noop".into())
        }
        async fn stats(&self, _: Option<&str>, _: Option<u64>) -> Result<String, String> {
            Err("noop".into())
        }
        async fn init(&self, _: Option<String>) -> Result<String, String> {
            Err("noop".into())
        }
        async fn update(&self, _: Option<String>) -> Result<String, String> {
            Err("noop".into())
        }
        async fn migrate(&self, _: Option<String>) -> Result<String, String> {
            Err("noop".into())
        }
        async fn session_new(
            &self,
            _: Option<String>,
            _: Option<String>,
        ) -> Result<String, String> {
            Err("noop".into())
        }
        async fn pkg_get_narrative_md(&self, _: &str) -> Result<Option<String>, String> {
            self.pkg_narrative_md.clone().unwrap_or(Ok(None))
        }
        async fn card_list(&self, _: Option<String>) -> Result<String, String> {
            self.card_list
                .clone()
                .unwrap_or(Err("not configured".into()))
        }
        async fn card_find(
            &self,
            _: Option<String>,
            _: Option<serde_json::Value>,
            _: Option<serde_json::Value>,
            _: Option<usize>,
            _: Option<usize>,
        ) -> Result<String, String> {
            Err("noop".into())
        }
        async fn card_alias_list(&self, _: Option<String>) -> Result<String, String> {
            Err("noop".into())
        }
        async fn card_get_by_alias(&self, _: &str) -> Result<String, String> {
            Err("noop".into())
        }
        async fn card_alias_set(
            &self,
            _: &str,
            _: &str,
            _: Option<String>,
            _: Option<String>,
        ) -> Result<String, String> {
            Err("noop".into())
        }
        async fn card_append(&self, _: &str, _: serde_json::Value) -> Result<String, String> {
            Err("noop".into())
        }
        async fn card_install(&self, _: String) -> Result<String, String> {
            Err("noop".into())
        }
        async fn card_lineage(
            &self,
            _: &str,
            _: Option<String>,
            _: Option<usize>,
            _: Option<bool>,
            _: Option<Vec<String>>,
        ) -> Result<String, String> {
            Err("noop".into())
        }
        async fn hub_reindex(
            &self,
            _: Option<String>,
            _: Option<String>,
        ) -> Result<String, String> {
            Err("noop".into())
        }
        async fn hub_gendoc(
            &self,
            _: String,
            _: Option<String>,
            _: Option<Vec<String>>,
            _: Option<String>,
            _: Option<bool>,
        ) -> Result<String, String> {
            Err("noop".into())
        }
        async fn hub_dist(
            &self,
            _: String,
            _: Option<String>,
            _: Option<String>,
            _: Option<String>,
            _: Option<String>,
            _: Option<Vec<String>>,
            _: Option<String>,
            _: Option<bool>,
        ) -> Result<String, String> {
            Err("noop".into())
        }
        async fn hub_info(&self, _: String) -> Result<String, String> {
            Err("noop".into())
        }
        async fn hub_search(
            &self,
            _: Option<String>,
            _: Option<String>,
            _: Option<bool>,
            _: Option<i32>,
            _: Option<String>,
            _: Option<serde_json::Value>,
            _: Option<Vec<String>>,
            _: Option<String>,
            _: Option<Vec<String>>,
        ) -> Result<String, String> {
            Err("noop".into())
        }
        async fn pkg_scaffold(
            &self,
            _: String,
            _: Option<String>,
            _: Option<String>,
            _: Option<String>,
        ) -> Result<String, String> {
            Err("noop".into())
        }
        async fn info(&self) -> String {
            "fake".into()
        }
        async fn hub_index_aggregate(&self) -> Result<String, String> {
            self.hub_index_aggregate
                .clone()
                .unwrap_or(Err("not configured".into()))
        }
        async fn pool_ensure(&self) -> Result<String, String> {
            Err("not configured".into())
        }
        async fn pool_status(&self, _sid: Option<String>) -> Result<String, String> {
            Err("not configured".into())
        }
        async fn pool_stop(&self, _sid: Option<String>) -> Result<String, String> {
            Err("not configured".into())
        }
        #[allow(clippy::too_many_arguments)]
        async fn pkg_test(
            &self,
            _pkg: Option<String>,
            _code_file: Option<String>,
            _code: Option<String>,
            _spec_dir: Option<String>,
            _filter: Option<String>,
            _search_paths: Option<Vec<String>>,
            _project_root: Option<String>,
            _auto_search_paths: Option<bool>,
        ) -> Result<String, String> {
            Err("not configured".into())
        }
        async fn setting_resolve(&self, _target: Option<String>) -> Result<String, String> {
            Err("not configured".into())
        }
    }

    fn make_fake_catalog(engine: FakeEngine) -> (ResourceCatalog, tempfile::TempDir) {
        let tmp = tempfile::tempdir().unwrap();
        let app_dir = Arc::new(AppDir::new(tmp.path().to_path_buf()));
        (ResourceCatalog::new(Arc::new(engine), app_dir), tmp)
    }

    // 1. read_pkg_init_lua_ok
    #[tokio::test]
    async fn read_pkg_init_lua_ok() {
        let (cat, _tmp) = make_fake_catalog(FakeEngine::ok_init_lua("return 42"));
        let result = cat.read("alc://packages/mypkg/init.lua").await.unwrap();
        match &result.contents[0] {
            ResourceContents::TextResourceContents {
                text, mime_type, ..
            } => {
                assert_eq!(text, "return 42");
                assert_eq!(mime_type.as_deref(), Some("text/x-lua"));
            }
            _ => panic!("expected text"),
        }
    }

    // 2. read_pkg_init_lua_not_found
    #[tokio::test]
    async fn read_pkg_init_lua_not_found() {
        struct NotFoundEngine;
        #[async_trait::async_trait]
        impl EngineApi for NotFoundEngine {
            async fn pkg_read_init_lua(&self, name: &str) -> Result<String, String> {
                Err(format!("pkg not found: {name}"))
            }
            async fn pkg_meta(&self, name: &str) -> Result<String, String> {
                Err(format!("pkg not found: {name}"))
            }
            async fn run(
                &self,
                _: Option<String>,
                _: Option<String>,
                _: Option<serde_json::Value>,
                _: Option<String>,
                _: Option<bool>,
            ) -> Result<String, String> {
                Err("noop".into())
            }
            async fn advice(
                &self,
                _: &str,
                _: Option<String>,
                _: Option<serde_json::Value>,
                _: Option<String>,
            ) -> Result<String, String> {
                Err("noop".into())
            }
            async fn continue_single(
                &self,
                _: &str,
                _: String,
                _: Option<&str>,
                _: Option<algocline_core::TokenUsage>,
            ) -> Result<String, String> {
                Err("noop".into())
            }
            async fn continue_batch(
                &self,
                _: &str,
                _: Vec<algocline_core::QueryResponse>,
            ) -> Result<String, String> {
                Err("noop".into())
            }
            async fn status(
                &self,
                _: Option<&str>,
                _: Option<serde_json::Value>,
                _: bool,
            ) -> Result<String, String> {
                Err("noop".into())
            }
            async fn eval(
                &self,
                _: Option<String>,
                _: Option<String>,
                _: Option<String>,
                _: &str,
                _: Option<serde_json::Value>,
                _: bool,
            ) -> Result<String, String> {
                Err("noop".into())
            }
            async fn eval_history(&self, _: Option<&str>, _: usize) -> Result<String, String> {
                Err("noop".into())
            }
            async fn eval_detail(&self, _: &str) -> Result<String, String> {
                Err("noop".into())
            }
            async fn eval_compare(&self, _: &str, _: &str) -> Result<String, String> {
                Err("noop".into())
            }
            async fn scenario_list(&self) -> Result<String, String> {
                Err("noop".into())
            }
            async fn scenario_show(&self, _: &str) -> Result<String, String> {
                Err("noop".into())
            }
            async fn scenario_install(&self, _: String) -> Result<String, String> {
                Err("noop".into())
            }
            async fn pkg_link(
                &self,
                _: String,
                _: Option<String>,
                _: Option<bool>,
                _: Option<String>,
                _: Option<String>,
            ) -> Result<String, String> {
                Err("noop".into())
            }
            async fn pkg_list(
                &self,
                _: Option<String>,
                _: Option<i32>,
                _: Option<String>,
                _: Option<serde_json::Value>,
                _: Option<Vec<String>>,
                _: Option<String>,
            ) -> Result<String, String> {
                Err("noop".into())
            }
            async fn pkg_install(
                &self,
                _: String,
                _: Option<String>,
                _: Option<bool>,
            ) -> Result<String, String> {
                Err("noop".into())
            }
            async fn pkg_unlink(&self, _: String) -> Result<String, String> {
                Err("noop".into())
            }
            async fn pkg_remove(
                &self,
                _: &str,
                _: Option<String>,
                _: Option<String>,
                _: Option<String>,
            ) -> Result<String, String> {
                Err("noop".into())
            }
            async fn pkg_repair(
                &self,
                _: Option<String>,
                _: Option<String>,
            ) -> Result<String, String> {
                Err("noop".into())
            }
            async fn pkg_doctor(
                &self,
                _: Option<String>,
                _: Option<String>,
            ) -> Result<String, String> {
                Err("noop".into())
            }
            async fn add_note(&self, _: &str, _: &str, _: Option<&str>) -> Result<String, String> {
                Err("noop".into())
            }
            async fn log_view(
                &self,
                _: Option<&str>,
                _: Option<usize>,
                _: Option<usize>,
            ) -> Result<String, String> {
                Err("noop".into())
            }
            async fn stats(&self, _: Option<&str>, _: Option<u64>) -> Result<String, String> {
                Err("noop".into())
            }
            async fn init(&self, _: Option<String>) -> Result<String, String> {
                Err("noop".into())
            }
            async fn update(&self, _: Option<String>) -> Result<String, String> {
                Err("noop".into())
            }
            async fn migrate(&self, _: Option<String>) -> Result<String, String> {
                Err("noop".into())
            }
            async fn session_new(
                &self,
                _: Option<String>,
                _: Option<String>,
            ) -> Result<String, String> {
                Err("noop".into())
            }
            async fn pkg_get_narrative_md(&self, _: &str) -> Result<Option<String>, String> {
                Ok(None)
            }
            async fn card_list(&self, _: Option<String>) -> Result<String, String> {
                Err("noop".into())
            }
            async fn card_get(&self, _: &str) -> Result<String, String> {
                Err("noop".into())
            }
            async fn card_find(
                &self,
                _: Option<String>,
                _: Option<serde_json::Value>,
                _: Option<serde_json::Value>,
                _: Option<usize>,
                _: Option<usize>,
            ) -> Result<String, String> {
                Err("noop".into())
            }
            async fn card_alias_list(&self, _: Option<String>) -> Result<String, String> {
                Err("noop".into())
            }
            async fn card_get_by_alias(&self, _: &str) -> Result<String, String> {
                Err("noop".into())
            }
            async fn card_alias_set(
                &self,
                _: &str,
                _: &str,
                _: Option<String>,
                _: Option<String>,
            ) -> Result<String, String> {
                Err("noop".into())
            }
            async fn card_append(&self, _: &str, _: serde_json::Value) -> Result<String, String> {
                Err("noop".into())
            }
            async fn card_install(&self, _: String) -> Result<String, String> {
                Err("noop".into())
            }
            async fn card_samples(
                &self,
                _: &str,
                _: Option<usize>,
                _: Option<usize>,
                _: Option<serde_json::Value>,
            ) -> Result<String, String> {
                Err("noop".into())
            }
            async fn card_lineage(
                &self,
                _: &str,
                _: Option<String>,
                _: Option<usize>,
                _: Option<bool>,
                _: Option<Vec<String>>,
            ) -> Result<String, String> {
                Err("noop".into())
            }
            async fn hub_reindex(
                &self,
                _: Option<String>,
                _: Option<String>,
            ) -> Result<String, String> {
                Err("noop".into())
            }
            async fn hub_gendoc(
                &self,
                _: String,
                _: Option<String>,
                _: Option<Vec<String>>,
                _: Option<String>,
                _: Option<bool>,
            ) -> Result<String, String> {
                Err("noop".into())
            }
            async fn hub_dist(
                &self,
                _: String,
                _: Option<String>,
                _: Option<String>,
                _: Option<String>,
                _: Option<String>,
                _: Option<Vec<String>>,
                _: Option<String>,
                _: Option<bool>,
            ) -> Result<String, String> {
                Err("noop".into())
            }
            async fn hub_info(&self, _: String) -> Result<String, String> {
                Err("noop".into())
            }
            async fn hub_search(
                &self,
                _: Option<String>,
                _: Option<String>,
                _: Option<bool>,
                _: Option<i32>,
                _: Option<String>,
                _: Option<serde_json::Value>,
                _: Option<Vec<String>>,
                _: Option<String>,
                _: Option<Vec<String>>,
            ) -> Result<String, String> {
                Err("noop".into())
            }
            async fn pkg_scaffold(
                &self,
                _: String,
                _: Option<String>,
                _: Option<String>,
                _: Option<String>,
            ) -> Result<String, String> {
                Err("noop".into())
            }
            async fn info(&self) -> String {
                "fake".into()
            }
            async fn hub_index_aggregate(&self) -> Result<String, String> {
                Err("noop".into())
            }
            async fn pool_ensure(&self) -> Result<String, String> {
                Err("not found".into())
            }
            async fn pool_status(&self, _sid: Option<String>) -> Result<String, String> {
                Err("not found".into())
            }
            async fn pool_stop(&self, _sid: Option<String>) -> Result<String, String> {
                Err("not found".into())
            }
            #[allow(clippy::too_many_arguments)]
            async fn pkg_test(
                &self,
                _pkg: Option<String>,
                _code_file: Option<String>,
                _code: Option<String>,
                _spec_dir: Option<String>,
                _filter: Option<String>,
                _search_paths: Option<Vec<String>>,
                _project_root: Option<String>,
                _auto_search_paths: Option<bool>,
            ) -> Result<String, String> {
                Err("noop".into())
            }
            async fn setting_resolve(&self, _target: Option<String>) -> Result<String, String> {
                Err("noop".into())
            }
        }
        let tmp = tempfile::tempdir().unwrap();
        let app_dir = Arc::new(AppDir::new(tmp.path().to_path_buf()));
        let cat = ResourceCatalog::new(Arc::new(NotFoundEngine), app_dir);
        let err = cat
            .read("alc://packages/missing/init.lua")
            .await
            .unwrap_err();
        let msg = err.to_string();
        assert!(
            msg.contains("pkg not found") || msg.contains("internal"),
            "got: {msg}"
        );
    }

    // 3. read_pkg_meta_ok
    #[tokio::test]
    async fn read_pkg_meta_ok() {
        let entry_json = r#"{"name":"foo","version":"1.0"}"#;
        let (cat, _tmp) = make_fake_catalog(FakeEngine::ok_meta(entry_json));
        let result = cat.read("alc://packages/foo/meta").await.unwrap();
        match &result.contents[0] {
            ResourceContents::TextResourceContents {
                text, mime_type, ..
            } => {
                assert!(text.contains("foo"));
                assert_eq!(mime_type.as_deref(), Some("application/json"));
            }
            _ => panic!("expected text"),
        }
    }

    // 4. read_pkg_meta_not_found
    #[tokio::test]
    async fn read_pkg_meta_not_found() {
        let (cat, _tmp) = make_fake_catalog(FakeEngine::noop());
        let err = cat.read("alc://packages/unknown/meta").await.unwrap_err();
        let msg = err.to_string();
        assert!(
            msg.contains("resource not found") || !msg.is_empty(),
            "got: {msg}"
        );
    }

    // 4b. read_narrative_ok
    #[tokio::test]
    async fn read_narrative_ok() {
        let md = "# mypkg\n\n## Overview\n\nsome content\n";
        let (cat, _tmp) = make_fake_catalog(FakeEngine::ok_narrative_md(md));
        let result = cat.read("alc://packages/mypkg/narrative").await.unwrap();
        assert_eq!(result.contents.len(), 1);
        match &result.contents[0] {
            ResourceContents::TextResourceContents {
                text, mime_type, ..
            } => {
                assert!(
                    text.contains("mypkg"),
                    "expected pkg name in narrative, got: {text}"
                );
                assert_eq!(mime_type.as_deref(), Some("text/markdown"));
            }
            _ => panic!("expected text resource contents"),
        }
    }

    // 4c. read_narrative_not_found
    #[tokio::test]
    async fn read_narrative_not_found() {
        // FakeEngine::noop() returns Ok(None) for pkg_get_narrative_md
        let (cat, _tmp) = make_fake_catalog(FakeEngine::noop());
        let err = cat
            .read("alc://packages/unknown_pkg/narrative")
            .await
            .unwrap_err();
        assert_eq!(
            err.code.0, -32002,
            "expected resource_not_found (-32002), got: {:?}",
            err.code
        );
    }

    // 5. read_card_get_ok
    #[tokio::test]
    async fn read_card_get_ok() {
        let (cat, _tmp) = make_fake_catalog(FakeEngine::ok_card_get(r#"{"id":"abc"}"#));
        let result = cat.read("alc://cards/abc").await.unwrap();
        match &result.contents[0] {
            ResourceContents::TextResourceContents {
                text, mime_type, ..
            } => {
                assert!(text.contains("abc"));
                assert_eq!(mime_type.as_deref(), Some("application/json"));
            }
            _ => panic!("expected text"),
        }
    }

    // 6. read_card_get_not_found (engine returns error)
    #[tokio::test]
    async fn read_card_get_not_found() {
        let (cat, _tmp) = make_fake_catalog(FakeEngine::noop());
        let err = cat.read("alc://cards/missing").await.unwrap_err();
        // NoopEngine returns "not configured" which becomes internal_error
        let msg = err.to_string();
        assert!(!msg.is_empty(), "expected non-empty error");
    }

    // 7. read_card_samples_with_pagination
    #[tokio::test]
    async fn read_card_samples_with_pagination() {
        let (cat, _tmp) = make_fake_catalog(FakeEngine::ok_card_samples(
            Some(10),
            Some(50),
            r#"{"samples":[]}"#,
        ));
        let result = cat
            .read("alc://cards/abc/samples?offset=10&limit=50")
            .await
            .unwrap();
        assert_eq!(result.contents.len(), 1);
    }

    // 8. read_card_samples_default_limit
    #[tokio::test]
    async fn read_card_samples_default_limit() {
        // When no limit query param, default should be Some(DEFAULT_CARD_SAMPLES_LIMIT).
        let (cat, _tmp) = make_fake_catalog(FakeEngine::ok_card_samples(
            None,
            Some(DEFAULT_CARD_SAMPLES_LIMIT),
            r#"{"samples":[]}"#,
        ));
        let result = cat.read("alc://cards/abc/samples").await.unwrap();
        assert_eq!(result.contents.len(), 1);
    }

    // 9. read_card_samples_rejects_unknown_query_param
    #[tokio::test]
    async fn read_card_samples_rejects_unknown_query_param() {
        // Unknown key "foo" must be rejected even when offset/limit are valid.
        let (cat, _tmp) = make_fake_catalog(FakeEngine::noop());
        let err = cat
            .read("alc://cards/abc/samples?offset=0&limit=10&foo=bar")
            .await
            .unwrap_err();
        let msg = err.to_string();
        assert!(
            msg.contains("unsupported query param") && msg.contains("foo"),
            "got: {msg}"
        );
    }

    // 10. read_scenario_ok
    #[tokio::test]
    async fn read_scenario_ok() {
        let (cat, _tmp) = make_fake_catalog(FakeEngine::ok_scenario_show("-- scenario lua"));
        let result = cat.read("alc://scenarios/myscenario").await.unwrap();
        match &result.contents[0] {
            ResourceContents::TextResourceContents {
                text, mime_type, ..
            } => {
                assert_eq!(text, "-- scenario lua");
                assert_eq!(mime_type.as_deref(), Some("text/x-lua"));
            }
            _ => panic!("expected text"),
        }
    }

    // 10. read_eval_ok
    #[tokio::test]
    async fn read_eval_ok() {
        let (cat, _tmp) = make_fake_catalog(FakeEngine::ok_eval_detail(r#"{"id":"sc_1234"}"#));
        let result = cat.read("alc://eval/sc_1234").await.unwrap();
        match &result.contents[0] {
            ResourceContents::TextResourceContents { mime_type, .. } => {
                assert_eq!(mime_type.as_deref(), Some("application/json"));
            }
            _ => panic!("expected text"),
        }
    }

    // 11. read_eval_bad_id_format
    #[tokio::test]
    async fn read_eval_bad_id_format() {
        let (cat, _tmp) = make_fake_catalog(FakeEngine::noop());
        let err = cat.read("alc://eval/nounderscore").await.unwrap_err();
        let msg = err.to_string();
        assert!(msg.contains("invalid eval_id"), "got: {msg}");
    }

    // 12. read_logs_ok
    #[tokio::test]
    async fn read_logs_ok() {
        let (cat, _tmp) = make_fake_catalog(FakeEngine::ok_log_view(
            Some(DEFAULT_LOGS_LIMIT),
            Some(DEFAULT_LOGS_MAX_CHARS),
            r#"{"entries":[]}"#,
        ));
        let result = cat.read("alc://logs/ses-abc123").await.unwrap();
        match &result.contents[0] {
            ResourceContents::TextResourceContents { mime_type, .. } => {
                assert_eq!(mime_type.as_deref(), Some("application/json"));
            }
            _ => panic!("expected text"),
        }
    }

    // 13. read_logs_with_pagination
    #[tokio::test]
    async fn read_logs_with_pagination() {
        let (cat, _tmp) = make_fake_catalog(FakeEngine::ok_log_view(
            Some(20),
            Some(5000),
            r#"{"entries":[]}"#,
        ));
        let result = cat
            .read("alc://logs/ses-abc123?limit=20&max_chars=5000")
            .await
            .unwrap();
        assert_eq!(result.contents.len(), 1);
    }

    // 14. list_templates_returns_7 — already covered above

    // ── Fix 1: pagination cap tests ──────────────────────────────────────────

    #[tokio::test]
    async fn read_logs_rejects_limit_above_cap() {
        let (cat, _tmp) = make_fake_catalog(FakeEngine::noop());
        let err = cat
            .read("alc://logs/ses-abc?limit=20000")
            .await
            .unwrap_err();
        let msg = err.to_string();
        assert!(
            msg.contains("exceeds cap"),
            "expected cap error, got: {msg}"
        );
    }

    #[tokio::test]
    async fn read_card_samples_rejects_offset_above_cap() {
        let (cat, _tmp) = make_fake_catalog(FakeEngine::noop());
        let err = cat
            .read("alc://cards/abc/samples?offset=99999999")
            .await
            .unwrap_err();
        let msg = err.to_string();
        assert!(
            msg.contains("exceeds cap"),
            "expected cap error, got: {msg}"
        );
    }

    // ── Fix 3: read_types subpath rejection ───────────────────────────────────

    #[tokio::test]
    async fn read_types_rejects_subpath() {
        let tmp = tempfile::tempdir().unwrap();
        let cat = make_test_catalog(tmp.path().to_path_buf());
        let err = cat.read("alc://types/sub/file.lua").await.unwrap_err();
        let msg = err.to_string();
        assert!(
            msg.contains("resource not found"),
            "expected resource not found, got: {msg}"
        );
    }

    // ── Fix 5: reserved-char ID rejection ────────────────────────────────────

    #[tokio::test]
    async fn read_cards_rejects_id_with_ampersand() {
        let (cat, _tmp) = make_fake_catalog(FakeEngine::noop());
        let err = cat.read("alc://cards/bad&id").await.unwrap_err();
        let msg = err.to_string();
        assert!(
            msg.contains("reserved character"),
            "expected reserved character error, got: {msg}"
        );
    }

    #[tokio::test]
    async fn read_logs_rejects_id_with_percent() {
        let (cat, _tmp) = make_fake_catalog(FakeEngine::noop());
        let err = cat.read("alc://logs/ses%20abc").await.unwrap_err();
        let msg = err.to_string();
        assert!(
            msg.contains("reserved character"),
            "expected reserved character error, got: {msg}"
        );
    }

    // ── Fix 6: eval ID strict validation ─────────────────────────────────────

    #[test]
    fn eval_id_strict_validation() {
        // Positive case
        assert!(is_valid_eval_id("mystrategy_1234567890"));
        assert!(is_valid_eval_id("my-strategy_9876543210"));
        // Negative cases
        assert!(!is_valid_eval_id("_"));
        assert!(!is_valid_eval_id("a_"));
        assert!(!is_valid_eval_id("_1"));
        assert!(!is_valid_eval_id("a_b"));
        assert!(!is_valid_eval_id("a__1"));
        assert!(!is_valid_eval_id("nounderscore"));
    }

    // ── Fix 9: parse_query accepts = inside value ─────────────────────────────

    #[test]
    fn parse_query_accepts_equals_in_value() {
        let uri = "alc://cards/x?key=a=b";
        let parsed = parse_uri(uri).unwrap();
        assert_eq!(
            parsed.query.get("key").map(|s| s.as_str()),
            Some("a=b"),
            "value after first = should be preserved"
        );
    }

    // ── ST-1: make_resource / make_template field extension tests ────────────

    #[test]
    fn make_resource_with_title_sets_title() {
        let r = make_resource(
            "alc://types/alc.d.lua",
            "alc.d.lua",
            Some("My Title"),
            "Lua type stubs",
            "text/x-lua",
            None,
        );
        assert_eq!(r.raw.title.as_deref(), Some("My Title"));
        assert!(r.annotations.is_none());
    }

    #[test]
    fn make_resource_with_size_sets_size() {
        let r = make_resource(
            "alc://types/alc.d.lua",
            "alc.d.lua",
            None,
            "desc",
            "text/x-lua",
            Some(1234),
        );
        assert_eq!(r.raw.size, Some(1234));
    }

    #[test]
    fn make_resource_without_annotations_is_none() {
        let r = make_resource(
            "alc://types/alc.d.lua",
            "alc.d.lua",
            None,
            "desc",
            "text/x-lua",
            None,
        );
        assert!(r.annotations.is_none());
    }

    #[test]
    fn make_template_with_title_sets_title() {
        let t = make_template(
            "alc://packages/{name}/init.lua",
            "package-init-lua",
            Some("Package Lua Source"),
            "Lua source of an installed package",
            Some("text/x-lua"),
        );
        assert_eq!(t.raw.title.as_deref(), Some("Package Lua Source"));
    }

    #[test]
    fn list_fixed_has_title_set() {
        let tmp = tempfile::tempdir().unwrap();
        let catalog = make_test_catalog(tmp.path().to_path_buf());
        let fixed = catalog.list_fixed();
        assert!(
            fixed.iter().any(|r| r.raw.title.is_some()),
            "at least one fixed resource should have a title"
        );
    }

    #[test]
    fn list_templates_have_titles() {
        let tmp = tempfile::tempdir().unwrap();
        let catalog = make_test_catalog(tmp.path().to_path_buf());
        let templates = catalog.list_templates();
        assert!(
            templates.iter().all(|t| t.raw.title.is_some()),
            "all templates should have a title"
        );
    }

    // ── ST-1: resource_not_found error code tests ────────────────────────────

    #[tokio::test]
    async fn read_types_bad_segment_returns_resource_not_found() {
        let tmp = tempfile::tempdir().unwrap();
        let catalog = make_test_catalog(tmp.path().to_path_buf());
        let err = catalog
            .read("alc://types/sub/unknown.lua")
            .await
            .unwrap_err();
        // error code -32002 = RESOURCE_NOT_FOUND
        assert_eq!(err.code.0, -32002, "expected -32002, got: {:?}", err.code);
    }

    #[tokio::test]
    async fn read_types_missing_file_returns_resource_not_found() {
        let tmp = tempfile::tempdir().unwrap();
        let catalog = make_test_catalog(tmp.path().to_path_buf());
        let err = catalog.read("alc://types/alc.d.lua").await.unwrap_err();
        assert_eq!(err.code.0, -32002, "expected -32002, got: {:?}", err.code);
    }

    #[tokio::test]
    async fn read_packages_meta_not_found_returns_resource_not_found() {
        let (cat, _tmp) = make_fake_catalog(FakeEngine::noop());
        let err = cat.read("alc://packages/unknown/meta").await.unwrap_err();
        // noop engine returns "noop" which is mapped via err_to_mcp (internal_error),
        // but the wildcard arm should return resource_not_found.
        // Let's verify the wildcard arm (bad segment count):
        let err2 = cat
            .read("alc://packages/unknown/bad/extra")
            .await
            .unwrap_err();
        assert_eq!(
            err2.code.0, -32002,
            "wildcard arm should return -32002, got: {:?}",
            err2.code
        );
        // The not-found path (pkg not found) error code depends on engine response.
        // We check noop -> internal (not resource_not_found) for this case.
        let _ = err; // suppress unused warning
    }

    #[tokio::test]
    async fn read_packages_wildcard_returns_resource_not_found() {
        let (cat, _tmp) = make_fake_catalog(FakeEngine::noop());
        // 3-segment path that doesn't match any arm → wildcard
        let err = cat.read("alc://packages/a/b/c").await.unwrap_err();
        assert_eq!(err.code.0, -32002, "expected -32002, got: {:?}", err.code);
    }

    #[tokio::test]
    async fn read_cards_wildcard_returns_resource_not_found() {
        let (cat, _tmp) = make_fake_catalog(FakeEngine::noop());
        // 3-segment path that doesn't match [card_id] or [card_id, "samples"]
        let err = cat.read("alc://cards/a/b/c").await.unwrap_err();
        assert_eq!(err.code.0, -32002, "expected -32002, got: {:?}", err.code);
    }

    #[tokio::test]
    async fn read_scenarios_wildcard_returns_resource_not_found() {
        let (cat, _tmp) = make_fake_catalog(FakeEngine::noop());
        // 2-segment path → wildcard in read_scenarios
        let err = cat.read("alc://scenarios/a/b").await.unwrap_err();
        assert_eq!(err.code.0, -32002, "expected -32002, got: {:?}", err.code);
    }

    #[tokio::test]
    async fn read_eval_wildcard_returns_resource_not_found() {
        let (cat, _tmp) = make_fake_catalog(FakeEngine::noop());
        let err = cat.read("alc://eval/a/b").await.unwrap_err();
        assert_eq!(err.code.0, -32002, "expected -32002, got: {:?}", err.code);
    }

    #[tokio::test]
    async fn read_logs_wildcard_returns_resource_not_found() {
        let (cat, _tmp) = make_fake_catalog(FakeEngine::noop());
        let err = cat.read("alc://logs/a/b").await.unwrap_err();
        assert_eq!(err.code.0, -32002, "expected -32002, got: {:?}", err.code);
    }

    // ── ST-2: extract_template_vars ───────────────────────────────────────────

    // T1: happy path — extract single variable from packages template
    #[test]
    fn extract_template_vars_single_var() {
        let vars = extract_template_vars("alc://packages/{name}/init.lua");
        assert_eq!(vars, vec!["name"]);
    }

    // T1: happy path — extract single variable from cards template
    #[test]
    fn extract_template_vars_card_id() {
        let vars = extract_template_vars("alc://cards/{card_id}/samples");
        assert_eq!(vars, vec!["card_id"]);
    }

    // T2: boundary — no variables in static URI
    #[test]
    fn extract_template_vars_no_vars() {
        let vars = extract_template_vars("alc://types/alc.d.lua");
        assert!(vars.is_empty());
    }

    // T2: boundary — empty string
    #[test]
    fn extract_template_vars_empty_template() {
        let vars = extract_template_vars("");
        assert!(vars.is_empty());
    }

    // T2: boundary — unclosed brace is ignored gracefully
    #[test]
    fn extract_template_vars_unclosed_brace() {
        let vars = extract_template_vars("alc://packages/{name");
        assert!(vars.is_empty(), "unclosed brace should produce no vars");
    }

    // T1: happy path — two variables in one template
    #[test]
    fn extract_template_vars_two_vars() {
        let vars = extract_template_vars("alc://{service}/{id}");
        assert_eq!(vars, vec!["service", "id"]);
    }

    // ── ST-2: service_from_template ──────────────────────────────────────────

    // T1: happy path — packages service
    #[test]
    fn service_from_template_packages() {
        assert_eq!(
            service_from_template("alc://packages/{name}/init.lua"),
            Some("packages")
        );
    }

    // T2: boundary — no alc:// prefix returns None
    #[test]
    fn service_from_template_wrong_scheme() {
        assert_eq!(service_from_template("https://foo/bar"), None);
    }

    // T2: boundary — empty template returns None
    #[test]
    fn service_from_template_empty() {
        assert_eq!(service_from_template(""), None);
    }

    // ── ST-2: CompletionCandidates::from_all ─────────────────────────────────

    // T1: happy path — small list returned as-is
    #[test]
    fn completion_candidates_from_all_small() {
        let items: Vec<String> = (0..5).map(|i| format!("item-{i}")).collect();
        let c = CompletionCandidates::from_all(items.clone());
        assert_eq!(c.values, items);
        assert_eq!(c.total, 5);
        assert!(!c.has_more);
    }

    // T2: boundary — exactly 100 items (no truncation)
    #[test]
    fn completion_candidates_from_all_at_cap() {
        let items: Vec<String> = (0..100).map(|i| format!("item-{i}")).collect();
        let c = CompletionCandidates::from_all(items);
        assert_eq!(c.values.len(), 100);
        assert_eq!(c.total, 100);
        assert!(!c.has_more);
    }

    // T2: boundary — 101 items triggers truncation
    #[test]
    fn completion_candidates_from_all_over_cap() {
        let items: Vec<String> = (0..101).map(|i| format!("item-{i}")).collect();
        let c = CompletionCandidates::from_all(items);
        assert_eq!(c.values.len(), 100);
        assert_eq!(c.total, 101);
        assert!(c.has_more);
    }

    // T1: empty() produces zeroed result
    #[test]
    fn completion_candidates_empty() {
        let c = CompletionCandidates::empty();
        assert!(c.values.is_empty());
        assert_eq!(c.total, 0);
        assert!(!c.has_more);
    }

    // ── ST-2: complete_resource_arg — pkg domain ──────────────────────────────

    // T1: happy path — packages completion returns filtered names
    #[tokio::test]
    async fn complete_resource_arg_packages_prefix_filter() {
        let engine = FakeEngine {
            pkg_list: Some(Ok(
                r#"{"packages":[{"name":"cot"},{"name":"sc"},{"name":"cove"}]}"#.into(),
            )),
            ..FakeEngine::noop()
        };
        let (cat, _tmp) = make_fake_catalog(engine);
        let result = cat
            .complete_resource_arg("alc://packages/{name}/init.lua", "name", "c")
            .await;
        // "cot", "cove" start with "c" but "sc" does not
        assert_eq!(result.total, 2);
        assert!(!result.has_more);
        let mut got = result.values.clone();
        got.sort();
        assert_eq!(got, vec!["cot", "cove"]);
    }

    // T1: happy path — empty prefix returns all packages
    #[tokio::test]
    async fn complete_resource_arg_packages_empty_prefix() {
        let engine = FakeEngine {
            pkg_list: Some(Ok(r#"{"packages":[{"name":"cot"},{"name":"sc"}]}"#.into())),
            ..FakeEngine::noop()
        };
        let (cat, _tmp) = make_fake_catalog(engine);
        let result = cat
            .complete_resource_arg("alc://packages/{name}/init.lua", "name", "")
            .await;
        assert_eq!(result.total, 2);
        assert!(!result.has_more);
    }

    // T3: error path — engine error returns empty result (no MCP error)
    #[tokio::test]
    async fn complete_resource_arg_engine_error_returns_empty() {
        let engine = FakeEngine {
            pkg_list: Some(Err("storage error".into())),
            ..FakeEngine::noop()
        };
        let (cat, _tmp) = make_fake_catalog(engine);
        let result = cat
            .complete_resource_arg("alc://packages/{name}/init.lua", "name", "")
            .await;
        assert!(result.values.is_empty());
        assert_eq!(result.total, 0);
        assert!(!result.has_more);
    }

    // T2: unknown domain returns empty result
    #[tokio::test]
    async fn complete_resource_arg_unknown_domain_returns_empty() {
        let (cat, _tmp) = make_fake_catalog(FakeEngine::noop());
        let result = cat
            .complete_resource_arg("alc://logs/{session_id}", "session_id", "")
            .await;
        assert!(result.values.is_empty());
        assert_eq!(result.total, 0);
    }

    // T2: unknown arg name for known service returns empty result
    #[tokio::test]
    async fn complete_resource_arg_unknown_arg_returns_empty() {
        let (cat, _tmp) = make_fake_catalog(FakeEngine::noop());
        let result = cat
            .complete_resource_arg("alc://packages/{name}/init.lua", "unknown_arg", "")
            .await;
        assert!(result.values.is_empty());
    }

    // T2: template with no valid alc:// prefix returns empty
    #[tokio::test]
    async fn complete_resource_arg_bad_template_returns_empty() {
        let (cat, _tmp) = make_fake_catalog(FakeEngine::noop());
        let result = cat
            .complete_resource_arg("https://other/{name}", "name", "")
            .await;
        assert!(result.values.is_empty());
    }

    // E2E parity note: seeding 100+ eval results via the MCP test harness
    // (which relies on the real binary + on-disk evals dir) is impractical —
    // each eval result requires a full alc_eval invocation with an LLM mock.
    // The invariant is therefore verified here at the unit level.
    //
    // L-5 regression: prefix filter must happen BEFORE cap truncation.
    // Build 105 eval IDs matching prefix "strat-a" plus 5 matching "strat-b".
    // With prefix="strat-a", result must be capped at 100 with has_more=true
    // and total=105 (not 101 from a pre-cap fetch).
    #[tokio::test]
    async fn list_eval_ids_prefix_filter_before_cap() {
        // Build the eval_history JSON response with 110 entries (105 "strat-a", 5 "strat-b").
        let mut evals: Vec<serde_json::Value> = (0..105)
            .map(|i| {
                serde_json::json!({
                    "eval_id": format!("strat-a_{}", 1_000_000_u64 + i),
                    "strategy": "strat-a",
                    "timestamp": 1_000_000_u64 + i,
                })
            })
            .collect();
        evals.extend((0..5).map(|i| {
            serde_json::json!({
                "eval_id": format!("strat-b_{}", 2_000_000_u64 + i),
                "strategy": "strat-b",
                "timestamp": 2_000_000_u64 + i,
            })
        }));
        let history_json = serde_json::json!({ "evals": evals }).to_string();

        let engine = FakeEngine {
            eval_history: Some(Ok(history_json)),
            ..FakeEngine::noop()
        };
        let (cat, _tmp) = make_fake_catalog(engine);
        let result = cat
            .complete_resource_arg("alc://eval/{result_id}", "result_id", "strat-a")
            .await;

        assert_eq!(
            result.total, 105,
            "total must reflect all prefix-matching entries before cap"
        );
        assert_eq!(
            result.values.len(),
            100,
            "values must be capped at COMPLETION_CAP=100"
        );
        assert!(result.has_more, "has_more must be true when total > cap");
    }
}
