use std::collections::HashSet;
use std::path::{Path, PathBuf};

use anyhow::Result;
use rayon::prelude::*;
use shuck_cache::FileCacheKey;
use shuck_config::ConfigArguments;
use shuck_linter::{Applicability, LinterSettings, RuleSelector, ShellCheckCodeMap};

use super::CheckReport;
use super::analyze::{FileCheckResult, analyze_file, discover_followed_paths, read_shared_source};
use super::cache::{CheckCacheData, CheckCacheSettings, push_cached_diagnostics};
use super::settings::{ResolvedCheckSettings, resolve_project_check_settings};
use super::source_resolver::NativeSourceResolver;
use crate::args::{CheckCommand, FileSelectionArgs, RuleSelectionArgs};
use crate::commands::project_runner::{
    PendingProjectFile, ProjectRunRequest, prepare_project_runs_with_cache_key,
};
use crate::discover::{DiscoveredFile, DiscoveryOptions, FileKind};

pub(super) fn run_check_with_cwd(
    args: &CheckCommand,
    config_arguments: &ConfigArguments,
    cwd: &Path,
    cache_root: &Path,
) -> Result<CheckReport> {
    let include_source = matches!(args.output_format, crate::args::CheckOutputFormatArg::Full);
    let fix_applicability = requested_fix_applicability(args);
    let mut runs = prepare_project_runs_with_cache_key::<
        CheckCacheData,
        ResolvedCheckSettings,
        CheckCacheSettings,
        _,
        _,
    >(
        ProjectRunRequest {
            inputs: &args.paths,
            cwd,
            discovery_options: &DiscoveryOptions {
                exclude_patterns: args.file_selection.exclude.clone(),
                extend_exclude_patterns: args.file_selection.extend_exclude.clone(),
                respect_gitignore: args.respect_gitignore(),
                force_exclude: args.force_exclude(),
                parallel: true,
                cache_root: Some(cache_root.to_path_buf()),
                use_config_roots: config_arguments.use_config_roots(),
                ..DiscoveryOptions::default()
            },
            cache_root,
            no_cache: args.no_cache || fix_applicability.is_some(),
            cache_tag: b"project-cache-key",
        },
        |project_root| {
            resolve_project_check_settings(
                project_root,
                config_arguments,
                &args.rule_selection,
                &args.zsh_plugin_resolution,
            )
        },
        |_, files, settings| Ok(CheckCacheSettings::new(settings, files)),
    )?;
    let shellcheck_map = ShellCheckCodeMap::default();

    let mut report = CheckReport::default();

    for run in &mut runs {
        if !run.settings.embedded_enabled {
            run.files.retain(|file| file.kind == FileKind::Shell);
        }
    }

    // Paths already linted (directly discovered inputs, plus lint=true directive
    // targets as they are processed), so no file is linted twice.
    let mut linted: HashSet<PathBuf> = HashSet::new();
    for run in &runs {
        linted.extend(
            run.files
                .iter()
                .filter(|file| file.kind == FileKind::Shell)
                .map(|file| {
                    crate::discover::normalize_path(&file.absolute_path)
                        .canonicalize()
                        .unwrap_or_else(|_| file.absolute_path.clone())
                }),
        );
    }

    for mut run in runs {
        let project_settings = run.settings.clone();
        let direct_input_paths = run
            .files
            .iter()
            .filter(|file| file.kind == FileKind::Shell)
            .map(|file| file.absolute_path.clone())
            .collect::<Vec<_>>();
        let analyzed_paths = LinterSettings::analyzed_path_set(direct_input_paths.iter().cloned());
        let linter_settings = project_settings
            .linter_settings
            .clone()
            .with_analyzed_path_set(analyzed_paths);
        // Relative `[lint] source-paths` resolve against the project root (where
        // the config lives), falling back to the process cwd.
        let source_root_base = run
            .files
            .first()
            .map(|file| file.project_root.canonical_root.clone())
            .unwrap_or_else(|| cwd.to_path_buf());
        let source_resolver =
            NativeSourceResolver::new(source_root_base, project_settings.source_paths.clone());
        // Extra search roots for the closure; when none are configured the
        // closure's own base-directory resolution already covers relative hints.
        let closure_resolver: Option<&(dyn shuck_semantic::SourcePathResolver + Send + Sync)> =
            source_resolver.has_roots().then_some(&source_resolver);
        let mut follow_worklist: Vec<(PathBuf, DiscoveredFile)> = Vec::new();
        let pending = run.take_pending_files_with_validator(
            |_, cached| Ok(cached.dependencies_match()),
            |file, cached| {
                report.cache_hits += 1;
                report.parse_failed |= cached.parse_failed;
                report.dependency_paths.extend(cached.dependency_paths());
                // Followed targets are linted per run (their diagnostics are
                // not part of this entry), so a hit must still enqueue them.
                for followed in &cached.followed_paths {
                    follow_worklist.push((followed.clone(), file.clone()));
                }
                let source = (include_source && !cached.diagnostics.is_empty())
                    .then(|| read_shared_source(&file.absolute_path))
                    .transpose()?;
                push_cached_diagnostics(
                    &mut report,
                    &file.display_path,
                    &file.relative_path,
                    &file.absolute_path,
                    &cached.diagnostics,
                    source,
                );
                Ok(())
            },
        )?;

        let results = pending
            .into_par_iter()
            .map(|pending| {
                analyze_file(
                    pending,
                    &linter_settings,
                    &project_settings.per_file_shell,
                    Some(project_settings.zsh_plugins.as_ref()),
                    closure_resolver,
                    Some(&source_resolver),
                    project_settings.lint_sources,
                    &shellcheck_map,
                    include_source,
                    fix_applicability,
                    &project_settings.fixable_rules,
                )
            })
            .collect::<Vec<_>>();

        for result in results {
            let result = result?;
            report.fixes_applied += result.fixes_applied;
            report.parse_failed |= result.parse_failed;
            report.diagnostics.extend(result.diagnostics);
            report.dependency_paths.extend(result.dependency_paths);
            for followed in &result.followed_paths {
                follow_worklist.push((followed.clone(), result.file.clone()));
            }
            if let Some(cache) = run.cache.as_mut() {
                cache.insert(
                    result.file.relative_path.clone(),
                    result.file_key,
                    result.cache_data,
                );
            }
            report.cache_misses += 1;
        }

        // Discover the complete transitive target set before linting any
        // followed file. Every followed file then receives the same analyzed
        // path set, just like a batch of directly checked inputs.
        let followed_files = discover_followed_file_closure(
            follow_worklist,
            &linted,
            &project_settings,
            &source_resolver,
        )?;
        let followed_settings = project_settings
            .linter_settings
            .clone()
            .with_analyzed_path_set(LinterSettings::analyzed_path_set(
                direct_input_paths
                    .iter()
                    .cloned()
                    .chain(followed_files.iter().map(|file| file.absolute_path.clone())),
            ));

        for file in followed_files {
            if !linted.insert(file.absolute_path.clone()) {
                continue;
            }
            let Some(followed_result) = analyze_followed_file(
                file,
                &followed_settings,
                &project_settings,
                closure_resolver,
                &source_resolver,
                &shellcheck_map,
                include_source,
            )?
            else {
                continue;
            };
            report.parse_failed |= followed_result.parse_failed;
            report.diagnostics.extend(followed_result.diagnostics);
            report
                .dependency_paths
                .extend(followed_result.dependency_paths);
        }

        run.persist_cache()?;
    }

    report.diagnostics.sort_by(|left, right| {
        left.path
            .cmp(&right.path)
            .then(left.span.start.line.cmp(&right.span.start.line))
            .then(left.span.start.column.cmp(&right.span.start.column))
            .then(left.message.cmp(&right.message))
    });
    report.dependency_paths.sort();
    report.dependency_paths.dedup();

    Ok(report)
}

