//! Mutation testing reports: GitHub Actions output and Stryker JSON schema v2.
//!
//! ## GitHub Actions (auto-detected)
//! Both features activate automatically via environment variables.
//!
//! - **Tier 1** (`GITHUB_ACTIONS=true`): Emit `::warning` annotation lines for
//!   survived mutants. GitHub Actions renders these as yellow warning badges
//!   on the PR "Files changed" tab.
//! - **Tier 2** (`GITHUB_STEP_SUMMARY` set): Append a Markdown summary table
//!   to the step summary file.
//!
//! ## Stryker mutation-testing-report-schema v2 (`--report json`)
//! The Stryker schema is the de-facto standard interchange format for mutation
//! testing reports, consumed by the Stryker Dashboard and various HTML renderers.
//!
//! Schema reference: <https://github.com/stryker-mutator/mutation-testing-elements>

use anyhow::{bail, Context, Result};
use crate::cache::{CacheCounts, MutantCacheDescriptor};
use crate::protocol::{MutantResult, MutantStatus};
use crate::stats::TestStats;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fmt::Write as FmtWrite;
use std::io::Write as IoWrite;
use std::path::{Path, PathBuf};

/// Self-contained HTML template that loads mutation-testing-elements from unpkg CDN.
/// `REPORT_JSON_PLACEHOLDER` is replaced with the serialised Stryker JSON at write time.
const HTML_TEMPLATE: &str = r#"<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>irradiate — Mutation Testing Report</title>
  <script defer src="https://www.unpkg.com/mutation-testing-elements"></script>
</head>
<body>
  <mutation-test-report-app title-postfix="irradiate">
  </mutation-test-report-app>
  <script>
    document.querySelector('mutation-test-report-app').report = REPORT_JSON_PLACEHOLDER;
  </script>
</body>
</html>
"#;

/// Write a self-contained HTML mutation testing report using the Stryker
/// mutation-testing-elements web component.
///
/// The report JSON is inlined into the page; the only external dependency is
/// the `mutation-testing-elements` script loaded from the unpkg CDN.
pub fn write_html_report(report_json: &serde_json::Value, output_path: &Path) -> anyhow::Result<()> {
    let json_str = serde_json::to_string(report_json)?;
    let html = HTML_TEMPLATE.replace("REPORT_JSON_PLACEHOLDER", &json_str);
    std::fs::write(output_path, html)
        .with_context(|| format!("Failed to write HTML report to {}", output_path.display()))?;
    Ok(())
}

/// Maximum number of `::warning` annotations emitted per step.
/// GitHub Actions truncates annotation lists beyond this point.
const MAX_ANNOTATIONS: usize = 10;

/// Compute the 1-based line number in a source file for a byte offset that is
/// `fn_offset_in_fn` bytes into the function body, given that the function
/// starts at 1-based line `fn_start_line`.
///
/// Returns `fn_start_line` if anything goes wrong (wrong offset, empty source).
pub fn byte_offset_to_line(
    function_source: &str,
    fn_start_line: usize,
    offset_in_fn: usize,
) -> usize {
    if fn_start_line == 0 {
        return 1;
    }
    let capped = offset_in_fn.min(function_source.len());
    let extra_lines = function_source[..capped]
        .bytes()
        .filter(|&b| b == b'\n')
        .count();
    fn_start_line + extra_lines
}

/// Emit GitHub Actions annotations for survived mutants, then write a Markdown
/// step summary if `$GITHUB_STEP_SUMMARY` is set.
///
/// This is a no-op when `GITHUB_ACTIONS != "true"`.
pub fn emit_github_annotations(
    results: &[MutantResult],
    descriptors: &[MutantCacheDescriptor],
    killed: usize,
    survived_count: usize,
) {
    if std::env::var("GITHUB_ACTIONS").as_deref() != Ok("true") {
        return;
    }

    // --- Tier 1: ::warning annotations ---
    let survived: Vec<&MutantResult> = results
        .iter()
        .filter(|r| r.status == MutantStatus::Survived)
        .collect();

    let to_annotate = survived.len().min(MAX_ANNOTATIONS);

    for result in &survived[..to_annotate] {
        if let Some(desc) = descriptors
            .iter()
            .find(|d| d.mutant_name == result.mutant_name)
        {
            let line = byte_offset_to_line(&desc.function_source, desc.fn_start_line, desc.start);
            let file = if desc.source_file.is_empty() {
                "unknown".to_string()
            } else {
                desc.source_file.clone()
            };
            // Extract just the function name for the message
            let func_display = desc
                .mutant_name
                .rsplit_once("__irradiate_")
                .map(|(prefix, _)| prefix.rsplit_once('.').map_or(prefix, |(_, local)| local))
                .unwrap_or(&desc.mutant_name);
            println!(
                "::warning file={file},line={line},endLine={line},title=Survived mutant ({op})::replaced `{orig}` with `{repl}` in {func}()",
                file = file,
                line = line,
                op = desc.operator,
                orig = desc.original,
                repl = desc.replacement,
                func = func_display,
            );
        }
    }

    if survived.len() > MAX_ANNOTATIONS {
        println!(
            "::notice::{annotated} of {total} survived mutants annotated. Use `irradiate results --json` for full details.",
            annotated = MAX_ANNOTATIONS,
            total = survived.len(),
        );
    }

    // --- Tier 2: step summary ---
    if let Ok(summary_path) = std::env::var("GITHUB_STEP_SUMMARY") {
        match build_step_summary(results, descriptors, killed, survived_count) {
            Ok(markdown) => {
                match std::fs::OpenOptions::new()
                    .create(true)
                    .append(true)
                    .open(&summary_path)
                {
                    Ok(mut f) => {
                        if let Err(e) = f.write_all(markdown.as_bytes()) {
                            eprintln!("irradiate: failed to write step summary: {e}");
                        }
                    }
                    Err(e) => {
                        eprintln!("irradiate: could not open $GITHUB_STEP_SUMMARY ({summary_path}): {e}");
                    }
                }
            }
            Err(e) => {
                eprintln!("irradiate: failed to build step summary: {e}");
            }
        }
    }
}

/// Build the Markdown step summary string.
///
/// Extracted as a pure function so it can be tested without touching the
/// filesystem or requiring GITHUB_STEP_SUMMARY to be set.
pub fn build_step_summary(
    results: &[MutantResult],
    descriptors: &[MutantCacheDescriptor],
    killed: usize,
    survived_count: usize,
) -> Result<String, std::fmt::Error> {
    let mut no_coverage = 0usize;
    let mut timeout = 0usize;

    for r in results {
        match r.status {
            MutantStatus::NoTests => no_coverage += 1,
            MutantStatus::Timeout => timeout += 1,
            _ => {}
        }
    }

    let tested = killed + survived_count;
    let score_str = if tested > 0 {
        format!("{:.1}%", killed as f64 / tested as f64 * 100.0)
    } else {
        "N/A".to_string()
    };

    let mut md = String::new();
    writeln!(md, "## Mutation Testing Results — irradiate")?;
    writeln!(md)?;
    writeln!(md, "| Metric | Count |")?;
    writeln!(md, "|--------|-------|")?;
    writeln!(md, "| Killed | {killed} |")?;
    writeln!(md, "| Survived | {survived_count} |")?;
    writeln!(md, "| No coverage | {no_coverage} |")?;
    writeln!(md, "| Timeout | {timeout} |")?;
    writeln!(md, "| **Score** | **{score_str}** |")?;
    writeln!(md)?;

    if survived_count > 0 {
        writeln!(
            md,
            "<details>\n<summary>Survived mutants ({survived_count})</summary>\n"
        )?;
        writeln!(md, "| Mutant | Location | Operator | Mutation |")?;
        writeln!(md, "|--------|----------|----------|----------|")?;

        for result in results {
            if result.status != MutantStatus::Survived {
                continue;
            }
            if let Some(desc) = descriptors
                .iter()
                .find(|d| d.mutant_name == result.mutant_name)
            {
                let line =
                    byte_offset_to_line(&desc.function_source, desc.fn_start_line, desc.start);
                let location = if desc.source_file.is_empty() {
                    format!("line {line}")
                } else {
                    format!("{}:{line}", desc.source_file)
                };
                writeln!(
                    md,
                    "| `{}` | `{location}` | {} | `{}` → `{}` |",
                    result.mutant_name, desc.operator, desc.original, desc.replacement,
                )?;
            } else {
                writeln!(
                    md,
                    "| `{}` | unknown | unknown | unknown |",
                    result.mutant_name,
                )?;
            }
        }

        writeln!(md, "\n</details>")?;
        writeln!(md)?;
    }

    Ok(md)
}

