use shuck_ast::{ConditionalUnaryOp, Span, Word};

use crate::{
    Checker, ConditionalNodeFact, ConditionalOperatorFamily, Diagnostic, Edit, ExpansionContext,
    Fix, FixAvailability, Rule, SimpleTestOperatorFamily, SimpleTestShape, Violation,
    WordFactContext, WordQuote,
};

pub struct QuotedCommandInTest;

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

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

    fn message(&self) -> String {
        "this test uses a quoted pipeline literal instead of command output".to_owned()
    }

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

pub fn quoted_command_in_test(checker: &mut Checker) {
    let source = checker.source();
    let diagnostics = checker
        .facts()
        .commands()
        .iter()
        .flat_map(|command| {
            let mut diagnostics = Vec::new();
            if let Some(simple_test) = command.simple_test() {
                diagnostics.extend(collect_simple_test_diagnostics(checker, simple_test));
            }
            if let Some(conditional) = command.conditional() {
                diagnostics.extend(collect_conditional_diagnostics(conditional, source));
            }
            diagnostics
        })
        .collect::<Vec<_>>();

    for (span, replacement) in diagnostics {
        checker.report_diagnostic_dedup(
            Diagnostic::new(QuotedCommandInTest, span)
                .with_fix(Fix::unsafe_edit(Edit::replacement(replacement, span))),
        );
    }
}

fn collect_simple_test_diagnostics(
    checker: &Checker<'_>,
    simple_test: &crate::SimpleTestFact<'_>,
) -> Vec<(Span, String)> {
    simple_test_condition_operand(simple_test)
        .and_then(|word| {
            simple_test_quoted_pipeline_replacement(checker, word.span, checker.source())
                .map(|replacement| (word.span, replacement))
        })
        .into_iter()
        .collect()
}

fn simple_test_condition_operand<'a>(
    simple_test: &'a crate::SimpleTestFact<'a>,
) -> Option<&'a Word> {
    match simple_test.effective_shape() {
        SimpleTestShape::Truthy => simple_test.effective_operands().first().copied(),
        SimpleTestShape::Unary
            if simple_test.effective_operator_family() == SimpleTestOperatorFamily::StringUnary =>
        {
            simple_test.effective_operands().get(1).copied()
        }
        SimpleTestShape::Unary
        | SimpleTestShape::Empty
        | SimpleTestShape::Binary
        | SimpleTestShape::Other => None,
    }
}

fn collect_conditional_diagnostics(
    conditional: &crate::ConditionalFact<'_>,
    source: &str,
) -> Vec<(Span, String)> {
    let mut diagnostics = Vec::new();
    let unary_operand_spans = conditional
        .nodes()
        .iter()
        .filter_map(|node| match node {
            ConditionalNodeFact::Unary(unary) => unary.operand().word().map(|word| word.span),
            ConditionalNodeFact::BareWord(_)
            | ConditionalNodeFact::Binary(_)
            | ConditionalNodeFact::Other(_) => None,
        })
        .collect::<Vec<_>>();
    let comparison_operand_spans = conditional
        .nodes()
        .iter()
        .filter_map(|node| match node {
            ConditionalNodeFact::Binary(binary)
                if binary.operator_family() != ConditionalOperatorFamily::Logical =>
            {
                Some([
                    binary.left().word().map(|word| word.span),
                    binary.right().word().map(|word| word.span),
                ])
            }
            ConditionalNodeFact::BareWord(_)
            | ConditionalNodeFact::Unary(_)
            | ConditionalNodeFact::Binary(_)
            | ConditionalNodeFact::Other(_) => None,
        })
        .flatten()
        .flatten()
        .collect::<Vec<_>>();

    match conditional.root() {
        ConditionalNodeFact::BareWord(bare_word)
            if conditional_quoted_pipeline_replacement(bare_word.operand(), source).is_some() =>
        {
            if let Some(word) = bare_word.operand().word()
                && let Some(replacement) =
                    conditional_quoted_pipeline_replacement(bare_word.operand(), source)
            {
                diagnostics.push((word.span, replacement));
            }
        }
        ConditionalNodeFact::Unary(unary)
            if conditional_unary_checks_literal_string(unary)
                && conditional_quoted_pipeline_replacement(unary.operand(), source).is_some() =>
        {
            if let Some(word) = unary.operand().word()
                && let Some(replacement) =
                    conditional_quoted_pipeline_replacement(unary.operand(), source)
            {
                diagnostics.push((word.span, replacement));
            }
        }
        ConditionalNodeFact::BareWord(_)
        | ConditionalNodeFact::Unary(_)
        | ConditionalNodeFact::Binary(_)
        | ConditionalNodeFact::Other(_) => {}
    }

    for node in conditional.nodes().iter().skip(1) {
        match node {
            ConditionalNodeFact::BareWord(bare_word)
                if bare_word.operand().word().is_some_and(|word| {
                    !unary_operand_spans.contains(&word.span)
                        && !comparison_operand_spans.contains(&word.span)
                        && conditional_quoted_pipeline_replacement(bare_word.operand(), source)
                            .is_some()
                }) =>
            {
                if let Some(word) = bare_word.operand().word()
                    && let Some(replacement) =
                        conditional_quoted_pipeline_replacement(bare_word.operand(), source)
                {
                    diagnostics.push((word.span, replacement));
                }
            }
            ConditionalNodeFact::Unary(unary)
                if conditional_unary_checks_literal_string(unary)
                    && conditional_quoted_pipeline_replacement(unary.operand(), source)
                        .is_some() =>
            {
                if let Some(word) = unary.operand().word()
                    && let Some(replacement) =
                        conditional_quoted_pipeline_replacement(unary.operand(), source)
                {
                    diagnostics.push((word.span, replacement));
                }
            }
            ConditionalNodeFact::BareWord(_)
            | ConditionalNodeFact::Unary(_)
            | ConditionalNodeFact::Binary(_)
            | ConditionalNodeFact::Other(_) => {}
        }
    }

    diagnostics
}

