pub(crate) use shuck_ast::raw_shell::{QuoteState, RawShellScanner, shell_comment_can_start};

pub(crate) fn leading_shell_indent(line: &str) -> &str {
    let indent_end = line
        .char_indices()
        .find(|(_, ch)| !matches!(ch, ' ' | '\t'))
        .map_or(line.len(), |(index, _)| index);
    &line[..indent_end]
}

pub(crate) struct HeredocStart<'line> {
    pub(crate) delimiter: &'line str,
    pub(crate) strip_tabs: bool,
    pub(crate) operator_end: usize,
}

pub(crate) fn heredoc_start(line: &str) -> Option<HeredocStart<'_>> {
    let marker = line.find("<<")?;
    let after_marker = &line[marker + 2..];
    if after_marker.starts_with('<') {
        return None;
    }
    let (strip_tabs, after_marker) = if let Some(rest) = after_marker.strip_prefix('-') {
        (true, rest)
    } else {
        (false, after_marker)
    };
    let delimiter = after_marker
        .split_whitespace()
        .next()?
        .trim_matches(['\'', '"']);
    (!delimiter.is_empty()).then_some(HeredocStart {
        delimiter,
        strip_tabs,
        operator_end: marker + if strip_tabs { 3 } else { 2 },
    })
}

pub(crate) fn common_indent_prefix<'a>(left: &'a str, right: &str) -> &'a str {
    let len = left
        .as_bytes()
        .iter()
        .zip(right.as_bytes())
        .take_while(|(left, right)| left == right)
        .count();
    &left[..len]
}

pub(crate) fn refine_common_indent(common: &mut Option<String>, indent: &str) -> bool {
    *common = Some(match common.take() {
        Some(previous) => common_indent_prefix(&previous, indent).to_string(),
        None => indent.to_string(),
    });
    common.as_deref() == Some("")
}

pub(crate) fn common_nonempty_shell_indent<'a>(lines: impl IntoIterator<Item = &'a str>) -> String {
    let mut common: Option<String> = None;
    for line in lines {
        if line.trim().is_empty() {
            continue;
        }
        let indent = leading_shell_indent(line);
        if indent.is_empty() || refine_common_indent(&mut common, indent) {
            return String::new();
        }
    }
    common.unwrap_or_default()
}

pub(crate) fn line_without_continuation_backslash(line: &str) -> Option<&str> {
    let trimmed = line.trim_end_matches([' ', '\t', '\r']);
    let prefix = trimmed.strip_suffix('\\')?;
    Some(prefix.trim_end_matches([' ', '\t', '\r']))
}

pub(crate) fn redirect_operator_end(bytes: &[u8], start: usize) -> Option<usize> {
    match bytes.get(start).copied()? {
        b'>' => Some(match bytes.get(start + 1).copied() {
            Some(b'>' | b'|' | b'&') => start + 2,
            _ => start + 1,
        }),
        b'<' => Some(match bytes.get(start + 1).copied() {
            Some(b'<' | b'>' | b'&') => {
                if bytes.get(start + 2) == Some(&b'<') {
                    start + 3
                } else {
                    start + 2
                }
            }
            _ => start + 1,
        }),
        _ => None,
    }
}

pub(crate) fn skip_single_quoted(source: &str, offset: usize, upper: usize) -> usize {
    RawShellScanner::bounded(source, upper).skip_single_quoted_body(offset)
}

pub(crate) fn skip_double_quoted(source: &str, offset: usize, upper: usize) -> usize {
    RawShellScanner::bounded(source, upper).skip_double_quoted_body(offset)
}

pub(crate) fn skip_escaped_or_quoted(
    source: &str,
    offset: usize,
    upper: usize,
    ch: char,
) -> Option<usize> {
    debug_assert_eq!(source[offset..].chars().next(), Some(ch));
    RawShellScanner::bounded(source, upper).skip_escaped_or_quoted_at(offset)
}

#[derive(Debug, Clone, Copy)]
pub(crate) struct RawShellText<'source> {
    text: &'source str,
}

impl<'source> RawShellText<'source> {
    pub(crate) fn new(text: &'source str) -> Self {
        Self { text }
    }

    pub(crate) fn matching_command_substitution_close(&self, body_start: usize) -> Option<usize> {
        RawShellScanner::new(self.text).matching_command_substitution_close(body_start)
    }

    pub(crate) fn next_command_substitution(&self, index: usize) -> Option<(usize, usize)> {
        RawShellScanner::new(self.text).next_command_substitution(index)
    }