/// Expands an initial set of `lint=true` edges into the complete cycle-safe
/// closure, preserving the project-root context of the referring file.
fn discover_followed_file_closure(
    mut worklist: Vec<(PathBuf, DiscoveredFile)>,
    already_linted: &HashSet<PathBuf>,
    project_settings: &ResolvedCheckSettings,
    source_resolver: &NativeSourceResolver,
) -> Result<Vec<DiscoveredFile>> {
    let mut seen = HashSet::new();
    let mut files = Vec::new();
    while let Some((path, referrer)) = worklist.pop() {
        let Ok(canonical) = crate::discover::normalize_path(&path).canonicalize() else {
            continue;
        };
        if already_linted.contains(&canonical) || !seen.insert(canonical.clone()) {
            continue;
        }
        let file = followed_discovered_file(&canonical, &referrer);
        for followed in discover_followed_paths(
            &canonical,
            &project_settings.per_file_shell,
            source_resolver,
        )? {
            worklist.push((followed, file.clone()));
        }
        files.push(file);
    }
    Ok(files)
}

fn followed_discovered_file(canonical: &Path, referrer: &DiscoveredFile) -> DiscoveredFile {
    let relative_path = canonical
        .strip_prefix(&referrer.project_root.canonical_root)
        .map(Path::to_path_buf)
        .unwrap_or_else(|_| canonical.to_path_buf());
    DiscoveredFile {
        display_path: relative_path.clone(),
        absolute_path: canonical.to_path_buf(),
        relative_path,
        project_root: referrer.project_root.clone(),
        kind: FileKind::Shell,
    }
}

