//! Lint workflow orchestration — runs lint, resolves changed-file scoping,
//! drives autofix, processes baseline lifecycle, and assembles results.
//!
//! Mirrors `core/extension/test/run.rs` — the command layer provides CLI args,
//! this module owns all business logic and returns a structured result.

use crate::core::component::Component;
use crate::core::engine::baseline::BaselineFlags;
use crate::core::engine::run_dir::{self, RunDir};
use crate::core::engine::shell;
use crate::core::extension::lint::baseline::{self as lint_baseline, LintFinding};
use crate::core::extension::lint::build_lint_runner;
use crate::core::extension::{self, ExtensionCapability, LintChangedFileRoute};
use crate::core::git;
use crate::core::refactor::AppliedRefactor;
use serde::Serialize;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

/// Arguments for the main lint workflow — populated by the command layer from CLI flags.
#[derive(Debug, Clone)]
pub struct LintRunWorkflowArgs {
    pub component_label: String,
    pub component_id: String,
    pub path_override: Option<String>,
    pub settings: Vec<(String, String)>,
    pub summary: bool,
    pub file: Option<String>,
    pub glob: Option<String>,
    pub changed_only: bool,
    pub changed_since: Option<String>,
    pub errors_only: bool,
    pub sniffs: Option<String>,
    pub exclude_sniffs: Option<String>,
    pub category: Option<String>,
    pub baseline_flags: BaselineFlags,
    pub json_summary: bool,
}

/// Result of the main lint workflow — ready for report assembly.
#[derive(Debug, Clone, Serialize)]
pub struct LintRunWorkflowResult {
    pub status: String,
    pub component: String,
    pub exit_code: i32,
    pub autofix: Option<AppliedRefactor>,
    pub hints: Option<Vec<String>>,
    pub baseline_comparison: Option<lint_baseline::BaselineComparison>,
    pub lint_findings: Option<Vec<LintFinding>>,
    pub summary: Option<LintSummaryOutput>,
}

