use super::*;

#[derive(Debug, Clone)]
pub struct FunctionHeaderFact<'a> {
    command_id: CommandId,
    function: &'a FunctionDef,
    binding_id: Option<BindingId>,
    scope_id: Option<ScopeId>,
    call_arity: FunctionCallArityFacts,
}

impl<'a> FunctionHeaderFact<'a> {
    pub fn command_id(&self) -> CommandId {
        self.command_id
    }

    pub fn function(&self) -> &'a FunctionDef {
        self.function
    }

    pub fn static_name_entry(&self) -> Option<(&'a Name, Span)> {
        self.function.static_name_entries().next()
    }

    pub fn binding_id(&self) -> Option<BindingId> {
        self.binding_id
    }

    pub fn function_scope(&self) -> Option<ScopeId> {
        self.scope_id
    }

    pub fn call_arity(&self) -> &FunctionCallArityFacts {
        &self.call_arity
    }

    pub fn function_span_in_source(&self, source: &str) -> Span {
        trim_trailing_whitespace_span(self.function.span, source)
    }

    pub fn span_in_source(&self, source: &str) -> Span {
        trim_trailing_whitespace_span(self.function.header.span(), source)
    }

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

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

    pub fn function_keyword_span(&self) -> Option<Span> {
        self.function.header.function_keyword_span
    }
}

#[derive(Debug, Clone, Default)]
pub struct FunctionCallArityFacts {
    call_count: usize,
    min_arg_count: usize,
    max_arg_count: usize,
    zero_arg_call_spans: Vec<Span>,
    zero_arg_diagnostic_spans: Vec<Span>,
}

impl FunctionCallArityFacts {
    pub fn call_count(&self) -> usize {
        self.call_count
    }

    pub fn min_arg_count(&self) -> Option<usize> {
        (self.call_count != 0).then_some(self.min_arg_count)
    }

    pub fn max_arg_count(&self) -> Option<usize> {
        (self.call_count != 0).then_some(self.max_arg_count)
    }

    pub fn called_only_without_args(&self) -> bool {
        self.call_count != 0 && self.max_arg_count == 0
    }

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

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

    fn record_call(&mut self, arg_count: usize, name_span: Span, diagnostic_span: Span) {
        if self.call_count == 0 {
            self.min_arg_count = arg_count;
            self.max_arg_count = arg_count;
        } else {
            self.min_arg_count = self.min_arg_count.min(arg_count);
            self.max_arg_count = self.max_arg_count.max(arg_count);
        }
        if arg_count == 0 {
            self.zero_arg_call_spans.push(name_span);
            self.zero_arg_diagnostic_spans.push(diagnostic_span);
        }
        self.call_count += 1;
    }
}

#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct FunctionCliDispatchFacts {
    exported_from_case_cli: bool,
    dispatcher_span: Option<Span>,
}

impl FunctionCliDispatchFacts {
    pub fn exported_from_case_cli(self) -> bool {
        self.exported_from_case_cli
    }

    #[cfg(test)]
    pub fn dispatcher_span(self) -> Option<Span> {
        self.dispatcher_span
    }

    fn record_dispatch(&mut self, span: Span) {
        self.exported_from_case_cli = true;
        self.dispatcher_span.get_or_insert(span);
    }
}

#[derive(Debug, Clone, Copy)]
pub(crate) struct FunctionFactInput<'a> {
    pub(crate) command_id: CommandId,
    pub(crate) function: &'a FunctionDef,
}

#[cfg_attr(shuck_profiling, inline(never))]
pub(crate) fn build_function_header_facts<'a>(
    semantic: &SemanticModel,
    semantic_analysis: &SemanticAnalysis<'_>,
    functions: &[FunctionFactInput<'a>],
    commands: &[CommandFact<'a>],
    command_fact_indices_by_id: &[Option<usize>],
    source: &str,
) -> Vec<FunctionHeaderFact<'a>> {
    let call_arity_by_binding = build_function_call_arity_facts(
        semantic_analysis,
        &functions
            .iter()
            .map(|input| input.function)
            .collect::<Vec<_>>(),
        commands,
        command_fact_indices_by_id,
        source,
    );
    functions
        .iter()
        .copied()
        .map(|input| {
            let binding_id = semantic.function_definition_binding_for_command_span(
                semantic.command_span(input.command_id),
            );
            let scope_id = binding_id
                .and_then(|binding_id| semantic_analysis.function_scope_for_binding(binding_id));
            let call_arity = binding_id
                .and_then(|binding_id| call_arity_by_binding.get(&binding_id).cloned())
                .unwrap_or_default();

            FunctionHeaderFact {
                command_id: input.command_id,
                function: input.function,
                binding_id,
                scope_id,
                call_arity,
            }
        })
        .collect()
}