/// Lints a `lint=true` directive target as an additional input, reusing the referring
/// run's settings. Never applies fixes to followed files. Returns `None` when
/// the target's metadata cannot be read.
#[allow(clippy::too_many_arguments)]
fn analyze_followed_file(
    file: DiscoveredFile,
    linter_settings: &LinterSettings,
    project_settings: &ResolvedCheckSettings,
    closure_resolver: Option<&(dyn shuck_semantic::SourcePathResolver + Send + Sync)>,
    source_resolver: &NativeSourceResolver,
    shellcheck_map: &ShellCheckCodeMap,
    include_source: bool,
) -> Result<Option<FileCheckResult>> {
    let Ok(file_key) = FileCacheKey::from_path(&file.absolute_path) else {
        return Ok(None);
    };
    let pending = PendingProjectFile { file, file_key };
    let result = analyze_file(
        pending,
        linter_settings,
        &project_settings.per_file_shell,
        Some(project_settings.zsh_plugins.as_ref()),
        closure_resolver,
        Some(source_resolver),
        project_settings.lint_sources,
        shellcheck_map,
        include_source,
        // Never rewrite followed files: they are pulled in transitively, not
        // requested by the user on the command line.
        None,
        &project_settings.fixable_rules,
    )?;
    Ok(Some(result))
}

pub(crate) fn benchmark_check_paths(
    cwd: &Path,
    paths: &[PathBuf],
    output_format: crate::args::CheckOutputFormatArg,
) -> Result<usize> {
    let report = run_check_with_cwd(
        &CheckCommand {
            fix: false,
            unsafe_fixes: false,
            add_ignore: None,
            no_cache: true,
            output_format,
            watch: false,
            paths: paths.to_vec(),
            stdin_filename: None,
            rule_selection: RuleSelectionArgs {
                select: Some(vec![RuleSelector::All]),
                ..RuleSelectionArgs::default()
            },
            zsh_plugin_resolution: Default::default(),
            file_selection: FileSelectionArgs::default(),
            exit_zero: false,
            exit_non_zero_on_fix: false,
        },
        &ConfigArguments::default(),
        cwd,
        &cwd.join("cache"),
    )?;

    Ok(report.diagnostics.len())
}
fn requested_fix_applicability(args: &CheckCommand) -> Option<Applicability> {
    if args.unsafe_fixes {
        Some(Applicability::Unsafe)
    } else if args.fix {
        Some(Applicability::Safe)
    } else {
        None
    }
}

