use shuck_ast::Span;

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

pub struct UnusedHeredoc;

impl Violation for UnusedHeredoc {
    const FIX_AVAILABILITY: FixAvailability = FixAvailability::Sometimes;

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

    fn message(&self) -> String {
        "this here-document has no command to consume it".to_owned()
    }

    fn fix_title(&self) -> Option<String> {
        Some("remove the unused here-document".to_owned())
    }
}

pub fn unused_heredoc(checker: &mut Checker) {
    let locator = checker.locator();
    let spans = checker
        .facts()
        .source_facts()
        .unused_heredoc_spans()
        .to_vec();
    for span in spans {
        let mut diagnostic = Diagnostic::new(UnusedHeredoc, span);
        if let Some(fix) = unused_heredoc_fix(locator, span) {
            diagnostic = diagnostic.with_fix(fix);
        }
        checker.report_diagnostic_dedup(diagnostic);
    }
}

fn unused_heredoc_fix(locator: Locator<'_>, span: Span) -> Option<Fix> {
    let source = locator.source();
    let marker = heredoc_marker_from_redirect_span(span, source)?;
    if marker.name.is_empty() {
        return None;
    }

    let line_range = locator.line_range(span.start.line())?;
    let line_start = usize::from(line_range.start());
    let raw_line_end = usize::from(line_range.end());
    let line_end = trim_cr_end(source, raw_line_end);
    let header_delete_start = preceding_space_start(source, span.start.offset(), line_start);
    let body_start = locator
        .line_index()
        .line_start(span.start.line() + 1)
        .map(usize::from)
        .unwrap_or(source.len());
    let body_end = heredoc_body_end(locator, span.start.line() + 1, &marker)?;

    let prefix_is_blank = source
        .get(line_start..header_delete_start)
        .is_some_and(|prefix| prefix.trim().is_empty());
    let suffix_is_blank = source
        .get(span.end.offset()..line_end)
        .is_some_and(|suffix| suffix.trim().is_empty());
    if prefix_is_blank && suffix_is_blank {
        return Some(Fix::unsafe_edit(Edit::deletion_at(line_start, body_end)));
    }

    Some(Fix::unsafe_edits([
        Edit::deletion_at(header_delete_start, span.end.offset()),
        Edit::deletion_at(body_start, body_end),
    ]))
}

struct HeredocMarker {
    name: String,
    strip_tabs: bool,
}

fn heredoc_marker_from_redirect_span(span: Span, source: &str) -> Option<HeredocMarker> {
    let text = span.slice(source).trim();
    let operator_start = text.find("<<")?;
    let mut marker = &text[operator_start + 2..];
    let strip_tabs = marker.starts_with('-');
    if strip_tabs {
        marker = &marker[1..];
    }
    let name = marker
        .trim()
        .trim_matches(|ch| matches!(ch, '\'' | '"' | '\\'))
        .to_owned();
    Some(HeredocMarker { name, strip_tabs })
}

fn heredoc_body_end(
    locator: Locator<'_>,
    mut line_number: usize,
    marker: &HeredocMarker,
) -> Option<usize> {
    let source = locator.source();
    loop {
        let line_range = locator.line_range(line_number)?;
        let line_start = usize::from(line_range.start());
        let line_end = trim_cr_end(source, usize::from(line_range.end()));
        let line = source.get(line_start..line_end)?;
        let candidate = if marker.strip_tabs {
            line.trim_start_matches('\t')
        } else {
            line
        };
        if candidate == marker.name {
            return Some(
                locator
                    .line_index()
                    .line_start(line_number + 1)
                    .map(usize::from)
                    .unwrap_or(source.len()),
            );
        }
        line_number += 1;
    }
}

fn preceding_space_start(source: &str, mut offset: usize, floor: usize) -> usize {
    while offset > floor && matches!(source.as_bytes().get(offset - 1), Some(b' ' | b'\t')) {
        offset -= 1;
    }
    offset
}

fn trim_cr_end(source: &str, offset: usize) -> usize {
    if offset > 0 && matches!(source.as_bytes().get(offset - 1), Some(b'\r')) {
        offset - 1
    } else {
        offset
    }
}

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

    #[test]
    fn reports_heredocs_without_a_consuming_command() {
        let source = "\
#!/bin/sh
<<EOF
alpha
EOF

x=1 <<EOF
beta
EOF

>out <<EOF
gamma
EOF
";
        let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::UnusedHeredoc));

        assert_eq!(
            diagnostics
                .iter()
                .map(|diagnostic| diagnostic.span.start.line())
                .collect::<Vec<_>>(),
            vec![2, 6, 10]
        );
        assert_eq!(
            diagnostics
                .iter()
                .map(|diagnostic| diagnostic.span.slice(source))
                .collect::<Vec<_>>(),
            vec!["<<EOF", "<<EOF", "<<EOF"]
        );
    }

    #[test]
    fn ignores_heredocs_attached_to_commands() {
        let source = "\
#!/bin/sh
cat <<EOF
alpha
EOF

: <<EOF
beta
EOF

\"\" <<EOF
gamma
EOF
";
        let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::UnusedHeredoc));

        assert!(diagnostics.is_empty());
    }

    #[test]
    fn applies_unsafe_fix_to_delete_unused_heredoc_redirect_and_body() {
        let source = "\
#!/bin/sh
<<EOF
alpha
EOF

x=1 <<EOF
beta
EOF
";
        let result = test_snippet_with_fix(
            source,
            &LinterSettings::for_rule(Rule::UnusedHeredoc),
            Applicability::Unsafe,
        );

        assert_eq!(result.fixes_applied, 2);
        assert_eq!(result.fixed_source, "#!/bin/sh\n\nx=1\n");
        assert!(result.fixed_diagnostics.is_empty());
    }
}
