use super::*;

#[derive(Debug, Clone)]
pub struct DeclarationAssignmentProbe {
    kind: DeclarationKind,
    readonly_flag: bool,
    target_name: Box<str>,
    target_name_span: Span,
    has_command_substitution: bool,
    status_capture: bool,
}

impl DeclarationAssignmentProbe {
    pub fn kind(&self) -> &DeclarationKind {
        &self.kind
    }

    pub fn readonly_flag(&self) -> bool {
        self.readonly_flag
    }

    pub fn target_name(&self) -> &str {
        &self.target_name
    }

    pub fn target_name_span(&self) -> Span {
        self.target_name_span
    }

    pub fn has_command_substitution(&self) -> bool {
        self.has_command_substitution
    }

    pub fn status_capture(&self) -> bool {
        self.status_capture
    }
}

#[derive(Debug, Clone)]
pub struct BindingValueFact<'a> {
    kind: BindingValueKind<'a>,
    standalone_status_or_pid_capture: bool,
    uses_only_self_default_parameter_expansions: bool,
    conditional_assignment_shortcut: bool,
    one_sided_short_circuit_assignment: bool,
    zsh_selectorless_subscript_value_base_names: Box<[Name]>,
}

#[derive(Debug, Clone)]
pub(crate) enum BindingValueKind<'a> {
    Scalar(&'a Word),
    Loop(Box<[&'a Word]>),
}

impl<'a> BindingValueFact<'a> {
    fn scalar(
        word: &'a Word,
        target_name: &Name,
        semantic: &'a LinterSemanticArtifacts<'a>,
        source: &str,
    ) -> Self {
        Self::scalar_with_status_or_pid_capture(
            word,
            word_is_standalone_status_or_pid_capture(word),
            target_name,
            semantic,
            source,
        )
    }

    fn scalar_with_status_or_pid_capture(
        word: &'a Word,
        standalone_status_or_pid_capture: bool,
        target_name: &Name,
        semantic: &'a LinterSemanticArtifacts<'a>,
        source: &str,
    ) -> Self {
        Self {
            kind: BindingValueKind::Scalar(word),
            standalone_status_or_pid_capture,
            uses_only_self_default_parameter_expansions:
                word_uses_only_self_default_parameter_expansions(
                    word,
                    target_name,
                    semantic,
                    source,
                ),
            conditional_assignment_shortcut: false,
            one_sided_short_circuit_assignment: false,
            zsh_selectorless_subscript_value_base_names:
                word_zsh_selectorless_subscript_value_base_names(word, source),
        }
    }

    fn from_loop_words(words: Box<[&'a Word]>) -> Self {
        Self {
            kind: BindingValueKind::Loop(words),
            standalone_status_or_pid_capture: false,
            uses_only_self_default_parameter_expansions: false,
            conditional_assignment_shortcut: false,
            one_sided_short_circuit_assignment: false,
            zsh_selectorless_subscript_value_base_names: Box::default(),
        }
    }

    pub fn scalar_word(&self) -> Option<&'a Word> {
        match &self.kind {
            BindingValueKind::Scalar(word) => Some(*word),
            BindingValueKind::Loop(_) => None,
        }
    }

    pub fn loop_words(&self) -> Option<&[&'a Word]> {
        match &self.kind {
            BindingValueKind::Scalar(_) => None,
            BindingValueKind::Loop(words) => Some(words.as_ref()),
        }
    }

    pub fn conditional_assignment_shortcut(&self) -> bool {
        self.conditional_assignment_shortcut
    }

    pub fn one_sided_short_circuit_assignment(&self) -> bool {
        self.one_sided_short_circuit_assignment
    }

    pub fn standalone_status_or_pid_capture(&self) -> bool {
        self.standalone_status_or_pid_capture
    }

    pub fn uses_only_self_default_parameter_expansions(&self) -> bool {
        self.uses_only_self_default_parameter_expansions
    }

    pub fn zsh_selectorless_subscript_value(&self) -> bool {
        !self.zsh_selectorless_subscript_value_base_names.is_empty()
    }

    pub fn zsh_selectorless_subscript_value_references_base_name(&self, name: &Name) -> bool {
        self.zsh_selectorless_subscript_value_base_names
            .iter()
            .any(|base_name| base_name == name)
    }

    fn mark_conditional_assignment_shortcut(&mut self) {
        self.conditional_assignment_shortcut = true;
    }

    fn mark_one_sided_short_circuit_assignment(&mut self) {
        self.one_sided_short_circuit_assignment = true;
    }
}

#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
struct SelfParameterExpansionUses {
    default_operator: bool,
    other_read: bool,
}

fn word_uses_only_self_default_parameter_expansions<'a>(
    word: &'a Word,
    name: &Name,
    semantic: &'a LinterSemanticArtifacts<'a>,
    source: &str,
) -> bool {
    let mut uses = SelfParameterExpansionUses::default();
    collect_self_parameter_expansion_uses_in_word(word, name, semantic, source, &mut uses);
    uses.default_operator && !uses.other_read
}

fn collect_self_parameter_expansion_uses_in_word<'a>(
    word: &'a Word,
    name: &Name,
    semantic: &'a LinterSemanticArtifacts<'a>,
    source: &str,
    uses: &mut SelfParameterExpansionUses,
) {
    let mut visitor = SelfParameterExpansionUseVisitor {
        name,
        semantic,
        source,
        uses,
    };
    walk_word_subtree(
        word,
        WordTraversalContext {
            source,
            locator: None,
            shell_dialect: shuck_semantic::ShellDialect::Bash,
        },
        &mut visitor,
    );
}

struct SelfParameterExpansionUseVisitor<'name, 'uses, 'a> {
    name: &'name Name,
    semantic: &'a LinterSemanticArtifacts<'a>,
    source: &'a str,
    uses: &'uses mut SelfParameterExpansionUses,
}

impl<'name, 'uses, 'a> SelfParameterExpansionUseVisitor<'name, 'uses, 'a> {
    fn collect_command_body(&mut self, body: &'a StmtSeq) {
        let name = self.name;
        let semantic = self.semantic;
        let source = self.source;
        semantic
            .command_topology()
            .body(body)
            .for_each_command_visit(true, |_, visit| {
                collect_command_self_parameter_uses(
                    visit.command,
                    name,
                    semantic,
                    source,
                    self.uses,
                );
                CommandTopologyTraversal::Descend
            });
    }
}

fn collect_command_self_parameter_uses<'a>(
    command: &'a Command,
    name: &Name,
    semantic: &'a LinterSemanticArtifacts<'a>,
    source: &str,
    uses: &mut SelfParameterExpansionUses,
) {
    visit_command_substitution_loop_header_words(command, &mut |word| {
        collect_self_parameter_expansion_uses_in_word(word, name, semantic, source, uses);
    });
    visit_command_argument_words_for_substitutions(command, source, &mut |word| {
        collect_self_parameter_expansion_uses_in_word(word, name, semantic, source, uses);
    });
    for assignment in command_assignments(command) {
        collect_assignment_value_self_parameter_uses(
            &assignment.value,
            name,
            semantic,
            source,
            uses,
        );
    }
    for operand in declaration_operands(command) {
        if let DeclOperand::Assignment(assignment) = operand {
            collect_assignment_value_self_parameter_uses(
                &assignment.value,
                name,
                semantic,
                source,
                uses,
            );
        }
    }
}

fn collect_assignment_value_self_parameter_uses<'a>(
    value: &'a AssignmentValue,
    name: &Name,
    semantic: &'a LinterSemanticArtifacts<'a>,
    source: &str,
    uses: &mut SelfParameterExpansionUses,
) {
    match value {
        AssignmentValue::Scalar(word) => {
            collect_self_parameter_expansion_uses_in_word(word, name, semantic, source, uses);
        }
        AssignmentValue::Compound(array) => {
            for element in &array.elements {
                match element {
                    ArrayElem::Sequential(word) => {
                        collect_self_parameter_expansion_uses_in_word(
                            word, name, semantic, source, uses,
                        );
                    }
                    ArrayElem::Keyed { key, value } | ArrayElem::KeyedAppend { key, value } => {
                        visit_subscript_words(Some(key), source, &mut |word| {
                            collect_self_parameter_expansion_uses_in_word(
                                word, name, semantic, source, uses,
                            );
                        });
                        collect_self_parameter_expansion_uses_in_word(
                            value, name, semantic, source, uses,
                        );
                    }
                }
            }
        }
    }
}

impl<'name, 'uses, 'a> WordSubtreeVisitor<'a>
    for SelfParameterExpansionUseVisitor<'name, 'uses, 'a>
{
    fn visit_part(&mut self, part: &'a WordPartNode, _state: WordTraversalState<'a>) {
        match &part.kind {
            WordPart::Literal(_)
            | WordPart::ZshQualifiedGlob(_)
            | WordPart::SingleQuoted { .. }
            | WordPart::CommandSubstitution { .. } => {}
            WordPart::ArithmeticExpansion {
                expression_ast,
                expression_word_ast,
                ..
            } => {
                if let Some(expression) = expression_ast {
                    collect_arithmetic_expression_self_uses(
                        expression,
                        self.name,
                        self.semantic,
                        self.source,
                        self.uses,
                    );
                } else {
                    collect_self_parameter_expansion_uses_in_word(
                        expression_word_ast,
                        self.name,
                        self.semantic,
                        self.source,
                        self.uses,
                    );
                }
            }
            WordPart::ProcessSubstitution { body, .. } => self.collect_command_body(body),
            WordPart::DoubleQuoted { .. } => {}
            WordPart::Variable(name) => record_name_read(name, self.name, self.uses),
            WordPart::Parameter(parameter) => collect_parameter_expansion_self_uses(
                parameter,
                self.name,
                self.semantic,
                self.source,
                self.uses,
            ),
            WordPart::ParameterExpansion {
                reference,
                operator,
                ..
            } => record_parameter_operation_use(
                reference,
                operator,
                self.name,
                self.semantic,
                self.source,
                self.uses,
            ),
            WordPart::Length(reference)
            | WordPart::ArrayAccess(reference)
            | WordPart::ArrayLength(reference)
            | WordPart::ArrayIndices(reference)
            | WordPart::Substring { reference, .. }
            | WordPart::ArraySlice { reference, .. }
            | WordPart::Transformation { reference, .. } => {
                record_var_ref_read(reference, self.name, self.semantic, self.source, self.uses)
            }
            WordPart::IndirectExpansion { reference, .. } => {
                record_var_ref_read(reference, self.name, self.semantic, self.source, self.uses)
            }
            WordPart::PrefixMatch { .. } => {}
        }
    }

    fn visit_command_substitution(
        &mut self,
        part: &'a WordPartNode,
        _state: WordTraversalState<'a>,
    ) {
        if let WordPart::CommandSubstitution { body, .. } = &part.kind {
            self.collect_command_body(body);
        }
    }
}

fn collect_arithmetic_expression_self_uses(
    expression: &ArithmeticExprNode,
    name: &Name,
    semantic: &LinterSemanticArtifacts<'_>,
    source: &str,
    uses: &mut SelfParameterExpansionUses,
) {
    match &expression.kind {
        ArithmeticExpr::Number(_) => {}
        ArithmeticExpr::Variable(variable) => record_name_read(variable, name, uses),
        ArithmeticExpr::Indexed {
            name: variable,
            index,
        } => {
            record_name_read(variable, name, uses);
            collect_arithmetic_expression_self_uses(index, name, semantic, source, uses);
        }
        ArithmeticExpr::ShellWord(word) => {
            collect_self_parameter_expansion_uses_in_word(word, name, semantic, source, uses);
        }
        ArithmeticExpr::Parenthesized { expression } => {
            collect_arithmetic_expression_self_uses(expression, name, semantic, source, uses);
        }
        ArithmeticExpr::Unary { expr, .. } | ArithmeticExpr::Postfix { expr, .. } => {
            collect_arithmetic_expression_self_uses(expr, name, semantic, source, uses);
        }
        ArithmeticExpr::Binary { left, right, .. } => {
            collect_arithmetic_expression_self_uses(left, name, semantic, source, uses);
            collect_arithmetic_expression_self_uses(right, name, semantic, source, uses);
        }
        ArithmeticExpr::Conditional {
            condition,
            then_expr,
            else_expr,
        } => {
            collect_arithmetic_expression_self_uses(condition, name, semantic, source, uses);
            collect_arithmetic_expression_self_uses(then_expr, name, semantic, source, uses);
            collect_arithmetic_expression_self_uses(else_expr, name, semantic, source, uses);
        }
        ArithmeticExpr::Assignment { target, value, .. } => {
            collect_arithmetic_lvalue_self_uses(target, name, semantic, source, uses);
            collect_arithmetic_expression_self_uses(value, name, semantic, source, uses);
        }
    }
}

fn collect_arithmetic_lvalue_self_uses(
    target: &ArithmeticLvalue,
    name: &Name,
    semantic: &LinterSemanticArtifacts<'_>,
    source: &str,
    uses: &mut SelfParameterExpansionUses,
) {
    match target {
        ArithmeticLvalue::Variable(variable) => record_name_read(variable, name, uses),
        ArithmeticLvalue::Indexed {
            name: variable,
            index,
        } => {
            record_name_read(variable, name, uses);
            collect_arithmetic_expression_self_uses(index, name, semantic, source, uses);
        }
    }
}

fn collect_parameter_expansion_self_uses(
    parameter: &ParameterExpansion,
    name: &Name,
    semantic: &LinterSemanticArtifacts<'_>,
    source: &str,
    uses: &mut SelfParameterExpansionUses,
) {
    match &parameter.syntax {
        ParameterExpansionSyntax::Bourne(syntax) => {
            collect_bourne_parameter_expansion_self_uses(syntax, name, semantic, source, uses);
        }
        ParameterExpansionSyntax::Zsh(syntax) => {
            collect_zsh_parameter_expansion_self_uses(syntax, name, semantic, source, uses);
        }
    }
}

fn collect_bourne_parameter_expansion_self_uses(
    syntax: &BourneParameterExpansion,
    name: &Name,
    semantic: &LinterSemanticArtifacts<'_>,
    source: &str,
    uses: &mut SelfParameterExpansionUses,
) {
    match syntax {
        BourneParameterExpansion::Access { reference }
        | BourneParameterExpansion::Length { reference }
        | BourneParameterExpansion::Indices { reference }
        | BourneParameterExpansion::Transformation { reference, .. } => {
            record_var_ref_read(reference, name, semantic, source, uses);
        }
        BourneParameterExpansion::Operation {
            reference,
            operator,
            ..
        } => record_parameter_operation_use(reference, operator, name, semantic, source, uses),
        BourneParameterExpansion::Indirect { reference, .. } => {
            record_var_ref_read(reference, name, semantic, source, uses);
        }
        BourneParameterExpansion::Slice { reference, .. } => {
            record_var_ref_read(reference, name, semantic, source, uses);
        }
        BourneParameterExpansion::PrefixMatch { .. } => {}
    }
}