/// Map an irradiate `MutantStatus` to its Stryker schema status string.
fn stryker_status(status: MutantStatus) -> &'static str {
    match status {
        MutantStatus::Killed => "Killed",
        MutantStatus::Survived => "Survived",
        MutantStatus::NoTests => "NoCoverage",
        MutantStatus::Timeout => "Timeout",
        MutantStatus::TypeCheck => "Killed",
        MutantStatus::Error => "RuntimeError",
    }
}

/// Build a Stryker mutation-testing-report-schema v2 JSON value from irradiate results.
///
/// # Arguments
/// - `results` — one `MutantResult` per tested mutant
/// - `descriptors` — rich descriptors with operator, byte spans, and source file.
///   May be empty (e.g. when called from `irradiate results --report`); in that
///   case mutants are included with minimal location info.
/// - `stats` — optional stats from the coverage run; used to populate `coveredBy`
/// - `project_root` — absolute path to the project root (written into the report)
/// - `paths_to_mutate` — used to locate source files when `descriptor.source_file`
///   is not set
pub fn build_stryker_report(
    results: &[MutantResult],
    descriptors: &[MutantCacheDescriptor],
    stats: Option<&TestStats>,
    project_root: &Path,
    paths_to_mutate: &[PathBuf],
) -> serde_json::Value {
    // Index: mutant_name → descriptor
    let desc_by_name: HashMap<&str, &MutantCacheDescriptor> =
        descriptors.iter().map(|d| (d.mutant_name.as_str(), d)).collect();

    // Group mutant entries by relative source file path.
    // The file key is slash-separated and relative to project_root.
    let mut file_mutants: HashMap<String, Vec<serde_json::Value>> = HashMap::new();

    for result in results {
        let name = result.mutant_name.as_str();
        let desc = desc_by_name.get(name).copied();

        // --- Status and timing ---
        let status_str = stryker_status(result.status);
        let duration_ms = (result.duration * 1000.0).round() as u64;

        // --- coveredBy (function-level coverage from stats run) ---
        let covered_by: Vec<serde_json::Value> = if let Some(s) = stats {
            let func_key = name
                .rsplit_once("__irradiate_")
                .or_else(|| name.rsplit_once("__sp_"))
                .or_else(|| name.rsplit_once("__decrem_"))
                .map(|(prefix, _)| prefix)
                .unwrap_or(name);
            s.tests_for_function(func_key)
                .into_iter()
                .map(serde_json::Value::String)
                .collect()
        } else {
            vec![]
        };

        // --- Location ---
        let location = if let Some(d) = desc {
            let (rel_start_line, start_col) =
                crate::mutation::byte_offset_to_location(&d.function_source, d.start);
            let (rel_end_line, end_col) =
                crate::mutation::byte_offset_to_location(&d.function_source, d.end);
            // fn_start_line is 1-indexed; rel_*_line are also 1-indexed relative to function start.
            let abs_start_line = d.fn_start_line + rel_start_line - 1;
            let abs_end_line = d.fn_start_line + rel_end_line - 1;
            serde_json::json!({
                "start": { "line": abs_start_line, "column": start_col },
                "end":   { "line": abs_end_line,   "column": end_col }
            })
        } else {
            // Minimal placeholder when no descriptor is available
            serde_json::json!({
                "start": { "line": 1, "column": 1 },
                "end":   { "line": 1, "column": 1 }
            })
        };

        // --- Mutator name, replacement, description ---
        let (mutator_name, replacement, description) = if let Some(d) = desc {
            (
                d.operator.clone(),
                d.replacement.clone(),
                format!("replaced `{}` with `{}`", d.original, d.replacement),
            )
        } else {
            (
                "unknown".to_string(),
                String::new(),
                "unknown mutation".to_string(),
            )
        };

        // --- Source file key (relative to project_root, slash-separated) ---
        // source_file is already a relative slash-separated path set by the pipeline.
        let file_key = if let Some(d) = desc {
            if d.source_file.is_empty() {
                // source_file was not set; derive from module name
                derive_file_key_from_name(name, project_root, paths_to_mutate)
            } else {
                d.source_file.clone()
            }
        } else {
            derive_file_key_from_name(name, project_root, paths_to_mutate)
        };

        // --- Build mutant object ---
        let mut mutant_obj = serde_json::json!({
            "id": name,
            "mutatorName": mutator_name,
            "replacement": replacement,
            "description": description,
            "location": location,
            "status": status_str,
            "duration": duration_ms,
        });
        if !covered_by.is_empty() {
            mutant_obj["coveredBy"] = serde_json::Value::Array(covered_by);
        }

        file_mutants.entry(file_key).or_default().push(mutant_obj);
    }

    // Build the `files` object: file_key → { language, source, mutants }
    let mut files_obj = serde_json::Map::new();
    for (file_key, mutants) in file_mutants {
        let source_path = project_root.join(&file_key);
        let source = std::fs::read_to_string(&source_path).unwrap_or_default();
        files_obj.insert(
            file_key,
            serde_json::json!({
                "language": "python",
                "source": source,
                "mutants": mutants,
            }),
        );
    }

    serde_json::json!({
        "schemaVersion": "2",
        "thresholds": { "high": 80, "low": 60 },
        "projectRoot": project_root.display().to_string(),
        "files": serde_json::Value::Object(files_obj),
        "framework": {
            "name": "irradiate",
            "version": env!("CARGO_PKG_VERSION"),
        },
    })
}

/// Derive a slash-separated file key from the mutant name (best-effort, no descriptor).
///
/// Mutant name format: `module.x_func__irradiate_N`
/// Module "simple_lib" → check for `paths_to_mutate/simple_lib/__init__.py`
/// or `paths_to_mutate/simple_lib.py`.
fn derive_file_key_from_name(name: &str, project_root: &Path, paths_to_mutate: &[PathBuf]) -> String {
    let module = name.split('.').next().unwrap_or(name);
    let module_path = module.replace('.', "/");

    // Try each source path in order.
    for p in paths_to_mutate {
        let base = if p.is_absolute() {
            p.clone()
        } else {
            project_root.join(p)
        };

        let init_candidate = base.join(&module_path).join("__init__.py");
        let mod_candidate = base.join(format!("{module_path}.py"));

        if init_candidate.exists() {
            return init_candidate
                .strip_prefix(project_root)
                .unwrap_or(&init_candidate)
                .to_string_lossy()
                .replace('\\', "/");
        }
        if mod_candidate.exists() {
            return mod_candidate
                .strip_prefix(project_root)
                .unwrap_or(&mod_candidate)
                .to_string_lossy()
                .replace('\\', "/");
        }
    }

    // Fall back to first path's .py candidate
    let first = paths_to_mutate.first().map_or_else(
        || project_root.to_path_buf(),
        |p| if p.is_absolute() { p.clone() } else { project_root.join(p) },
    );
    let fallback = first.join(format!("{module_path}.py"));
    fallback
        .strip_prefix(project_root)
        .unwrap_or(&fallback)
        .to_string_lossy()
        .replace('\\', "/")
}

/// Per-file metadata, mutmut-compatible.
#[derive(Debug, Default, Serialize, Deserialize)]
pub(crate) struct FileMeta {
    exit_code_by_key: HashMap<String, i32>,
    #[serde(default)]
    durations_by_key: HashMap<String, f64>,
}

