use crate::code_audit::CodeAuditResult;
use crate::component::Component;
use crate::engine::run_dir::{self, RunDir};
use crate::engine::undo::UndoSnapshot;
use crate::extension;
use crate::extension::test::compute_changed_test_files;
use crate::git;
use crate::refactor::auto as fixer;
use crate::refactor::auto::{self, FixApplied, FixResultsSummary};
use crate::Error;
use serde::Serialize;
use std::collections::{BTreeSet, HashSet};
use std::path::{Path, PathBuf};

use super::verify::AuditConvergenceScoring;

pub const KNOWN_REFACTOR_SOURCES: &[&str] = &["audit", "lint", "test"];

/// Name of the env var pointing to previous command output files.
///
/// When set, `--from audit` reads the cached audit result instead of
/// re-running the audit. The action sets this during `run-homeboy-commands.sh`
/// and it persists across steps via `GITHUB_ENV`.
const OUTPUT_DIR_ENV: &str = "HOMEBOY_OUTPUT_DIR";

#[derive(Debug, Clone)]
pub struct RefactorSourceRequest {
    pub component: Component,
    pub root: PathBuf,
    pub sources: Vec<String>,
    pub changed_since: Option<String>,
    pub only: Vec<crate::code_audit::AuditFinding>,
    pub exclude: Vec<crate::code_audit::AuditFinding>,
    pub settings: Vec<(String, String)>,
    pub lint: LintSourceOptions,
    pub test: TestSourceOptions,
    pub write: bool,
    /// Skip the clean working tree check (for CI or when you know what you're doing)
    pub force: bool,
}

pub fn lint_refactor_request(
    component: Component,
    root: PathBuf,
    settings: Vec<(String, String)>,
    options: LintSourceOptions,
    write: bool,
) -> RefactorSourceRequest {
    RefactorSourceRequest {
        component,
        root,
        sources: vec!["lint".to_string()],
        changed_since: None,
        only: Vec::new(),
        exclude: Vec::new(),
        settings,
        lint: options,
        test: TestSourceOptions::default(),
        write,
        force: false,
    }
}

pub fn build_test_refactor_request(
    component: Component,
    root: PathBuf,
    settings: Vec<(String, String)>,
    options: TestSourceOptions,
    write: bool,
) -> RefactorSourceRequest {
    RefactorSourceRequest {
        component,
        root,
        sources: vec!["test".to_string()],
        changed_since: None,
        only: Vec::new(),
        exclude: Vec::new(),
        settings,
        lint: LintSourceOptions::default(),
        test: options,
        write,
        force: false,
    }
}

pub fn run_lint_refactor(
    component: Component,
    root: PathBuf,
    settings: Vec<(String, String)>,
    options: LintSourceOptions,
    write: bool,
) -> crate::Result<RefactorSourceRun> {
    collect_refactor_sources(lint_refactor_request(
        component, root, settings, options, write,
    ))
}

pub fn run_test_refactor(
    component: Component,
    root: PathBuf,
    settings: Vec<(String, String)>,
    options: TestSourceOptions,
    write: bool,
) -> crate::Result<RefactorSourceRun> {
    collect_refactor_sources(build_test_refactor_request(
        component, root, settings, options, write,
    ))
}

#[derive(Debug, Clone, Default)]
pub struct LintSourceOptions {
    pub selected_files: Option<Vec<String>>,
    pub file: Option<String>,
    pub glob: Option<String>,
    pub errors_only: bool,
    pub sniffs: Option<String>,
    pub exclude_sniffs: Option<String>,
    pub category: Option<String>,
}

#[derive(Debug, Clone, Default)]
pub struct TestSourceOptions {
    pub selected_files: Option<Vec<String>>,
    pub skip_lint: bool,
    pub script_args: Vec<String>,
}

#[derive(Debug, Clone, Serialize)]
pub struct RefactorSourceRun {
    pub component_id: String,
    pub source_path: String,
    pub sources: Vec<String>,
    pub dry_run: bool,
    pub applied: bool,
    pub merge_strategy: String,
    pub collected_edits: Vec<CollectedEdit>,
    pub stages: Vec<SourceStageSummary>,
    pub source_totals: SourceTotals,
    pub overlaps: Vec<SourceOverlap>,
    pub files_modified: usize,
    pub changed_files: Vec<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub fix_summary: Option<FixResultsSummary>,
    pub warnings: Vec<String>,
    pub hints: Vec<String>,
    /// When set, autofix was blocked by a safety guard. The pipeline
    /// short-circuited without modifying any files.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub guard_block: Option<crate::refactor::auto::guard::GuardBlock>,
}

#[derive(Debug, Clone, Serialize)]
pub struct SourceStageSummary {
    pub stage: String,
    pub collected: bool,
    pub applied: bool,
    pub edit_count: usize,
    pub files_modified: usize,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub detected_findings: Option<usize>,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub changed_files: Vec<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub fix_summary: Option<FixResultsSummary>,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub warnings: Vec<String>,
}

#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct SourceOverlap {
    pub file: String,
    pub earlier_stage: String,
    pub later_stage: String,
    pub resolution: String,
}

#[derive(Debug, Clone, Serialize)]
pub struct SourceTotals {
    pub stages_with_edits: usize,
    pub total_edits: usize,
    pub total_files_selected: usize,
}

#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct CollectedEdit {
    pub source: String,
    pub file: String,
    pub rule_id: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub action: Option<String>,
}

#[derive(Default)]
struct FixAccumulator {
    fixes: Vec<FixApplied>,
}

impl FixAccumulator {
    fn extend(&mut self, items: Vec<FixApplied>) {
        self.fixes.extend(items);
    }

    fn summary(&self) -> Option<FixResultsSummary> {
        if self.fixes.is_empty() {
            None
        } else {
            Some(auto::summarize_fix_results(&self.fixes))
        }
    }
}

struct PlannedStage {
    source: String,
    summary: SourceStageSummary,
    fix_results: Vec<FixApplied>,
}

