use super::*;

pub(crate) struct LinterFactsBuilder<'a, 'analysis> {
    file: &'a File,
    source: &'a str,
    semantic_artifacts: &'a LinterSemanticArtifacts<'a>,
    semantic: &'a SemanticModel,
    semantic_analysis: &'analysis SemanticAnalysis<'a>,
    _indexer: &'a Indexer,
    command_visits_by_id: &'a [Option<CommandVisit<'a>>],
    shell: ShellDialect,
    ambient_shell_options: AmbientShellOptions,
}

#[derive(Debug, Default)]
pub(crate) struct FactBuildCapacity {
    commands: usize,
    functions: usize,
    word_nodes: usize,
    word_occurrences: usize,
    word_occurrences_per_command: usize,
    compound_assignment_values: usize,
}

#[derive(Debug, Default)]
pub(crate) struct ArithmeticFactSummary {
    pub(crate) array_index_arithmetic_spans: Vec<Span>,
    pub(crate) arithmetic_score_line_spans: Vec<Span>,
    pub(crate) dollar_in_arithmetic_spans: Vec<Span>,
    pub(crate) arithmetic_expansion_spans: Vec<Span>,
    pub(crate) arithmetic_index_subscript_spans: Vec<Span>,
    pub(crate) arithmetic_command_substitution_spans: Vec<Span>,
    pub(crate) arithmetic_only_suppressed_subscript_spans: Vec<Span>,
}

#[derive(Debug, Default)]
pub(crate) struct HeredocFactSummary {
    pub(crate) unused_heredoc_spans: Vec<Span>,
    pub(crate) heredoc_missing_end_spans: Vec<Span>,
    pub(crate) heredoc_closer_not_alone_spans: Vec<Span>,
    pub(crate) misquoted_heredoc_close_spans: Vec<Span>,
    pub(crate) heredoc_end_space_spans: Vec<Span>,
    pub(crate) echo_here_doc_spans: Vec<Span>,
    pub(crate) spaced_tabstrip_close_spans: Vec<Span>,
}