pub(crate) fn build_function_doc_content_facts<'a>(
    semantic: &SemanticModel,
    headers: &[FunctionHeaderFact<'a>],
    commands: &[CommandFact<'a>],
    positional_parameter_facts: &FxHashMap<ScopeId, FunctionPositionalParameterFacts>,
    source: &str,
    line_index: &LineIndex,
    comment_index: &CommentIndex,
) -> Vec<FunctionDocContentFact> {
    let mut summaries = build_function_doc_body_summaries(semantic, commands, source);
    let scopes_using_any_positional_parameters = build_function_scopes_using_positional_parameters(
        semantic,
        commands,
        positional_parameter_facts,
    );

    headers
        .iter()
        .filter_map(|header| {
            let (name, name_span) = header.static_name_entry()?;
            let function_scope = header.function_scope()?;
            let function_span = header.function_span_in_source(source);
            let comment_block = leading_function_doc_block(
                source,
                line_index,
                comment_index,
                function_span.start.line(),
            );
            let summary = summaries.remove(&function_scope).unwrap_or_default();
            let uses_positional_parameters = positional_parameter_facts
                .get(&function_scope)
                .is_some_and(|facts| facts.uses_positional_parameters());
            let uses_any_positional_parameters =
                scopes_using_any_positional_parameters.contains(&function_scope);

            Some(FunctionDocContentFact::new(
                name,
                name_span,
                span_line_count(header.function().body.span),
                comment_block.map(|block| block.span),
                comment_block
                    .map(|block| block.sections)
                    .unwrap_or_default(),
                FunctionDocBodyBehavior::new(
                    summary.uses_global_variables,
                    uses_positional_parameters,
                    uses_any_positional_parameters,
                    summary.writes_stdout,
                    summary.has_explicit_return,
                ),
            ))
        })
        .collect()
}

#[derive(Debug, Clone, Copy, Default)]
struct FunctionDocBodySummary {
    uses_global_variables: bool,
    writes_stdout: bool,
    has_explicit_return: bool,
}

fn build_function_doc_body_summaries(
    semantic: &SemanticModel,
    commands: &[CommandFact<'_>],
    source: &str,
) -> FxHashMap<ScopeId, FunctionDocBodySummary> {
    let mut summaries = FxHashMap::<ScopeId, FunctionDocBodySummary>::default();
    let reference_bindings = reference_binding_index(semantic);
    let global_reference_skip_spans = function_doc_global_reference_skip_spans(commands, source);

    for reference in semantic.references() {
        if !reference_can_represent_global(reference, &global_reference_skip_spans) {
            continue;
        }
        let Some(function_scope) = semantic.enclosing_function_scope(reference.scope) else {
            continue;
        };
        if reference_is_global_variable_use(
            semantic,
            reference,
            function_scope,
            &reference_bindings,
        ) {
            summaries
                .entry(function_scope)
                .or_default()
                .uses_global_variables = true;
        }
    }

    for binding in semantic.bindings() {
        let Some(function_scope) = semantic.enclosing_function_scope(binding.scope) else {
            continue;
        };
        if !binding_can_represent_global_write(semantic, binding, function_scope) {
            continue;
        }
        summaries
            .entry(function_scope)
            .or_default()
            .uses_global_variables = true;
    }

    for command in commands {
        let Some(function_scope) = command.enclosing_function_scope() else {
            continue;
        };
        let summary = summaries.entry(function_scope).or_default();
        summary.writes_stdout |= function_body_command_writes_stdout(command, source);
        summary.has_explicit_return |=
            !command.is_nested_word_command() && command_has_explicit_return(command);
    }

    summaries
}

fn reference_binding_index(semantic: &SemanticModel) -> FxHashMap<ReferenceId, BindingId> {
    let mut index = FxHashMap::default();
    for binding in semantic.bindings() {
        for reference in &binding.references {
            index.insert(*reference, binding.id);
        }
    }
    index
}

fn reference_can_represent_global(reference: &Reference, skip_spans: &FxHashSet<FactSpan>) -> bool {
    !matches!(reference.kind, ReferenceKind::DeclarationName)
        && !is_special_or_positional_parameter_name(reference.name.as_str())
        && !skip_spans.contains(&FactSpan::new(reference.span))
}

fn reference_is_global_variable_use(
    semantic: &SemanticModel,
    reference: &Reference,
    function_scope: ScopeId,
    reference_bindings: &FxHashMap<ReferenceId, BindingId>,
) -> bool {
    let Some(binding_id) = reference_bindings.get(&reference.id).copied() else {
        return true;
    };
    let binding = semantic.binding(binding_id);
    if matches!(binding.kind, BindingKind::FunctionDefinition) {
        return false;
    }

    !binding.attributes.contains(BindingAttributes::LOCAL)
        || semantic.enclosing_function_scope(binding.scope) != Some(function_scope)
}

fn binding_can_represent_global_write(
    semantic: &SemanticModel,
    binding: &Binding,
    function_scope: ScopeId,
) -> bool {
    !binding.attributes.contains(BindingAttributes::LOCAL)
        && !matches!(
            binding.kind,
            BindingKind::FunctionDefinition | BindingKind::Imported
        )
        && !is_special_or_positional_parameter_name(binding.name.as_str())
        && !binding_targets_prior_local(semantic, binding, function_scope)
}

fn binding_targets_prior_local(
    semantic: &SemanticModel,
    binding: &Binding,
    function_scope: ScopeId,
) -> bool {
    semantic
        .bindings_for(&binding.name)
        .iter()
        .any(|candidate| {
            let candidate = semantic.binding(*candidate);
            candidate.id != binding.id
                && candidate.span.start.offset() < binding.span.start.offset()
                && candidate.attributes.contains(BindingAttributes::LOCAL)
                && semantic.enclosing_function_scope(candidate.scope) == Some(function_scope)
        })
}

fn is_special_or_positional_parameter_name(name: &str) -> bool {
    name.bytes().all(|byte| byte.is_ascii_digit())
        || matches!(name, "@" | "*" | "#" | "?" | "-" | "$" | "!")
}

fn function_body_command_writes_stdout(command: &CommandFact<'_>, source: &str) -> bool {
    if command.is_nested_word_command() || command_stdout_is_redirected(command) {
        return false;
    }

    command.normalized().effective_basename_is("echo")
        || (command.normalized().effective_basename_is("printf")
            && (!command.effective_name_is("printf")
                || !printf_assigns_to_variable(command, source)))
}

fn printf_assigns_to_variable(command: &CommandFact<'_>, source: &str) -> bool {
    match command
        .body_args()
        .first()
        .and_then(|word| static_word_text(word, source))
    {
        Some(text) if text == "-v" => command.body_args().len() >= 2,
        Some(text) if text.starts_with("-v") && text.len() > 2 => true,
        _ => false,
    }
}

fn command_stdout_is_redirected(command: &CommandFact<'_>) -> bool {
    command.redirects().iter().any(redirect_redirects_stdout)
}

fn redirect_redirects_stdout(redirect: &Redirect) -> bool {
    if redirect.fd_var.is_some() {
        return false;
    }

    match redirect.kind {
        RedirectKind::Output
        | RedirectKind::Clobber
        | RedirectKind::Append
        | RedirectKind::DupOutput => redirect.fd.unwrap_or(1) == 1,
        RedirectKind::ReadWrite => redirect.fd == Some(1),
        RedirectKind::OutputBoth => true,
        RedirectKind::Input
        | RedirectKind::HereDoc
        | RedirectKind::HereDocStrip
        | RedirectKind::HereString
        | RedirectKind::DupInput => false,
    }
}

fn command_has_explicit_return(command: &CommandFact<'_>) -> bool {
    matches!(
        command.command(),
        Command::Builtin(BuiltinCommand::Return(command)) if command.code.is_some()
    )
}

fn function_doc_global_reference_skip_spans(
    commands: &[CommandFact<'_>],
    source: &str,
) -> FxHashSet<FactSpan> {
    commands
        .iter()
        .filter_map(|command| printf_assignment_target_span(command, source))
        .map(FactSpan::new)
        .collect()
}

fn printf_assignment_target_span(command: &CommandFact<'_>, source: &str) -> Option<Span> {
    if !command.effective_name_is("printf") {
        return None;
    }

    let args = command.body_args();
    match args.first().and_then(|word| static_word_text(word, source)) {
        Some(text) if text == "-v" => args.get(1).map(|word| word.span),
        Some(text) if text.starts_with("-v") && text.len() > 2 => Some(args[0].span),
        _ => None,
    }
}

#[derive(Debug, Clone, Copy)]
struct LeadingFunctionDocBlock {
    span: Span,
    sections: FunctionDocSections,
}

fn leading_function_doc_block(
    source: &str,
    line_index: &LineIndex,
    comment_index: &CommentIndex,
    function_start_line: usize,
) -> Option<LeadingFunctionDocBlock> {
    let mut line = function_start_line.checked_sub(1)?;
    let mut block_start: Option<Position> = None;
    let mut block_end: Option<Position> = None;
    let mut sections = FunctionDocSections::default();
    let mut has_doc_comment = false;

    loop {
        let Some(comment) = comment_index
            .comments_on_line(line)
            .iter()
            .find(|comment| comment.is_own_line)
        else {
            break;
        };
        let line_start = usize::from(line_index.line_start(line)?);
        let line_end = usize::from(line_index.line_range(line, source)?.end()).min(source.len());
        let comment_start = usize::from(comment.range.start());
        let comment_end = usize::from(comment.range.end()).min(line_end);
        if comment_start < line_start || comment_start >= comment_end {
            break;
        }

        let comment_text = &source[comment_start..comment_end];
        let Some(comment_body) = function_doc_comment_body(comment_text) else {
            break;
        };

        let line_start_position = Position::at(line, 1, line_start);
        let comment_start_position =
            line_start_position.advanced_by(&source[line_start..comment_start]);
        let comment_end_position =
            line_start_position.advanced_by(&source[line_start..comment_end]);
        block_start = Some(comment_start_position);
        block_end.get_or_insert(comment_end_position);

        if is_function_doc_comment_body(comment_body) {
            has_doc_comment = true;
            sections.record_comment_body(comment_body);
        }

        let Some(previous_line) = line.checked_sub(1) else {
            break;
        };
        line = previous_line;
    }

    if !has_doc_comment {
        return None;
    }

    Some(LeadingFunctionDocBlock {
        span: Span::from_positions(block_start?, block_end?),
        sections,
    })
}

fn function_doc_comment_body(comment_text: &str) -> Option<&str> {
    let body = comment_text.strip_prefix('#')?.trim();
    (!body.starts_with('!')).then_some(body)
}

fn is_function_doc_comment_body(body: &str) -> bool {
    if body.is_empty() {
        return false;
    }

    let lower = body.to_ascii_lowercase();
    !lower.starts_with("shellcheck ") && !lower.starts_with("shuck:")
}

fn build_function_scopes_using_positional_parameters(
    semantic: &SemanticModel,
    commands: &[CommandFact<'_>],
    positional_parameter_facts: &FxHashMap<ScopeId, FunctionPositionalParameterFacts>,
) -> FxHashSet<ScopeId> {
    let local_reset_offsets_by_scope = local_positional_reset_offsets_by_scope(semantic, commands);
    let mut scopes = positional_parameter_facts
        .iter()
        .filter_map(|(&scope, facts)| facts.uses_positional_parameters().then_some(scope))
        .collect::<FxHashSet<_>>();

    for reference in semantic.references() {
        if !semantic.is_guarded_parameter_reference(reference.id)
            || !is_positional_parameter_reference_name(reference.name.as_str())
        {
            continue;
        }
        if reference_has_local_positional_reset(
            semantic,
            reference.scope,
            reference.span.start.offset(),
            &local_reset_offsets_by_scope,
        ) {
            continue;
        }
        if let Some(scope) = semantic.enclosing_function_scope(reference.scope) {
            scopes.insert(scope);
        }
    }

    scopes
}

fn is_positional_parameter_reference_name(name: &str) -> bool {
    matches!(name, "@" | "*" | "#")
        || (!name.is_empty() && name != "0" && name.bytes().all(|byte| byte.is_ascii_digit()))
}

fn span_line_count(span: Span) -> usize {
    span.end.line().saturating_sub(span.start.line()) + 1
}

#[cfg_attr(shuck_profiling, inline(never))]
pub(crate) fn build_function_cli_dispatch_facts(
    dispatches: &[CaseCliDispatch],
) -> FxHashMap<ScopeId, FunctionCliDispatchFacts> {
    let mut facts = FxHashMap::<ScopeId, FunctionCliDispatchFacts>::default();
    for dispatch in dispatches {
        facts
            .entry(dispatch.function_scope())
            .or_default()
            .record_dispatch(dispatch.dispatcher_span());
    }
    facts
}

pub(crate) fn build_function_parameter_fallback_spans(
    commands: &[CommandFact<'_>],
    command_fact_indices_by_id: &[Option<usize>],
    structural_command_ids: &[CommandId],
    source: &str,
) -> Vec<Span> {
    let structural_commands = structural_command_ids
        .iter()
        .copied()
        .filter_map(|id| {
            command_fact_indices_by_id
                .get(id.index())
                .copied()
                .flatten()
                .and_then(|index| commands.get(index))
        })
        .collect::<Vec<_>>();

    structural_commands
        .windows(2)
        .filter_map(|pair| function_parameter_fallback_span(pair, source))
        .chain(
            commands
                .iter()
                .filter_map(named_coproc_subshell_fallback_span),
        )
        .collect()
}

pub(crate) fn build_completion_registered_function_command_flags(
    commands: &[CommandFact<'_>],
    registered_scopes: &FxHashSet<ScopeId>,
) -> Vec<bool> {
    let mut flags = vec![false; function_command_slot_count(commands)];
    for command in commands {
        flags[command.id().index()] = command
            .enclosing_function_scope()
            .is_some_and(|scope| registered_scopes.contains(&scope));
    }
    flags
}

pub(crate) fn build_completion_registered_function_scopes(
    semantic: &SemanticModel,
    semantic_analysis: &SemanticAnalysis<'_>,
    commands: &[CommandFact<'_>],
    command_fact_indices_by_id: &[Option<usize>],
    lists: &[ListFact<'_>],
    source: &str,
) -> FxHashSet<ScopeId> {
    let mut function_candidates = Vec::new();
    function_candidates.resize_with(function_command_slot_count(commands), || None);
    for command in commands {
        function_candidates[command.id().index()] =
            completion_registered_function_candidate(semantic, command);
    }
    let mut file_level_function_candidates = Vec::new();
    file_level_function_candidates.resize_with(function_command_slot_count(commands), || None);
    for command in commands {
        file_level_function_candidates[command.id().index()] =
            file_level_completion_function_candidate(semantic, command);
    }
    let top_level_candidate_scopes = function_candidates
        .iter()
        .flatten()
        .map(|candidate| candidate.scope)
        .collect::<FxHashSet<_>>();
    let file_level_candidate_scopes = file_level_function_candidates
        .iter()
        .flatten()
        .map(|candidate| candidate.scope)
        .collect::<FxHashSet<_>>();
    let mut scopes = FxHashSet::default();

    for list in lists {
        for (index, segment) in list.segments().iter().enumerate() {
            let Some(candidate) = function_candidates[segment.command_id().index()].as_ref() else {
                continue;
            };

            if list.segments()[index + 1..].iter().any(|later_segment| {
                let later_command = command_fact(
                    commands,
                    command_fact_indices_by_id,
                    later_segment.command_id(),
                );
                is_unconditional_completion_registration(semantic, later_command)
                    && command_registers_completion_function(later_command, source, &candidate.name)
            }) {
                scopes.insert(candidate.scope);
            }
        }
    }

    for list in lists {
        for (index, segment) in list.segments().iter().enumerate() {
            let Some(candidate) =
                file_level_function_candidates[segment.command_id().index()].as_ref()
            else {
                continue;
            };

            if list.segments()[index + 1..].iter().any(|later_segment| {
                let later_command = command_fact(
                    commands,
                    command_fact_indices_by_id,
                    later_segment.command_id(),
                );
                is_same_branch_completion_registration(semantic, later_command)
                    && command_registers_completion_function(later_command, source, &candidate.name)
            }) {
                scopes.insert(candidate.scope);
            }
        }
    }

    // The registration predicates do not depend on the candidate, so gather
    // the (typically rare) registration commands once instead of rescanning
    // every command per candidate.
    let same_branch_registrations = commands
        .iter()
        .filter(|command| is_same_branch_completion_registration(semantic, command))
        .collect::<Vec<_>>();
    let compdef_registrations = commands
        .iter()
        .filter(|command| {
            is_unconditional_completion_registration(semantic, command)
                && command.effective_or_literal_name() == Some("compdef")
        })
        .collect::<Vec<_>>();

    if !same_branch_registrations.is_empty() {
        for command in commands {
            let Some(candidate) = file_level_function_candidates[command.id().index()].as_ref()
            else {
                continue;
            };
            let Some(parent) = nearest_structural_parent_command(semantic, command.id()) else {
                continue;
            };
            if same_branch_registrations.iter().any(|later_command| {
                later_command.span().start.offset() > command.span().start.offset()
                    && nearest_structural_parent_command(semantic, later_command.id())
                        == Some(parent)
                    && same_structural_branch_between(
                        source,
                        command.span().end.offset(),
                        later_command.span().start.offset(),
                    )
                    && command_registers_completion_function(later_command, source, &candidate.name)
            }) {
                scopes.insert(candidate.scope);
            }
        }
    }

    if !compdef_registrations.is_empty() {
        for command in commands {
            let Some(candidate) = function_candidates[command.id().index()].as_ref() else {
                continue;
            };
            if compdef_registrations.iter().any(|later_command| {
                command_registers_completion_function(later_command, source, &candidate.name)
            }) {
                scopes.insert(candidate.scope);
            }
        }

        for scope in semantic.scopes() {
            if !top_level_candidate_scopes.contains(&scope.id) {
                continue;
            }
            let ScopeKind::Function(FunctionScopeKind::Named(names)) = &scope.kind else {
                continue;
            };
            if compdef_registrations.iter().any(|command| {
                names.iter().any(|name| {
                    command_registers_completion_function(command, source, name.as_str())
                })
            }) {
                scopes.insert(scope.id);
            }
        }
    }

    extend_completion_registered_function_scopes_through_helpers(
        semantic,
        semantic_analysis,
        commands,
        &file_level_candidate_scopes,
        source,
        &mut scopes,
    );

    scopes
}

pub(crate) fn extend_completion_registered_function_scopes_through_helpers(
    semantic: &SemanticModel,
    semantic_analysis: &SemanticAnalysis<'_>,
    commands: &[CommandFact<'_>],
    candidate_scopes: &FxHashSet<ScopeId>,
    source: &str,
    scopes: &mut FxHashSet<ScopeId>,
) {
    let candidate_bindings = semantic_analysis
        .function_bindings_by_scope()
        .filter(|(scope, _)| candidate_scopes.contains(scope))
        .flat_map(|(scope, bindings)| bindings.iter().map(move |binding| (scope, *binding)))
        .collect::<Vec<_>>();

    // `_arguments` invocations are the only commands the fixed-point loop
    // inspects, so collect them once up front.
    let argument_spec_commands = commands
        .iter()
        .filter(|command| command.normalized().effective_or_literal_name() == Some("_arguments"))
        .collect::<Vec<_>>();

    let mut changed = true;
    while changed {
        changed = false;
        for (target_scope, binding_id) in &candidate_bindings {
            if scopes.contains(target_scope) {
                continue;
            }
            let name = semantic.binding(*binding_id).name.clone();
            let called_from_completion_scope = semantic_analysis
                .function_call_arity_sites(&name)
                .any(|(site, resolved_binding)| {
                    resolved_binding == *binding_id
                        && semantic_analysis
                            .enclosing_function_scope_at(site.name_span.start.offset())
                            .is_some_and(|caller_scope| scopes.contains(&caller_scope))
                });
            let referenced_by_completion_arguments = argument_spec_commands.iter().any(|command| {
                command
                    .enclosing_function_scope()
                    .is_some_and(|caller_scope| scopes.contains(&caller_scope))
                    && command.body_args().iter().any(|word| {
                        static_word_text(word, source).is_some_and(|text| {
                            zsh_arguments_spec_invokes_function(
                                &text,
                                &semantic.binding(*binding_id).name,
                            )
                        })
                    })
            });
            if called_from_completion_scope || referenced_by_completion_arguments {
                scopes.insert(*target_scope);
                changed = true;
            }
        }
    }
}

#[cfg_attr(shuck_profiling, inline(never))]
pub(crate) fn build_external_entrypoint_function_scopes(
    semantic: &SemanticModel,
    commands: &[CommandFact<'_>],
    command_fact_indices_by_id: &[Option<usize>],
    lists: &[ListFact<'_>],
    source: &str,
) -> FxHashSet<ScopeId> {
    let mut function_candidates = Vec::new();
    function_candidates.resize_with(function_command_slot_count(commands), || None);
    for command in commands {
        function_candidates[command.id().index()] =
            external_entrypoint_function_candidate(semantic, command);
    }

    let mut scopes = FxHashSet::default();
    for candidate in function_candidates.iter().flatten() {
        if is_zsh_special_hook_name(&candidate.name) {
            scopes.insert(candidate.scope);
        }
    }

    let mut zsh_widget_functions = FxHashMap::<Box<str>, Box<str>>::default();
    let mut zsh_hook_targets = FxHashSet::<(Box<str>, ZshHookTarget)>::default();
    for command in commands {
        if !is_top_level_zsh_entrypoint_registration(semantic, command) {
            continue;
        }
        match command_zsh_external_entrypoint_action(command, source) {
            Some(ZshExternalEntrypointAction::RegisterWidget { widget, function }) => {
                zsh_widget_functions.insert(widget, function);
            }
            Some(ZshExternalEntrypointAction::UnregisterWidgets { widgets }) => {
                for widget in widgets {
                    zsh_widget_functions.remove(&widget);
                }
            }
            Some(ZshExternalEntrypointAction::RegisterHookFunction { hook, function }) => {
                zsh_hook_targets.insert((hook, ZshHookTarget::Function(function)));
            }
            Some(ZshExternalEntrypointAction::RegisterHookWidget { hook, widget }) => {
                zsh_hook_targets.insert((hook, ZshHookTarget::Widget(widget)));
            }
            Some(ZshExternalEntrypointAction::UnregisterHookFunction { hook, function }) => {
                zsh_hook_targets.remove(&(hook, ZshHookTarget::Function(function)));
            }
            Some(ZshExternalEntrypointAction::UnregisterHookWidget { hook, widget }) => {
                zsh_hook_targets.remove(&(hook, ZshHookTarget::Widget(widget)));
            }
            Some(ZshExternalEntrypointAction::UnregisterHookFunctionPattern { hook, pattern }) => {
                zsh_hook_targets.retain(|(registered_hook, registered_target)| {
                    registered_hook.as_ref() != hook.as_ref()
                        || !registered_target.is_function_matching_pattern(&pattern)
                });
            }
            Some(ZshExternalEntrypointAction::UnregisterHookWidgetPattern { hook, pattern }) => {
                zsh_hook_targets.retain(|(registered_hook, registered_target)| {
                    registered_hook.as_ref() != hook.as_ref()
                        || !registered_target.is_widget_matching_pattern(&pattern)
                });
            }
            None => {}
        }
    }

    for candidate in function_candidates.iter().flatten() {
        if zsh_widget_functions
            .values()
            .any(|name| name.as_ref() == candidate.name.as_ref())
            || zsh_hook_targets
                .iter()
                .any(|(_, target)| target.matches_function(&candidate.name, &zsh_widget_functions))
        {
            scopes.insert(candidate.scope);
        }
    }

    for list in lists {
        for (index, segment) in list.segments().iter().enumerate() {
            let Some(candidate) = function_candidates[segment.command_id().index()].as_ref() else {
                continue;
            };

            if list.segments()[index + 1..].iter().any(|later_segment| {
                command_registers_completion_function(
                    command_fact(
                        commands,
                        command_fact_indices_by_id,
                        later_segment.command_id(),
                    ),
                    source,
                    &candidate.name,
                )
            }) {
                scopes.insert(candidate.scope);
            }
        }
    }

    scopes
}

pub(crate) fn is_top_level_zsh_entrypoint_registration(
    semantic: &SemanticModel,
    command: &CommandFact<'_>,
) -> bool {
    semantic.scope(command.scope()).parent.is_none()
        && !command.is_nested_word_command()
        && !has_structural_parent_command(semantic, command.id())
}

pub(crate) fn has_structural_parent_command(semantic: &SemanticModel, id: CommandId) -> bool {
    nearest_structural_parent_command(semantic, id).is_some()
}

pub(crate) fn nearest_structural_parent_command(
    semantic: &SemanticModel,
    id: CommandId,
) -> Option<CommandId> {
    let mut current = semantic.syntax_backed_command_parent_id(id);
    while let Some(parent) = current {
        if matches!(
            semantic.command_kind(parent),
            shuck_semantic::CommandKind::Compound(_)
                | shuck_semantic::CommandKind::Function
                | shuck_semantic::CommandKind::AnonymousFunction
        ) {
            return Some(parent);
        }
        current = semantic.syntax_backed_command_parent_id(parent);
    }
    None
}

pub(crate) fn same_structural_branch_between(source: &str, start: usize, end: usize) -> bool {
    let Some(between) = source.get(start..end) else {
        return false;
    };
    !contains_zsh_structural_branch_boundary(between)
}

pub(crate) fn contains_zsh_structural_branch_boundary(text: &str) -> bool {
    #[derive(Clone, Copy, PartialEq, Eq)]
    enum Quote {
        None,
        Single,
        Double,
    }

    let mut chars = text.chars().peekable();
    let mut quote = Quote::None;
    let mut in_comment = false;
    let mut escaped = false;
    let mut word = String::new();
    let mut at_word_start = true;

    while let Some(ch) = chars.next() {
        if in_comment {
            if ch == '\n' {
                in_comment = false;
                at_word_start = true;
            }
            continue;
        }

        match quote {
            Quote::Single => {
                if ch == '\'' {
                    quote = Quote::None;
                }
                continue;
            }
            Quote::Double => {
                if escaped {
                    escaped = false;
                    continue;
                }
                if ch == '\\' {
                    escaped = true;
                    continue;
                }
                if ch == '"' {
                    quote = Quote::None;
                }
                continue;
            }
            Quote::None => {}
        }

        match ch {
            '#' => {
                if structural_boundary_word(&word) {
                    return true;
                }
                word.clear();
                if at_word_start {
                    in_comment = true;
                } else {
                    at_word_start = false;
                }
            }
            '\'' => {
                if structural_boundary_word(&word) {
                    return true;
                }
                word.clear();
                at_word_start = false;
                quote = Quote::Single;
            }
            '"' => {
                if structural_boundary_word(&word) {
                    return true;
                }
                word.clear();
                at_word_start = false;
                quote = Quote::Double;
            }
            ';' => {
                if structural_boundary_word(&word) {
                    return true;
                }
                word.clear();
                if matches!(chars.peek(), Some(';' | '&' | '|')) {
                    return true;
                }
                at_word_start = true;
            }
            _ if ch.is_whitespace() => {
                if structural_boundary_word(&word) {
                    return true;
                }
                word.clear();
                at_word_start = true;
            }
            _ if is_shell_identifier_char(ch) => {
                word.push(ch);
                at_word_start = false;
            }
            _ => {
                if structural_boundary_word(&word) {
                    return true;
                }
                word.clear();
                at_word_start = false;
            }
        }
    }

    structural_boundary_word(&word)
}

pub(crate) fn structural_boundary_word(word: &str) -> bool {
    matches!(word, "else" | "elif")
}

pub(crate) fn is_shell_identifier_char(ch: char) -> bool {
    ch == '_' || ch.is_ascii_alphanumeric()
}

pub(crate) fn is_unconditional_completion_registration(
    semantic: &SemanticModel,
    command: &CommandFact<'_>,
) -> bool {
    if !is_top_level_zsh_entrypoint_registration(semantic, command) {
        return false;
    }
    command.effective_or_literal_name() != Some("compdef")
        || !has_binary_parent_command(semantic, command.id())
}

pub(crate) fn is_same_branch_completion_registration(
    semantic: &SemanticModel,
    command: &CommandFact<'_>,
) -> bool {
    matches!(
        command.effective_or_literal_name(),
        Some("compdef" | "complete")
    ) && !has_binary_parent_command(semantic, command.id())
}

pub(crate) fn has_binary_parent_command(semantic: &SemanticModel, id: CommandId) -> bool {
    let mut current = semantic.syntax_backed_command_parent_id(id);
    while let Some(parent) = current {
        if matches!(
            semantic.command_kind(parent),
            shuck_semantic::CommandKind::Binary
        ) {
            return true;
        }
        current = semantic.syntax_backed_command_parent_id(parent);
    }
    false
}

pub(crate) fn function_command_slot_count(commands: &[CommandFact<'_>]) -> usize {
    commands
        .iter()
        .map(|command| command.id().index())
        .max()
        .map_or(0, |index| index + 1)
}

pub(crate) fn completion_registered_function_candidate(
    semantic: &SemanticModel,
    command: &CommandFact<'_>,
) -> Option<CompletionRegisteredFunctionCandidate> {
    if !is_top_level_zsh_entrypoint_registration(semantic, command) {
        return None;
    }
    let Command::Function(function) = command.command() else {
        return None;
    };
    let (name, _) = function.static_name_entries().next()?;
    let scope = semantic.scope_at(function.body.span.start.offset());

    Some(CompletionRegisteredFunctionCandidate {
        scope,
        name: name.as_str().to_owned().into_boxed_str(),
    })
}

pub(crate) fn file_level_completion_function_candidate(
    semantic: &SemanticModel,
    command: &CommandFact<'_>,
) -> Option<CompletionRegisteredFunctionCandidate> {
    if command.is_nested_word_command()
        || semantic.enclosing_function_scope(command.scope()).is_some()
    {
        return None;
    }
    let Command::Function(function) = command.command() else {
        return None;
    };
    let (name, _) = function.static_name_entries().next()?;
    let scope = semantic.scope_at(function.body.span.start.offset());

    Some(CompletionRegisteredFunctionCandidate {
        scope,
        name: name.as_str().to_owned().into_boxed_str(),
    })
}

pub(crate) fn external_entrypoint_function_candidate(
    semantic: &SemanticModel,
    command: &CommandFact<'_>,
) -> Option<CompletionRegisteredFunctionCandidate> {
    let Command::Function(function) = command.command() else {
        return None;
    };
    let (name, _) = function.static_name_entries().next()?;
    let scope = semantic.scope_at(function.body.span.start.offset());
    let name_text = name.as_str();

    Some(CompletionRegisteredFunctionCandidate {
        scope,
        name: name_text.to_owned().into_boxed_str(),
    })
}

pub(crate) fn command_registers_completion_function(
    command: &CommandFact<'_>,
    source: &str,
    expected_name: &str,
) -> bool {
    let command_name = command.effective_or_literal_name();

    if command_name == Some("compdef") {
        let mut index = 0usize;
        let args = command.body_args();
        while let Some(word) = args.get(index) {
            let Some(text) = static_word_text(word, source) else {
                return false;
            };
            if text == "--" {
                index += 1;
                break;
            }
            let Some(flags) = text.strip_prefix('-') else {
                break;
            };
            if flags.is_empty() || flags.starts_with('-') {
                break;
            }
            if flags.contains('d') || flags.contains('D') {
                return false;
            }
            index += 1;
            for flag in flags.chars() {
                if matches!(flag, 'p' | 'P') {
                    if index >= args.len() {
                        return false;
                    }
                    index += 1;
                }
            }
        }

        if let Some(word) = args.get(index) {
            let Some(text) = static_word_text(word, source) else {
                return false;
            };
            if text.contains('=') {
                return false;
            }
            if text == expected_name {
                return true;
            }
            return false;
        }
        return false;
    }

    if command_name == Some("complete") {
        let mut expects_function_name = false;
        for word in command.body_args() {
            let Some(text) = static_word_text(word, source) else {
                expects_function_name = false;
                continue;
            };

            if expects_function_name {
                return text == expected_name;
            }

            if text == "--" {
                return false;
            }
            if text == "-F" || text == "--function" {
                expects_function_name = true;
                continue;
            }
            if let Some(name) = text.strip_prefix("-F")
                && !name.is_empty()
            {
                return name == expected_name;
            }
            if let Some(name) = text.strip_prefix("--function=") {
                return name == expected_name;
            }
        }
    }

    false
}

pub(crate) fn zsh_arguments_spec_invokes_function(spec: &str, expected_name: &Name) -> bool {
    spec.split(':')
        .skip(1)
        .map(str::trim)
        .any(|segment| segment == expected_name.as_str())
}

pub(crate) enum ZshExternalEntrypointAction {
    RegisterWidget {
        widget: Box<str>,
        function: Box<str>,
    },
    UnregisterWidgets {
        widgets: Vec<Box<str>>,
    },
    RegisterHookFunction {
        hook: Box<str>,
        function: Box<str>,
    },
    RegisterHookWidget {
        hook: Box<str>,
        widget: Box<str>,
    },
    UnregisterHookFunction {
        hook: Box<str>,
        function: Box<str>,
    },
    UnregisterHookWidget {
        hook: Box<str>,
        widget: Box<str>,
    },
    UnregisterHookFunctionPattern {
        hook: Box<str>,
        pattern: Box<str>,
    },
    UnregisterHookWidgetPattern {
        hook: Box<str>,
        pattern: Box<str>,
    },
}

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) enum ZshHookTarget {
    Function(Box<str>),
    Widget(Box<str>),
}

impl ZshHookTarget {
    fn matches_function(
        &self,
        function: &str,
        widget_functions: &FxHashMap<Box<str>, Box<str>>,
    ) -> bool {
        match self {
            Self::Function(name) => name.as_ref() == function,
            Self::Widget(widget) => {
                widget.as_ref() == function
                    || widget_functions
                        .get(widget)
                        .is_some_and(|name| name.as_ref() == function)
            }
        }
    }

    fn is_function_matching_pattern(&self, pattern: &str) -> bool {
        match self {
            Self::Function(name) => zsh_hook_function_pattern_matches(pattern, name),
            Self::Widget(_) => false,
        }
    }

    fn is_widget_matching_pattern(&self, pattern: &str) -> bool {
        match self {
            Self::Function(_) => false,
            Self::Widget(name) => zsh_hook_function_pattern_matches(pattern, name),
        }
    }
}

pub(crate) fn command_zsh_external_entrypoint_action(
    command: &CommandFact<'_>,
    source: &str,
) -> Option<ZshExternalEntrypointAction> {
    match command.effective_or_literal_name()? {
        "zle" => zle_external_entrypoint_action(command, source),
        "add-zsh-hook" => {
            add_zsh_hook_external_entrypoint_action(command, source, AddZshHookTargetKind::Function)
        }
        "add-zle-hook-widget" => {
            add_zsh_hook_external_entrypoint_action(command, source, AddZshHookTargetKind::Widget)
        }
        _ => None,
    }
}

pub(crate) fn zle_external_entrypoint_action(
    command: &CommandFact<'_>,
    source: &str,
) -> Option<ZshExternalEntrypointAction> {
    let args = static_command_args(command, source)?;

    if let Some(registration_index) = args.iter().position(|arg| arg == "-N") {
        let operands = args[registration_index + 1..]
            .iter()
            .filter(|arg| !arg.starts_with('-'))
            .collect::<Vec<_>>();
        return match operands.as_slice() {
            [widget] => Some(ZshExternalEntrypointAction::RegisterWidget {
                widget: widget.as_str().into(),
                function: widget.as_str().into(),
            }),
            [widget, function, ..] => Some(ZshExternalEntrypointAction::RegisterWidget {
                widget: widget.as_str().into(),
                function: function.as_str().into(),
            }),
            _ => None,
        };
    }

    if let Some(removal_index) = args.iter().position(|arg| arg == "-D") {
        let widgets = args[removal_index + 1..]
            .iter()
            .filter(|arg| !arg.starts_with('-'))
            .map(|arg| arg.as_str().into())
            .collect::<Vec<_>>();
        if !widgets.is_empty() {
            return Some(ZshExternalEntrypointAction::UnregisterWidgets { widgets });
        }
    }

    None
}

pub(crate) fn add_zsh_hook_external_entrypoint_action(
    command: &CommandFact<'_>,
    source: &str,
    target_kind: AddZshHookTargetKind,
) -> Option<ZshExternalEntrypointAction> {
    let args = static_command_args(command, source)?;
    let removal_mode = add_zsh_hook_removal_mode(&args);
    let operands = args
        .iter()
        .filter(|arg| !arg.starts_with('-'))
        .collect::<Vec<_>>();
    match operands.as_slice() {
        [hook, function, ..] if removal_mode == Some(AddZshHookRemovalMode::Exact) => {
            match target_kind {
                AddZshHookTargetKind::Function => {
                    Some(ZshExternalEntrypointAction::UnregisterHookFunction {
                        hook: hook.as_str().into(),
                        function: function.as_str().into(),
                    })
                }
                AddZshHookTargetKind::Widget => {
                    Some(ZshExternalEntrypointAction::UnregisterHookWidget {
                        hook: hook.as_str().into(),
                        widget: function.as_str().into(),
                    })
                }
            }
        }
        [hook, pattern, ..] if removal_mode == Some(AddZshHookRemovalMode::Pattern) => {
            match target_kind {
                AddZshHookTargetKind::Function => {
                    Some(ZshExternalEntrypointAction::UnregisterHookFunctionPattern {
                        hook: hook.as_str().into(),
                        pattern: pattern.as_str().into(),
                    })
                }
                AddZshHookTargetKind::Widget => {
                    Some(ZshExternalEntrypointAction::UnregisterHookWidgetPattern {
                        hook: hook.as_str().into(),
                        pattern: pattern.as_str().into(),
                    })
                }
            }
        }
        [hook, function, ..] if removal_mode.is_none() => match target_kind {
            AddZshHookTargetKind::Function => {
                Some(ZshExternalEntrypointAction::RegisterHookFunction {
                    hook: hook.as_str().into(),
                    function: function.as_str().into(),
                })
            }
            AddZshHookTargetKind::Widget => Some(ZshExternalEntrypointAction::RegisterHookWidget {
                hook: hook.as_str().into(),
                widget: function.as_str().into(),
            }),
        },
        _ => None,
    }
}

#[derive(Debug, Clone, Copy)]
pub(crate) enum AddZshHookTargetKind {
    Function,
    Widget,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AddZshHookRemovalMode {
    Exact,
    Pattern,
}

pub(crate) fn add_zsh_hook_removal_mode(args: &[String]) -> Option<AddZshHookRemovalMode> {
    if args
        .iter()
        .any(|arg| add_zsh_hook_option_contains(arg, 'D'))
    {
        return Some(AddZshHookRemovalMode::Pattern);
    }
    args.iter()
        .any(|arg| add_zsh_hook_option_contains(arg, 'd'))
        .then_some(AddZshHookRemovalMode::Exact)
}

pub(crate) fn add_zsh_hook_option_contains(arg: &str, flag: char) -> bool {
    arg.starts_with('-') && arg != "--" && arg.chars().skip(1).any(|c| c == flag)
}

pub(crate) fn zsh_hook_function_pattern_matches(pattern: &str, function: &str) -> bool {
    if pattern_contains_unsupported_zsh_metachar(pattern) {
        return true;
    }
    zsh_simple_glob_pattern_matches(pattern.as_bytes(), function.as_bytes())
}

pub(crate) fn zsh_simple_glob_pattern_matches(pattern: &[u8], text: &[u8]) -> bool {
    if pattern.is_empty() {
        return text.is_empty();
    }

    match pattern[0] {
        b'*' => {
            zsh_simple_glob_pattern_matches(&pattern[1..], text)
                || (!text.is_empty() && zsh_simple_glob_pattern_matches(pattern, &text[1..]))
        }
        b'?' => !text.is_empty() && zsh_simple_glob_pattern_matches(&pattern[1..], &text[1..]),
        b'[' => zsh_bracket_pattern_matches(pattern, text),
        b'<' if pattern.starts_with(b"<->") => zsh_numeric_pattern_matches(&pattern[3..], text),
        literal => {
            text.first().is_some_and(|byte| *byte == literal)
                && zsh_simple_glob_pattern_matches(&pattern[1..], &text[1..])
        }
    }
}

pub(crate) fn pattern_contains_unsupported_zsh_metachar(pattern: &str) -> bool {
    let mut in_bracket_class = false;
    for byte in pattern.bytes() {
        match byte {
            b'[' if !in_bracket_class => in_bracket_class = true,
            b']' if in_bracket_class => in_bracket_class = false,
            b'(' | b')' | b'|' | b'~' | b'#' if !in_bracket_class => return true,
            _ => {}
        }
    }
    false
}

pub(crate) fn zsh_numeric_pattern_matches(pattern_after_numeric: &[u8], text: &[u8]) -> bool {
    let digit_count = text.iter().take_while(|byte| byte.is_ascii_digit()).count();
    (1..=digit_count)
        .any(|consumed| zsh_simple_glob_pattern_matches(pattern_after_numeric, &text[consumed..]))
}

pub(crate) fn zsh_bracket_pattern_matches(pattern: &[u8], text: &[u8]) -> bool {
    let Some(&candidate) = text.first() else {
        return false;
    };
    let Some(close_index) = pattern.iter().position(|byte| *byte == b']') else {
        return candidate == b'[' && zsh_simple_glob_pattern_matches(&pattern[1..], &text[1..]);
    };
    if close_index == 1 {
        return candidate == b'[' && zsh_simple_glob_pattern_matches(&pattern[1..], &text[1..]);
    }

    let class = &pattern[1..close_index];
    let (negated, class) = match class {
        [b'!' | b'^', rest @ ..] => (true, rest),
        _ => (false, class),
    };
    let matched = zsh_bracket_class_contains(class, candidate);
    if matched != negated {
        zsh_simple_glob_pattern_matches(&pattern[close_index + 1..], &text[1..])
    } else {
        false
    }
}

pub(crate) fn zsh_bracket_class_contains(class: &[u8], candidate: u8) -> bool {
    let mut index = 0;
    while index < class.len() {
        if index + 2 < class.len() && class[index + 1] == b'-' {
            if class[index] <= candidate && candidate <= class[index + 2] {
                return true;
            }
            index += 3;
        } else {
            if class[index] == candidate {
                return true;
            }
            index += 1;
        }
    }
    false
}

pub(crate) fn static_command_args(command: &CommandFact<'_>, source: &str) -> Option<Vec<String>> {
    command
        .body_args()
        .iter()
        .map(|word| static_word_text(word, source).map(|text| text.into_owned()))
        .collect()
}

pub(crate) fn is_zsh_special_hook_name(name: &str) -> bool {
    matches!(
        name,
        "precmd"
            | "preexec"
            | "chpwd"
            | "periodic"
            | "zshaddhistory"
            | "zsh_directory_name"
            | "zshexit"
    )
}

#[derive(Debug, Clone)]
pub(crate) struct CompletionRegisteredFunctionCandidate {
    scope: ScopeId,
    name: Box<str>,
}

pub(crate) fn function_parameter_fallback_span(
    pair: &[&CommandFact<'_>],
    source: &str,
) -> Option<Span> {
    let [first, second] = pair else {
        return None;
    };
    let name = first.normalized().effective_or_literal_name()?;
    if !is_plausible_shell_function_name(name) || !first.normalized().body_args().is_empty() {
        return None;
    }
    if !matches!(first.command(), Command::Simple(_)) {
        return None;
    }
    let Command::Compound(CompoundCommand::Subshell(commands)) = second.command() else {
        return None;
    };
    if commands.is_empty() {
        return None;
    }
    if first.span().start.line() != second.span().start.line() {
        return None;
    }
    let tail = source.get(second.span().end.offset()..)?;
    if !matches!(next_function_body_delimiter(tail), Some('{') | Some('(')) {
        return None;
    }
    let text = first.span().slice(source);
    let relative = text.find('(')?;
    let start = first.span().start.advanced_by(&text[..relative]);
    Some(Span::from_positions(start, start.advanced_by("(")))
}

pub(crate) fn named_coproc_subshell_fallback_span(command: &CommandFact<'_>) -> Option<Span> {
    let Command::Compound(CompoundCommand::Coproc(coproc)) = command.command() else {
        return None;
    };
    coproc.name_span?;
    let Command::Compound(CompoundCommand::Subshell(commands)) = &coproc.body.command else {
        return None;
    };
    if commands.is_empty() {
        return None;
    }
    let body_start = coproc.body.span.start;
    if coproc.span.start.line() != body_start.line() {
        return None;
    }
    Some(Span::from_positions(body_start, body_start))
}
pub(crate) fn build_function_call_arity_facts<'a>(
    semantic_analysis: &SemanticAnalysis<'_>,
    functions: &[&FunctionDef],
    commands: &[CommandFact<'a>],
    command_fact_indices_by_id: &[Option<usize>],
    source: &str,
) -> FxHashMap<BindingId, FunctionCallArityFacts> {
    let mut facts = FxHashMap::<BindingId, FunctionCallArityFacts>::default();
    let mut seen_names = FxHashSet::default();
    let mut unique_function_names: Vec<&Name> = Vec::with_capacity(functions.len());
    for function in functions {
        let Some((name, _)) = function.static_name_entries().next() else {
            continue;
        };
        if seen_names.insert(name.clone()) {
            unique_function_names.push(name);
        }
    }
    if unique_function_names.is_empty() {
        return facts;
    }

    let mut offsets = Vec::new();
    for name in &unique_function_names {
        for (site, _) in semantic_analysis.function_call_arity_sites(name) {
            offsets.push(site.name_span.start.offset());
        }
    }
    if offsets.is_empty() {
        return facts;
    }

    let command_ids_by_offset = build_innermost_command_ids_by_offset(commands, offsets);

    for name in unique_function_names {
        for (site, binding_id) in semantic_analysis.function_call_arity_sites(name) {
            let Some(command_id) = precomputed_command_id_for_offset(
                &command_ids_by_offset,
                site.name_span.start.offset(),
            ) else {
                continue;
            };
            let command = command_fact(commands, command_fact_indices_by_id, command_id);
            if !command.wrappers().is_empty()
                || command.effective_or_literal_name() != Some(name.as_str())
            {
                continue;
            }
            let Some(name_word) = command.body_name_word() else {
                continue;
            };
            facts.entry(binding_id).or_default().record_call(
                site.arg_count,
                name_word.span,
                function_call_diagnostic_span(command, name_word.span, source),
            );
        }
    }

    facts
}

pub(crate) fn function_call_diagnostic_span(
    command: &CommandFact<'_>,
    name_span: Span,
    source: &str,
) -> Span {
    if command.redirects().is_empty() {
        return name_span;
    }

    trim_trailing_whitespace_span(command.stmt().span, source)
}

pub(crate) fn function_body_without_braces_span(function: &FunctionDef) -> Option<Span> {
    match &function.body.command {
        Command::Compound(
            CompoundCommand::BraceGroup(_)
            | CompoundCommand::Subshell(_)
            | CompoundCommand::Arithmetic(_),
        ) => None,
        Command::Compound(_) => Some(function.body.span),
        Command::Simple(_)
        | Command::Decl(_)
        | Command::Builtin(_)
        | Command::Binary(_)
        | Command::Function(_)
        | Command::AnonymousFunction(_) => None,
    }
}

pub(crate) fn next_function_body_delimiter(text: &str) -> Option<char> {
    let mut tail = text;

    loop {
        tail = trim_shell_layout_prefix(tail);

        if let Some(rest) = tail.strip_prefix('#') {
            tail = rest.split_once('\n').map_or("", |(_, rest)| rest);
            continue;
        }

        return tail.chars().next();
    }
}

pub(crate) fn trim_shell_layout_prefix(text: &str) -> &str {
    let mut tail = text;

    loop {
        tail = tail.trim_start_matches([' ', '\t', '\r', '\n']);

        if let Some(rest) = tail
            .strip_prefix("\\\r\n")
            .or_else(|| tail.strip_prefix("\\\n"))
        {
            tail = rest;
            continue;
        }

        return tail;
    }
}

pub(crate) fn is_plausible_shell_function_name(name: &str) -> bool {
    let Some(first) = name.chars().next() else {
        return false;
    };
    if !matches!(first, 'a'..='z' | 'A'..='Z' | '_') {
        return false;
    }
    if !name
        .chars()
        .all(|ch| matches!(ch, 'a'..='z' | 'A'..='Z' | '0'..='9' | '_' | '-'))
    {
        return false;
    }
    !matches!(
        name,
        "!" | "{"
            | "}"
            | "if"
            | "then"
            | "else"
            | "elif"
            | "fi"
            | "do"
            | "done"
            | "case"
            | "esac"
            | "for"
            | "in"
            | "while"
            | "until"
            | "time"
            | "[["
            | "]]"
            | "function"
            | "select"
            | "coproc"
    )
}

pub(crate) fn build_function_positional_parameter_facts(
    semantic: &SemanticModel,
    commands: &[CommandFact<'_>],
    positional_parameter_fragments: &[PositionalParameterFragmentFact],
) -> FxHashMap<ScopeId, FunctionPositionalParameterFacts> {
    let local_reset_offsets_by_scope = local_positional_reset_offsets_by_scope(semantic, commands);

    let mut facts = semantic
        .function_positional_reference_summary(&local_reset_offsets_by_scope)
        .into_iter()
        .map(|(scope, summary)| {
            (
                scope,
                FunctionPositionalParameterFacts {
                    required_arg_count: summary.required_arg_count(),
                    uses_unprotected_positional_parameters: summary
                        .uses_unprotected_positional_parameters(),
                    resets_positional_parameters: false,
                },
            )
        })
        .collect::<FxHashMap<_, _>>();

    for fragment in positional_parameter_fragments {
        let fragment_offset = fragment.span().start.offset();
        let fragment_scope = semantic.scope_at(fragment_offset);
        if reference_has_local_positional_reset(
            semantic,
            fragment_scope,
            fragment_offset,
            &local_reset_offsets_by_scope,
        ) {
            continue;
        }

        let Some(scope) = semantic.enclosing_function_scope(fragment_scope) else {
            continue;
        };

        let entry = facts.entry(scope).or_default();
        if !fragment.is_guarded() {
            entry.uses_unprotected_positional_parameters = true;
        }
    }

    for command in commands {
        let Some(scope) =
            semantic.enclosing_function_scope_without_transient_boundary(command.scope())
        else {
            continue;
        };

        if command
            .options()
            .set()
            .is_some_and(|set| set.resets_positional_parameters())
        {
            facts.entry(scope).or_default().resets_positional_parameters = true;
        }
    }

    facts
}

fn local_positional_reset_offsets_by_scope(
    semantic: &SemanticModel,
    commands: &[CommandFact<'_>],
) -> FxHashMap<ScopeId, Vec<usize>> {
    let mut local_reset_offsets_by_scope: FxHashMap<ScopeId, Vec<usize>> = FxHashMap::default();

    for command in commands {
        if !command
            .options()
            .set()
            .is_some_and(|set| set.resets_positional_parameters())
        {
            continue;
        }

        let offset = command.span().start.offset();
        if let Some(scope) = semantic.innermost_transient_scope_within_function(command.scope()) {
            local_reset_offsets_by_scope
                .entry(scope)
                .or_default()
                .push(offset);
        }
    }

    local_reset_offsets_by_scope
}

pub(crate) fn reference_has_local_positional_reset(
    semantic: &SemanticModel,
    scope: ScopeId,
    offset: usize,
    local_reset_offsets_by_scope: &FxHashMap<ScopeId, Vec<usize>>,
) -> bool {
    semantic
        .transient_ancestor_scopes_within_function(scope)
        .any(|transient_scope| {
            local_reset_offsets_by_scope
                .get(&transient_scope)
                .is_some_and(|offsets| offsets.iter().any(|reset_offset| *reset_offset < offset))
        })
}
