//! PHP type and target evidence extraction for graph edge construction.
//!
//! Collects type and call-target evidence from PHP sources for heuristic graph edges when
//! precise SCIP coverage is absent.

use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;

use tree_sitter::Node;

use crate::domain::{FriggError, FriggResult};
use crate::graph::RelationKind;
use crate::indexer::{SourceSpan, SymbolDefinition, SymbolKind, source_span};

use super::super::registry::{SymbolLanguage, parser_for_language};
use super::resolution::PhpNameResolutionContext;
use super::symbol_from_node;

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum PhpTypeEvidenceKind {
    Parameter,
    Return,
    Property,
    PromotedProperty,
    Catch,
}

#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) struct PhpTypeEvidence {
    pub(crate) owner_symbol_id: Option<String>,
    pub(crate) kind: PhpTypeEvidenceKind,
    pub(crate) target_canonical_name: String,
    pub(crate) line: usize,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum PhpTargetEvidenceKind {
    Attribute,
    ClassString,
    Instantiation,
    CallableLiteral,
}

#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) struct PhpTargetEvidence {
    pub(crate) owner_symbol_id: Option<String>,
    pub(crate) kind: PhpTargetEvidenceKind,
    pub(crate) target_canonical_name: String,
    pub(crate) target_member_name: Option<String>,
    pub(crate) line: usize,
}

#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) struct PhpLiteralEvidence {
    pub(crate) owner_symbol_id: Option<String>,
    pub(crate) array_keys: Vec<String>,
    pub(crate) named_arguments: Vec<String>,
    pub(crate) line: usize,
}

/// Aggregated PHP type, target, and literal evidence extracted from one parsed source file.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct PhpSourceEvidence {
    pub(crate) canonical_names_by_stable_id: BTreeMap<String, String>,
    pub(crate) type_evidence: Vec<PhpTypeEvidence>,
    pub(crate) target_evidence: Vec<PhpTargetEvidence>,
    pub(crate) literal_evidence: Vec<PhpLiteralEvidence>,
}

/// Extracts PHP graph evidence from parsed source and the file's symbol inventory.
pub(crate) fn extract_source_evidence_from_source(
    path: &Path,
    source: &str,
    file_symbols: &[SymbolDefinition],
) -> FriggResult<PhpSourceEvidence> {
    let mut parser = parser_for_language(SymbolLanguage::Php)?;
    let tree = parser.parse(source, None).ok_or_else(|| {
        FriggError::Internal(format!(
            "failed to parse source for php evidence extraction: {}",
            path.display()
        ))
    })?;
    let context = super::php_name_resolution_context_from_root(source, tree.root_node());
    let mut evidence = PhpSourceEvidence::default();
    collect_source_evidence(
        source,
        tree.root_node(),
        file_symbols,
        &context,
        context.namespace.as_deref(),
        None,
        None,
        &mut evidence,
    );
    normalize_source_evidence(&mut evidence);
    Ok(evidence)
}

/// Resolves PHP target-evidence records into concrete graph relation edges.
pub(crate) fn resolve_target_evidence_edges(
    symbols: &[SymbolDefinition],
    symbol_index_by_stable_id: &BTreeMap<String, usize>,
    symbol_indices_by_canonical_name: &BTreeMap<String, Vec<usize>>,
    symbol_indices_by_lower_canonical_name: &BTreeMap<String, Vec<usize>>,
    evidence: &PhpSourceEvidence,
) -> Vec<(usize, usize, RelationKind)> {
    let mut edges = Vec::new();
    for target in &evidence.target_evidence {
        let Some(source_symbol_id) = target.owner_symbol_id.as_ref() else {
            continue;
        };
        let Some(source_symbol_index) = symbol_index_by_stable_id.get(source_symbol_id).copied()
        else {
            continue;
        };
        let Some(target_symbol_index) = resolve_target_symbol_index(
            symbols,
            symbol_indices_by_canonical_name,
            symbol_indices_by_lower_canonical_name,
            target,
        ) else {
            continue;
        };
        if source_symbol_index == target_symbol_index {
            continue;
        }
        edges.push((
            source_symbol_index,
            target_symbol_index,
            RelationKind::RefersTo,
        ));
    }
    edges.sort_by(|left, right| {
        left.0
            .cmp(&right.0)
            .then(left.1.cmp(&right.1))
            .then(left.2.cmp(&right.2))
    });
    edges.dedup();
    edges
}

