use std::path::{Path, PathBuf};
use std::sync::Arc;

use serde::{Deserialize, Serialize};
use shuck_cache::{CacheKey, CacheKeyHasher, FileCacheKey};
use shuck_linter::Applicability;

use super::CheckReport;
use super::settings::{EffectiveCheckSettings, ResolvedCheckSettings};
use crate::commands::check_output::{
    DisplayPosition, DisplaySpan, DisplayedApplicability, DisplayedDiagnostic,
    DisplayedDiagnosticKind, DisplayedEdit, DisplayedFix,
};
use crate::discover::{DiscoveredFile, FileKind};

#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct CheckCacheSettings {
    effective: EffectiveCheckSettings,
    analyzed_paths: Vec<PathBuf>,
    source_resolution_home: Option<PathBuf>,
}

impl CheckCacheSettings {
    pub(super) fn new(settings: &ResolvedCheckSettings, files: &[DiscoveredFile]) -> Self {
        Self {
            effective: settings.effective.clone(),
            analyzed_paths: analyzed_shell_relative_paths(files),
            source_resolution_home: std::env::var_os("HOME").map(PathBuf::from),
        }
    }
}

impl CacheKey for CheckCacheSettings {
    fn cache_key(&self, state: &mut CacheKeyHasher) {
        state.write_tag(b"check-cache-settings");
        self.effective.cache_key(state);
        self.analyzed_paths.cache_key(state);
        self.source_resolution_home.cache_key(state);
    }
}