    pub(crate) fn unclosed_command_substitution_body_start(&self) -> Option<usize> {
        let bytes = self.text.as_bytes();
        let mut quote = QuoteState::default();
        let mut index = 0usize;

        while index + 1 < self.text.len() {
            let Some(ch) = self.text[index..].chars().next() else {
                break;
            };
            let next_index = index + ch.len_utf8();
            if quote.consume_shell_word_char(ch) {
                index = next_index;
                continue;
            }
            if !quote.in_single_quotes()
                && bytes[index] == b'$'
                && bytes[index + 1] == b'('
                && bytes.get(index + 2).is_none_or(|byte| *byte != b'(')
            {
                let Some(close_offset) = self.matching_command_substitution_close(index + 2) else {
                    return Some(index + 2);
                };
                index = close_offset + 1;
                continue;
            }
            index = next_index;
        }

        None
    }

    pub(crate) fn has_unclosed_command_substitution_open(&self) -> bool {
        self.unclosed_command_substitution_body_start().is_some()
    }

    pub(crate) fn normalize_pipeline_continuations(&self) -> Option<String> {
        let trailing = normalize_raw_trailing_pipe_continuations(self.text);
        let leading =
            normalize_raw_leading_pipe_continuations(trailing.as_deref().unwrap_or(self.text));
        leading.or(trailing)
    }

    pub(crate) fn line_ends_with_continuation_operator(&self) -> bool {
        line_ends_with_raw_continuation_operator(self.text)
    }

    pub(crate) fn line_without_trailing_pipe_continuation(&self) -> Option<&'source str> {
        let prefix = line_without_continuation_backslash(self.text)?;
        RawShellText::new(prefix)
            .line_ends_with_continuation_operator()
            .then_some(prefix)
    }

    pub(crate) fn leading_pipe_continuation(
        &self,
    ) -> Option<(&'source str, &'static str, &'source str)> {
        leading_pipe_continuation(self.text)
    }

    pub(crate) fn trim_command_substitution_open_padding(&self) -> String {
        let mut rendered = String::with_capacity(self.text.len());
        let mut cursor = 0usize;
        let mut index = 0usize;
        let mut in_single_quotes = false;
        let mut in_double_quotes = false;
        let mut escaped = false;

        while index < self.text.len() {
            let Some(ch) = self.text[index..].chars().next() else {
                break;
            };
            let next_index = index + ch.len_utf8();
            if escaped {
                escaped = false;
            } else if ch == '\\' && !in_single_quotes {
                escaped = true;
            } else if ch == '\'' && !in_double_quotes {
                in_single_quotes = !in_single_quotes;
            } else if ch == '"' && !in_single_quotes {
                in_double_quotes = !in_double_quotes;
            } else if !in_single_quotes && self.text[index..].starts_with("$(") {
                rendered.push_str(&self.text[cursor..index + 2]);
                index += 2;
                while self.text[index..].starts_with([' ', '\t']) {
                    index += 1;
                }
                cursor = index;
                continue;
            }
            index = next_index;
        }
        rendered.push_str(&self.text[cursor..]);
        rendered
    }

    pub(crate) fn starts_with_redirect_continuation(&self) -> bool {
        let trimmed = self.text.trim_start_matches([' ', '\t']);
        let bytes = trimmed.as_bytes();
        let mut index = 0;
        while bytes.get(index).is_some_and(u8::is_ascii_digit) {
            index += 1;
        }
        match bytes.get(index) {
            Some(b'<' | b'>') => true,
            Some(b'&') => bytes.get(index + 1) == Some(&b'>'),
            _ => false,
        }
    }

    pub(crate) fn continuation_inside_unclosed_substitution(&self) -> bool {
        let Some(before_continuation) = self.text.strip_suffix('\\') else {
            return false;
        };

        RawShellScanner::new(before_continuation)
            .has_unclosed_substitution_before(before_continuation.len())
    }

    pub(crate) fn quoted_backslash_continuation(&self) -> bool {
        let mut chars = self.text.chars().peekable();
        let mut in_single_quotes = false;
        let mut in_double_quotes = false;
        while let Some(ch) = chars.next() {
            if ch == '\'' && !in_double_quotes {
                in_single_quotes = !in_single_quotes;
                continue;
            }
            if ch == '"' && !in_single_quotes {
                in_double_quotes = !in_double_quotes;
                continue;
            }
            if ch == '\\' {
                let mut probe = chars.clone();
                let escaped_newline = match probe.next() {
                    Some('\n') => true,
                    Some('\r') => probe.next().is_some_and(|next| next == '\n'),
                    _ => false,
                };
                if escaped_newline {
                    return in_single_quotes || in_double_quotes;
                }
                if !in_single_quotes {
                    chars.next();
                }
            }
        }
        false
    }
}

