use crate::slog;
use crate::io::onboarding;
use crate::llm::prompts::{ONBOARDING_PROMPT, SYSTEM_PROMPT};
use crate::llm::providers::claude_code::ClaudeCodeResult;
use crate::llm::types::{Message, Role, ToolCall, ToolDefinition, ToolFunctionDefinition};
use crate::state::SessionState;
use crate::store::StoreOps;
use serde::Deserialize;
use serde_json::json;

const MAX_TURNS: usize = 30;
const KEY_ANTHROPIC_API_KEY: &str = "anthropic_api_key";

#[derive(Deserialize, Clone)]
pub struct ProviderConfig {
    pub provider: String,
    pub model: String,
    pub base_url: Option<String>,
    pub enable_tools: Option<bool>,
    /// Claude Code session ID for conversation resumption.
    pub session_id: Option<String>,
}

/// Result of a chat call, including messages and optional metadata.
#[allow(dead_code)]
#[derive(Debug)]
pub struct ChatResult {
    pub messages: Vec<Message>,
    /// Session ID returned by Claude Code for resumption.
    pub session_id: Option<String>,
}

fn get_anthropic_api_key_exists_impl(store: &dyn StoreOps) -> bool {
    match store.get(KEY_ANTHROPIC_API_KEY) {
        Some(value) => value.as_str().map(|k| !k.is_empty()).unwrap_or(false),
        None => false,
    }
}

fn set_anthropic_api_key_impl(store: &dyn StoreOps, api_key: &str) -> Result<(), String> {
    store.set(KEY_ANTHROPIC_API_KEY, json!(api_key));
    store.save()?;

    match store.get(KEY_ANTHROPIC_API_KEY) {
        Some(value) => {
            if let Some(retrieved) = value.as_str() {
                if retrieved != api_key {
                    return Err("Retrieved key does not match saved key".to_string());
                }
            } else {
                return Err("Stored value is not a string".to_string());
            }
        }
        None => {
            return Err("API key was saved but cannot be retrieved".to_string());
        }
    }

    Ok(())
}

fn get_anthropic_api_key_impl(store: &dyn StoreOps) -> Result<String, String> {
    match store.get(KEY_ANTHROPIC_API_KEY) {
        Some(value) => {
            if let Some(key) = value.as_str() {
                if key.is_empty() {
                    Err("Anthropic API key is empty. Please set your API key.".to_string())
                } else {
                    Ok(key.to_string())
                }
            } else {
                Err("Stored API key is not a string".to_string())
            }
        }
        None => Err("Anthropic API key not found. Please set your API key.".to_string()),
    }
}

fn parse_tool_arguments(args_str: &str) -> Result<serde_json::Value, String> {
    serde_json::from_str(args_str).map_err(|e| format!("Error parsing arguments: {e}"))
}

pub fn get_tool_definitions() -> Vec<ToolDefinition> {
    vec![
        ToolDefinition {
            kind: "function".to_string(),
            function: ToolFunctionDefinition {
                name: "read_file".to_string(),
                description: "Reads the complete content of a file from the project. Use this to understand existing code before making changes.".to_string(),
                parameters: json!({
                    "type": "object",
                    "properties": {
                        "path": { "type": "string", "description": "Relative path to the file from project root" }
                    },
                    "required": ["path"]
                }),
            },
        },
        ToolDefinition {
            kind: "function".to_string(),
            function: ToolFunctionDefinition {
                name: "write_file".to_string(),
                description: "Creates or completely overwrites a file with new content. YOU MUST USE THIS to implement code changes - do not suggest code to the user. The content parameter must contain the COMPLETE file including all imports, functions, and unchanged code.".to_string(),
                parameters: json!({
                    "type": "object",
                    "properties": {
                        "path": { "type": "string", "description": "Relative path to the file from project root" },
                        "content": { "type": "string", "description": "The complete file content to write (not a diff or partial code)" }
                    },
                    "required": ["path", "content"]
                }),
            },
        },
        ToolDefinition {
            kind: "function".to_string(),
            function: ToolFunctionDefinition {
                name: "list_directory".to_string(),
                description: "Lists all files and directories at a given path. Use this to explore the project structure.".to_string(),
                parameters: json!({
                    "type": "object",
                    "properties": {
                        "path": { "type": "string", "description": "Relative path to list (use '.' for project root)" }
                    },
                    "required": ["path"]
                }),
            },
        },
        ToolDefinition {
            kind: "function".to_string(),
            function: ToolFunctionDefinition {
                name: "search_files".to_string(),
                description: "Searches for text patterns across all files in the project. Use this to find functions, variables, or code patterns when you don't know which file they're in."
                    .to_string(),
                parameters: json!({
                    "type": "object",
                    "properties": {
                        "query": { "type": "string", "description": "The text pattern to search for across all files" }
                    },
                    "required": ["query"]
                }),
            },
        },
        ToolDefinition {
            kind: "function".to_string(),
            function: ToolFunctionDefinition {
                name: "exec_shell".to_string(),
                description: "Executes a shell command in the project root directory. Use this to run tests, build commands, git operations, or any command-line tool. Examples: cargo check, npm test, git status.".to_string(),
                parameters: json!({
                    "type": "object",
                    "properties": {
                        "command": {
                            "type": "string",
                            "description": "The command binary to execute (e.g., 'git', 'cargo', 'npm', 'ls')"
                        },
                        "args": {
                            "type": "array",
                            "items": { "type": "string" },
                            "description": "Array of arguments to pass to the command (e.g., ['status'] for git status)"
                        }
                    },
                    "required": ["command", "args"]
                }),
            },
        },
    ]
}

