use shuck_ast::Span;

use super::pattern_policy;
use crate::{Checker, Edit, ExpansionContext, Fix, FixAvailability, Rule, ShellDialect, Violation};

pub struct PatternWithVariable;

impl Violation for PatternWithVariable {
    const FIX_AVAILABILITY: FixAvailability = FixAvailability::Always;

    fn rule() -> Rule {
        Rule::PatternWithVariable
    }

    fn message(&self) -> String {
        "pattern expressions should not expand variables".to_owned()
    }

    fn fix_title(&self) -> Option<String> {
        Some("quote the expansion in the pattern".to_owned())
    }
}

pub fn pattern_with_variable(checker: &mut Checker) {
    let source = checker.source();
    let shell = checker.shell();
    let replacement_expansion_spans = checker
        .facts()
        .words()
        .replacement_expansion_fragments()
        .iter()
        .map(|fragment| fragment.span())
        .collect::<Vec<_>>();
    let special_target_operand_spans = checker
        .facts()
        .words()
        .parameter_pattern_special_target_fragments()
        .iter()
        .map(|fragment| fragment.span())
        .collect::<Vec<_>>();

    let diagnostics = checker
        .facts()
        .words()
        .expansion_word_facts(ExpansionContext::ParameterPattern)
        .flat_map(|fact| {
            if span_is_within_any(fact.span(), &replacement_expansion_spans)
                || special_target_operand_spans.contains(&fact.span())
            {
                return Vec::new();
            }

            let quoted_expansion_spans = fact.double_quoted_expansion_spans();
            fact.active_expansion_spans()
                .iter()
                .copied()
                .filter(|span| {
                    shell != ShellDialect::Zsh
                        || !span_is_zsh_numeric_glob_tail_group(*span, source)
                })
                .filter(|span| !span_is_within_any(*span, quoted_expansion_spans))
                .filter(|span| !fact.expansion_span_is_zsh_force_glob_parameter(*span))
                .filter(|span| {
                    !pattern_policy::span_expands_only_static_pattern_safe_literals(
                        checker,
                        *span,
                        fact.expansion_span_is_plain_parameter_reference(*span),
                        fact.expansion_span_is_zsh_presence_test(*span),
                    )
                })
                .map(|span| {
                    crate::Diagnostic::new(PatternWithVariable, span).with_fix(Fix::unsafe_edit(
                        Edit::replacement(format!("\"{}\"", span.slice(source)), span),
                    ))
                })
                .collect::<Vec<_>>()
        })
        .collect::<Vec<_>>();

    for diagnostic in diagnostics {
        checker.report_diagnostic_dedup(diagnostic);
    }
}

fn span_is_within_any(span: Span, hosts: &[Span]) -> bool {
    hosts.iter().any(|host| {
        host.start.offset() <= span.start.offset() && span.end.offset() <= host.end.offset()
    })
}

fn span_is_zsh_numeric_glob_tail_group(span: Span, source: &str) -> bool {
    if !span.slice(source).starts_with(">(") {
        return false;
    }

    let Some(prefix) = source.get(..span.start.offset()) else {
        return false;
    };

    let mut saw_numeric_glob_body = false;
    for ch in prefix.chars().rev() {
        if ch == '<' {
            return saw_numeric_glob_body;
        }
        if ch == '-' || ch.is_ascii_digit() {
            saw_numeric_glob_body = true;
            continue;
        }
        break;
    }

    false
}

#[cfg(test)]
mod tests {
    use crate::test::test_snippet;
    use crate::test::{test_path_with_fix, test_snippet_with_fix};
    use crate::{Applicability, LinterSettings, Rule, assert_diagnostics_diff};

    #[test]
    fn reports_nested_parameter_pattern_groups_and_substitutions() {
        let source = "\
#!/bin/bash
suffix=bc
trimmed=${name%@($suffix|$(printf '%s' zz))}
";
        let diagnostics =
            test_snippet(source, &LinterSettings::for_rule(Rule::PatternWithVariable));

        assert_eq!(
            diagnostics
                .iter()
                .map(|diagnostic| diagnostic.span.slice(source))
                .collect::<Vec<_>>(),
            vec!["$suffix", "$(printf '%s' zz)"]
        );
    }