#[allow(clippy::too_many_arguments)]
fn collect_source_evidence(
    source: &str,
    node: Node<'_>,
    file_symbols: &[SymbolDefinition],
    context: &PhpNameResolutionContext,
    current_namespace: Option<&str>,
    current_class_canonical_name: Option<&str>,
    current_owner_symbol_id: Option<&str>,
    evidence: &mut PhpSourceEvidence,
) {
    let mut next_namespace = current_namespace.map(ToOwned::to_owned);
    let mut next_class_canonical_name = current_class_canonical_name.map(ToOwned::to_owned);
    let mut next_owner_symbol_id = current_owner_symbol_id.map(ToOwned::to_owned);
    let mut namespace_local_context: Option<PhpNameResolutionContext> = None;

    match node.kind() {
        "namespace_definition" => {
            if node.child_by_field_name("body").is_some() {
                namespace_local_context = Some(
                    super::php_name_resolution_context_for_namespace_node(source, node),
                );
            }
            if let Some(namespace_name) = node
                .child_by_field_name("name")
                .and_then(|field| field.utf8_text(source.as_bytes()).ok())
                .map(str::trim)
                .filter(|name| !name.is_empty())
            {
                next_namespace = Some(namespace_name.to_owned());
                if let Some(symbol) =
                    find_symbol_for_node(file_symbols, SymbolKind::Module, namespace_name, node)
                {
                    evidence
                        .canonical_names_by_stable_id
                        .entry(symbol.stable_id.clone())
                        .or_insert_with(|| namespace_name.to_owned());
                    next_owner_symbol_id = Some(symbol.stable_id.clone());
                }
            }
        }
        "class_declaration"
        | "interface_declaration"
        | "trait_declaration"
        | "enum_declaration" => {
            if let Some((kind, name)) = symbol_from_node(source, node) {
                let canonical_name = namespace_qualified_name(next_namespace.as_deref(), &name);
                if let Some(symbol) = find_symbol_for_node(file_symbols, kind, &name, node) {
                    evidence
                        .canonical_names_by_stable_id
                        .entry(symbol.stable_id.clone())
                        .or_insert_with(|| canonical_name.clone());
                    next_owner_symbol_id = Some(symbol.stable_id.clone());
                }
                next_class_canonical_name = Some(canonical_name);
            }
        }
        "anonymous_class" => {
            next_class_canonical_name = None;
            next_owner_symbol_id = None;
        }
        "function_definition" => {
            if let Some((kind, name)) = symbol_from_node(source, node) {
                let canonical_name = namespace_qualified_name(next_namespace.as_deref(), &name);
                if let Some(symbol) = find_symbol_for_node(file_symbols, kind, &name, node) {
                    evidence
                        .canonical_names_by_stable_id
                        .entry(symbol.stable_id.clone())
                        .or_insert_with(|| canonical_name.clone());
                    next_owner_symbol_id = Some(symbol.stable_id.clone());
                    if let Some(parameters) = node.child_by_field_name("parameters") {
                        collect_parameter_type_evidence(
                            source,
                            parameters,
                            file_symbols,
                            context,
                            next_class_canonical_name.as_deref(),
                            Some(symbol.stable_id.as_str()),
                            evidence,
                        );
                    }
                    if let Some(return_type) = node.child_by_field_name("return_type") {
                        collect_type_evidence(
                            source,
                            return_type,
                            context,
                            next_class_canonical_name.as_deref(),
                            Some(symbol.stable_id.as_str()),
                            PhpTypeEvidenceKind::Return,
                            source_span(node).start_line,
                            &mut evidence.type_evidence,
                        );
                    }
                }
            }
        }
        "method_declaration" => {
            if let Some((kind, name)) = symbol_from_node(source, node)
                && let Some(class_name) = next_class_canonical_name.as_deref()
            {
                let canonical_name = format!("{class_name}::{name}");
                if let Some(symbol) = find_symbol_for_node(file_symbols, kind, &name, node) {
                    evidence
                        .canonical_names_by_stable_id
                        .entry(symbol.stable_id.clone())
                        .or_insert_with(|| canonical_name.clone());
                    next_owner_symbol_id = Some(symbol.stable_id.clone());
                    if let Some(parameters) = node.child_by_field_name("parameters") {
                        collect_parameter_type_evidence(
                            source,
                            parameters,
                            file_symbols,
                            context,
                            next_class_canonical_name.as_deref(),
                            Some(symbol.stable_id.as_str()),
                            evidence,
                        );
                    }
                    if let Some(return_type) = node.child_by_field_name("return_type") {
                        collect_type_evidence(
                            source,
                            return_type,
                            context,
                            next_class_canonical_name.as_deref(),
                            Some(symbol.stable_id.as_str()),
                            PhpTypeEvidenceKind::Return,
                            source_span(node).start_line,
                            &mut evidence.type_evidence,
                        );
                    }
                }
            }
        }
        "property_declaration" => {
            if let Some(type_node) = node.child_by_field_name("type") {
                let mut cursor = node.walk();
                for child in node.children(&mut cursor).filter(|child| child.is_named()) {
                    if child.kind() != "property_element" {
                        continue;
                    }
                    let Some((kind, name)) = symbol_from_node(source, child) else {
                        continue;
                    };
                    let owner_symbol_id = find_symbol_for_node(file_symbols, kind, &name, child)
                        .map(|symbol| {
                            if let Some(class_name) = next_class_canonical_name.as_deref() {
                                evidence
                                    .canonical_names_by_stable_id
                                    .entry(symbol.stable_id.clone())
                                    .or_insert_with(|| format!("{class_name}::{name}"));
                            }
                            symbol.stable_id.as_str()
                        });
                    collect_type_evidence(
                        source,
                        type_node,
                        context,
                        next_class_canonical_name.as_deref(),
                        owner_symbol_id,
                        PhpTypeEvidenceKind::Property,
                        source_span(child).start_line,
                        &mut evidence.type_evidence,
                    );
                }
            }
        }
        "property_element" => {
            if let Some((kind, name)) = symbol_from_node(source, node)
                && let Some(symbol) = find_symbol_for_node(file_symbols, kind, &name, node)
                && let Some(class_name) = next_class_canonical_name.as_deref()
            {
                evidence
                    .canonical_names_by_stable_id
                    .entry(symbol.stable_id.clone())
                    .or_insert_with(|| format!("{class_name}::{name}"));
            }
        }
        "const_element" => {
            if let Some((kind, name)) = symbol_from_node(source, node)
                && let Some(symbol) = find_symbol_for_node(file_symbols, kind, &name, node)
            {
                let canonical_name = if let Some(class_name) = next_class_canonical_name.as_deref()
                {
                    format!("{class_name}::{name}")
                } else {
                    namespace_qualified_name(next_namespace.as_deref(), &name)
                };
                evidence
                    .canonical_names_by_stable_id
                    .entry(symbol.stable_id.clone())
                    .or_insert(canonical_name);
            }
        }
        "enum_case" => {
            if let Some((kind, name)) = symbol_from_node(source, node)
                && let Some(symbol) = find_symbol_for_node(file_symbols, kind, &name, node)
                && let Some(class_name) = next_class_canonical_name.as_deref()
            {
                evidence
                    .canonical_names_by_stable_id
                    .entry(symbol.stable_id.clone())
                    .or_insert_with(|| format!("{class_name}::{name}"));
            }
        }
        "catch_clause" => {
            if let Some(type_node) = node.child_by_field_name("type") {
                collect_type_evidence(
                    source,
                    type_node,
                    context,
                    next_class_canonical_name.as_deref(),
                    next_owner_symbol_id.as_deref().or_else(|| {
                        find_innermost_symbol_for_span_in_file(
                            file_symbols,
                            SymbolLanguage::Php,
                            &source_span(node),
                        )
                        .map(|symbol| symbol.stable_id.as_str())
                    }),
                    PhpTypeEvidenceKind::Catch,
                    source_span(node).start_line,
                    &mut evidence.type_evidence,
                );
            }
        }
        "attribute" => {
            if let Some(target_name) = attribute_target_name(source, node).and_then(|raw_name| {
                context.resolve_class_like_name(
                    raw_name.as_str(),
                    next_class_canonical_name.as_deref(),
                )
            }) {
                evidence.target_evidence.push(PhpTargetEvidence {
                    owner_symbol_id: next_owner_symbol_id.clone().or_else(|| {
                        find_innermost_symbol_for_span_in_file(
                            file_symbols,
                            SymbolLanguage::Php,
                            &source_span(node),
                        )
                        .map(|symbol| symbol.stable_id.clone())
                    }),
                    kind: PhpTargetEvidenceKind::Attribute,
                    target_canonical_name: target_name,
                    target_member_name: None,
                    line: source_span(node).start_line,
                });
            }
        }
        "class_constant_access_expression" => {
            if let Some(target_name) = class_string_target_name(
                source,
                node,
                context,
                next_class_canonical_name.as_deref(),
            ) {
                evidence.target_evidence.push(PhpTargetEvidence {
                    owner_symbol_id: next_owner_symbol_id.clone().or_else(|| {
                        find_innermost_symbol_for_span_in_file(
                            file_symbols,
                            SymbolLanguage::Php,
                            &source_span(node),
                        )
                        .map(|symbol| symbol.stable_id.clone())
                    }),
                    kind: PhpTargetEvidenceKind::ClassString,
                    target_canonical_name: target_name,
                    target_member_name: None,
                    line: source_span(node).start_line,
                });
            }
        }
        "object_creation_expression" => {
            if let Some(target_name) = instantiation_target_name(
                source,
                node,
                context,
                next_class_canonical_name.as_deref(),
            ) {
                evidence.target_evidence.push(PhpTargetEvidence {
                    owner_symbol_id: next_owner_symbol_id.clone().or_else(|| {
                        find_innermost_symbol_for_span_in_file(
                            file_symbols,
                            SymbolLanguage::Php,
                            &source_span(node),
                        )
                        .map(|symbol| symbol.stable_id.clone())
                    }),
                    kind: PhpTargetEvidenceKind::Instantiation,
                    target_canonical_name: target_name,
                    target_member_name: None,
                    line: source_span(node).start_line,
                });
            }
        }
        "array_creation_expression" => {
            if let Some((target_canonical_name, target_member_name)) =
                callable_literal_target(source, node, context, next_class_canonical_name.as_deref())
            {
                evidence.target_evidence.push(PhpTargetEvidence {
                    owner_symbol_id: next_owner_symbol_id.clone().or_else(|| {
                        find_innermost_symbol_for_span_in_file(
                            file_symbols,
                            SymbolLanguage::Php,
                            &source_span(node),
                        )
                        .map(|symbol| symbol.stable_id.clone())
                    }),
                    kind: PhpTargetEvidenceKind::CallableLiteral,
                    target_canonical_name,
                    target_member_name: Some(target_member_name),
                    line: source_span(node).start_line,
                });
            }
            if let Some(array_keys) = literal_array_keys(source, node) {
                evidence.literal_evidence.push(PhpLiteralEvidence {
                    owner_symbol_id: next_owner_symbol_id.clone().or_else(|| {
                        find_innermost_symbol_for_span_in_file(
                            file_symbols,
                            SymbolLanguage::Php,
                            &source_span(node),
                        )
                        .map(|symbol| symbol.stable_id.clone())
                    }),
                    array_keys,
                    named_arguments: Vec::new(),
                    line: source_span(node).start_line,
                });
            }
        }
        "arguments" => {
            if let Some(named_arguments) = named_argument_keys(source, node) {
                evidence.literal_evidence.push(PhpLiteralEvidence {
                    owner_symbol_id: next_owner_symbol_id.clone().or_else(|| {
                        find_innermost_symbol_for_span_in_file(
                            file_symbols,
                            SymbolLanguage::Php,
                            &source_span(node),
                        )
                        .map(|symbol| symbol.stable_id.clone())
                    }),
                    array_keys: Vec::new(),
                    named_arguments,
                    line: source_span(node).start_line,
                });
            }
        }
        _ => {}
    }

    let effective_context = namespace_local_context.as_ref().unwrap_or(context);
    let mut cursor = node.walk();
    for child in node.children(&mut cursor) {
        collect_source_evidence(
            source,
            child,
            file_symbols,
            effective_context,
            next_namespace.as_deref(),
            next_class_canonical_name.as_deref(),
            next_owner_symbol_id.as_deref(),
            evidence,
        );
    }
}

