// ABOUTME: Bootstrap router that maps task classifications to worker routing decisions.
// ABOUTME: Selects worker type, model, and delegation level based on capabilities.

use super::types::{
    DelegationType, RoutingDecision, SkillRef, TaskClassification, TaskComplexity,
    UserCapabilities, WorkerType,
};

const PRIVATE_MODELS_PUBLISHER_SLUG: &str = "seren-private-models";

/// Preferred models for code tasks (ordered by capability).
const CODE_PREFERRED_MODELS: &[&str] = &["anthropic/claude-opus-4-6", "openai/gpt-5.3"];

/// Preferred models for simple Q&A (ordered by speed/cost).
const SIMPLE_PREFERRED_MODELS: &[&str] = &[
    "minimax/minimax-m2.5",
    "google/gemini-3-flash-preview",
    "google/gemini-2.5-flash",
    "anthropic/claude-haiku-4.5",
    "moonshotai/kimi-k2.5",
    "z-ai/glm-5.1",
    "anthropic/claude-sonnet-4",
];

/// Task types eligible for McpPublisher routing when gateway tools are available.
/// Explicit allowlist: any task type NOT listed here routes to ChatModel instead.
///
/// Currently EMPTY — all tasks route through ChatModel, which has access to the
/// full tool inventory from all publishers via the gateway tool bridge and supports
/// multi-round tool calls. McpPublisher sends to a single publisher's chat/completions
/// endpoint, which fails when a task needs tools from multiple publishers, multi-round
/// execution, or when no publisher meaningfully matches the query (random fallback).
///
/// The McpPublisher infrastructure remains available. Re-enable per-task-type only
/// when there is high confidence that single-publisher routing is correct for that type
/// (e.g., a future "direct_publisher_query" task type with an explicit publisher target).
const MCP_PUBLISHER_ELIGIBLE_TASK_TYPES: &[&str] = &[];

/// Fallback models for context-overflow errors (all have 1M+ token windows).
/// Tried in order when the primary model rejects a request for exceeding its
/// context limit (e.g. Claude 4.5 at 200K).
pub const LARGE_CONTEXT_FALLBACK_MODELS: &[&str] = &[
    "google/gemini-3.1-pro-preview",
    "google/gemini-3-flash-preview",
    "anthropic/claude-opus-4.6",
];

/// Route a classified task to the appropriate worker.
///
/// Bootstrap routing logic:
/// 1. Code generation + file system + local agent available → LocalAgent
/// 2. Requires tools + tools available → ChatModel (with tools)
/// 3. Default → ChatModel (without tools)
///
/// Model selection:
/// - Code tasks: prefer the most capable model
/// - Simple Q&A: prefer a fast/cheap model
///
/// Delegation: LocalAgent defaults to FullHandoff (agent manages its own loop);
/// ChatModel and McpPublisher default to InLoop (trust graduation can override).
pub fn route(
    classification: &TaskClassification,
    capabilities: &UserCapabilities,
    query: &str,
) -> RoutingDecision {
    let worker_type = select_worker_type(classification, capabilities);
    let model_id = select_model(classification, capabilities);
    let selected_skills = resolve_skills(classification, capabilities);
    let reason = build_reason(classification, &worker_type, &model_id);

    let publisher_slug = extract_publisher_slug(&worker_type, capabilities, query);

    let delegation = match worker_type {
        WorkerType::LocalAgent => DelegationType::FullHandoff,
        _ => DelegationType::InLoop,
    };

    RoutingDecision {
        worker_type,
        model_id,
        delegation,
        reason,
        selected_skills,
        publisher_slug,
        reasoning_effort: capabilities.reasoning_effort.clone(),
        project_root: capabilities.project_root.clone(),
    }
}

/// Select the worker type based on task requirements and available capabilities.
fn select_worker_type(
    classification: &TaskClassification,
    capabilities: &UserCapabilities,
) -> WorkerType {
    // McpPublisher routing runs FIRST, before the private-chat guard below,
    // so that seren-private threads can still reach publishers when the task
    // requires tools and gateway tools are available. Without this ordering
    // the early `force_private_chat → ChatModel` return would strip all
    // publisher/MCP tool access from private chat — the root cause of #1529.
    //
    // Only task types in MCP_PUBLISHER_ELIGIBLE_TASK_TYPES can route here.
    // The requires_file_system guard stays as defense-in-depth: publishers
    // cannot satisfy local file operations regardless of task type.
    if MCP_PUBLISHER_ELIGIBLE_TASK_TYPES.contains(&classification.task_type.as_str())
        && classification.requires_tools
        && !classification.requires_file_system
        && has_any_gateway_tool(capabilities)
    {
        return WorkerType::McpPublisher;
    }

    // Private chat uses the organization's private model for direct chat.
    // LocalAgent is excluded — it bypasses the private model pipeline.
    if capabilities.force_private_chat {
        return WorkerType::ChatModel;
    }

    // Code generation with file system access + active local agent session → LocalAgent
    if classification.task_type == "code_generation"
        && classification.requires_file_system
        && capabilities.has_local_agent
        && capabilities.active_agent_session_id.is_some()
    {
        return WorkerType::LocalAgent;
    }

    // Everything else → ChatModel
    WorkerType::ChatModel
}

/// Parse a gateway tool name into its publisher slug.
///
/// Gateway tool names follow the pattern `gateway__<publisher-slug>__<tool-name>`.
/// Returns the slug only when both separators are present.
fn parse_gateway_slug(tool_name: &str) -> Option<&str> {
    let rest = tool_name.strip_prefix("gateway__")?;
    let slug_end = rest.find("__")?;
    Some(&rest[..slug_end])
}

/// Check if any gateway tool exists in the available tools.
fn has_any_gateway_tool(capabilities: &UserCapabilities) -> bool {
    capabilities
        .available_tools
        .iter()
        .any(|t| parse_gateway_slug(t).is_some())
}