/// Compact lint summary for automation consumers.
#[derive(Debug, Clone, Serialize)]
pub struct LintSummaryOutput {
    pub total_findings: usize,
    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
    pub categories: BTreeMap<String, usize>,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub top_findings: Vec<LintFinding>,
    pub exit_code: i32,
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct ScopedLintRun {
    glob: String,
    step: Option<String>,
}

/// Run the main lint workflow.
///
/// Handles changed-file scoping, autofix planning, lint runner execution,
/// baseline lifecycle, hint assembly, and result construction.
pub fn run_main_lint_workflow(
    component: &Component,
    source_path: &PathBuf,
    args: LintRunWorkflowArgs,
    run_dir: &RunDir,
) -> crate::core::Result<LintRunWorkflowResult> {
    let scoped_runs = resolve_scoped_lint_runs(component, &args)?;

    // Early exit if changed-file mode produced no files
    if let Some(ref runs) = scoped_runs {
        if runs.is_empty() {
            return Ok(LintRunWorkflowResult {
                status: "passed".to_string(),
                component: args.component_label,
                exit_code: 0,
                autofix: None,
                hints: None,
                baseline_comparison: None,
                lint_findings: None,
                summary: if args.json_summary {
                    Some(build_lint_summary(&[], 0))
                } else {
                    None
                },
            });
        }
    }

    // Run lint
    let output = if let Some(runs) = scoped_runs {
        run_scoped_lint_runs(component, &args, run_dir, &runs)?
    } else {
        build_lint_runner(
            component,
            args.path_override.clone(),
            &args.settings,
            args.summary || args.json_summary,
            args.file.as_deref(),
            args.glob.as_deref(),
            args.errors_only,
            args.sniffs.as_deref(),
            args.exclude_sniffs.as_deref(),
            args.category.as_deref(),
            None,
            run_dir,
        )?
        .passthrough(!args.json_summary)
        .run()?
    };

    let lint_findings_file = run_dir.step_file(run_dir::files::LINT_FINDINGS);
    let raw_lint_findings = lint_baseline::parse_findings_file(&lint_findings_file)?;
    let lint_findings = filter_lint_findings(raw_lint_findings, &args);

    let mut hints = Vec::new();

    let runner_exit_code =
        normalize_empty_finding_exit_code(output.exit_code, output.success, &lint_findings);
    let lint_exit_code = normalize_finding_exit_code(runner_exit_code, &lint_findings);

    // Baseline lifecycle
    let (baseline_comparison, baseline_exit_override) =
        process_baseline(source_path, &args, &lint_findings)?;

    let exit_code = effective_lint_exit_code(lint_exit_code, baseline_exit_override);
    let status = if exit_code == 0 { "passed" } else { "failed" }.to_string();
    let lint_clean = lint_findings.is_empty() && exit_code == 0;

    // Hint assembly — point to the auto-fix CTA for autofixable findings.
    //
    // Per the contract under #1459 (issue #1507), autofixable findings never
    // fail the run; they nudge. The CTA is rendered here in core, not by each
    // extension's runner, so every language extension benefits from a single
    // consistent prose. `homeboy lint --fix` is the ergonomic alias and is
    // listed first; the canonical `homeboy refactor --from lint --write`
    // invocation follows for users who want the longer form.
    if !lint_clean {
        hints.push(build_autofix_hint(&args));
        if args.changed_only {
            hints.push(
                "--changed-only is file-scoped: findings may be outside the changed hunks in modified files."
                    .to_string(),
            );
        }
        hints.push("Some issues may require manual fixes".to_string());
    }

    if args.file.is_none()
        && args.glob.is_none()
        && !args.changed_only
        && args.changed_since.is_none()
    {
        hints.push(
            "For targeted linting: --file <path>, --glob <pattern>, --changed-only, or --changed-since <ref>".to_string(),
        );
    }

    hints.push("Full options: homeboy docs commands/lint".to_string());

    if !args.baseline_flags.baseline && baseline_comparison.is_none() {
        hints.push(format!(
            "Save lint baseline: homeboy lint {} --baseline",
            args.component_label
        ));
    }

    let hints = if hints.is_empty() { None } else { Some(hints) };
    Ok(LintRunWorkflowResult {
        status,
        component: args.component_label,
        exit_code,
        autofix: None,
        hints,
        baseline_comparison,
        summary: if args.json_summary {
            Some(build_lint_summary(&lint_findings, exit_code))
        } else {
            None
        },
        lint_findings: Some(lint_findings),
    })
}

fn filter_lint_findings(
    findings: Vec<LintFinding>,
    args: &LintRunWorkflowArgs,
) -> Vec<LintFinding> {
    let included_sniffs = parse_csv_filter(args.sniffs.as_deref());
    let excluded_sniffs = parse_csv_filter(args.exclude_sniffs.as_deref());
    let category = args
        .category
        .as_deref()
        .map(str::trim)
        .filter(|value| !value.is_empty());

    findings
        .into_iter()
        .filter(|finding| {
            category.is_none_or(|expected| finding.category == expected)
                && (included_sniffs.is_empty()
                    || included_sniffs
                        .iter()
                        .any(|expected| finding_matches_sniff(finding, expected)))
                && !excluded_sniffs
                    .iter()
                    .any(|excluded| finding_matches_sniff(finding, excluded))
        })
        .collect()
}

fn parse_csv_filter(value: Option<&str>) -> Vec<String> {
    value
        .unwrap_or_default()
        .split(',')
        .map(str::trim)
        .filter(|item| !item.is_empty())
        .map(str::to_string)
        .collect()
}

fn finding_matches_sniff(finding: &LintFinding, sniff: &str) -> bool {
    finding.category == sniff
        || finding_rule(finding).is_some_and(|rule| rule == sniff)
        || finding.id == sniff
        || finding.id.split("::").any(|part| part == sniff)
        || finding.id.ends_with(sniff)
}

fn finding_rule(finding: &LintFinding) -> Option<&str> {
    finding.extra.get("rule").and_then(|value| value.as_str())
}

fn normalize_empty_finding_exit_code(
    exit_code: i32,
    success: bool,
    lint_findings: &[LintFinding],
) -> i32 {
    if lint_findings.is_empty() && !success && exit_code == 1 {
        0
    } else {
        exit_code
    }
}

fn normalize_finding_exit_code(exit_code: i32, lint_findings: &[LintFinding]) -> i32 {
    if !lint_findings.is_empty() && exit_code == 0 {
        1
    } else {
        exit_code
    }
}

fn effective_lint_exit_code(exit_code: i32, baseline_exit_override: Option<i32>) -> i32 {
    match baseline_exit_override {
        Some(0) if exit_code >= 2 => exit_code,
        Some(override_code) => override_code,
        None => exit_code,
    }
}

fn build_lint_summary(findings: &[LintFinding], exit_code: i32) -> LintSummaryOutput {
    let mut categories = BTreeMap::new();
    for finding in findings {
        *categories.entry(finding.category.clone()).or_insert(0) += 1;
    }

    LintSummaryOutput {
        total_findings: findings.len(),
        categories,
        top_findings: findings.iter().take(20).cloned().collect(),
        exit_code,
    }
}

fn build_autofix_hint(args: &LintRunWorkflowArgs) -> String {
    let lint_command = lint_autofix_command(args);

    if refactor_can_preserve_scope(args) {
        let refactor_command = refactor_autofix_command(args);
        format!("Auto-fix: {lint_command} (or {refactor_command})")
    } else {
        format!("Auto-fix: {lint_command}")
    }
}

fn lint_autofix_command(args: &LintRunWorkflowArgs) -> String {
    let mut parts = vec![
        "homeboy".to_string(),
        "lint".to_string(),
        args.component_label.clone(),
    ];

    append_common_scope_args(&mut parts, args);
    parts.push("--fix".to_string());

    shell::quote_args(&parts)
}

fn refactor_autofix_command(args: &LintRunWorkflowArgs) -> String {
    let mut parts = vec![
        "homeboy".to_string(),
        "refactor".to_string(),
        args.component_label.clone(),
    ];

    append_path_and_changed_since_args(&mut parts, args);
    parts.extend([
        "--from".to_string(),
        "lint".to_string(),
        "--write".to_string(),
    ]);

    shell::quote_args(&parts)
}

fn refactor_can_preserve_scope(args: &LintRunWorkflowArgs) -> bool {
    args.file.is_none() && args.glob.is_none() && !args.changed_only
}

fn append_common_scope_args(parts: &mut Vec<String>, args: &LintRunWorkflowArgs) {
    append_path_and_changed_since_args(parts, args);
    if let Some(file) = &args.file {
        parts.push("--file".to_string());
        parts.push(file.clone());
    }
    if let Some(glob) = &args.glob {
        parts.push("--glob".to_string());
        parts.push(glob.clone());
    }
    if args.changed_only {
        parts.push("--changed-only".to_string());
    }
}

fn append_path_and_changed_since_args(parts: &mut Vec<String>, args: &LintRunWorkflowArgs) {
    if let Some(path) = &args.path_override {
        parts.push("--path".to_string());
        parts.push(path.clone());
    }
    if let Some(changed_since) = &args.changed_since {
        parts.push("--changed-since".to_string());
        parts.push(changed_since.clone());
    }
}

fn run_scoped_lint_runs(
    component: &Component,
    args: &LintRunWorkflowArgs,
    run_dir: &RunDir,
    runs: &[ScopedLintRun],
) -> crate::core::Result<extension::RunnerOutput> {
    let mut success = true;
    let mut exit_code = 0;

    for (index, run) in runs.iter().enumerate() {
        let scoped_run_dir;
        let active_run_dir = if index == 0 {
            run_dir
        } else {
            scoped_run_dir = RunDir::create()?;
            &scoped_run_dir
        };

        let output = build_lint_runner(
            component,
            args.path_override.clone(),
            &args.settings,
            args.summary || args.json_summary,
            args.file.as_deref(),
            Some(run.glob.as_str()),
            args.errors_only,
            args.sniffs.as_deref(),
            args.exclude_sniffs.as_deref(),
            args.category.as_deref(),
            run.step.as_deref(),
            active_run_dir,
        )?
        .passthrough(!args.json_summary)
        .run()?;

        if !output.success {
            success = false;
            if exit_code == 0 {
                exit_code = output.exit_code;
            }
        }
    }

    Ok(extension::RunnerOutput {
        exit_code,
        success,
        stdout: String::new(),
        stderr: String::new(),
    })
}

pub fn run_self_check_lint_workflow(
    component: &Component,
    source_path: &Path,
    component_label: String,
    json_summary: bool,
) -> crate::core::Result<LintRunWorkflowResult> {
    let output = extension::self_check::run_self_checks_with_passthrough(
        component,
        ExtensionCapability::Lint,
        source_path,
        !json_summary,
    )?;
    let status = if output.success { "passed" } else { "failed" }.to_string();
    let hints = (!output.success).then(|| {
        vec![format!(
            "Fix the failing self-check command declared in {}'s homeboy.json self_checks.lint",
            component.id
        )]
    });

    Ok(LintRunWorkflowResult {
        status,
        component: component_label,
        exit_code: output.exit_code,
        autofix: None,
        hints,
        baseline_comparison: None,
        lint_findings: Some(Vec::new()),
        summary: if json_summary {
            Some(build_lint_summary(&[], output.exit_code))
        } else {
            None
        },
    })
}

/// Resolve runner-compatible scopes from --changed-only or --changed-since flags.
///
/// Returns `Some(Vec::new())` when changed-file mode is active but no compatible
/// files were found — the caller should treat this as an early "passed" exit.
/// Returns `None` when no changed-file scoping is active (use args.glob directly).
fn resolve_scoped_lint_runs(
    component: &Component,
    args: &LintRunWorkflowArgs,
) -> crate::core::Result<Option<Vec<ScopedLintRun>>> {
    if args.changed_only {
        let uncommitted = git::get_uncommitted_changes(&component.local_path)?;
        let mut changed_files: Vec<String> = Vec::new();
        changed_files.extend(uncommitted.staged);
        changed_files.extend(uncommitted.unstaged);
        changed_files.extend(uncommitted.untracked);

        if changed_files.is_empty() {
            println!("No files in working tree changes");
            return Ok(Some(Vec::new()));
        }

        eprintln!(
            "Linting {} changed file(s) (--changed-only is file-scoped; findings may be outside changed hunks)",
            changed_files.len()
        );

        Ok(Some(build_changed_lint_runs(component, &changed_files)))
    } else if let Some(ref git_ref) = args.changed_since {
        let changed_files = git::get_files_changed_since(&component.local_path, git_ref)?;

        if changed_files.is_empty() {
            println!("No files changed since {}", git_ref);
            return Ok(Some(Vec::new()));
        }

        Ok(Some(build_changed_lint_runs(component, &changed_files)))
    } else {
        Ok(None)
    }
}

fn build_changed_lint_runs(component: &Component, changed_files: &[String]) -> Vec<ScopedLintRun> {
    let routes = changed_file_routes_for_component(component);
    build_changed_lint_runs_with_routes(component, changed_files, &routes)
}

fn build_changed_lint_runs_with_routes(
    component: &Component,
    changed_files: &[String],
    routes: &[LintChangedFileRoute],
) -> Vec<ScopedLintRun> {
    if routes.is_empty() {
        return vec![ScopedLintRun {
            glob: glob_for_files(&component.local_path, changed_files),
            step: None,
        }];
    }

    let mut runs = Vec::new();
    for route in routes {
        let matched_files: Vec<String> = changed_files
            .iter()
            .filter(|file| route_matches_file(route, file))
            .cloned()
            .collect();

        if !matched_files.is_empty() {
            runs.push(ScopedLintRun {
                glob: glob_for_files(&component.local_path, &matched_files),
                step: Some(route.step.clone()),
            });
        }
    }
    runs
}

fn changed_file_routes_for_component(component: &Component) -> Vec<LintChangedFileRoute> {
    let Some(extensions) = component.extensions.as_ref() else {
        return Vec::new();
    };

    extensions
        .keys()
        .filter_map(|extension_id| extension::load_extension(extension_id).ok())
        .filter_map(|manifest| manifest.lint)
        .flat_map(|lint| lint.changed_file_routes)
        .collect()
}

fn route_matches_file(route: &LintChangedFileRoute, file: &str) -> bool {
    if !route.extensions.is_empty() && has_extension(file, &route.extensions) {
        return true;
    }

    route
        .globs
        .iter()
        .any(|pattern| glob_match::glob_match(pattern, file))
}

fn has_extension(file: &str, extensions: &[String]) -> bool {
    Path::new(file)
        .extension()
        .and_then(|extension| extension.to_str())
        .is_some_and(|extension| extensions.iter().any(|expected| expected == extension))
}

fn glob_for_files(root: &str, files: &[String]) -> String {
    let abs_files: Vec<String> = files
        .iter()
        .map(|file| format!("{}/{}", root, file))
        .collect();

    if abs_files.len() == 1 {
        abs_files[0].clone()
    } else {
        format!("{{{}}}", abs_files.join(","))
    }
}

/// Process baseline lifecycle — save, load, compare.
fn process_baseline(
    source_path: &PathBuf,
    args: &LintRunWorkflowArgs,
    lint_findings: &[LintFinding],
) -> crate::core::Result<(Option<lint_baseline::BaselineComparison>, Option<i32>)> {
    let mut baseline_comparison = None;
    let mut baseline_exit_override = None;

    if args.baseline_flags.baseline {
        let saved = lint_baseline::save_baseline(source_path, &args.component_id, lint_findings)?;
        eprintln!(
            "[lint] Baseline saved to {} ({} findings)",
            saved.display(),
            lint_findings.len()
        );
    }

    if !args.baseline_flags.baseline && !args.baseline_flags.ignore_baseline {
        if let Some(existing) = lint_baseline::load_baseline(source_path) {
            let comparison = lint_baseline::compare(lint_findings, &existing);

            if comparison.drift_increased {
                eprintln!(
                    "[lint] DRIFT INCREASED: {} new finding(s) since baseline",
                    comparison.new_items.len()
                );
                baseline_exit_override = Some(1);
            } else if !comparison.resolved_fingerprints.is_empty() {
                eprintln!(
                    "[lint] Drift reduced: {} finding(s) resolved since baseline",
                    comparison.resolved_fingerprints.len()
                );
                baseline_exit_override = Some(0);
            } else {
                eprintln!("[lint] No change from baseline");
                baseline_exit_override = Some(0);
            }

            baseline_comparison = Some(comparison);
        }
    }

    Ok((baseline_comparison, baseline_exit_override))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::core::component::SelfCheckConfig;
    use crate::core::engine::baseline::BaselineFlags;

    fn component(root: &str) -> Component {
        Component::new(
            "fixture".to_string(),
            root.to_string(),
            "".to_string(),
            None,
        )
    }

    fn split_lint_routes() -> Vec<LintChangedFileRoute> {
        vec![
            LintChangedFileRoute {
                extensions: vec!["php".to_string()],
                globs: Vec::new(),
                step: "phpcs,phpstan".to_string(),
            },
            LintChangedFileRoute {
                extensions: vec![
                    "js".to_string(),
                    "jsx".to_string(),
                    "ts".to_string(),
                    "tsx".to_string(),
                ],
                globs: Vec::new(),
                step: "eslint".to_string(),
            },
        ]
    }

    fn lint_args() -> LintRunWorkflowArgs {
        LintRunWorkflowArgs {
            component_label: "demo".to_string(),
            component_id: "demo".to_string(),
            path_override: None,
            settings: Vec::new(),
            summary: false,
            file: None,
            glob: None,
            changed_only: false,
            changed_since: None,
            errors_only: false,
            sniffs: None,
            exclude_sniffs: None,
            category: None,
            baseline_flags: BaselineFlags::default(),
            json_summary: false,
        }
    }

    #[test]
    fn autofix_hint_preserves_changed_since_scope() {
        let mut args = lint_args();
        args.path_override = Some("/tmp/pr checkout".to_string());
        args.changed_since = Some("origin/main".to_string());

        let hint = build_autofix_hint(&args);

        assert!(hint.contains(
            "homeboy lint demo --path '/tmp/pr checkout' --changed-since origin/main --fix"
        ));
        assert!(hint.contains(
            "homeboy refactor demo --path '/tmp/pr checkout' --changed-since origin/main --from lint --write"
        ));
    }

    #[test]
    fn autofix_hint_preserves_changed_only_and_file_scope() {
        let mut args = lint_args();
        args.file = Some("src/lib.rs".to_string());
        args.changed_only = true;

        let hint = build_autofix_hint(&args);

        assert!(hint.contains("homeboy lint demo --file src/lib.rs --changed-only --fix"));
        assert!(!hint.contains("homeboy refactor"));
    }

    #[test]
    fn test_run_self_check_lint_workflow() {
        let dir = tempfile::tempdir().expect("temp dir");
        std::fs::write(dir.path().join("lint.sh"), "printf lint-ok\n")
            .expect("script should be written");

        let mut component = Component::new(
            "fixture".to_string(),
            dir.path().to_string_lossy().to_string(),
            "".to_string(),
            None,
        );
        component.self_checks = Some(SelfCheckConfig {
            lint: vec!["sh lint.sh".to_string()],
            test: Vec::new(),
        });

        let result =
            run_self_check_lint_workflow(&component, dir.path(), "fixture".to_string(), false)
                .expect("lint self-check should run");

        assert_eq!(result.status, "passed");
        assert_eq!(result.exit_code, 0);
        assert_eq!(result.component, "fixture");
    }

    #[test]
    fn test_run_main_lint_workflow() {
        let dir = tempfile::tempdir().expect("temp dir");
        std::process::Command::new("git")
            .args(["init"])
            .current_dir(dir.path())
            .output()
            .expect("git init should run");
        let run_dir = RunDir::create().expect("run dir");
        let mut args = lint_args();
        args.changed_only = true;

        let result = run_main_lint_workflow(
            &component(&dir.path().to_string_lossy()),
            &dir.path().to_path_buf(),
            args,
            &run_dir,
        )
        .expect("unchanged git repo should skip lint runner");

        assert_eq!(result.status, "passed");
        assert_eq!(result.exit_code, 0);
        assert!(result.lint_findings.is_none());
    }

    #[test]
    fn lint_summary_counts_categories_and_caps_top_findings() {
        let findings = (0..25)
            .map(|index| LintFinding {
                id: format!("src/file-{index}.rs::rule"),
                message: "message".to_string(),
                category: if index % 2 == 0 {
                    "style".to_string()
                } else {
                    "correctness".to_string()
                },
                ..LintFinding::default()
            })
            .collect::<Vec<_>>();

        let summary = build_lint_summary(&findings, 1);

        assert_eq!(summary.total_findings, 25);
        assert_eq!(summary.categories.get("style"), Some(&13));
        assert_eq!(summary.categories.get("correctness"), Some(&12));
        assert_eq!(summary.top_findings.len(), 20);
        assert_eq!(summary.exit_code, 1);
    }

    #[test]
    fn filter_lint_findings_keeps_requested_category_only() {
        let mut args = lint_args();
        args.category = Some("security".to_string());
        let findings = vec![
            lint_finding(
                "a",
                "security",
                "WordPress.Security.ValidatedSanitizedInput",
            ),
            lint_finding("b", "database", "WordPress.DB.PreparedSQL"),
            lint_finding("c", "eslint", "react-hooks/rules-of-hooks"),
        ];

        let filtered = filter_lint_findings(findings, &args);

        assert_eq!(filtered.len(), 1);
        assert_eq!(filtered[0].id, "a");
    }

    #[test]
    fn filter_lint_findings_honors_include_and_exclude_sniffs() {
        let mut args = lint_args();
        args.sniffs = Some(
            "WordPress.Security.ValidatedSanitizedInput,Generic.WhiteSpace.ScopeIndent".to_string(),
        );
        args.exclude_sniffs = Some("Generic.WhiteSpace.ScopeIndent".to_string());
        let findings = vec![
            lint_finding(
                "inc/a.php::WordPress.Security.ValidatedSanitizedInput",
                "security",
                "WordPress.Security.ValidatedSanitizedInput",
            ),
            lint_finding(
                "inc/b.php::Generic.WhiteSpace.ScopeIndent",
                "whitespace",
                "Generic.WhiteSpace.ScopeIndent",
            ),
            lint_finding(
                "inc/c.php::WordPress.DB.PreparedSQL",
                "database",
                "WordPress.DB.PreparedSQL",
            ),
        ];

        let filtered = filter_lint_findings(findings, &args);

        assert_eq!(filtered.len(), 1);
        assert_eq!(
            filtered[0].id,
            "inc/a.php::WordPress.Security.ValidatedSanitizedInput"
        );
    }

    #[test]
    fn empty_filtered_findings_turn_lint_finding_exit_into_pass() {
        let exit_code = normalize_empty_finding_exit_code(1, false, &[]);

        assert_eq!(exit_code, 0);
    }

    #[test]
    fn empty_filtered_findings_do_not_hide_infrastructure_errors() {
        let exit_code = normalize_empty_finding_exit_code(2, false, &[]);

        assert_eq!(exit_code, 2);
    }

    #[test]
    fn findings_force_failure_when_runner_exits_cleanly() {
        let exit_code = normalize_finding_exit_code(0, &[lint_finding("a", "security", "rule")]);

        assert_eq!(exit_code, 1);
    }

    #[test]
    fn baseline_clean_override_honors_known_findings_but_not_infrastructure_errors() {
        assert_eq!(effective_lint_exit_code(1, Some(0)), 0);
        assert_eq!(effective_lint_exit_code(2, Some(0)), 2);
    }

    #[test]
    fn manifest_changed_php_files_route_to_php_steps_only() {
        let component = component("/repo");
        let runs = build_changed_lint_runs_with_routes(
            &component,
            &["data-machine.php".to_string(), "inc/Foo.php".to_string()],
            &split_lint_routes(),
        );

        assert_eq!(
            runs,
            vec![ScopedLintRun {
                glob: "{/repo/data-machine.php,/repo/inc/Foo.php}".to_string(),
                step: Some("phpcs,phpstan".to_string()),
            }]
        );
    }

    #[test]
    fn manifest_changed_markdown_files_do_not_route_to_eslint() {
        let component = component("/repo");
        let runs = build_changed_lint_runs_with_routes(
            &component,
            &[
                "docs/core-system/agent-bundles.md".to_string(),
                "README.md".to_string(),
            ],
            &split_lint_routes(),
        );

        assert!(runs.is_empty());
    }

    #[test]
    fn manifest_changed_mixed_php_and_js_files_split_by_runner() {
        let component = component("/repo");
        let runs = build_changed_lint_runs_with_routes(
            &component,
            &[
                "inc/Foo.php".to_string(),
                "docs/notes.md".to_string(),
                "assets/app.js".to_string(),
                "assets/view.tsx".to_string(),
            ],
            &split_lint_routes(),
        );

        assert_eq!(
            runs,
            vec![
                ScopedLintRun {
                    glob: "/repo/inc/Foo.php".to_string(),
                    step: Some("phpcs,phpstan".to_string()),
                },
                ScopedLintRun {
                    glob: "{/repo/assets/app.js,/repo/assets/view.tsx}".to_string(),
                    step: Some("eslint".to_string()),
                },
            ]
        );
    }

    #[test]
    fn manifest_changed_files_can_route_by_glob() {
        let component = component("/repo");
        let routes = vec![LintChangedFileRoute {
            extensions: Vec::new(),
            globs: vec!["assets/**/*.css".to_string()],
            step: "stylelint".to_string(),
        }];
        let runs = build_changed_lint_runs_with_routes(
            &component,
            &["assets/css/admin.css".to_string(), "README.md".to_string()],
            &routes,
        );

        assert_eq!(
            runs,
            vec![ScopedLintRun {
                glob: "/repo/assets/css/admin.css".to_string(),
                step: Some("stylelint".to_string()),
            }]
        );
    }

    #[test]
    fn lint_config_deserializes_changed_file_routes() {
        let config: crate::core::extension::LintConfig = serde_json::from_str(
            r#"{
                "extension_script": "scripts/lint.sh",
                "changed_file_routes": [
                    { "extensions": ["php"], "step": "phpcs,phpstan" },
                    { "globs": ["assets/**/*.css"], "step": "stylelint" }
                ]
            }"#,
        )
        .expect("parse lint config");

        assert_eq!(config.changed_file_routes.len(), 2);
        assert_eq!(config.changed_file_routes[0].extensions, vec!["php"]);
        assert_eq!(config.changed_file_routes[0].step, "phpcs,phpstan");
        assert_eq!(config.changed_file_routes[1].globs, vec!["assets/**/*.css"]);
        assert_eq!(config.changed_file_routes[1].step, "stylelint");
    }

    #[test]
    fn non_wordpress_changed_files_keep_existing_single_runner_scope() {
        let component = Component::new(
            "fixture".to_string(),
            "/repo".to_string(),
            "".to_string(),
            None,
        );
        let runs = build_changed_lint_runs(
            &component,
            &["src/main.rs".to_string(), "README.md".to_string()],
        );

        assert_eq!(
            runs,
            vec![ScopedLintRun {
                glob: "{/repo/src/main.rs,/repo/README.md}".to_string(),
                step: None,
            }]
        );
    }

    fn lint_finding(id: &str, category: &str, rule: &str) -> LintFinding {
        LintFinding {
            id: id.to_string(),
            message: "message".to_string(),
            category: category.to_string(),
            extra: [("rule".to_string(), serde_json::json!(rule))]
                .into_iter()
                .collect(),
            ..LintFinding::default()
        }
    }
}