fn collect_parameter_type_evidence(
    source: &str,
    parameters: Node<'_>,
    file_symbols: &[SymbolDefinition],
    context: &PhpNameResolutionContext,
    current_class_canonical_name: Option<&str>,
    owner_symbol_id: Option<&str>,
    evidence: &mut PhpSourceEvidence,
) {
    let mut cursor = parameters.walk();
    for parameter in parameters
        .children(&mut cursor)
        .filter(|child| child.is_named())
    {
        let (kind, line) = match parameter.kind() {
            "simple_parameter" => (
                PhpTypeEvidenceKind::Parameter,
                source_span(parameter).start_line,
            ),
            "property_promotion_parameter" => (
                PhpTypeEvidenceKind::PromotedProperty,
                source_span(parameter).start_line,
            ),
            _ => continue,
        };
        if let Some(type_node) = parameter.child_by_field_name("type") {
            collect_type_evidence(
                source,
                type_node,
                context,
                current_class_canonical_name,
                owner_symbol_id,
                kind,
                line,
                &mut evidence.type_evidence,
            );
        }
        if let Some(attributes) = parameter.child_by_field_name("attributes") {
            let mut attr_cursor = attributes.walk();
            for attribute_group in attributes
                .children(&mut attr_cursor)
                .filter(|child| child.is_named())
            {
                let mut group_cursor = attribute_group.walk();
                for attribute in attribute_group
                    .children(&mut group_cursor)
                    .filter(|child| child.is_named() && child.kind() == "attribute")
                {
                    if let Some(target_name) =
                        attribute_target_name(source, attribute).and_then(|raw_name| {
                            context.resolve_class_like_name(
                                raw_name.as_str(),
                                current_class_canonical_name,
                            )
                        })
                    {
                        evidence.target_evidence.push(PhpTargetEvidence {
                            owner_symbol_id: owner_symbol_id.map(ToOwned::to_owned).or_else(|| {
                                find_innermost_symbol_for_span_in_file(
                                    file_symbols,
                                    SymbolLanguage::Php,
                                    &source_span(attribute),
                                )
                                .map(|symbol| symbol.stable_id.clone())
                            }),
                            kind: PhpTargetEvidenceKind::Attribute,
                            target_canonical_name: target_name,
                            target_member_name: None,
                            line: source_span(attribute).start_line,
                        });
                    }
                }
            }
        }
    }
}