pub async fn get_ollama_models(base_url: Option<String>) -> Result<Vec<String>, String> {
    use crate::llm::providers::ollama::OllamaProvider;
    let url = base_url.unwrap_or_else(|| "http://localhost:11434".to_string());
    OllamaProvider::get_models(&url).await
}

pub fn get_anthropic_api_key_exists(store: &dyn StoreOps) -> Result<bool, String> {
    Ok(get_anthropic_api_key_exists_impl(store))
}

pub fn set_anthropic_api_key(store: &dyn StoreOps, api_key: String) -> Result<(), String> {
    set_anthropic_api_key_impl(store, &api_key)
}

/// Build a prompt for Claude Code that includes prior conversation history.
///
/// When a Claude Code session cannot be resumed (no session_id), we embed
/// the prior messages as a structured preamble so the LLM retains context.
/// If there is only one user message (the current one), the content is
/// returned as-is with no preamble.
fn build_claude_code_context_prompt(messages: &[Message], latest_user_content: &str) -> String {
    // Collect prior messages (everything except the trailing user message).
    let prior: Vec<&Message> = messages
        .iter()
        .rev()
        .skip(1) // skip the latest user message
        .collect::<Vec<_>>()
        .into_iter()
        .rev()
        .collect();

    if prior.is_empty() {
        return latest_user_content.to_string();
    }

    let mut parts = Vec::new();
    parts.push("<conversation_history>".to_string());
    for msg in &prior {
        let label = match msg.role {
            Role::User => "User",
            Role::Assistant => "Assistant",
            Role::Tool => "Tool",
            Role::System => continue,
        };
        parts.push(format!("[{}]: {}", label, msg.content));
    }
    parts.push("</conversation_history>".to_string());
    parts.push(String::new());
    parts.push(latest_user_content.to_string());
    parts.join("\n")
}