/// Extract the most relevant publisher slug from available gateway tools
/// based on the user's query. Falls back to the first gateway slug when
/// no query-based match is found.
fn extract_gateway_slug(capabilities: &UserCapabilities, query: &str) -> Option<String> {
    // Collect all unique publisher slugs from gateway tools
    let mut slug_tools: std::collections::HashMap<&str, Vec<&str>> =
        std::collections::HashMap::new();
    for tool_name in &capabilities.available_tools {
        if let Some(slug) = parse_gateway_slug(tool_name) {
            slug_tools.entry(slug).or_default().push(tool_name.as_str());
        }
    }

    if slug_tools.is_empty() {
        return None;
    }

    // If only one publisher, return it
    if slug_tools.len() == 1 {
        return slug_tools.keys().next().map(|s| s.to_string());
    }

    // Score each publisher by how many of its tool names match query terms.
    // Normalize query terms to lowercase for case-insensitive matching.
    let query_lower = query.to_lowercase();
    let query_terms: Vec<&str> = query_lower.split_whitespace().collect();

    if query_terms.is_empty() {
        return slug_tools.keys().next().map(|s| s.to_string());
    }

    let mut best_slug: Option<&str> = None;
    let mut best_score: usize = 0;

    for (slug, tools) in &slug_tools {
        let mut score: usize = 0;
        // Score the slug itself against query terms
        let slug_lower = slug.to_lowercase();
        for term in &query_terms {
            if slug_lower.contains(term) {
                score += 10; // Strong signal: slug matches query
            }
        }
        // Score individual tool names
        for tool_name in tools {
            let tool_lower = tool_name.to_lowercase();
            for term in &query_terms {
                if tool_lower.contains(term) {
                    score += 1;
                }
            }
        }
        if score > best_score {
            best_score = score;
            best_slug = Some(slug);
        }
    }

    // Fall back to first slug if no query match
    best_slug
        .or_else(|| slug_tools.keys().next().copied())
        .map(String::from)
}

/// Extract the publisher slug for routing decisions.
///
/// Only returns a slug when the worker type is McpPublisher and a valid
/// `gateway__<slug>__<tool>` tool exists in the capabilities.
fn extract_publisher_slug(
    worker_type: &WorkerType,
    capabilities: &UserCapabilities,
    query: &str,
) -> Option<String> {
    if *worker_type != WorkerType::McpPublisher {
        return if *worker_type == WorkerType::ChatModel && capabilities.force_private_chat {
            Some(PRIVATE_MODELS_PUBLISHER_SLUG.to_string())
        } else {
            None
        };
    }
    extract_gateway_slug(capabilities, query)
}

/// Select the best available model for the task.
///
/// Priority:
/// 1. User's explicit selection from the UI
/// 2. Thompson sampling rankings (satisfaction-driven, computed by service layer)
/// 3. Hardcoded preference lists (cold start fallback)
fn select_model(classification: &TaskClassification, capabilities: &UserCapabilities) -> String {
    if capabilities.force_private_chat {
        return capabilities
            .selected_model
            .as_ref()
            .filter(|selected| !selected.trim().is_empty())
            .cloned()
            .unwrap_or_else(|| "organization/private-model".to_string());
    }

    // 1. Respect the user's explicit model selection
    if let Some(ref selected) = capabilities.selected_model {
        if !selected.is_empty() {
            return selected.clone();
        }
    }

    // 2. Use satisfaction-driven rankings when available
    if !capabilities.model_rankings.is_empty() {
        for (model_id, _score) in &capabilities.model_rankings {
            if capabilities.available_models.iter().any(|m| m == model_id) {
                return model_id.clone();
            }
        }
    }

    // 3. Fallback to hardcoded preference lists (cold start)
    let preferred = match classification.complexity {
        TaskComplexity::Complex | TaskComplexity::Moderate => CODE_PREFERRED_MODELS,
        TaskComplexity::Simple => SIMPLE_PREFERRED_MODELS,
    };

    // Override: code tasks always prefer capable models regardless of complexity
    let preferred = if classification.task_type == "code_generation" {
        CODE_PREFERRED_MODELS
    } else {
        preferred
    };

    // Find the first preferred model that's available
    for model in preferred {
        if capabilities.available_models.iter().any(|m| m == model) {
            return model.to_string();
        }
    }

    // Fallback: use the first available model, or a sensible default
    capabilities
        .available_models
        .first()
        .cloned()
        .unwrap_or_else(|| "arcee-ai/trinity-large-thinking".to_string())
}

/// Resolve skill slugs from the classification to full SkillRef objects.
fn resolve_skills(
    classification: &TaskClassification,
    capabilities: &UserCapabilities,
) -> Vec<SkillRef> {
    classification
        .relevant_skills
        .iter()
        .filter_map(|slug| {
            capabilities
                .installed_skills
                .iter()
                .find(|s| s.slug == *slug)
                .cloned()
        })
        .collect()
}

/// Build a human-readable reason string for the transition announcement.
fn build_reason(
    classification: &TaskClassification,
    worker_type: &WorkerType,
    model_id: &str,
) -> String {
    let model_name = humanize_model_id(model_id);
    let task_desc = humanize_task_type(&classification.task_type);

    match worker_type {
        WorkerType::CloudAgent => {
            "Working with your organization's private cloud agent".to_string()
        }
        WorkerType::LocalAgent => {
            format!("Working with agent on {}", task_desc)
        }
        WorkerType::ChatModel => {
            if model_id == "organization/private-model" {
                return "Working with your organization's private models".to_string();
            }
            format!("Working with {} on {}", model_name, task_desc)
        }
        WorkerType::McpPublisher => {
            format!("Working with publisher on {}", task_desc)
        }
    }
}