#[allow(clippy::too_many_arguments)]
fn collect_type_evidence(
    source: &str,
    type_node: Node<'_>,
    context: &PhpNameResolutionContext,
    current_class_canonical_name: Option<&str>,
    owner_symbol_id: Option<&str>,
    kind: PhpTypeEvidenceKind,
    line: usize,
    output: &mut Vec<PhpTypeEvidence>,
) {
    let mut targets = BTreeSet::new();
    collect_type_targets(
        source,
        type_node,
        context,
        current_class_canonical_name,
        &mut targets,
    );
    for target_canonical_name in targets {
        output.push(PhpTypeEvidence {
            owner_symbol_id: owner_symbol_id.map(ToOwned::to_owned),
            kind,
            target_canonical_name,
            line,
        });
    }
}

fn collect_type_targets(
    source: &str,
    node: Node<'_>,
    context: &PhpNameResolutionContext,
    current_class_canonical_name: Option<&str>,
    targets: &mut BTreeSet<String>,
) {
    match node.kind() {
        "named_type" | "name" | "qualified_name" | "relative_name" => {
            if let Ok(raw_name) = node.utf8_text(source.as_bytes())
                && let Some(target) =
                    context.resolve_class_like_name(raw_name, current_class_canonical_name)
            {
                targets.insert(target);
            }
        }
        _ => {
            let mut cursor = node.walk();
            for child in node.children(&mut cursor).filter(|child| child.is_named()) {
                collect_type_targets(
                    source,
                    child,
                    context,
                    current_class_canonical_name,
                    targets,
                );
            }
        }
    }
}