#[allow(clippy::too_many_arguments)]
pub async fn chat<F, U, T, A>(
    messages: Vec<Message>,
    config: ProviderConfig,
    state: &SessionState,
    store: &dyn StoreOps,
    mut on_update: F,
    mut on_token: U,
    mut on_thinking: T,
    mut on_activity: A,
) -> Result<ChatResult, String>
where
    F: FnMut(&[Message]) + Send,
    U: FnMut(&str) + Send,
    T: FnMut(&str) + Send,
    A: FnMut(&str) + Send,
{
    use crate::llm::providers::anthropic::AnthropicProvider;
    use crate::llm::providers::ollama::OllamaProvider;

    let _ = state.cancel_tx.send(false);
    let mut cancel_rx = state.cancel_rx.clone();
    cancel_rx.borrow_and_update();

    let base_url = config
        .base_url
        .clone()
        .unwrap_or_else(|| "http://localhost:11434".to_string());

    slog!("[chat] provider={} model={}", config.provider, config.model);
    let is_claude_code = config.provider == "claude-code";
    let is_claude = !is_claude_code && config.model.starts_with("claude-");

    if !is_claude_code && !is_claude && config.provider.as_str() != "ollama" {
        return Err(format!("Unsupported provider: {}", config.provider));
    }

    // Claude Code provider: bypasses our tool loop entirely.
    // Claude Code has its own agent loop, tools, and context management.
    // We pipe the user message in, stream text tokens for live display, and
    // collect the structured messages (assistant turns + tool results) from
    // the stream-json output for the final message history.
    if is_claude_code {
        use crate::llm::providers::claude_code::ClaudeCodeProvider;

        let latest_user_content = messages
            .iter()
            .rev()
            .find(|m| m.role == Role::User)
            .map(|m| m.content.clone())
            .ok_or_else(|| "No user message found".to_string())?;

        // When resuming with a session_id, Claude Code loads its own transcript
        // from disk — the latest user message is sufficient.  Without a
        // session_id (e.g. after a page refresh) the prior conversation context
        // would be lost because Claude Code only receives a single prompt
        // string.  In that case, prepend the conversation history so the LLM
        // retains full context even though the session cannot be resumed.
        let user_message = if config.session_id.is_some() {
            latest_user_content
        } else {
            build_claude_code_context_prompt(&messages, &latest_user_content)
        };

        let project_root = state
            .get_project_root()
            .unwrap_or_else(|_| std::path::PathBuf::from("."));

        let provider = ClaudeCodeProvider::new();
        let ClaudeCodeResult {
            messages: cc_messages,
            session_id,
        } = provider
            .chat_stream(
                &user_message,
                &project_root.to_string_lossy(),
                config.session_id.as_deref(),
                None,
                &mut cancel_rx,
                |token| on_token(token),
                |thinking| on_thinking(thinking),
                |tool_name| on_activity(tool_name),
            )
            .await
            .map_err(|e| format!("Claude Code Error: {e}"))?;

        // Build the final message history: user messages + Claude Code's turns.
        // If the session produced no structured messages (e.g. empty response),
        // fall back to an empty assistant message so the UI stops loading.
        let mut result = messages.clone();
        if cc_messages.is_empty() {
            result.push(Message {
                role: Role::Assistant,
                content: String::new(),
                tool_calls: None,
                tool_call_id: None,
            });
        } else {
            result.extend(cc_messages);
        }
        on_update(&result);
        return Ok(ChatResult {
            messages: result,
            session_id,
        });
    }

    let tool_defs = get_tool_definitions();
    let tools = if config.enable_tools.unwrap_or(true) {
        tool_defs.as_slice()
    } else {
        &[]
    };

    let mut current_history = messages.clone();

    // Build the system prompt — append onboarding instructions when the
    // project's spec files still contain scaffold placeholders.
    let system_content = {
        let mut content = SYSTEM_PROMPT.to_string();
        if let Ok(root) = state.get_project_root() {
            let status = onboarding::check_onboarding_status(&root);
            if status.needs_onboarding() {
                content.push_str("\n\n");
                content.push_str(ONBOARDING_PROMPT);
            }
        }
        content
    };

    current_history.insert(
        0,
        Message {
            role: Role::System,
            content: system_content,
            tool_calls: None,
            tool_call_id: None,
        },
    );

    current_history.insert(
        1,
        Message {
            role: Role::System,
            content: "REMINDER: Distinguish between showing examples (use code blocks in chat) vs implementing changes (use write_file tool). Keywords like 'show me', 'example', 'how does' = chat response. Keywords like 'create', 'add', 'implement', 'fix' = use tools."
                .to_string(),
            tool_calls: None,
            tool_call_id: None,
        },
    );

    let mut new_messages: Vec<Message> = Vec::new();
    let mut turn_count = 0;

    loop {
        if *cancel_rx.borrow() {
            return Err("Chat cancelled by user".to_string());
        }

        if turn_count >= MAX_TURNS {
            return Err("Max conversation turns reached.".to_string());
        }
        turn_count += 1;

        let response = if is_claude {
            let api_key = get_anthropic_api_key_impl(store)?;
            let anthropic_provider = AnthropicProvider::new(api_key);
            anthropic_provider
                .chat_stream(
                    &config.model,
                    &current_history,
                    tools,
                    &mut cancel_rx,
                    |token| on_token(token),
                    |tool_name| on_activity(tool_name),
                )
                .await
                .map_err(|e| format!("Anthropic Error: {e}"))?
        } else {
            let ollama_provider = OllamaProvider::new(base_url.clone());
            ollama_provider
                .chat_stream(
                    &config.model,
                    &current_history,
                    tools,
                    &mut cancel_rx,
                    |token| on_token(token),
                )
                .await
                .map_err(|e| format!("Ollama Error: {e}"))?
        };

        if let Some(tool_calls) = response.tool_calls {
            let assistant_msg = Message {
                role: Role::Assistant,
                content: response.content.unwrap_or_default(),
                tool_calls: Some(tool_calls.clone()),
                tool_call_id: None,
            };

            current_history.push(assistant_msg.clone());
            new_messages.push(assistant_msg);
            on_update(&current_history[2..]);

            for call in tool_calls {
                if *cancel_rx.borrow() {
                    return Err("Chat cancelled before tool execution".to_string());
                }

                let output = execute_tool(&call, state).await;

                let tool_msg = Message {
                    role: Role::Tool,
                    content: output,
                    tool_calls: None,
                    tool_call_id: call.id,
                };

                current_history.push(tool_msg.clone());
                new_messages.push(tool_msg);
                on_update(&current_history[2..]);
            }
        } else {
            let assistant_msg = Message {
                role: Role::Assistant,
                content: response.content.unwrap_or_default(),
                tool_calls: None,
                tool_call_id: None,
            };

            new_messages.push(assistant_msg.clone());
            current_history.push(assistant_msg);
            on_update(&current_history[2..]);
            break;
        }
    }

    Ok(ChatResult {
        messages: current_history[2..].to_vec(),
        session_id: None,
    })
}

