use shuck_ast::Span;

use crate::{Checker, Diagnostic, Edit, Fix, FixAvailability, Rule, Violation};

pub struct CommandOutputArraySplit;

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

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

    fn message(&self) -> String {
        "avoid splitting command output directly into arrays; use mapfile or read -a".to_owned()
    }

    fn fix_title(&self) -> Option<String> {
        Some("quote the command substitution in the array assignment".to_owned())
    }
}

pub fn command_output_array_split(checker: &mut Checker) {
    let source = checker.source();
    let diagnostics = checker
        .facts()
        .words()
        .array_assignment_split_word_facts()
        .flat_map(|fact| {
            fact.split_sensitive_unquoted_command_substitution_spans()
                .iter()
                .copied()
        })
        .map(|span| {
            Diagnostic::new(CommandOutputArraySplit, span)
                .with_fix(Fix::unsafe_edit(double_quote_span_edit(span, source)))
        })
        .collect::<Vec<_>>();

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

fn double_quote_span_edit(span: Span, source: &str) -> Edit {
    Edit::replacement(format!("\"{}\"", span.slice(source)), span)
}

#[cfg(test)]
mod tests {
    use std::path::Path;

    use crate::test::{test_snippet, test_snippet_at_path, test_snippet_with_fix};
    use crate::{Applicability, LinterSettings, Rule, ShellDialect};

    #[test]
    fn reports_unquoted_command_substitutions_in_array_assignments() {
        let source = "\
#!/bin/bash
arr=($(printf '%s\\n' a b) `printf '%s\\n' c d` prefix$(printf '%s' z)suffix)
declare listed=($(printf '%s\\n' one two))
arr+=($(printf '%s\\n' tail))
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::CommandOutputArraySplit),
        );

        assert_eq!(
            diagnostics
                .iter()
                .map(|diagnostic| diagnostic.span.slice(source))
                .collect::<Vec<_>>(),
            vec![
                "$(printf '%s\\n' a b)",
                "`printf '%s\\n' c d`",
                "$(printf '%s' z)",
                "$(printf '%s\\n' one two)",
                "$(printf '%s\\n' tail)"
            ]
        );
    }

    #[test]
    fn applies_unsafe_fix_by_quoting_command_substitution_in_array_assignment() {
        let source = "#!/bin/bash\narr=($(printf '%s\\n' a b))\n";
        let result = test_snippet_with_fix(
            source,
            &LinterSettings::for_rule(Rule::CommandOutputArraySplit),
            Applicability::Unsafe,
        );

        assert_eq!(result.fixes_applied, 1);
        assert_eq!(
            result.fixed_source,
            "#!/bin/bash\narr=(\"$(printf '%s\\n' a b)\")\n"
        );
        assert!(result.fixed_diagnostics.is_empty());
    }

    #[test]
    fn ignores_quoted_and_non_split_array_contexts() {
        let source = "\
#!/bin/bash
arr=(\"$(printf '%s\\n' a b)\" \"`printf '%s\\n' c d`\")
value=$(printf '%s\\n' scalar)
arr=([0]=$(printf '%s\\n' keyed))
declare -A map=([k]=$(printf '%s\\n' assoc))
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::CommandOutputArraySplit),
        );

        assert!(diagnostics.is_empty());
    }

    #[test]
    fn reports_command_output_array_split_in_nb_theme_validation_flow() {
        let source = r#"#!/usr/bin/env bash
set -o noglob
IFS=$'\n\t'

validate_theme() {
  if ! _bat --command-exists
  then
    printf "bat required\n"
  elif [[ -z "${1:-}" ]]
  then
    return 1
  else
    local _theme_list=
    _theme_list=($(_bat --list-themes --color never))

    local __theme=
    for __theme in "${_theme_list[@]}"
    do
      if [[ "$1" == "${__theme:-}" ]]
      then
        return 0
      fi
    done
  fi
}
"#;
        let diagnostics = test_snippet_at_path(
            Path::new("/tmp/scripts/xwmx__nb__nb"),
            source,
            &LinterSettings::for_rule(Rule::CommandOutputArraySplit),
        );

        assert_eq!(
            diagnostics
                .iter()
                .map(|diagnostic| diagnostic.span.slice(source))
                .collect::<Vec<_>>(),
            vec!["$(_bat --list-themes --color never)"]
        );
    }

    #[test]
    fn keeps_s018_before_following_extglob_parameter_pattern_condition() {
        let source = r#"#!/usr/bin/env bash
shopt -s extglob
check() {
  local _theme_list=
  _theme_list=($(printf '%s\n' 'Solarized (dark)' base16))
  local __theme=
  for __theme in "${_theme_list[@]}"
  do
    if [[ "${2}" =~ \( ]]
    then
      if [[ "${2}" == "${__theme:-}" ]]
      then
        return 0
      fi
    elif [[ "${2}" =~ ^${__theme%% (*} ]]
    then
      return 0
    fi
  done
}
"#;
        let diagnostics = test_snippet_at_path(
            Path::new("/tmp/scripts/xwmx__nb__nb"),
            source,
            &LinterSettings::for_rule(Rule::CommandOutputArraySplit),
        );

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

    #[test]
    fn skips_native_zsh_array_command_substitutions_without_split_behavior() {
        let source = "arr=($(cmd))\nsetopt sh_word_split\narr=($(cmd))\n";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::CommandOutputArraySplit).with_shell(ShellDialect::Zsh),
        );

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