fn find_symbol_for_node<'a>(
    file_symbols: &'a [SymbolDefinition],
    kind: SymbolKind,
    name: &str,
    node: Node<'_>,
) -> Option<&'a SymbolDefinition> {
    let span = source_span(node);
    file_symbols.iter().find(|symbol| {
        symbol.kind == kind
            && symbol.name == name
            && symbol.span.start_byte == span.start_byte
            && symbol.span.end_byte == span.end_byte
    })
}

fn find_innermost_symbol_for_span_in_file<'a>(
    file_symbols: &'a [SymbolDefinition],
    language: SymbolLanguage,
    span: &SourceSpan,
) -> Option<&'a SymbolDefinition> {
    file_symbols
        .iter()
        .filter(|symbol| {
            symbol.language == language
                && span.start_byte >= symbol.span.start_byte
                && span.end_byte <= symbol.span.end_byte
        })
        .min_by(|left, right| {
            let left_width = left.span.end_byte.saturating_sub(left.span.start_byte);
            let right_width = right.span.end_byte.saturating_sub(right.span.start_byte);
            left_width
                .cmp(&right_width)
                .then(left.span.start_byte.cmp(&right.span.start_byte))
                .then(left.stable_id.cmp(&right.stable_id))
        })
}

fn namespace_qualified_name(namespace: Option<&str>, short_name: &str) -> String {
    let short_name = short_name.trim();
    if short_name.is_empty() {
        return String::new();
    }
    match namespace
        .map(str::trim)
        .filter(|namespace| !namespace.is_empty())
    {
        Some(namespace) => format!("{namespace}\\{short_name}"),
        None => short_name.to_owned(),
    }
}