/// Answer a one-off side question using the existing conversation as context.
///
/// Unlike `chat`, this function:
/// - Does NOT perform tool calls.
/// - Does NOT modify the main conversation history.
/// - Does NOT touch the shared cancel signal.
/// - Performs a single LLM call and returns the response text.
pub async fn side_question<U>(
    context_messages: Vec<Message>,
    question: String,
    config: ProviderConfig,
    store: &dyn StoreOps,
    mut on_token: U,
) -> Result<String, String>
where
    U: FnMut(&str) + Send,
{
    use crate::llm::providers::anthropic::AnthropicProvider;
    use crate::llm::providers::ollama::OllamaProvider;

    // Use a local cancel channel that is never cancelled, so the side question
    // runs to completion independently of any main chat cancel signal.
    // Keep `_cancel_tx` alive for the duration of the function so the channel
    // stays open and `changed()` inside the providers does not spuriously fire.
    let (_cancel_tx, cancel_rx) = tokio::sync::watch::channel(false);
    let mut cancel_rx = cancel_rx;
    cancel_rx.borrow_and_update();

    let base_url = config
        .base_url
        .clone()
        .unwrap_or_else(|| "http://localhost:11434".to_string());

    let is_claude_code = config.provider == "claude-code";
    let is_claude = !is_claude_code && config.model.starts_with("claude-");

    // Build a minimal history: existing context + the side question.
    let mut history = context_messages;
    history.push(Message {
        role: Role::User,
        content: question,
        tool_calls: None,
        tool_call_id: None,
    });

    // No tools for side questions.
    let tools: &[ToolDefinition] = &[];

    let response = if is_claude {
        let api_key = get_anthropic_api_key_impl(store)?;
        let provider = AnthropicProvider::new(api_key);
        provider
            .chat_stream(
                &config.model,
                &history,
                tools,
                &mut cancel_rx,
                |token| on_token(token),
                |_tool_name| {},
            )
            .await
            .map_err(|e| format!("Anthropic Error: {e}"))?
    } else if is_claude_code {
        return Err("Claude Code provider does not support side questions".to_string());
    } else {
        let provider = OllamaProvider::new(base_url);
        provider
            .chat_stream(&config.model, &history, tools, &mut cancel_rx, |token| {
                on_token(token)
            })
            .await
            .map_err(|e| format!("Ollama Error: {e}"))?
    };

    Ok(response.content.unwrap_or_default())
}

async fn execute_tool(call: &ToolCall, state: &SessionState) -> String {
    use crate::io::{fs, search, shell};

    let name = call.function.name.as_str();
    let args: serde_json::Value = match parse_tool_arguments(&call.function.arguments) {
        Ok(v) => v,
        Err(e) => return e,
    };

    match name {
        "read_file" => {
            let path = args["path"].as_str().unwrap_or("").to_string();
            match fs::read_file(path, state).await {
                Ok(content) => content,
                Err(e) => format!("Error: {e}"),
            }
        }
        "write_file" => {
            let path = args["path"].as_str().unwrap_or("").to_string();
            let content = args["content"].as_str().unwrap_or("").to_string();
            match fs::write_file(path, content, state).await {
                Ok(()) => "File written successfully.".to_string(),
                Err(e) => format!("Error: {e}"),
            }
        }
        "list_directory" => {
            let path = args["path"].as_str().unwrap_or("").to_string();
            match fs::list_directory(path, state).await {
                Ok(entries) => serde_json::to_string(&entries).unwrap_or_default(),
                Err(e) => format!("Error: {e}"),
            }
        }
        "search_files" => {
            let query = args["query"].as_str().unwrap_or("").to_string();
            match search::search_files(query, state).await {
                Ok(results) => serde_json::to_string(&results).unwrap_or_default(),
                Err(e) => format!("Error: {e}"),
            }
        }
        "exec_shell" => {
            let command = args["command"].as_str().unwrap_or("").to_string();
            let args_vec: Vec<String> = args["args"]
                .as_array()
                .map(|arr| {
                    arr.iter()
                        .map(|v| v.as_str().unwrap_or("").to_string())
                        .collect()
                })
                .unwrap_or_default();

            match shell::exec_shell(command, args_vec, state).await {
                Ok(output) => serde_json::to_string(&output).unwrap_or_default(),
                Err(e) => format!("Error: {e}"),
            }
        }
        _ => format!("Unknown tool: {name}"),
    }
}