fn collect_zsh_parameter_expansion_self_uses(
    syntax: &ZshParameterExpansion,
    name: &Name,
    semantic: &LinterSemanticArtifacts<'_>,
    source: &str,
    uses: &mut SelfParameterExpansionUses,
) {
    match &syntax.target {
        ZshExpansionTarget::Reference(reference) => match &syntax.operation {
            Some(ZshExpansionOperation::Defaulting {
                kind:
                    shuck_ast::ZshDefaultingOp::UseDefault | shuck_ast::ZshDefaultingOp::AssignDefault,
                ..
            }) => record_parameter_default_use(reference, name, semantic, source, uses),
            _ => record_var_ref_read(reference, name, semantic, source, uses),
        },
        ZshExpansionTarget::Nested(parameter) => {
            collect_parameter_expansion_self_uses(parameter, name, semantic, source, uses);
        }
        ZshExpansionTarget::Word(_) | ZshExpansionTarget::Empty => {}
    }
}

fn record_parameter_operation_use(
    reference: &VarRef,
    operator: &ParameterOp,
    name: &Name,
    semantic: &LinterSemanticArtifacts<'_>,
    source: &str,
    uses: &mut SelfParameterExpansionUses,
) {
    if matches!(
        operator,
        ParameterOp::UseDefault | ParameterOp::AssignDefault
    ) {
        record_parameter_default_use(reference, name, semantic, source, uses);
    } else {
        record_var_ref_read(reference, name, semantic, source, uses);
    }
}

fn record_parameter_default_use(
    reference: &VarRef,
    name: &Name,
    semantic: &LinterSemanticArtifacts<'_>,
    source: &str,
    uses: &mut SelfParameterExpansionUses,
) {
    if reference.name.as_str() == name.as_str() {
        uses.default_operator = true;
    }
    visit_var_ref_subscript_words_with_source(reference, source, &mut |word| {
        collect_self_parameter_expansion_uses_in_word(word, name, semantic, source, uses);
    });
}

fn record_var_ref_read(
    reference: &VarRef,
    name: &Name,
    semantic: &LinterSemanticArtifacts<'_>,
    source: &str,
    uses: &mut SelfParameterExpansionUses,
) {
    record_name_read(&reference.name, name, uses);
    visit_var_ref_subscript_words_with_source(reference, source, &mut |word| {
        collect_self_parameter_expansion_uses_in_word(word, name, semantic, source, uses);
    });
}

fn record_name_read(name: &Name, target_name: &Name, uses: &mut SelfParameterExpansionUses) {
    if name.as_str() == target_name.as_str() {
        uses.other_read = true;
    }
}

pub(crate) fn command_may_have_assignment_spacing_candidate(
    command: &Command,
    source: &str,
) -> bool {
    match command {
        Command::Simple(command) => {
            simple_command_may_have_assignment_spacing_candidate(command, source)
        }
        Command::Decl(command) => {
            command
                .assignments
                .iter()
                .any(assignment_reports_assignment_spacing)
                || declaration_operands_may_have_assignment_spacing_candidate(
                    &command.operands,
                    source,
                )
        }
        Command::Builtin(command) => match command {
            BuiltinCommand::Break(command) => command
                .assignments
                .iter()
                .any(assignment_reports_assignment_spacing),
            BuiltinCommand::Continue(command) => command
                .assignments
                .iter()
                .any(assignment_reports_assignment_spacing),
            BuiltinCommand::Return(command) => command
                .assignments
                .iter()
                .any(assignment_reports_assignment_spacing),
            BuiltinCommand::Exit(command) => command
                .assignments
                .iter()
                .any(assignment_reports_assignment_spacing),
        },
        Command::Binary(_)
        | Command::Compound(_)
        | Command::Function(_)
        | Command::AnonymousFunction(_) => false,
    }
}

fn simple_command_may_have_assignment_spacing_candidate(
    command: &SimpleCommand,
    source: &str,
) -> bool {
    for (index, assignment) in command.assignments.iter().enumerate() {
        if assignment_reports_assignment_spacing(assignment)
            && (index + 1 < command.assignments.len() || !span_is_empty(command.name.span))
        {
            return true;
        }
    }

    !span_is_empty(command.name.span)
        && word_reports_assignment_spacing(&command.name, source)
        && !command.args.is_empty()
}

fn declaration_operands_may_have_assignment_spacing_candidate(
    operands: &[DeclOperand],
    source: &str,
) -> bool {
    operands.iter().enumerate().any(|(index, operand)| {
        declaration_operand_reports_assignment_spacing(operand, source)
            && index + 1 < operands.len()
    })
}

pub(crate) fn source_may_have_assignment_spacing_candidate(source: &str) -> bool {
    let bytes = source.as_bytes();
    let mut index = 0;

    while index < bytes.len() {
        if bytes[index] != b'=' || !assignment_spacing_gap_can_start_at(bytes, index + 1) {
            index += 1;
            continue;
        }

        let target_end = if index > 0 && bytes[index - 1] == b'+' {
            index - 1
        } else {
            index
        };
        if target_end == 0 {
            index += 1;
            continue;
        }

        if bytes[target_end - 1] == b']' {
            return true;
        }

        let mut target_start = target_end;
        while target_start > 0 && is_shell_name_continue_byte(bytes[target_start - 1]) {
            target_start -= 1;
        }
        if target_start == target_end
            || !is_shell_name_start_byte(bytes[target_start])
            || &source[target_start..target_end] == "IFS"
        {
            index += 1;
            continue;
        }

        return true;
    }

    false
}

fn assignment_spacing_gap_can_start_at(bytes: &[u8], start: usize) -> bool {
    matches!(bytes.get(start), Some(b' ' | b'\t'))
        || matches!(
            (bytes.get(start), bytes.get(start + 1)),
            (Some(b'\\'), Some(b'\n'))
        )
        || matches!(
            (bytes.get(start), bytes.get(start + 1), bytes.get(start + 2)),
            (Some(b'\\'), Some(b'\r'), Some(b'\n'))
        )
}

fn is_shell_name_start_byte(byte: u8) -> bool {
    byte == b'_' || byte.is_ascii_alphabetic()
}

fn is_shell_name_continue_byte(byte: u8) -> bool {
    is_shell_name_start_byte(byte) || byte.is_ascii_digit()
}