fn attribute_target_name(source: &str, node: Node<'_>) -> Option<String> {
    let mut cursor = node.walk();
    node.children(&mut cursor)
        .filter(|child| child.is_named())
        .find(|child| matches!(child.kind(), "name" | "qualified_name" | "relative_name"))
        .and_then(|child| child.utf8_text(source.as_bytes()).ok())
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned)
}

fn class_string_target_name(
    source: &str,
    node: Node<'_>,
    context: &PhpNameResolutionContext,
    current_class_canonical_name: Option<&str>,
) -> Option<String> {
    let named_children = named_children(node);
    if named_children.len() != 2 {
        return None;
    }
    let name = named_children[1]
        .utf8_text(source.as_bytes())
        .ok()
        .map(str::trim)?;
    if !name.eq_ignore_ascii_case("class") {
        return None;
    }
    let raw_scope = named_children[0].utf8_text(source.as_bytes()).ok()?.trim();
    context.resolve_class_like_name(raw_scope, current_class_canonical_name)
}

fn instantiation_target_name(
    source: &str,
    node: Node<'_>,
    context: &PhpNameResolutionContext,
    current_class_canonical_name: Option<&str>,
) -> Option<String> {
    let named_children = named_children(node);
    let first = named_children.first()?;
    if first.kind() == "anonymous_class" {
        return None;
    }
    if !matches!(first.kind(), "name" | "qualified_name" | "relative_name") {
        return None;
    }
    let raw_name = first.utf8_text(source.as_bytes()).ok()?.trim();
    context.resolve_class_like_name(raw_name, current_class_canonical_name)
}

fn callable_literal_target(
    source: &str,
    node: Node<'_>,
    context: &PhpNameResolutionContext,
    current_class_canonical_name: Option<&str>,
) -> Option<(String, String)> {
    let initializers = named_children(node)
        .into_iter()
        .filter(|child| child.kind() == "array_element_initializer")
        .collect::<Vec<_>>();
    if initializers.len() != 2 {
        return None;
    }
    let first = named_children(initializers[0]).into_iter().next()?;
    let second = named_children(initializers[1]).into_iter().next()?;
    let target_name =
        class_string_target_name(source, first, context, current_class_canonical_name)?;
    let target_member_name = string_literal_value(source, second)?;
    Some((target_name, target_member_name))
}

fn string_literal_value(source: &str, node: Node<'_>) -> Option<String> {
    let text = node.utf8_text(source.as_bytes()).ok()?.trim();
    let unquoted = text
        .strip_prefix('\'')
        .and_then(|value| value.strip_suffix('\''))
        .or_else(|| {
            text.strip_prefix('"')
                .and_then(|value| value.strip_suffix('"'))
        })
        .unwrap_or(text)
        .trim();
    (!unquoted.is_empty()).then(|| unquoted.to_owned())
}

fn literal_array_keys(source: &str, node: Node<'_>) -> Option<Vec<String>> {
    let mut keys = BTreeSet::new();
    for initializer in named_children(node)
        .into_iter()
        .filter(|child| child.kind() == "array_element_initializer")
    {
        let children = named_children(initializer);
        if children.len() < 2 {
            continue;
        }
        if let Some(key) = literal_key_text(source, children[0]) {
            keys.insert(key);
        }
    }
    (!keys.is_empty()).then(|| keys.into_iter().collect())
}