pub fn collect_refactor_sources(
    request: RefactorSourceRequest,
) -> crate::Result<RefactorSourceRun> {
    let sources = normalize_sources(&request.sources)?;
    let root_str = request.root.to_string_lossy().to_string();
    let original_changes = git::get_uncommitted_changes(&root_str).ok();

    // Refuse to write to a dirty working tree unless --force is set.
    // Refactoring operates directly on the working tree, so mixing auto-generated
    // fixes with uncommitted manual changes makes rollback difficult.
    // Dry runs (no --write) are always safe — they don't modify files.
    if request.write && !request.force {
        if let Some(ref changes) = original_changes {
            if changes.has_changes {
                return Err(crate::Error::validation_invalid_argument(
                    "write",
                    "Working tree has uncommitted changes",
                    None,
                    Some(vec![
                        "Commit or stash your changes first".to_string(),
                        "Or use --force to proceed anyway".to_string(),
                    ]),
                ));
            }
        }
    }

    // Autofix safety guards — check whether writing is safe in CI context.
    // Guards detect reverted/force-pushed bot commits and PR labels that
    // permanently disable autofix. Outside CI, guards are no-ops.
    if request.write {
        let guard_config = crate::refactor::auto::guard::GuardConfig::from_env();
        match crate::refactor::auto::guard::check_guards(&root_str, &guard_config) {
            crate::refactor::auto::guard::GuardResult::Proceed => {}
            crate::refactor::auto::guard::GuardResult::Blocked(block) => {
                eprintln!(
                    "[refactor] autofix blocked: {} (status: {})",
                    block.message(),
                    block.status()
                );
                return Ok(RefactorSourceRun {
                    component_id: String::new(),
                    source_path: root_str.clone(),
                    sources: normalize_sources(&request.sources)?,
                    dry_run: false,
                    applied: false,
                    merge_strategy: String::new(),
                    collected_edits: Vec::new(),
                    stages: Vec::new(),
                    source_totals: SourceTotals {
                        stages_with_edits: 0,
                        total_edits: 0,
                        total_files_selected: 0,
                    },
                    overlaps: Vec::new(),
                    files_modified: 0,
                    changed_files: Vec::new(),
                    fix_summary: None,
                    warnings: Vec::new(),
                    hints: Vec::new(),
                    guard_block: Some(block),
                });
            }
        }
    }

    let scoped_changed_files = if let Some(git_ref) = request.changed_since.as_deref() {
        Some(git::get_files_changed_since(&root_str, git_ref)?)
    } else {
        None
    };
    let scoped_test_files = if let Some(git_ref) = request.changed_since.as_deref() {
        Some(compute_changed_test_files(&request.component, git_ref)?)
    } else {
        None
    };

    let mut planned_stages = Vec::new();
    let merge_order = sources.join(" → ");
    let mut warnings = vec![format!("Deterministic merge order: {}", merge_order)];
    let mut accumulator = FixAccumulator::default();

    // Save undo snapshot before any modifications so we can roll back.
    if request.write {
        let mut snapshot_files: HashSet<String> = HashSet::new();
        if let Some(changes) = &original_changes {
            snapshot_files.extend(changes.staged.iter().cloned());
            snapshot_files.extend(changes.unstaged.iter().cloned());
            snapshot_files.extend(changes.untracked.iter().cloned());
        }
        if !snapshot_files.is_empty() {
            let mut snap = UndoSnapshot::new(&request.root, "refactor sources (pre)");
            for file in &snapshot_files {
                snap.capture_file(file);
            }
            if let Err(e) = snap.save() {
                crate::log_status!(
                    "undo",
                    "Warning: failed to save pre-refactor undo snapshot: {}",
                    e
                );
            }
        }
    }

    let run_dir = RunDir::create()?;

    for source in &sources {
        let stage = match source.as_str() {
            "audit" => plan_audit_stage(
                &request.component.id,
                &request.root,
                scoped_changed_files.as_deref(),
                &request.only,
                &request.exclude,
                request.write,
            )?,
            "lint" => run_lint_stage(
                &request.component,
                &request.root,
                &request.settings,
                &request.lint,
                scoped_changed_files.as_deref(),
                request.write,
                &run_dir,
            )?,
            "test" => run_test_stage(
                &request.component,
                &request.root,
                &request.settings,
                &request.test,
                scoped_test_files.as_deref(),
                request.write,
                &run_dir,
            )?,
            _ => unreachable!("sources are normalized before orchestration"),
        };

        // Format generated/modified files so subsequent stages (especially lint)
        // see properly formatted code.
        if stage.summary.files_modified > 0 {
            format_changed_files(&request.root, &stage.summary.changed_files, &mut warnings);
        }

        accumulator.extend(stage.fix_results.clone());
        planned_stages.push(stage);
    }

    let collected_edits = collect_collected_edits(&planned_stages);
    let mut stage_summaries: Vec<SourceStageSummary> = planned_stages
        .into_iter()
        .map(|stage| stage.summary)
        .collect();
    let changed_files = collect_stage_changed_files(&stage_summaries);
    let overlaps = analyze_stage_overlaps(&stage_summaries);
    if !overlaps.is_empty() {
        warnings.push(format!(
            "{} staged file overlap(s) resolved by precedence order {}",
            overlaps.len(),
            merge_order
        ));
    }

    let source_totals = summarize_source_totals(&stage_summaries, changed_files.len());
    let files_modified = changed_files.len();
    let applied = request.write && files_modified > 0;

    if applied {
        let abs_changed: Vec<PathBuf> =
            changed_files.iter().map(|f| request.root.join(f)).collect();
        match crate::engine::format_write::format_after_write(&request.root, &abs_changed) {
            Ok(fmt) => {
                if let Some(cmd) = &fmt.command {
                    if !fmt.success {
                        warnings.push(format!("Formatter ({}) exited non-zero", cmd));
                    }
                }
            }
            Err(e) => {
                crate::log_status!("format", "Warning: post-write format failed: {}", e);
            }
        }
    }

    for stage in &mut stage_summaries {
        stage.applied = request.write && stage.files_modified > 0;
    }

    if files_modified == 0 {
        warnings.push("No automated fixes accumulated across audit/lint/test".to_string());
    }

    let hints = if applied {
        sources
            .iter()
            .map(|source| format!("Re-run checks: homeboy {} {}", source, request.component.id))
            .collect()
    } else if files_modified > 0 {
        vec!["Dry run. Re-run with --write to apply fixes to the working tree.".to_string()]
    } else {
        Vec::new()
    };

    Ok(RefactorSourceRun {
        component_id: request.component.id,
        source_path: root_str,
        sources,
        dry_run: !request.write,
        applied,
        merge_strategy: "sequential_source_merge".to_string(),
        collected_edits,
        stages: stage_summaries,
        source_totals,
        overlaps,
        files_modified,
        changed_files,
        fix_summary: accumulator.summary(),
        warnings,
        hints,
        guard_block: None,
    })
}

pub fn normalize_sources(sources: &[String]) -> crate::Result<Vec<String>> {
    let lowered: Vec<String> = sources.iter().map(|source| source.to_lowercase()).collect();

    if lowered.iter().any(|source| source == "all") {
        return Ok(KNOWN_REFACTOR_SOURCES
            .iter()
            .map(|source| source.to_string())
            .collect());
    }

    let unknown: Vec<String> = lowered
        .iter()
        .filter(|source| !KNOWN_REFACTOR_SOURCES.contains(&source.as_str()))
        .cloned()
        .collect();

    if !unknown.is_empty() {
        return Err(Error::validation_invalid_argument(
            "from",
            format!("Unknown refactor source(s): {}", unknown.join(", ")),
            None,
            Some(vec![format!(
                "Known sources: {}",
                KNOWN_REFACTOR_SOURCES.join(", ")
            )]),
        ));
    }

    let mut ordered = Vec::new();
    for known in KNOWN_REFACTOR_SOURCES {
        if lowered.iter().any(|source| source == known) {
            ordered.push((*known).to_string());
        }
    }

    if ordered.is_empty() {
        return Err(Error::validation_missing_argument(vec!["from".to_string()]));
    }

    Ok(ordered)
}