fn conditional_unary_checks_literal_string(unary: &crate::ConditionalUnaryFact<'_>) -> bool {
    unary.operator_family() == ConditionalOperatorFamily::StringUnary
        || unary.op() == ConditionalUnaryOp::Not
}

fn simple_test_quoted_pipeline_replacement(
    checker: &Checker<'_>,
    span: Span,
    source: &str,
) -> Option<String> {
    checker
        .facts()
        .words()
        .word_fact(
            span,
            WordFactContext::Expansion(ExpansionContext::CommandArgument),
        )
        .and_then(|fact| quoted_pipeline_replacement_from_word_fact(fact, source))
}

fn quoted_pipeline_replacement_from_word_fact(
    fact: crate::WordOccurrenceRef<'_, '_>,
    source: &str,
) -> Option<String> {
    if fact.classification().quote != WordQuote::FullyQuoted
        || !fact.classification().is_fixed_literal()
    {
        return None;
    }

    let text = fact.static_text_cow(source)?;
    looks_like_quoted_pipeline_literal(&text).then(|| command_substitution_replacement(&text))
}

fn conditional_quoted_pipeline_replacement(
    operand: crate::ConditionalOperandFact<'_>,
    source: &str,
) -> Option<String> {
    operand
        .word()
        .zip(operand.word_classification())
        .and_then(|(word, classification)| {
            (classification.quote == WordQuote::FullyQuoted && classification.is_fixed_literal())
                .then(|| shuck_ast::static_word_text(word, source))
        })
        .flatten()
        .and_then(|text| {
            looks_like_quoted_pipeline_literal(&text)
                .then(|| command_substitution_replacement(&text))
        })
}

fn command_substitution_replacement(text: &str) -> String {
    format!("\"$({text})\"")
}

fn looks_like_quoted_pipeline_literal(text: &str) -> bool {
    if text.contains("||") {
        return false;
    }

    let segments = text.split('|').map(str::trim).collect::<Vec<_>>();

    segments.len() >= 2
        && segments.iter().all(|segment| {
            !segment.is_empty()
                && segment
                    .split_ascii_whitespace()
                    .any(looks_like_command_word)
        })
}