#[cfg_attr(shuck_profiling, inline(never))]
pub(crate) fn build_assignment_spacing_spans(
    commands: &[CommandFact<'_>],
    source: &str,
) -> Vec<Span> {
    let mut spans = Vec::new();

    for fact in commands {
        collect_assignment_spacing_spans_in_command(fact.command(), source, &mut spans);
    }

    spans.sort_unstable_by_key(|span| (span.start.offset(), span.end.offset()));
    spans.dedup();
    spans
}

fn collect_assignment_spacing_spans_in_command(
    command: &Command,
    source: &str,
    spans: &mut Vec<Span>,
) {
    match command {
        Command::Simple(command) => {
            collect_simple_assignment_spacing_spans(command, source, spans);
        }
        Command::Decl(command) => {
            collect_prefix_assignment_spacing_spans(
                &command.assignments,
                command.variant_span,
                source,
                spans,
            );
            collect_declaration_operand_assignment_spacing_spans(&command.operands, source, spans);
        }
        Command::Builtin(command) => match command {
            BuiltinCommand::Break(command) => {
                collect_prefix_assignment_spacing_spans(
                    &command.assignments,
                    first_word_start_after(command.span, &command.assignments, source),
                    source,
                    spans,
                );
            }
            BuiltinCommand::Continue(command) => {
                collect_prefix_assignment_spacing_spans(
                    &command.assignments,
                    first_word_start_after(command.span, &command.assignments, source),
                    source,
                    spans,
                );
            }
            BuiltinCommand::Return(command) => {
                collect_prefix_assignment_spacing_spans(
                    &command.assignments,
                    first_word_start_after(command.span, &command.assignments, source),
                    source,
                    spans,
                );
            }
            BuiltinCommand::Exit(command) => {
                collect_prefix_assignment_spacing_spans(
                    &command.assignments,
                    first_word_start_after(command.span, &command.assignments, source),
                    source,
                    spans,
                );
            }
        },
        Command::Binary(_)
        | Command::Compound(_)
        | Command::Function(_)
        | Command::AnonymousFunction(_) => {}
    }
}

#[derive(Debug, Clone, Copy)]
struct AssignmentSpacingItem {
    span: Span,
    report: bool,
}

fn collect_simple_assignment_spacing_spans(
    command: &SimpleCommand,
    source: &str,
    spans: &mut Vec<Span>,
) {
    let mut previous = None;
    for assignment in &command.assignments {
        let item = AssignmentSpacingItem {
            span: assignment.span,
            report: assignment_reports_assignment_spacing(assignment),
        };
        collect_assignment_spacing_gap_from_previous(previous, item, source, spans);
        previous = Some(item);
    }

    if !span_is_empty(command.name.span) {
        let name_item = AssignmentSpacingItem {
            span: command.name.span,
            report: word_reports_assignment_spacing(&command.name, source),
        };
        collect_assignment_spacing_gap_from_previous(previous, name_item, source, spans);

        if name_item.report {
            previous = Some(name_item);
            for arg in &command.args {
                let arg_item = AssignmentSpacingItem {
                    span: arg.span,
                    report: word_reports_assignment_spacing(arg, source),
                };
                collect_assignment_spacing_gap_from_previous(previous, arg_item, source, spans);
                if !arg_item.report {
                    break;
                }
                previous = Some(arg_item);
            }
        }
    }
}

fn collect_prefix_assignment_spacing_spans(
    assignments: &[Assignment],
    fallback_next_span: Span,
    source: &str,
    spans: &mut Vec<Span>,
) {
    if assignments.is_empty() {
        return;
    }

    let mut previous = None;
    for assignment in assignments {
        let item = AssignmentSpacingItem {
            span: assignment.span,
            report: assignment_reports_assignment_spacing(assignment),
        };
        collect_assignment_spacing_gap_from_previous(previous, item, source, spans);
        previous = Some(item);
    }
    collect_assignment_spacing_gap_from_previous(
        previous,
        AssignmentSpacingItem {
            span: fallback_next_span,
            report: false,
        },
        source,
        spans,
    );
}

fn collect_declaration_operand_assignment_spacing_spans(
    operands: &[DeclOperand],
    source: &str,
    spans: &mut Vec<Span>,
) {
    let mut previous = None;
    for operand in operands {
        let item = AssignmentSpacingItem {
            span: declaration_operand_span(operand),
            report: declaration_operand_has_empty_assignment_value(operand, source),
        };
        collect_assignment_spacing_gap_from_previous(previous, item, source, spans);
        previous = Some(item);
    }
}

fn collect_assignment_spacing_gap_from_previous(
    previous: Option<AssignmentSpacingItem>,
    current: AssignmentSpacingItem,
    source: &str,
    spans: &mut Vec<Span>,
) {
    let Some(previous) = previous else {
        return;
    };
    if !previous.report {
        return;
    }
    if let Some(span) =
        horizontal_whitespace_span_between(previous.span.end, current.span.start, source)
    {
        spans.push(span);
    }
}

fn assignment_has_empty_scalar_value(assignment: &Assignment) -> bool {
    matches!(
        &assignment.value,
        AssignmentValue::Scalar(word) if span_is_empty(word.span)
    )
}

fn assignment_reports_assignment_spacing(assignment: &Assignment) -> bool {
    assignment_has_empty_scalar_value(assignment)
        && !is_assignment_spacing_exempt_target(assignment.target.name.as_str())
}

fn declaration_operand_has_empty_assignment_value(operand: &DeclOperand, source: &str) -> bool {
    declaration_operand_reports_assignment_spacing(operand, source)
}

fn declaration_operand_reports_assignment_spacing(operand: &DeclOperand, source: &str) -> bool {
    match operand {
        DeclOperand::Assignment(assignment) => assignment_reports_assignment_spacing(assignment),
        DeclOperand::Dynamic(word) => word_reports_assignment_spacing(word, source),
        DeclOperand::Flag(_) | DeclOperand::Name(_) => false,
    }
}

fn declaration_operand_span(operand: &DeclOperand) -> Span {
    match operand {
        DeclOperand::Flag(word) | DeclOperand::Dynamic(word) => word.span,
        DeclOperand::Name(reference) => reference.span,
        DeclOperand::Assignment(assignment) => assignment.span,
    }
}

fn word_reports_assignment_spacing(word: &Word, source: &str) -> bool {
    let Some(target) = plain_literal_assignment_spacing_target(word, source) else {
        return false;
    };
    is_shell_variable_name(target) && !is_assignment_spacing_exempt_target(target)
}

fn plain_literal_assignment_spacing_target<'a>(word: &'a Word, source: &'a str) -> Option<&'a str> {
    let [part] = word.parts.as_slice() else {
        return None;
    };
    let WordPart::Literal(text) = &part.kind else {
        return None;
    };
    let text = text.as_str(source, part.span);
    let target = text.strip_suffix("+=").or_else(|| text.strip_suffix('='))?;
    (!target.is_empty() && !target.chars().any(char::is_whitespace)).then_some(target)
}

fn is_assignment_spacing_exempt_target(target: &str) -> bool {
    target == "IFS"
}

fn first_word_start_after(command_span: Span, assignments: &[Assignment], source: &str) -> Span {
    let start = assignments
        .last()
        .map_or(command_span.start.offset(), |assignment| {
            assignment.span.end.offset()
        });
    let end = command_span.end.offset().min(source.len());
    let next = first_command_word_offset_in_gap(source, start, end);
    let position = command_span
        .start
        .advanced_by(&source[command_span.start.offset()..next]);
    Span::from_positions(position, position)
}

fn first_command_word_offset_in_gap(source: &str, start: usize, end: usize) -> usize {
    let bytes = source.as_bytes();
    let mut index = start;

    while index < end {
        match bytes[index] {
            b' ' | b'\t' | b'\r' | b'\n' => index += 1,
            b'\\' if index + 1 < end && bytes[index + 1] == b'\n' => index += 2,
            b'\\' if index + 2 < end && bytes[index + 1] == b'\r' && bytes[index + 2] == b'\n' => {
                index += 3;
            }
            _ => return index,
        }
    }

    end
}

fn horizontal_whitespace_span_between(
    start: Position,
    end: Position,
    source: &str,
) -> Option<Span> {
    if start.offset() >= end.offset() || end.offset() > source.len() {
        return None;
    }
    let gap = source.get(start.offset()..end.offset())?;
    if gap_is_assignment_spacing_whitespace(gap) {
        Some(Span::from_positions(start, end))
    } else {
        None
    }
}

fn gap_is_assignment_spacing_whitespace(gap: &str) -> bool {
    let bytes = gap.as_bytes();
    let mut index = 0;
    let mut saw_line_continuation = false;

    while index < bytes.len() {
        match bytes[index] {
            b' ' | b'\t' => index += 1,
            b'\\' if bytes.get(index + 1) == Some(&b'\n') => {
                saw_line_continuation = true;
                index += 2;
            }
            b'\\'
                if bytes.get(index + 1) == Some(&b'\r') && bytes.get(index + 2) == Some(&b'\n') =>
            {
                saw_line_continuation = true;
                index += 3;
            }
            _ => return false,
        }
    }

    !gap.is_empty()
        && (saw_line_continuation || bytes.iter().all(|byte| matches!(byte, b' ' | b'\t')))
}

fn span_is_empty(span: Span) -> bool {
    span.start.offset() == span.end.offset()
}

#[cfg_attr(shuck_profiling, inline(never))]
pub(crate) fn build_bare_command_name_assignment_spans<'a>(
    commands: &[CommandFact<'a>],
    word_nodes: &[WordNode<'a>],
    word_occurrences: &[WordOccurrence],
    word_index: &FxHashMap<FactSpan, SmallVec<[WordOccurrenceId; 2]>>,
    source: &str,
) -> Vec<Span> {
    commands
        .iter()
        .filter_map(|command| {
            bare_command_name_assignment_span(
                command,
                word_nodes,
                word_occurrences,
                word_index,
                source,
            )
        })
        .collect()
}

#[cfg_attr(shuck_profiling, inline(never))]
pub(crate) fn build_assignment_like_command_name_spans<'a>(
    commands: &[CommandFact<'a>],
    source: &str,
) -> Vec<Span> {
    let mut spans = Vec::new();

    for fact in commands {
        collect_assignment_like_command_name_spans_in_command(fact, source, &mut spans);
    }

    spans
}

#[cfg_attr(shuck_profiling, inline(never))]
pub(crate) fn build_assign_special_zero_spans<'a>(
    commands: &[CommandFact<'a>],
    source: &str,
) -> Vec<Span> {
    commands
        .iter()
        .filter_map(|fact| match fact.command() {
            Command::Simple(command) => assign_special_zero_span(&command.name, source),
            Command::Builtin(_)
            | Command::Decl(_)
            | Command::Binary(_)
            | Command::Compound(_)
            | Command::Function(_)
            | Command::AnonymousFunction(_) => None,
        })
        .collect()
}

#[derive(Debug, Clone)]
pub struct SpaceyAssignmentFact {
    diagnostic_span: Span,
    replacement: Box<str>,
}

impl SpaceyAssignmentFact {
    pub fn diagnostic_span(&self) -> Span {
        self.diagnostic_span
    }

    pub fn replacement(&self) -> &str {
        &self.replacement
    }
}

#[cfg_attr(shuck_profiling, inline(never))]
pub(crate) fn build_spacey_assignment_facts<'a>(
    commands: &[CommandFact<'a>],
    source: &str,
) -> Vec<SpaceyAssignmentFact> {
    commands
        .iter()
        .filter_map(|fact| match fact.command() {
            Command::Simple(command) => spacey_assignment_fact(command, source),
            Command::Compound(CompoundCommand::Time(command)) => {
                spacey_time_assignment_fact(command, source)
            }
            Command::Builtin(_)
            | Command::Decl(_)
            | Command::Binary(_)
            | Command::Compound(_)
            | Command::Function(_)
            | Command::AnonymousFunction(_) => None,
        })
        .collect()
}

pub(crate) fn collect_assignment_like_command_name_spans_in_command(
    fact: &CommandFact<'_>,
    source: &str,
    spans: &mut Vec<Span>,
) {
    let command = fact.command();
    match command {
        Command::Simple(command) => {
            collect_assignment_like_command_name_span(&command.name, source, spans);
        }
        Command::Decl(command) => {
            for operand in &command.operands {
                if let DeclOperand::Dynamic(word) = operand {
                    if zsh_declaration_brace_assignment_target(word, source, fact.shell_behavior())
                    {
                        continue;
                    }
                    collect_assignment_like_command_name_span(word, source, spans);
                }
            }
        }
        Command::Builtin(_)
        | Command::Binary(_)
        | Command::Compound(_)
        | Command::Function(_)
        | Command::AnonymousFunction(_) => {}
    }
}

pub(crate) fn collect_assignment_like_command_name_span(
    word: &Word,
    source: &str,
    spans: &mut Vec<Span>,
) {
    if let Some(span) = assignment_like_command_name_span(word, source) {
        spans.push(span);
    }
}

pub(crate) fn assignment_like_command_name_span(word: &Word, source: &str) -> Option<Span> {
    let prefix = leading_literal_word_prefix(word, source);
    let target_end = prefix.find("+=").or_else(|| prefix.find('='))?;
    let target = &prefix[..target_end];
    if target.is_empty() || target.chars().any(char::is_whitespace) {
        return None;
    }

    if let Some(remainder) = target.strip_prefix('+') {
        is_shell_variable_name(remainder).then_some(word.span)
    } else {
        (!is_shell_variable_name(target)).then_some(word.span)
    }
}

fn assign_special_zero_span(word: &Word, source: &str) -> Option<Span> {
    let text = word.span.slice(source);
    if text.starts_with("0=") {
        return Some(word.span);
    }

    if text.starts_with("+0=") {
        return Some(Span::from_positions(
            word.span.start.advanced_by("+"),
            word.span.end,
        ));
    }

    None
}

fn spacey_assignment_fact(command: &SimpleCommand, source: &str) -> Option<SpaceyAssignmentFact> {
    let target = command.name.span.slice(source);
    if !is_shell_variable_name(target) {
        return None;
    }

    let first_arg = command.args.first()?;
    let first_arg_text = first_arg.span.slice(source);
    if first_arg_text == "=" {
        let value = command.args.get(1);
        let end = value.map_or(first_arg.span.end, |word| word.span.end);
        let value = value.map_or("", |word| word.span.slice(source));
        return Some(SpaceyAssignmentFact {
            diagnostic_span: Span::from_positions(command.name.span.start, end),
            replacement: format!("{target}={value}").into_boxed_str(),
        });
    }

    first_arg_text
        .strip_prefix('=')
        .filter(|value| !value.is_empty() && !value.starts_with('='))
        .map(|_| SpaceyAssignmentFact {
            diagnostic_span: Span::from_positions(command.name.span.start, first_arg.span.end),
            replacement: format!("{target}{first_arg_text}").into_boxed_str(),
        })
}

fn spacey_time_assignment_fact(
    command: &TimeCommand,
    source: &str,
) -> Option<SpaceyAssignmentFact> {
    if command.posix_format {
        return None;
    }

    let timed = command.command.as_deref()?;
    let Command::Simple(inner) = &timed.command else {
        return None;
    };
    if let Some(fact) = spacey_assignment_fact(inner, source) {
        return Some(fact);
    }

    let prefix = source.get(command.span.start.offset()..inner.name.span.start.offset())?;
    if prefix.trim() != "time" {
        return None;
    }

    let operator_text = inner.name.span.slice(source);
    if operator_text == "=" {
        let value = inner.args.first();
        let end = value.map_or(inner.name.span.end, |word| word.span.end);
        let value = value.map_or("", |word| word.span.slice(source));
        return Some(SpaceyAssignmentFact {
            diagnostic_span: Span::from_positions(command.span.start, end),
            replacement: format!("time={value}").into_boxed_str(),
        });
    }

    operator_text
        .strip_prefix('=')
        .filter(|value| !value.is_empty() && !value.starts_with('='))
        .map(|_| SpaceyAssignmentFact {
            diagnostic_span: Span::from_positions(command.span.start, inner.name.span.end),
            replacement: format!("time{operator_text}").into_boxed_str(),
        })
}

pub(crate) fn zsh_declaration_brace_assignment_target(
    word: &Word,
    source: &str,
    behavior: &ShellBehaviorAt<'_>,
) -> bool {
    if behavior.zsh_options().is_none() {
        return false;
    }

    let prefix = leading_literal_word_prefix(word, source);
    let Some(target_end) = prefix.find("+=").or_else(|| prefix.find('=')) else {
        return false;
    };
    let target_end_offset = word.span.start.offset() + target_end;

    word.brace_syntax()
        .iter()
        .any(|brace| brace.expands() && brace.span.end.offset() <= target_end_offset)
}

pub(crate) fn bare_command_name_assignment_span<'a>(
    command: &CommandFact<'a>,
    word_nodes: &[WordNode<'a>],
    word_occurrences: &[WordOccurrence],
    word_index: &FxHashMap<FactSpan, SmallVec<[WordOccurrenceId; 2]>>,
    source: &str,
) -> Option<Span> {
    let (assignment, anchor_full_command) = match command.command() {
        Command::Simple(simple) if simple.assignments.len() == 1 => (
            &simple.assignments[0],
            !simple.name.span.slice(source).is_empty(),
        ),
        Command::Builtin(BuiltinCommand::Break(builtin)) if builtin.assignments.len() == 1 => {
            (&builtin.assignments[0], true)
        }
        Command::Builtin(BuiltinCommand::Continue(builtin)) if builtin.assignments.len() == 1 => {
            (&builtin.assignments[0], true)
        }
        Command::Builtin(BuiltinCommand::Return(builtin)) if builtin.assignments.len() == 1 => {
            (&builtin.assignments[0], true)
        }
        Command::Builtin(BuiltinCommand::Exit(builtin)) if builtin.assignments.len() == 1 => {
            (&builtin.assignments[0], true)
        }
        Command::Simple(_)
        | Command::Builtin(_)
        | Command::Decl(_)
        | Command::Binary(_)
        | Command::Compound(_)
        | Command::Function(_)
        | Command::AnonymousFunction(_) => return None,
    };

    let AssignmentValue::Scalar(word) = &assignment.value else {
        return None;
    };
    let fact = word_occurrence_with_context(
        word_nodes,
        word_occurrences,
        word_index,
        word.span,
        WordFactContext::Expansion(ExpansionContext::AssignmentValue),
    )?;
    let analysis = occurrence_analysis(word_nodes, fact);
    if analysis.quote != WordQuote::Unquoted
        || analysis.literalness != WordLiteralness::FixedLiteral
    {
        return None;
    }

    let text = occurrence_static_text(word_nodes, fact, source)?;
    if !is_bare_command_name_assignment_value(&text, command.shell_behavior().zsh_options()) {
        return None;
    }

    Some(if anchor_full_command {
        anchored_assignment_command_span(command, assignment, source)
    } else {
        assignment_target_span(assignment)
    })
}

pub(crate) fn anchored_assignment_command_span(
    command: &CommandFact<'_>,
    assignment: &Assignment,
    source: &str,
) -> Span {
    match command.command() {
        Command::Builtin(_) => return command.span_in_source(source),
        Command::Simple(simple) => {
            let end = simple
                .args
                .last()
                .map(|word| word.span.end)
                .unwrap_or(simple.name.span.end);

            return Span {
                start: assignment.span.start,
                end,
            };
        }
        Command::Decl(_)
        | Command::Binary(_)
        | Command::Compound(_)
        | Command::Function(_)
        | Command::AnonymousFunction(_) => {}
    }

    Span {
        start: assignment.span.start,
        end: assignment.span.end,
    }
}

pub(crate) fn assignment_target_span(assignment: &Assignment) -> Span {
    assignment.target.subscript.as_deref().map_or_else(
        || assignment.target.name_span,
        |subscript| {
            Span::from_positions(
                assignment.target.name_span.start,
                subscript.span().end.advanced_by("]"),
            )
        },
    )
}

pub(crate) fn is_bare_command_name_assignment_value(
    text: &str,
    zsh_options: Option<&ZshOptionState>,
) -> bool {
    let text = zsh_literal_assignment_value_for_command_name_check(text, zsh_options);
    matches!(
        text,
        "admin"
            | "alias"
            | "awk"
            | "basename"
            | "bg"
            | "break"
            | "c99"
            | "cat"
            | "cd"
            | "cflow"
            | "chmod"
            | "chown"
            | "cksum"
            | "cmp"
            | "comm"
            | "command"
            | "compress"
            | "continue"
            | "cp"
            | "csplit"
            | "ctags"
            | "cut"
            | "cxref"
            | "date"
            | "dd"
            | "delta"
            | "df"
            | "dirname"
            | "du"
            | "echo"
            | "env"
            | "eval"
            | "ex"
            | "exec"
            | "exit"
            | "expand"
            | "export"
            | "expr"
            | "file"
            | "fg"
            | "find"
            | "fold"
            | "getopts"
            | "grep"
            | "hash"
            | "head"
            | "jobs"
            | "join"
            | "kill"
            | "link"
            | "ln"
            | "ls"
            | "m4"
            | "make"
            | "mkdir"
            | "mkfifo"
            | "more"
            | "mv"
            | "nm"
            | "nice"
            | "nl"
            | "nohup"
            | "od"
            | "paste"
            | "patch"
            | "pathchk"
            | "pax"
            | "printf"
            | "pwd"
            | "read"
            | "readonly"
            | "renice"
            | "return"
            | "rm"
            | "rmdir"
            | "sed"
            | "set"
            | "shift"
            | "sh"
            | "sleep"
            | "sort"
            | "split"
            | "strings"
            | "tail"
            | "test"
            | "time"
            | "touch"
            | "tr"
            | "trap"
            | "tty"
            | "type"
            | "ulimit"
            | "umask"
            | "unalias"
            | "uname"
            | "unexpand"
            | "uniq"
            | "unlink"
            | "unset"
            | "wait"
            | "wc"
            | "xargs"
            | "zcat"
    )
}

pub(crate) fn zsh_literal_assignment_value_for_command_name_check<'a>(
    text: &'a str,
    zsh_options: Option<&ZshOptionState>,
) -> &'a str {
    let Some(candidate) = text.strip_prefix('=') else {
        return text;
    };
    let Some(options) = zsh_options else {
        return text;
    };
    if !options.equals.is_definitely_off() {
        return text;
    }
    candidate
}

#[derive(Debug, Default)]
pub(crate) struct EnvPrefixScopeSpans {
    pub(crate) assignment_scope_spans: Vec<Span>,
    pub(crate) expansion_scope_spans: Vec<Span>,
    pub(crate) expansion_fix_facts: Vec<EnvPrefixExpansionFixFact>,
}

#[derive(Debug, Clone)]
pub struct EnvPrefixExpansionFixFact {
    diagnostic_span: Span,
    assignment_spans: Vec<Span>,
    delete_span: Span,
}

impl EnvPrefixExpansionFixFact {
    pub fn diagnostic_span(&self) -> Span {
        self.diagnostic_span
    }

    pub fn assignment_spans(&self) -> &[Span] {
        &self.assignment_spans
    }

    pub fn delete_span(&self) -> Span {
        self.delete_span
    }
}