/// Format modified files between refactor stages.
///
/// This ensures generated code (test files, refactored sources) is properly
/// formatted before subsequent stages run. Without this, the lint stage's
/// `cargo fmt --check` fails on unformatted auto-generated code — blocking
/// the pipeline on problems it didn't create.
///
/// Uses the same `format_after_write` as the post-write step. Non-fatal:
/// if formatting fails, it logs a warning and continues.
fn format_changed_files(root: &Path, changed_files: &[String], warnings: &mut Vec<String>) {
    if changed_files.is_empty() {
        return;
    }

    let abs_changed: Vec<PathBuf> = changed_files.iter().map(|f| root.join(f)).collect();

    match crate::engine::format_write::format_after_write(root, &abs_changed) {
        Ok(fmt) => {
            if let Some(cmd) = &fmt.command {
                if fmt.success {
                    crate::log_status!(
                        "format",
                        "Formatted {} file(s) via {} (inter-stage)",
                        abs_changed.len(),
                        cmd
                    );
                } else {
                    warnings.push(format!(
                        "Inter-stage formatter ({}) exited non-zero (continuing)",
                        cmd
                    ));
                }
            }
        }
        Err(e) => {
            crate::log_status!(
                "format",
                "Warning: inter-stage format failed (continuing): {}",
                e
            );
        }
    }
}

fn collect_collected_edits(stages: &[PlannedStage]) -> Vec<CollectedEdit> {
    let mut edits = Vec::new();

    for stage in stages {
        for fix in &stage.fix_results {
            edits.push(CollectedEdit {
                source: stage.source.clone(),
                file: fix.file.clone(),
                rule_id: fix.rule.clone(),
                action: fix.action.clone(),
            });
        }
    }

    edits.sort_by(|a, b| {
        a.source
            .cmp(&b.source)
            .then(a.file.cmp(&b.file))
            .then(a.rule_id.cmp(&b.rule_id))
    });

    edits
}

fn collect_stage_changed_files(stages: &[SourceStageSummary]) -> Vec<String> {
    let mut final_changed_files = BTreeSet::new();
    for stage in stages {
        for file in &stage.changed_files {
            final_changed_files.insert(file.clone());
        }
    }
    final_changed_files.into_iter().collect()
}

/// Try to load a cached audit result from a previous `homeboy audit` run.
///
/// Checks `HOMEBOY_OUTPUT_DIR/audit.json` for a `CliResponse<CodeAuditResult>`
/// envelope. If found and parseable, returns the `CodeAuditResult` without
/// re-running the audit. This avoids redundant full-codebase scans when the
/// refactor step runs after an audit gate that already produced the results.
///
/// Returns `None` if:
/// - `HOMEBOY_OUTPUT_DIR` is not set
/// - The file doesn't exist
/// - The file can't be parsed (e.g. the audit failed and wrote an error envelope)
fn try_load_cached_audit() -> Option<CodeAuditResult> {
    let output_dir = std::env::var(OUTPUT_DIR_ENV).ok()?;
    let audit_file = PathBuf::from(&output_dir).join("audit.json");

    let content = std::fs::read_to_string(&audit_file).ok()?;
    let json: serde_json::Value = serde_json::from_str(&content).ok()?;

    // Only use cached results from successful runs
    if !json.get("success")?.as_bool()? {
        return None;
    }

    // The `--output` envelope wraps the audit in a `data` field
    let data = json.get("data")?;
    let result: CodeAuditResult = serde_json::from_value(data.clone()).ok()?;

    crate::log_status!(
        "refactor",
        "Using cached audit result ({} findings from {})",
        result.findings.len(),
        audit_file.display()
    );

    Some(result)
}

/// Try to load cached lint findings from a previous `homeboy lint` run.
///
/// Checks `HOMEBOY_OUTPUT_DIR/lint.json` for a `CliResponse<LintCommandOutput>`
/// envelope. If found and the run passed (zero findings), returns `Clean`
/// — the fix stage can be skipped entirely.
///
/// If findings exist, returns `HasFindings(count)` so the fix stage knows
/// to invoke fix-only mode without re-running the diagnostic pass.
fn try_load_cached_lint() -> Option<CachedLintResult> {
    let output_dir = std::env::var(OUTPUT_DIR_ENV).ok()?;
    let lint_file = PathBuf::from(&output_dir).join("lint.json");

    let content = std::fs::read_to_string(&lint_file).ok()?;
    let json: serde_json::Value = serde_json::from_str(&content).ok()?;

    let success = json.get("success")?.as_bool()?;
    let data = json.get("data")?;
    let passed = data.get("passed")?.as_bool()?;
    let finding_count = data
        .get("lint_findings")
        .and_then(|f| f.as_array())
        .map(|a| a.len())
        .unwrap_or(0);

    if !success {
        return None;
    }

    if passed && finding_count == 0 {
        crate::log_status!(
            "refactor",
            "Cached lint result is clean (0 findings from {}) — skipping lint fix stage",
            lint_file.display()
        );
        return Some(CachedLintResult::Clean);
    }

    if finding_count > 0 {
        crate::log_status!(
            "refactor",
            "Cached lint result has {} findings — fix stage will invoke fix-only mode",
            finding_count
        );
        return Some(CachedLintResult::HasFindings(finding_count));
    }

    None
}

/// Try to load cached test results from a previous `homeboy test` run.
///
/// Same pattern as lint: if the test run passed, skip the test fix stage.
/// If tests failed, return None so the fix stage runs to attempt auto-fixes.
fn try_load_cached_test() -> Option<CachedTestResult> {
    let output_dir = std::env::var(OUTPUT_DIR_ENV).ok()?;
    let test_file = PathBuf::from(&output_dir).join("test.json");

    let content = std::fs::read_to_string(&test_file).ok()?;
    let json: serde_json::Value = serde_json::from_str(&content).ok()?;

    let success = json.get("success")?.as_bool()?;
    let data = json.get("data")?;
    let passed = data.get("passed")?.as_bool()?;

    if success && passed {
        crate::log_status!(
            "refactor",
            "Cached test result is clean (passed from {}) — skipping test fix stage",
            test_file.display()
        );
        return Some(CachedTestResult::Clean);
    }

    crate::log_status!(
        "refactor",
        "Cached test result has failures — fix stage will re-run tests with auto-fix"
    );
    None
}

enum CachedLintResult {
    /// Lint passed with zero findings — nothing to fix.
    Clean,
    /// Lint had findings — the fix stage should invoke fix-only mode.
    HasFindings(usize),
}

enum CachedTestResult {
    /// Tests passed — nothing to fix.
    Clean,
}