fn looks_like_command_word(token: &str) -> bool {
    !token.is_empty()
        && token
            .chars()
            .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.' | '/'))
}

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

    use crate::test::{test_path_with_fix, test_snippet, test_snippet_with_fix};
    use crate::{Applicability, LinterSettings, Rule, assert_diagnostics_diff};

    #[test]
    fn reports_quoted_pipeline_literals_in_simple_tests() {
        let source = "\
#!/bin/sh
[ \"lsmod | grep v4l2loopback\" ]
[ -n \"modprobe | grep snd\" ]
";
        let diagnostics =
            test_snippet(source, &LinterSettings::for_rule(Rule::QuotedCommandInTest));

        assert_eq!(
            diagnostics
                .iter()
                .map(|diagnostic| diagnostic.span.slice(source))
                .collect::<Vec<_>>(),
            vec!["\"lsmod | grep v4l2loopback\"", "\"modprobe | grep snd\""]
        );
    }

    #[test]
    fn reports_negated_quoted_pipeline_literals_in_simple_tests() {
        let source = "\
#!/bin/sh
[ ! \"lsmod | grep v4l2loopback\" ]
test ! \"modprobe | grep snd\"
[ ! -n \"lsmod | grep usb\" ]
test ! -z \"modprobe | grep snd_hda\"
";
        let diagnostics =
            test_snippet(source, &LinterSettings::for_rule(Rule::QuotedCommandInTest));

        assert_eq!(
            diagnostics
                .iter()
                .map(|diagnostic| diagnostic.span.slice(source))
                .collect::<Vec<_>>(),
            vec![
                "\"lsmod | grep v4l2loopback\"",
                "\"modprobe | grep snd\"",
                "\"lsmod | grep usb\"",
                "\"modprobe | grep snd_hda\""
            ]
        );
    }

    #[test]
    fn reports_nested_quoted_pipeline_literals_in_conditionals() {
        let source = "\
#!/bin/bash
[[ \"$ok\" && \"lsmod | grep v4l2loopback\" ]]
[[ -n \"lsmod | grep v4l2loopback\" && -n \"$ok\" ]]
";
        let diagnostics =
            test_snippet(source, &LinterSettings::for_rule(Rule::QuotedCommandInTest));

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

    #[test]
    fn reports_negated_quoted_pipeline_literals_in_conditionals() {
        let source = "\
#!/bin/bash
[[ ! \"lsmod | grep v4l2loopback\" ]]
[[ \"$ok\" && ! \"modprobe | grep snd\" ]]
";
        let diagnostics =
            test_snippet(source, &LinterSettings::for_rule(Rule::QuotedCommandInTest));

        assert_eq!(
            diagnostics
                .iter()
                .map(|diagnostic| diagnostic.span.slice(source))
                .collect::<Vec<_>>(),
            vec!["\"lsmod | grep v4l2loopback\"", "\"modprobe | grep snd\""]
        );
    }

    #[test]
    fn ignores_non_pipeline_literals_and_binary_comparisons() {
        let source = "\
#!/bin/sh
[ \"echo hi\" ]
[ ! \"echo hi\" ]
[ \"foo | bar\" = x ]
[[ \"grep foo file\" = x ]]
[[ -n \"echo hi\" ]]
[[ ! \"echo hi\" ]]
[[ -f \"foo | bar\" ]]
[[ -x \"foo | bar\" ]]
";
        let diagnostics =
            test_snippet(source, &LinterSettings::for_rule(Rule::QuotedCommandInTest));

        assert!(diagnostics.is_empty());
    }

    #[test]
    fn applies_unsafe_fix_to_quoted_pipeline_literals() {
        let source = "\
#!/bin/sh
[ \"lsmod | grep v4l2loopback\" ]
[ -n \"modprobe | grep snd\" ]
[[ \"$ok\" && \"dmesg | grep usb\" ]]
";
        let result = test_snippet_with_fix(
            source,
            &LinterSettings::for_rule(Rule::QuotedCommandInTest),
            Applicability::Unsafe,
        );

        assert_eq!(result.fixes_applied, 3);
        assert_eq!(
            result.fixed_source,
            "\
#!/bin/sh
[ \"$(lsmod | grep v4l2loopback)\" ]
[ -n \"$(modprobe | grep snd)\" ]
[[ \"$ok\" && \"$(dmesg | grep usb)\" ]]
"
        );
        assert!(result.fixed_diagnostics.is_empty());
    }

    #[test]
    fn preserves_inner_quotes_when_fixing_quoted_pipeline_literals() {
        let source = "\
#!/bin/sh
[ \"printf \\\"%s\\\" foo | grep foo\" ]
";
        let result = test_snippet_with_fix(
            source,
            &LinterSettings::for_rule(Rule::QuotedCommandInTest),
            Applicability::Unsafe,
        );

        assert_eq!(result.fixes_applied, 1);
        assert_eq!(
            result.fixed_source,
            "\
#!/bin/sh
[ \"$(printf \"%s\" foo | grep foo)\" ]
"
        );
        assert!(result.fixed_diagnostics.is_empty());
    }

    #[test]
    fn leaves_non_pipeline_literals_unchanged_when_fixing() {
        let source = "\
#!/bin/sh
[ \"echo hi\" ]
[ \"foo | bar\" = x ]
[[ -f \"foo | bar\" ]]
";
        let result = test_snippet_with_fix(
            source,
            &LinterSettings::for_rule(Rule::QuotedCommandInTest),
            Applicability::Unsafe,
        );

        assert_eq!(result.fixes_applied, 0);
        assert_eq!(result.fixed_source, source);
        assert!(result.fixed_diagnostics.is_empty());
    }

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

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