pub fn cancel_chat(state: &SessionState) -> Result<(), String> {
    state.cancel_tx.send(true).map_err(|e| e.to_string())?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::llm::types::{FunctionCall, ToolCall};
    use crate::state::SessionState;
    use serde_json::json;
    use std::collections::HashMap;
    use std::sync::Mutex;

    // ---------------------------------------------------------------------------
    // Minimal in-memory StoreOps mock
    // ---------------------------------------------------------------------------

    struct MockStore {
        data: Mutex<HashMap<String, serde_json::Value>>,
        save_should_fail: bool,
    }

    impl MockStore {
        fn new() -> Self {
            Self {
                data: Mutex::new(HashMap::new()),
                save_should_fail: false,
            }
        }

        fn with_save_error() -> Self {
            Self {
                data: Mutex::new(HashMap::new()),
                save_should_fail: true,
            }
        }

        fn with_entry(key: &str, value: serde_json::Value) -> Self {
            let mut map = HashMap::new();
            map.insert(key.to_string(), value);
            Self {
                data: Mutex::new(map),
                save_should_fail: false,
            }
        }
    }

    impl StoreOps for MockStore {
        fn get(&self, key: &str) -> Option<serde_json::Value> {
            self.data.lock().ok().and_then(|m| m.get(key).cloned())
        }

        fn set(&self, key: &str, value: serde_json::Value) {
            if let Ok(mut m) = self.data.lock() {
                m.insert(key.to_string(), value);
            }
        }

        fn delete(&self, key: &str) {
            if let Ok(mut m) = self.data.lock() {
                m.remove(key);
            }
        }

        fn save(&self) -> Result<(), String> {
            if self.save_should_fail {
                Err("mock save error".to_string())
            } else {
                Ok(())
            }
        }
    }

    // ---------------------------------------------------------------------------
    // parse_tool_arguments
    // ---------------------------------------------------------------------------

    #[test]
    fn parse_tool_arguments_valid_json() {
        let result = parse_tool_arguments(r#"{"path": "src/main.rs"}"#);
        assert!(result.is_ok());
        assert_eq!(result.unwrap()["path"], json!("src/main.rs"));
    }

    #[test]
    fn parse_tool_arguments_invalid_json() {
        let result = parse_tool_arguments("not json {{{");
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("Error parsing arguments:"));
    }

    #[test]
    fn parse_tool_arguments_empty_object() {
        let result = parse_tool_arguments("{}");
        assert!(result.is_ok());
    }

    // ---------------------------------------------------------------------------
    // get_anthropic_api_key_exists_impl
    // ---------------------------------------------------------------------------

    #[test]
    fn api_key_exists_when_key_is_present_and_non_empty() {
        let store = MockStore::with_entry("anthropic_api_key", json!("sk-test-key"));
        assert!(get_anthropic_api_key_exists_impl(&store));
    }

    #[test]
    fn api_key_exists_returns_false_when_key_is_empty_string() {
        let store = MockStore::with_entry("anthropic_api_key", json!(""));
        assert!(!get_anthropic_api_key_exists_impl(&store));
    }

    #[test]
    fn api_key_exists_returns_false_when_key_absent() {
        let store = MockStore::new();
        assert!(!get_anthropic_api_key_exists_impl(&store));
    }

    #[test]
    fn api_key_exists_returns_false_when_value_is_not_string() {
        let store = MockStore::with_entry("anthropic_api_key", json!(42));
        assert!(!get_anthropic_api_key_exists_impl(&store));
    }

    // ---------------------------------------------------------------------------
    // get_anthropic_api_key_impl
    // ---------------------------------------------------------------------------

    #[test]
    fn get_api_key_returns_key_when_present() {
        let store = MockStore::with_entry("anthropic_api_key", json!("sk-test-key"));
        let result = get_anthropic_api_key_impl(&store);
        assert_eq!(result.unwrap(), "sk-test-key");
    }

    #[test]
    fn get_api_key_errors_when_empty() {
        let store = MockStore::with_entry("anthropic_api_key", json!(""));
        let result = get_anthropic_api_key_impl(&store);
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("empty"));
    }

    #[test]
    fn get_api_key_errors_when_absent() {
        let store = MockStore::new();
        let result = get_anthropic_api_key_impl(&store);
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("not found"));
    }

    #[test]
    fn get_api_key_errors_when_value_not_string() {
        let store = MockStore::with_entry("anthropic_api_key", json!(123));
        let result = get_anthropic_api_key_impl(&store);
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("not a string"));
    }

    // ---------------------------------------------------------------------------
    // set_anthropic_api_key_impl
    // ---------------------------------------------------------------------------

    #[test]
    fn set_api_key_stores_and_returns_ok() {
        let store = MockStore::new();
        let result = set_anthropic_api_key_impl(&store, "sk-my-key");
        assert!(result.is_ok());
        assert_eq!(
            store.get("anthropic_api_key"),
            Some(json!("sk-my-key"))
        );
    }

    #[test]
    fn set_api_key_returns_error_when_save_fails() {
        let store = MockStore::with_save_error();
        let result = set_anthropic_api_key_impl(&store, "sk-my-key");
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("mock save error"));
    }

    // ---------------------------------------------------------------------------
    // Public wrappers: get_anthropic_api_key_exists / set_anthropic_api_key
    // ---------------------------------------------------------------------------

    #[test]
    fn public_api_key_exists_returns_ok_bool() {
        let store = MockStore::with_entry("anthropic_api_key", json!("sk-abc"));
        let result = get_anthropic_api_key_exists(&store);
        assert_eq!(result, Ok(true));
    }

    #[test]
    fn public_api_key_exists_false_when_absent() {
        let store = MockStore::new();
        let result = get_anthropic_api_key_exists(&store);
        assert_eq!(result, Ok(false));
    }

    #[test]
    fn public_set_api_key_succeeds() {
        let store = MockStore::new();
        let result = set_anthropic_api_key(&store, "sk-xyz".to_string());
        assert!(result.is_ok());
    }

    #[test]
    fn public_set_api_key_propagates_save_error() {
        let store = MockStore::with_save_error();
        let result = set_anthropic_api_key(&store, "sk-xyz".to_string());
        assert!(result.is_err());
    }

    // ---------------------------------------------------------------------------
    // get_tool_definitions
    // ---------------------------------------------------------------------------

    #[test]
    fn tool_definitions_returns_five_tools() {
        let tools = get_tool_definitions();
        assert_eq!(tools.len(), 5);
    }

    #[test]
    fn tool_definitions_has_expected_names() {
        let tools = get_tool_definitions();
        let names: Vec<&str> = tools.iter().map(|t| t.function.name.as_str()).collect();
        assert!(names.contains(&"read_file"));
        assert!(names.contains(&"write_file"));
        assert!(names.contains(&"list_directory"));
        assert!(names.contains(&"search_files"));
        assert!(names.contains(&"exec_shell"));
    }

    #[test]
    fn tool_definitions_each_has_function_kind() {
        let tools = get_tool_definitions();
        for tool in &tools {
            assert_eq!(tool.kind, "function");
        }
    }

    #[test]
    fn tool_definitions_each_has_non_empty_description() {
        let tools = get_tool_definitions();
        for tool in &tools {
            assert!(!tool.function.description.is_empty());
        }
    }

    #[test]
    fn tool_definitions_parameters_have_object_type() {
        let tools = get_tool_definitions();
        for tool in &tools {
            assert_eq!(tool.function.parameters["type"], json!("object"));
        }
    }

    #[test]
    fn read_file_tool_requires_path_parameter() {
        let tools = get_tool_definitions();
        let read_file = tools
            .iter()
            .find(|t| t.function.name == "read_file")
            .unwrap();
        let required = read_file.function.parameters["required"]
            .as_array()
            .unwrap();
        let required_names: Vec<&str> =
            required.iter().map(|v| v.as_str().unwrap()).collect();
        assert!(required_names.contains(&"path"));
    }

    #[test]
    fn exec_shell_tool_requires_command_and_args() {
        let tools = get_tool_definitions();
        let exec_shell = tools
            .iter()
            .find(|t| t.function.name == "exec_shell")
            .unwrap();
        let required = exec_shell.function.parameters["required"]
            .as_array()
            .unwrap();
        let required_names: Vec<&str> =
            required.iter().map(|v| v.as_str().unwrap()).collect();
        assert!(required_names.contains(&"command"));
        assert!(required_names.contains(&"args"));
    }

    // ---------------------------------------------------------------------------
    // cancel_chat
    // ---------------------------------------------------------------------------

    #[test]
    fn cancel_chat_sends_true_to_channel() {
        let state = SessionState::default();
        let result = cancel_chat(&state);
        assert!(result.is_ok());
        assert!(*state.cancel_rx.borrow());
    }

    // ---------------------------------------------------------------------------
    // chat — unsupported provider early return (no network calls)
    // ---------------------------------------------------------------------------

    #[tokio::test]
    async fn chat_rejects_unknown_provider() {
        let state = SessionState::default();
        let store = MockStore::new();
        let config = ProviderConfig {
            provider: "unsupported-provider".to_string(),
            model: "some-model".to_string(),
            base_url: None,
            enable_tools: None,
            session_id: None,
        };
        let messages = vec![Message {
            role: Role::User,
            content: "hello".to_string(),
            tool_calls: None,
            tool_call_id: None,
        }];

        let result = chat(
            messages,
            config,
            &state,
            &store,
            |_| {},
            |_| {},
            |_| {},
            |_| {},
        )
        .await;

        assert!(result.is_err());
        assert!(result
            .unwrap_err()
            .contains("Unsupported provider: unsupported-provider"));
    }

    // ---------------------------------------------------------------------------
    // chat — ollama path exercises system prompt insertion + tool setup
    //         (connection to a non-existent port fails fast)
    // ---------------------------------------------------------------------------

    #[tokio::test]
    async fn chat_ollama_bad_url_fails_with_ollama_error() {
        let state = SessionState::default();
        let store = MockStore::new();
        let config = ProviderConfig {
            provider: "ollama".to_string(),
            model: "llama3".to_string(),
            // Port 1 is reserved / closed — connection fails immediately.
            base_url: Some("http://127.0.0.1:1".to_string()),
            enable_tools: Some(false),
            session_id: None,
        };
        let messages = vec![Message {
            role: Role::User,
            content: "ping".to_string(),
            tool_calls: None,
            tool_call_id: None,
        }];

        let result = chat(
            messages,
            config,
            &state,
            &store,
            |_| {},
            |_| {},
            |_| {},
            |_| {},
        )
        .await;

        assert!(result.is_err());
        let err = result.unwrap_err();
        assert!(err.contains("Ollama Error:"), "unexpected error: {err}");
    }

    // ---------------------------------------------------------------------------
    // chat — Anthropic model prefix detection (fails without API key)
    // ---------------------------------------------------------------------------

    #[tokio::test]
    async fn chat_claude_model_without_api_key_returns_error() {
        let state = SessionState::default();
        // No API key in store → should fail with "API key not found"
        let store = MockStore::new();
        let config = ProviderConfig {
            provider: "anthropic".to_string(),
            model: "claude-3-haiku-20240307".to_string(),
            base_url: None,
            enable_tools: Some(false),
            session_id: None,
        };
        let messages = vec![Message {
            role: Role::User,
            content: "hello".to_string(),
            tool_calls: None,
            tool_call_id: None,
        }];

        let result = chat(
            messages,
            config,
            &state,
            &store,
            |_| {},
            |_| {},
            |_| {},
            |_| {},
        )
        .await;

        assert!(result.is_err());
        let err = result.unwrap_err();
        assert!(
            err.contains("API key"),
            "expected API key error, got: {err}"
        );
    }

    // ---------------------------------------------------------------------------
    // execute_tool — unknown tool name (no I/O, no network)
    // ---------------------------------------------------------------------------

    #[tokio::test]
    async fn execute_tool_returns_error_for_unknown_tool() {
        let state = SessionState::default();
        let call = ToolCall {
            id: Some("call-1".to_string()),
            kind: "function".to_string(),
            function: FunctionCall {
                name: "nonexistent_tool".to_string(),
                arguments: "{}".to_string(),
            },
        };

        let result = execute_tool(&call, &state).await;
        assert_eq!(result, "Unknown tool: nonexistent_tool");
    }

    #[tokio::test]
    async fn execute_tool_returns_parse_error_for_invalid_json_args() {
        let state = SessionState::default();
        let call = ToolCall {
            id: Some("call-2".to_string()),
            kind: "function".to_string(),
            function: FunctionCall {
                name: "read_file".to_string(),
                arguments: "INVALID JSON".to_string(),
            },
        };

        let result = execute_tool(&call, &state).await;
        assert!(
            result.contains("Error parsing arguments:"),
            "unexpected result: {result}"
        );
    }

    // ---------------------------------------------------------------------------
    // execute_tool — tools that use SessionState (no project root → errors)
    // ---------------------------------------------------------------------------

    #[tokio::test]
    async fn execute_tool_read_file_no_project_root_returns_error() {
        let state = SessionState::default(); // no project_root set
        let call = ToolCall {
            id: None,
            kind: "function".to_string(),
            function: FunctionCall {
                name: "read_file".to_string(),
                arguments: r#"{"path": "some_file.txt"}"#.to_string(),
            },
        };

        let result = execute_tool(&call, &state).await;
        assert!(result.starts_with("Error:"), "unexpected result: {result}");
    }

    #[tokio::test]
    async fn execute_tool_write_file_no_project_root_returns_error() {
        let state = SessionState::default();
        let call = ToolCall {
            id: None,
            kind: "function".to_string(),
            function: FunctionCall {
                name: "write_file".to_string(),
                arguments: r#"{"path": "out.txt", "content": "hello"}"#.to_string(),
            },
        };

        let result = execute_tool(&call, &state).await;
        assert!(result.starts_with("Error:"), "unexpected result: {result}");
    }

    #[tokio::test]
    async fn execute_tool_list_directory_no_project_root_returns_error() {
        let state = SessionState::default();
        let call = ToolCall {
            id: None,
            kind: "function".to_string(),
            function: FunctionCall {
                name: "list_directory".to_string(),
                arguments: r#"{"path": "."}"#.to_string(),
            },
        };

        let result = execute_tool(&call, &state).await;
        assert!(result.starts_with("Error:"), "unexpected result: {result}");
    }

    #[tokio::test]
    async fn execute_tool_search_files_no_project_root_returns_error() {
        let state = SessionState::default();
        let call = ToolCall {
            id: None,
            kind: "function".to_string(),
            function: FunctionCall {
                name: "search_files".to_string(),
                arguments: r#"{"query": "fn main"}"#.to_string(),
            },
        };

        let result = execute_tool(&call, &state).await;
        assert!(result.starts_with("Error:"), "unexpected result: {result}");
    }

    #[tokio::test]
    async fn execute_tool_exec_shell_no_project_root_returns_error() {
        let state = SessionState::default();
        let call = ToolCall {
            id: None,
            kind: "function".to_string(),
            function: FunctionCall {
                name: "exec_shell".to_string(),
                arguments: r#"{"command": "ls", "args": []}"#.to_string(),
            },
        };

        let result = execute_tool(&call, &state).await;
        assert!(result.starts_with("Error:"), "unexpected result: {result}");
    }

    // ---------------------------------------------------------------------------
    // build_claude_code_context_prompt (Bug 245)
    // ---------------------------------------------------------------------------

    #[test]
    fn context_prompt_single_message_returns_content_as_is() {
        let messages = vec![Message {
            role: Role::User,
            content: "hello".to_string(),
            tool_calls: None,
            tool_call_id: None,
        }];
        let result = build_claude_code_context_prompt(&messages, "hello");
        assert_eq!(result, "hello");
    }

    #[test]
    fn context_prompt_includes_prior_conversation() {
        let messages = vec![
            Message {
                role: Role::User,
                content: "What is Rust?".to_string(),
                tool_calls: None,
                tool_call_id: None,
            },
            Message {
                role: Role::Assistant,
                content: "Rust is a systems language.".to_string(),
                tool_calls: None,
                tool_call_id: None,
            },
            Message {
                role: Role::User,
                content: "Tell me more".to_string(),
                tool_calls: None,
                tool_call_id: None,
            },
        ];
        let result = build_claude_code_context_prompt(&messages, "Tell me more");
        assert!(
            result.contains("<conversation_history>"),
            "should have history preamble"
        );
        assert!(
            result.contains("[User]: What is Rust?"),
            "should include prior user message"
        );
        assert!(
            result.contains("[Assistant]: Rust is a systems language."),
            "should include prior assistant message"
        );
        assert!(
            result.contains("</conversation_history>"),
            "should close history block"
        );
        assert!(
            result.ends_with("Tell me more"),
            "should end with latest user message"
        );
    }

    #[test]
    fn context_prompt_skips_system_messages() {
        let messages = vec![
            Message {
                role: Role::System,
                content: "You are a helpful assistant.".to_string(),
                tool_calls: None,
                tool_call_id: None,
            },
            Message {
                role: Role::User,
                content: "hi".to_string(),
                tool_calls: None,
                tool_call_id: None,
            },
            Message {
                role: Role::Assistant,
                content: "hello".to_string(),
                tool_calls: None,
                tool_call_id: None,
            },
            Message {
                role: Role::User,
                content: "bye".to_string(),
                tool_calls: None,
                tool_call_id: None,
            },
        ];
        let result = build_claude_code_context_prompt(&messages, "bye");
        assert!(
            !result.contains("helpful assistant"),
            "should not include system messages"
        );
        assert!(result.contains("[User]: hi"));
        assert!(result.contains("[Assistant]: hello"));
    }
}