pub(crate) fn normalize_raw_pipeline_continuations(text: &str) -> Option<String> {
    RawShellText::new(text).normalize_pipeline_continuations()
}

pub(crate) fn line_ends_with_raw_continuation_operator(line: &str) -> bool {
    let code = trailing_comment_start(line)
        .map(|comment_start| &line[..comment_start])
        .unwrap_or(line);
    let trimmed = code.trim_end_matches([' ', '\t', '\r']);
    line_ends_with_pipeline_operator(trimmed) || trimmed.ends_with("&&") || trimmed.ends_with("||")
}

pub(crate) fn matching_raw_command_substitution_close(
    raw: &str,
    body_start: usize,
) -> Option<usize> {
    RawShellText::new(raw).matching_command_substitution_close(body_start)
}

fn line_ends_with_pipeline_operator(line: &str) -> bool {
    let trimmed = line.trim_end_matches([' ', '\t', '\r']);
    trimmed.ends_with("|&") || (trimmed.ends_with('|') && !trimmed.ends_with("||"))
}

fn normalize_raw_trailing_pipe_continuations(text: &str) -> Option<String> {
    let mut lines = text
        .split('\n')
        .map(ToString::to_string)
        .collect::<Vec<_>>();
    let mut changed = false;

    for line in &mut lines {
        let Some(prefix) = RawShellText::new(line)
            .line_without_trailing_pipe_continuation()
            .map(str::to_string)
        else {
            continue;
        };
        *line = prefix;
        changed = true;
    }

    changed.then(|| lines.join("\n"))
}

fn normalize_raw_leading_pipe_continuations(text: &str) -> Option<String> {
    let mut lines = text
        .split('\n')
        .map(ToString::to_string)
        .collect::<Vec<_>>();
    let mut changed = false;

    for index in 0..lines.len().saturating_sub(1) {
        let Some(prefix) = line_without_continuation_backslash(&lines[index]).map(str::to_string)
        else {
            continue;
        };
        let Some((indent, operator, rest)) = RawShellText::new(&lines[index + 1])
            .leading_pipe_continuation()
            .map(|(indent, operator, rest)| {
                (
                    indent.to_string(),
                    operator,
                    rest.trim_start_matches([' ', '\t', '\r']).to_string(),
                )
            })
        else {
            continue;
        };

        lines[index] = format!("{prefix} {operator}");
        lines[index + 1] = format!("{indent}{rest}");
        changed = true;
    }

    changed.then(|| lines.join("\n"))
}

fn leading_pipe_continuation(line: &str) -> Option<(&str, &'static str, &str)> {
    let content_start = line
        .char_indices()
        .find_map(|(index, ch)| (!matches!(ch, ' ' | '\t')).then_some(index))
        .unwrap_or(line.len());
    let indent = &line[..content_start];
    let rest = &line[content_start..];
    if let Some(remainder) = rest.strip_prefix("|&") {
        Some((indent, "|&", remainder))
    } else if let Some(remainder) = rest.strip_prefix("||") {
        Some((indent, "||", remainder))
    } else if let Some(remainder) = rest.strip_prefix("&&") {
        Some((indent, "&&", remainder))
    } else {
        rest.strip_prefix('|')
            .map(|remainder| (indent, "|", remainder))
    }
}

fn trailing_comment_start(line: &str) -> Option<usize> {
    RawShellScanner::new(line).find_comment(0, line.len())
}

#[derive(Debug, Clone)]
pub(crate) struct RenderedHeredocTail {
    pub(crate) delimiter: String,
    pub(crate) strip_tabs: bool,
    pub(crate) command_indent: String,
}

impl RenderedHeredocTail {
    pub(crate) fn body_line<'a>(&self, line: &'a str) -> &'a str {
        if self.strip_tabs {
            line
        } else if let Some(stripped) = line.strip_prefix(&self.command_indent)
            && stripped == self.delimiter
        {
            stripped
        } else {
            line
        }
    }

    pub(crate) fn closes(&self, line: &str) -> bool {
        let line = self.body_line(line);
        if self.strip_tabs {
            line.trim_start_matches('\t') == self.delimiter
        } else {
            line == self.delimiter
        }
    }

    pub(crate) fn closes_source_line(&self, line: &str) -> bool {
        if self.strip_tabs {
            line.trim_start_matches('\t') == self.delimiter
        } else {
            line == self.delimiter
        }
    }
}