/// Convert a model ID to a human-readable name.
fn humanize_model_id(model_id: &str) -> &str {
    match model_id {
        "anthropic/claude-opus-4-6" => "Claude Opus",
        "anthropic/claude-opus-4.5" => "Claude Opus",
        "anthropic/claude-sonnet-4" => "Claude Sonnet",
        "anthropic/claude-haiku-4.5" => "Claude Haiku",
        "openai/gpt-5.3" => "GPT-5.3",
        "openai/gpt-5" => "GPT-5",
        "openai/gpt-4o" => "GPT-4o",
        "openai/gpt-4o-mini" => "GPT-4o Mini",
        "anthropic/claude-opus-4.6" => "Claude Opus 4.6",
        "anthropic/claude-sonnet-4.6" => "Claude Sonnet 4.6",
        "us.anthropic.claude-opus-4-6-v1" => "Claude Opus 4.6",
        "us.anthropic.claude-sonnet-4-6" => "Claude Sonnet 4.6",
        "us.anthropic.claude-sonnet-4" => "Claude Sonnet 4",
        "google/gemini-3.1-pro-preview" => "Gemini 3.1 Pro",
        "google/gemini-2.5-pro" => "Gemini Pro",
        "google/gemini-2.5-flash" => "Gemini Flash",
        "google/gemini-3-flash-preview" => "Gemini 3 Flash",
        "moonshotai/kimi-k2.5" => "Kimi K2.5",
        "z-ai/glm-5.1" => "GLM 5.1",
        "organization/private-model" => "Private Models",
        _ => model_id,
    }
}

/// HTTP status codes that indicate a transient failure eligible for model reroute.
const REROUTABLE_STATUS_CODES: &[u16] = &[408, 429, 502, 503, 504];

/// Maximum number of reroute attempts before giving up.
pub const MAX_REROUTE_ATTEMPTS: usize = 2;

/// Check whether an error message indicates a transient failure eligible for reroute.
pub fn is_reroutable_error(error_message: &str) -> bool {
    // Context-overflow errors are always reroutable — a larger-context model
    // can handle them without any changes to the request payload.
    if is_context_overflow_error(error_message) {
        return true;
    }

    // Don't reroute auth or client errors
    if error_message.contains("401")
        || error_message.contains("403")
        || error_message.contains("400")
        || error_message.contains("API key")
        || error_message.contains("Insufficient credits")
    {
        return false;
    }

    REROUTABLE_STATUS_CODES
        .iter()
        .any(|code| error_message.contains(&code.to_string()))
}

/// Maximum number of retries for network transport errors before giving up.
pub const MAX_NETWORK_RETRIES: usize = 5;

/// Check whether an error is a network transport failure (not an HTTP status error).
///
/// These errors occur when the HTTP request cannot be sent at all — DNS resolution
/// failure, connection refused, TLS handshake error, stream reset, etc.  They should
/// be retried on the **same model** with exponential backoff rather than rerouted to a
/// different model, because all models share the same gateway endpoint.
pub fn is_network_transport_error(error_message: &str) -> bool {
    let lower = error_message.to_lowercase();
    lower.contains("error sending request")
        || lower.contains("connection refused")
        || lower.contains("connection reset")
        || lower.contains("dns error")
        || lower.contains("timed out")
        || lower.contains("connection closed before message completed")
        || lower.contains("broken pipe")
        || lower.contains("network is unreachable")
}

/// Check whether an error indicates the prompt exceeded the model's context window.
pub fn is_context_overflow_error(error_message: &str) -> bool {
    error_message.contains("prompt is too long")
        || error_message.contains("context_length_exceeded")
        || error_message.contains("maximum context length")
}

/// Pick the first large-context fallback model not yet tried.
pub fn get_large_context_fallback(tried_models: &[String]) -> Option<&'static str> {
    LARGE_CONTEXT_FALLBACK_MODELS
        .iter()
        .find(|m| !tried_models.contains(&m.to_string()))
        .copied()
}

/// Select a fallback model after a transient failure, ranked by satisfaction signals.
///
/// Queries the local eval_signals table for models with positive satisfaction
/// for the given task_type, then falls back to the hardcoded preference list.
/// Excludes any models already tried.
pub fn reroute_on_failure(
    conn: &rusqlite::Connection,
    task_type: &str,
    tried_models: &[String],
    available_models: &[String],
    classification: &TaskClassification,
) -> Option<(String, String)> {
    // 1. Query satisfaction-ranked models from eval_signals
    if let Ok(ranked) = query_satisfaction_ranked_models(conn, task_type, tried_models) {
        for (model_id, score) in &ranked {
            if available_models.iter().any(|m| m == model_id) {
                let reason = format!(
                    "Rerouted to {} (rated helpful for {}, score: {})",
                    humanize_model_id(model_id),
                    humanize_task_type(task_type),
                    score,
                );
                return Some((model_id.clone(), reason));
            }
        }
    }

    // 2. Fall back to hardcoded preference list, skipping tried models
    let preferred = if classification.task_type == "code_generation" {
        CODE_PREFERRED_MODELS
    } else {
        match classification.complexity {
            TaskComplexity::Complex | TaskComplexity::Moderate => CODE_PREFERRED_MODELS,
            TaskComplexity::Simple => SIMPLE_PREFERRED_MODELS,
        }
    };

    for model in preferred {
        let model_str = model.to_string();
        if !tried_models.contains(&model_str) && available_models.iter().any(|m| m == model) {
            let reason = format!(
                "Rerouted to {} for {}",
                humanize_model_id(model),
                humanize_task_type(task_type),
            );
            return Some((model_str, reason));
        }
    }

    // 3. Try any available model not yet tried
    for model in available_models {
        if !tried_models.contains(model) {
            let reason = format!(
                "Rerouted to {} for {}",
                humanize_model_id(model),
                humanize_task_type(task_type),
            );
            return Some((model.clone(), reason));
        }
    }

    None
}