/// Entry in the JSON results `mutants` array.
#[derive(Serialize)]
struct JsonMutantEntry {
    name: String,
    status: MutantStatus,
}

/// Top-level JSON output for `irradiate results --json`.
#[derive(Serialize)]
struct JsonResults {
    mutation_score_pct: f64,
    total: usize,
    killed: usize,
    survived: usize,
    no_tests: usize,
    timeout: usize,
    errors: usize,
    mutants: Vec<JsonMutantEntry>,
}

/// Build a `JsonResults` from raw (name, exit_code) pairs.
///
/// Pure function — no I/O. Extracted for testability.
/// When `show_all` is false, `mutants` contains only survived entries.
fn build_json_results(all_results: &[(String, i32)], show_all: bool) -> JsonResults {
    let mut killed = 0usize;
    let mut survived = 0usize;
    let mut no_tests = 0usize;
    let mut timeout = 0usize;
    let mut type_check = 0usize;
    let mut errors = 0usize;
    let mut mutants = Vec::new();

    for (name, exit_code) in all_results {
        let status = MutantStatus::from_exit_code(*exit_code, false);
        match status {
            MutantStatus::Killed => killed += 1,
            MutantStatus::Survived => survived += 1,
            MutantStatus::NoTests => no_tests += 1,
            MutantStatus::Timeout => timeout += 1,
            MutantStatus::TypeCheck => type_check += 1,
            MutantStatus::Error => errors += 1,
        }
        if show_all || status == MutantStatus::Survived {
            mutants.push(JsonMutantEntry { name: name.clone(), status });
        }
    }

    let total = all_results.len();
    // TypeCheck mutants count as killed in the mutation score.
    let effective_killed = killed + type_check;
    let denominator = (effective_killed + survived) as f64;
    let mutation_score_pct = if denominator > 0.0 {
        ((effective_killed as f64 / denominator * 100.0) * 10.0).round() / 10.0
    } else {
        0.0
    };

    JsonResults { mutation_score_pct, total, killed, survived, no_tests, timeout, errors, mutants }
}

/// Display results from previous run.
pub fn results(
    show_all: bool,
    json_output: bool,
    report: Option<String>,
    report_output: Option<PathBuf>,
) -> Result<()> {
    let project_dir = std::env::current_dir()?;
    let mutants_dir = project_dir.join("mutants");
    let all_results = load_all_meta(&mutants_dir)?;

    if all_results.is_empty() && !json_output && report.is_none() {
        eprintln!("No results found. Run `irradiate run` first.");
        return Ok(());
    }

    if json_output {
        let json_out = build_json_results(&all_results, show_all);
        println!("{}", serde_json::to_string_pretty(&json_out)?);
        return Ok(());
    }

    // Generate Stryker-format report from meta files (no descriptor/location info).
    if let Some(ref fmt) = report {
        let output_path = report_output
            .clone()
            .unwrap_or_else(|| PathBuf::from(format!("irradiate-report.{fmt}")));
        let file_config = crate::config::load_config(&project_dir)?;
        let paths_to_mutate: Vec<PathBuf> = file_config
            .paths_to_mutate
            .unwrap_or_else(|| vec!["src".to_string()])
            .into_iter()
            .map(PathBuf::from)
            .collect();
        // Load stats for coveredBy if available
        let stats_path = project_dir.join(".irradiate").join("stats.json");
        let stats = if stats_path.exists() {
            crate::stats::TestStats::load(&stats_path).ok()
        } else {
            None
        };
        // Build MutantResult list from meta files
        let mutant_results = load_all_meta_as_results(&mutants_dir)?;
        let report_val = build_stryker_report(
            &mutant_results,
            &[], // no descriptors available from meta files alone
            stats.as_ref(),
            &project_dir,
            &paths_to_mutate,
        );
        if fmt == "html" {
            write_html_report(&report_val, &output_path)?;
        } else {
            let json_str = serde_json::to_string_pretty(&report_val)?;
            std::fs::write(&output_path, &json_str)
                .with_context(|| format!("Failed to write report to {}", output_path.display()))?;
        }
        eprintln!("Report written to {}", output_path.display());
        return Ok(());
    }

    let mut survived = Vec::new();
    let mut killed = 0;
    let mut no_tests = 0;
    let mut timeout = 0;
    let mut type_check = 0;
    let mut errors = 0;

    for (name, exit_code) in &all_results {
        let status = MutantStatus::from_exit_code(*exit_code, false);
        match status {
            MutantStatus::Survived => survived.push(name.as_str()),
            MutantStatus::Killed => killed += 1,
            MutantStatus::NoTests => no_tests += 1,
            MutantStatus::Timeout => timeout += 1,
            MutantStatus::TypeCheck => type_check += 1,
            MutantStatus::Error => errors += 1,
        }
        if show_all {
            let emoji = status_emoji(status);
            println!("{emoji} {name}");
        }
    }

    if !show_all && !survived.is_empty() {
        eprintln!("Survived mutants:");
        for name in &survived {
            println!("  {name}");
        }
    }

    let total = all_results.len();
    let type_check_str = if type_check > 0 {
        format!("  TypeCheck: {type_check}")
    } else {
        String::new()
    };
    eprintln!(
        "\nTotal: {total}  Killed: {killed}{type_check_str}  Survived: {}  No tests: {no_tests}  Timeout: {timeout}  Errors: {errors}",
        survived.len()
    );

    Ok(())
}

/// Show diff for a specific mutant.
pub fn show(mutant_name: &str) -> Result<()> {
    let mutants_dir = std::env::current_dir()?.join("mutants");
    let all_meta = load_all_meta(&mutants_dir)?;

    if !all_meta.iter().any(|(name, _)| name == mutant_name) {
        bail!(
            "Mutant '{mutant_name}' not found. Run `irradiate results --all` to see all mutants."
        );
    }

    // mutant_name = "module.x_func__irradiate_N"
    // We need the module (for file lookup) and the local variant name (for function lookup)
    let (module, local_variant) = mutant_name.split_once('.').unwrap_or(("", mutant_name));
    let (local_func_mangled, _) = local_variant
        .rsplit_once("__irradiate_")
        .unwrap_or((local_variant, ""));
    let orig_name = format!("{local_func_mangled}__irradiate_orig");

    // Find the mutated source file
    let candidates = [
        mutants_dir.join(format!("{}/{}.py", module.replace('.', "/"), "__init__")),
        mutants_dir.join(format!("{}.py", module.replace('.', "/"))),
    ];
    let source_file = candidates
        .iter()
        .find(|p| p.exists())
        .ok_or_else(|| anyhow::anyhow!("Cannot find source file for module '{module}' in mutants/. Regenerate with: irradiate run"))?;

    let content = std::fs::read_to_string(source_file)?;

    let orig_marker = format!("def {orig_name}(");
    let mutant_marker = format!("def {local_variant}(");

    match (
        extract_function(&content, &orig_marker),
        extract_function(&content, &mutant_marker),
    ) {
        (Some(orig), Some(mutant)) => {
            println!("# {mutant_name}");
            for line in diff_lines(&orig, &mutant) {
                println!("{line}");
            }
        }
        _ => {
            bail!(
                "Could not extract functions for '{mutant_name}' from {}. \
                 The mutants directory may be stale — regenerate with: irradiate run",
                source_file.display()
            );
        }
    }

    Ok(())
}