pub(crate) fn rendered_shell_text_has_heredoc_tail(text: &str) -> bool {
    text.lines()
        .any(|line| rendered_heredoc_tail_start(line).is_some())
}

pub(crate) fn rendered_heredoc_tail_start(line: &str) -> Option<RenderedHeredocTail> {
    let start = heredoc_start(line)?;
    Some(RenderedHeredocTail {
        delimiter: start.delimiter.to_string(),
        strip_tabs: start.strip_tabs,
        command_indent: leading_shell_indent(line).to_string(),
    })
}

pub(crate) fn normalize_rendered_heredoc_start_spacing(line: &str) -> Option<String> {
    let operator_end = heredoc_start(line)?.operator_end;
    let target_start = line[operator_end..]
        .char_indices()
        .find_map(|(index, ch)| (!matches!(ch, ' ' | '\t' | '\r')).then_some(operator_end + index))
        .unwrap_or(line.len());
    if target_start == operator_end || target_start == line.len() {
        return None;
    }

    let mut normalized = String::with_capacity(line.len());
    normalized.push_str(&line[..operator_end]);
    normalized.push_str(&line[target_start..]);
    Some(normalized)
}

#[derive(Clone, Copy)]
pub(crate) enum CommandSubstitutionPipelineContinuation {
    None,
    Comment,
    StructuralPipe { line_started_in_quote: bool },
}

#[derive(Default)]
pub(crate) struct RawLineQuoteState {
    single_quoted: bool,
    double_quoted: bool,
    escaped: bool,
}

impl RawLineQuoteState {
    fn reset(&mut self) {
        *self = Self::default();
    }

    pub(crate) fn in_quote(&self) -> bool {
        self.single_quoted || self.double_quoted
    }

    fn operator_is_unquoted_at(&mut self, line: &str, operator_offset: usize) -> bool {
        for (offset, ch) in line.char_indices() {
            if offset == operator_offset {
                return !self.in_quote() && !self.escaped;
            }

            self.consume(ch);
        }

        false
    }

    fn consume(&mut self, ch: char) {
        if self.escaped {
            self.escaped = false;
            return;
        }

        match ch {
            '\\' if !self.single_quoted => self.escaped = true,
            '\'' if !self.double_quoted => {
                self.single_quoted = !self.single_quoted;
            }
            '"' if !self.single_quoted => {
                self.double_quoted = !self.double_quoted;
            }
            _ => {}
        }
    }
}

pub(crate) fn command_substitution_pipeline_stage_continuation(
    line: &str,
    was_pipeline_stage: bool,
    quote_state: &mut RawLineQuoteState,
) -> CommandSubstitutionPipelineContinuation {
    let content = line.trim_end_matches(['\r', '\n']);
    let scan_start = command_substitution_context_start(content).unwrap_or(0);
    if scan_start > 0 {
        quote_state.reset();
    }
    let content = &content[scan_start..];

    if was_pipeline_stage
        && !quote_state.in_quote()
        && content.trim_start_matches([' ', '\t']).starts_with('#')
    {
        return CommandSubstitutionPipelineContinuation::Comment;
    }
    let line_started_in_quote = quote_state.in_quote();
    if rendered_line_ends_with_command_substitution_continuation_in_quote_state(
        content,
        quote_state,
    ) {
        CommandSubstitutionPipelineContinuation::StructuralPipe {
            line_started_in_quote,
        }
    } else {
        CommandSubstitutionPipelineContinuation::None
    }
}

pub(crate) fn rendered_line_opens_command_substitution_pipeline(line: &str) -> bool {
    if !rendered_line_ends_with_structural_pipe_continuation(line) {
        return false;
    }

    command_substitution_context_start(line).is_some()
        && line
            .bytes()
            .take_while(|byte| matches!(*byte, b' ' | b'\t'))
            .any(|byte| byte == b' ')
}