#[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,
    };
    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::{FileKind, normalize_path};
    use shuck_config::ConfigArguments;

    #[test]
    fn parse_failure_with_warning_lint_stays_fatal_under_exit_zero() {
        let tempdir = tempdir().unwrap();
        fs::write(
            tempdir.path().join("broken.sh"),
            "#!/bin/sh\nif true\n  echo hi\nfi\n",
        )
        .unwrap();

        let mut args = check_args(false);
        args.exit_zero = true;

        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.exit_status(true, false), ExitStatus::Failure);
        assert_eq!(second.exit_status(true, false), ExitStatus::Failure);
        assert_eq!(first.cache_misses, 1);
        assert_eq!(second.cache_hits, 1);
        assert_eq!(diagnostic_codes(&first), vec!["C064".to_owned()]);
        assert_eq!(diagnostic_codes(&second), vec!["C064".to_owned()]);
        assert!(first.parse_failed);
        assert!(second.parse_failed);
    }

    #[test]
    fn unfixable_rules_prevent_fix_application() {
        let tempdir = tempdir().unwrap();
        let script = tempdir.path().join("warn.sh");
        let source = "#!/bin/bash\nprintf '%s\\n' x &;\n";
        fs::write(&script, source).unwrap();

        let mut args = check_args(true);
        args.fix = true;
        args.rule_selection = RuleSelectionArgs {
            extend_select: vec![RuleSelector::Rule(Rule::AmpersandSemicolon)],
            unfixable: vec![RuleSelector::Rule(Rule::AmpersandSemicolon)],
            ..RuleSelectionArgs::default()
        };

        let report = run_check_with_cwd(
            &args,
            &ConfigArguments::default(),
            tempdir.path(),
            &cache_root(tempdir.path()),
        )
        .unwrap();

        assert_eq!(report.fixes_applied, 0);
        assert_eq!(fs::read_to_string(script).unwrap(), source);
        assert_eq!(
            diagnostic_codes(&report),
            vec![Rule::AmpersandSemicolon.code().to_owned()]
        );
    }

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

        let report = run_check_with_cwd(
            &check_args(true),
            &ConfigArguments::default(),
            tempdir.path(),
            &cache_root(tempdir.path()),
        )
        .unwrap();

        assert_eq!(report.cache_hits, 0);
        assert_eq!(report.cache_misses, 1);
        assert!(!tempdir.path().join(".shuck_cache").exists());
    }

    #[test]
    fn sorts_diagnostics_deterministically_after_parallel_run() {
        let tempdir = tempdir().unwrap();
        fs::write(tempdir.path().join("z.sh"), "#!/bin/bash\nif true\n").unwrap();
        fs::write(tempdir.path().join("a.bash"), "local foo=bar\n").unwrap();

        let report = run_check_with_cwd(
            &check_args(true),
            &ConfigArguments::default(),
            tempdir.path(),
            &cache_root(tempdir.path()),
        )
        .unwrap();
        let paths = report
            .diagnostics
            .iter()
            .map(|diagnostic| diagnostic.path.clone())
            .collect::<Vec<_>>();

        let mut sorted_paths = paths.clone();
        sorted_paths.sort();
        assert_eq!(paths, sorted_paths);
        assert_eq!(paths.first(), Some(&PathBuf::from("a.bash")));
        assert_eq!(paths.last(), Some(&PathBuf::from("z.sh")));
    }

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

        let args = CheckCommand {
            paths: vec![PathBuf::from("."), PathBuf::from("dup.sh")],
            ..check_args(true)
        };
        let report = run_check_with_cwd(
            &args,
            &ConfigArguments::default(),
            tempdir.path(),
            &cache_root(tempdir.path()),
        )
        .unwrap();

        assert_eq!(report.cache_hits, 0);
        assert_eq!(report.cache_misses, 1);
        assert_eq!(report.diagnostics.len(), 1);
    }

    #[test]
    fn skips_a_configured_cache_directory_inside_the_walked_tree() {
        let tempdir = tempdir().unwrap();
        let cache_root = tempdir.path().join("custom-cache");
        fs::create_dir_all(&cache_root).unwrap();
        fs::write(tempdir.path().join("ok.sh"), "#!/bin/bash\necho ok\n").unwrap();
        fs::write(cache_root.join("broken.sh"), "#!/bin/bash\nif true\n").unwrap();

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

        assert!(report.diagnostics.is_empty());
        assert!(!tempdir.path().join(".shuck_cache").exists());
    }
}