pub fn write_meta_files(
    mutants_dir: &Path,
    all_names: &HashMap<String, Vec<String>>,
    results: &[MutantResult],
) -> Result<()> {
    // Build result lookup
    let result_map: HashMap<&str, &MutantResult> = results
        .iter()
        .map(|r| (r.mutant_name.as_str(), r))
        .collect();

    for (module, names) in all_names {
        let mut meta = FileMeta::default();
        for name in names {
            if let Some(result) = result_map.get(name.as_str()) {
                meta.exit_code_by_key.insert(name.clone(), result.exit_code);
                meta.durations_by_key.insert(name.clone(), result.duration);
            }
        }

        // Find the .meta file for this module
        let module_path = format!("{}/{}.py.meta", module.replace('.', "/"), "__init__");
        let module_path_alt = format!("{}.py.meta", module.replace('.', "/"));
        let meta_file = if mutants_dir.join(&module_path).exists() {
            mutants_dir.join(module_path)
        } else {
            mutants_dir.join(module_path_alt)
        };

        if let Some(parent) = meta_file.parent() {
            std::fs::create_dir_all(parent)?;
        }
        std::fs::write(&meta_file, serde_json::to_string_pretty(&meta)?)?;
    }

    Ok(())
}

pub fn load_all_meta(mutants_dir: &Path) -> Result<Vec<(String, i32)>> {
    let mut all = Vec::new();
    if !mutants_dir.exists() {
        return Ok(all);
    }

    for entry in walkdir(mutants_dir)? {
        if entry.extension().is_some_and(|e| e == "meta") {
            let content = std::fs::read_to_string(&entry)?;
            if let Ok(meta) = serde_json::from_str::<FileMeta>(&content) {
                for (name, exit_code) in meta.exit_code_by_key {
                    all.push((name, exit_code));
                }
            }
        }
    }

    all.sort_by(|a, b| a.0.cmp(&b.0));
    Ok(all)
}

/// Load all results from .meta files, including durations, as `MutantResult` values.
/// Used by `results --report` which needs the full result struct.
pub fn load_all_meta_as_results(mutants_dir: &Path) -> Result<Vec<MutantResult>> {
    let mut all = Vec::new();
    if !mutants_dir.exists() {
        return Ok(all);
    }

    for entry in walkdir(mutants_dir)? {
        if entry.extension().is_some_and(|e| e == "meta") {
            let content = std::fs::read_to_string(&entry)?;
            if let Ok(meta) = serde_json::from_str::<FileMeta>(&content) {
                for (name, exit_code) in &meta.exit_code_by_key {
                    let duration = meta.durations_by_key.get(name).copied().unwrap_or(0.0);
                    let status = MutantStatus::from_exit_code(*exit_code, false);
                    all.push(MutantResult {
                        mutant_name: name.clone(),
                        exit_code: *exit_code,
                        duration,
                        status,
                    });
                }
            }
        }
    }

    all.sort_by(|a, b| a.mutant_name.cmp(&b.mutant_name));
    Ok(all)
}

fn walkdir(dir: &Path) -> Result<Vec<PathBuf>> {
    let mut files = Vec::new();
    if !dir.exists() {
        return Ok(files);
    }
    for entry in std::fs::read_dir(dir)? {
        let entry = entry?;
        let path = entry.path();
        if path.is_dir() {
            files.extend(walkdir(&path)?);
        } else {
            files.push(path);
        }
    }
    Ok(files)
}

/// Print the run summary. Returns (killed, survived) counts for threshold checking.
pub fn print_summary(
    results: &[MutantResult],
    elapsed_secs: f64,
    cache_counts: CacheCounts,
    descriptors: &HashMap<String, MutantCacheDescriptor>,
    sampled_from: Option<usize>,
) -> (usize, usize) {
    let mut killed = 0usize;
    let mut survived = 0usize;
    let mut no_tests = 0usize;
    let mut timeout = 0usize;
    let mut type_check = 0usize;
    let mut errors = 0usize;

    for r in results {
        match r.status {
            MutantStatus::Killed => killed += 1,
            MutantStatus::Survived => survived += 1,
            MutantStatus::NoTests => no_tests += 1,
            MutantStatus::Timeout => timeout += 1,
            MutantStatus::TypeCheck => type_check += 1,
            MutantStatus::Error => errors += 1,
        }
    }

    // TypeCheck mutants count as killed in the mutation score.
    let effective_killed = killed + type_check;

    let total = results.len();
    let rate = if elapsed_secs > 0.0 {
        total as f64 / elapsed_secs
    } else {
        0.0
    };

    // INV-5: Mutation score is always printed in summary output.
    let tested = effective_killed + survived;
    let score_str = if tested > 0 {
        format!("{:.1}%", effective_killed as f64 / tested as f64 * 100.0)
    } else {
        "N/A".to_string()
    };

    eprintln!();
    if let Some(population) = sampled_from {
        eprintln!(
            "Mutation testing complete ({total} of {population} mutants sampled in {elapsed_secs:.1}s, {rate:.0} mutants/sec)"
        );
    } else {
        eprintln!(
            "Mutation testing complete ({total} mutants in {elapsed_secs:.1}s, {rate:.0} mutants/sec)"
        );
    }
    eprintln!("  Cache hits: {0}", cache_counts.hits);
    eprintln!("  Cache misses: {0}", cache_counts.misses);
    eprintln!("  Killed:    {killed}");
    if type_check > 0 {
        eprintln!("  TypeCheck: {type_check}");
    }
    eprintln!("  Survived:  {survived}");
    if no_tests > 0 {
        eprintln!("  No tests:  {no_tests}");
    }
    if timeout > 0 {
        eprintln!("  Timeout:   {timeout}");
    }
    if errors > 0 {
        eprintln!("  Errors:    {errors}");
    }
    eprintln!("  Score:     {score_str}");
    if sampled_from.is_some() {
        eprintln!("  (sampled — score is an estimate)");
    }

    // Per-operator kill rates (only when we have descriptor info and enough mutants)
    if tested > 0 && !descriptors.is_empty() {
        let mut operator_killed: std::collections::BTreeMap<&str, usize> =
            std::collections::BTreeMap::new();
        let mut operator_tested: std::collections::BTreeMap<&str, usize> =
            std::collections::BTreeMap::new();
        for r in results {
            if r.status == MutantStatus::NoTests || r.status == MutantStatus::Error {
                continue;
            }
            let op = descriptors
                .get(&r.mutant_name)
                .map(|d| d.operator.as_str())
                .unwrap_or("unknown");
            *operator_tested.entry(op).or_default() += 1;
            if r.status == MutantStatus::Killed || r.status == MutantStatus::Timeout || r.status == MutantStatus::TypeCheck {
                *operator_killed.entry(op).or_default() += 1;
            }
        }
        if operator_tested.len() > 1 {
            eprintln!();
            eprintln!("  By operator:");
            // Sort by kill rate ascending (weakest first)
            let mut ops: Vec<_> = operator_tested.keys().copied().collect();
            ops.sort_by(|a, b| {
                let rate_a =
                    *operator_killed.get(a).unwrap_or(&0) as f64 / *operator_tested.get(a).unwrap_or(&1) as f64;
                let rate_b =
                    *operator_killed.get(b).unwrap_or(&0) as f64 / *operator_tested.get(b).unwrap_or(&1) as f64;
                rate_a
                    .partial_cmp(&rate_b)
                    .unwrap_or(std::cmp::Ordering::Equal)
            });
            for op in &ops {
                let k = *operator_killed.get(op).unwrap_or(&0);
                let t = *operator_tested.get(op).unwrap_or(&1);
                let pct = k as f64 / t as f64 * 100.0;
                eprintln!("    {op:<36} {k:>3}/{t:<3} {pct:>5.1}%");
            }
        }
    }

    if survived > 0 {
        eprintln!();
        eprintln!("Survived mutants:");

        // Group survivors by operator category
        let mut by_operator: std::collections::BTreeMap<String, Vec<String>> =
            std::collections::BTreeMap::new();
        for r in results {
            if r.status != MutantStatus::Survived {
                continue;
            }
            let (operator, line) = if let Some(desc) = descriptors.get(&r.mutant_name) {
                let (rel_line, _col) =
                    crate::mutation::byte_offset_to_location(&desc.function_source, desc.start);
                let abs_line = desc.fn_start_line + rel_line - 1;
                let file = if desc.source_file.is_empty() {
                    &r.mutant_name
                } else {
                    &desc.source_file
                };
                (
                    desc.operator.clone(),
                    format!(
                        "  {file}:{abs_line}  replaced `{}` with `{}`  [{}]",
                        desc.original, desc.replacement, r.mutant_name,
                    ),
                )
            } else {
                ("unknown".to_string(), format!("  {}", r.mutant_name))
            };
            by_operator.entry(operator).or_default().push(line);
        }

        for (operator, lines) in &by_operator {
            eprintln!();
            eprintln!("  {} ({}):", operator, lines.len());
            for line in lines {
                eprintln!("  {line}");
            }
        }
    }

    (effective_killed, survived)
}