fn plan_audit_stage(
    component_id: &str,
    root: &Path,
    changed_files: Option<&[String]>,
    only: &[crate::code_audit::AuditFinding],
    exclude: &[crate::code_audit::AuditFinding],
    write: bool,
) -> crate::Result<PlannedStage> {
    let result = if let Some(cached) = try_load_cached_audit() {
        cached
    } else if let Some(changed) = changed_files {
        if changed.is_empty() {
            CodeAuditResult {
                component_id: component_id.to_string(),
                source_path: root.to_string_lossy().to_string(),
                summary: crate::code_audit::AuditSummary {
                    files_scanned: 0,
                    conventions_detected: 0,
                    outliers_found: 0,
                    alignment_score: None,
                    files_skipped: 0,
                    warnings: vec![],
                },
                conventions: vec![],
                directory_conventions: vec![],
                findings: vec![],
                duplicate_groups: vec![],
            }
        } else {
            crate::code_audit::audit_path_scoped(
                component_id,
                &root.to_string_lossy(),
                changed,
                None,
            )?
        }
    } else {
        crate::code_audit::audit_path_with_id(component_id, &root.to_string_lossy())?
    };

    let policy = fixer::FixPolicy {
        only: (!only.is_empty()).then_some(only.to_vec()),
        exclude: exclude.to_vec(),
    };
    let mut fix_result = super::generate::generate_audit_fixes(&result, root, &policy);
    let (fix_result, policy_summary, changed_files, stage_warnings): (
        fixer::FixResult,
        fixer::PolicySummary,
        Vec<String>,
        Vec<String>,
    ) = if write {
        // Single pass: generate fixes from the provided findings, apply, validate.
        // The audit already ran and provided findings in `result` — the refactor
        // command does not re-run the audit internally. The convergence loop
        // (audit → fix → merge → re-audit) belongs in the full orchestration
        // pipeline, not inside a single refactor invocation.
        let outcome = super::verify::run_audit_refactor(
            result.clone(),
            only,
            exclude,
            AuditConvergenceScoring::default(),
            true,
        )?;

        let changed_files = outcome
            .fix_result
            .chunk_results
            .iter()
            .filter(|chunk| matches!(chunk.status, fixer::ChunkStatus::Applied))
            .flat_map(|chunk| chunk.files.clone())
            .collect::<BTreeSet<_>>()
            .into_iter()
            .collect::<Vec<_>>();

        let warnings = outcome
            .iteration_summary
            .iter()
            .filter(|summary| summary.status != "continued")
            .map(|summary| format!("audit refactor: {}", summary.status))
            .collect::<Vec<_>>();

        (
            outcome.fix_result,
            outcome.policy_summary,
            changed_files,
            warnings,
        )
    } else {
        let policy_summary = fixer::apply_fix_policy(&mut fix_result, false, &policy);
        let changed_files = collect_audit_changed_files(&fix_result);

        // Surface findings whose collected edits are all manual/review-only — these
        // are visible in preview but `--write` will decline to apply them.
        // Without this hint the divergence between dry-run and --write is
        // invisible (homeboy#1159).
        let review_only_count = count_review_only_fixes(&fix_result);
        let stage_warnings = if review_only_count > 0 {
            vec![format!(
                "{} finding(s) produced manual/review-only edits — visible in preview but will NOT be applied by --write. \
                 Resolve manually or acknowledge via baseline.",
                review_only_count
            )]
        } else {
            Vec::new()
        };

        (fix_result, policy_summary, changed_files, stage_warnings)
    };

    let fix_results = summarize_audit_fix_result_entries(&fix_result);
    let edit_count = fix_results.len();

    Ok(PlannedStage {
        source: "audit".to_string(),
        summary: SourceStageSummary {
            stage: "audit".to_string(),
            collected: true,
            applied: write && !changed_files.is_empty(),
            edit_count,
            files_modified: changed_files.len(),
            detected_findings: Some(result.findings.len()),
            changed_files,
            fix_summary: if write {
                if fix_result.files_modified > 0 {
                    Some(auto::summarize_audit_fix_result(&fix_result))
                } else {
                    None
                }
            } else if policy_summary.visible_insertions + policy_summary.visible_new_files > 0 {
                Some(auto::summarize_audit_fix_result(&fix_result))
            } else {
                None
            },
            warnings: stage_warnings,
        },
        fix_results,
    })
}

fn run_lint_stage(
    component: &Component,
    root: &Path,
    settings: &[(String, String)],
    options: &LintSourceOptions,
    changed_files: Option<&[String]>,
    write: bool,
    run_dir: &RunDir,
) -> crate::Result<PlannedStage> {
    // Check for cached lint results from the quality gate.
    let cached = try_load_cached_lint();

    // If clean, nothing to fix — skip entirely.
    if let Some(CachedLintResult::Clean) = cached {
        return Ok(empty_lint_stage());
    }

    let root_str = root.to_string_lossy().to_string();
    let findings_file = run_dir.step_file(run_dir::files::LINT_FINDINGS);
    let fix_sidecars = auto::AutofixSidecarFiles::for_run_dir(run_dir);

    let selected_files = options.selected_files.as_deref().or(changed_files);
    if selected_files.is_some_and(|files| files.is_empty()) {
        return Ok(empty_lint_stage());
    }
    let effective_glob = if let Some(changed_files) = selected_files {
        let abs_files: Vec<String> = changed_files
            .iter()
            .map(|f| format!("{}/{}", root_str, f))
            .collect();
        if abs_files.len() == 1 {
            Some(abs_files[0].clone())
        } else {
            Some(format!("{{{}}}", abs_files.join(",")))
        }
    } else {
        options.glob.clone()
    };

    // Helper: build the lint runner with the current stage options.
    // Used by both the diagnostic pass and the fix-only pass.
    let build_lint_runner = || {
        extension::lint::build_lint_runner(
            component,
            None,
            settings,
            false,
            options.file.as_deref(),
            effective_glob.as_deref(),
            options.errors_only,
            options.sniffs.as_deref(),
            options.exclude_sniffs.as_deref(),
            options.category.as_deref(),
            None,
            run_dir,
        )
    };

    // ── Phase 1: Diagnose ──────────────────────────────────────────────
    // Run the linter WITHOUT auto-fix. The extension reports findings but
    // does NOT modify files. This separates diagnosis from fix application
    // so the engine controls the full lifecycle.
    //
    // When cached findings exist from the quality gate, skip the diagnostic
    // pass entirely — the engine already knows what needs fixing.
    let lint_findings = if let Some(CachedLintResult::HasFindings(count)) = cached {
        crate::log_status!(
            "refactor",
            "Using {} cached lint findings — skipping diagnostic pass",
            count
        );
        // Parse cached findings for reporting
        let output_dir = std::env::var(OUTPUT_DIR_ENV).ok();
        let cached_findings = output_dir
            .and_then(|dir| {
                let file = PathBuf::from(&dir).join("lint.json");
                let content = std::fs::read_to_string(&file).ok()?;
                let json: serde_json::Value = serde_json::from_str(&content).ok()?;
                let data = json.get("data")?;
                let findings: Vec<crate::extension::lint::LintFinding> =
                    serde_json::from_value(data.get("lint_findings")?.clone()).ok()?;
                Some(findings)
            })
            .unwrap_or_default();
        cached_findings
    } else {
        // No cached findings — run the diagnostic pass.
        build_lint_runner()?.run()?;

        crate::extension::lint::baseline::parse_findings_file(&findings_file).unwrap_or_default()
    };

    // ── Phase 2: Apply fixes (only when --write) ───────────────────────
    // The engine controls fix application. The extension's fix-mode
    // invocation runs ONLY the fixers, not the diagnostic pass. The engine
    // tracks what changed via undo snapshots and git diff.
    let (stage_changed_files, fix_results) = if write && !lint_findings.is_empty() {
        let before_dirty = git::get_dirty_files(&root_str).unwrap_or_default();

        // Save undo snapshot before applying lint fixes.
        let mut snap = UndoSnapshot::new(root, "lint fix");
        for file in &before_dirty {
            snap.capture_file(file);
        }
        if let Err(error) = snap.save() {
            crate::log_status!("undo", "Warning: failed to save undo snapshot: {}", error);
        }

        // Invoke the extension in fix-only mode.
        //
        // HOMEBOY_FIX_ONLY=1 is the single contract: the extension runs its
        // fixers and skips its own validation pass (the engine validates
        // separately via the diagnose phase). Auto-fixing lives exclusively
        // under `homeboy refactor` — there is no other entry point.
        build_lint_runner()?.env("HOMEBOY_FIX_ONLY", "1").run()?;

        let after_dirty = git::get_dirty_files(&root_str).unwrap_or_default();
        let before_set: HashSet<&str> = before_dirty.iter().map(|s| s.as_str()).collect();
        let changed: Vec<String> = after_dirty
            .into_iter()
            .filter(|f| !before_set.contains(f.as_str()))
            .collect();

        let fix_results = fix_sidecars.consume_fix_results();
        (changed, fix_results)
    } else {
        (Vec::new(), Vec::new())
    };

    let edit_count = fix_results.len();

    Ok(PlannedStage {
        source: "lint".to_string(),
        summary: SourceStageSummary {
            stage: "lint".to_string(),
            collected: true,
            applied: write && !stage_changed_files.is_empty(),
            edit_count,
            files_modified: stage_changed_files.len(),
            detected_findings: Some(lint_findings.len()),
            changed_files: stage_changed_files,
            fix_summary: auto::summarize_optional_fix_results(&fix_results),
            warnings: Vec::new(),
        },
        fix_results,
    })
}