#[cfg_attr(shuck_profiling, inline(never))]
pub(crate) fn build_env_prefix_scope_spans(
    source: &str,
    semantic: &SemanticModel,
    commands: &[CommandFact<'_>],
) -> EnvPrefixScopeSpans {
    let mut scope_spans = EnvPrefixScopeSpans::default();
    let mut seen_assignment_scope_spans = FxHashSet::default();
    let mut seen_expansion_scope_spans = FxHashSet::default();

    for command in commands {
        if command_is_assignment_only(command, source) {
            continue;
        }

        let command_span = command.span();
        let assignments = command_assignments(command.command());
        let fix_seed = env_prefix_expansion_fix_seed(source, assignments);
        let broken_legacy_bracket_tail = match command.command() {
            Command::Simple(simple) => broken_legacy_bracket_tail(simple, source),
            Command::Builtin(_)
            | Command::Decl(_)
            | Command::Binary(_)
            | Command::Compound(_)
            | Command::Function(_)
            | Command::AnonymousFunction(_) => None,
        };

        for (index, assignment) in assignments.iter().enumerate() {
            let span_key = FactSpan::new(assignment.target.name_span);
            let earlier_prefix_uses_name = assignments.iter().take(index).any(|other| {
                assignment_mentions_name_outside_nested_commands(
                    semantic,
                    command_span,
                    other,
                    &assignment.target.name,
                )
            });
            let later_prefix_uses_name =
                assignments
                    .iter()
                    .enumerate()
                    .skip(index + 1)
                    .any(|(other_index, other)| {
                        assignment_mentions_name_outside_nested_commands(
                            semantic,
                            command_span,
                            other,
                            &assignment.target.name,
                        ) || match (command.command(), broken_legacy_bracket_tail) {
                            (Command::Simple(simple), Some(tail))
                                if tail.assignment_index == other_index =>
                            {
                                broken_legacy_bracket_tail_mentions_name(
                                    semantic,
                                    command_span,
                                    simple,
                                    tail,
                                    &assignment.target.name,
                                )
                            }
                            (
                                Command::Builtin(_)
                                | Command::Decl(_)
                                | Command::Binary(_)
                                | Command::Compound(_)
                                | Command::Function(_)
                                | Command::AnonymousFunction(_),
                                _,
                            )
                            | (Command::Simple(_), _) => false,
                        }
                    });
            let body_uses_name = command_body_mentions_name_outside_nested_commands(
                semantic,
                command,
                source,
                &assignment.target.name,
            );

            if (earlier_prefix_uses_name
                || later_prefix_uses_name
                || (body_uses_name && !assignment_is_identity_self_copy(assignment)))
                && seen_assignment_scope_spans.insert(span_key)
            {
                scope_spans
                    .assignment_scope_spans
                    .push(assignment.target.name_span);
            }

            for (other_index, other) in assignments.iter().enumerate() {
                if other_index == index {
                    continue;
                }

                let _ = visit_assignment_reference_spans_outside_nested_commands(
                    semantic,
                    command_span,
                    other,
                    &assignment.target.name,
                    &mut |span| {
                        push_env_prefix_expansion_fact(
                            span,
                            fix_seed.as_ref(),
                            &mut scope_spans.expansion_scope_spans,
                            &mut seen_expansion_scope_spans,
                            &mut scope_spans.expansion_fix_facts,
                        );
                        ControlFlow::Continue(())
                    },
                );

                match (command.command(), broken_legacy_bracket_tail) {
                    (Command::Simple(simple), Some(tail))
                        if tail.assignment_index == other_index =>
                    {
                        let _ = visit_broken_legacy_bracket_tail_reference_spans(
                            semantic,
                            command_span,
                            simple,
                            tail,
                            &assignment.target.name,
                            &mut |span| {
                                push_env_prefix_expansion_fact(
                                    span,
                                    fix_seed.as_ref(),
                                    &mut scope_spans.expansion_scope_spans,
                                    &mut seen_expansion_scope_spans,
                                    &mut scope_spans.expansion_fix_facts,
                                );
                                ControlFlow::Continue(())
                            },
                        );
                    }
                    (
                        Command::Builtin(_)
                        | Command::Decl(_)
                        | Command::Binary(_)
                        | Command::Compound(_)
                        | Command::Function(_)
                        | Command::AnonymousFunction(_),
                        _,
                    )
                    | (Command::Simple(_), _) => {}
                }
            }

            if assignments.iter().enumerate().any(|(other_index, other)| {
                other_index != index && other.target.name == assignment.target.name
            }) {
                let _ = visit_assignment_reference_spans_outside_nested_commands(
                    semantic,
                    command_span,
                    assignment,
                    &assignment.target.name,
                    &mut |span| {
                        push_env_prefix_expansion_fact(
                            span,
                            fix_seed.as_ref(),
                            &mut scope_spans.expansion_scope_spans,
                            &mut seen_expansion_scope_spans,
                            &mut scope_spans.expansion_fix_facts,
                        );
                        ControlFlow::Continue(())
                    },
                );
            }

            let _ = visit_command_body_reference_spans_outside_nested_commands(
                semantic,
                command,
                source,
                &assignment.target.name,
                &mut |span| {
                    push_env_prefix_expansion_fact(
                        span,
                        fix_seed.as_ref(),
                        &mut scope_spans.expansion_scope_spans,
                        &mut seen_expansion_scope_spans,
                        &mut scope_spans.expansion_fix_facts,
                    );
                    ControlFlow::Continue(())
                },
            );
        }
    }

    scope_spans
        .assignment_scope_spans
        .sort_by_key(|span| (span.start.offset(), span.end.offset()));
    scope_spans
        .expansion_scope_spans
        .sort_by_key(|span| (span.start.offset(), span.end.offset()));
    scope_spans.expansion_fix_facts.sort_by_key(|fact| {
        (
            fact.diagnostic_span.start.offset(),
            fact.diagnostic_span.end.offset(),
        )
    });
    scope_spans
}

#[derive(Debug, Clone)]
pub(crate) struct EnvPrefixExpansionFixSeed {
    assignment_spans: Vec<Span>,
    delete_span: Span,
}

impl EnvPrefixExpansionFixSeed {
    fn for_diagnostic(&self, diagnostic_span: Span) -> EnvPrefixExpansionFixFact {
        EnvPrefixExpansionFixFact {
            diagnostic_span,
            assignment_spans: self.assignment_spans.clone(),
            delete_span: self.delete_span,
        }
    }
}

pub(crate) fn env_prefix_expansion_fix_seed(
    source: &str,
    assignments: &[Assignment],
) -> Option<EnvPrefixExpansionFixSeed> {
    let first_assignment = assignments.first()?;
    let last_assignment = assignments.last()?;
    let prefix = source.get(
        line_start_offset(source, first_assignment.span.start.offset())
            ..first_assignment.span.start.offset(),
    )?;
    if !prefix.bytes().all(|byte| matches!(byte, b' ' | b'\t')) {
        return None;
    }

    let body_start = skip_env_prefix_separator(source, last_assignment.span.end)?;
    if body_start.offset() <= last_assignment.span.end.offset() {
        return None;
    }

    Some(EnvPrefixExpansionFixSeed {
        assignment_spans: assignments
            .iter()
            .map(|assignment| assignment.span)
            .collect(),
        delete_span: Span::from_positions(first_assignment.span.start, body_start),
    })
}

pub(crate) fn line_start_offset(source: &str, offset: usize) -> usize {
    source[..offset]
        .rfind('\n')
        .map_or(0, |newline| newline + '\n'.len_utf8())
}

pub(crate) fn skip_env_prefix_separator(source: &str, start: Position) -> Option<Position> {
    let mut position = start;
    let mut tail = source.get(start.offset()..)?;
    while let Some(ch) = tail.chars().next() {
        if matches!(ch, ' ' | '\t' | '\r' | '\n') {
            position.advance(ch);
            tail = &tail[ch.len_utf8()..];
            continue;
        }
        if ch == '\\' {
            let mut chars = tail.chars();
            let _ = chars.next();
            if matches!(chars.next(), Some('\n')) {
                position.advance('\\');
                position.advance('\n');
                tail = &tail['\\'.len_utf8() + '\n'.len_utf8()..];
                continue;
            }
        }
        break;
    }
    Some(position)
}

#[derive(Debug, Clone, Copy)]
pub(crate) struct BrokenLegacyBracketTail {
    assignment_index: usize,
    synthetic_word_count: usize,
}

pub(crate) type EnvPrefixReferenceSpanVisitor<'a> = dyn FnMut(Span) -> ControlFlow<()> + 'a;

pub(crate) fn command_is_assignment_only(fact: &CommandFact<'_>, source: &str) -> bool {
    match fact.command() {
        Command::Simple(command) if !command.assignments.is_empty() => {
            fact.literal_name() == Some("")
                || broken_legacy_bracket_tail(command, source)
                    .is_some_and(|tail| tail.synthetic_word_count == command.args.len() + 1)
        }
        Command::Simple(_)
        | Command::Builtin(_)
        | Command::Decl(_)
        | Command::Binary(_)
        | Command::Compound(_)
        | Command::Function(_)
        | Command::AnonymousFunction(_) => false,
    }
}

pub(crate) fn broken_legacy_bracket_tail(
    command: &SimpleCommand,
    source: &str,
) -> Option<BrokenLegacyBracketTail> {
    let assignment_index = command.assignments.len().checked_sub(1)?;
    if !assignment_is_broken_legacy_bracket_arithmetic(&command.assignments[assignment_index]) {
        return None;
    }

    let synthetic_word_count = std::iter::once(&command.name)
        .chain(command.args.iter())
        .position(|word| static_word_text(word, source).as_deref() == Some("]"))?
        + 1;

    Some(BrokenLegacyBracketTail {
        assignment_index,
        synthetic_word_count,
    })
}

pub(crate) fn assignment_is_broken_legacy_bracket_arithmetic(assignment: &Assignment) -> bool {
    let AssignmentValue::Scalar(word) = &assignment.value else {
        return false;
    };
    let [part] = word.parts.as_slice() else {
        return false;
    };
    matches!(
        &part.kind,
        WordPart::ArithmeticExpansion {
            syntax: ArithmeticExpansionSyntax::LegacyBracket,
            expression_ast: None,
            ..
        }
    )
}

pub(crate) fn assignment_mentions_name_outside_nested_commands(
    semantic: &SemanticModel,
    command_span: Span,
    assignment: &Assignment,
    name: &Name,
) -> bool {
    visit_assignment_reference_spans_outside_nested_commands(
        semantic,
        command_span,
        assignment,
        name,
        &mut |_span| ControlFlow::Break(()),
    )
    .is_break()
}

pub(crate) fn command_body_mentions_name_outside_nested_commands(
    semantic: &SemanticModel,
    fact: &CommandFact<'_>,
    source: &str,
    name: &Name,
) -> bool {
    visit_command_body_reference_spans_outside_nested_commands(
        semantic,
        fact,
        source,
        name,
        &mut |_span| ControlFlow::Break(()),
    )
    .is_break()
}

pub(crate) fn simple_command_body_words<'a>(
    command: &'a SimpleCommand,
    source: &'a str,
) -> impl Iterator<Item = &'a Word> {
    let skip =
        broken_legacy_bracket_tail(command, source).map_or(0, |tail| tail.synthetic_word_count);
    std::iter::once(&command.name)
        .chain(command.args.iter())
        .skip(skip)
}

pub(crate) fn broken_legacy_bracket_tail_mentions_name(
    semantic: &SemanticModel,
    command_span: Span,
    command: &SimpleCommand,
    tail: BrokenLegacyBracketTail,
    name: &Name,
) -> bool {
    visit_broken_legacy_bracket_tail_reference_spans(
        semantic,
        command_span,
        command,
        tail,
        name,
        &mut |_span| ControlFlow::Break(()),
    )
    .is_break()
}

pub(crate) fn visit_assignment_reference_spans_outside_nested_commands(
    semantic: &SemanticModel,
    command_span: Span,
    assignment: &Assignment,
    name: &Name,
    visit: &mut EnvPrefixReferenceSpanVisitor<'_>,
) -> ControlFlow<()> {
    visit_named_command_reference_spans_in_subspan(
        semantic,
        command_span,
        assignment.span,
        name,
        visit,
    )
}

pub(crate) fn visit_command_body_reference_spans_outside_nested_commands(
    semantic: &SemanticModel,
    fact: &CommandFact<'_>,
    source: &str,
    name: &Name,
    visit: &mut EnvPrefixReferenceSpanVisitor<'_>,
) -> ControlFlow<()> {
    match fact.command() {
        Command::Simple(command) => {
            for word in simple_command_body_words(command, source) {
                visit_named_command_reference_spans_in_subspan(
                    semantic,
                    fact.span(),
                    word.span,
                    name,
                    visit,
                )?;
            }
        }
        Command::Builtin(command) => {
            for word in builtin_words(command) {
                visit_named_command_reference_spans_in_subspan(
                    semantic,
                    fact.span(),
                    word.span,
                    name,
                    visit,
                )?;
            }
        }
        Command::Decl(command) => {
            for operand in &command.operands {
                let span = match operand {
                    DeclOperand::Flag(word) | DeclOperand::Dynamic(word) => word.span,
                    DeclOperand::Assignment(assignment) => assignment.span,
                    DeclOperand::Name(_) => continue,
                };
                visit_named_command_reference_spans_in_subspan(
                    semantic,
                    fact.span(),
                    span,
                    name,
                    visit,
                )?;
            }
        }
        Command::Binary(_)
        | Command::Compound(_)
        | Command::Function(_)
        | Command::AnonymousFunction(_) => {}
    }

    for word in fact.redirects().iter().filter_map(Redirect::word_target) {
        visit_named_command_reference_spans_in_subspan(
            semantic,
            fact.span(),
            word.span,
            name,
            visit,
        )?;
    }

    ControlFlow::Continue(())
}

pub(crate) fn visit_broken_legacy_bracket_tail_reference_spans(
    semantic: &SemanticModel,
    command_span: Span,
    command: &SimpleCommand,
    tail: BrokenLegacyBracketTail,
    name: &Name,
    visit: &mut EnvPrefixReferenceSpanVisitor<'_>,
) -> ControlFlow<()> {
    let Some(span) = broken_legacy_bracket_tail_span(command, tail) else {
        return ControlFlow::Continue(());
    };

    visit_named_command_reference_spans_in_subspan(semantic, command_span, span, name, visit)
}

pub(crate) fn broken_legacy_bracket_tail_span(
    command: &SimpleCommand,
    tail: BrokenLegacyBracketTail,
) -> Option<Span> {
    let mut words = std::iter::once(&command.name)
        .chain(command.args.iter())
        .take(tail.synthetic_word_count.saturating_sub(1));
    let first = words.next()?;
    let last = words.last().unwrap_or(first);
    Some(Span::from_positions(first.span.start, last.span.end))
}

pub(crate) fn builtin_words(command: &BuiltinCommand) -> Vec<&Word> {
    let mut words = Vec::new();
    match command {
        BuiltinCommand::Break(command) => {
            if let Some(word) = &command.depth {
                words.push(word);
            }
            words.extend(command.extra_args.iter());
        }
        BuiltinCommand::Continue(command) => {
            if let Some(word) = &command.depth {
                words.push(word);
            }
            words.extend(command.extra_args.iter());
        }
        BuiltinCommand::Return(command) => {
            if let Some(word) = &command.code {
                words.push(word);
            }
            words.extend(command.extra_args.iter());
        }
        BuiltinCommand::Exit(command) => {
            if let Some(word) = &command.code {
                words.push(word);
            }
            words.extend(command.extra_args.iter());
        }
    }
    words
}