fn status_emoji(status: MutantStatus) -> &'static str {
    match status {
        MutantStatus::Killed => "\u{1f389}",
        MutantStatus::Survived => "\u{1f641}",
        MutantStatus::NoTests => "\u{1fae5}",
        MutantStatus::Timeout => "\u{23f0}",
        MutantStatus::TypeCheck => "\u{1f9d9}",
        MutantStatus::Error => "\u{1f4a5}",
    }
}

fn extract_function(source: &str, marker: &str) -> Option<String> {
    let start = source.find(marker)?;
    let rest = &source[start..];
    let mut lines = Vec::new();
    for (i, line) in rest.lines().enumerate() {
        if i > 0 {
            let trimmed = line.trim_start();
            let indent = line.len() - trimmed.len();
            if !trimmed.is_empty() && indent == 0 {
                break;
            }
        }
        lines.push(line);
    }
    Some(lines.join("\n"))
}

fn diff_lines(original: &str, mutant: &str) -> Vec<String> {
    let mut result = Vec::new();
    let orig_lines: Vec<&str> = original.lines().collect();
    let mut_lines: Vec<&str> = mutant.lines().collect();

    let max_len = orig_lines.len().max(mut_lines.len());
    for i in 0..max_len {
        let orig = orig_lines.get(i).unwrap_or(&"");
        let muta = mut_lines.get(i).unwrap_or(&"");
        if orig == muta {
            result.push(format!(" {orig}"));
        } else {
            if !orig.is_empty() {
                result.push(format!("-{orig}"));
            }
            if !muta.is_empty() {
                result.push(format!("+{muta}"));
            }
        }
    }
    result
}

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

    /// Build a MutantCacheDescriptor for GitHub Actions tests.
    /// function_source is synthesised from `orig` so that `offset` points at it.
    fn make_descriptor(
        mutant_name: &str,
        source_file: &str,
        operator: &str,
        orig: &str,
        repl: &str,
        fn_start_line: usize,
        offset: usize,
    ) -> MutantCacheDescriptor {
        let function_source = format!("def foo():\n    return {orig}\n");
        MutantCacheDescriptor {
            mutant_name: mutant_name.to_string(),
            function_source,
            operator: operator.to_string(),
            start: offset,
            end: offset + orig.len(),
            original: orig.to_string(),
            replacement: repl.to_string(),
            source_file: source_file.to_string(),
            fn_byte_offset: 0,
            fn_start_line,
        }
    }

    /// Build a MutantCacheDescriptor for Stryker report tests (full control over source).
    fn make_full_descriptor(
        mutant_name: &str,
        function_source: &str,
        operator: &str,
        start: usize,
        end: usize,
        original: &str,
        replacement: &str,
        fn_start_line: usize,
        source_file: &str,
    ) -> MutantCacheDescriptor {
        MutantCacheDescriptor {
            mutant_name: mutant_name.to_string(),
            function_source: function_source.to_string(),
            operator: operator.to_string(),
            start,
            end,
            original: original.to_string(),
            replacement: replacement.to_string(),
            fn_start_line,
            fn_byte_offset: 0,
            source_file: source_file.to_string(),
        }
    }

    fn make_result(name: &str, status: MutantStatus, duration: f64) -> MutantResult {
        MutantResult {
            mutant_name: name.to_string(),
            exit_code: match status {
                MutantStatus::Killed => 1,
                MutantStatus::Survived => 0,
                MutantStatus::NoTests => 33,
                MutantStatus::Timeout => -1,
                MutantStatus::TypeCheck => 37,
                MutantStatus::Error => 2,
            },
            duration,
            status,
        }
    }

    // -------------------------------------------------------------------------
    // GitHub Actions tests
    // -------------------------------------------------------------------------

    /// INV-1: No annotations emitted when GITHUB_ACTIONS is not set.
    /// (Tested indirectly — we verify the function is a no-op by checking it
    /// doesn't panic and produces no visible side effects in test env.)
    #[test]
    fn test_no_annotations_outside_github_actions() {
        // GITHUB_ACTIONS is not "true" in normal test env.
        // This test simply ensures the function doesn't panic or crash.
        let results = vec![make_result("mod.x_foo__irradiate_1", MutantStatus::Survived, 0.1)];
        let desc = make_descriptor("mod.x_foo__irradiate_1", "src/mod.py", "binop_swap", "+", "-", 1, 19);
        // Should be a complete no-op (doesn't write anything)
        emit_github_annotations(&results, &[desc], 5, 1);
    }

    /// INV-2: At most MAX_ANNOTATIONS (10) ::warning lines emitted.
    /// Verified via build_step_summary which covers the annotation logic path
    /// and by checking that the survived slice is capped.
    #[test]
    fn test_annotation_cap_at_ten() {
        // Build 15 survived results, verify only 10 would be annotated.
        let survived: Vec<MutantResult> = (1..=15)
            .map(|i| make_result(&format!("mod.x_foo__irradiate_{i}"), MutantStatus::Survived, 0.1))
            .collect();

        let to_annotate = survived.len().min(MAX_ANNOTATIONS);
        assert_eq!(to_annotate, 10, "INV-2: annotation cap is 10");
    }

    /// INV-3: Step summary is valid Markdown (has heading, table, details block).
    #[test]
    fn test_step_summary_valid_markdown() {
        let results = vec![
            make_result("mod.x_add__irradiate_1", MutantStatus::Survived, 0.1),
            make_result("mod.x_sub__irradiate_1", MutantStatus::Killed, 0.1),
        ];
        let descs = vec![
            make_descriptor("mod.x_add__irradiate_1", "src/mod.py", "binop_swap", "+", "-", 2, 19),
        ];

        let md = build_step_summary(&results, &descs, 1, 1).unwrap();

        assert!(md.contains("## Mutation Testing Results"), "INV-3: has heading");
        assert!(md.contains("| Killed | 1 |"), "INV-3: has killed row");
        assert!(md.contains("| Survived | 1 |"), "INV-3: has survived row");
        assert!(md.contains("<details>"), "INV-3: has details block for survived");
        assert!(md.contains("`mod.x_add__irradiate_1`"), "INV-3: lists survived mutant");
    }

    /// INV-3: Summary still written when no mutants survived.
    #[test]
    fn test_step_summary_no_survivors() {
        let results = vec![make_result("mod.x_foo__irradiate_1", MutantStatus::Killed, 0.1)];
        let md = build_step_summary(&results, &[], 1, 0).unwrap();

        assert!(md.contains("## Mutation Testing Results"), "heading present");
        assert!(md.contains("| Survived | 0 |"), "zero survivors shown");
        assert!(!md.contains("<details>"), "no details block when no survivors");
    }

    /// INV-4: Annotation file paths match source_file from descriptors.
    #[test]
    fn test_annotation_location_matches_source_file() {
        let desc = make_descriptor(
            "pkg.x_compute__irradiate_3",
            "src/pkg/compute.py",
            "binop_swap",
            "+",
            "-",
            10,
            19, // offset within function_source
        );
        assert_eq!(desc.source_file, "src/pkg/compute.py", "INV-4: source_file preserved");
    }

    /// Verify byte_offset_to_line computes correct 1-based line numbers.
    #[test]
    fn test_byte_offset_to_line_first_line() {
        let src = "def foo():\n    return x + 1\n";
        // offset 0 → same line as fn_start (line 5)
        assert_eq!(byte_offset_to_line(src, 5, 0), 5);
    }

    #[test]
    fn test_byte_offset_to_line_second_line() {
        let src = "def foo():\n    return x + 1\n";
        // offset 11 (just past the first newline) → fn_start + 1
        assert_eq!(byte_offset_to_line(src, 5, 11), 6);
    }

    #[test]
    fn test_byte_offset_to_line_zero_fn_start() {
        // fn_start_line=0 means unknown; should return 1
        let src = "def foo():\n    return 1\n";
        assert_eq!(byte_offset_to_line(src, 0, 5), 1);
    }

    /// Summary score computation: 1 killed + 1 survived = 50%.
    #[test]
    fn test_step_summary_score() {
        let results = vec![
            make_result("mod.x_a__irradiate_1", MutantStatus::Survived, 0.1),
            make_result("mod.x_b__irradiate_1", MutantStatus::Killed, 0.1),
        ];
        let md = build_step_summary(&results, &[], 1, 1).unwrap();
        assert!(md.contains("50.0%"), "50% score shown");
    }

    /// No-tests and timeout counts appear in the summary.
    #[test]
    fn test_step_summary_counts_no_tests_and_timeout() {
        let results = vec![
            make_result("mod.x_a__irradiate_1", MutantStatus::NoTests, 0.1),
            make_result("mod.x_b__irradiate_1", MutantStatus::Timeout, 0.1),
            make_result("mod.x_c__irradiate_1", MutantStatus::Killed, 0.1),
        ];
        let md = build_step_summary(&results, &[], 1, 0).unwrap();
        assert!(md.contains("| No coverage | 1 |"));
        assert!(md.contains("| Timeout | 1 |"));
    }

    // -------------------------------------------------------------------------
    // Stryker JSON report tests
    // -------------------------------------------------------------------------

    /// INV-1: Report always contains schemaVersion "2".
    #[test]
    fn test_schema_version_present() {
        let results = vec![make_result("mod.x_f__irradiate_1", MutantStatus::Killed, 0.1)];
        let report = build_stryker_report(&results, &[], None, Path::new("/proj"), &[PathBuf::from("src")]);
        assert_eq!(report["schemaVersion"], "2");
    }

    /// INV-2: All 5 status mappings are correct.
    #[test]
    fn test_status_mapping() {
        assert_eq!(stryker_status(MutantStatus::Killed), "Killed");
        assert_eq!(stryker_status(MutantStatus::Survived), "Survived");
        assert_eq!(stryker_status(MutantStatus::NoTests), "NoCoverage");
        assert_eq!(stryker_status(MutantStatus::Timeout), "Timeout");
        assert_eq!(stryker_status(MutantStatus::Error), "RuntimeError");
        assert_eq!(stryker_status(MutantStatus::TypeCheck), "Killed");
    }

    /// INV-3: Location line/column values are 1-indexed.
    #[test]
    fn test_location_is_1indexed() {
        // "def add(a, b):\n    return a + b\n"
        // The '+' at 'a + b' on line 2 column 14 (1-indexed).
        let source = "def add(a, b):\n    return a + b\n";
        // byte offset of '+': "def add(a, b):\n    return a " = 15+11 = 26 bytes
        let (line, col) = crate::mutation::byte_offset_to_location(source, 26);
        assert_eq!(line, 2, "line should be 1-indexed (line 2 = second line)");
        assert_eq!(col, 12, "column should be 1-indexed");
    }

    /// INV-4: Every mutant in results appears in the report.
    #[test]
    fn test_every_result_in_report() {
        let results = vec![
            make_result("mod.x_f__irradiate_1", MutantStatus::Killed, 0.1),
            make_result("mod.x_g__irradiate_1", MutantStatus::Survived, 0.2),
        ];
        let report = build_stryker_report(&results, &[], None, Path::new("/proj"), &[PathBuf::from("src")]);

        // Collect all mutant ids from the report
        let mut found_ids: Vec<String> = Vec::new();
        if let Some(files) = report["files"].as_object() {
            for file_val in files.values() {
                if let Some(mutants) = file_val["mutants"].as_array() {
                    for m in mutants {
                        if let Some(id) = m["id"].as_str() {
                            found_ids.push(id.to_string());
                        }
                    }
                }
            }
        }
        found_ids.sort();
        assert!(
            found_ids.contains(&"mod.x_f__irradiate_1".to_string()),
            "killed mutant must be in report"
        );
        assert!(
            found_ids.contains(&"mod.x_g__irradiate_1".to_string()),
            "survived mutant must be in report"
        );
    }

    /// Report with descriptors: correct status, operator, and description.
    #[test]
    fn test_report_with_descriptors() {
        let tmp = tempfile::tempdir().unwrap();
        let src_file = tmp.path().join("core.py");
        std::fs::write(&src_file, "def add(a, b):\n    return a + b\n").unwrap();

        let results = vec![make_result("core.x_add__irradiate_1", MutantStatus::Killed, 0.05)];
        let rel_src = src_file
            .strip_prefix(tmp.path())
            .unwrap()
            .to_string_lossy()
            .replace('\\', "/");
        let descriptors = vec![make_full_descriptor(
            "core.x_add__irradiate_1",
            "def add(a, b):\n    return a + b\n",
            "binop_swap",
            26, // offset of '+'
            27,
            "+",
            "-",
            1,
            &rel_src,
        )];

        let report =
            build_stryker_report(&results, &descriptors, None, tmp.path(), &[PathBuf::from("src")]);

        // schemaVersion
        assert_eq!(report["schemaVersion"], "2");

        // Find the mutant in files
        let files = report["files"].as_object().unwrap();
        let file_entry = files.values().next().unwrap();
        let mutants = file_entry["mutants"].as_array().unwrap();
        assert_eq!(mutants.len(), 1);
        let m = &mutants[0];
        assert_eq!(m["id"], "core.x_add__irradiate_1");
        assert_eq!(m["status"], "Killed");
        assert_eq!(m["mutatorName"], "binop_swap");
        assert_eq!(m["replacement"], "-");
        assert!(m["description"].as_str().unwrap().contains('+'));

        // INV-3: location is 1-indexed
        let start_line = m["location"]["start"]["line"].as_u64().unwrap();
        assert!(start_line >= 1, "line must be >= 1 (1-indexed)");
        let start_col = m["location"]["start"]["column"].as_u64().unwrap();
        assert!(start_col >= 1, "column must be >= 1 (1-indexed)");
    }

    /// Report includes framework name and version.
    #[test]
    fn test_framework_field() {
        let report = build_stryker_report(&[], &[], None, Path::new("/proj"), &[PathBuf::from("src")]);
        assert_eq!(report["framework"]["name"], "irradiate");
        assert!(
            report["framework"]["version"].as_str().is_some(),
            "version field must be present"
        );
    }

    /// Mutant in results but not in descriptors → included with minimal info (failure mode).
    #[test]
    fn test_result_without_descriptor_included() {
        let results = vec![make_result("unknown.x_h__irradiate_1", MutantStatus::Survived, 0.0)];
        let report =
            build_stryker_report(&results, &[], None, Path::new("/proj"), &[PathBuf::from("src")]);
        let files = report["files"].as_object().unwrap();
        let all_mutants: Vec<_> = files.values().flat_map(|f| f["mutants"].as_array().unwrap().iter()).collect();
        assert_eq!(all_mutants.len(), 1);
        assert_eq!(all_mutants[0]["id"], "unknown.x_h__irradiate_1");
        assert_eq!(all_mutants[0]["status"], "Survived");
    }

    // -------------------------------------------------------------------------
    // HTML report tests
    // -------------------------------------------------------------------------

    /// INV-1: Output file contains `<!DOCTYPE html>`.
    #[test]
    fn test_html_report_is_valid_html() {
        let report = build_stryker_report(&[], &[], None, Path::new("/proj"), &[PathBuf::from("src")]);
        let tmp = tempfile::tempdir().unwrap();
        let out = tmp.path().join("report.html");
        write_html_report(&report, &out).unwrap();
        let content = std::fs::read_to_string(&out).unwrap();
        assert!(content.contains("<!DOCTYPE html>"), "INV-1: must contain DOCTYPE");
    }

    /// INV-2: Output contains the mutation-testing-elements script tag.
    #[test]
    fn test_html_report_contains_script_tag() {
        let report = build_stryker_report(&[], &[], None, Path::new("/proj"), &[PathBuf::from("src")]);
        let tmp = tempfile::tempdir().unwrap();
        let out = tmp.path().join("report.html");
        write_html_report(&report, &out).unwrap();
        let content = std::fs::read_to_string(&out).unwrap();
        assert!(
            content.contains("mutation-testing-elements"),
            "INV-2: must reference mutation-testing-elements"
        );
        assert!(
            content.contains("unpkg.com"),
            "INV-2: script loaded from unpkg CDN"
        );
    }

    /// INV-3: Output contains the actual JSON data (not the placeholder string).
    #[test]
    fn test_html_report_contains_json_not_placeholder() {
        let results = vec![make_result("mod.x_f__irradiate_1", MutantStatus::Killed, 0.1)];
        let report = build_stryker_report(&results, &[], None, Path::new("/proj"), &[PathBuf::from("src")]);
        let tmp = tempfile::tempdir().unwrap();
        let out = tmp.path().join("report.html");
        write_html_report(&report, &out).unwrap();
        let content = std::fs::read_to_string(&out).unwrap();
        // Placeholder must be gone
        assert!(
            !content.contains("REPORT_JSON_PLACEHOLDER"),
            "INV-3: placeholder must be replaced"
        );
        // JSON content must be present
        assert!(
            content.contains("schemaVersion"),
            "INV-3: JSON data must be inlined"
        );
        assert!(
            content.contains("mod.x_f__irradiate_1"),
            "INV-3: mutant id must appear in output"
        );
    }

    /// INV-4: Output is self-contained (single file, no local file references).
    #[test]
    fn test_html_report_is_self_contained() {
        let report = build_stryker_report(&[], &[], None, Path::new("/proj"), &[PathBuf::from("src")]);
        let tmp = tempfile::tempdir().unwrap();
        let out = tmp.path().join("report.html");
        write_html_report(&report, &out).unwrap();
        let content = std::fs::read_to_string(&out).unwrap();
        // Must be a single file — no local <link rel="stylesheet">, no local <script src="./...">
        assert!(
            !content.contains("rel=\"stylesheet\""),
            "INV-4: no local stylesheet references"
        );
        // Confirm the web component tag is present (self-contained rendering)
        assert!(
            content.contains("<mutation-test-report-app"),
            "INV-4: web component tag present"
        );
    }

    /// coveredBy is populated from stats when provided.
    #[test]
    fn test_covered_by_from_stats() {
        use std::collections::HashMap;
        let mut tests_by_function = HashMap::new();
        tests_by_function.insert(
            "mod.x_foo".to_string(),
            vec!["tests/test_mod.py::test_it".to_string()],
        );
        let stats = TestStats {
            tests_by_function,
            ..Default::default()
        };

        let results = vec![make_result("mod.x_foo__irradiate_1", MutantStatus::Killed, 0.1)];
        let report =
            build_stryker_report(&results, &[], Some(&stats), Path::new("/p"), &[PathBuf::from("src")]);

        let files = report["files"].as_object().unwrap();
        let mutants: Vec<_> = files
            .values()
            .flat_map(|f| f["mutants"].as_array().unwrap().iter())
            .collect();
        let covered_by = mutants[0]["coveredBy"].as_array().unwrap();
        assert_eq!(covered_by.len(), 1);
        assert_eq!(covered_by[0], "tests/test_mod.py::test_it");
    }

    // -------------------------------------------------------------------------
    // Tests moved from pipeline.rs
    // -------------------------------------------------------------------------

    // --- write_meta_files + load_all_meta round-trip ---

    #[test]
    fn test_meta_round_trip() {
        let tmp = tempfile::tempdir().unwrap();
        let mutants_dir = tmp.path();

        // Set up: module "mymod" with two mutant names
        let mut all_names: HashMap<String, Vec<String>> = HashMap::new();
        all_names.insert(
            "mymod".to_string(),
            vec![
                "mymod.x_foo__irradiate_1".to_string(),
                "mymod.x_foo__irradiate_2".to_string(),
            ],
        );

        let results = vec![
            MutantResult {
                mutant_name: "mymod.x_foo__irradiate_1".to_string(),
                exit_code: 1,
                duration: 0.5,
                status: MutantStatus::Killed,
            },
            MutantResult {
                mutant_name: "mymod.x_foo__irradiate_2".to_string(),
                exit_code: 0,
                duration: 0.3,
                status: MutantStatus::Survived,
            },
        ];

        write_meta_files(mutants_dir, &all_names, &results).unwrap();

        let loaded = load_all_meta(mutants_dir).unwrap();
        // load_all_meta returns (name, exit_code) sorted by name
        assert_eq!(loaded.len(), 2);
        let map: HashMap<&str, i32> = loaded.iter().map(|(n, c)| (n.as_str(), *c)).collect();
        assert_eq!(map["mymod.x_foo__irradiate_1"], 1);
        assert_eq!(map["mymod.x_foo__irradiate_2"], 0);
    }

    #[test]
    fn test_meta_round_trip_package() {
        // Test the __init__ path: create the stub so write_meta_files uses the init variant
        let tmp = tempfile::tempdir().unwrap();
        let mutants_dir = tmp.path();

        let mut all_names: HashMap<String, Vec<String>> = HashMap::new();
        all_names.insert(
            "mypkg".to_string(),
            vec!["mypkg.x_bar__irradiate_1".to_string()],
        );

        // Create the __init__.py.meta stub so write_meta_files takes the init branch
        let init_meta_dir = mutants_dir.join("mypkg");
        std::fs::create_dir_all(&init_meta_dir).unwrap();
        std::fs::write(init_meta_dir.join("__init__.py.meta"), "{}").unwrap();

        let results = vec![MutantResult {
            mutant_name: "mypkg.x_bar__irradiate_1".to_string(),
            exit_code: 1,
            duration: 0.1,
            status: MutantStatus::Killed,
        }];

        write_meta_files(mutants_dir, &all_names, &results).unwrap();

        let loaded = load_all_meta(mutants_dir).unwrap();
        assert_eq!(loaded.len(), 1);
        assert_eq!(loaded[0].0, "mypkg.x_bar__irradiate_1");
        assert_eq!(loaded[0].1, 1);
    }

    // --- print_summary tests ---

    use crate::cache::CacheCounts;

    fn make_results_for_summary(killed: usize, survived: usize) -> Vec<MutantResult> {
        let mut v = Vec::new();
        for i in 0..killed {
            v.push(MutantResult {
                mutant_name: format!("mod.x_k_{i}"),
                exit_code: 1,
                duration: 0.1,
                status: MutantStatus::Killed,
            });
        }
        for i in 0..survived {
            v.push(MutantResult {
                mutant_name: format!("mod.x_s_{i}"),
                exit_code: 0,
                duration: 0.1,
                status: MutantStatus::Survived,
            });
        }
        v
    }

    fn zero_cache() -> CacheCounts {
        CacheCounts { hits: 0, misses: 0 }
    }

    /// INV-5: Score is always printed — verify the function doesn't panic and returns counts.
    #[test]
    fn test_print_summary_returns_killed_survived() {
        let results = make_results_for_summary(3, 1);
        let (killed, survived) = print_summary(&results, 1.0, zero_cache(), &HashMap::new(), None);
        assert_eq!(killed, 3);
        assert_eq!(survived, 1);
    }

    /// INV-5: With zero tested mutants, score is N/A and no panic.
    #[test]
    fn test_print_summary_no_tested_mutants() {
        let results: Vec<MutantResult> = vec![];
        let (killed, survived) = print_summary(&results, 0.0, zero_cache(), &HashMap::new(), None);
        assert_eq!(killed, 0);
        assert_eq!(survived, 0);
    }

    // --- build_json_results ---

    fn raw(name: &str, exit_code: i32) -> (String, i32) {
        (name.to_string(), exit_code)
    }

    /// INV-1: JSON output is valid JSON; INV-5: total == sum of all status counts.
    #[test]
    fn test_json_results_valid_json_and_inv5_total() {
        let results = vec![
            raw("mod.x_a__irradiate_1", 1),  // killed
            raw("mod.x_b__irradiate_1", 0),  // survived
            raw("mod.x_c__irradiate_1", 33), // no_tests
            raw("mod.x_d__irradiate_1", 2),  // error
        ];
        let json_out = build_json_results(&results, true);
        // INV-1: serializes to valid JSON
        let serialized = serde_json::to_string(&json_out).unwrap();
        let parsed: serde_json::Value = serde_json::from_str(&serialized).unwrap();

        assert_eq!(parsed["total"], 4);
        assert_eq!(parsed["killed"], 1);
        assert_eq!(parsed["survived"], 1);
        assert_eq!(parsed["no_tests"], 1);
        assert_eq!(parsed["errors"], 1);

        // INV-5: total == killed + survived + no_tests + timeout + errors
        let sum = parsed["killed"].as_u64().unwrap()
            + parsed["survived"].as_u64().unwrap()
            + parsed["no_tests"].as_u64().unwrap()
            + parsed["timeout"].as_u64().unwrap()
            + parsed["errors"].as_u64().unwrap();
        assert_eq!(sum, parsed["total"].as_u64().unwrap());
    }

    /// INV-2: mutation_score_pct = killed / (killed + survived) * 100, rounded to 1 decimal.
    #[test]
    fn test_json_results_mutation_score_pct() {
        // 3 killed out of 4 (3 killed + 1 survived) = 75.0%
        let results = vec![
            raw("a", 1), // killed
            raw("b", 1), // killed
            raw("c", 1), // killed
            raw("d", 0), // survived
        ];
        let json_out = build_json_results(&results, true);
        assert_eq!(json_out.mutation_score_pct, 75.0);
    }

    /// INV-2: score rounds to 1 decimal correctly.
    #[test]
    fn test_json_results_mutation_score_rounding() {
        // 2 killed / 3 total relevant = 66.666...% → rounds to 66.7
        let results = vec![
            raw("a", 1), // killed
            raw("b", 1), // killed
            raw("c", 0), // survived
        ];
        let json_out = build_json_results(&results, true);
        assert_eq!(json_out.mutation_score_pct, 66.7);
    }

    /// Failure mode: no results → JSON with total: 0 and empty mutants array (not an error).
    #[test]
    fn test_json_results_empty_input() {
        let json_out = build_json_results(&[], true);
        assert_eq!(json_out.total, 0);
        assert_eq!(json_out.killed, 0);
        assert_eq!(json_out.survived, 0);
        assert_eq!(json_out.mutants.len(), 0);
        assert_eq!(json_out.mutation_score_pct, 0.0);
        // Must still serialize without panic
        let serialized = serde_json::to_string(&json_out).unwrap();
        let parsed: serde_json::Value = serde_json::from_str(&serialized).unwrap();
        assert_eq!(parsed["total"], 0);
    }

    /// When show_all=false, mutants array contains only survived entries.
    #[test]
    fn test_json_results_show_all_false_only_survived() {
        let results = vec![
            raw("a", 1), // killed
            raw("b", 0), // survived
            raw("c", 0), // survived
        ];
        let json_out = build_json_results(&results, false);
        assert_eq!(json_out.mutants.len(), 2);
        for m in &json_out.mutants {
            assert_eq!(m.status, MutantStatus::Survived);
        }
    }

    /// When show_all=true, mutants array includes all statuses.
    #[test]
    fn test_json_results_show_all_true_includes_all() {
        let results = vec![
            raw("a", 1),  // killed
            raw("b", 0),  // survived
            raw("c", 33), // no_tests
        ];
        let json_out = build_json_results(&results, true);
        assert_eq!(json_out.mutants.len(), 3);
    }

    /// INV-4: All status strings in JSON are lowercase snake_case.
    #[test]
    fn test_json_results_status_serialization_snake_case() {
        let results = vec![
            raw("a", 0),  // survived
            raw("b", 1),  // killed
            raw("c", 33), // no_tests
        ];
        let json_out = build_json_results(&results, true);
        let serialized = serde_json::to_string(&json_out).unwrap();
        assert!(serialized.contains("\"survived\""));
        assert!(serialized.contains("\"killed\""));
        assert!(serialized.contains("\"no_tests\""));
    }

    // --- extract_function tests ---

    #[test]
    fn test_extract_function_single() {
        let src = "def foo():\n    return 1\n";
        let result = extract_function(src, "def foo():");
        assert_eq!(result, Some("def foo():\n    return 1".to_string()));
    }

    #[test]
    fn test_extract_function_stops_at_top_level() {
        let src = "def foo():\n    return 1\n\ndef bar():\n    return 2\n";
        let result = extract_function(src, "def foo():");
        // Should include foo's body but stop before bar
        let s = result.unwrap();
        assert!(s.contains("def foo():"));
        assert!(s.contains("return 1"));
        assert!(!s.contains("def bar():"));
    }

    #[test]
    fn test_extract_function_first_match_only() {
        // When two functions have the same marker prefix, only the first is returned
        let src = "def foo():\n    return 1\n\ndef foo_extra():\n    return 2\n";
        let result = extract_function(src, "def foo():");
        let s = result.unwrap();
        assert!(!s.contains("foo_extra"));
    }

    #[test]
    fn test_extract_function_no_match() {
        let src = "def bar():\n    return 42\n";
        assert_eq!(extract_function(src, "def foo():"), None);
    }

    #[test]
    fn test_extract_function_nested_blocks() {
        let src = "def foo():\n    if True:\n        for i in range(3):\n            pass\n    return 0\n\nx = 1\n";
        let result = extract_function(src, "def foo():");
        let s = result.unwrap();
        assert!(s.contains("if True:"));
        assert!(s.contains("for i in range(3):"));
        assert!(s.contains("return 0"));
        assert!(!s.contains("x = 1"));
    }

    #[test]
    fn test_extract_function_empty_source() {
        assert_eq!(extract_function("", "def foo():"), None);
    }

    // --- diff_lines tests ---

    #[test]
    fn test_diff_lines_identical() {
        let lines = diff_lines("a\nb\nc", "a\nb\nc");
        assert_eq!(lines, vec![" a", " b", " c"]);
    }

    #[test]
    fn test_diff_lines_single_change() {
        let lines = diff_lines("a\nb\nc", "a\nX\nc");
        assert!(lines.contains(&"-b".to_string()));
        assert!(lines.contains(&"+X".to_string()));
        assert!(lines.contains(&" a".to_string()));
        assert!(lines.contains(&" c".to_string()));
    }

    #[test]
    fn test_diff_lines_added_lines() {
        // mutant has more lines than original
        let lines = diff_lines("a", "a\nb");
        assert!(lines.contains(&" a".to_string()));
        assert!(lines.contains(&"+b".to_string()));
    }

    #[test]
    fn test_diff_lines_removed_lines() {
        // original has more lines than mutant
        let lines = diff_lines("a\nb", "a");
        assert!(lines.contains(&" a".to_string()));
        assert!(lines.contains(&"-b".to_string()));
    }

    #[test]
    fn test_diff_lines_both_empty() {
        assert_eq!(diff_lines("", ""), Vec::<String>::new());
    }

    #[test]
    fn test_diff_lines_one_empty() {
        // original empty, mutant has a line
        let lines = diff_lines("", "hello");
        assert!(lines.contains(&"+hello".to_string()));
    }
}