#[cfg_attr(shuck_profiling, inline(never))]
pub(crate) fn estimate_fact_build_capacity(
    semantic: &SemanticModel,
    command_visits_by_id: &[Option<CommandVisit<'_>>],
) -> FactBuildCapacity {
    let commands = semantic.command_count();
    let mut direct_words = 0usize;
    let mut maximum_direct_words = 0usize;
    let mut compound_assignment_values = 0usize;

    for id in semantic.commands_in_source_order().iter().copied() {
        let Some(visit) = command_visits_by_id
            .get(id.index())
            .and_then(|visit| *visit)
        else {
            continue;
        };
        let (command_words, command_compound_assignment_values) =
            estimate_command_word_capacity(visit.command, visit.redirects);
        direct_words = direct_words.saturating_add(command_words);
        maximum_direct_words = maximum_direct_words.max(command_words);
        compound_assignment_values =
            compound_assignment_values.saturating_add(command_compound_assignment_values);
    }

    let references = semantic.references().len();
    let average_references = references.div_ceil(commands.max(1));
    FactBuildCapacity {
        commands,
        functions: commands.saturating_div(8),
        word_nodes: direct_words.saturating_add(references),
        word_occurrences: direct_words.saturating_add(references.saturating_mul(2)),
        word_occurrences_per_command: maximum_direct_words
            .saturating_add(average_references.saturating_mul(2)),
        compound_assignment_values,
    }
}

fn estimate_command_word_capacity(command: &Command, redirects: &[Redirect]) -> (usize, usize) {
    let mut words = redirects
        .iter()
        .filter(|redirect| redirect.word_target().is_some())
        .count();

    words = words.saturating_add(match command {
        Command::Simple(command) => 1usize.saturating_add(command.args.len()),
        Command::Builtin(command) => match command {
            BuiltinCommand::Break(command) => {
                usize::from(command.depth.is_some()).saturating_add(command.extra_args.len())
            }
            BuiltinCommand::Continue(command) => {
                usize::from(command.depth.is_some()).saturating_add(command.extra_args.len())
            }
            BuiltinCommand::Return(command) => {
                usize::from(command.code.is_some()).saturating_add(command.extra_args.len())
            }
            BuiltinCommand::Exit(command) => {
                usize::from(command.code.is_some()).saturating_add(command.extra_args.len())
            }
        },
        Command::Decl(command) => command
            .operands
            .iter()
            .filter(|operand| matches!(operand, DeclOperand::Dynamic(_)))
            .count(),
        Command::Compound(command) => match command {
            CompoundCommand::For(command) => command.words.as_ref().map_or(0, Vec::len),
            CompoundCommand::Repeat(_) => 1,
            CompoundCommand::Foreach(command) => command.words.len(),
            CompoundCommand::Select(command) => command.words.len(),
            CompoundCommand::Case(command) => 1usize.saturating_add(
                command
                    .cases
                    .iter()
                    .map(|case| case.patterns.len())
                    .sum::<usize>(),
            ),
            CompoundCommand::If(_)
            | CompoundCommand::ArithmeticFor(_)
            | CompoundCommand::While(_)
            | CompoundCommand::Until(_)
            | CompoundCommand::Subshell(_)
            | CompoundCommand::BraceGroup(_)
            | CompoundCommand::Arithmetic(_)
            | CompoundCommand::Time(_)
            | CompoundCommand::Conditional(_)
            | CompoundCommand::Coproc(_)
            | CompoundCommand::Always(_) => 0,
        },
        Command::Function(function) => function.header.entries.len(),
        Command::AnonymousFunction(function) => function.args.len(),
        Command::Binary(_) => 0,
    });

    let mut compound_assignment_values = 0usize;
    for assignment in
        command_assignments(command)
            .iter()
            .chain(
                declaration_operands(command)
                    .iter()
                    .filter_map(|operand| match operand {
                        DeclOperand::Assignment(assignment) => Some(assignment),
                        DeclOperand::Flag(_) | DeclOperand::Name(_) | DeclOperand::Dynamic(_) => {
                            None
                        }
                    }),
            )
    {
        match &assignment.value {
            AssignmentValue::Scalar(_) => words = words.saturating_add(1),
            AssignmentValue::Compound(array) => {
                words = words.saturating_add(array.elements.len());
                compound_assignment_values =
                    compound_assignment_values.saturating_add(array.elements.len());
            }
        }
    }

    (words, compound_assignment_values)
}

impl<'a, 'analysis> LinterFactsBuilder<'a, 'analysis> {
    pub(crate) fn new(
        file: &'a File,
        source: &'a str,
        semantic: &'a LinterSemanticArtifacts<'a>,
        semantic_analysis: &'analysis SemanticAnalysis<'a>,
        indexer: &'a Indexer,
        shell: ShellDialect,
        ambient_shell_options: AmbientShellOptions,
    ) -> Self {
        Self {
            file,
            source,
            semantic_artifacts: semantic,
            semantic: semantic.semantic(),
            semantic_analysis,
            _indexer: indexer,
            command_visits_by_id: semantic.command_visits_by_id(),
            shell,
            ambient_shell_options,
        }
    }

    pub(crate) fn build(self) -> LinterFacts<'a> {
        let source = self.source;
        let line_index = self._indexer.line_index();
        let locator = crate::Locator::new(source, line_index);
        let semantic_analysis = self.semantic_analysis;
        let capacity = estimate_fact_build_capacity(self.semantic, self.command_visits_by_id);

        let mut commands: Vec<CommandFact<'_>> = Vec::with_capacity(self.semantic.command_count());
        let mut substitution_occurrences_by_command_id: Vec<Vec<HostedSubstitutionOccurrence<'_>>> =
            (0..self.semantic.command_count())
                .map(|_| Vec::new())
                .collect();
        let mut redirect_fact_store = ListArena::new();
        let mut declaration_assignment_probe_store = ListArena::new();
        let mut command_ids_by_span =
            CommandLookupIndex::with_capacity_and_hasher(capacity.commands, Default::default());
        let mut command_ids_by_name_word_span = FxHashMap::with_capacity_and_hasher(
            capacity.commands.saturating_div(2),
            Default::default(),
        );
        let mut if_condition_command_ids =
            DenseCommandIdSet::with_capacity(self.semantic.command_count());
        let mut elif_condition_command_ids =
            DenseCommandIdSet::with_capacity(self.semantic.command_count());
        let mut binding_values =
            FxHashMap::with_capacity_and_hasher(self.semantic.bindings().len(), Default::default());
        let mut broken_assoc_key_spans = Vec::new();
        let mut comma_array_assignment_spans = Vec::new();
        let mut ifs_literal_backslash_assignment_value_spans = Vec::new();
        let mut word_nodes = Vec::with_capacity(capacity.word_nodes);
        let mut word_spans = ListArena::with_capacity(capacity.word_nodes.saturating_mul(2));
        let mut word_span_scratch = Vec::new();
        let mut word_node_ids_by_span =
            FxHashMap::with_capacity_and_hasher(capacity.word_nodes, Default::default());
        let mut word_occurrences = Vec::with_capacity(capacity.word_occurrences);
        let mut pending_arithmetic_word_occurrences = Vec::new();
        let mut pending_parameter_operand_word_occurrences = Vec::new();
        let mut compound_assignment_value_word_spans = FxHashSet::with_capacity_and_hasher(
            capacity.compound_assignment_values,
            Default::default(),
        );
        let mut array_assignment_split_word_ids =
            Vec::with_capacity(capacity.commands.saturating_div(8));
        let mut seen_word_occurrences = FxHashSet::with_capacity_and_hasher(
            capacity.word_occurrences_per_command,
            Default::default(),
        );
        let mut seen_pending_arithmetic_word_occurrences = FxHashSet::with_capacity_and_hasher(
            capacity.word_occurrences_per_command,
            Default::default(),
        );
        let mut seen_pending_parameter_operand_word_occurrences =
            FxHashSet::with_capacity_and_hasher(
                capacity.word_occurrences_per_command,
                Default::default(),
            );
        let mut assoc_binding_visibility_memo = FxHashMap::default();
        let mut pattern_exactly_one_extglob_spans = Vec::new();
        let mut case_pattern_expansions = Vec::new();
        let mut pattern_literal_spans = Vec::new();
        let mut pattern_charclass_spans = Vec::new();
        let mut arithmetic_summary = ArithmeticFactSummary::default();
        let mut surface_fragments = SurfaceFragmentSink::new(self.source);
        let mut functions = Vec::with_capacity(capacity.functions);
        let mut function_body_without_braces_spans = Vec::new();
        let mut redundant_return_status_spans = Vec::new();
        let mut getopts_cases = Vec::new();
        let mut condition_status_capture_spans = Vec::new();
        let mut precise_function_guard_suppressions = Vec::new();
        let mut command_substitution_command_spans = Vec::new();
        let mut arithmetic_update_operator_spans = Vec::new();
        let mut arithmetic_literal_spans = Vec::new();

        for id in self.semantic.commands_in_source_order().iter().copied() {
            let Some(context) = self.semantic.command_context(id) else {
                continue;
            };
            let Some(visit) = self
                .command_visits_by_id
                .get(id.index())
                .and_then(|visit| *visit)
            else {
                continue;
            };
            let key = FactSpan::new(command_span(visit.command));
            debug_assert_eq!(context.syntax_span(), command_span(visit.command));
            debug_assert_eq!(
                context.kind(),
                shuck_semantic::CommandKind::from_command(visit.command)
            );
            let lookup_kind = command_lookup_kind(visit.command);
            let entries = command_ids_by_span.entry(key).or_default();
            // Recovery on malformed nested word commands can surface the same
            // syntax-backed command more than once. Keep the first lookup target
            // so stmt-to-fact resolution stays deterministic.
            if !entries.iter().any(|entry| entry.kind == lookup_kind) {
                entries.push(CommandLookupEntry {
                    kind: lookup_kind,
                    id,
                });
            }

            if context.is_in_if_condition() {
                if_condition_command_ids.insert(id);
            }
            if context.is_in_elif_condition() {
                elif_condition_command_ids.insert(id);
            }
            collect_binding_values(
                visit.command,
                self.semantic_artifacts,
                self.semantic,
                self.source,
                &mut binding_values,
            );
            collect_broken_assoc_key_spans(visit.command, self.source, &mut broken_assoc_key_spans);
            collect_command_substitution_command_span(
                visit.command,
                self.source,
                &mut command_substitution_command_spans,
            );
            collect_comma_array_assignment_spans(
                visit.command,
                self.source,
                self.shell,
                self.semantic,
                &mut comma_array_assignment_spans,
            );
            collect_ifs_literal_backslash_assignment_value_spans(
                visit.command,
                self.source,
                &mut ifs_literal_backslash_assignment_value_spans,
            );
            let normalized = command::normalize_command(visit.command, self.source);
            let command_start_offset = command_span(visit.command).start.offset();
            let scope = context.scope();
            let command_shell_behavior =
                effective_command_shell_behavior(self.semantic, command_start_offset, &normalized);
            if let Some(name_word) = normalized.body_name_word() {
                command_ids_by_name_word_span
                    .entry(FactSpan::new(name_word.span))
                    .or_insert(id);
            }
            let nested_word_command = context.is_nested_word_command();
            build_word_facts_for_command(
                visit,
                self.source,
                locator,
                self.semantic,
                WordFactCommandContext {
                    command_id: id,
                    nested_word_command,
                    scope,
                },
                &normalized,
                command_shell_behavior.clone(),
                WordFactOutputs {
                    command_visits_by_id: self.command_visits_by_id,
                    word_nodes: &mut word_nodes,
                    word_spans: &mut word_spans,
                    word_span_scratch: &mut word_span_scratch,
                    word_node_ids_by_span: &mut word_node_ids_by_span,
                    word_occurrences: &mut word_occurrences,
                    pending_arithmetic_word_occurrences: &mut pending_arithmetic_word_occurrences,
                    pending_parameter_operand_word_occurrences:
                        &mut pending_parameter_operand_word_occurrences,
                    compound_assignment_value_word_spans: &mut compound_assignment_value_word_spans,
                    array_assignment_split_word_ids: &mut array_assignment_split_word_ids,
                    seen_word_occurrences: &mut seen_word_occurrences,
                    seen_pending_arithmetic_word_occurrences:
                        &mut seen_pending_arithmetic_word_occurrences,
                    seen_pending_parameter_operand_word_occurrences:
                        &mut seen_pending_parameter_operand_word_occurrences,
                    assoc_binding_visibility_memo: &mut assoc_binding_visibility_memo,
                    semantic_analysis: self.semantic_analysis,
                    case_pattern_expansions: &mut case_pattern_expansions,
                    pattern_literal_spans: &mut pattern_literal_spans,
                    arithmetic: &mut arithmetic_summary,
                    surface: &mut surface_fragments,
                },
            );
            collect_zsh_option_map_arithmetic_suppressed_subscripts(
                visit.command,
                self.semantic,
                scope,
                self.source,
                &mut arithmetic_summary.arithmetic_only_suppressed_subscript_spans,
            );
            collect_base_prefix_spans_in_command_parts(
                visit.command,
                self.source,
                &mut arithmetic_literal_spans,
            );
            collect_arithmetic_update_operator_spans_in_command(
                visit.command,
                self.semantic,
                self.semantic_artifacts,
                scope,
                self.source,
                &mut arithmetic_update_operator_spans,
            );
            for redirect in visit.redirects {
                if let Some(word) = redirect.word_target() {
                    collect_base_prefix_spans_in_word(
                        word,
                        self.source,
                        &mut arithmetic_literal_spans,
                    );
                    collect_arithmetic_update_operator_spans_in_word(
                        word,
                        self.semantic,
                        self.source,
                        &mut arithmetic_update_operator_spans,
                    );
                } else if let Some(heredoc) = redirect.heredoc()
                    && heredoc.delimiter.expands_body
                {
                    collect_arithmetic_update_operator_spans_in_heredoc_body(
                        &heredoc.body.parts,
                        self.semantic,
                        self.semantic_artifacts,
                        self.source,
                        &mut arithmetic_update_operator_spans,
                    );
                }
            }
            let redirect_facts = build_redirect_facts(
                visit.redirects,
                Some(self.semantic_artifacts),
                locator,
                &command_shell_behavior,
            );
            let redirect_fact_range = redirect_fact_store.push_many(redirect_facts);
            let options = CommandOptionFacts::build(
                visit.command,
                &normalized,
                self.semantic_artifacts,
                self.source,
                &command_shell_behavior,
            )
            .into_sparse();
            let declaration_assignment_probes = build_declaration_assignment_probes(
                visit.command,
                &normalized,
                self.semantic,
                self.source,
                &command_shell_behavior,
            );
            let declaration_assignment_probe_range =
                declaration_assignment_probe_store.push_many(declaration_assignment_probes);
            let glued_closing_bracket_operand_span =
                build_glued_closing_bracket_operand_span(visit.command, self.source);
            let glued_closing_bracket_insert_offset =
                build_glued_closing_bracket_insert_offset(visit.command, self.source);
            let simple_test = build_simple_test_fact(visit.command, self.source);
            let conditional_expression_visits = self
                .semantic_artifacts
                .conditional_expression_visits(command_span(visit.command));
            let conditional = build_conditional_fact(conditional_expression_visits, self.source);
            let enclosing_function_scope = self.semantic.enclosing_function_scope(scope);
            let command_fact = CommandFact {
                id,
                visit,
                nested_word_command,
                scope,
                enclosing_function_scope,
                normalized,
                shell_behavior: command_shell_behavior,
                redirect_facts: redirect_fact_range,
                substitution_facts: IdRange::empty(),
                options,
                scope_read_source_words: IdRange::empty(),
                scope_name_read_uses: IdRange::empty(),
                scope_heredoc_name_read_uses: IdRange::empty(),
                scope_name_write_uses: IdRange::empty(),
                declaration_assignment_probes: declaration_assignment_probe_range,
                glued_closing_bracket_operand_span,
                glued_closing_bracket_insert_offset,
                linebreak_in_test_anchor_span: None,
                linebreak_in_test_insert_offset: None,
                simple_test,
                conditional,
            };
            collect_substitution_occurrences_for_command(
                visit.command,
                visit.redirects,
                self.source,
                &mut substitution_occurrences_by_command_id[id.index()],
            );
            commands.push(command_fact);

            if let Command::Function(function) = visit.command {
                functions.push(FunctionFactInput {
                    command_id: id,
                    function,
                });
                if let Some(span) = function_body_without_braces_span(function) {
                    function_body_without_braces_spans.push(span);
                }
            }

            if !nested_word_command {
                match visit.command {
                    Command::Function(function) => {
                        collect_redundant_return_status_spans_in_function_body(
                            &function.body,
                            self.source,
                            &mut redundant_return_status_spans,
                        );
                    }
                    Command::AnonymousFunction(function) => {
                        collect_redundant_return_status_spans_in_function_body(
                            &function.body,
                            self.source,
                            &mut redundant_return_status_spans,
                        );
                    }
                    Command::Simple(_)
                    | Command::Builtin(_)
                    | Command::Decl(_)
                    | Command::Binary(_)
                    | Command::Compound(_) => {}
                }
                collect_condition_status_capture_from_direct_body_sequences(
                    visit.command,
                    self.source,
                    &mut condition_status_capture_spans,
                );
                collect_precise_function_return_guard_suppressions_from_direct_body_sequences(
                    visit.command,
                    self.source,
                    &mut precise_function_guard_suppressions,
                    context.flow().in_function,
                );
                match visit.command {
                    Command::Compound(CompoundCommand::If(command)) => {
                        collect_condition_status_capture_from_body(
                            &command.condition,
                            &command.then_branch,
                            self.source,
                            &mut condition_status_capture_spans,
                        );

                        let mut previous_condition = &command.condition;
                        for (index, (condition, branch)) in command.elif_branches.iter().enumerate()
                        {
                            if index > 0
                                || !stmt_seq_contains_nested_control_flow(
                                    &command.then_branch,
                                    self.semantic_artifacts.command_topology(),
                                )
                            {
                                collect_condition_status_capture_from_body(
                                    previous_condition,
                                    condition,
                                    self.source,
                                    &mut condition_status_capture_spans,
                                );
                            }
                            collect_condition_status_capture_from_body(
                                condition,
                                branch,
                                self.source,
                                &mut condition_status_capture_spans,
                            );
                            previous_condition = condition;
                        }

                        if let Some(else_branch) = &command.else_branch {
                            collect_condition_status_capture_from_body(
                                previous_condition,
                                else_branch,
                                self.source,
                                &mut condition_status_capture_spans,
                            );
                        }
                    }
                    Command::Compound(CompoundCommand::While(command)) => {
                        collect_condition_status_capture_from_body(
                            &command.condition,
                            &command.body,
                            self.source,
                            &mut condition_status_capture_spans,
                        );
                        if let Some(case) = build_getopts_case_fact_for_while(command, self.source)
                        {
                            getopts_cases.push(case);
                        }
                    }
                    Command::Compound(CompoundCommand::Until(command)) => {
                        collect_condition_status_capture_from_body(
                            &command.condition,
                            &command.body,
                            self.source,
                            &mut condition_status_capture_spans,
                        );
                    }
                    Command::Binary(command)
                        if matches!(command.op, BinaryOp::And | BinaryOp::Or) =>
                    {
                        if stmt_terminals_are_test_commands(&command.left, self.source) {
                            collect_status_parameter_spans_in_stmt(
                                &command.right,
                                self.source,
                                &mut condition_status_capture_spans,
                            );
                        }
                    }
                    Command::Simple(_)
                    | Command::Builtin(_)
                    | Command::Decl(_)
                    | Command::Binary(_)
                    | Command::Compound(_)
                    | Command::Function(_)
                    | Command::AnonymousFunction(_) => {}
                }
            }
        }

        arithmetic_update_operator_spans
            .sort_unstable_by_key(|span| (span.start.offset(), span.end.offset()));
        arithmetic_update_operator_spans.dedup();
        redundant_return_status_spans
            .sort_unstable_by_key(|span| (span.start.offset(), span.end.offset()));
        redundant_return_status_spans.dedup_by_key(|span| FactSpan::new(*span));
        sort_and_dedup_spans(&mut arithmetic_summary.arithmetic_expansion_spans);
        sort_and_dedup_spans(&mut arithmetic_summary.arithmetic_index_subscript_spans);
        let arithmetic_update_operator_fix_facts =
            if matches!(self.shell, ShellDialect::Sh | ShellDialect::Dash) {
                build_arithmetic_update_operator_fix_facts(
                    &arithmetic_update_operator_spans,
                    self.source,
                )
            } else {
                Vec::new()
            };
        arithmetic_literal_spans
            .sort_unstable_by_key(|(span, kind)| (span.start.offset(), span.end.offset(), *kind));
        arithmetic_literal_spans.dedup();
        let arithmetic_literal_facts = arithmetic_literal_spans
            .iter()
            .map(|(span, kind)| {
                ArithmeticLiteralFact::new(
                    *span,
                    *kind,
                    self.semantic
                        .shell_behavior_at(span.start.offset())
                        .arithmetic_literals(),
                )
            })
            .collect::<Vec<_>>();

        let mut fact_store = FactStore::empty();
        fact_store.redirect_facts = redirect_fact_store;
        fact_store.declaration_assignment_probes = declaration_assignment_probe_store;
        fact_store.word_spans = word_spans;

        commands.sort_unstable_by(compare_command_facts_by_offset);
        let command_fact_indices_by_id = build_command_fact_indices_by_id(&commands);
        let structural_command_ids = self
            .semantic
            .structural_commands()
            .iter()
            .copied()
            .filter(|id| {
                command_fact_indices_by_id
                    .get(id.index())
                    .is_some_and(Option::is_some)
            })
            .collect::<Vec<_>>();
        let command_child_index = CommandChildIndex::from_semantic_syntax_backed_children(
            self.semantic,
            &command_fact_indices_by_id,
        );

        populate_linebreak_in_test_facts(&mut commands, self.source);
        populate_substitution_fact_ranges(
            &mut commands,
            &mut fact_store,
            &command_fact_indices_by_id,
            &command_ids_by_span,
            &command_child_index,
            self.semantic_artifacts,
            locator,
            &substitution_occurrences_by_command_id,
        );

        let presence_tested_names =
            build_presence_tested_names(&commands, self.source, self.semantic);
        let function_headers = build_function_header_facts(
            self.semantic,
            semantic_analysis,
            &functions,
            &commands,
            &command_fact_indices_by_id,
            self.source,
        );
        let case_cli_dispatches = semantic_analysis.case_cli_dispatches(self.file, self.source);
        let function_cli_dispatch_facts = build_function_cli_dispatch_facts(&case_cli_dispatches);
        let function_definition_command_ids_by_scope = function_headers
            .iter()
            .filter_map(|header| {
                header
                    .function_scope()
                    .map(|scope| (scope, header.command_id()))
            })
            .collect::<FxHashMap<_, _>>();
        let case_cli_reachable_function_scopes = semantic_analysis
            .case_cli_reachable_function_scopes(self.file, &case_cli_dispatches)
            .into_iter()
            .collect();
        collect_condition_status_capture_from_sequence(
            &self.file.body,
            self.source,
            &mut condition_status_capture_spans,
        );
        collect_precise_function_return_guard_suppressions_in_seq(
            &self.file.body,
            self.source,
            &mut precise_function_guard_suppressions,
            false,
        );
        if !precise_function_guard_suppressions.is_empty() {
            condition_status_capture_spans
                .retain(|span| !precise_function_guard_suppressions.contains(span));
        }
        condition_status_capture_spans
            .retain(|span| matches!(span.slice(self.source), "$?" | "${?}"));
        sort_and_dedup_spans(&mut condition_status_capture_spans);
        sort_and_dedup_spans(&mut command_substitution_command_spans);
        sort_and_dedup_case_pattern_expansions(&mut case_pattern_expansions);
        let function_in_alias_facts = build_function_in_alias_facts(&commands, self.source);
        let function_parameter_fallback_spans = build_function_parameter_fallback_spans(
            &commands,
            &command_fact_indices_by_id,
            &structural_command_ids,
            self.source,
        );
        let for_headers = build_for_header_facts(
            &commands,
            &command_fact_indices_by_id,
            &command_ids_by_span,
            locator,
        );
        let select_headers = build_select_header_facts(
            &commands,
            &command_fact_indices_by_id,
            &command_ids_by_span,
            locator,
        );
        let case_items = build_case_item_facts(&commands, self.source);
        let (case_pattern_shadows, case_pattern_impossible_spans) =
            build_case_pattern_facts(&commands, self.source);
        let pipelines = build_pipeline_facts(
            &commands,
            &command_fact_indices_by_id,
            self.semantic,
            &command_ids_by_span,
            &command_child_index,
        );
        populate_scope_fact_ranges(
            &mut commands,
            &mut fact_store,
            &command_fact_indices_by_id,
            &pipelines,
            &if_condition_command_ids,
            source,
        );
        let lists = build_list_facts(
            &commands,
            &command_fact_indices_by_id,
            &command_ids_by_span,
            &command_child_index,
            self.source,
        );
        let completion_registered_function_scopes = build_completion_registered_function_scopes(
            self.semantic,
            semantic_analysis,
            &commands,
            &command_fact_indices_by_id,
            &lists,
            self.source,
        );
        let completion_registered_function_command_flags =
            build_completion_registered_function_command_flags(
                &commands,
                &completion_registered_function_scopes,
            );
        let external_entrypoint_function_scopes = build_external_entrypoint_function_scopes(
            self.semantic,
            &commands,
            &command_fact_indices_by_id,
            &lists,
            self.source,
        );
        annotate_conditional_assignment_value_paths(self.semantic, &lists, &mut binding_values);
        let statement_facts = build_statement_facts(&commands, self.semantic);
        let background_semicolon_spans =
            build_background_semicolon_spans(&commands, &case_items, locator);
        let single_test_subshell_spans = build_single_test_subshell_spans(
            &commands,
            &command_fact_indices_by_id,
            &command_ids_by_span,
            &command_child_index,
            locator,
        );
        let subshell_test_group_spans = build_subshell_test_group_spans(
            &commands,
            &command_fact_indices_by_id,
            &command_ids_by_span,
            &command_child_index,
            locator,
        );
        let shebang_header_facts = build_shebang_header_facts(locator);
        let errexit_enabled_anywhere = self.ambient_shell_options.errexit
            || shebang_header_facts.enables_errexit
            || commands
                .iter()
                .filter_map(|fact| fact.options().set())
                .any(|set| set.errexit_change == Some(true));
        let pipefail_enabled_anywhere = self.ambient_shell_options.pipefail
            || commands
                .iter()
                .filter_map(|fact| fact.options().set())
                .any(|set| set.pipefail_change == Some(true));
        let commented_continuation_comment_spans =
            build_commented_continuation_comment_spans(self.source, self._indexer);
        let comment_double_quote_nesting_spans =
            build_comment_double_quote_nesting_spans(self.source, self._indexer);
        let trailing_directive_comment_spans = build_trailing_directive_comment_spans(
            self.semantic_artifacts.directive_attachment_facts(),
            &case_items,
            self.source,
            self._indexer,
        );
        let backtick_command_name_spans = build_backtick_command_name_spans(&commands);
        let dollar_question_after_command_spans =
            build_dollar_question_after_command_spans(&self.file.body, self.source);
        let SurfaceFragmentFacts {
            single_quoted,
            dollar_double_quoted,
            open_double_quotes,
            suspect_closing_quotes,
            backticks,
            legacy_arithmetic,
            positional_parameters,
            positional_parameter_operator_spans,
            unicode_smart_quote_spans,
            pattern_exactly_one_extglob_spans: surface_pattern_exactly_one_extglob_spans,
            pattern_charclass_spans: surface_pattern_charclass_spans,
            parameter_pattern_spans,
            nested_pattern_charclass_spans,
            nested_parameter_expansions,
            indirect_expansions,
            mut indexed_array_references,
            plain_unindexed_references,
            parameter_pattern_special_targets,
            zsh_parameter_index_flags,
            substring_expansions,
            case_modifications,
            replacement_expansions,
            mut positional_parameter_trims,
            suppressed_subscript_spans,
            subscript_later_suppression_spans,
            mut arithmetic_only_suppressed_subscript_spans,
        } = surface_fragments.finish();
        arithmetic_only_suppressed_subscript_spans.extend(
            arithmetic_summary
                .arithmetic_only_suppressed_subscript_spans
                .iter()
                .copied(),
        );
        for fragment in &mut indexed_array_references {
            let span = match fragment {
                IndexedArrayReferenceFragmentFact::OneBased(fragment)
                | IndexedArrayReferenceFragmentFact::ZeroBased(fragment)
                | IndexedArrayReferenceFragmentFact::OneBasedWithZeroAlias(fragment)
                | IndexedArrayReferenceFragmentFact::Ambiguous(fragment) => fragment.span(),
            };
            let behavior = self
                .semantic
                .shell_behavior_at(span.start.offset())
                .subscript_indexing();
            *fragment = (*fragment).with_subscript_index_behavior(behavior);
        }
        let nonpersistent_assignment_spans = build_nonpersistent_assignment_spans(
            self.semantic,
            semantic_analysis,
            &commands,
            self.source,
            matches!(self.shell, ShellDialect::Zsh),
            matches!(self.shell, ShellDialect::Bash) && pipefail_enabled_anywhere,
            &arithmetic_only_suppressed_subscript_spans,
        );
        let heredoc_summary =
            build_heredoc_fact_summary(&commands, locator, self.file.span.end.offset());
        let plus_equals_assignment_spans = build_plus_equals_assignment_spans(&commands);
        let literal_brace_spans = build_literal_brace_spans(
            &word_nodes,
            &word_occurrences,
            CommandFacts::new(&commands, &fact_store, &command_fact_indices_by_id),
            &fact_store,
            locator,
            self._indexer.region_index(),
        );
        let function_positional_parameter_facts = build_function_positional_parameter_facts(
            self.semantic,
            &commands,
            &positional_parameters,
        );
        let double_paren_grouping_spans = build_double_paren_grouping_spans(&commands, self.source);
        let suppressed_subscript_reference_spans = build_suppressed_subscript_reference_spans(
            self.semantic,
            &suppressed_subscript_spans,
            &arithmetic_only_suppressed_subscript_spans,
        );
        #[cfg(test)]
        let subscript_later_suppression_reference_spans =
            build_subscript_later_suppression_reference_spans(
                self.semantic,
                &subscript_later_suppression_spans,
            );
        pattern_exactly_one_extglob_spans.extend(surface_pattern_exactly_one_extglob_spans);
        pattern_charclass_spans.extend(surface_pattern_charclass_spans);
        let escape_scan_matches = build_escape_scan_matches(
            &commands,
            &command_fact_indices_by_id,
            &word_nodes,
            &word_occurrences,
            EscapeScanInputs {
                pattern_literal_spans: &pattern_literal_spans,
                pattern_charclass_spans: &pattern_charclass_spans,
                parameter_pattern_spans: &parameter_pattern_spans,
                single_quoted_fragments: &single_quoted,
                backtick_fragments: &backticks,
            },
            EscapeScanContext {
                source: self.source,
            },
        );
        let echo_backslash_escape_word_spans =
            build_echo_backslash_escape_word_spans(&commands, self.source);
        let nested_pattern_charclass_spans = nested_pattern_charclass_spans
            .into_iter()
            .map(FactSpan::new)
            .collect();
        let conditional_portability = build_conditional_portability_facts(
            &commands,
            &elif_condition_command_ids,
            ConditionalPortabilityInputs {
                word_nodes: &word_nodes,
                word_occurrences: &word_occurrences,
                pattern_exactly_one_extglob_spans: &pattern_exactly_one_extglob_spans,
                pattern_charclass_spans: &pattern_charclass_spans,
                parameter_pattern_spans: &parameter_pattern_spans,
                nested_pattern_charclass_spans: &nested_pattern_charclass_spans,
            },
            source,
        );
        let EnvPrefixScopeSpans {
            assignment_scope_spans: env_prefix_assignment_scope_spans,
            expansion_scope_spans: env_prefix_expansion_scope_spans,
            expansion_fix_facts: env_prefix_expansion_fix_facts,
        } = build_env_prefix_scope_spans(self.source, self.semantic, &commands);
        let unset_command_ids_by_target_name = build_unset_command_ids_by_target_name(
            &commands,
            &command_fact_indices_by_id,
            &structural_command_ids,
            source,
        );
        let function_unset_command_ids_by_target_name =
            build_function_unset_command_ids_by_target_name(
                &commands,
                &command_fact_indices_by_id,
                &structural_command_ids,
                source,
            );
        let word_index = build_word_occurrence_index(
            &commands,
            &word_nodes,
            &mut word_occurrences,
            pending_arithmetic_word_occurrences,
            pending_parameter_operand_word_occurrences,
            &mut fact_store,
        );
        populate_array_assignment_split_scalar_expansion_spans(
            self.shell,
            &commands,
            &word_nodes,
            &mut word_occurrences,
            &mut fact_store,
            &array_assignment_split_word_ids,
        );
        let compound_assignment_value_word_flags: Box<[bool]> = word_occurrences
            .iter()
            .map(|occurrence| {
                compound_assignment_value_word_spans
                    .contains(&word_nodes[occurrence.node_id.index()].key)
            })
            .collect();
        let echo_to_sed_substitution_spans = build_echo_to_sed_substitution_spans(
            CommandFacts::new(&commands, &fact_store, &command_fact_indices_by_id),
            &pipelines,
            &backticks,
            WordFactLookup {
                nodes: &word_nodes,
                occurrences: &word_occurrences,
                word_index: &word_index,
                fact_store: &fact_store,
                source,
                line_index: self._indexer.line_index(),
            },
        );
        let assignment_like_command_name_spans =
            build_assignment_like_command_name_spans(&commands, self.source);
        let bare_command_name_assignment_spans = build_bare_command_name_assignment_spans(
            &commands,
            &word_nodes,
            &word_occurrences,
            &word_index,
            source,
        );
        let brace_variable_before_bracket_spans =
            build_brace_variable_before_bracket_spans(&word_nodes, &word_occurrences, source);
        let alias_definition_expansion_facts = build_alias_definition_expansion_facts(
            &commands,
            &fact_store,
            &word_nodes,
            &word_occurrences,
            &word_index,
            source,
        );
        let innermost_command_ids_by_offset = build_innermost_command_ids_by_offset(
            &commands,
            commands
                .iter()
                .map(|command| command.span().start.offset())
                .collect(),
        );
        attach_positional_parameter_trim_fixes(
            &mut positional_parameter_trims,
            &commands,
            &command_fact_indices_by_id,
            source,
        );
        let innermost_command_ids_by_binding_offset = build_innermost_command_ids_by_offset(
            &commands,
            self.semantic
                .bindings()
                .iter()
                .map(|binding| binding.span.start.offset())
                .collect(),
        );
        let assignment_value_target_index = build_assignment_value_target_index(&commands);
        let command_dominance_barrier_flags = build_command_dominance_barrier_flags(&commands);
        let c006_suppressing_reference_offsets_by_name =
            build_c006_suppressing_reference_offsets_by_name(
                self.semantic,
                &commands,
                &command_fact_indices_by_id,
                &innermost_command_ids_by_offset,
                &subscript_later_suppression_spans,
            );

        let backtick_substitution_spans = text_ranges_to_spans(
            self._indexer
                .region_index()
                .backtick_command_substitution_ranges(),
            locator,
        );
        let backtick_escaped_parameters =
            word_spans::backtick_escaped_parameters(locator, &backtick_substitution_spans);
        let mut backtick_escaped_parameter_reference_spans =
            word_spans::backtick_escaped_parameter_reference_spans(
                locator,
                &backtick_substitution_spans,
            );
        backtick_escaped_parameter_reference_spans.extend(
            backtick_escaped_parameters
                .iter()
                .map(|escaped| escaped.reference_span),
        );
        backtick_escaped_parameter_reference_spans
            .sort_by_key(|span| (span.start.offset(), span.end.offset()));
        backtick_escaped_parameter_reference_spans.dedup();
        let backtick_double_escaped_parameter_spans =
            word_spans::backtick_double_escaped_parameter_spans(
                locator,
                &backtick_substitution_spans,
            );
        LinterFacts {
            semantic: self.semantic,
            semantic_artifacts: self.semantic_artifacts,
            command: CommandFactStore {
                commands,
                command_fact_indices_by_id,
                structural_command_ids,
                #[cfg(test)]
                command_ids_by_span,
                command_ids_by_name_word_span,
                innermost_command_ids_by_offset,
                innermost_command_ids_by_binding_offset,
                command_dominance_barrier_flags,
                if_condition_command_ids,
                elif_condition_command_ids,
                fact_store,
                redundant_echo_space_facts: OnceLock::new(),
                completion_registered_function_command_flags,
                completion_registered_function_scopes,
                external_entrypoint_function_scopes,
                function_headers,
                function_doc_content: OnceLock::new(),
                function_definition_command_ids_by_scope,
                case_cli_reachable_function_scopes,
                function_in_alias_facts,
                alias_definition_expansion_facts,
                function_body_without_braces_spans,
                function_parameter_fallback_spans,
                redundant_return_status_spans,
                for_headers,
                select_headers,
                case_items,
                case_pattern_shadows,
                case_pattern_impossible_spans,
                case_pattern_expansions,
                getopts_cases,
                pipelines,
                lists,
                tautology_chain_operator_spans: OnceLock::new(),
                statement_facts,
                background_semicolon_spans,
                single_test_subshell_spans,
                subshell_test_group_spans,
                function_positional_parameter_facts,
                function_cli_dispatch_facts,
                condition_status_capture_spans,
                command_substitution_command_spans,
                backtick_command_name_spans,
                assignment_spacing_spans: OnceLock::new(),
                missing_space_before_bracket_close_facts: OnceLock::new(),
                jammed_test_bracket_facts: OnceLock::new(),
                assignment_like_command_name_spans,
                assign_special_zero_spans: OnceLock::new(),
                spacey_assignment_facts: OnceLock::new(),
                bare_command_name_assignment_spans,
            },
            words: WordFactStore {
                plain_unindexed_array_references: OnceLock::new(),
                suppressed_subscript_reference_spans,
                #[cfg(test)]
                subscript_later_suppression_reference_spans,
                compound_assignment_value_word_flags,
                word_nodes,
                word_occurrences,
                word_index,
                array_assignment_split_word_ids,
                brace_variable_before_bracket_spans,
                bare_done_word_spans: OnceLock::new(),
                array_index_arithmetic_spans: arithmetic_summary.array_index_arithmetic_spans,
                arithmetic_score_line_spans: arithmetic_summary.arithmetic_score_line_spans,
                dollar_in_arithmetic_spans: arithmetic_summary.dollar_in_arithmetic_spans,
                arithmetic_expansion_spans: arithmetic_summary.arithmetic_expansion_spans,
                arithmetic_index_subscript_spans: arithmetic_summary
                    .arithmetic_index_subscript_spans,
                arithmetic_command_substitution_spans: arithmetic_summary
                    .arithmetic_command_substitution_spans,
                arithmetic_only_suppressed_subscript_spans,
                single_quoted_fragments: single_quoted,
                dollar_double_quoted_fragments: dollar_double_quoted,
                open_double_quote_fragments: open_double_quotes,
                suspect_closing_quote_fragments: suspect_closing_quotes,
                literal_brace_spans,
                backtick_fragments: backticks,
                legacy_arithmetic_fragments: legacy_arithmetic,
                positional_parameter_fragments: positional_parameters,
                positional_parameter_operator_spans,
                double_paren_grouping_spans,
                arithmetic_update_operator_spans,
                arithmetic_update_operator_fix_facts,
                arithmetic_literal_facts,
                escape_scan_matches,
                echo_backslash_escape_word_spans,
                echo_to_sed_substitution_spans,
                unicode_smart_quote_spans,
                #[cfg(test)]
                pattern_literal_spans,
                pattern_charclass_spans,
                nested_parameter_expansion_fragments: nested_parameter_expansions,
                indirect_expansion_fragments: indirect_expansions,
                indexed_array_reference_fragments: indexed_array_references,
                plain_unindexed_reference_spans: plain_unindexed_references,
                parameter_pattern_special_target_fragments: parameter_pattern_special_targets,
                zsh_parameter_index_flag_fragments: zsh_parameter_index_flags,
                substring_expansion_fragments: substring_expansions,
                case_modification_fragments: case_modifications,
                replacement_expansion_fragments: replacement_expansions,
                positional_parameter_trim_fragments: positional_parameter_trims,
            },
            assignments: AssignmentFactStore {
                assignment_value_target_index,
                binding_values,
                broken_assoc_key_spans,
                comma_array_assignment_spans,
                ifs_literal_backslash_assignment_value_spans,
                env_prefix_assignment_scope_spans,
                env_prefix_expansion_scope_spans,
                env_prefix_expansion_fix_facts,
                unset_command_ids_by_target_name,
                function_unset_command_ids_by_target_name,
                presence_tested_names: presence_tested_names.global_names,
                nested_presence_test_spans: presence_tested_names.nested_command_spans_by_name,
                c006_presence_tested_names: presence_tested_names.c006_global_names,
                c006_nested_presence_test_spans: presence_tested_names
                    .c006_nested_command_spans_by_name,
                c006_suppressing_reference_offsets_by_name,
                presence_test_references_by_name: presence_tested_names.references_by_name,
                presence_test_names_by_name: presence_tested_names.names_by_name,
                possible_variable_misspelling_use_scan: OnceLock::new(),
                possible_variable_misspelling_index: OnceLock::new(),
                subshell_assignment_sites: nonpersistent_assignment_spans.subshell_assignment_sites,
                subshell_later_use_sites: nonpersistent_assignment_spans.subshell_later_use_sites,
                plus_equals_assignment_spans,
            },
            source_facts: SourceFactStore {
                source,
                line_index: self._indexer.line_index(),
                comment_index: self._indexer.comment_index(),
                shell: self.shell,
                script_line_count: OnceLock::new(),
                indented_shebang_span: shebang_header_facts.indented_shebang_span,
                indented_shebang_indent_span: shebang_header_facts.indented_shebang_indent_span,
                space_after_hash_bang_span: shebang_header_facts.space_after_hash_bang_span,
                space_after_hash_bang_whitespace_span: shebang_header_facts
                    .space_after_hash_bang_whitespace_span,
                shebang_not_on_first_line_span: shebang_header_facts.shebang_not_on_first_line_span,
                shebang_not_on_first_line_fix_span: shebang_header_facts
                    .shebang_not_on_first_line_fix_span,
                shebang_not_on_first_line_preferred_newline: shebang_header_facts
                    .shebang_not_on_first_line_preferred_newline,
                missing_shebang_line_span: shebang_header_facts.missing_shebang_line_span,
                duplicate_shebang_flag_span: shebang_header_facts.duplicate_shebang_flag_span,
                non_absolute_shebang_span: shebang_header_facts.non_absolute_shebang_span,
                shebang_interpreter: OnceLock::new(),
                shebang_invocation: OnceLock::new(),
                missing_file_description_comment: OnceLock::new(),
                errexit_enabled_anywhere,
                region_index: self._indexer.region_index(),
                commented_continuation_comment_spans,
                comment_double_quote_nesting_spans,
                escaped_dash_command_name_spans: OnceLock::new(),
                trailing_directive_comment_spans,
                todo_comment_facts: OnceLock::new(),
                backtick_substitution_spans,
                backtick_escaped_parameters,
                backtick_escaped_parameter_reference_spans,
                backtick_double_escaped_parameter_spans,
                dollar_question_after_command_spans,
                unused_heredoc_spans: heredoc_summary.unused_heredoc_spans,
                heredoc_missing_end_spans: heredoc_summary.heredoc_missing_end_spans,
                heredoc_closer_not_alone_spans: heredoc_summary.heredoc_closer_not_alone_spans,
                misquoted_heredoc_close_spans: heredoc_summary.misquoted_heredoc_close_spans,
                heredoc_end_space_spans: heredoc_summary.heredoc_end_space_spans,
                indented_heredoc_close_facts: OnceLock::new(),
                echo_here_doc_spans: heredoc_summary.echo_here_doc_spans,
                spaced_tabstrip_close_spans: heredoc_summary.spaced_tabstrip_close_spans,
            },
            compat: CompatFactStore {
                possible_variable_misspelling_scope_compat_name_uses: OnceLock::new(),
                conditional_portability,
            },
        }
    }
}