fn empty_lint_stage() -> PlannedStage {
    PlannedStage {
        source: "lint".to_string(),
        summary: SourceStageSummary {
            stage: "lint".to_string(),
            collected: true,
            applied: false,
            edit_count: 0,
            files_modified: 0,
            detected_findings: Some(0),
            changed_files: Vec::new(),
            fix_summary: None,
            warnings: Vec::new(),
        },
        fix_results: Vec::new(),
    }
}

fn run_test_stage(
    component: &Component,
    root: &Path,
    settings: &[(String, String)],
    options: &TestSourceOptions,
    changed_test_files: Option<&[String]>,
    write: bool,
    run_dir: &RunDir,
) -> crate::Result<PlannedStage> {
    // Check for cached test results — if the quality gate already passed,
    // there's nothing to fix and we can skip re-running the test suite entirely.
    if let Some(CachedTestResult::Clean) = try_load_cached_test() {
        return Ok(PlannedStage {
            source: "test".to_string(),
            summary: SourceStageSummary {
                stage: "test".to_string(),
                collected: true,
                applied: false,
                edit_count: 0,
                files_modified: 0,
                detected_findings: None,
                changed_files: Vec::new(),
                fix_summary: None,
                warnings: Vec::new(),
            },
            fix_results: Vec::new(),
        });
    }

    let root_str = root.to_string_lossy().to_string();
    let fix_sidecars = auto::AutofixSidecarFiles::for_run_dir(run_dir);
    let selected_test_files = options.selected_files.as_deref().or(changed_test_files);

    // ── Phase 1: Diagnose ──────────────────────────────────────────────
    // Run tests WITHOUT auto-fix. The extension reports failures but does
    // NOT modify files. This separates diagnosis from fix application.
    //
    // Helper: build the test runner with the current stage options.
    let build_runner = || {
        extension::test::build_test_runner(
            component,
            None,
            settings,
            options.skip_lint,
            false,
            None,
            selected_test_files,
            run_dir,
        )
    };

    let mut runner = build_runner()?;
    if !options.script_args.is_empty() {
        runner = runner.script_args(&options.script_args);
    }

    runner.run()?;

    // ── Phase 2: Apply fixes (only when --write) ───────────────────────
    // The engine controls fix application. The extension's fix-mode
    // invocation runs ONLY the fixers, not the diagnostic pass.
    let (stage_changed_files, fix_results) = if write {
        let before_dirty = git::get_dirty_files(&root_str).unwrap_or_default();

        // Save undo snapshot before applying test fixes.
        let mut snap = UndoSnapshot::new(root, "test fix");
        for file in &before_dirty {
            snap.capture_file(file);
        }
        if let Err(error) = snap.save() {
            crate::log_status!("undo", "Warning: failed to save undo snapshot: {}", error);
        }

        // Invoke the extension in fix-only mode. Reuses the same builder
        // as the diagnostic pass with HOMEBOY_FIX_ONLY=1 — the single env-var
        // contract for running fixers. See `run_lint_stage` for context.
        let mut fix_runner = build_runner()?.env("HOMEBOY_FIX_ONLY", "1");
        if !options.script_args.is_empty() {
            fix_runner = fix_runner.script_args(&options.script_args);
        }

        fix_runner.run()?;

        let after_dirty = git::get_dirty_files(&root_str).unwrap_or_default();
        let before_set: HashSet<&str> = before_dirty.iter().map(|s| s.as_str()).collect();
        let changed: Vec<String> = after_dirty
            .into_iter()
            .filter(|f| !before_set.contains(f.as_str()))
            .collect();

        let fix_results = fix_sidecars.consume_fix_results();
        (changed, fix_results)
    } else {
        (Vec::new(), Vec::new())
    };

    let edit_count = fix_results.len();
    Ok(PlannedStage {
        source: "test".to_string(),
        summary: SourceStageSummary {
            stage: "test".to_string(),
            collected: true,
            applied: write && !stage_changed_files.is_empty(),
            edit_count,
            files_modified: stage_changed_files.len(),
            detected_findings: None,
            changed_files: stage_changed_files,
            fix_summary: auto::summarize_optional_fix_results(&fix_results),
            warnings: Vec::new(),
        },
        fix_results,
    })
}

/// Count fixes whose edits are ALL blocked from auto-apply — i.e., they'd be
/// dropped entirely by `apply_fix_policy(write=true)`. Used to surface a warning
/// when dry-run previews edits that `--write` will silently decline.
/// (homeboy#1159, homeboy#1478)
fn count_review_only_fixes(fix_result: &fixer::FixResult) -> usize {
    let review_only_fixes = fix_result
        .fixes
        .iter()
        .filter(|fix| {
            !fix.insertions.is_empty() && fix.insertions.iter().all(|ins| !ins.auto_apply)
        })
        .count();
    let review_only_new_files = fix_result
        .new_files
        .iter()
        .filter(|nf| !nf.auto_apply)
        .count();
    review_only_fixes + review_only_new_files
}

/// Count only files that `--write` mode would actually modify.
///
/// `apply_fix_policy` keeps all policy-selected edits visible in dry-run, but
/// `auto_apply` marks whether each edit would survive a subsequent `--write`.
/// Counting a file here as "would be modified" when `--write` would silently
/// decline it produces the CI deadlock described in homeboy#1159: dry-run exits
/// 1 (triggering autofix), autofix re-runs with `--write` and applies nothing,
/// the skipped-bot-loop guard fires, and the PR is stuck.
///
/// Filter to fixes that have at least one auto-apply insertion — the same
/// predicate `apply_fix_policy(write=true)` uses to keep a fix. This
/// aligns dry-run `files_modified` with what a subsequent `--write` run
/// would actually produce. New files follow the same rule.
fn collect_audit_changed_files(fix_result: &fixer::FixResult) -> Vec<String> {
    let mut files = BTreeSet::new();
    for fix in &fix_result.fixes {
        if fix.insertions.iter().any(|ins| ins.auto_apply) {
            files.insert(fix.file.clone());
        }
    }
    for new_file in &fix_result.new_files {
        if new_file.auto_apply {
            files.insert(new_file.file.clone());
        }
    }
    files.into_iter().collect()
}