fn literal_key_text(source: &str, node: Node<'_>) -> Option<String> {
    let text = node.utf8_text(source.as_bytes()).ok()?.trim();
    if text.is_empty() {
        return None;
    }
    if let Some(unquoted) = text
        .strip_prefix('\'')
        .and_then(|value| value.strip_suffix('\''))
        .or_else(|| {
            text.strip_prefix('"')
                .and_then(|value| value.strip_suffix('"'))
        })
    {
        let normalized = unquoted.trim();
        return (!normalized.is_empty()).then(|| normalized.to_owned());
    }
    text.chars()
        .all(|value| value.is_ascii_digit() || value == '-' || value == '+')
        .then(|| text.to_owned())
}

fn named_argument_keys(source: &str, node: Node<'_>) -> Option<Vec<String>> {
    let mut keys = BTreeSet::new();
    for argument in named_children(node)
        .into_iter()
        .filter(|child| child.kind() == "argument")
    {
        let Some(name) = argument
            .child_by_field_name("name")
            .and_then(|field| field.utf8_text(source.as_bytes()).ok())
            .map(str::trim)
            .filter(|value| !value.is_empty())
        else {
            continue;
        };
        keys.insert(name.to_owned());
    }
    (!keys.is_empty()).then(|| keys.into_iter().collect())
}

fn resolve_target_symbol_index(
    symbols: &[SymbolDefinition],
    symbol_indices_by_canonical_name: &BTreeMap<String, Vec<usize>>,
    symbol_indices_by_lower_canonical_name: &BTreeMap<String, Vec<usize>>,
    target: &PhpTargetEvidence,
) -> Option<usize> {
    if let Some(member_name) = target.target_member_name.as_deref() {
        let candidate = format!("{}::{member_name}", target.target_canonical_name);
        if let Some(index) = resolve_unique_canonical_symbol_index(
            symbols,
            symbol_indices_by_canonical_name,
            symbol_indices_by_lower_canonical_name,
            &candidate,
            Some(&[
                SymbolKind::Method,
                SymbolKind::Property,
                SymbolKind::Constant,
                SymbolKind::EnumCase,
            ]),
        ) {
            return Some(index);
        }
    }
    resolve_unique_canonical_symbol_index(
        symbols,
        symbol_indices_by_canonical_name,
        symbol_indices_by_lower_canonical_name,
        &target.target_canonical_name,
        Some(&[
            SymbolKind::Class,
            SymbolKind::Interface,
            SymbolKind::PhpTrait,
            SymbolKind::PhpEnum,
        ]),
    )
}

fn resolve_unique_canonical_symbol_index(
    symbols: &[SymbolDefinition],
    symbol_indices_by_canonical_name: &BTreeMap<String, Vec<usize>>,
    symbol_indices_by_lower_canonical_name: &BTreeMap<String, Vec<usize>>,
    target_name: &str,
    allowed_kinds: Option<&[SymbolKind]>,
) -> Option<usize> {
    if let Some(indices) = symbol_indices_by_canonical_name.get(target_name) {
        let matches = indices
            .iter()
            .copied()
            .filter(|index| {
                allowed_kinds.is_none_or(|allowed| allowed.contains(&symbols[*index].kind))
            })
            .collect::<Vec<_>>();
        if matches.len() == 1 {
            return matches.first().copied();
        }
        if !matches.is_empty() {
            return None;
        }
    }
    let lower = target_name.to_ascii_lowercase();
    let matches = symbol_indices_by_lower_canonical_name
        .get(&lower)
        .into_iter()
        .flatten()
        .copied()
        .filter(|index| allowed_kinds.is_none_or(|allowed| allowed.contains(&symbols[*index].kind)))
        .collect::<Vec<_>>();
    if matches.len() == 1 {
        matches.first().copied()
    } else {
        None
    }
}

fn normalize_source_evidence(evidence: &mut PhpSourceEvidence) {
    evidence.type_evidence.sort();
    evidence.type_evidence.dedup();
    evidence.target_evidence.sort();
    evidence.target_evidence.dedup();
    evidence.literal_evidence.sort();
    evidence.literal_evidence.dedup();
}

fn named_children(node: Node<'_>) -> Vec<Node<'_>> {
    let mut cursor = node.walk();
    node.children(&mut cursor)
        .filter(|child| child.is_named())
        .collect()
}