    #[test]
    fn ignores_quoted_and_replacement_parameter_patterns() {
        let source = "\
#!/bin/bash
trimmed_one=${name%$trimmed_one_suffix}
trimmed_two=${name##foo$trimmed_two_suffix}
quoted=${name#\"$quoted_suffix\"}
replaced_one=${name/$replaced_one_suffix/x}
replaced_two=${name/foo$replaced_two_suffix/x}
";
        let diagnostics =
            test_snippet(source, &LinterSettings::for_rule(Rule::PatternWithVariable));

        assert_eq!(
            diagnostics
                .iter()
                .map(|diagnostic| diagnostic.span.slice(source))
                .collect::<Vec<_>>(),
            vec!["$trimmed_one_suffix", "$trimmed_two_suffix"]
        );
    }

    #[test]
    fn ignores_zero_and_array_trim_targets() {
        let source = "\
#!/bin/bash
trimmed=${name%$trimmed_suffix}
script_name=${0##*/}
all_trimmed=(\"${items[@]#$array_prefix/}\")
one_trimmed=${items[i]%$item_suffix}
";
        let diagnostics =
            test_snippet(source, &LinterSettings::for_rule(Rule::PatternWithVariable));

        assert_eq!(
            diagnostics
                .iter()
                .map(|diagnostic| diagnostic.span.slice(source))
                .collect::<Vec<_>>(),
            vec!["$trimmed_suffix"]
        );
    }

    #[test]
    fn skips_zsh_explicit_pattern_expansion_operands() {
        let source = "\
#!/usr/bin/env zsh
value=foobar
prefix='foo*'
trimmed=${value#${~prefix}}
long_trimmed=${value##${=~prefix}}
suffix_trimmed=${value%${~~~suffix}}
literal_trimmed=${value#${~~literal}}
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::PatternWithVariable)
                .with_shell(crate::ShellDialect::Zsh),
        );

        assert_eq!(
            diagnostics
                .iter()
                .map(|diagnostic| diagnostic.span.slice(source))
                .collect::<Vec<_>>(),
            vec!["${~~literal}"]
        );
    }

    #[test]
    fn ignores_pattern_variables_with_static_pattern_safe_values() {
        let source = "\
#!/usr/bin/env zsh
prefix='src/lib/'
pattern='src/*'
trimmed=${path#$prefix}
pattern_trimmed=${path#$pattern}
unknown_trimmed=${path#$unknown}
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::PatternWithVariable)
                .with_shell(crate::ShellDialect::Zsh),
        );

        assert_eq!(
            diagnostics
                .iter()
                .map(|diagnostic| diagnostic.span.slice(source))
                .collect::<Vec<_>>(),
            vec!["$pattern", "$unknown"]
        );
    }

    #[test]
    fn ignores_zsh_presence_tests_in_pattern_operands() {
        let source = "\
#!/usr/bin/env zsh
trimmed=${value#${+ICE[nocompletions]}}
also_trimmed=${value#${+enabled}}
positional_trimmed=${value#${+1}}
status_trimmed=${value#${+?}}
unknown_trimmed=${value#$unknown}
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::PatternWithVariable)
                .with_shell(crate::ShellDialect::Zsh),
        );

        assert_eq!(
            diagnostics
                .iter()
                .map(|diagnostic| diagnostic.span.slice(source))
                .collect::<Vec<_>>(),
            vec!["$unknown"]
        );
    }

    #[test]
    fn ignores_zsh_numeric_glob_patterns_without_expansions() {
        let source = "\
#!/usr/bin/env zsh
if [[ $OPTARG != (|+|-)<->(|.<->)(|[eE](|-|+)<->) ]]; then
  return 1
fi
if [[ $value == ${~pattern} ]]; then
  return 0
fi
process_sub_trimmed=${value#<(printf '%s' foo)}
trimmed=${value#$pattern}
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::PatternWithVariable)
                .with_shell(crate::ShellDialect::Zsh),
        );

        assert_eq!(
            diagnostics
                .iter()
                .map(|diagnostic| diagnostic.span.slice(source))
                .collect::<Vec<_>>(),
            vec!["<(printf '%s' foo)", "$pattern"]
        );
    }

    #[test]
    fn reports_process_substitutions_after_numeric_glob_like_prefixes_outside_zsh() {
        let source = "\
#!/bin/bash
trimmed=${value#<->(printf '%s' foo)}
";
        let diagnostics =
            test_snippet(source, &LinterSettings::for_rule(Rule::PatternWithVariable));

        assert_eq!(
            diagnostics
                .iter()
                .map(|diagnostic| diagnostic.span.slice(source))
                .collect::<Vec<_>>(),
            vec![">(printf '%s' foo)"]
        );
    }

    #[test]
    fn reports_command_substitutions_even_when_nested_references_are_static_safe() {
        let source = "\
#!/usr/bin/env zsh
prefix='src/lib/'
trimmed=${path#$(printf '%s' \"$prefix\")}
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::PatternWithVariable)
                .with_shell(crate::ShellDialect::Zsh),
        );

        assert_eq!(
            diagnostics
                .iter()
                .map(|diagnostic| diagnostic.span.slice(source))
                .collect::<Vec<_>>(),
            vec!["$(printf '%s' \"$prefix\")"]
        );
    }

    #[test]
    fn reports_parameter_operations_even_when_target_bindings_are_static_safe() {
        let source = "\
#!/usr/bin/env zsh
prefix='src'
trimmed=${path#${prefix:-*}}
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::PatternWithVariable)
                .with_shell(crate::ShellDialect::Zsh),
        );

        assert_eq!(
            diagnostics
                .iter()
                .map(|diagnostic| diagnostic.span.slice(source))
                .collect::<Vec<_>>(),
            vec!["${prefix:-*}"]
        );
    }

    #[test]
    fn reports_nested_patterns_inside_special_targets() {
        let source = "\
#!/bin/bash
nested=${items[i]%${name%$suffix}}
";
        let diagnostics =
            test_snippet(source, &LinterSettings::for_rule(Rule::PatternWithVariable));

        assert_eq!(
            diagnostics
                .iter()
                .map(|diagnostic| diagnostic.span.slice(source))
                .collect::<Vec<_>>(),
            vec!["$suffix"]
        );
    }

    #[test]
    fn attaches_unsafe_fix_metadata_to_reported_expansions() {
        let source = "\
#!/bin/bash
suffix=b
trimmed=${name%$suffix}
";
        let diagnostics =
            test_snippet(source, &LinterSettings::for_rule(Rule::PatternWithVariable));

        assert_eq!(diagnostics.len(), 1);
        assert_eq!(
            diagnostics[0].fix.as_ref().map(|fix| fix.applicability()),
            Some(Applicability::Unsafe)
        );
        assert_eq!(
            diagnostics[0].fix_title.as_deref(),
            Some("quote the expansion in the pattern")
        );
    }

    #[test]
    fn applies_unsafe_fix_to_parameter_pattern_expansions() {
        let source = "\
#!/bin/bash
suffix=b
trimmed=${name%$suffix}
";
        let result = test_snippet_with_fix(
            source,
            &LinterSettings::for_rule(Rule::PatternWithVariable),
            Applicability::Unsafe,
        );

        assert_eq!(result.fixes_applied, 1);
        assert_eq!(
            result.fixed_source,
            "\
#!/bin/bash
suffix=b
trimmed=${name%\"$suffix\"}
"
        );
        assert!(result.fixed_diagnostics.is_empty());
    }

    #[test]
    fn snapshots_unsafe_fix_output_for_fixture() -> anyhow::Result<()> {
        let result = test_path_with_fix(
            std::path::Path::new("correctness")
                .join("C055.sh")
                .as_path(),
            &LinterSettings::for_rule(Rule::PatternWithVariable),
            Applicability::Unsafe,
        )?;

        assert_diagnostics_diff!("C055_fix_C055.sh", result);
        Ok(())
    }
}