pub(crate) fn visit_named_command_reference_spans_in_subspan(
    semantic: &SemanticModel,
    command_span: Span,
    subspan: Span,
    name: &Name,
    visit: &mut EnvPrefixReferenceSpanVisitor<'_>,
) -> ControlFlow<()> {
    for reference in semantic.references_in_command_span(command_span, subspan) {
        if &reference.name == name
            && reference_kind_counts_as_env_prefix_command_read(reference.kind)
        {
            visit(reference.span)?;
        }
    }

    ControlFlow::Continue(())
}

pub(crate) fn reference_kind_counts_as_env_prefix_command_read(
    kind: shuck_semantic::ReferenceKind,
) -> bool {
    matches!(
        kind,
        shuck_semantic::ReferenceKind::Expansion
            | shuck_semantic::ReferenceKind::ParameterExpansion
            | shuck_semantic::ReferenceKind::Length
            | shuck_semantic::ReferenceKind::ArrayAccess
            | shuck_semantic::ReferenceKind::IndirectExpansion
            | shuck_semantic::ReferenceKind::ArithmeticRead
            | shuck_semantic::ReferenceKind::ParameterPattern
            | shuck_semantic::ReferenceKind::ParameterSliceArithmetic
            | shuck_semantic::ReferenceKind::ConditionalOperand
            | shuck_semantic::ReferenceKind::RequiredRead
    )
}

pub(crate) fn assignment_is_identity_self_copy(assignment: &Assignment) -> bool {
    if assignment.append {
        return false;
    }

    let AssignmentValue::Scalar(word) = &assignment.value else {
        return false;
    };
    word_is_identity_self_copy(word, &assignment.target.name)
}

pub(crate) fn word_is_identity_self_copy(word: &Word, name: &Name) -> bool {
    let [part] = word.parts.as_slice() else {
        return false;
    };
    word_part_is_identity_self_copy(&part.kind, name)
}

pub(crate) fn word_part_is_identity_self_copy(part: &WordPart, name: &Name) -> bool {
    match part {
        WordPart::Variable(variable) => variable == name,
        WordPart::DoubleQuoted { parts, .. } => {
            let [part] = parts.as_slice() else {
                return false;
            };
            word_part_is_identity_self_copy(&part.kind, name)
        }
        WordPart::Parameter(parameter) => parameter_is_plain_access_to_name(parameter, name),
        _ => false,
    }
}

pub(crate) fn parameter_is_plain_access_to_name(
    parameter: &ParameterExpansion,
    name: &Name,
) -> bool {
    match &parameter.syntax {
        ParameterExpansionSyntax::Bourne(BourneParameterExpansion::Access { reference })
            if reference.subscript.is_none() =>
        {
            &reference.name == name
        }
        ParameterExpansionSyntax::Zsh(syntax)
            if syntax.operation.is_none()
                && matches!(&syntax.target, ZshExpansionTarget::Reference(reference) if reference.subscript.is_none() && &reference.name == name) =>
        {
            true
        }
        _ => false,
    }
}

pub(crate) fn push_fact_span(
    span: Span,
    spans: &mut Vec<Span>,
    seen: &mut FxHashSet<FactSpan>,
) -> bool {
    let key = FactSpan::new(span);
    if seen.insert(key) {
        spans.push(span);
        true
    } else {
        false
    }
}

pub(crate) fn push_env_prefix_expansion_fact(
    span: Span,
    fix_seed: Option<&EnvPrefixExpansionFixSeed>,
    spans: &mut Vec<Span>,
    seen: &mut FxHashSet<FactSpan>,
    fix_facts: &mut Vec<EnvPrefixExpansionFixFact>,
) {
    if !push_fact_span(span, spans, seen) {
        return;
    }

    if let Some(seed) = fix_seed {
        fix_facts.push(seed.for_diagnostic(span));
    }
}