/// Query eval_signals for models ranked by positive satisfaction count.
///
/// Returns (model_id, positive_count) pairs sorted descending, excluding tried models.
fn query_satisfaction_ranked_models(
    conn: &rusqlite::Connection,
    task_type: &str,
    tried_models: &[String],
) -> Result<Vec<(String, i64)>, rusqlite::Error> {
    let mut stmt = conn.prepare(
        "SELECT model_id, SUM(CASE WHEN satisfaction = 1 THEN 1 ELSE 0 END) as positive_count
         FROM eval_signals
         WHERE task_type = ?1 AND model_id IS NOT NULL
         GROUP BY model_id
         HAVING positive_count > 0
         ORDER BY positive_count DESC",
    )?;

    let rows = stmt.query_map(rusqlite::params![task_type], |row| {
        Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
    })?;

    let mut results = Vec::new();
    for row in rows {
        if let Ok((model_id, score)) = row {
            if !tried_models.contains(&model_id) {
                results.push((model_id, score));
            }
        }
    }

    Ok(results)
}

/// Convert a task type to a human-readable description.
fn humanize_task_type(task_type: &str) -> &str {
    match task_type {
        "code_generation" => "code generation",
        "file_operations" => "file operations",
        "research" => "research",
        "document_generation" => "document generation",
        "general_chat" => "your question",
        _ => task_type,
    }
}

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

    fn make_capabilities(has_agent: bool, models: &[&str], tools: &[&str]) -> UserCapabilities {
        UserCapabilities {
            has_local_agent: has_agent,
            agent_type: if has_agent {
                Some("claude-code".to_string())
            } else {
                None
            },
            active_agent_session_id: if has_agent {
                Some("test-session-id".to_string())
            } else {
                None
            },
            selected_model: None,
            force_private_chat: false,
            private_chat_deployment_id: None,
            available_models: models.iter().map(|m| m.to_string()).collect(),
            available_tools: tools.iter().map(|t| t.to_string()).collect(),
            tool_definitions: vec![],
            installed_skills: vec![],
            model_rankings: vec![],
            reasoning_effort: None,
            project_root: None,
        }
    }

    fn make_capabilities_with_skills(
        has_agent: bool,
        models: &[&str],
        skills: Vec<SkillRef>,
    ) -> UserCapabilities {
        UserCapabilities {
            has_local_agent: has_agent,
            agent_type: if has_agent {
                Some("claude-code".to_string())
            } else {
                None
            },
            active_agent_session_id: if has_agent {
                Some("test-session-id".to_string())
            } else {
                None
            },
            selected_model: None,
            force_private_chat: false,
            private_chat_deployment_id: None,
            available_models: models.iter().map(|m| m.to_string()).collect(),
            available_tools: vec![],
            tool_definitions: vec![],
            installed_skills: skills,
            model_rankings: vec![],
            reasoning_effort: None,
            project_root: None,
        }
    }

    fn make_classification(
        task_type: &str,
        requires_tools: bool,
        requires_fs: bool,
    ) -> TaskClassification {
        TaskClassification {
            task_type: task_type.to_string(),
            requires_tools,
            requires_file_system: requires_fs,
            complexity: if task_type == "general_chat" {
                TaskComplexity::Simple
            } else {
                TaskComplexity::Moderate
            },
            relevant_skills: vec![],
        }
    }

    fn make_skill(slug: &str, name: &str) -> SkillRef {
        SkillRef {
            slug: slug.to_string(),
            name: name.to_string(),
            description: String::new(),
            tags: vec![],
            path: format!("/skills/{}/SKILL.md", slug),
        }
    }

    // =========================================================================
    // Worker Type Selection
    // =========================================================================

    #[test]
    fn routes_code_generation_with_agent_to_local_agent() {
        let classification = make_classification("code_generation", true, true);
        let capabilities = make_capabilities(true, &["anthropic/claude-opus-4-6"], &["firecrawl"]);
        let decision = route(&classification, &capabilities, "test query");
        assert_eq!(decision.worker_type, WorkerType::LocalAgent);
    }

    #[test]
    fn routes_private_org_policy_to_private_models_chat() {
        let classification = make_classification("general_chat", false, false);
        let mut capabilities = make_capabilities(false, &["anthropic/claude-sonnet-4"], &[]);
        capabilities.force_private_chat = true;
        capabilities.private_chat_deployment_id = Some("deployment_123".to_string());
        capabilities.selected_model = Some("us.anthropic.claude-opus-4-6-v1".to_string());
        let decision = route(&classification, &capabilities, "test query");
        assert_eq!(decision.worker_type, WorkerType::ChatModel);
        assert_eq!(decision.publisher_slug, Some(PRIVATE_MODELS_PUBLISHER_SLUG.to_string()));
        assert_eq!(decision.model_id, "us.anthropic.claude-opus-4-6-v1");
    }

    #[test]
    fn blank_private_org_deployment_id_does_not_route_to_cloud_agent() {
        let classification = make_classification("general_chat", false, false);
        let mut capabilities = make_capabilities(false, &["anthropic/claude-sonnet-4"], &[]);
        capabilities.force_private_chat = true;
        capabilities.private_chat_deployment_id = Some("   ".to_string());
        let decision = route(&classification, &capabilities, "test query");
        assert_eq!(decision.worker_type, WorkerType::ChatModel);
        assert_eq!(decision.publisher_slug, Some(PRIVATE_MODELS_PUBLISHER_SLUG.to_string()));
        assert_eq!(decision.model_id, "organization/private-model");
    }

    #[test]
    fn forced_private_chat_overrides_local_agent_routing() {
        let classification = make_classification("code_generation", true, true);
        let mut capabilities =
            make_capabilities(true, &["anthropic/claude-opus-4-6"], &["firecrawl"]);
        capabilities.force_private_chat = true;
        let decision = route(&classification, &capabilities, "test query");
        assert_eq!(decision.worker_type, WorkerType::ChatModel);
        assert_eq!(decision.publisher_slug, Some(PRIVATE_MODELS_PUBLISHER_SLUG.to_string()));
        assert_eq!(decision.model_id, "organization/private-model");
    }

    #[test]
    fn routes_code_generation_without_active_session_to_chat_model() {
        let classification = make_classification("code_generation", true, true);
        let mut capabilities =
            make_capabilities(true, &["anthropic/claude-opus-4-6"], &["firecrawl"]);
        // Agent is available but no active session — should fall back to ChatModel
        capabilities.active_agent_session_id = None;
        let decision = route(&classification, &capabilities, "test query");
        assert_eq!(decision.worker_type, WorkerType::ChatModel);
    }

    #[test]
    fn routes_code_generation_without_agent_to_chat_model() {
        let classification = make_classification("code_generation", true, true);
        let capabilities = make_capabilities(false, &["anthropic/claude-sonnet-4"], &["firecrawl"]);
        let decision = route(&classification, &capabilities, "test query");
        assert_eq!(decision.worker_type, WorkerType::ChatModel);
    }

    #[test]
    fn routes_general_chat_to_chat_model() {
        let classification = make_classification("general_chat", false, false);
        let capabilities = make_capabilities(true, &["anthropic/claude-sonnet-4"], &[]);
        let decision = route(&classification, &capabilities, "test query");
        assert_eq!(decision.worker_type, WorkerType::ChatModel);
    }

    #[test]
    fn routes_research_with_non_mcp_tools_to_chat_model() {
        let classification = make_classification("research", true, false);
        let capabilities =
            make_capabilities(false, &["anthropic/claude-sonnet-4"], &["web_search"]);
        let decision = route(&classification, &capabilities, "test query");
        assert_eq!(decision.worker_type, WorkerType::ChatModel);
    }

    #[test]
    fn routes_research_with_gateway_tools_to_chat_model_not_publisher() {
        // Research routes to ChatModel (not McpPublisher) so it has access to
        // all tools from all publishers, not just one. (#1343)
        let classification = make_classification("research", true, false);
        let capabilities = make_capabilities(
            false,
            &["anthropic/claude-sonnet-4"],
            &["gateway__firecrawl-serenai__scrape"],
        );
        let decision = route(&classification, &capabilities, "test query");
        assert_eq!(decision.worker_type, WorkerType::ChatModel);
        assert_eq!(decision.publisher_slug, None);
    }

    #[test]
    fn local_mcp_tools_do_not_trigger_publisher_routing() {
        let classification = make_classification("research", true, false);
        let capabilities = make_capabilities(
            false,
            &["anthropic/claude-sonnet-4"],
            &["mcp__playwright__click"],
        );
        let decision = route(&classification, &capabilities, "test query");
        assert_eq!(decision.worker_type, WorkerType::ChatModel);
        assert_eq!(decision.publisher_slug, None);
    }

    #[test]
    fn mcp_publisher_does_not_trigger_without_requires_tools() {
        let classification = make_classification("general_chat", false, false);
        let capabilities = make_capabilities(
            false,
            &["anthropic/claude-sonnet-4"],
            &["gateway__firecrawl-serenai__scrape"],
        );
        let decision = route(&classification, &capabilities, "test query");
        assert_eq!(decision.worker_type, WorkerType::ChatModel);
        assert_eq!(decision.publisher_slug, None);
    }

    #[test]
    fn code_generation_with_gateway_tools_but_no_local_agent_routes_to_chat_model() {
        // code_generation + requires_file_system but no local agent session —
        // must NOT route to McpPublisher even when gateway tools exist
        let classification = make_classification("code_generation", true, true);
        let capabilities = make_capabilities(
            false,
            &["anthropic/claude-sonnet-4"],
            &["gateway__firecrawl-serenai__scrape"],
        );
        let decision = route(&classification, &capabilities, "test query");
        assert_eq!(decision.worker_type, WorkerType::ChatModel);
        assert_eq!(decision.publisher_slug, None);
    }

    #[test]
    fn file_operations_with_gateway_tools_routes_to_chat_model() {
        // file_operations requires_file_system — publishers cannot satisfy this
        let classification = make_classification("file_operations", true, true);
        let capabilities = make_capabilities(
            false,
            &["anthropic/claude-sonnet-4"],
            &["gateway__firecrawl-serenai__scrape"],
        );
        let decision = route(&classification, &capabilities, "test query");
        assert_eq!(decision.worker_type, WorkerType::ChatModel);
        assert_eq!(decision.publisher_slug, None);
    }

    #[test]
    fn malformed_gateway_tool_does_not_trigger_publisher_routing() {
        // gateway__badtool (no second __) should not route to McpPublisher
        let classification = make_classification("research", true, false);
        let capabilities =
            make_capabilities(false, &["anthropic/claude-sonnet-4"], &["gateway__badtool"]);
        let decision = route(&classification, &capabilities, "test query");
        assert_eq!(decision.worker_type, WorkerType::ChatModel);
        assert_eq!(decision.publisher_slug, None);
    }

    #[test]
    fn skill_execution_routes_to_chat_model_not_publisher() {
        // skill_execution was silently mis-routed to McpPublisher before #1343.
        // Skills need ChatModel for full tool inventory and multi-round tool calls.
        let classification = make_classification("skill_execution", true, false);
        let capabilities = make_capabilities(
            false,
            &["anthropic/claude-sonnet-4"],
            &[
                "gateway__firecrawl-serenai__scrape",
                "gateway__gmail__get_messages",
            ],
        );
        let decision = route(&classification, &capabilities, "/bat-sales-coach");
        assert_eq!(decision.worker_type, WorkerType::ChatModel);
        assert_eq!(decision.publisher_slug, None);
    }

    #[test]
    fn non_allowlisted_task_types_never_route_to_mcp_publisher() {
        // Verify that ALL current task types route to ChatModel when gateway tools
        // exist. The MCP_PUBLISHER_ELIGIBLE_TASK_TYPES allowlist is empty. (#1343)
        let task_types = [
            "skill_execution",
            "code_generation",
            "file_operations",
            "research",
            "document_generation",
            "general_chat",
        ];
        for task_type in task_types {
            let classification = make_classification(task_type, true, false);
            let capabilities = make_capabilities(
                false,
                &["anthropic/claude-sonnet-4"],
                &["gateway__firecrawl-serenai__scrape"],
            );
            let decision = route(&classification, &capabilities, "test query");
            assert_ne!(
                decision.worker_type,
                WorkerType::McpPublisher,
                "task_type '{}' should NOT route to McpPublisher",
                task_type
            );
        }
    }

    #[test]
    fn multiple_gateway_publishers_routes_to_chat_model() {
        // With multiple publishers, ChatModel is the right choice — it can
        // call tools from any publisher via the gateway tool bridge. (#1343)
        let classification = make_classification("research", true, false);
        let capabilities = make_capabilities(
            false,
            &["anthropic/claude-sonnet-4"],
            &[
                "gateway__firecrawl-serenai__scrape",
                "gateway__perplexity-serenai__search",
            ],
        );
        let decision = route(
            &classification,
            &capabilities,
            "search the web for Rust tutorials",
        );
        assert_eq!(decision.worker_type, WorkerType::ChatModel);
        assert_eq!(decision.publisher_slug, None);
    }

    #[test]
    fn routes_gmail_query_to_chat_model_not_single_publisher() {
        // Even when the query clearly targets one publisher, ChatModel handles it
        // because it has access to all tools and supports multi-round execution. (#1343)
        let classification = make_classification("research", true, false);
        let capabilities = make_capabilities(
            false,
            &["anthropic/claude-sonnet-4"],
            &[
                "gateway__polymarket-data__get_markets",
                "gateway__gmail__get_messages",
                "gateway__gmail__post_messages_send",
            ],
        );
        let decision = route(
            &classification,
            &capabilities,
            "Summarize my five most recent emails in Gmail",
        );
        assert_eq!(decision.worker_type, WorkerType::ChatModel);
        assert_eq!(decision.publisher_slug, None);
    }

    // =========================================================================
    // Model Selection
    // =========================================================================

    #[test]
    fn selects_capable_model_for_code_tasks() {
        let classification = make_classification("code_generation", true, true);
        let capabilities = make_capabilities(
            false,
            &["anthropic/claude-sonnet-4", "anthropic/claude-opus-4-6"],
            &[],
        );
        let decision = route(&classification, &capabilities, "test query");
        assert_eq!(decision.model_id, "anthropic/claude-opus-4-6");
    }

    #[test]
    fn selects_fast_model_for_simple_tasks() {
        let classification = make_classification("general_chat", false, false);
        let capabilities = make_capabilities(
            false,
            &["anthropic/claude-sonnet-4", "google/gemini-2.5-flash"],
            &[],
        );
        let decision = route(&classification, &capabilities, "test query");
        assert_eq!(decision.model_id, "google/gemini-2.5-flash");
    }

    #[test]
    fn falls_back_to_first_available_model() {
        let classification = make_classification("general_chat", false, false);
        let capabilities = make_capabilities(false, &["some/unknown-model"], &[]);
        let decision = route(&classification, &capabilities, "test query");
        assert_eq!(decision.model_id, "some/unknown-model");
    }

    #[test]
    fn falls_back_to_default_when_no_models() {
        let classification = make_classification("general_chat", false, false);
        let capabilities = make_capabilities(false, &[], &[]);
        let decision = route(&classification, &capabilities, "test query");
        assert_eq!(decision.model_id, "arcee-ai/trinity-large-thinking");
    }

    #[test]
    fn respects_user_selected_model() {
        let classification = make_classification("general_chat", false, false);
        let mut capabilities = make_capabilities(
            false,
            &[
                "anthropic/claude-sonnet-4",
                "google/gemini-2.5-flash",
                "openai/gpt-5",
            ],
            &[],
        );
        // Without selection, router picks gemini flash for simple tasks
        let decision = route(&classification, &capabilities, "test query");
        assert_eq!(decision.model_id, "google/gemini-2.5-flash");

        // With explicit selection, router respects the user's choice
        capabilities.selected_model = Some("openai/gpt-5".to_string());
        let decision = route(&classification, &capabilities, "test query");
        assert_eq!(decision.model_id, "openai/gpt-5");
        assert!(decision.reason.contains("GPT-5"));
    }

    // =========================================================================
    // Delegation
    // =========================================================================

    #[test]
    fn local_agent_uses_full_handoff_delegation() {
        let classification = make_classification("code_generation", true, true);
        let capabilities = make_capabilities(true, &["anthropic/claude-opus-4-6"], &[]);
        let decision = route(&classification, &capabilities, "test query");
        assert_eq!(decision.worker_type, WorkerType::LocalAgent);
        assert_eq!(decision.delegation, DelegationType::FullHandoff);
    }

    #[test]
    fn chat_model_uses_in_loop_delegation() {
        let classification = make_classification("general_chat", false, false);
        let capabilities = make_capabilities(false, &["anthropic/claude-sonnet-4"], &[]);
        let decision = route(&classification, &capabilities, "test query");
        assert_eq!(decision.worker_type, WorkerType::ChatModel);
        assert_eq!(decision.delegation, DelegationType::InLoop);
    }

    // =========================================================================
    // Reason String
    // =========================================================================

    #[test]
    fn reason_is_human_readable() {
        let classification = make_classification("code_generation", true, true);
        let capabilities = make_capabilities(true, &["anthropic/claude-opus-4-6"], &[]);
        let decision = route(&classification, &capabilities, "test query");
        assert!(decision.reason.contains("agent"));
        assert!(decision.reason.contains("code generation"));
    }

    #[test]
    fn reason_includes_model_name_for_chat() {
        let classification = make_classification("research", true, false);
        let capabilities = make_capabilities(false, &["anthropic/claude-sonnet-4"], &[]);
        let decision = route(&classification, &capabilities, "test query");
        assert!(decision.reason.contains("Claude Sonnet"));
        assert!(decision.reason.contains("research"));
    }

    // =========================================================================
    // Skill Resolution
    // =========================================================================

    #[test]
    fn resolves_matching_skills_from_capabilities() {
        let mut classification = make_classification("code_generation", true, true);
        classification.relevant_skills = vec!["prose".to_string()];

        let capabilities = make_capabilities_with_skills(
            false,
            &["anthropic/claude-sonnet-4"],
            vec![make_skill("prose", "Prose")],
        );

        let decision = route(&classification, &capabilities, "test query");
        assert_eq!(decision.selected_skills.len(), 1);
        assert_eq!(decision.selected_skills[0].slug, "prose");
    }

    #[test]
    fn ignores_nonexistent_skill_slugs() {
        let mut classification = make_classification("code_generation", true, true);
        classification.relevant_skills = vec!["nonexistent".to_string()];

        let capabilities = make_capabilities_with_skills(
            false,
            &["anthropic/claude-sonnet-4"],
            vec![make_skill("prose", "Prose")],
        );

        let decision = route(&classification, &capabilities, "test query");
        assert!(decision.selected_skills.is_empty());
    }

    #[test]
    fn resolves_multiple_skills() {
        let mut classification = make_classification("code_generation", true, true);
        classification.relevant_skills = vec!["prose".to_string(), "git-commit".to_string()];

        let capabilities = make_capabilities_with_skills(
            false,
            &["anthropic/claude-sonnet-4"],
            vec![
                make_skill("prose", "Prose"),
                make_skill("git-commit", "Git Commit"),
            ],
        );

        let decision = route(&classification, &capabilities, "test query");
        assert_eq!(decision.selected_skills.len(), 2);
    }

    // =========================================================================
    // Model Rankings (Thompson Sampling)
    // =========================================================================

    #[test]
    fn uses_model_rankings_when_provided() {
        let classification = make_classification("general_chat", false, false);
        let mut capabilities = make_capabilities(
            false,
            &[
                "anthropic/claude-sonnet-4",
                "google/gemini-2.5-flash",
                "openai/gpt-5",
            ],
            &[],
        );
        // Rankings override hardcoded preferences
        capabilities.model_rankings = vec![
            ("openai/gpt-5".to_string(), 0.95),
            ("anthropic/claude-sonnet-4".to_string(), 0.80),
            ("google/gemini-2.5-flash".to_string(), 0.60),
        ];

        let decision = route(&classification, &capabilities, "test query");
        assert_eq!(decision.model_id, "openai/gpt-5");
    }

    #[test]
    fn rankings_skip_unavailable_models() {
        let classification = make_classification("general_chat", false, false);
        let mut capabilities = make_capabilities(
            false,
            &["anthropic/claude-sonnet-4", "google/gemini-2.5-flash"],
            &[],
        );
        // Top-ranked model is not in available_models
        capabilities.model_rankings = vec![
            ("openai/gpt-5".to_string(), 0.95),
            ("anthropic/claude-sonnet-4".to_string(), 0.80),
        ];

        let decision = route(&classification, &capabilities, "test query");
        assert_eq!(decision.model_id, "anthropic/claude-sonnet-4");
    }

    #[test]
    fn user_selection_overrides_rankings() {
        let classification = make_classification("general_chat", false, false);
        let mut capabilities = make_capabilities(
            false,
            &["anthropic/claude-sonnet-4", "google/gemini-2.5-flash"],
            &[],
        );
        capabilities.model_rankings = vec![
            ("google/gemini-2.5-flash".to_string(), 0.95),
            ("anthropic/claude-sonnet-4".to_string(), 0.80),
        ];
        capabilities.selected_model = Some("anthropic/claude-sonnet-4".to_string());

        let decision = route(&classification, &capabilities, "test query");
        assert_eq!(decision.model_id, "anthropic/claude-sonnet-4");
    }

    #[test]
    fn empty_rankings_falls_back_to_hardcoded() {
        let classification = make_classification("general_chat", false, false);
        let capabilities = make_capabilities(
            false,
            &["anthropic/claude-sonnet-4", "google/gemini-2.5-flash"],
            &[],
        );
        // model_rankings is empty (default) — cold start behavior
        assert!(capabilities.model_rankings.is_empty());

        let decision = route(&classification, &capabilities, "test query");
        // Should use SIMPLE_PREFERRED_MODELS fallback
        assert_eq!(decision.model_id, "google/gemini-2.5-flash");
    }

    // =========================================================================
    // Reroutable Error Detection
    // =========================================================================

    #[test]
    fn detects_408_as_reroutable() {
        assert!(is_reroutable_error(
            "Gateway returned HTTP 408 Request Timeout"
        ));
    }

    #[test]
    fn detects_429_as_reroutable() {
        assert!(is_reroutable_error("HTTP 429: Rate limit exceeded"));
    }

    #[test]
    fn detects_502_as_reroutable() {
        assert!(is_reroutable_error("Gateway returned HTTP 502"));
    }

    #[test]
    fn rejects_401_as_not_reroutable() {
        assert!(!is_reroutable_error("HTTP 401: Unauthorized"));
    }

    #[test]
    fn rejects_403_as_not_reroutable() {
        assert!(!is_reroutable_error("HTTP 403: Forbidden"));
    }

    #[test]
    fn rejects_400_as_not_reroutable() {
        assert!(!is_reroutable_error("HTTP 400: Bad Request"));
    }

    #[test]
    fn context_overflow_400_is_reroutable() {
        assert!(is_reroutable_error(
            r#"HTTP 400: Provider returned error — {"type":"error","error":{"type":"invalid_request_error","message":"prompt is too long: 205969 tokens > 200000 maximum"}}"#
        ));
    }

    #[test]
    fn context_length_exceeded_is_reroutable() {
        assert!(is_reroutable_error("HTTP 400: context_length_exceeded"));
    }

    #[test]
    fn large_context_fallback_picks_first_untried() {
        let tried = vec!["google/gemini-3.1-pro-preview".to_string()];
        let fallback = get_large_context_fallback(&tried);
        assert_eq!(fallback, Some("google/gemini-3-flash-preview"));
    }

    #[test]
    fn large_context_fallback_returns_none_when_all_tried() {
        let tried: Vec<String> = LARGE_CONTEXT_FALLBACK_MODELS
            .iter()
            .map(|m| m.to_string())
            .collect();
        assert_eq!(get_large_context_fallback(&tried), None);
    }

    #[test]
    fn rejects_insufficient_credits() {
        assert!(!is_reroutable_error("HTTP 402: Insufficient credits"));
    }

    #[test]
    fn rejects_generic_error_as_not_reroutable() {
        assert!(!is_reroutable_error("Something went wrong"));
    }

    // =========================================================================
    // Network Transport Errors
    // =========================================================================

    #[test]
    fn detects_reqwest_send_error_as_network() {
        assert!(is_network_transport_error(
            "Request failed: error sending request for url (https://api.serendb.com/publishers/seren-models/chat/completions)"
        ));
    }

    #[test]
    fn detects_connection_refused_as_network() {
        assert!(is_network_transport_error("connection refused"));
    }

    #[test]
    fn detects_dns_error_as_network() {
        assert!(is_network_transport_error(
            "dns error: failed to lookup address information"
        ));
    }

    #[test]
    fn detects_timeout_as_network() {
        assert!(is_network_transport_error("operation timed out"));
    }

    #[test]
    fn detects_broken_pipe_as_network() {
        assert!(is_network_transport_error("broken pipe"));
    }

    #[test]
    fn network_error_is_not_reroutable() {
        // Network errors should NOT be reroutable — rerouting to a different
        // model won't help when the gateway itself is unreachable.
        assert!(!is_reroutable_error(
            "Request failed: error sending request for url (https://api.serendb.com/...)"
        ));
    }

    #[test]
    fn http_status_error_is_not_network() {
        // HTTP 502 is a server error, not a network transport error
        assert!(!is_network_transport_error("Gateway returned HTTP 502"));
    }

    // =========================================================================
    // Reroute Fallback (without DB — hardcoded preferences)
    // =========================================================================

    #[test]
    fn reroute_skips_tried_models() {
        let conn = rusqlite::Connection::open_in_memory().unwrap();
        conn.execute(
            "CREATE TABLE IF NOT EXISTS eval_signals (
                message_id TEXT PRIMARY KEY,
                task_type TEXT NOT NULL,
                model_id TEXT,
                worker_type TEXT,
                satisfaction INTEGER NOT NULL,
                created_at INTEGER NOT NULL,
                synced INTEGER DEFAULT 0
            )",
            [],
        )
        .unwrap();

        let classification = make_classification("general_chat", false, false);
        let tried = vec!["google/gemini-3-flash-preview".to_string()];
        let available = vec![
            "google/gemini-3-flash-preview".to_string(),
            "google/gemini-2.5-flash".to_string(),
            "anthropic/claude-sonnet-4".to_string(),
        ];

        let result = reroute_on_failure(&conn, "general_chat", &tried, &available, &classification);
        assert!(result.is_some());
        let (model, _reason) = result.unwrap();
        // Should skip gemini-3-flash (tried) and pick gemini-2.5-flash (next preferred)
        assert_eq!(model, "google/gemini-2.5-flash");
    }

    #[test]
    fn reroute_returns_none_when_all_models_tried() {
        let conn = rusqlite::Connection::open_in_memory().unwrap();
        conn.execute(
            "CREATE TABLE IF NOT EXISTS eval_signals (
                message_id TEXT PRIMARY KEY,
                task_type TEXT NOT NULL,
                model_id TEXT,
                worker_type TEXT,
                satisfaction INTEGER NOT NULL,
                created_at INTEGER NOT NULL,
                synced INTEGER DEFAULT 0
            )",
            [],
        )
        .unwrap();

        let classification = make_classification("general_chat", false, false);
        let available = vec!["anthropic/claude-sonnet-4".to_string()];
        let tried = vec!["anthropic/claude-sonnet-4".to_string()];

        let result = reroute_on_failure(&conn, "general_chat", &tried, &available, &classification);
        assert!(result.is_none());
    }

    #[test]
    fn reroute_prefers_satisfaction_ranked_model() {
        let conn = rusqlite::Connection::open_in_memory().unwrap();
        conn.execute(
            "CREATE TABLE IF NOT EXISTS eval_signals (
                message_id TEXT PRIMARY KEY,
                task_type TEXT NOT NULL,
                model_id TEXT,
                worker_type TEXT,
                satisfaction INTEGER NOT NULL,
                created_at INTEGER NOT NULL,
                synced INTEGER DEFAULT 0
            )",
            [],
        )
        .unwrap();

        // Insert satisfaction signals: sonnet has 3 positive, haiku has 1
        for i in 0..3 {
            conn.execute(
                "INSERT INTO eval_signals (message_id, task_type, model_id, worker_type, satisfaction, created_at, synced)
                 VALUES (?1, 'general_chat', 'anthropic/claude-sonnet-4', 'chat_model', 1, 1000, 0)",
                rusqlite::params![format!("msg-sonnet-{}", i)],
            )
            .unwrap();
        }
        conn.execute(
            "INSERT INTO eval_signals (message_id, task_type, model_id, worker_type, satisfaction, created_at, synced)
             VALUES ('msg-haiku-1', 'general_chat', 'anthropic/claude-haiku-4.5', 'chat_model', 1, 1000, 0)",
            [],
        )
        .unwrap();

        let classification = make_classification("general_chat", false, false);
        let tried = vec!["moonshotai/kimi-k2.5".to_string()];
        let available = vec![
            "moonshotai/kimi-k2.5".to_string(),
            "anthropic/claude-haiku-4.5".to_string(),
            "anthropic/claude-sonnet-4".to_string(),
        ];

        let result = reroute_on_failure(&conn, "general_chat", &tried, &available, &classification);
        assert!(result.is_some());
        let (model, reason) = result.unwrap();
        // Should prefer sonnet (3 positive signals) over haiku (1)
        assert_eq!(model, "anthropic/claude-sonnet-4");
        assert!(reason.contains("rated helpful"));
        assert!(reason.contains("score: 3"));
    }
}
