//! Trinity - The Test Generator Agent
//!
//! Trinity generates test code for tasks processed by Gimli (the code generator).
//! It builds LLM prompts with full function context, runs generated tests via
//! `cargo test` or language-appropriate runners, parses the output, and suggests
//! fixes when tests fail.

use std::path::Path;
use std::sync::Arc;

use anyhow::{Context, Result};
use regex::Regex;

use crate::db::sqlite::SqliteDb;
use crate::error::FurnaceError;
use crate::types::{LlmProvider, Task, TaskType, TestResults};

// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------

/// Maximum number of fix suggestions returned for a failing test run.
const MAX_FIX_SUGGESTIONS: usize = 5;

/// Timeout for LLM-based test generation and fix suggestion calls.
const DEFAULT_TEST_TIMEOUT_SECS: u64 = 120;

// ---------------------------------------------------------------------------
// Trinity
// ---------------------------------------------------------------------------

/// Trinity is responsible for generating, executing, and analysing tests that
/// verify code produced by the Gimli agent.  It works at the *Task* level:
/// given a completed task and its generated code, Trinity produces a test file,
/// runs it in the project workspace, and reports structured results back.
pub struct Trinity {
    /// LLM backend used for test generation and fix suggestions.
    llm: Arc<dyn LlmProvider>,
    /// SQLite handle for persisting test artefacts and reading task data.
    _sqlite: Arc<SqliteDb>,
}

impl Trinity {
    // -- Construction -------------------------------------------------------

    /// Create a new Trinity instance.
    pub fn new(llm: Arc<dyn LlmProvider>, sqlite: Arc<SqliteDb>) -> Self {
        Self { llm, _sqlite: sqlite }
    }

    // -- Public API ---------------------------------------------------------

    /// Generate test source code for *generated_code* within the context of
    /// *task*.  The returned `String` is a complete, compilable test module
    /// suitable for writing to a `.rs` / `.ts` / `.py` file depending on the
    /// target language inferred from the task.
    pub async fn generate_tests(&self, task: &Task, generated_code: &str) -> Result<String> {
        tracing::info!(
            task_id = %task.id,
            target_file = %task.target_file,
            "Trinity: generating tests"
        );

        let prompt = self.build_test_prompt(task, generated_code);

        let system = "\
            You are an expert test engineer.  Given source code and its context, \
            generate a comprehensive test suite.  Include happy-path tests, edge \
            cases, and error-handling tests.  Output ONLY valid source code for \
            the test module -- no prose, no markdown fences.  The tests must be \
            self-contained and compile on their own when placed alongside the \
            code under test. \
            IMPORTANT: Generate tests directly. Do not ask for clarification or \
            suggest alternatives -- produce the test code.";

        if !self.llm.is_available() {
            return Err(FurnaceError::Agent {
                agent: "Trinity".into(),
                message: "LLM unavailable: host does not support MCP sampling".into(),
            }.into());
        }

        let test_code = tokio::time::timeout(
            std::time::Duration::from_secs(DEFAULT_TEST_TIMEOUT_SECS),
            self.llm.complete_with_system(system, &prompt),
        )
        .await
        .map_err(|_| FurnaceError::Agent {
            agent: "Trinity".into(),
            message: format!("LLM test generation timed out after {DEFAULT_TEST_TIMEOUT_SECS}s"),
        })?
        .map_err(|e| FurnaceError::Agent {
            agent: "Trinity".into(),
            message: format!("LLM test generation failed: {e}"),
        })?;

        let test_code = Self::strip_markdown_fences(&test_code);

        tracing::info!(
            task_id = %task.id,
            lines = test_code.lines().count(),
            "Trinity: test code generated"
        );

        Ok(test_code)
    }