fn attach_positional_parameter_trim_fixes(
    fragments: &mut [PositionalParameterTrimFragmentFact],
    commands: &[CommandFact<'_>],
    command_fact_indices_by_id: &[Option<usize>],
    source: &str,
) {
    if fragments.is_empty() {
        return;
    }

    let command_ids_by_fragment_offset = build_innermost_command_ids_by_offset(
        commands,
        fragments
            .iter()
            .map(|fragment| fragment.span().start.offset())
            .collect(),
    );
    let mut fragment_indices_by_command = FxHashMap::<CommandId, Vec<usize>>::default();
    for (index, fragment) in fragments.iter().enumerate() {
        let Some(command_id) = precomputed_command_id_for_offset(
            &command_ids_by_fragment_offset,
            fragment.span().start.offset(),
        ) else {
            continue;
        };
        fragment_indices_by_command
            .entry(command_id)
            .or_default()
            .push(index);
    }

    for (command_id, mut indices) in fragment_indices_by_command {
        let Some(command) = command_fact_indices_by_id
            .get(command_id.index())
            .copied()
            .flatten()
            .and_then(|index| commands.get(index))
        else {
            continue;
        };
        if command.is_nested_word_command() {
            continue;
        }
        indices.sort_unstable_by_key(|index| fragments[*index].span().start.offset());
        let first_span = fragments[indices[0]].span();
        if command.span().start.line() != first_span.start.line() {
            continue;
        }

        let line_start = source[..command.span().start.offset()]
            .rfind('\n')
            .map_or(0, |offset| offset + 1);
        let indent = &source[line_start..command.span().start.offset()];
        if !indent.bytes().all(|byte| matches!(byte, b' ' | b'\t'))
            || previous_line_ends_with_control_operator(source, line_start)
        {
            continue;
        }

        let mut target = None;
        let mut replacements = Vec::with_capacity(indices.len());
        let mut compatible = true;
        for index in &indices {
            let span = fragments[*index].span();
            let text = span.slice(source);
            let Some((current_target, rest)) = text
                .strip_prefix("${*")
                .map(|rest| ('*', rest))
                .or_else(|| text.strip_prefix("${@").map(|rest| ('@', rest)))
            else {
                compatible = false;
                break;
            };
            if target.is_some_and(|target| target != current_target) {
                compatible = false;
                break;
            }
            target = Some(current_target);
            replacements.push(PositionalParameterTrimReplacement::new(
                span,
                format!("${{_shuck_positional_params{rest}").into_boxed_str(),
            ));
        }
        let Some(target) = target.filter(|_| compatible) else {
            continue;
        };

        fragments[indices[0]].set_fix(PositionalParameterTrimFix::new(
            line_start,
            format!("{indent}_shuck_positional_params=${target}\n").into_boxed_str(),
            replacements.into_boxed_slice(),
        ));
    }
}

fn previous_line_ends_with_control_operator(source: &str, line_start: usize) -> bool {
    let previous = source[..line_start]
        .strip_suffix('\n')
        .unwrap_or(&source[..line_start]);
    let line = previous
        .rsplit_once('\n')
        .map_or(previous, |(_, line)| line);
    let line = line_without_trailing_comment(line).trim_end_matches([' ', '\t']);
    let line = line
        .strip_suffix('\\')
        .map_or(line, |continued| continued.trim_end_matches([' ', '\t']));
    line.ends_with('|') || line.ends_with("|&") || line.ends_with("&&")
}

fn line_without_trailing_comment(line: &str) -> &str {
    let mut escaped = false;
    let mut in_single_quotes = false;
    let mut in_double_quotes = false;
    let mut comment_can_start = true;

    for (offset, ch) in line.char_indices() {
        if escaped {
            escaped = false;
            continue;
        }
        if in_single_quotes {
            if ch == '\'' {
                in_single_quotes = false;
            }
            continue;
        }
        if in_double_quotes {
            match ch {
                '\\' => escaped = true,
                '"' => in_double_quotes = false,
                _ => {}
            }
            continue;
        }

        match ch {
            '#' if comment_can_start => return &line[..offset],
            '\\' => {
                escaped = true;
                comment_can_start = false;
            }
            '\'' => {
                in_single_quotes = true;
                comment_can_start = false;
            }
            '"' => {
                in_double_quotes = true;
                comment_can_start = false;
            }
            ' ' | '\t' => comment_can_start = true,
            '|' | '&' | ';' | '(' | ')' | '<' | '>' => comment_can_start = true,
            _ => comment_can_start = false,
        }
    }

    line
}

#[cfg_attr(shuck_profiling, inline(never))]
pub(crate) fn build_word_occurrence_index(
    commands: &[CommandFact<'_>],
    word_nodes: &[WordNode<'_>],
    word_occurrences: &mut Vec<WordOccurrence>,
    pending_arithmetic_word_occurrences: Vec<PendingArithmeticWordOccurrence>,
    pending_parameter_operand_word_occurrences: Vec<PendingParameterOperandWordOccurrence>,
    fact_store: &mut FactStore<'_>,
) -> FxHashMap<FactSpan, SmallVec<[WordOccurrenceId; 2]>> {
    word_occurrences.extend(
        pending_arithmetic_word_occurrences
            .into_iter()
            .map(|pending| WordOccurrence {
                node_id: pending.node_id,
                command_id: pending.command_id,
                nested_word_command: pending.nested_word_command,
                context: WordFactContext::ArithmeticCommand,
                host_kind: pending.host_kind,
                runtime_literal: RuntimeLiteralAnalysis::default(),
                operand_class: None,
                enclosing_expansion_context: Some(pending.enclosing_expansion_context),
                split_sensitive_unquoted_command_substitution_spans: IdRange::empty(),
                array_assignment_split_scalar_expansion_spans: IdRange::empty(),
            }),
    );
    word_occurrences.extend(pending_parameter_operand_word_occurrences.into_iter().map(
        |pending| WordOccurrence {
            node_id: pending.node_id,
            command_id: pending.command_id,
            nested_word_command: pending.nested_word_command,
            context: WordFactContext::ParameterOperand,
            host_kind: pending.host_kind,
            runtime_literal: RuntimeLiteralAnalysis::default(),
            operand_class: None,
            enclosing_expansion_context: Some(pending.enclosing_expansion_context),
            split_sensitive_unquoted_command_substitution_spans: IdRange::empty(),
            array_assignment_split_scalar_expansion_spans: IdRange::empty(),
        },
    ));

    let mut word_index = FxHashMap::<FactSpan, SmallVec<[WordOccurrenceId; 2]>>::default();
    word_index.reserve(word_occurrences.len());

    let command_slot_count = commands
        .iter()
        .map(|command| command.id().index())
        .max()
        .map_or(0, |index| index + 1);
    let mut word_occurrence_offsets_by_command = vec![0usize; command_slot_count];
    for fact in word_occurrences.iter() {
        word_occurrence_offsets_by_command[fact.command_id.index()] += 1;
    }

    let mut next_word_occurrence_offset = 0usize;
    let word_occurrence_ids_by_command = word_occurrence_offsets_by_command
        .iter_mut()
        .map(|count| {
            let range = IdRange::from_start_len(next_word_occurrence_offset, *count);
            *count = next_word_occurrence_offset;
            next_word_occurrence_offset = range.end_index();
            range
        })
        .collect::<Vec<_>>();

    let mut word_occurrence_ids = vec![WordOccurrenceId::new(0); next_word_occurrence_offset];
    for (index, fact) in word_occurrences.iter().enumerate() {
        let id = WordOccurrenceId::new(index);
        word_index
            .entry(occurrence_key(word_nodes, fact))
            .or_default()
            .push(id);
        let command_index = fact.command_id.index();
        let offset = word_occurrence_offsets_by_command[command_index];
        word_occurrence_ids[offset] = id;
        word_occurrence_offsets_by_command[command_index] += 1;
    }

    let mut word_occurrence_id_store = ListArena::with_capacity(word_occurrence_ids.len());
    let all_word_occurrence_ids = word_occurrence_id_store.push_many(word_occurrence_ids);
    debug_assert_eq!(all_word_occurrence_ids.start_index(), 0);
    debug_assert_eq!(
        all_word_occurrence_ids.end_index(),
        next_word_occurrence_offset
    );
    fact_store.word_occurrence_ids = word_occurrence_id_store;
    fact_store.word_occurrence_ids_by_command = word_occurrence_ids_by_command;

    word_index
}

pub(crate) fn build_c006_suppressing_reference_offsets_by_name(
    semantic: &SemanticModel,
    commands: &[CommandFact<'_>],
    command_fact_indices_by_id: &[Option<usize>],
    innermost_command_ids_by_offset: &CommandOffsetLookup,
    subscript_later_suppression_spans: &[Span],
) -> FxHashMap<Name, Vec<usize>> {
    let mut offsets_by_name = semantic
        .guarded_or_defaulting_reference_offsets_by_name()
        .iter()
        .map(|(name, offsets)| (name.clone(), offsets.to_vec()))
        .collect::<FxHashMap<_, _>>();

    for span in subscript_later_suppression_spans {
        for reference in semantic.references_in_span(*span) {
            if c006_subscript_reference_suppresses_later_references(
                commands,
                command_fact_indices_by_id,
                innermost_command_ids_by_offset,
                reference,
            ) {
                offsets_by_name
                    .entry(reference.name.clone())
                    .or_default()
                    .push(reference.span.start.offset());
            }
        }
    }

    for offsets in offsets_by_name.values_mut() {
        offsets.sort_unstable();
        offsets.dedup();
    }

    offsets_by_name
}

pub(crate) fn c006_subscript_reference_suppresses_later_references(
    commands: &[CommandFact<'_>],
    command_fact_indices_by_id: &[Option<usize>],
    innermost_command_ids_by_offset: &CommandOffsetLookup,
    reference: &Reference,
) -> bool {
    precomputed_command_id_for_offset(
        innermost_command_ids_by_offset,
        reference.span.start.offset(),
    )
    .and_then(|id| {
        command_fact_indices_by_id
            .get(id.index())
            .copied()
            .flatten()
            .and_then(|index| commands.get(index))
    })
    .and_then(CommandFact::static_utility_name)
    .is_none_or(|name| !matches!(name, "unset" | "[" | "[[" | "test"))
}

pub(crate) fn stmt_seq_contains_nested_control_flow(
    body: &StmtSeq,
    command_topology: CommandTopology<'_, '_>,
) -> bool {
    let mut contains = false;
    command_topology
        .body(body)
        .for_each_command_visit(false, |_, visit| match visit.command {
            Command::Compound(
                CompoundCommand::If(_)
                | CompoundCommand::While(_)
                | CompoundCommand::Until(_)
                | CompoundCommand::For(_)
                | CompoundCommand::Select(_)
                | CompoundCommand::Case(_)
                | CompoundCommand::Always(_),
            ) => {
                contains = true;
                CommandTopologyTraversal::Break
            }
            Command::Binary(_)
            | Command::Compound(
                CompoundCommand::BraceGroup(_)
                | CompoundCommand::Subshell(_)
                | CompoundCommand::Time(_),
            ) => CommandTopologyTraversal::Descend,
            Command::Simple(_)
            | Command::Builtin(_)
            | Command::Decl(_)
            | Command::Compound(_)
            | Command::Function(_)
            | Command::AnonymousFunction(_) => CommandTopologyTraversal::SkipChildren,
        });
    contains
}

pub(crate) fn populate_linebreak_in_test_facts(commands: &mut [CommandFact<'_>], source: &str) {
    for index in 0..commands.len().saturating_sub(1) {
        let (current_slice, next_slice) = commands.split_at_mut(index + 1);
        let current = &mut current_slice[index];
        let next = &next_slice[0];
        let Some((anchor_span, insert_offset)) =
            build_linebreak_in_test_site(current, next, source)
        else {
            continue;
        };

        current.linebreak_in_test_anchor_span = Some(anchor_span);
        current.linebreak_in_test_insert_offset = Some(insert_offset);
    }
}

pub(crate) fn build_linebreak_in_test_site(
    current: &CommandFact<'_>,
    next: &CommandFact<'_>,
    source: &str,
) -> Option<(Span, usize)> {
    if !current.static_utility_name_is("[")
        || !next.static_utility_name_is("]")
        || !next.body_args().is_empty()
    {
        return None;
    }

    let last_arg_is_closing_bracket = current
        .body_args()
        .last()
        .and_then(|word| static_word_text(word, source))
        .as_deref()
        == Some("]");
    let current_span = current.span();
    if last_arg_is_closing_bracket {
        return None;
    }
    let insert_offset = linebreak_in_test_insert_offset(current_span, source)?;

    let between = source.get(current_span.end.offset()..next.span().start.offset())?;
    if !between.chars().all(|char| matches!(char, ' ' | '\t')) {
        return None;
    }

    let anchor_span = current
        .body_args()
        .last()
        .map(|word| word.span)
        .or_else(|| current.body_name_word().map(|word| word.span))
        .map(|span| Span::from_positions(span.end, span.end))
        .unwrap_or_else(|| Span::from_positions(current_span.end, current_span.end));
    Some((anchor_span, insert_offset))
}

pub(crate) fn linebreak_in_test_insert_offset(span: Span, source: &str) -> Option<usize> {
    let text = span.slice(source);
    if text.ends_with("\r\n") {
        Some(span.end.offset() - 2)
    } else if text.ends_with('\n') {
        Some(span.end.offset() - 1)
    } else {
        None
    }
}

pub(crate) fn text_ranges_to_spans(ranges: &[TextRange], locator: Locator<'_>) -> Vec<Span> {
    ranges
        .iter()
        .filter_map(|range| text_range_to_span(*range, locator))
        .collect()
}

pub(crate) fn text_range_to_span(range: TextRange, locator: Locator<'_>) -> Option<Span> {
    Some(Span::from_positions(
        locator.position_at_offset(usize::from(range.start()))?,
        locator.position_at_offset(usize::from(range.end()))?,
    ))
}

pub(crate) fn build_unset_command_ids_by_target_name(
    commands: &[CommandFact<'_>],
    command_fact_indices_by_id: &[Option<usize>],
    structural_command_ids: &[CommandId],
    source: &str,
) -> FxHashMap<Name, Vec<CommandId>> {
    let mut command_ids_by_name = FxHashMap::<Name, Vec<CommandId>>::default();

    for command_id in structural_command_ids.iter().copied() {
        let Some(command) = command_fact_indices_by_id
            .get(command_id.index())
            .copied()
            .flatten()
            .and_then(|index| commands.get(index))
        else {
            continue;
        };
        let Some(unset) = command.options().unset() else {
            continue;
        };
        if unset.function_mode || unset.nameref_mode() || !unset.options_parseable() {
            continue;
        }

        for operand in unset.operand_facts() {
            if operand.array_subscript().is_some() {
                continue;
            }
            if let Some(text) = static_word_text(operand.word(), source) {
                command_ids_by_name
                    .entry(Name::from(text.as_ref()))
                    .or_default()
                    .push(command_id);
            }
        }
    }

    command_ids_by_name
}

pub(crate) fn build_function_unset_command_ids_by_target_name(
    commands: &[CommandFact<'_>],
    command_fact_indices_by_id: &[Option<usize>],
    structural_command_ids: &[CommandId],
    source: &str,
) -> FxHashMap<Name, Vec<CommandId>> {
    let mut command_ids_by_name = FxHashMap::<Name, Vec<CommandId>>::default();

    for command_id in structural_command_ids.iter().copied() {
        let Some(command) = command_fact_indices_by_id
            .get(command_id.index())
            .copied()
            .flatten()
            .and_then(|index| commands.get(index))
        else {
            continue;
        };
        let Some(unset) = command.options().unset() else {
            continue;
        };
        if !unset.function_mode || !unset.options_parseable() {
            continue;
        }

        let mut targets = Vec::new();
        for word in unset.operand_words() {
            let Some(text) = static_word_text(word, source) else {
                break;
            };
            let target = Name::from(text.as_ref());
            if !targets.contains(&target) {
                targets.push(target);
            }
        }

        for target in targets {
            command_ids_by_name
                .entry(target)
                .or_default()
                .push(command_id);
        }
    }

    command_ids_by_name
}

pub(crate) fn sort_and_dedup_case_pattern_expansions(
    expansions: &mut Vec<CasePatternExpansionFact>,
) {
    let mut seen = FxHashSet::default();
    expansions.retain(|fact| seen.insert(FactSpan::new(fact.span())));
    expansions.sort_by_key(|fact| (fact.span().start.offset(), fact.span().end.offset()));
}

#[cfg(test)]
mod builder_tests {
    use shuck_ast::{Position, Span};

    use super::linebreak_in_test_insert_offset;

    #[test]
    fn linebreak_in_test_insert_offset_targets_lf_newlines() {
        let source = "if [ \"$x\" = y\n";
        let span = Span::from_positions(Position::new(), Position::new().advanced_by(source));
        let insert_offset =
            linebreak_in_test_insert_offset(span, source).expect("expected LF insert offset");

        assert_eq!(&source[insert_offset..], "\n");
    }

    #[test]
    fn linebreak_in_test_insert_offset_targets_crlf_newlines() {
        let source = "if [ \"$x\" = y\r\n";
        let span = Span::from_positions(Position::new(), Position::new().advanced_by(source));
        let insert_offset =
            linebreak_in_test_insert_offset(span, source).expect("expected CRLF insert offset");

        assert_eq!(&source[insert_offset..], "\r\n");
    }
}