pub(crate) fn rendered_line_ends_with_structural_pipe_continuation(line: &str) -> bool {
    let trimmed = line.trim_end_matches([' ', '\t', '\r']);
    let Some((pipe_offset, scan_end)) = final_pipe_operator_bounds(trimmed) else {
        return false;
    };
    let scan_start = command_substitution_context_start(&trimmed[..pipe_offset]).unwrap_or(0);

    final_pipe_operator_is_unquoted(&trimmed[scan_start..scan_end])
}

pub(crate) fn command_substitution_context_start(line: &str) -> Option<usize> {
    line.rfind("$(")
        .or_else(|| line.rfind("<("))
        .or_else(|| line.rfind(">("))
        .map(|offset| offset.saturating_add(2))
}

fn rendered_line_ends_with_command_substitution_continuation_in_quote_state(
    line: &str,
    quote_state: &mut RawLineQuoteState,
) -> bool {
    let trimmed = line.trim_end_matches([' ', '\t', '\r']);
    let operator_offset =
        final_command_substitution_continuation_operator_bounds(trimmed).map(|(offset, _)| offset);
    let Some(operator_offset) = operator_offset else {
        for ch in trimmed.chars() {
            quote_state.consume(ch);
        }
        quote_state.escaped = false;
        return false;
    };

    let operator_is_unquoted = quote_state.operator_is_unquoted_at(trimmed, operator_offset);
    quote_state.escaped = false;
    operator_is_unquoted
}

fn final_pipe_operator_bounds(line: &str) -> Option<(usize, usize)> {
    if line.ends_with("|&") {
        Some((line.len().saturating_sub(2), line.len()))
    } else if line.ends_with('|') && !line.ends_with("||") {
        Some((line.len().saturating_sub(1), line.len()))
    } else {
        None
    }
}

fn final_command_substitution_continuation_operator_bounds(line: &str) -> Option<(usize, usize)> {
    if line.ends_with("||") || line.ends_with("&&") || line.ends_with("|&") {
        Some((line.len().saturating_sub(2), line.len()))
    } else if line.ends_with('|') {
        Some((line.len().saturating_sub(1), line.len()))
    } else {
        None
    }
}

fn final_pipe_operator_is_unquoted(text: &str) -> bool {
    let Some((pipe_offset, _)) =
        final_pipe_operator_bounds(text.trim_end_matches([' ', '\t', '\r']))
    else {
        return false;
    };
    let mut quote_state = RawLineQuoteState::default();
    quote_state.operator_is_unquoted_at(text, pipe_offset)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn pipeline_continuation_normalization_moves_leading_operator() {
        assert_eq!(
            normalize_raw_pipeline_continuations("echo a \\\n  | grep a"),
            Some("echo a |\n  grep a".to_string())
        );
    }

    #[test]
    fn raw_scanner_finds_shell_comments_outside_quotes() {
        let source = "echo '# no' \"# no\" value#no # yes";
        let scanner = RawShellScanner::new(source);

        assert_eq!(scanner.find_comment(0, source.len()), Some(28));
    }

    #[test]
    fn raw_scanner_matches_nested_command_substitutions() {
        let raw = "$(echo \"$(date +%s)\" '(')";
        let scanner = RawShellScanner::new(raw);

        assert_eq!(
            scanner.matching_command_substitution_close(2),
            Some(raw.len() - 1)
        );
    }

    #[test]
    fn raw_scanner_finds_unclosed_process_substitution() {
        let raw = "cat <(printf '%s\\n' \"$(value)\"";
        let scanner = RawShellScanner::new(raw);

        assert!(scanner.has_unclosed_substitution_before(raw.len()));
    }

    #[test]
    fn raw_scanner_skips_escaped_command_substitutions() {
        let raw = r#"echo \$(skip) "$(keep)" '$(skip)'"#;
        let scanner = RawShellScanner::new(raw);

        assert_eq!(scanner.next_command_substitution(0), Some((15, 21)));
    }

    #[test]
    fn unclosed_command_substitution_ignores_quoted_open() {
        assert!(!RawShellText::new("'$(").has_unclosed_command_substitution_open());
        assert!(RawShellText::new("value=$(echo").has_unclosed_command_substitution_open());
        assert_eq!(
            RawShellText::new("$(closed) $(open").unclosed_command_substitution_body_start(),
            Some(12)
        );
    }

    #[test]
    fn rendered_pipeline_continuation_ignores_quoted_pipe() {
        assert!(!rendered_line_ends_with_structural_pipe_continuation(
            "printf '%s|'"
        ));
        assert!(rendered_line_ends_with_structural_pipe_continuation(
            "printf '%s' |"
        ));
    }
}