    /// Generate tests for multiple tasks in a single LLM call.
    ///
    /// Builds a combined prompt with all task/code pairs, sends one LLM request,
    /// and parses the response to extract per-task test blocks. Falls back to
    /// individual `generate_tests` calls if batch parsing fails.
    pub async fn generate_tests_batch(
        &self,
        tasks_and_code: &[(&Task, &str)],
    ) -> Result<Vec<(String, String)>> {
        if tasks_and_code.is_empty() {
            return Ok(vec![]);
        }

        // Single task — delegate to normal path
        if tasks_and_code.len() == 1 {
            let (task, code) = tasks_and_code[0];
            let tests = self.generate_tests(task, code).await?;
            return Ok(vec![(task.id.clone(), tests)]);
        }

        tracing::info!(count = tasks_and_code.len(), "Trinity: batch test generation starting");

        // Build combined prompt
        let mut prompt_parts = Vec::new();
        prompt_parts.push(
            "Generate test suites for MULTIPLE code files in a single response.\n\
             Output one fenced code block per task, in the SAME ORDER as listed below.\n\
             Do not skip any task. Do not combine tasks into a single block."
                .to_string(),
        );

        for (i, (task, code)) in tasks_and_code.iter().enumerate() {
            let task_prompt = self.build_test_prompt(task, code);
            prompt_parts.push(format!(
                "\n---\n## Task {} of {} (ID: {})\n\n{}",
                i + 1,
                tasks_and_code.len(),
                task.id,
                task_prompt,
            ));
        }

        let combined_prompt = prompt_parts.join("\n");
        let system = format!(
            "You are an expert test engineer generating tests for {} files simultaneously.\n\
             For EACH task, output a complete, compilable test module in its own fenced code block.\n\
             Output exactly {} fenced code blocks, one per task, in the same order as given.\n\
             Output ONLY valid source code -- no prose outside code blocks.\n\
             IMPORTANT: Generate tests directly. Do not ask for clarification.\n",
            tasks_and_code.len(),
            tasks_and_code.len()
        );

        if !self.llm.is_available() {
            return Err(FurnaceError::Agent {
                agent: "Trinity".into(),
                message: "LLM unavailable: host does not support MCP sampling".into(),
            }.into());
        }

        let raw_response = tokio::time::timeout(
            std::time::Duration::from_secs(180),
            self.llm.complete_with_system(&system, &combined_prompt),
        )
        .await
        .map_err(|_| FurnaceError::Agent {
            agent: "Trinity".into(),
            message: "Batch test generation timed out after 180s".into(),
        })?
        .map_err(|e| FurnaceError::Agent {
            agent: "Trinity".into(),
            message: format!("Batch test generation failed: {e}"),
        })?;

        // Parse per-task test blocks
        let task_ids: Vec<&str> = tasks_and_code.iter().map(|(t, _)| t.id.as_str()).collect();
        let parsed = Self::parse_batch_test_response(&raw_response, &task_ids);

        match parsed {
            Some(test_map) if test_map.len() == tasks_and_code.len() => {
                let results: Vec<(String, String)> = tasks_and_code
                    .iter()
                    .map(|(task, _)| {
                        let tests = test_map.get(task.id.as_str()).cloned().unwrap_or_default();
                        (task.id.clone(), tests)
                    })
                    .collect();
                tracing::info!(count = results.len(), "Trinity: batch test generation complete");
                Ok(results)
            }
            _ => {
                tracing::warn!("Batch test response parsing failed, falling back to individual generation");
                let mut results = Vec::new();
                for (task, code) in tasks_and_code {
                    match self.generate_tests(task, code).await {
                        Ok(tests) => results.push((task.id.clone(), tests)),
                        Err(e) => {
                            tracing::warn!(task_id = %task.id, error = %e, "Individual test gen fallback failed");
                            return Err(e);
                        }
                    }
                }
                Ok(results)
            }
        }
    }

    /// Parse a batch test response into per-task test code.
    ///
    /// Uses two strategies:
    /// 1. **Positional matching** (primary): extract all fenced code blocks in
    ///    order and zip them with task IDs by position.
    /// 2. **Marker-based matching** (fallback): look for
    ///    `// === TESTS FOR TASK: <task_id> ===` markers.
    ///
    /// Returns `None` if neither strategy produces a complete mapping.
    fn parse_batch_test_response<'a>(
        response: &str,
        task_ids: &[&'a str],
    ) -> Option<std::collections::HashMap<&'a str, String>> {
        // Strategy 1: Positional — extract all fenced code blocks in order
        let blocks = Self::extract_all_code_blocks(response);
        if blocks.len() == task_ids.len() {
            let mut test_map = std::collections::HashMap::new();
            for (&tid, code) in task_ids.iter().zip(blocks.into_iter()) {
                test_map.insert(tid, code);
            }
            return Some(test_map);
        }

        // Strategy 2: Marker-based (fallback for non-standard formatting)
        let mut test_map = std::collections::HashMap::new();
        let id_set: std::collections::HashSet<&str> = task_ids.iter().copied().collect();

        let marker_re = regex::Regex::new(r"// === TESTS FOR TASK:\s*(\S+)\s*===").ok()?;
        let sections: Vec<&str> = marker_re.split(response).collect();
        let matched_ids: Vec<String> = marker_re
            .captures_iter(response)
            .filter_map(|c| c.get(1).map(|m| m.as_str().to_string()))
            .collect();

        for (i, matched_id) in matched_ids.iter().enumerate() {
            if let Some(&tid) = task_ids.iter().find(|&&id| id == matched_id.as_str()) {
                if !id_set.contains(tid) {
                    continue;
                }
                if let Some(section) = sections.get(i + 1) {
                    let code = Self::strip_markdown_fences(section.trim());
                    test_map.insert(tid, code);
                }
            }
        }