pub(crate) fn build_plus_equals_assignment_spans(commands: &[CommandFact<'_>]) -> Vec<Span> {
    let mut spans = Vec::new();

    for fact in commands {
        collect_plus_equals_assignment_spans_in_command(fact.command(), &mut spans);
    }

    spans
}

pub(crate) fn collect_plus_equals_assignment_spans_in_command(
    command: &Command,
    spans: &mut Vec<Span>,
) {
    match command {
        Command::Simple(command) => {
            collect_plus_equals_assignment_spans_in_assignments(&command.assignments, spans);
        }
        Command::Builtin(command) => match command {
            BuiltinCommand::Break(command) => {
                collect_plus_equals_assignment_spans_in_assignments(&command.assignments, spans);
            }
            BuiltinCommand::Continue(command) => {
                collect_plus_equals_assignment_spans_in_assignments(&command.assignments, spans);
            }
            BuiltinCommand::Return(command) => {
                collect_plus_equals_assignment_spans_in_assignments(&command.assignments, spans);
            }
            BuiltinCommand::Exit(command) => {
                collect_plus_equals_assignment_spans_in_assignments(&command.assignments, spans);
            }
        },
        Command::Decl(command) => {
            collect_plus_equals_assignment_spans_in_assignments(&command.assignments, spans);
            for operand in &command.operands {
                if let DeclOperand::Assignment(assignment) = operand {
                    collect_plus_equals_assignment_span(assignment, spans);
                }
            }
        }
        Command::Binary(_)
        | Command::Compound(_)
        | Command::Function(_)
        | Command::AnonymousFunction(_) => {}
    }
}

pub(crate) fn collect_plus_equals_assignment_spans_in_assignments(
    assignments: &[Assignment],
    spans: &mut Vec<Span>,
) {
    for assignment in assignments {
        collect_plus_equals_assignment_span(assignment, spans);
    }
}

pub(crate) fn collect_plus_equals_assignment_span(assignment: &Assignment, spans: &mut Vec<Span>) {
    if !assignment.append {
        return;
    }

    let target = &assignment.target;
    let end = target
        .subscript
        .as_ref()
        .map(|subscript| subscript.syntax_source_text().span().end.advanced_by("]"))
        .unwrap_or(target.name_span.end);
    spans.push(Span::from_positions(target.name_span.start, end));
}

#[cfg_attr(shuck_profiling, inline(never))]
pub(crate) fn build_nonpersistent_assignment_spans(
    semantic: &SemanticModel,
    semantic_analysis: &SemanticAnalysis<'_>,
    commands: &[CommandFact<'_>],
    source: &str,
    suppress_zsh_nested_subshell_noise: bool,
    suppress_bash_pipefail_pipeline_side_effects: bool,
    arithmetic_only_suppressed_subscript_spans: &[Span],
) -> NonpersistentAssignmentSpans {
    let command_contexts = build_nonpersistent_assignment_command_contexts(commands);
    let prompt_runtime_reads = build_prompt_runtime_read_spans(commands, source)
        .into_iter()
        .map(|read| NonpersistentAssignmentExtraRead {
            name: read.name,
            span: read.span,
            scope: read.scope,
        })
        .collect();
    let analysis =
        semantic.analyze_nonpersistent_assignments(&NonpersistentAssignmentAnalysisContext {
            options: NonpersistentAssignmentAnalysisOptions {
                suppress_bash_pipefail_pipeline_side_effects,
                require_return_use_same_execution_context: suppress_zsh_nested_subshell_noise,
                ignored_names: vec![Name::from("IFS")],
            },
            commands: command_contexts,
            extra_reads: prompt_runtime_reads,
        });
    let mut candidate_effects = Vec::new();
    for effect in analysis.effects {
        if arithmetic_only_suppressed_subscript_spans
            .iter()
            .any(|span| span_contains(*span, effect.assignment_span))
        {
            continue;
        }
        if suppress_zsh_nested_subshell_noise
            && !nonpersistent_assignment_reaches_later_use(semantic, &effect)
        {
            continue;
        }
        if suppress_zsh_nested_subshell_noise
            && zsh_later_use_is_parameter_subscript_flag(source, effect.later_use_span)
        {
            continue;
        }

        candidate_effects.push(effect);
    }

    if candidate_effects.is_empty() {
        return NonpersistentAssignmentSpans::default();
    }

    let needed_reset_names = suppress_zsh_nested_subshell_noise.then(|| {
        candidate_effects
            .iter()
            .map(|effect| effect.name.clone())
            .collect::<FxHashSet<_>>()
    });
    let extra_reset_sites = needed_reset_names.as_ref().map(|needed_names| {
        build_nonpersistent_assignment_extra_reset_sites(
            semantic,
            semantic_analysis,
            commands,
            source,
            needed_names,
        )
    });
    let mut reported_effects = Vec::new();
    for effect in candidate_effects {
        if let Some(extra_reset_sites) = &extra_reset_sites
            && extra_reset_sites.iter().any(|reset| {
                reset.name == effect.name
                    && reset.span.start.offset() > effect.assignment_span.end.offset()
                    && reset.flow_span.end.offset() <= effect.later_use_span.start.offset()
                    && nonpersistent_reset_site_covers_later_use(
                        semantic,
                        semantic_analysis,
                        &effect,
                        reset,
                    )
            })
        {
            continue;
        }

        reported_effects.push(effect);
    }

    if reported_effects.is_empty() {
        return NonpersistentAssignmentSpans::default();
    }

    let loop_assignment_spans = build_subshell_loop_assignment_report_spans(commands);
    let mut later_use_sites = Vec::new();
    let mut assignment_sites = Vec::new();

    for effect in reported_effects {
        let assignment_binding = semantic.binding(effect.assignment_binding);
        assignment_sites.push(NamedSpan {
            name: effect.name.clone(),
            span: subshell_assignment_report_span(assignment_binding, &loop_assignment_spans),
        });
        later_use_sites.push(NamedSpan {
            name: effect.name,
            span: effect.later_use_span,
        });
    }

    let mut seen = FxHashSet::default();
    later_use_sites.retain(|site| seen.insert((FactSpan::new(site.span), site.name.clone())));
    later_use_sites.sort_by_key(|site| (site.span.start.offset(), site.span.end.offset()));

    seen.clear();
    assignment_sites.retain(|site| seen.insert((FactSpan::new(site.span), site.name.clone())));
    assignment_sites.sort_by_key(|site| (site.span.start.offset(), site.span.end.offset()));

    NonpersistentAssignmentSpans {
        subshell_assignment_sites: assignment_sites,
        subshell_later_use_sites: later_use_sites,
    }
}

pub(crate) fn nonpersistent_assignment_reaches_later_use(
    semantic: &SemanticModel,
    effect: &shuck_semantic::NonpersistentAssignmentEffect,
) -> bool {
    let assignment_scope = semantic.binding(effect.assignment_binding).scope;
    let assignment_transient = semantic.innermost_transient_scope_within_function(assignment_scope);
    let later_use_scope = semantic.scope_at(effect.later_use_span.start.offset());
    let later_use_transient = semantic.innermost_transient_scope_within_function(later_use_scope);
    if let Some(later_use_transient) = later_use_transient {
        let Some(assignment_transient) = assignment_transient else {
            return false;
        };
        if later_use_transient != assignment_transient
            && !semantic
                .ancestor_scopes(assignment_transient)
                .any(|scope| scope == later_use_transient)
        {
            return false;
        }
    }

    true
}

pub(crate) fn zsh_later_use_is_parameter_subscript_flag(source: &str, span: Span) -> bool {
    if span.start.offset() + 1 != span.end.offset() {
        return false;
    }

    let bytes = source.as_bytes();
    let start = span.start.offset();
    let end = span.end.offset();
    if start == 0
        || end >= bytes.len()
        || bytes[start - 1] != b'('
        || bytes[end] != b')'
        || !bytes[start].is_ascii_alphabetic()
    {
        return false;
    }

    let Some(open_subscript_offset) = source[..start - 1].rfind('[') else {
        return false;
    };
    if open_subscript_offset > 0 && bytes[open_subscript_offset - 1] == b'$' {
        return false;
    }
    let subscript_prefix = &source[open_subscript_offset + 1..start - 1];
    if !subscript_prefix.is_empty() || subscript_prefix.contains(']') {
        return false;
    }

    true
}

pub(crate) fn nonpersistent_reset_site_covers_later_use(
    semantic: &SemanticModel,
    semantic_analysis: &SemanticAnalysis<'_>,
    effect: &shuck_semantic::NonpersistentAssignmentEffect,
    reset: &NonpersistentAssignmentExtraResetSite,
) -> bool {
    let reset_blocks = semantic_analysis
        .block_ids_for_span(reset.flow_span)
        .iter()
        .copied()
        .collect::<FxHashSet<_>>();
    if reset_blocks.is_empty() {
        return false;
    }
    if !reset_site_control_ancestors_contain_later_use(
        semantic,
        reset.command_id,
        effect.later_use_span,
    ) {
        return false;
    }

    let Some(later_use_command) =
        semantic.innermost_command_id_at(effect.later_use_span.start.offset())
    else {
        return false;
    };
    let later_use_blocks =
        semantic_analysis.block_ids_for_span(semantic.command_syntax_span(later_use_command));
    if later_use_blocks.is_empty() {
        return false;
    }

    let assignment_scope = semantic.binding(effect.assignment_binding).scope;
    let entry = semantic_analysis.flow_entry_block_for_binding_scopes(
        &[assignment_scope],
        effect.later_use_span.start.offset(),
    );
    later_use_blocks.iter().copied().all(|target| {
        semantic_analysis.blocks_cover_all_paths_to_block(entry, target, &reset_blocks)
    })
}

pub(crate) fn span_contains(outer: Span, inner: Span) -> bool {
    outer.start.offset() <= inner.start.offset() && inner.end.offset() <= outer.end.offset()
}

pub(crate) fn build_subshell_loop_assignment_report_spans(
    commands: &[CommandFact<'_>],
) -> FxHashMap<FactSpan, Span> {
    let mut spans = FxHashMap::default();

    for command in commands {
        match command.command() {
            Command::Compound(CompoundCommand::For(for_command)) => {
                let keyword_span = leading_keyword_span(for_command.span, "for");
                for target in &for_command.targets {
                    if target.name.is_some() {
                        spans.insert(FactSpan::new(target.span), keyword_span);
                    }
                }
            }
            Command::Compound(CompoundCommand::Select(select_command)) => {
                spans.insert(
                    FactSpan::new(select_command.variable_span),
                    leading_keyword_span(select_command.span, "select"),
                );
            }
            _ => {}
        }
    }

    spans
}

pub(crate) fn leading_keyword_span(command_span: Span, keyword: &str) -> Span {
    Span::from_positions(command_span.start, command_span.start.advanced_by(keyword))
}

pub(crate) fn subshell_assignment_report_span(
    binding: &Binding,
    loop_assignment_spans: &FxHashMap<FactSpan, Span>,
) -> Span {
    if binding.kind == BindingKind::LoopVariable
        && let Some(span) = loop_assignment_spans.get(&FactSpan::new(binding.span))
    {
        return *span;
    }

    binding.span
}

#[derive(Debug, Default)]
pub(crate) struct NonpersistentAssignmentSpans {
    pub(crate) subshell_assignment_sites: Vec<NamedSpan>,
    pub(crate) subshell_later_use_sites: Vec<NamedSpan>,
}

#[derive(Debug, Clone)]
pub(crate) struct NonpersistentAssignmentExtraResetSite {
    name: Name,
    span: Span,
    flow_span: Span,
    command_id: CommandId,
}

pub(crate) fn build_nonpersistent_assignment_extra_reset_sites(
    semantic: &SemanticModel,
    semantic_analysis: &SemanticAnalysis<'_>,
    commands: &[CommandFact<'_>],
    source: &str,
    needed_names: &FxHashSet<Name>,
) -> Vec<NonpersistentAssignmentExtraResetSite> {
    let helper_output_names_by_scope =
        helper_output_names_by_scope(semantic, semantic_analysis, needed_names);
    let zsh_set_a_outparam_positions_by_scope =
        zsh_set_a_outparam_positions_by_scope(semantic, semantic_analysis, commands, source);
    let helper_function_names = helper_function_names_for_scopes(
        semantic,
        semantic_analysis,
        &helper_output_names_by_scope,
        &zsh_set_a_outparam_positions_by_scope,
    );
    let needs_reply_reset = needed_names.contains("REPLY") || needed_names.contains("reply");
    if helper_function_names.is_empty() && !needs_reply_reset {
        return Vec::new();
    }
    let reply_function_names = needs_reply_reset.then(|| function_definition_names(semantic));
    let mut resets = Vec::new();

    for command in commands {
        let Some(command_name) = command.effective_or_literal_name() else {
            continue;
        };
        let can_call_relevant_helper = helper_function_names.contains(command_name);
        let can_set_reply_by_name =
            needs_reply_reset && zsh_helper_name_can_set_reply(command_name);
        if !can_call_relevant_helper && !can_set_reply_by_name {
            continue;
        }
        if !command_runs_in_persistent_shell_context(semantic, command, source) {
            continue;
        }

        let reply_name_may_resolve_to_function = can_set_reply_by_name
            && reply_function_names
                .as_ref()
                .is_some_and(|names| names.contains(command_name));
        let resolved_function_scope = (can_call_relevant_helper
            || reply_name_may_resolve_to_function)
            .then(|| resolved_function_scope_for_command(semantic, semantic_analysis, command))
            .flatten();

        let Some((callee_scope, call_name_span)) = resolved_function_scope else {
            if let Some(reset_span) = zsh_reply_helper_reset_span(command) {
                let flow_span =
                    reset_flow_span_for_command(semantic, semantic_analysis, command, source);
                resets.push(NonpersistentAssignmentExtraResetSite {
                    name: Name::from("REPLY"),
                    span: reset_span,
                    flow_span,
                    command_id: command.id(),
                });
                resets.push(NonpersistentAssignmentExtraResetSite {
                    name: Name::from("reply"),
                    span: reset_span,
                    flow_span,
                    command_id: command.id(),
                });
            }
            continue;
        };

        let names = helper_output_names_by_scope.get(&callee_scope);
        let positions = zsh_set_a_outparam_positions_by_scope.get(&callee_scope);
        if names.is_none() && positions.is_none() {
            continue;
        }

        let flow_span = reset_flow_span_for_command(semantic, semantic_analysis, command, source);
        if let Some(names) = names {
            resets.extend(names.iter().cloned().map(|name| {
                NonpersistentAssignmentExtraResetSite {
                    name,
                    span: call_name_span,
                    flow_span,
                    command_id: command.id(),
                }
            }));
        }

        if let Some(positions) = positions {
            for position in positions {
                let Some(argument) = command.body_args().get(position.saturating_sub(1)).copied()
                else {
                    continue;
                };
                let Some(name) = static_word_text(argument, source) else {
                    continue;
                };
                if !is_shell_variable_name(&name) {
                    continue;
                }
                if !needed_names.contains(name.as_ref()) {
                    continue;
                }
                resets.push(NonpersistentAssignmentExtraResetSite {
                    name: Name::from(name.as_ref()),
                    span: argument.span,
                    flow_span,
                    command_id: command.id(),
                });
            }
        }
    }

    resets.sort_by(|left, right| {
        left.name
            .as_str()
            .cmp(right.name.as_str())
            .then_with(|| left.span.start.offset().cmp(&right.span.start.offset()))
            .then_with(|| left.span.end.offset().cmp(&right.span.end.offset()))
            .then_with(|| {
                left.flow_span
                    .start
                    .offset()
                    .cmp(&right.flow_span.start.offset())
            })
            .then_with(|| {
                left.flow_span
                    .end
                    .offset()
                    .cmp(&right.flow_span.end.offset())
            })
            .then_with(|| left.command_id.index().cmp(&right.command_id.index()))
    });
    resets.dedup_by(|left, right| {
        left.name == right.name
            && left.span == right.span
            && left.flow_span == right.flow_span
            && left.command_id == right.command_id
    });
    resets
}

pub(crate) fn reset_flow_span_for_command(
    semantic: &SemanticModel,
    semantic_analysis: &SemanticAnalysis<'_>,
    command: &CommandFact<'_>,
    source: &str,
) -> Span {
    let command_span = command.span();
    let mut current = command.id();
    let mut pipeline_span = None;
    while let Some(parent) = semantic.syntax_backed_command_parent_id(current) {
        if matches!(semantic.command_kind(parent), CommandKind::Binary) {
            let parent_span = semantic.command_syntax_span(parent);
            if span_contains(parent_span, command_span)
                && !semantic_analysis.block_ids_for_span(parent_span).is_empty()
            {
                let before_command =
                    &source[parent_span.start.offset()..command_span.start.offset()];
                if text_ends_with_pipeline_operator(before_command.trim_end()) {
                    let previous_len = pipeline_span
                        .map(|span: Span| span.end.offset() - span.start.offset())
                        .unwrap_or(usize::MAX);
                    let parent_len = parent_span.end.offset() - parent_span.start.offset();
                    if parent_len < previous_len {
                        pipeline_span = Some(parent_span);
                    }
                }
            }
        }
        current = parent;
    }

    pipeline_span.unwrap_or(command_span)
}

pub(crate) fn zsh_reply_helper_reset_span(command: &CommandFact<'_>) -> Option<Span> {
    let name = command.effective_or_literal_name()?;
    if !zsh_helper_name_can_set_reply(name) {
        return None;
    }

    Some(command.body_name_word()?.span)
}

pub(crate) fn zsh_helper_name_can_set_reply(name: &str) -> bool {
    (name.starts_with('.') && name != "." && name != "..") || name.starts_with('_')
}

pub(crate) fn helper_output_names_by_scope(
    semantic: &SemanticModel,
    semantic_analysis: &SemanticAnalysis<'_>,
    needed_names: &FxHashSet<Name>,
) -> FxHashMap<ScopeId, Vec<Name>> {
    let mut names_by_scope = FxHashMap::<ScopeId, Vec<Name>>::default();

    for binding in semantic.bindings() {
        if !needed_names.contains(binding.name.as_str()) {
            continue;
        }
        if !helper_binding_can_reset_parent_scope(semantic, semantic_analysis, binding) {
            continue;
        }
        if !matches!(semantic.scope_kind(binding.scope), ScopeKind::Function(_)) {
            continue;
        }

        names_by_scope
            .entry(binding.scope)
            .or_default()
            .push(binding.name.clone());
    }

    for names in names_by_scope.values_mut() {
        names.sort_by(|left, right| left.as_str().cmp(right.as_str()));
        names.dedup();
    }

    names_by_scope
}

pub(crate) fn helper_function_names_for_scopes(
    semantic: &SemanticModel,
    semantic_analysis: &SemanticAnalysis<'_>,
    helper_output_names_by_scope: &FxHashMap<ScopeId, Vec<Name>>,
    zsh_set_a_outparam_positions_by_scope: &FxHashMap<ScopeId, Vec<usize>>,
) -> FxHashSet<Name> {
    let mut helper_scopes = FxHashSet::default();
    helper_scopes.extend(helper_output_names_by_scope.keys().copied());
    helper_scopes.extend(zsh_set_a_outparam_positions_by_scope.keys().copied());
    if helper_scopes.is_empty() {
        return FxHashSet::default();
    }

    let mut function_names = FxHashSet::default();
    for binding in semantic.bindings() {
        if !matches!(binding.kind, BindingKind::FunctionDefinition) {
            continue;
        }
        let Some(function_scope) = semantic_analysis.function_scope_for_binding(binding.id) else {
            continue;
        };
        if helper_scopes.contains(&function_scope) {
            function_names.insert(binding.name.clone());
        }
    }

    function_names
}

pub(crate) fn function_definition_names(semantic: &SemanticModel) -> FxHashSet<Name> {
    semantic
        .bindings()
        .iter()
        .filter(|binding| matches!(binding.kind, BindingKind::FunctionDefinition))
        .map(|binding| binding.name.clone())
        .collect()
}

pub(crate) fn helper_binding_can_reset_parent_scope(
    semantic: &SemanticModel,
    semantic_analysis: &SemanticAnalysis<'_>,
    binding: &Binding,
) -> bool {
    if binding.attributes.contains(BindingAttributes::LOCAL) {
        return false;
    }
    if !binding_command_is_unconditional_in_function(semantic, semantic_analysis, binding) {
        return false;
    }

    match binding.kind {
        BindingKind::Assignment
        | BindingKind::ParameterDefaultAssignment
        | BindingKind::AppendAssignment
        | BindingKind::ArrayAssignment
        | BindingKind::ReadTarget
        | BindingKind::MapfileTarget
        | BindingKind::PrintfTarget
        | BindingKind::GetoptsTarget
        | BindingKind::ZparseoptsTarget
        | BindingKind::ArithmeticAssignment => true,
        BindingKind::Declaration(_) => binding
            .attributes
            .contains(BindingAttributes::DECLARATION_INITIALIZED),
        BindingKind::LoopVariable
        | BindingKind::FunctionDefinition
        | BindingKind::Nameref
        | BindingKind::Imported => false,
    }
}

pub(crate) fn binding_command_is_unconditional_in_function(
    semantic: &SemanticModel,
    semantic_analysis: &SemanticAnalysis<'_>,
    binding: &Binding,
) -> bool {
    let Some(current) = semantic.innermost_command_id_at(binding.span.start.offset()) else {
        return true;
    };

    command_is_unconditional_in_function(semantic, semantic_analysis, current)
}

pub(crate) fn command_is_unconditional_in_function(
    semantic: &SemanticModel,
    semantic_analysis: &SemanticAnalysis<'_>,
    mut current: CommandId,
) -> bool {
    if !command_has_reachable_cfg_block(semantic, semantic_analysis, current) {
        return false;
    }

    while let Some(parent) = semantic.syntax_backed_command_parent_id(current) {
        if matches!(semantic.command_kind(parent), CommandKind::Function) {
            return true;
        }
        if reset_site_is_always_run_binary_operand(semantic, parent, current) {
            current = parent;
            continue;
        }
        if command_kind_may_skip_child(semantic.command_kind(parent)) {
            return false;
        }
        current = parent;
    }

    true
}

pub(crate) fn command_has_reachable_cfg_block(
    semantic: &SemanticModel,
    semantic_analysis: &SemanticAnalysis<'_>,
    command_id: CommandId,
) -> bool {
    semantic_analysis
        .block_ids_for_span(semantic.command_syntax_span(command_id))
        .iter()
        .any(|block| !semantic_analysis.block_is_unreachable(*block))
}

pub(crate) fn zsh_set_a_outparam_positions_by_scope(
    semantic: &SemanticModel,
    semantic_analysis: &SemanticAnalysis<'_>,
    commands: &[CommandFact<'_>],
    source: &str,
) -> FxHashMap<ScopeId, Vec<usize>> {
    let mut positions_by_scope = FxHashMap::<ScopeId, Vec<usize>>::default();

    for command in commands {
        if !command.effective_name_is("set") {
            continue;
        }
        if !command_runs_in_persistent_shell_context(semantic, command, source) {
            continue;
        }
        if !command_is_unconditional_in_function(semantic, semantic_analysis, command.id()) {
            continue;
        }
        let Some(function_scope) = semantic.enclosing_function_scope(command.scope()) else {
            continue;
        };

        positions_by_scope
            .entry(function_scope)
            .or_default()
            .extend(zsh_set_a_outparam_positions(command.body_args(), source));
    }

    for positions in positions_by_scope.values_mut() {
        positions.sort_unstable();
        positions.dedup();
    }

    positions_by_scope
}

pub(crate) fn zsh_set_a_outparam_positions(args: &[&Word], source: &str) -> Vec<usize> {
    let mut positions = Vec::new();
    let mut saw_array_flag = false;

    for word in args {
        let text = word.span.slice(source);
        if !saw_array_flag {
            if static_word_text(word, source).is_some_and(|text| text == "-A") {
                saw_array_flag = true;
            }
            continue;
        }

        if let Some(position) = positional_outparam_index_from_word_text(text) {
            positions.push(position);
        }
        break;
    }

    positions
}

pub(crate) fn positional_outparam_index_from_word_text(text: &str) -> Option<usize> {
    positional_outparam_index(text).or_else(|| {
        text.strip_prefix('"')
            .and_then(|inner| inner.strip_suffix('"'))
            .and_then(positional_outparam_index)
    })
}

pub(crate) fn positional_outparam_index(text: &str) -> Option<usize> {
    let parameter = text
        .strip_prefix("${")
        .and_then(|inner| inner.strip_suffix('}'))
        .or_else(|| text.strip_prefix('$'))?;
    let index = parameter.parse::<usize>().ok()?;
    (index > 0).then_some(index)
}

pub(crate) fn resolved_function_scope_for_command(
    semantic: &SemanticModel,
    semantic_analysis: &SemanticAnalysis<'_>,
    command: &CommandFact<'_>,
) -> Option<(ScopeId, Span)> {
    let name = Name::from(command.effective_or_literal_name()?);
    let name_span = command.body_name_word()?.span;
    let binding = semantic_analysis
        .visible_function_binding_at_call(&name, name_span)
        .or_else(|| {
            command_is_inside_function_body(semantic, command.scope()).then(|| {
                semantic_analysis
                    .function_call_arity_sites(&name)
                    .find_map(|(site, binding)| {
                        (site.name_span == name_span
                            && fallback_function_binding_is_callable_from_function_body(
                                semantic,
                                semantic_analysis,
                                command,
                                name_span,
                                binding,
                            ))
                        .then_some(binding)
                    })
            })?
        })?;
    let scope = semantic_analysis.function_scope_for_binding(binding)?;
    Some((scope, name_span))
}

pub(crate) fn fallback_function_binding_is_callable_from_function_body(
    semantic: &SemanticModel,
    semantic_analysis: &SemanticAnalysis<'_>,
    command: &CommandFact<'_>,
    call_name_span: Span,
    binding: BindingId,
) -> bool {
    let binding = semantic.binding(binding);
    if binding.span.start.offset() <= call_name_span.start.offset() {
        return true;
    }

    let Some(command_function_scope) = enclosing_function_scope(semantic, command.scope()) else {
        return false;
    };
    if Some(command_function_scope) == enclosing_function_scope(semantic, binding.scope) {
        return false;
    }

    !function_scope_may_run_before_offset(
        semantic,
        semantic_analysis,
        command_function_scope,
        binding.span.start.offset(),
    )
}

pub(crate) fn function_scope_may_run_before_offset(
    semantic: &SemanticModel,
    semantic_analysis: &SemanticAnalysis<'_>,
    function_scope: ScopeId,
    offset: usize,
) -> bool {
    semantic_analysis
        .function_bindings_by_scope()
        .find(|(scope, _)| *scope == function_scope)
        .is_some_and(|(_, bindings)| {
            bindings.iter().copied().any(|function_binding| {
                let name = &semantic.binding(function_binding).name;
                semantic_analysis.resolved_function_call_sites(name).any(
                    |(site, resolved_binding)| {
                        resolved_binding == function_binding
                            && site.name_span.start.offset() < offset
                    },
                )
            })
        })
}

pub(crate) fn command_is_inside_function_body(semantic: &SemanticModel, scope: ScopeId) -> bool {
    enclosing_function_scope(semantic, scope).is_some()
}

pub(crate) fn enclosing_function_scope(
    semantic: &SemanticModel,
    scope: ScopeId,
) -> Option<ScopeId> {
    if matches!(semantic.scope_kind(scope), ScopeKind::Function(_)) {
        return Some(scope);
    }

    semantic
        .ancestor_scopes(scope)
        .find(|scope| matches!(semantic.scope_kind(*scope), ScopeKind::Function(_)))
}

pub(crate) fn command_runs_in_persistent_shell_context(
    semantic: &SemanticModel,
    command: &CommandFact<'_>,
    source: &str,
) -> bool {
    if matches!(
        command.stmt().terminator,
        Some(StmtTerminator::Background(_))
    ) {
        return false;
    }

    let transient_scopes = semantic
        .transient_ancestor_scopes_within_function(command.scope())
        .collect::<SmallVec<[_; 2]>>();
    if transient_scopes.is_empty() {
        return true;
    }

    transient_scopes
        .iter()
        .all(|scope| matches!(semantic.scope_kind(*scope), ScopeKind::Pipeline))
        && zsh_pipeline_tail_runs_in_current_shell(command, semantic, source)
}

pub(crate) fn zsh_pipeline_tail_runs_in_current_shell(
    command: &CommandFact<'_>,
    semantic: &SemanticModel,
    source: &str,
) -> bool {
    if command.shell_behavior().shell_dialect() != shuck_semantic::ShellDialect::Zsh {
        return false;
    }
    if command
        .shell_behavior()
        .zsh_options()
        .is_some_and(|options| {
            *options == ZshOptionState::for_emulate(shuck_semantic::ZshEmulationMode::Sh)
        })
    {
        return false;
    }

    command_is_pipeline_tail_operand(semantic, command.id(), source)
        || command_is_textual_pipeline_tail_operand(command.span(), source)
}

pub(crate) fn command_is_pipeline_tail_operand(
    semantic: &SemanticModel,
    mut current: CommandId,
    source: &str,
) -> bool {
    let mut saw_pipeline_parent = false;
    while let Some(parent) = semantic.syntax_backed_command_parent_id(current) {
        if !matches!(semantic.command_kind(parent), CommandKind::Binary) {
            break;
        }
        match binary_child_pipe_tail_relation(semantic, parent, current, source) {
            Some(true) => {
                saw_pipeline_parent = true;
                current = parent;
            }
            Some(false) => return false,
            None => break,
        }
    }
    saw_pipeline_parent
}

pub(crate) fn binary_child_pipe_tail_relation(
    semantic: &SemanticModel,
    parent: CommandId,
    child: CommandId,
    source: &str,
) -> Option<bool> {
    let parent_span = semantic.command_syntax_span(parent);
    let child_span = semantic.command_syntax_span(child);
    if child_span.start == parent_span.start {
        let after_child = &source[child_span.end.offset()..parent_span.end.offset()];
        return text_starts_with_pipeline_operator(after_child.trim_start()).map(|_| false);
    }

    let before_child = &source[parent_span.start.offset()..child_span.start.offset()];
    let before_child = before_child.trim_end();
    text_ends_with_pipeline_operator(before_child).then_some(true)
}

pub(crate) fn command_is_textual_pipeline_tail_operand(command_span: Span, source: &str) -> bool {
    let before_command = &source[..command_span.start.offset()];
    let before_command = before_command.trim_end();
    if !text_ends_with_pipeline_operator(before_command) {
        return false;
    }

    let after_command = &source[command_span.end.offset()..];
    let after_command = after_command.trim_start();
    !after_command.starts_with('|')
}

pub(crate) fn text_ends_with_pipeline_operator(text: &str) -> bool {
    text.ends_with("|&") || (text.ends_with('|') && !text.ends_with("||"))
}

pub(crate) fn text_starts_with_pipeline_operator(text: &str) -> Option<()> {
    (text.starts_with("|&") || (text.starts_with('|') && !text.starts_with("||"))).then_some(())
}

pub(crate) fn reset_site_control_ancestors_contain_later_use(
    semantic: &SemanticModel,
    command_id: CommandId,
    later_use_span: Span,
) -> bool {
    let mut current = command_id;
    while let Some(parent) = semantic.syntax_backed_command_parent_id(current) {
        if reset_site_is_always_run_binary_operand(semantic, parent, current) {
            current = parent;
            continue;
        }
        if matches!(semantic.command_kind(parent), CommandKind::Binary) {
            current = parent;
            continue;
        }
        if command_kind_may_skip_child(semantic.command_kind(parent))
            && !span_contains(semantic.command_syntax_span(parent), later_use_span)
        {
            return false;
        }
        current = parent;
    }
    true
}

pub(crate) fn reset_site_is_always_run_binary_operand(
    semantic: &SemanticModel,
    parent: CommandId,
    child: CommandId,
) -> bool {
    if !matches!(semantic.command_kind(parent), CommandKind::Binary) {
        return false;
    }

    semantic.command_syntax_span(parent).start == semantic.command_syntax_span(child).start
}

pub(crate) fn command_kind_may_skip_child(kind: CommandKind) -> bool {
    match kind {
        CommandKind::Binary => true,
        CommandKind::Compound(
            CompoundCommandKind::If
            | CompoundCommandKind::For
            | CompoundCommandKind::Repeat
            | CompoundCommandKind::Foreach
            | CompoundCommandKind::ArithmeticFor
            | CompoundCommandKind::While
            | CompoundCommandKind::Until
            | CompoundCommandKind::Case
            | CompoundCommandKind::Select,
        ) => true,
        CommandKind::Simple
        | CommandKind::Builtin(_)
        | CommandKind::Decl
        | CommandKind::Compound(
            CompoundCommandKind::Subshell
            | CompoundCommandKind::BraceGroup
            | CompoundCommandKind::Arithmetic
            | CompoundCommandKind::Time
            | CompoundCommandKind::Conditional
            | CompoundCommandKind::Coproc
            | CompoundCommandKind::Always,
        )
        | CommandKind::Function
        | CommandKind::AnonymousFunction => false,
    }
}

pub(crate) fn build_nonpersistent_assignment_command_contexts(
    commands: &[CommandFact<'_>],
) -> Vec<NonpersistentAssignmentCommandContext> {
    commands
        .iter()
        .map(|command| {
            let mut prefix_reset_names = command_assignments(command.command())
                .iter()
                .map(|assignment| assignment.target.name.clone())
                .collect::<Vec<_>>();
            prefix_reset_names.sort_by(|left, right| left.as_str().cmp(right.as_str()));
            prefix_reset_names.dedup();

            NonpersistentAssignmentCommandContext {
                span: command.span(),
                prefix_reset_names,
            }
        })
        .collect()
}

pub(crate) struct PromptRuntimeRead {
    name: Name,
    span: Span,
    scope: ScopeId,
}

pub(crate) fn build_prompt_runtime_read_spans(
    commands: &[CommandFact<'_>],
    source: &str,
) -> Vec<PromptRuntimeRead> {
    let mut reads = Vec::new();

    for command in commands {
        let scope = command.scope();
        for assignment in command_assignments(command.command()) {
            collect_prompt_runtime_reads_from_assignment(assignment, scope, source, &mut reads);
        }
        for operand in declaration_operands(command.command()) {
            if let DeclOperand::Assignment(assignment) = operand {
                collect_prompt_runtime_reads_from_assignment(assignment, scope, source, &mut reads);
            }
        }
    }

    let mut seen = FxHashSet::default();
    reads.retain(|read| seen.insert((FactSpan::new(read.span), read.name.clone())));
    reads
}

pub(crate) fn collect_prompt_runtime_reads_from_assignment(
    assignment: &Assignment,
    scope: ScopeId,
    source: &str,
    reads: &mut Vec<PromptRuntimeRead>,
) {
    if assignment.target.name.as_str() != "PS4" {
        return;
    }
    let AssignmentValue::Scalar(word) = &assignment.value else {
        return;
    };

    let target_span = assignment_target_span(assignment);
    for name in escaped_braced_parameter_names(word.span.slice(source)) {
        reads.push(PromptRuntimeRead {
            name: Name::from(name.as_str()),
            span: target_span,
            scope,
        });
    }
}

pub(crate) fn escaped_braced_parameter_names(text: &str) -> Vec<String> {
    let mut names = Vec::new();
    let mut index = 0;

    while let Some(relative) = text[index..].find(r"\${") {
        let name_start = index + relative + 3;
        let mut name_end = name_start;
        for (offset, ch) in text[name_start..].char_indices() {
            if offset == 0 {
                if !(ch == '_' || ch.is_ascii_alphabetic()) {
                    break;
                }
            } else if !(ch == '_' || ch.is_ascii_alphanumeric()) {
                break;
            }
            name_end = name_start + offset + ch.len_utf8();
        }

        if name_end > name_start {
            let name = &text[name_start..name_end];
            if is_shell_variable_name(name) {
                names.push(name.to_owned());
            }
        }
        index = name_start.max(name_end);
    }

    names
}

#[cfg_attr(shuck_profiling, inline(never))]
pub(crate) fn build_innermost_command_ids_by_offset(
    commands: &[CommandFact<'_>],
    mut offsets: Vec<usize>,
) -> CommandOffsetLookup {
    if offsets.is_empty() {
        return CommandOffsetLookup::default();
    }

    offsets.sort_unstable();
    offsets.dedup();

    let mut entries = Vec::with_capacity(offsets.len());
    let mut active_commands = Vec::new();
    let mut next_command = 0;
    for offset in offsets {
        pop_finished_commands(&mut active_commands, offset);

        while let Some(command) = commands.get(next_command) {
            let span = command.span();
            if span.start.offset() > offset {
                break;
            }

            pop_finished_commands(&mut active_commands, span.start.offset());
            active_commands.push(OpenCommand {
                end_offset: span.end.offset(),
                id: command.id(),
            });
            next_command += 1;
        }

        pop_finished_commands(&mut active_commands, offset);
        if let Some(command) = active_commands.last() {
            entries.push(CommandOffsetLookupEntry {
                offset,
                id: command.id,
            });
        }
    }

    CommandOffsetLookup { entries }
}

#[derive(Debug, Default, Clone)]
pub(crate) struct CommandOffsetLookup {
    entries: Vec<CommandOffsetLookupEntry>,
}

#[derive(Debug, Clone, Copy)]
pub(crate) struct CommandOffsetLookupEntry {
    offset: usize,
    id: CommandId,
}

pub(crate) fn precomputed_command_id_for_offset(
    command_ids_by_offset: &CommandOffsetLookup,
    offset: usize,
) -> Option<CommandId> {
    command_ids_by_offset
        .entries
        .binary_search_by_key(&offset, |entry| entry.offset)
        .ok()
        .map(|index| command_ids_by_offset.entries[index].id)
}

#[derive(Debug, Clone, Copy)]
pub(crate) struct OpenCommand {
    end_offset: usize,
    id: CommandId,
}

pub(crate) fn pop_finished_commands(active_commands: &mut Vec<OpenCommand>, offset: usize) {
    while active_commands
        .last()
        .is_some_and(|command| command.end_offset < offset)
    {
        active_commands.pop();
    }
}

pub(crate) fn build_dollar_question_after_command_spans(
    commands: &StmtSeq,
    source: &str,
) -> Vec<Span> {
    let mut spans = Vec::new();
    BodyShapeAnalyzer::new(source).visit_status_available_sites(commands, true, &mut |site| {
        match site {
            StatusAvailableSite::SimpleTest(command) => {
                collect_c107_status_spans_in_simple_test(command, source, &mut spans);
            }
            StatusAvailableSite::ConditionalExpression(expression) => {
                collect_c107_status_spans_in_conditional_expr(expression, source, &mut spans);
            }
            StatusAvailableSite::ArithmeticCommand(command) => {
                collect_c107_status_spans_in_arithmetic_command(command, source, &mut spans);
            }
        }
    });

    let mut seen = FxHashSet::default();
    spans.retain(|span| seen.insert(FactSpan::new(*span)));
    spans.sort_by_key(|span| (span.start.offset(), span.end.offset()));
    spans
}

#[cfg_attr(shuck_profiling, inline(never))]
pub(crate) fn build_declaration_assignment_probes<'a>(
    command: &'a Command,
    normalized: &NormalizedCommand<'a>,
    semantic: &SemanticModel,
    source: &str,
    behavior: &ShellBehaviorAt<'_>,
) -> Vec<DeclarationAssignmentProbe> {
    if let Some(declaration) = normalized.declaration.as_ref() {
        return declaration
            .assignment_operands
            .iter()
            .filter_map(|assignment| {
                let AssignmentValue::Scalar(word) = &assignment.value else {
                    return None;
                };

                Some(DeclarationAssignmentProbe {
                    kind: declaration.kind.clone(),
                    readonly_flag: declaration.readonly_flag,
                    target_name: assignment.target.name.as_str().into(),
                    target_name_span: assignment.target.name_span,
                    has_command_substitution: word_has_command_substitution(word, source, behavior),
                    status_capture: word_is_standalone_status_capture(word),
                })
            })
            .collect();
    }

    let Command::Simple(_) = command else {
        return Vec::new();
    };

    if !normalized.wrappers.is_empty() {
        return Vec::new();
    }

    let Some(declaration) = semantic.declaration_for_command_span(command_span(command)) else {
        return Vec::new();
    };
    let kind = declaration_kind_from_semantic(declaration.builtin);
    let readonly_flag = semantic_declaration_readonly_flag(declaration);

    declaration
        .operands
        .iter()
        .filter_map(|operand| {
            let SemanticDeclarationOperand::Assignment {
                name,
                name_span,
                value_span,
                has_command_substitution,
                ..
            } = operand
            else {
                return None;
            };
            Some(DeclarationAssignmentProbe {
                kind: kind.clone(),
                readonly_flag,
                target_name: name.as_str().into(),
                target_name_span: *name_span,
                has_command_substitution: *has_command_substitution,
                status_capture: word_for_declaration_value_span(command, *value_span)
                    .is_some_and(|word| word_span_is_standalone_status_capture(word, *value_span)),
            })
        })
        .collect()
}

pub(crate) fn declaration_kind_from_semantic(builtin: DeclarationBuiltin) -> DeclarationKind {
    match builtin {
        DeclarationBuiltin::Export => DeclarationKind::Export,
        DeclarationBuiltin::Local => DeclarationKind::Local,
        DeclarationBuiltin::Declare => DeclarationKind::Declare,
        DeclarationBuiltin::Typeset => DeclarationKind::Typeset,
        DeclarationBuiltin::Readonly => DeclarationKind::Other("readonly".to_owned()),
    }
}

pub(crate) fn semantic_declaration_readonly_flag(declaration: &Declaration) -> bool {
    if !matches!(
        declaration.builtin,
        DeclarationBuiltin::Local | DeclarationBuiltin::Declare | DeclarationBuiltin::Typeset
    ) {
        return false;
    }

    declaration.operands.iter().any(|operand| match operand {
        SemanticDeclarationOperand::Flag { flags, .. } => {
            flags.starts_with('-') && flags.contains('r')
        }
        SemanticDeclarationOperand::Name { .. }
        | SemanticDeclarationOperand::Assignment { .. }
        | SemanticDeclarationOperand::DynamicWord { .. } => false,
    })
}

pub(crate) fn word_for_declaration_value_span(command: &Command, span: Span) -> Option<&Word> {
    let Command::Simple(command) = command else {
        return None;
    };

    command.args.iter().find(|word| {
        span.start.offset() >= word.span.start.offset()
            && span.end.offset() <= word.span.end.offset()
    })
}

pub(crate) fn word_span_is_standalone_status_capture(word: &Word, span: Span) -> bool {
    let parts = word_parts_in_span(word, span);
    matches!(parts.as_slice(), [part] if part_is_standalone_status_capture(&part.kind))
}

pub(crate) fn word_span_is_standalone_status_or_pid_capture(word: &Word, span: Span) -> bool {
    let parts = word_parts_in_span(word, span);
    matches!(parts.as_slice(), [part] if part_is_standalone_status_or_pid_capture(&part.kind))
}

pub(crate) fn word_parts_in_span(word: &Word, span: Span) -> Vec<&WordPartNode> {
    word.parts
        .iter()
        .filter(|part| {
            span.start.offset() <= part.span.start.offset()
                && part.span.end.offset() <= span.end.offset()
        })
        .collect()
}

pub(crate) fn part_is_standalone_status_capture(part: &WordPart) -> bool {
    match part {
        WordPart::Variable(name) => name.as_str() == "?",
        WordPart::DoubleQuoted { parts, .. } => {
            matches!(parts.as_slice(), [part] if part_is_standalone_status_capture(&part.kind))
        }
        WordPart::Parameter(parameter) => matches!(
            parameter.bourne(),
            Some(BourneParameterExpansion::Access { reference })
                if reference.name.as_str() == "?" && reference.subscript.is_none()
        ),
        _ => false,
    }
}

pub(crate) fn word_is_standalone_status_or_pid_capture(word: &Word) -> bool {
    matches!(word.parts.as_slice(), [part] if part_is_standalone_status_or_pid_capture(&part.kind))
}

pub(crate) fn part_is_standalone_status_or_pid_capture(part: &WordPart) -> bool {
    match part {
        WordPart::Variable(name) => matches!(name.as_str(), "?" | "!"),
        WordPart::DoubleQuoted { parts, .. } => {
            matches!(
                parts.as_slice(),
                [part] if part_is_standalone_status_or_pid_capture(&part.kind)
            )
        }
        WordPart::Parameter(parameter) => matches!(
            parameter.bourne(),
            Some(BourneParameterExpansion::Access { reference })
                if matches!(reference.name.as_str(), "?" | "!") && reference.subscript.is_none()
        ),
        _ => false,
    }
}

pub(crate) fn word_has_command_substitution(
    word: &Word,
    source: &str,
    behavior: &ShellBehaviorAt<'_>,
) -> bool {
    word_classification_from_analysis(analyze_word(word, source, Some(behavior)))
        .has_command_substitution()
}

pub(crate) fn word_zsh_selectorless_subscript_value_base_names(
    word: &Word,
    source: &str,
) -> Box<[Name]> {
    let mut base_names = Vec::new();
    if word_part_nodes_are_zsh_selectorless_subscript_value(&word.parts, source, &mut base_names)
        && !base_names.is_empty()
    {
        base_names.into_boxed_slice()
    } else {
        Box::default()
    }
}

pub(crate) fn word_part_nodes_are_zsh_selectorless_subscript_value(
    parts: &[WordPartNode],
    source: &str,
    base_names: &mut Vec<Name>,
) -> bool {
    for (index, part) in parts.iter().enumerate() {
        if let WordPart::Variable(name) = &part.kind
            && parts.get(index + 1).is_some_and(|next| {
                matches!(
                    &next.kind,
                    WordPart::Literal(text) if literal_starts_with_zsh_subscript(text.as_str(source, next.span))
                )
            })
        {
            base_names.push(name.clone());
            continue;
        }
        if !word_part_is_zsh_selectorless_subscript_value(&part.kind, source, base_names) {
            return false;
        }
    }
    true
}

pub(crate) fn word_part_is_zsh_selectorless_subscript_value(
    part: &WordPart,
    source: &str,
    base_names: &mut Vec<Name>,
) -> bool {
    match part {
        WordPart::Literal(_) | WordPart::SingleQuoted { .. } => true,
        WordPart::DoubleQuoted { parts, .. } => {
            word_part_nodes_are_zsh_selectorless_subscript_value(parts, source, base_names)
        }
        WordPart::Parameter(parameter) => {
            parameter_is_zsh_selectorless_subscript_value(parameter, base_names)
        }
        WordPart::ArrayAccess(reference) | WordPart::ArraySlice { reference, .. } => {
            if !var_ref_has_selectorless_subscript(reference) {
                return false;
            }
            base_names.push(reference.name.clone());
            true
        }
        WordPart::ZshQualifiedGlob(_)
        | WordPart::Variable(_)
        | WordPart::CommandSubstitution { .. }
        | WordPart::ArithmeticExpansion { .. }
        | WordPart::ParameterExpansion { .. }
        | WordPart::Length(_)
        | WordPart::ArrayLength(_)
        | WordPart::ArrayIndices(_)
        | WordPart::Substring { .. }
        | WordPart::IndirectExpansion { .. }
        | WordPart::PrefixMatch { .. }
        | WordPart::ProcessSubstitution { .. }
        | WordPart::Transformation { .. } => false,
    }
}

pub(crate) fn literal_starts_with_zsh_subscript(text: &str) -> bool {
    let Some(rest) = text.strip_prefix('[') else {
        return false;
    };
    rest.contains(']')
}

pub(crate) fn parameter_is_zsh_selectorless_subscript_value(
    parameter: &ParameterExpansion,
    base_names: &mut Vec<Name>,
) -> bool {
    match &parameter.syntax {
        ParameterExpansionSyntax::Bourne(BourneParameterExpansion::Access { reference }) => {
            if !var_ref_has_selectorless_subscript(reference) {
                return false;
            }
            base_names.push(reference.name.clone());
            true
        }
        ParameterExpansionSyntax::Bourne(
            BourneParameterExpansion::Operation { .. }
            | BourneParameterExpansion::Length { .. }
            | BourneParameterExpansion::Indices { .. }
            | BourneParameterExpansion::Indirect { .. }
            | BourneParameterExpansion::PrefixMatch { .. }
            | BourneParameterExpansion::Slice { .. }
            | BourneParameterExpansion::Transformation { .. },
        ) => false,
        ParameterExpansionSyntax::Zsh(syntax)
            if syntax.length_prefix.is_none()
                && syntax.operation.is_none()
                && syntax.modifiers.is_empty() =>
        {
            match &syntax.target {
                ZshExpansionTarget::Reference(reference) => {
                    if !var_ref_has_selectorless_subscript(reference) {
                        return false;
                    }
                    base_names.push(reference.name.clone());
                    true
                }
                ZshExpansionTarget::Nested(parameter) => {
                    parameter_is_zsh_selectorless_subscript_value(parameter, base_names)
                }
                ZshExpansionTarget::Word(_) | ZshExpansionTarget::Empty => false,
            }
        }
        ParameterExpansionSyntax::Zsh(_) => false,
    }
}

pub(crate) fn var_ref_has_selectorless_subscript(reference: &VarRef) -> bool {
    reference
        .subscript
        .as_deref()
        .is_some_and(|subscript| subscript.selector().is_none())
}

pub(crate) fn advance_escaped_char_boundary(text: &str, start: usize) -> usize {
    let next = start + '\\'.len_utf8();
    if next >= text.len() {
        return next;
    }

    next + text[next..].chars().next().map_or(0, char::len_utf8)
}

#[cfg_attr(shuck_profiling, inline(never))]
pub(crate) fn collect_binding_values<'a>(
    command: &'a Command,
    semantic_artifacts: &'a LinterSemanticArtifacts<'a>,
    semantic: &SemanticModel,
    source: &str,
    binding_values: &mut FxHashMap<BindingId, BindingValueFact<'a>>,
) {
    let assignments = match command {
        Command::Simple(simple) if simple.name.span.slice(source).is_empty() => &simple.assignments,
        Command::Builtin(_) | Command::Decl(_) => command_assignments(command),
        Command::Simple(_)
        | Command::Binary(_)
        | Command::Compound(_)
        | Command::Function(_)
        | Command::AnonymousFunction(_) => &[],
    };

    for assignment in assignments {
        let AssignmentValue::Scalar(word) = &assignment.value else {
            continue;
        };
        if let Some(binding_id) =
            binding_value_definition_id_for_span(semantic, assignment.target.name_span)
        {
            binding_values.insert(
                binding_id,
                BindingValueFact::scalar(word, &assignment.target.name, semantic_artifacts, source),
            );
        }
    }

    for operand in declaration_operands(command) {
        let DeclOperand::Assignment(assignment) = operand else {
            continue;
        };
        let AssignmentValue::Scalar(word) = &assignment.value else {
            continue;
        };
        if let Some(binding_id) =
            binding_value_definition_id_for_span(semantic, assignment.target.name_span)
        {
            binding_values.insert(
                binding_id,
                BindingValueFact::scalar(word, &assignment.target.name, semantic_artifacts, source),
            );
        }
    }

    if matches!(command, Command::Simple(_))
        && let Some(declaration) = semantic.declaration_for_command_span(command_span(command))
    {
        for operand in &declaration.operands {
            let SemanticDeclarationOperand::Assignment {
                name: _,
                name_span,
                value_span,
                ..
            } = operand
            else {
                continue;
            };
            let Some(word) = word_for_declaration_value_span(command, *value_span) else {
                continue;
            };
            let standalone_status_or_pid_capture =
                word_span_is_standalone_status_or_pid_capture(word, *value_span);
            if let Some(binding_id) = binding_value_definition_id_for_span(semantic, *name_span) {
                let binding_name = &semantic.binding(binding_id).name;
                binding_values.insert(
                    binding_id,
                    BindingValueFact::scalar_with_status_or_pid_capture(
                        word,
                        standalone_status_or_pid_capture,
                        binding_name,
                        semantic_artifacts,
                        source,
                    ),
                );
            }
        }
    }

    match command {
        Command::Compound(CompoundCommand::For(command)) => {
            let Some(words) = &command.words else {
                return;
            };
            let values = words.iter().collect::<Vec<_>>().into_boxed_slice();
            for target in &command.targets {
                if target.name.is_some()
                    && let Some(binding_id) =
                        binding_value_definition_id_for_span(semantic, target.span)
                {
                    binding_values.insert(
                        binding_id,
                        BindingValueFact::from_loop_words(values.clone()),
                    );
                }
            }
        }
        Command::Compound(CompoundCommand::Foreach(command)) => {
            if let Some(binding_id) =
                binding_value_definition_id_for_span(semantic, command.variable_span)
            {
                binding_values.insert(
                    binding_id,
                    BindingValueFact::from_loop_words(
                        command.words.iter().collect::<Vec<_>>().into_boxed_slice(),
                    ),
                );
            }
        }
        Command::Compound(CompoundCommand::Select(command)) => {
            if let Some(binding_id) =
                binding_value_definition_id_for_span(semantic, command.variable_span)
            {
                binding_values.insert(
                    binding_id,
                    BindingValueFact::from_loop_words(
                        command.words.iter().collect::<Vec<_>>().into_boxed_slice(),
                    ),
                );
            }
        }
        _ => {}
    }
}

pub(crate) fn binding_value_definition_id_for_span(
    semantic: &SemanticModel,
    span: Span,
) -> Option<BindingId> {
    semantic.binding_for_definition_span(span)
}

pub(crate) fn binding_value_visible_id_for_name(
    semantic: &SemanticModel,
    name: &Name,
    span: Span,
) -> Option<BindingId> {
    semantic
        .visible_binding(name, span)
        .map(|binding| binding.id)
}

pub(crate) fn annotate_conditional_assignment_value_paths<'a>(
    semantic: &SemanticModel,
    lists: &[ListFact<'a>],
    binding_values: &mut FxHashMap<BindingId, BindingValueFact<'a>>,
) {
    for list in lists
        .iter()
        .filter(|list| list_has_conditional_assignment_shortcuts(list))
    {
        for segment in list.segments() {
            let Some(target) = segment.assignment_target() else {
                continue;
            };
            let Some(span) = segment.assignment_span() else {
                continue;
            };
            let Some(binding_id) =
                binding_value_visible_id_for_name(semantic, &Name::from(target), span)
            else {
                continue;
            };
            if let Some(binding_value) = binding_values.get_mut(&binding_id) {
                binding_value.mark_conditional_assignment_shortcut();
            }
        }
    }

    for list in lists
        .iter()
        .filter(|list| !list_has_conditional_assignment_shortcuts(list))
    {
        let mut prior_assignment_targets = FxHashSet::default();
        for (index, segment) in list.segments().iter().enumerate() {
            let Some(target) = segment.assignment_target() else {
                continue;
            };
            let Some(span) = segment.assignment_span() else {
                continue;
            };
            if index > 0
                && !prior_assignment_targets.contains(target)
                && let Some(binding_id) =
                    binding_value_visible_id_for_name(semantic, &Name::from(target), span)
                && let Some(binding_value) = binding_values.get_mut(&binding_id)
            {
                binding_value.mark_one_sided_short_circuit_assignment();
            }
            prior_assignment_targets.insert(target.to_owned());
        }
    }
}

pub(crate) fn list_has_conditional_assignment_shortcuts(list: &ListFact<'_>) -> bool {
    if list.mixed_short_circuit_kind() == Some(MixedShortCircuitKind::AssignmentTernary) {
        return true;
    }

    let [_, then_branch, else_branch] = list.segments() else {
        return false;
    };
    let [first_operator, second_operator] = list.operators() else {
        return false;
    };

    first_operator.op() == shuck_ast::BinaryOp::And
        && second_operator.op() == shuck_ast::BinaryOp::Or
        && then_branch.assignment_target().is_some()
        && then_branch.assignment_target() == else_branch.assignment_target()
}