fn summarize_audit_fix_result_entries(fix_result: &fixer::FixResult) -> Vec<FixApplied> {
    let mut entries = Vec::new();

    for fix in &fix_result.fixes {
        for insertion in &fix.insertions {
            if insertion.auto_apply {
                entries.push(FixApplied {
                    file: fix.file.clone(),
                    rule: format!("{:?}", insertion.finding).to_lowercase(),
                    action: Some("insert".to_string()),
                    primitive: insertion.primitive.as_ref().map(auto::primitive_name),
                });
            }
        }
    }

    for new_file in &fix_result.new_files {
        if new_file.auto_apply {
            entries.push(FixApplied {
                file: new_file.file.clone(),
                rule: format!("{:?}", new_file.finding).to_lowercase(),
                action: Some("create".to_string()),
                primitive: new_file.primitive.as_ref().map(auto::primitive_name),
            });
        }
    }

    entries
}

pub fn analyze_stage_overlaps(stages: &[SourceStageSummary]) -> Vec<SourceOverlap> {
    let mut overlaps = Vec::new();

    for (later_index, later_stage) in stages.iter().enumerate() {
        if later_stage.changed_files.is_empty() {
            continue;
        }

        let later_files: BTreeSet<&str> = later_stage
            .changed_files
            .iter()
            .map(String::as_str)
            .collect();

        for earlier_stage in stages.iter().take(later_index) {
            if earlier_stage.changed_files.is_empty() {
                continue;
            }

            for file in earlier_stage.changed_files.iter().map(String::as_str) {
                if later_files.contains(file) {
                    overlaps.push(SourceOverlap {
                        file: file.to_string(),
                        earlier_stage: earlier_stage.stage.clone(),
                        later_stage: later_stage.stage.clone(),
                        resolution: format!(
                            "{} pass ran after {} in pipeline sequence",
                            later_stage.stage, earlier_stage.stage
                        ),
                    });
                }
            }
        }
    }

    overlaps.sort_by(|a, b| {
        a.file
            .cmp(&b.file)
            .then(a.earlier_stage.cmp(&b.earlier_stage))
            .then(a.later_stage.cmp(&b.later_stage))
    });

    overlaps
}