        if test_map.len() == task_ids.len() {
            Some(test_map)
        } else {
            None
        }
    }

    /// Extract all fenced code blocks from a response, in order.
    fn extract_all_code_blocks(response: &str) -> Vec<String> {
        let mut blocks = Vec::new();
        let mut lines = response.lines().peekable();

        while let Some(line) = lines.next() {
            if line.starts_with("```") {
                let mut code_lines = Vec::new();
                for inner_line in lines.by_ref() {
                    if inner_line.starts_with("```") {
                        break;
                    }
                    code_lines.push(inner_line);
                }
                let code = code_lines.join("\n");
                if !code.trim().is_empty() {
                    blocks.push(code);
                }
            }
        }

        blocks
    }

    /// Build the full prompt that will be sent to the LLM for test generation.
    ///
    /// The prompt includes:
    /// - The task description and type
    /// - The target file path (to infer the language)
    /// - The generated code to test
    /// - Any dependency information from the task
    pub fn build_test_prompt(&self, task: &Task, code: &str) -> String {
        let language = Self::detect_language(&task.target_file);
        let task_type_label = match task.task_type {
            TaskType::Create => "New file creation",
            TaskType::Modify => "Modification of existing file",
            TaskType::Delete => "File deletion",
            TaskType::Test => "Test file",
        };

        let dependency_note = if task.dependencies.is_empty() {
            String::from("This task has no dependencies on other tasks.")
        } else {
            format!(
                "This task depends on tasks: {}",
                task.dependencies.join(", ")
            )
        };

        format!(
            r#"Generate a comprehensive test suite for the following code.

TASK DESCRIPTION:
{description}

TASK TYPE: {task_type_label}
TARGET FILE: {target_file}
LANGUAGE: {language}
{dependency_note}

CODE UNDER TEST:
```{language}
{code}
```

Requirements:
1. Write tests for all public functions and methods.
2. Include at least one happy-path test per function.
3. Include edge-case tests (empty inputs, boundary values, zero/negative numbers, etc.).
4. Include error-handling tests where the code can return errors or panic.
5. Use the idiomatic test framework for {language}.
6. For Rust, use #[cfg(test)] mod tests with #[test] (or #[tokio::test] for async functions).
7. For TypeScript, use Jest/describe/it blocks.
8. For Python, use pytest.
9. Make the tests self-contained -- mock external dependencies if needed.
10. Add brief doc-comments explaining what each test verifies.
"#,
            description = task.description,
            target_file = task.target_file,
        )
    }

    /// Build prefetch prompts for test generation without calling the LLM.
    ///
    /// Returns one `PrefetchPrompt` per task. Each prompt contains the same
    /// system + user prompt that `generate_tests` would use.
    pub fn prepare_test_prompts(
        &self,
        tasks_with_code: &[(&Task, &str)],
    ) -> Vec<crate::mcp::prefetch::PrefetchPrompt> {
        let system_prompt = "\
            You are an expert test engineer.  Given source code and its context, \
            generate a comprehensive test suite.  Include happy-path tests, edge \
            cases, and error-handling tests.  Output ONLY valid source code for \
            the test module -- no prose, no markdown fences.  The tests must be \
            self-contained and compile on their own when placed alongside the \
            code under test. \
            IMPORTANT: Generate tests directly. Do not ask for clarification or \
            suggest alternatives -- produce the test code.".to_string();

        tasks_with_code
            .iter()
            .map(|(task, code)| {
                let user_prompt = self.build_test_prompt(task, code);
                crate::mcp::prefetch::PrefetchPrompt {
                    id: format!("trinity-test-{}", task.id),
                    agent: "Trinity".into(),
                    task_id: Some(task.id.clone()),
                    system_prompt: system_prompt.clone(),
                    user_prompt,
                    description: format!("Generate tests for {}", task.target_file),
                }
            })
            .collect()
    }

    /// Run the test file at *test_file* within the project rooted at
    /// *workspace_root*.  Returns structured `TestResults`.
    pub async fn run_tests(
        &self,
        test_file: &Path,
        workspace_root: &Path,
    ) -> Result<TestResults> {
        tracing::info!(
            test_file = %test_file.display(),
            workspace = %workspace_root.display(),
            "Trinity: running tests"
        );

        let language = Self::detect_language(
            test_file
                .to_str()
                .unwrap_or("unknown"),
        );

        let output = match language.as_str() {
            "rust" => self.run_rust_tests(test_file, workspace_root).await?,
            "typescript" | "javascript" => {
                self.run_node_tests(test_file, workspace_root).await?
            }
            "python" => self.run_python_tests(test_file, workspace_root).await?,
            other => {
                tracing::warn!(language = other, "Trinity: unsupported language, trying generic runner");
                self.run_generic_tests(test_file, workspace_root).await?
            }
        };

        let results = self.parse_test_output(&output);

        tracing::info!(
            total = results.total,
            passed = results.passed,
            failed = results.failed,
            skipped = results.skipped,
            "Trinity: test run complete"
        );

        Ok(results)
    }

    /// Parse raw test-runner output into structured `TestResults`.
    ///
    /// This parser is intentionally lenient: it tries multiple common formats
    /// (Rust `cargo test`, Jest, pytest) and falls back to a line-counting
    /// heuristic.
    pub fn parse_test_output(&self, output: &str) -> TestResults {
        // Try Rust cargo test format first:
        //   test result: ok. 5 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out
        if let Some(results) = Self::parse_cargo_test_output(output) {
            return results;
        }

        // Try Jest format:
        //   Tests: 2 failed, 8 passed, 10 total
        if let Some(results) = Self::parse_jest_output(output) {
            return results;
        }

        // Try pytest format:
        //   === 3 passed, 1 failed in 0.42s ===
        if let Some(results) = Self::parse_pytest_output(output) {
            return results;
        }

        // Fallback: count lines containing "PASS", "FAIL", "ok", "FAILED"
        Self::parse_fallback(output)
    }

    /// Analyse *test_results* and ask the LLM for concrete fix suggestions.
    ///
    /// Returns up to `MAX_FIX_SUGGESTIONS` suggestions, each a short
    /// human-readable string describing the change to make.
    pub async fn suggest_fixes(&self, test_results: &TestResults) -> Result<Vec<String>> {
        if test_results.failed == 0 {
            tracing::debug!("Trinity: all tests passed -- no fixes needed");
            return Ok(Vec::new());
        }

        tracing::info!(
            failed = test_results.failed,
            "Trinity: suggesting fixes for failing tests"
        );

        let prompt = format!(
            r#"The following test run has {failed} failing test(s).
Analyse the test output below and suggest up to {max} concrete code fixes.
Each suggestion should be one short paragraph.

TEST OUTPUT:
{output}

Return a numbered list of suggestions, e.g.:
1. ...
2. ...
"#,
            failed = test_results.failed,
            max = MAX_FIX_SUGGESTIONS,
            output = test_results.output,
        );

        let system = "\
            You are an expert debugger.  Given test failure output, provide \
            concise, actionable fix suggestions.  Focus on the root cause. \
            IMPORTANT: Respond with your numbered list directly. Do not ask \
            for clarification or request additional information.";

        if !self.llm.is_available() {
            return Ok(vec!["LLM unavailable — unable to generate fix suggestions".to_string()]);
        }

        let response = tokio::time::timeout(
            std::time::Duration::from_secs(30),
            self.llm.complete_with_system(system, &prompt),
        )
        .await
        .map_err(|_| FurnaceError::Agent {
            agent: "Trinity".into(),
            message: "LLM fix suggestion timed out after 30s".into(),
        })?
        .map_err(|e| FurnaceError::Agent {
            agent: "Trinity".into(),
            message: format!("LLM fix suggestion failed: {e}"),
        })?;

        let suggestions = Self::parse_numbered_list(&response);

        tracing::info!(
            count = suggestions.len(),
            "Trinity: fix suggestions generated"
        );

        Ok(suggestions)
    }

    // -- Private: test runners ----------------------------------------------

    async fn run_rust_tests(
        &self,
        _test_file: &Path,
        workspace_root: &Path,
    ) -> Result<String> {
        // For Rust we run `cargo test` in the workspace.  If the test_file is
        // inside the project tree cargo will pick it up automatically.
        let output = tokio::time::timeout(
            std::time::Duration::from_secs(120),
            tokio::process::Command::new("cargo")
                .arg("test")
                .arg("--")
                .arg("--nocapture")
                .current_dir(workspace_root)
                .env("RUST_BACKTRACE", "1")
                .output()
        )
        .await
        .map_err(|_| anyhow::anyhow!("Test runner timed out after 120s"))?
        .context("Failed to spawn cargo test")?;

        let stdout = String::from_utf8_lossy(&output.stdout);
        let stderr = String::from_utf8_lossy(&output.stderr);
        Ok(format!("{stdout}\n{stderr}"))
    }

    async fn run_node_tests(
        &self,
        test_file: &Path,
        workspace_root: &Path,
    ) -> Result<String> {
        // Prefer npx jest; fall back to npm test.
        let output = tokio::time::timeout(
            std::time::Duration::from_secs(120),
            tokio::process::Command::new("npx")
                .arg("jest")
                .arg("--no-coverage")
                .arg(test_file.to_str().unwrap_or_default())
                .current_dir(workspace_root)
                .output()
        )
        .await
        .map_err(|_| anyhow::anyhow!("Test runner timed out after 120s"))?
        .context("Failed to spawn npx jest")?;

        let stdout = String::from_utf8_lossy(&output.stdout);
        let stderr = String::from_utf8_lossy(&output.stderr);
        Ok(format!("{stdout}\n{stderr}"))
    }

    async fn run_python_tests(
        &self,
        test_file: &Path,
        workspace_root: &Path,
    ) -> Result<String> {
        let output = tokio::time::timeout(
            std::time::Duration::from_secs(120),
            tokio::process::Command::new("python")
                .arg("-m")
                .arg("pytest")
                .arg("-v")
                .arg(test_file.to_str().unwrap_or_default())
                .current_dir(workspace_root)
                .output()
        )
        .await
        .map_err(|_| anyhow::anyhow!("Test runner timed out after 120s"))?
        .context("Failed to spawn pytest")?;

        let stdout = String::from_utf8_lossy(&output.stdout);
        let stderr = String::from_utf8_lossy(&output.stderr);
        Ok(format!("{stdout}\n{stderr}"))
    }

    async fn run_generic_tests(
        &self,
        test_file: &Path,
        workspace_root: &Path,
    ) -> Result<String> {
        // Last-resort: try to run the file directly.
        let output = tokio::time::timeout(
            std::time::Duration::from_secs(120),
            tokio::process::Command::new(
                test_file.to_str().unwrap_or("test"),
            )
            .current_dir(workspace_root)
            .output()
        )
        .await
        .map_err(|_| anyhow::anyhow!("Test runner timed out after 120s"))?
        .context("Failed to spawn generic test runner")?;

        let stdout = String::from_utf8_lossy(&output.stdout);
        let stderr = String::from_utf8_lossy(&output.stderr);
        Ok(format!("{stdout}\n{stderr}"))
    }

    // -- Private: output parsers --------------------------------------------

    /// Parse output from `cargo test`.
    ///
    /// Expected summary line:
    /// ```text
    /// test result: ok. 5 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out
    /// ```
    fn parse_cargo_test_output(output: &str) -> Option<TestResults> {
        let re = Regex::new(
            r"test result: \w+\.\s+(\d+) passed;\s+(\d+) failed;\s+(\d+) ignored",
        )
        .ok()?;

        let caps = re.captures(output)?;
        let passed: usize = caps.get(1)?.as_str().parse().ok()?;
        let failed: usize = caps.get(2)?.as_str().parse().ok()?;
        let skipped: usize = caps.get(3)?.as_str().parse().ok()?;

        Some(TestResults {
            total: passed + failed + skipped,
            passed,
            failed,
            skipped,
            output: output.to_string(),
        })
    }

    /// Parse output from Jest.
    ///
    /// Expected line:
    /// ```text
    /// Tests:       2 failed, 8 passed, 10 total
    /// ```
    fn parse_jest_output(output: &str) -> Option<TestResults> {
        let re = Regex::new(
            r"Tests:\s+(?:(\d+) failed,\s*)?(?:(\d+) skipped,\s*)?(?:(\d+) passed,\s*)?(\d+) total",
        )
        .ok()?;

        let caps = re.captures(output)?;
        let failed: usize = caps.get(1).and_then(|m| m.as_str().parse().ok()).unwrap_or(0);
        let skipped: usize = caps.get(2).and_then(|m| m.as_str().parse().ok()).unwrap_or(0);
        let passed: usize = caps.get(3).and_then(|m| m.as_str().parse().ok()).unwrap_or(0);
        let total: usize = caps.get(4)?.as_str().parse().ok()?;

        Some(TestResults {
            total,
            passed,
            failed,
            skipped,
            output: output.to_string(),
        })
    }

    /// Parse output from pytest.
    ///
    /// Expected line:
    /// ```text
    /// === 3 passed, 1 failed in 0.42s ===
    /// ```
    fn parse_pytest_output(output: &str) -> Option<TestResults> {
        // pytest can emit various combos; capture all optional groups.
        let re = Regex::new(
            r"=+\s*((?:\d+\s+\w+,?\s*)+)in\s+[\d.]+s\s*=+",
        )
        .ok()?;

        let caps = re.captures(output)?;
        let summary = caps.get(1)?.as_str();

        let num_re = Regex::new(r"(\d+)\s+(passed|failed|skipped|error|warnings?)").ok()?;
        let mut passed = 0usize;
        let mut failed = 0usize;
        let mut skipped = 0usize;

        for cap in num_re.captures_iter(summary) {
            let n: usize = cap[1].parse().unwrap_or(0);
            match &cap[2] {
                "passed" => passed = n,
                "failed" | "error" => failed += n,
                "skipped" => skipped = n,
                _ => {}
            }
        }

        let total = passed + failed + skipped;
        if total == 0 {
            return None;
        }

        Some(TestResults {
            total,
            passed,
            failed,
            skipped,
            output: output.to_string(),
        })
    }

    /// Heuristic fallback parser.  Counts occurrences of common pass/fail
    /// markers.
    fn parse_fallback(output: &str) -> TestResults {
        let mut passed = 0usize;
        let mut failed = 0usize;

        for line in output.lines() {
            let trimmed = line.trim();
            if trimmed.contains("PASSED")
                || trimmed.contains("... ok")
                || trimmed.starts_with("ok ")
                || trimmed.contains("PASS ")
            {
                passed += 1;
            }
            if trimmed.contains("FAILED")
                || trimmed.contains("FAIL ")
                || trimmed.contains("... FAIL")
            {
                failed += 1;
            }
        }

        let total = passed + failed;
        TestResults {
            total,
            passed,
            failed,
            skipped: 0,
            output: output.to_string(),
        }
    }

    // -- Private: helpers ---------------------------------------------------

    /// Detect programming language from a file path extension.
    pub fn detect_language(file_path: &str) -> String {
        let path = Path::new(file_path);
        match path.extension().and_then(|e| e.to_str()) {
            Some("rs") => "rust".into(),
            Some("ts") | Some("tsx") => "typescript".into(),
            Some("js") | Some("jsx") | Some("mjs") | Some("cjs") => "javascript".into(),
            Some("py") => "python".into(),
            Some("go") => "go".into(),
            Some("java") => "java".into(),
            Some("rb") => "ruby".into(),
            Some("cpp") | Some("cc") | Some("cxx") => "cpp".into(),
            Some("c") => "c".into(),
            _ => "unknown".into(),
        }
    }

    /// Strip surrounding markdown code fences from LLM output, if present.
    pub fn strip_markdown_fences(text: &str) -> String {
        let trimmed = text.trim();
        // Handle ```lang\n...\n```
        if trimmed.starts_with("```") {
            let after_first_fence = if let Some(nl) = trimmed.find('\n') {
                &trimmed[nl + 1..]
            } else {
                return trimmed.to_string();
            };
            if let Some(end_idx) = after_first_fence.rfind("```") {
                return after_first_fence[..end_idx].trim().to_string();
            }
        }
        trimmed.to_string()
    }

    /// Parse a numbered list from LLM output into individual suggestion
    /// strings.
    fn parse_numbered_list(text: &str) -> Vec<String> {
        let re = Regex::new(r"(?m)^\s*\d+[.)]\s*(.+)").unwrap();
        let mut items: Vec<String> = Vec::new();

        for cap in re.captures_iter(text) {
            let item = cap[1].trim().to_string();
            if !item.is_empty() {
                items.push(item);
            }
            if items.len() >= MAX_FIX_SUGGESTIONS {
                break;
            }
        }

        // If regex found nothing, treat each non-empty line as a suggestion.
        if items.is_empty() {
            for line in text.lines() {
                let trimmed = line.trim();
                if !trimmed.is_empty() {
                    items.push(trimmed.to_string());
                    if items.len() >= MAX_FIX_SUGGESTIONS {
                        break;
                    }
                }
            }
        }

        items
    }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;
    use crate::types::{Task, TaskStatus, TaskType};
    use chrono::Utc;
    use std::sync::Arc;

    // -- Mock LLM Provider --------------------------------------------------

    struct MockLlm {
        response: String,
    }

    impl MockLlm {
        fn new(response: &str) -> Self {
            Self {
                response: response.to_string(),
            }
        }
    }

    #[async_trait::async_trait]
    impl LlmProvider for MockLlm {
        async fn complete(&self, _prompt: &str) -> anyhow::Result<String> {
            Ok(self.response.clone())
        }

        async fn complete_with_system(
            &self,
            _system: &str,
            _prompt: &str,
        ) -> anyhow::Result<String> {
            Ok(self.response.clone())
        }
    }

    // -- Helper: build a sample task ----------------------------------------

    fn sample_task() -> Task {
        Task {
            id: "task-001".into(),
            scroll_id: "scroll-001".into(),
            description: "Implement a fibonacci function".into(),
            target_file: "src/math.rs".into(),
            task_type: TaskType::Create,
            status: TaskStatus::Completed,
            dependencies: vec![],
            generated_code: Some("pub fn fib(n: u64) -> u64 { if n <= 1 { n } else { fib(n-1) + fib(n-2) } }".into()),
            diff: None,
            created_at: Utc::now(),
            group_label: None,
        }
    }

    // -- Tests: build_test_prompt -------------------------------------------

    #[test]
    fn test_build_test_prompt_contains_code() {
        let llm: Arc<dyn LlmProvider> = Arc::new(MockLlm::new(""));
        let sqlite = create_test_sqlite();
        let trinity = Trinity::new(llm, sqlite);
        let task = sample_task();
        let code = "pub fn add(a: i32, b: i32) -> i32 { a + b }";

        let prompt = trinity.build_test_prompt(&task, code);

        assert!(prompt.contains(code), "Prompt must include the code under test");
        assert!(prompt.contains("rust"), "Prompt must include the detected language");
        assert!(prompt.contains(&task.description), "Prompt must include the task description");
    }

    #[test]
    fn test_build_test_prompt_includes_dependencies() {
        let llm: Arc<dyn LlmProvider> = Arc::new(MockLlm::new(""));
        let sqlite = create_test_sqlite();
        let trinity = Trinity::new(llm, sqlite);
        let mut task = sample_task();
        task.dependencies = vec!["task-000".into(), "task-002".into()];

        let prompt = trinity.build_test_prompt(&task, "fn foo() {}");

        assert!(prompt.contains("task-000"), "Prompt must mention dependency task-000");
        assert!(prompt.contains("task-002"), "Prompt must mention dependency task-002");
    }

    // -- Tests: generate_tests via mock LLM ---------------------------------

    #[tokio::test]
    async fn test_generate_tests_returns_code() {
        let mock_output = r#"```rust
#[cfg(test)]
mod tests {
    #[test]
    fn test_add() {
        assert_eq!(super::add(2, 3), 5);
    }
}
```"#;
        let llm: Arc<dyn LlmProvider> = Arc::new(MockLlm::new(mock_output));
        let sqlite = create_test_sqlite();
        let trinity = Trinity::new(llm, sqlite);
        let task = sample_task();

        let result = trinity
            .generate_tests(&task, "pub fn add(a: i32, b: i32) -> i32 { a + b }")
            .await;

        assert!(result.is_ok());
        let code = result.unwrap();
        assert!(code.contains("test_add"), "Generated test code should contain test_add");
        assert!(!code.contains("```"), "Markdown fences should be stripped");
    }

    // -- Tests: parse_test_output (cargo test) ------------------------------

    #[test]
    fn test_parse_cargo_test_output() {
        let output = r#"
running 5 tests
test tests::test_add ... ok
test tests::test_sub ... ok
test tests::test_mul ... ok
test tests::test_div ... ok
test tests::test_neg ... FAILED

test result: FAILED. 4 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
"#;
        let llm: Arc<dyn LlmProvider> = Arc::new(MockLlm::new(""));
        let sqlite = create_test_sqlite();
        let trinity = Trinity::new(llm, sqlite);

        let results = trinity.parse_test_output(output);
        assert_eq!(results.total, 5);
        assert_eq!(results.passed, 4);
        assert_eq!(results.failed, 1);
        assert_eq!(results.skipped, 0);
    }

    #[test]
    fn test_parse_cargo_test_all_pass() {
        let output = "test result: ok. 10 passed; 0 failed; 2 ignored; 0 measured; 0 filtered out";
        let llm: Arc<dyn LlmProvider> = Arc::new(MockLlm::new(""));
        let sqlite = create_test_sqlite();
        let trinity = Trinity::new(llm, sqlite);

        let results = trinity.parse_test_output(output);
        assert_eq!(results.total, 12);
        assert_eq!(results.passed, 10);
        assert_eq!(results.failed, 0);
        assert_eq!(results.skipped, 2);
    }

    // -- Tests: parse_test_output (Jest) ------------------------------------

    #[test]
    fn test_parse_jest_output() {
        let output = r#"
PASS  src/math.test.ts
  Math
    ✓ adds numbers (2 ms)
    ✗ divides by zero (1 ms)

Tests:       1 failed, 1 passed, 2 total
Snapshots:   0 total
Time:        1.234 s
"#;
        let llm: Arc<dyn LlmProvider> = Arc::new(MockLlm::new(""));
        let sqlite = create_test_sqlite();
        let trinity = Trinity::new(llm, sqlite);

        let results = trinity.parse_test_output(output);
        assert_eq!(results.total, 2);
        assert_eq!(results.passed, 1);
        assert_eq!(results.failed, 1);
    }

    // -- Tests: parse_test_output (pytest) ----------------------------------

    #[test]
    fn test_parse_pytest_output() {
        let output = r#"
============================= test session starts ==============================
collected 4 items

test_math.py::test_add PASSED
test_math.py::test_sub PASSED
test_math.py::test_mul PASSED
test_math.py::test_div FAILED

=========================== 3 passed, 1 failed in 0.42s ===========================
"#;
        let llm: Arc<dyn LlmProvider> = Arc::new(MockLlm::new(""));
        let sqlite = create_test_sqlite();
        let trinity = Trinity::new(llm, sqlite);

        let results = trinity.parse_test_output(output);
        assert_eq!(results.total, 4);
        assert_eq!(results.passed, 3);
        assert_eq!(results.failed, 1);
    }

    // -- Tests: parse_test_output (fallback) --------------------------------

    #[test]
    fn test_parse_fallback_output() {
        let output = r#"
test_one ... ok
test_two ... ok
test_three ... FAILED
"#;
        let llm: Arc<dyn LlmProvider> = Arc::new(MockLlm::new(""));
        let sqlite = create_test_sqlite();
        let trinity = Trinity::new(llm, sqlite);

        let results = trinity.parse_test_output(output);
        assert_eq!(results.passed, 2);
        assert_eq!(results.failed, 1);
        assert_eq!(results.total, 3);
    }

    // -- Tests: suggest_fixes -----------------------------------------------

    #[tokio::test]
    async fn test_suggest_fixes_all_pass() {
        let llm: Arc<dyn LlmProvider> = Arc::new(MockLlm::new(""));
        let sqlite = create_test_sqlite();
        let trinity = Trinity::new(llm, sqlite);

        let results = TestResults {
            total: 3,
            passed: 3,
            failed: 0,
            skipped: 0,
            output: String::new(),
        };

        let suggestions = trinity.suggest_fixes(&results).await.unwrap();
        assert!(suggestions.is_empty(), "No suggestions when all tests pass");
    }

    #[tokio::test]
    async fn test_suggest_fixes_with_failures() {
        let mock_response = "1. Check the boundary condition in line 42.\n2. Handle the None case for the optional parameter.";
        let llm: Arc<dyn LlmProvider> = Arc::new(MockLlm::new(mock_response));
        let sqlite = create_test_sqlite();
        let trinity = Trinity::new(llm, sqlite);

        let results = TestResults {
            total: 5,
            passed: 3,
            failed: 2,
            skipped: 0,
            output: "test_boundary ... FAILED\ntest_none ... FAILED".into(),
        };

        let suggestions = trinity.suggest_fixes(&results).await.unwrap();
        assert_eq!(suggestions.len(), 2);
        assert!(suggestions[0].contains("boundary"));
    }

    // -- Tests: helper utilities --------------------------------------------

    #[test]
    fn test_detect_language() {
        assert_eq!(Trinity::detect_language("src/main.rs"), "rust");
        assert_eq!(Trinity::detect_language("lib/index.ts"), "typescript");
        assert_eq!(Trinity::detect_language("app/views.py"), "python");
        assert_eq!(Trinity::detect_language("cmd/main.go"), "go");
        assert_eq!(Trinity::detect_language("no_extension"), "unknown");
    }

    #[test]
    fn test_strip_markdown_fences() {
        let input = "```rust\nfn main() {}\n```";
        assert_eq!(Trinity::strip_markdown_fences(input), "fn main() {}");

        let no_fences = "fn main() {}";
        assert_eq!(Trinity::strip_markdown_fences(no_fences), "fn main() {}");
    }

    #[test]
    fn test_parse_numbered_list() {
        let input = "1. First fix\n2. Second fix\n3. Third fix";
        let items = Trinity::parse_numbered_list(input);
        assert_eq!(items.len(), 3);
        assert_eq!(items[0], "First fix");
        assert_eq!(items[1], "Second fix");
        assert_eq!(items[2], "Third fix");
    }

    #[test]
    fn test_parse_numbered_list_with_parens() {
        let input = "1) Fix the null check\n2) Add error handling";
        let items = Trinity::parse_numbered_list(input);
        assert_eq!(items.len(), 2);
        assert_eq!(items[0], "Fix the null check");
    }

    #[test]
    fn test_parse_numbered_list_fallback() {
        let input = "Fix the null check\nAdd error handling";
        let items = Trinity::parse_numbered_list(input);
        assert_eq!(items.len(), 2);
    }

    // -- Tests: batch test generation ----------------------------------------

    fn batch_task(id: &str) -> Task {
        Task {
            id: id.into(),
            scroll_id: "SCROLL-001".into(),
            description: "Create math utilities".into(),
            target_file: "src/math.rs".into(),
            task_type: TaskType::Create,
            status: TaskStatus::Pending,
            dependencies: vec![],
            generated_code: None,
            diff: None,
            created_at: Utc::now(),
            group_label: None,
        }
    }

    #[tokio::test]
    async fn test_generate_tests_batch_empty() {
        let llm: Arc<dyn LlmProvider> = Arc::new(MockLlm::new(""));
        let sqlite = create_test_sqlite();
        let trinity = Trinity::new(llm, sqlite);

        let result = trinity.generate_tests_batch(&[]).await.unwrap();
        assert!(result.is_empty());
    }

    #[tokio::test]
    async fn test_generate_tests_batch_single_delegates() {
        let mock_output = "#[test]\nfn test_add() { assert_eq!(2 + 2, 4); }";
        let llm: Arc<dyn LlmProvider> = Arc::new(MockLlm::new(mock_output));
        let sqlite = create_test_sqlite();
        let trinity = Trinity::new(llm, sqlite);
        let task = batch_task("TASK-001");

        let results = trinity
            .generate_tests_batch(&[(&task, "pub fn add(a: i32, b: i32) -> i32 { a + b }")])
            .await
            .unwrap();
        assert_eq!(results.len(), 1);
        assert_eq!(results[0].0, "TASK-001");
        assert!(results[0].1.contains("test_add"));
    }

    #[tokio::test]
    async fn test_generate_tests_batch_multi_task() {
        // Mock returns batch-formatted response.
        let batch_response = "\
// === TESTS FOR TASK: TASK-001 ===\n\
#[test]\nfn test_add() { assert_eq!(2 + 2, 4); }\n\
// === TESTS FOR TASK: TASK-002 ===\n\
#[test]\nfn test_sub() { assert_eq!(3 - 1, 2); }";
        let llm: Arc<dyn LlmProvider> = Arc::new(MockLlm::new(batch_response));
        let sqlite = create_test_sqlite();
        let trinity = Trinity::new(llm, sqlite);

        let task1 = batch_task("TASK-001");
        let task2 = batch_task("TASK-002");

        let results = trinity
            .generate_tests_batch(&[
                (&task1, "pub fn add(a: i32, b: i32) -> i32 { a + b }"),
                (&task2, "pub fn sub(a: i32, b: i32) -> i32 { a - b }"),
            ])
            .await
            .unwrap();
        assert_eq!(results.len(), 2);
        assert_eq!(results[0].0, "TASK-001");
        assert!(results[0].1.contains("test_add"));
        assert_eq!(results[1].0, "TASK-002");
        assert!(results[1].1.contains("test_sub"));
    }

    #[tokio::test]
    async fn test_generate_tests_batch_fallback_on_parse_failure() {
        // Mock returns non-batch-formatted response — falls back to individual.
        let individual_response = "#[test]\nfn test_fallback() { }";
        let llm: Arc<dyn LlmProvider> = Arc::new(MockLlm::new(individual_response));
        let sqlite = create_test_sqlite();
        let trinity = Trinity::new(llm, sqlite);

        let task1 = batch_task("TASK-001");
        let task2 = batch_task("TASK-002");

        let results = trinity
            .generate_tests_batch(&[
                (&task1, "fn a() {}"),
                (&task2, "fn b() {}"),
            ])
            .await
            .unwrap();
        assert_eq!(results.len(), 2);
        // Both get the same fallback response.
        assert!(results[0].1.contains("test_fallback"));
        assert!(results[1].1.contains("test_fallback"));
    }

    #[test]
    fn test_parse_batch_test_response_valid() {
        let response = "\
// === TESTS FOR TASK: TASK-001 ===\n\
#[test] fn test_a() {}\n\
// === TESTS FOR TASK: TASK-002 ===\n\
#[test] fn test_b() {}";

        let task_ids = vec!["TASK-001", "TASK-002"];
        let result = Trinity::parse_batch_test_response(response, &task_ids);
        assert!(result.is_some());
        let map = result.unwrap();
        assert_eq!(map.len(), 2);
        assert!(map["TASK-001"].contains("test_a"));
        assert!(map["TASK-002"].contains("test_b"));
    }

    #[test]
    fn test_parse_batch_test_response_missing_task() {
        let response = "// === TESTS FOR TASK: TASK-001 ===\n#[test] fn test_a() {}";
        let task_ids = vec!["TASK-001", "TASK-002"];
        // Only one task found → None.
        let result = Trinity::parse_batch_test_response(response, &task_ids);
        assert!(result.is_none());
    }

    #[test]
    fn test_parse_batch_test_response_positional_no_markers() {
        // No markers — just fenced code blocks in order.
        let response = "\
Here are the test suites:\n\
```rust\n\
#[test] fn test_alpha() { assert!(true); }\n\
```\n\
```rust\n\
#[test] fn test_beta() { assert_eq!(1, 1); }\n\
```";
        let task_ids = vec!["TASK-aug-1739388000000", "TASK-aug-1739388000001"];
        let result = Trinity::parse_batch_test_response(response, &task_ids);
        assert!(result.is_some());
        let map = result.unwrap();
        assert_eq!(map.len(), 2);
        assert!(map["TASK-aug-1739388000000"].contains("test_alpha"));
        assert!(map["TASK-aug-1739388000001"].contains("test_beta"));
    }

    #[test]
    fn test_extract_all_code_blocks_trinity() {
        let response = "```rust\nfn a() {}\n```\ntext\n```rust\nfn b() {}\n```";
        let blocks = Trinity::extract_all_code_blocks(response);
        assert_eq!(blocks.len(), 2);
        assert!(blocks[0].contains("fn a()"));
        assert!(blocks[1].contains("fn b()"));
    }

    // -- Test helper: create in-memory SqliteDb -----------------------------

    fn create_test_sqlite() -> Arc<SqliteDb> {
        // Use a temporary path for testing; SqliteDb creates the file.
        let tmp = std::env::temp_dir().join(format!(
            "furnace_trinity_test_{}.db",
            std::process::id()
        ));
        Arc::new(SqliteDb::new(&tmp).expect("Failed to create test SqliteDb"))
    }

    #[test]
    fn test_prepare_test_prompts_returns_one_per_task() {
        let sqlite = create_test_sqlite();
        let llm: Arc<dyn LlmProvider> = Arc::new(MockLlm::new("test code"));
        let trinity = Trinity::new(llm, sqlite);

        let task1 = sample_task();
        let mut task2 = sample_task();
        task2.id = "task-002".into();
        task2.target_file = "src/utils.rs".into();

        let code1 = "fn foo() {}";
        let code2 = "fn bar() {}";

        let tasks_with_code: Vec<(&Task, &str)> = vec![(&task1, code1), (&task2, code2)];
        let prompts = trinity.prepare_test_prompts(&tasks_with_code);

        assert_eq!(prompts.len(), 2);
        assert_eq!(prompts[0].agent, "Trinity");
        assert_eq!(prompts[0].task_id, Some("task-001".into()));
        assert!(prompts[0].id.contains("task-001"));
        assert!(prompts[0].system_prompt.contains("test engineer"));
        assert!(prompts[0].user_prompt.contains("fn foo()"));
        assert!(prompts[0].description.contains("src/math.rs"));

        assert_eq!(prompts[1].task_id, Some("task-002".into()));
        assert!(prompts[1].user_prompt.contains("fn bar()"));
        assert!(prompts[1].description.contains("src/utils.rs"));
    }
}