fn analyzed_shell_relative_paths(files: &[DiscoveredFile]) -> Vec<PathBuf> {
    let mut paths = files
        .iter()
        .filter(|file| file.kind == FileKind::Shell)
        .map(|file| file.relative_path.clone())
        .collect::<Vec<_>>();
    paths.sort();
    paths.dedup();
    paths
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(super) struct CheckCacheData {
    pub(super) diagnostics: Vec<CachedDisplayedDiagnostic>,
    #[serde(default)]
    pub(super) parse_failed: bool,
    #[serde(default)]
    pub(super) dependency_fingerprints: Vec<ResolvedDependencyFingerprint>,
    /// Resolved `lint=true` directive targets this file pulled into the run.
    /// Kept so a cache hit still enqueues the targets for linting; source
    /// resolution candidates, including missing higher-precedence paths, are
    /// stored separately in `dependency_fingerprints`.
    #[serde(default)]
    pub(super) followed_paths: Vec<PathBuf>,
}

impl CheckCacheData {
    pub(super) fn from_displayed(
        diagnostics: &[DisplayedDiagnostic],
        parse_failed: bool,
        dependency_paths: &[PathBuf],
        followed_paths: &[PathBuf],
    ) -> Self {
        Self {
            diagnostics: diagnostics
                .iter()
                .map(CachedDisplayedDiagnostic::from_displayed)
                .collect(),
            parse_failed,
            dependency_fingerprints: dependency_paths
                .iter()
                .chain(followed_paths)
                .collect::<std::collections::BTreeSet<_>>()
                .into_iter()
                .map(|path| ResolvedDependencyFingerprint::from_path(path))
                .collect(),
            followed_paths: followed_paths.to_vec(),
        }
    }

    pub(super) fn dependency_paths(&self) -> Vec<PathBuf> {
        self.dependency_fingerprints
            .iter()
            .map(|fingerprint| fingerprint.path.clone())
            .collect()
    }

    pub(super) fn dependencies_match(&self) -> bool {
        self.dependency_fingerprints.iter().all(|fingerprint| {
            FileCacheKey::from_path(&fingerprint.path).ok() == fingerprint.file_key
        })
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(super) struct ResolvedDependencyFingerprint {
    pub(super) path: PathBuf,
    #[serde(default)]
    pub(super) file_key: Option<FileCacheKey>,
}

impl ResolvedDependencyFingerprint {
    fn from_path(path: &Path) -> Self {
        let path = path.to_path_buf();
        let file_key = FileCacheKey::from_path(&path).ok();
        Self { path, file_key }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(super) enum CachedDisplayedDiagnosticKind {
    ParseError,
    Lint { code: String, severity: String },
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(super) struct CachedDisplayedDiagnostic {
    start_line: usize,
    start_column: usize,
    end_line: usize,
    end_column: usize,
    message: String,
    pub(super) kind: CachedDisplayedDiagnosticKind,
    fix: Option<CachedLintFix>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
enum CachedApplicability {
    Safe,
    Unsafe,
}

impl From<Applicability> for CachedApplicability {
    fn from(value: Applicability) -> Self {
        match value {
            Applicability::Safe => Self::Safe,
            Applicability::Unsafe => Self::Unsafe,
        }
    }
}

impl From<CachedApplicability> for DisplayedApplicability {
    fn from(value: CachedApplicability) -> Self {
        match value {
            CachedApplicability::Safe => Self::Safe,
            CachedApplicability::Unsafe => Self::Unsafe,
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct CachedLintFix {
    applicability: CachedApplicability,
    message: Option<String>,
    edits: Vec<CachedLintEdit>,
}

impl CachedLintFix {
    fn from_displayed(fix: &DisplayedFix) -> Self {
        Self {
            applicability: match fix.applicability {
                DisplayedApplicability::Safe => CachedApplicability::Safe,
                DisplayedApplicability::Unsafe => CachedApplicability::Unsafe,
            },
            message: fix.message.clone(),
            edits: fix
                .edits
                .iter()
                .map(CachedLintEdit::from_displayed)
                .collect(),
        }
    }

    fn to_displayed(&self) -> DisplayedFix {
        DisplayedFix {
            applicability: self.applicability.into(),
            message: self.message.clone(),
            edits: self
                .edits
                .iter()
                .map(CachedLintEdit::to_displayed)
                .collect(),
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct CachedLintEdit {
    start_line: usize,
    start_column: usize,
    end_line: usize,
    end_column: usize,
    content: String,
}

impl CachedLintEdit {
    fn from_displayed(edit: &DisplayedEdit) -> Self {
        Self {
            start_line: edit.location.line,
            start_column: edit.location.column,
            end_line: edit.end_location.line,
            end_column: edit.end_location.column,
            content: edit.content.clone(),
        }
    }

    fn to_displayed(&self) -> DisplayedEdit {
        DisplayedEdit {
            location: DisplayPosition::new(self.start_line, self.start_column),
            end_location: DisplayPosition::new(self.end_line, self.end_column),
            content: self.content.clone(),
        }
    }
}

impl CachedDisplayedDiagnostic {
    fn from_displayed(diagnostic: &DisplayedDiagnostic) -> Self {
        Self {
            start_line: diagnostic.span.start.line,
            start_column: diagnostic.span.start.column,
            end_line: diagnostic.span.end.line,
            end_column: diagnostic.span.end.column,
            message: diagnostic.message.clone(),
            kind: match &diagnostic.kind {
                DisplayedDiagnosticKind::ParseError => CachedDisplayedDiagnosticKind::ParseError,
                DisplayedDiagnosticKind::Lint { code, severity } => {
                    CachedDisplayedDiagnosticKind::Lint {
                        code: code.clone(),
                        severity: severity.clone(),
                    }
                }
            },
            fix: diagnostic.fix.as_ref().map(CachedLintFix::from_displayed),
        }
    }
}
pub(super) fn push_cached_diagnostics(
    report: &mut CheckReport,
    path: &Path,
    relative_path: &Path,
    absolute_path: &Path,
    diagnostics: &[CachedDisplayedDiagnostic],
    source: Option<Arc<str>>,
) {
    for diagnostic in diagnostics {
        report.diagnostics.push(DisplayedDiagnostic {
            path: path.to_path_buf(),
            relative_path: relative_path.to_path_buf(),
            absolute_path: absolute_path.to_path_buf(),
            span: DisplaySpan::new(
                DisplayPosition::new(diagnostic.start_line, diagnostic.start_column),
                DisplayPosition::new(diagnostic.end_line, diagnostic.end_column),
            ),
            message: diagnostic.message.clone(),
            kind: match &diagnostic.kind {
                CachedDisplayedDiagnosticKind::ParseError => DisplayedDiagnosticKind::ParseError,
                CachedDisplayedDiagnosticKind::Lint { code, severity } => {
                    DisplayedDiagnosticKind::Lint {
                        code: code.clone(),
                        severity: severity.clone(),
                    }
                }
            },
            fix: diagnostic.fix.as_ref().map(CachedLintFix::to_displayed),
            source: source.clone(),
        });
    }
}

#[cfg(test)]
mod tests {
    #![allow(unused_imports)]

    use std::fs;
    use std::path::{Path, PathBuf};
    use std::sync::Arc;
    use std::sync::mpsc::{TryRecvError, channel};

    use notify::event::{CreateKind, EventAttributes, ModifyKind, RemoveKind, RenameMode};
    use shuck_extract::{
        EmbeddedFormat, EmbeddedScript, ExtractedDialect, HostLineStart, ImplicitShellFlags,
    };
    use shuck_linter::{
        Category, LinterSettings, Rule, RuleSelector, RuleSet, ShellCheckCodeMap, ShellDialect,
    };
    use shuck_parser::parser::Parser;
    use tempfile::tempdir;

    use super::*;
    use crate::ExitStatus;
    use crate::args::{
        CheckCommand, CheckOutputFormatArg, FileSelectionArgs, PatternRuleSelectorPair,
        PatternShellPair, RuleSelectionArgs,
    };
    use crate::commands::check::add_ignore::run_add_ignore_with_cwd;
    use crate::commands::check::analyze::{
        analyze_file, collect_lint_diagnostics, read_shared_source,
    };
    use crate::commands::check::cache::CachedDisplayedDiagnosticKind;
    use crate::commands::check::display::display_lint_diagnostics;
    use crate::commands::check::embedded::remap_embedded_position;
    use crate::commands::check::run::run_check_with_cwd;
    use crate::commands::check::settings::{
        CompiledPerFileShellList, PerFileShell, parse_rule_selectors,
        resolve_project_check_settings,
    };
    use crate::commands::check::test_support::*;
    use crate::commands::check::watch::{
        WatchTarget, collect_watch_targets, drain_watch_batch, should_clear_screen,
        watch_event_requires_rerun,
    };
    use crate::commands::check::{CheckReport, diagnostics_exit_status};
    use crate::commands::check_output::{
        DisplayPosition, DisplaySpan, DisplayedDiagnostic, DisplayedDiagnosticKind, print_report_to,
    };
    use crate::commands::project_runner::PendingProjectFile;
    use crate::discover::{DiscoveredFile, FileKind, ProjectRoot, normalize_path};
    use shuck_cache::cache_key_hex;
    use shuck_config::ConfigArguments;

    #[test]
    fn reuses_cached_results() {
        let tempdir = tempdir().unwrap();
        fs::write(tempdir.path().join("ok.sh"), "#!/bin/bash\necho ok\n").unwrap();

        let first = run_check_with_cwd(
            &check_args(false),
            &ConfigArguments::default(),
            tempdir.path(),
            &cache_root(tempdir.path()),
        )
        .unwrap();
        let second = run_check_with_cwd(
            &check_args(false),
            &ConfigArguments::default(),
            tempdir.path(),
            &cache_root(tempdir.path()),
        )
        .unwrap();

        assert_eq!(first.cache_hits, 0);
        assert_eq!(first.cache_misses, 1);
        assert_eq!(second.cache_hits, 1);
        assert_eq!(second.cache_misses, 0);
    }

    #[test]
    fn cache_tracks_missing_dependency_paths_until_they_exist() {
        let tempdir = tempdir().unwrap();
        let missing_dependency = tempdir.path().join("plugins/git.plugin.zsh");
        fs::create_dir_all(missing_dependency.parent().unwrap()).unwrap();

        let cache_data = CheckCacheData::from_displayed(
            &[],
            false,
            std::slice::from_ref(&missing_dependency),
            &[],
        );

        assert_eq!(
            cache_data.dependency_paths(),
            vec![missing_dependency.clone()]
        );
        assert!(cache_data.dependencies_match());

        fs::write(&missing_dependency, "plugin_loaded=1\n").unwrap();

        assert!(!cache_data.dependencies_match());
    }

    #[test]
    fn cache_detects_when_a_dependency_disappears() {
        let tempdir = tempdir().unwrap();
        let dependency = tempdir.path().join("plugins/git.plugin.zsh");
        fs::create_dir_all(dependency.parent().unwrap()).unwrap();
        fs::write(&dependency, "plugin_loaded=1\n").unwrap();

        let cache_data =
            CheckCacheData::from_displayed(&[], false, std::slice::from_ref(&dependency), &[]);
        assert!(cache_data.dependencies_match());

        fs::remove_file(&dependency).unwrap();

        assert!(!cache_data.dependencies_match());
    }

    #[cfg(unix)]
    #[test]
    fn cache_detects_when_a_dependency_symlink_retargets() {
        use std::os::unix::fs::symlink;

        let tempdir = tempdir().unwrap();
        let targets = tempdir.path().join("targets");
        fs::create_dir_all(&targets).unwrap();
        let first_target = targets.join("plugin-v1.zsh");
        let second_target = targets.join("plugin-v2.zsh");
        fs::write(&first_target, "plugin_loaded=v1\n").unwrap();
        fs::write(&second_target, "plugin_loaded=v2\n").unwrap();

        let symlink_path = tempdir.path().join("plugins/current.plugin.zsh");
        fs::create_dir_all(symlink_path.parent().unwrap()).unwrap();
        symlink(&first_target, &symlink_path).unwrap();

        let cache_data =
            CheckCacheData::from_displayed(&[], false, std::slice::from_ref(&symlink_path), &[]);
        assert_eq!(cache_data.dependency_paths(), vec![symlink_path.clone()]);
        assert!(cache_data.dependencies_match());

        fs::remove_file(&symlink_path).unwrap();
        symlink(&second_target, &symlink_path).unwrap();

        assert!(!cache_data.dependencies_match());
    }

    #[test]
    fn cache_key_includes_analyzed_path_set() {
        let tempdir = tempdir().unwrap();
        fs::write(
            tempdir.path().join("main.sh"),
            "#!/bin/sh\n. ./helper.sh\nprintf '%s\\n' \"$from_helper\"\n",
        )
        .unwrap();
        fs::write(
            tempdir.path().join("helper.sh"),
            "#!/bin/sh\nfrom_helper=ok\n",
        )
        .unwrap();

        let mut narrow_args = check_args(false);
        narrow_args.paths = vec![PathBuf::from("main.sh")];
        narrow_args.rule_selection.select =
            Some(vec![RuleSelector::Rule(Rule::UntrackedSourceFile)]);

        let mut broad_args = narrow_args.clone();
        broad_args.paths = vec![PathBuf::from("main.sh"), PathBuf::from("helper.sh")];

        let narrow = run_check_with_cwd(
            &narrow_args,
            &ConfigArguments::default(),
            tempdir.path(),
            &cache_root(tempdir.path()),
        )
        .unwrap();
        assert_eq!(narrow.cache_hits, 0);
        assert_eq!(narrow.cache_misses, 1);
        assert_eq!(diagnostic_codes(&narrow), vec!["C003"]);

        let broad = run_check_with_cwd(
            &broad_args,
            &ConfigArguments::default(),
            tempdir.path(),
            &cache_root(tempdir.path()),
        )
        .unwrap();
        assert_eq!(broad.cache_hits, 0);
        assert_eq!(broad.cache_misses, 2);
        assert!(
            broad.diagnostics.is_empty(),
            "{:?}",
            diagnostic_codes(&broad)
        );

        let broad_again = run_check_with_cwd(
            &broad_args,
            &ConfigArguments::default(),
            tempdir.path(),
            &cache_root(tempdir.path()),
        )
        .unwrap();
        assert_eq!(broad_again.cache_hits, 2);
        assert_eq!(broad_again.cache_misses, 0);
        assert!(
            broad_again.diagnostics.is_empty(),
            "{:?}",
            diagnostic_codes(&broad_again)
        );
    }

    #[test]
    fn cache_key_includes_home_for_home_relative_source_resolution() {
        let tempdir = tempdir().unwrap();
        let script = tempdir.path().join(".zshrc");
        fs::write(
            &script,
            "#!/bin/zsh\nsource \"$HOME/.helpers/prompt.zsh\"\n",
        )
        .unwrap();

        let mut args = check_args(false);
        args.paths = vec![PathBuf::from(".zshrc")];
        args.rule_selection.per_file_shell = Some(vec![PatternShellPair {
            pattern: ".zshrc".to_owned(),
            shell: ShellDialect::Zsh,
        }]);

        let project_root = ProjectRoot {
            storage_root: tempdir.path().to_path_buf(),
            canonical_root: fs::canonicalize(tempdir.path()).unwrap(),
        };
        let settings = resolve_project_check_settings(
            &project_root,
            &ConfigArguments::default(),
            &args.rule_selection,
            &args.zsh_plugin_resolution,
        )
        .unwrap();
        let files = [DiscoveredFile {
            display_path: PathBuf::from(".zshrc"),
            absolute_path: script.clone(),
            relative_path: PathBuf::from(".zshrc"),
            project_root,
            kind: FileKind::Shell,
        }];
        let base = CheckCacheSettings::new(&settings, &files);

        let first = CheckCacheSettings {
            source_resolution_home: Some(PathBuf::from("/tmp/home-a")),
            ..base.clone()
        };
        let second = CheckCacheSettings {
            source_resolution_home: Some(PathBuf::from("/tmp/home-b")),
            ..base
        };

        assert_ne!(cache_key_hex(&first), cache_key_hex(&second));
    }

    #[test]
    fn invalidates_cache_when_file_changes() {
        let tempdir = tempdir().unwrap();
        let script = tempdir.path().join("script.sh");
        fs::write(&script, "#!/bin/bash\necho ok\n").unwrap();

        let first = run_check_with_cwd(
            &check_args(false),
            &ConfigArguments::default(),
            tempdir.path(),
            &cache_root(tempdir.path()),
        )
        .unwrap();
        assert_eq!(first.cache_hits, 0);
        assert_eq!(first.cache_misses, 1);

        fs::write(&script, "#!/bin/bash\nif true\n").unwrap();
        make_file_read_only(&script);

        let second = run_check_with_cwd(
            &check_args(false),
            &ConfigArguments::default(),
            tempdir.path(),
            &cache_root(tempdir.path()),
        )
        .unwrap();
        assert_eq!(second.cache_hits, 0);
        assert_eq!(second.cache_misses, 1);
        assert_eq!(second.diagnostics.len(), 1);
    }

    #[test]
    fn invalidates_cache_when_rule_options_change() {
        let tempdir = tempdir().unwrap();
        let script = tempdir.path().join("script.sh");
        fs::write(
            &script,
            "#!/bin/bash\ntarget=ok\nname=target\nprintf '%s\\n' \"${!name}\"\n",
        )
        .unwrap();
        fs::write(
            tempdir.path().join("shuck.toml"),
            "[lint]\nselect = ['C001']\n",
        )
        .unwrap();

        let first = run_check_with_cwd(
            &check_args(false),
            &ConfigArguments::default(),
            tempdir.path(),
            &cache_root(tempdir.path()),
        )
        .unwrap();
        assert_eq!(first.cache_hits, 0);
        assert_eq!(first.cache_misses, 1);
        assert_eq!(first.diagnostics.len(), 1);
        assert!(first.diagnostics[0].message.contains("target"));

        fs::write(
            tempdir.path().join("shuck.toml"),
            "[lint]\nselect = ['C001']\n\n[lint.rule-options.c001]\ntreat-indirect-expansion-targets-as-used = true\n",
        )
        .unwrap();

        let second = run_check_with_cwd(
            &check_args(false),
            &ConfigArguments::default(),
            tempdir.path(),
            &cache_root(tempdir.path()),
        )
        .unwrap();
        assert_eq!(second.cache_hits, 0);
        assert_eq!(second.cache_misses, 1);
        assert!(second.diagnostics.is_empty());
    }

    #[test]
    fn invalidates_cache_when_c063_rule_options_change() {
        let tempdir = tempdir().unwrap();
        let script = tempdir.path().join("script.sh");
        fs::write(&script, "#!/bin/bash\nouter() {\n  inner() { :; }\n}\n").unwrap();
        fs::write(
            tempdir.path().join("shuck.toml"),
            "[lint]\nselect = ['C063']\n",
        )
        .unwrap();

        let first = run_check_with_cwd(
            &check_args(false),
            &ConfigArguments::default(),
            tempdir.path(),
            &cache_root(tempdir.path()),
        )
        .unwrap();
        assert_eq!(first.cache_hits, 0);
        assert_eq!(first.cache_misses, 1);
        assert!(first.diagnostics.is_empty());

        fs::write(
            tempdir.path().join("shuck.toml"),
            "[lint]\nselect = ['C063']\n\n[lint.rule-options.c063]\nreport-unreached-nested-definitions = true\n",
        )
        .unwrap();

        let second = run_check_with_cwd(
            &check_args(false),
            &ConfigArguments::default(),
            tempdir.path(),
            &cache_root(tempdir.path()),
        )
        .unwrap();
        assert_eq!(second.cache_hits, 0);
        assert_eq!(second.cache_misses, 1);
        assert_eq!(second.diagnostics.len(), 1);
    }

    #[test]
    fn invalidates_cache_when_s085_rule_options_change() {
        let tempdir = tempdir().unwrap();
        let script = tempdir.path().join("script.sh");
        fs::write(
            &script,
            "#!/bin/bash\nsetup() { :; }\nrun() { :; }\nsetup\nrun\n",
        )
        .unwrap();
        fs::write(
            tempdir.path().join("shuck.toml"),
            "[lint]\nselect = ['S085']\n",
        )
        .unwrap();

        let first = run_check_with_cwd(
            &check_args(false),
            &ConfigArguments::default(),
            tempdir.path(),
            &cache_root(tempdir.path()),
        )
        .unwrap();
        assert_eq!(first.cache_hits, 0);
        assert_eq!(first.cache_misses, 1);
        assert!(first.diagnostics.is_empty());

        fs::write(
            tempdir.path().join("shuck.toml"),
            "[lint]\nselect = ['S085']\n\n[lint.rule-options.s085]\nnon-trivial-line-threshold = 1\n",
        )
        .unwrap();

        let second = run_check_with_cwd(
            &check_args(false),
            &ConfigArguments::default(),
            tempdir.path(),
            &cache_root(tempdir.path()),
        )
        .unwrap();
        assert_eq!(second.cache_hits, 0);
        assert_eq!(second.cache_misses, 1);
        assert_eq!(second.diagnostics.len(), 1);
    }

    #[test]
    fn invalidates_cache_when_resolved_zsh_plugin_entrypoint_changes() {
        let tempdir = tempdir().unwrap();
        let omz_root = tempdir.path().join("oh-my-zsh");
        let plugin_dir = omz_root.join("plugins/git");
        fs::create_dir_all(&plugin_dir).unwrap();
        fs::write(omz_root.join("oh-my-zsh.sh"), "# bootstrap\n").unwrap();
        let plugin = plugin_dir.join("git.plugin.zsh");
        fs::write(&plugin, "git_prompt() { :; }\n").unwrap();
        fs::write(
            tempdir.path().join(".zshrc"),
            format!(
                "ZSH='{}'\nplugins=(git)\nsource \"$ZSH/oh-my-zsh.sh\"\n",
                omz_root.display()
            ),
        )
        .unwrap();

        let mut args = check_args(false);
        args.paths = vec![PathBuf::from(".zshrc")];
        args.rule_selection.per_file_shell = Some(vec![PatternShellPair {
            pattern: ".zshrc".to_owned(),
            shell: ShellDialect::Zsh,
        }]);

        let first = run_check_with_cwd(
            &args,
            &ConfigArguments::default(),
            tempdir.path(),
            &cache_root(tempdir.path()),
        )
        .unwrap();
        let second = run_check_with_cwd(
            &args,
            &ConfigArguments::default(),
            tempdir.path(),
            &cache_root(tempdir.path()),
        )
        .unwrap();
        assert_eq!(first.cache_hits, 0);
        assert_eq!(first.cache_misses, 1);
        assert_eq!(second.cache_hits, 1);
        assert_eq!(second.cache_misses, 0);

        fs::write(&plugin, "git_prompt() { echo changed; }\n").unwrap();

        let third = run_check_with_cwd(
            &args,
            &ConfigArguments::default(),
            tempdir.path(),
            &cache_root(tempdir.path()),
        )
        .unwrap();
        assert_eq!(third.cache_hits, 0);
        assert_eq!(third.cache_misses, 1);
    }

    #[test]
    fn invalidates_cache_when_configured_zsh_plugin_entrypoint_appears() {
        let tempdir = tempdir().unwrap();
        fs::write(tempdir.path().join(".zshrc"), "echo ok\n").unwrap();
        fs::write(
            tempdir.path().join("shuck.toml"),
            "[lint.zsh.plugins]\nentrypoints = [{ pattern = '.zshrc', paths = ['./vendor/prompt.plugin.zsh'] }]\n",
        )
        .unwrap();

        let mut args = check_args(false);
        args.paths = vec![PathBuf::from(".zshrc")];
        args.rule_selection.per_file_shell = Some(vec![PatternShellPair {
            pattern: ".zshrc".to_owned(),
            shell: ShellDialect::Zsh,
        }]);

        let first = run_check_with_cwd(
            &args,
            &ConfigArguments::default(),
            tempdir.path(),
            &cache_root(tempdir.path()),
        )
        .unwrap();
        let second = run_check_with_cwd(
            &args,
            &ConfigArguments::default(),
            tempdir.path(),
            &cache_root(tempdir.path()),
        )
        .unwrap();
        assert_eq!(first.cache_hits, 0);
        assert_eq!(first.cache_misses, 1);
        assert_eq!(second.cache_hits, 1);
        assert_eq!(second.cache_misses, 0);

        let plugin = tempdir.path().join("vendor/prompt.plugin.zsh");
        fs::create_dir_all(plugin.parent().unwrap()).unwrap();
        fs::write(&plugin, "prompt_fn() { :; }\n").unwrap();

        let third = run_check_with_cwd(
            &args,
            &ConfigArguments::default(),
            tempdir.path(),
            &cache_root(tempdir.path()),
        )
        .unwrap();
        assert_eq!(third.cache_hits, 0);
        assert_eq!(third.cache_misses, 1);
    }

    #[test]
    fn configured_zsh_plugin_loads_track_dependencies_for_dynamic_plugin_lists() {
        let tempdir = tempdir().unwrap();
        let omz_root = tempdir.path().join("oh-my-zsh");
        let plugin_dir = omz_root.join("plugins/git");
        fs::create_dir_all(&plugin_dir).unwrap();
        fs::write(omz_root.join("oh-my-zsh.sh"), "# bootstrap\n").unwrap();
        let plugin = plugin_dir.join("git.plugin.zsh");
        fs::write(&plugin, "git_prompt() { :; }\n").unwrap();
        fs::write(
            tempdir.path().join(".zshrc"),
            "plugin_name=git\nplugins=($plugin_name)\nsource \"$ZSH/oh-my-zsh.sh\"\n",
        )
        .unwrap();
        fs::write(
            tempdir.path().join("shuck.toml"),
            format!(
                "[lint.zsh.plugins.roots]\noh-my-zsh = '{}'\n\n[[lint.zsh.plugins.plugin-loads]]\npattern = '.zshrc'\nframework = 'oh-my-zsh'\nname = 'git'\n",
                omz_root.display()
            ),
        )
        .unwrap();

        let mut args = check_args(false);
        args.paths = vec![PathBuf::from(".zshrc")];
        args.rule_selection.per_file_shell = Some(vec![PatternShellPair {
            pattern: ".zshrc".to_owned(),
            shell: ShellDialect::Zsh,
        }]);

        let first = run_check_with_cwd(
            &args,
            &ConfigArguments::default(),
            tempdir.path(),
            &cache_root(tempdir.path()),
        )
        .unwrap();
        let second = run_check_with_cwd(
            &args,
            &ConfigArguments::default(),
            tempdir.path(),
            &cache_root(tempdir.path()),
        )
        .unwrap();
        assert_eq!(first.cache_hits, 0);
        assert_eq!(second.cache_hits, 1);

        fs::write(&plugin, "git_prompt() { echo configured; }\n").unwrap();

        let third = run_check_with_cwd(
            &args,
            &ConfigArguments::default(),
            tempdir.path(),
            &cache_root(tempdir.path()),
        )
        .unwrap();
        assert_eq!(third.cache_hits, 0);
        assert_eq!(third.cache_misses, 1);
    }

    #[test]
    fn mixes_cache_hits_and_misses_in_a_single_run() {
        let tempdir = tempdir().unwrap();
        let first = tempdir.path().join("first.sh");
        let second = tempdir.path().join("second.sh");
        fs::write(&first, "#!/bin/bash\necho ok\n").unwrap();
        fs::write(&second, "#!/bin/bash\necho ok\n").unwrap();

        let cache_root = cache_root(tempdir.path());
        let initial = run_check_with_cwd(
            &check_args(false),
            &ConfigArguments::default(),
            tempdir.path(),
            &cache_root,
        )
        .unwrap();
        assert_eq!(initial.cache_hits, 0);
        assert_eq!(initial.cache_misses, 2);

        fs::write(&second, "#!/bin/bash\nif true\n").unwrap();

        let rerun = run_check_with_cwd(
            &check_args(false),
            &ConfigArguments::default(),
            tempdir.path(),
            &cache_root,
        )
        .unwrap();
        assert_eq!(rerun.cache_hits, 1);
        assert_eq!(rerun.cache_misses, 1);
        assert_eq!(rerun.diagnostics.len(), 1);
        assert_eq!(rerun.diagnostics[0].path, PathBuf::from("second.sh"));
    }

    #[test]
    fn cached_diagnostics_retain_source_for_full_output() {
        let tempdir = tempdir().unwrap();
        fs::write(
            tempdir.path().join("warn.sh"),
            "#!/bin/bash\nunused=1\necho ok\n",
        )
        .unwrap();

        let first = run_check_with_cwd(
            &check_args_with_format(false, CheckOutputFormatArg::Full),
            &ConfigArguments::default(),
            tempdir.path(),
            &cache_root(tempdir.path()),
        )
        .unwrap();
        let second = run_check_with_cwd(
            &check_args_with_format(false, CheckOutputFormatArg::Full),
            &ConfigArguments::default(),
            tempdir.path(),
            &cache_root(tempdir.path()),
        )
        .unwrap();

        assert_eq!(first.cache_misses, 1);
        assert_eq!(second.cache_hits, 1);
        assert_eq!(second.diagnostics.len(), 1);
        assert_eq!(
            second.diagnostics[0].source.as_deref(),
            Some("#!/bin/bash\nunused=1\necho ok\n")
        );
    }
}