pub fn summarize_source_totals(
    stages: &[SourceStageSummary],
    total_files_selected: usize,
) -> SourceTotals {
    SourceTotals {
        stages_with_edits: stages.iter().filter(|stage| stage.edit_count > 0).count(),
        total_edits: stages.iter().map(|stage| stage.edit_count).sum(),
        total_files_selected,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::component::Component;
    use std::fs;
    use std::sync::{Mutex, OnceLock};
    use std::time::{SystemTime, UNIX_EPOCH};

    fn env_lock() -> &'static Mutex<()> {
        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
        LOCK.get_or_init(|| Mutex::new(()))
    }

    fn tmp_dir(name: &str) -> PathBuf {
        let nanos = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        std::env::temp_dir().join(format!("homeboy-refactor-sources-{name}-{nanos}"))
    }

    fn test_component(root: &Path) -> Component {
        Component {
            id: "component".to_string(),
            local_path: root.to_string_lossy().to_string(),
            remote_path: String::new(),
            ..Default::default()
        }
    }

    #[test]
    fn analyze_stage_overlaps_reports_later_stage_precedence() {
        let stages = vec![
            SourceStageSummary {
                stage: "audit".to_string(),
                collected: true,
                applied: true,
                edit_count: 1,
                files_modified: 1,
                detected_findings: Some(1),
                changed_files: vec!["src/lib.rs".to_string()],
                fix_summary: None,
                warnings: Vec::new(),
            },
            SourceStageSummary {
                stage: "lint".to_string(),
                collected: true,
                applied: true,
                edit_count: 1,
                files_modified: 2,
                detected_findings: Some(2),
                changed_files: vec!["src/lib.rs".to_string(), "src/main.rs".to_string()],
                fix_summary: None,
                warnings: Vec::new(),
            },
            SourceStageSummary {
                stage: "test".to_string(),
                collected: true,
                applied: true,
                edit_count: 1,
                files_modified: 1,
                detected_findings: None,
                changed_files: vec!["src/main.rs".to_string()],
                fix_summary: None,
                warnings: Vec::new(),
            },
        ];

        let overlaps = analyze_stage_overlaps(&stages);

        assert_eq!(
            overlaps,
            vec![
                SourceOverlap {
                    file: "src/lib.rs".to_string(),
                    earlier_stage: "audit".to_string(),
                    later_stage: "lint".to_string(),
                    resolution: "lint pass ran after audit in pipeline sequence".to_string(),
                },
                SourceOverlap {
                    file: "src/main.rs".to_string(),
                    earlier_stage: "lint".to_string(),
                    later_stage: "test".to_string(),
                    resolution: "test pass ran after lint in pipeline sequence".to_string(),
                },
            ]
        );
    }

    #[test]
    fn test_analyze_stage_overlaps() {
        assert!(analyze_stage_overlaps(&[]).is_empty());
    }

    #[test]
    fn analyze_stage_overlaps_ignores_disjoint_files() {
        let stages = vec![
            SourceStageSummary {
                stage: "audit".to_string(),
                collected: true,
                applied: true,
                edit_count: 1,
                files_modified: 1,
                detected_findings: Some(1),
                changed_files: vec!["src/lib.rs".to_string()],
                fix_summary: None,
                warnings: Vec::new(),
            },
            SourceStageSummary {
                stage: "lint".to_string(),
                collected: true,
                applied: true,
                edit_count: 1,
                files_modified: 1,
                detected_findings: Some(1),
                changed_files: vec!["src/main.rs".to_string()],
                fix_summary: None,
                warnings: Vec::new(),
            },
        ];

        assert!(analyze_stage_overlaps(&stages).is_empty());
    }

    #[test]
    fn summarize_source_totals_counts_stage_and_fix_totals() {
        let stages = vec![
            SourceStageSummary {
                stage: "audit".to_string(),
                collected: true,
                applied: false,
                edit_count: 2,
                files_modified: 1,
                detected_findings: Some(2),
                changed_files: vec!["src/lib.rs".to_string()],
                fix_summary: None,
                warnings: Vec::new(),
            },
            SourceStageSummary {
                stage: "lint".to_string(),
                collected: true,
                applied: false,
                edit_count: 0,
                files_modified: 0,
                detected_findings: Some(1),
                changed_files: Vec::new(),
                fix_summary: None,
                warnings: Vec::new(),
            },
            SourceStageSummary {
                stage: "test".to_string(),
                collected: true,
                applied: false,
                edit_count: 3,
                files_modified: 2,
                detected_findings: None,
                changed_files: vec!["tests/foo.rs".to_string(), "tests/bar.rs".to_string()],
                fix_summary: None,
                warnings: Vec::new(),
            },
        ];

        let totals = summarize_source_totals(&stages, 3);

        assert_eq!(totals.stages_with_edits, 2);
        assert_eq!(totals.total_edits, 5);
        assert_eq!(totals.total_files_selected, 3);
    }

    #[test]
    fn test_summarize_source_totals() {
        let totals = summarize_source_totals(&[], 0);

        assert_eq!(totals.stages_with_edits, 0);
        assert_eq!(totals.total_edits, 0);
        assert_eq!(totals.total_files_selected, 0);
    }

    #[test]
    fn test_lint_refactor_request() {
        let root = PathBuf::from("/tmp/homeboy-lint-refactor-request");
        let request = lint_refactor_request(
            test_component(&root),
            root,
            vec![("key".to_string(), "value".to_string())],
            LintSourceOptions::default(),
            true,
        );

        assert_eq!(request.sources, vec!["lint".to_string()]);
        assert!(request.write);
    }

    #[test]
    fn test_build_test_refactor_request() {
        let root = PathBuf::from("/tmp/homeboy-test-refactor-request");
        let request = build_test_refactor_request(
            test_component(&root),
            root,
            Vec::new(),
            TestSourceOptions::default(),
            false,
        );

        assert_eq!(request.sources, vec!["test".to_string()]);
        assert!(!request.write);
    }

    #[test]
    fn collect_refactor_sources_audit_write_uses_audit_refactor_engine() {
        let root = tmp_dir("audit-write");
        fs::create_dir_all(root.join("commands")).unwrap();
        fs::write(
            root.join("commands/good_one.rs"),
            "pub fn run() {}\npub fn helper() {}\n",
        )
        .unwrap();
        fs::write(
            root.join("commands/good_two.rs"),
            "pub fn run() {}\npub fn helper() {}\n",
        )
        .unwrap();
        fs::write(root.join("commands/bad.rs"), "pub fn run() {}\n").unwrap();

        let component = test_component(&root);
        let sources_run = collect_refactor_sources(RefactorSourceRequest {
            component,
            root: root.clone(),
            sources: vec!["audit".to_string()],
            changed_since: None,
            only: vec![crate::code_audit::AuditFinding::DuplicateFunction],
            exclude: vec![],
            settings: vec![],
            lint: LintSourceOptions::default(),
            test: TestSourceOptions::default(),
            write: true,
            force: false,
        })
        .unwrap();

        let audit_stage = sources_run
            .stages
            .iter()
            .find(|stage| stage.stage == "audit")
            .expect("audit stage present");

        assert!(audit_stage.collected);
        assert!(sources_run.collected_edits.is_empty());
        assert!(audit_stage.collected);
        assert!(audit_stage
            .warnings
            .iter()
            .any(|warning| warning.starts_with("audit refactor: ")));

        let _ = fs::remove_dir_all(root);
    }

    #[test]
    fn lint_stage_empty_selected_files_does_not_fall_back_to_broad_lint() {
        let root = tmp_dir("lint-empty-selected-files");
        fs::create_dir_all(&root).unwrap();
        let run_dir = RunDir::create().unwrap();
        let component = test_component(&root);

        let stage = run_lint_stage(
            &component,
            &root,
            &[],
            &LintSourceOptions {
                selected_files: Some(Vec::new()),
                ..Default::default()
            },
            None,
            true,
            &run_dir,
        )
        .expect("empty selected files should be a clean no-op");

        assert_eq!(stage.summary.stage, "lint");
        assert_eq!(stage.summary.detected_findings, Some(0));
        assert_eq!(stage.summary.files_modified, 0);
        assert!(stage.fix_results.is_empty());

        let _ = fs::remove_dir_all(root);
    }

    #[test]
    fn test_collect_refactor_sources() {
        let _collect: fn(RefactorSourceRequest) -> crate::Result<RefactorSourceRun> =
            collect_refactor_sources;
    }

    #[test]
    fn test_run_lint_refactor() {
        let _run: fn(
            Component,
            PathBuf,
            Vec<(String, String)>,
            LintSourceOptions,
            bool,
        ) -> crate::Result<RefactorSourceRun> = run_lint_refactor;
    }

    #[test]
    fn test_run_test_refactor() {
        let _run: fn(
            Component,
            PathBuf,
            Vec<(String, String)>,
            TestSourceOptions,
            bool,
        ) -> crate::Result<RefactorSourceRun> = run_test_refactor;
    }

    #[test]
    fn try_load_cached_audit_reads_output_dir() {
        let _guard = env_lock().lock().unwrap();
        std::env::remove_var(OUTPUT_DIR_ENV);
        let dir = tmp_dir("cached-audit");
        fs::create_dir_all(&dir).unwrap();
        let audit_result = CodeAuditResult {
            component_id: "test".to_string(),
            source_path: "/tmp/test".to_string(),
            summary: crate::code_audit::AuditSummary {
                files_scanned: 10,
                conventions_detected: 2,
                outliers_found: 1,
                alignment_score: None,
                files_skipped: 0,
                warnings: vec![],
            },
            conventions: vec![],
            directory_conventions: vec![],
            findings: vec![],
            duplicate_groups: vec![],
        };

        // Write a CliResponse envelope
        let envelope = serde_json::json!({
            "success": true,
            "data": audit_result,
        });
        fs::write(
            dir.join("audit.json"),
            serde_json::to_string_pretty(&envelope).unwrap(),
        )
        .unwrap();

        // Set the env var and load
        std::env::set_var(OUTPUT_DIR_ENV, dir.to_string_lossy().as_ref());
        let loaded = try_load_cached_audit();
        std::env::remove_var(OUTPUT_DIR_ENV);

        let loaded = loaded.expect("should load cached audit");
        assert_eq!(loaded.component_id, "test");
        assert_eq!(loaded.summary.files_scanned, 10);

        let _ = fs::remove_dir_all(dir);
    }

    #[test]
    fn try_load_cached_audit_skips_failed_envelope() {
        let _guard = env_lock().lock().unwrap();
        std::env::remove_var(OUTPUT_DIR_ENV);
        let dir = tmp_dir("cached-audit-fail");
        fs::create_dir_all(&dir).unwrap();
        let envelope = serde_json::json!({
            "success": false,
            "error": {
                "code": "internal.io_error",
                "message": "something broke",
                "details": {},
            },
        });
        fs::write(
            dir.join("audit.json"),
            serde_json::to_string_pretty(&envelope).unwrap(),
        )
        .unwrap();

        std::env::set_var(OUTPUT_DIR_ENV, dir.to_string_lossy().as_ref());
        let loaded = try_load_cached_audit();
        std::env::remove_var(OUTPUT_DIR_ENV);

        assert!(loaded.is_none(), "should not use failed audit result");

        let _ = fs::remove_dir_all(dir);
    }

    #[test]
    fn try_load_cached_audit_returns_none_when_unset() {
        let _guard = env_lock().lock().unwrap();
        std::env::remove_var(OUTPUT_DIR_ENV);
        assert!(try_load_cached_audit().is_none());
    }

    #[test]
    fn normalize_sources_orders_known_sources() {
        let normalized =
            normalize_sources(&["test".to_string(), "audit".to_string(), "lint".to_string()])
                .expect("sources should normalize");

        assert_eq!(normalized, vec!["audit", "lint", "test"]);
    }

    #[test]
    fn test_normalize_sources() {
        assert_eq!(
            normalize_sources(&["test".to_string()]).unwrap(),
            vec!["test"]
        );
    }

    #[test]
    fn normalize_sources_rejects_unknown_sources() {
        let err =
            normalize_sources(&["weird".to_string()]).expect_err("unknown source should fail");
        assert!(err.to_string().contains("Unknown refactor source"));
    }

    // ============================================================================
    // homeboy#1159 — dry-run vs --write contract alignment
    // ============================================================================
    //
    // Regression tests for the CI deadlock where dry-run reports files_modified>0
    // for edits that `--write` silently declines (cascading findings, manual-only
    // fixes). Before the fix, dry-run exit 1 + write applies nothing = stuck PR.

    use crate::code_audit::AuditFinding;
    use crate::refactor::auto::{Fix, FixResult, Insertion, InsertionKind, NewFile};

    fn auto_insertion() -> Insertion {
        Insertion {
            primitive: None,
            kind: InsertionKind::MethodStub,
            finding: AuditFinding::MissingMethod,
            manual_only: false,
            auto_apply: true,
            blocked_reason: None,
            code: "fn foo() {}".to_string(),
            description: "stub method".to_string(),
        }
    }

    fn manual_only_insertion() -> Insertion {
        Insertion {
            primitive: None,
            kind: InsertionKind::MethodStub,
            finding: AuditFinding::IntraMethodDuplicate,
            manual_only: true,
            auto_apply: false,
            blocked_reason: Some(
                "Blocked: manual-only edit, not eligible for --from auto-write".to_string(),
            ),
            code: String::new(),
            description: "manual-only duplicate flag".to_string(),
        }
    }

    fn review_only_insertion() -> Insertion {
        Insertion {
            primitive: None,
            kind: InsertionKind::MethodStub,
            finding: AuditFinding::OrphanedTest,
            manual_only: false,
            auto_apply: false,
            blocked_reason: Some(
                "Blocked: heuristic confidence finding requires human review before automated writes"
                    .to_string(),
            ),
            code: String::new(),
            description: "review-only orphaned test flag".to_string(),
        }
    }

    fn fix_result_with(fixes: Vec<Fix>, new_files: Vec<NewFile>) -> FixResult {
        let total_insertions = fixes.iter().map(|f| f.insertions.len()).sum();
        FixResult {
            fixes,
            new_files,
            decompose_plans: Vec::new(),
            skipped: Vec::new(),
            chunk_results: Vec::new(),
            total_insertions,
            files_modified: 0,
        }
    }

    #[test]
    fn collect_audit_changed_files_excludes_manual_only_only_fixes() {
        // The cascading-finding scenario from #1159 reproduction:
        //   dry-run: intramethodduplicate × edit_op_apply.rs — 2 collected edits
        //   --write: audit applied=false fixes_applied=0 files_modified=0
        // If every insertion in a fix is manual_only, --write drops the fix
        // entirely (policy.rs line ~94) — so dry-run must not count that file
        // as "would be modified".
        let fix = Fix {
            file: "src/core/engine/edit_op_apply.rs".to_string(),
            required_methods: Vec::new(),
            required_registrations: Vec::new(),
            insertions: vec![manual_only_insertion(), manual_only_insertion()],
            applied: false,
        };
        let result = fix_result_with(vec![fix], Vec::new());
        assert!(
            collect_audit_changed_files(&result).is_empty(),
            "Fix with only manual-only insertions should not count as would-modify"
        );
    }

    #[test]
    fn collect_audit_changed_files_excludes_review_only_only_fixes() {
        let fix = Fix {
            file: "tests/core/process_test.rs".to_string(),
            required_methods: Vec::new(),
            required_registrations: Vec::new(),
            insertions: vec![review_only_insertion()],
            applied: false,
        };
        let result = fix_result_with(vec![fix], Vec::new());
        assert!(
            collect_audit_changed_files(&result).is_empty(),
            "Review-only heuristic fixes should not count as would-modify"
        );
    }

    #[test]
    fn collect_audit_changed_files_includes_mixed_fixes() {
        // A fix that has at least one auto-apply insertion WILL be partially
        // applied by --write (the manual-only insertions get filtered during
        // apply, but the fix as a whole survives), so dry-run correctly
        // reports the file as would-modify.
        let fix = Fix {
            file: "src/lib.rs".to_string(),
            required_methods: Vec::new(),
            required_registrations: Vec::new(),
            insertions: vec![manual_only_insertion(), auto_insertion()],
            applied: false,
        };
        let result = fix_result_with(vec![fix], Vec::new());
        assert_eq!(
            collect_audit_changed_files(&result),
            vec!["src/lib.rs".to_string()],
            "Mixed fix (manual-only + auto) should count as would-modify"
        );
    }

    #[test]
    fn collect_audit_changed_files_includes_auto_apply_fixes() {
        // Baseline: the normal case, fully auto-apply, still counted.
        let fix = Fix {
            file: "src/lib.rs".to_string(),
            required_methods: Vec::new(),
            required_registrations: Vec::new(),
            insertions: vec![auto_insertion()],
            applied: false,
        };
        let result = fix_result_with(vec![fix], Vec::new());
        assert_eq!(
            collect_audit_changed_files(&result),
            vec!["src/lib.rs".to_string()]
        );
    }

    #[test]
    fn collect_audit_changed_files_excludes_manual_only_new_files() {
        let nf = NewFile {
            file: "src/generated.rs".to_string(),
            primitive: None,
            finding: AuditFinding::MissingMethod,
            manual_only: true,
            auto_apply: false,
            blocked_reason: Some("manual-only".to_string()),
            content: String::new(),
            description: "would create".to_string(),
            written: false,
        };
        let result = fix_result_with(Vec::new(), vec![nf]);
        assert!(collect_audit_changed_files(&result).is_empty());
    }

    #[test]
    fn collect_audit_changed_files_includes_auto_apply_new_files() {
        let nf = NewFile {
            file: "src/generated.rs".to_string(),
            primitive: None,
            finding: AuditFinding::MissingMethod,
            manual_only: false,
            auto_apply: true,
            blocked_reason: None,
            content: "// generated".to_string(),
            description: "create".to_string(),
            written: false,
        };
        let result = fix_result_with(Vec::new(), vec![nf]);
        assert_eq!(
            collect_audit_changed_files(&result),
            vec!["src/generated.rs".to_string()]
        );
    }

    #[test]
    fn count_manual_only_fixes_counts_both_fixes_and_new_files() {
        let manual_fix = Fix {
            file: "src/a.rs".to_string(),
            required_methods: Vec::new(),
            required_registrations: Vec::new(),
            insertions: vec![manual_only_insertion(), manual_only_insertion()],
            applied: false,
        };
        let mixed_fix = Fix {
            file: "src/b.rs".to_string(),
            required_methods: Vec::new(),
            required_registrations: Vec::new(),
            insertions: vec![manual_only_insertion(), auto_insertion()],
            applied: false,
        };
        let auto_fix = Fix {
            file: "src/c.rs".to_string(),
            required_methods: Vec::new(),
            required_registrations: Vec::new(),
            insertions: vec![auto_insertion()],
            applied: false,
        };
        let manual_nf = NewFile {
            file: "src/d.rs".to_string(),
            primitive: None,
            finding: AuditFinding::MissingMethod,
            manual_only: true,
            auto_apply: false,
            blocked_reason: None,
            content: String::new(),
            description: String::new(),
            written: false,
        };
        let result = fix_result_with(vec![manual_fix, mixed_fix, auto_fix], vec![manual_nf]);
        // Only the entirely-manual-only fix + the manual-only new file count.
        // The mixed fix survives --write, the fully-auto fix is normal.
        assert_eq!(count_review_only_fixes(&result), 2);
    }
}
