"""Device fingerprint catalog loading and evidence-rich matching.

Parsing rules for built-in and custom fingerprints are intentionally backward compatible:

- Legacy entries may continue to provide only:
  `name`, `vendor`, `model`, `services`, `products`, `metadata_tokens`,
  `endpoint_patterns`, `protocol_hints`, `confidence`, and `priority`.
- Newer entries may also provide:
  `aliases`, `family`, `class_context`, `optional_indicators`,
  `conflict_indicators`, and `evidence_groups`.
- Unknown fields are ignored by the loader so older override files remain valid.
- Category is inherited from the built-in catalog file when omitted, or falls back
  to the override source context for custom entries.
"""

from __future__ import annotations

import json
from dataclasses import dataclass
from importlib import resources
from pathlib import Path
from typing import Literal

from sentra.models import DeviceEvidence, RawEvidence
from sentra.state import fingerprint_override_path, load_json, normalize_path, user_fingerprint_override_path

FingerprintSource = Literal["builtin", "workspace", "user"]

_SOURCE_PRIORITY: dict[FingerprintSource, int] = {"builtin": 0, "workspace": 1, "user": 2}


@dataclass(frozen=True)
class WeightedEvidenceGroup:
    name: str
    weight: float
    optional: bool
    services: tuple[str, ...] = ()
    products: tuple[str, ...] = ()
    metadata_tokens: tuple[str, ...] = ()
    endpoint_patterns: tuple[str, ...] = ()
    protocol_hints: tuple[str, ...] = ()
    aliases: tuple[str, ...] = ()


@dataclass(frozen=True)
class FingerprintEntry:
    name: str
    vendor: str
    model: str
    category: str
    family: str
    class_context: str
    services: tuple[str, ...]
    products: tuple[str, ...]
    metadata_tokens: tuple[str, ...]
    endpoint_patterns: tuple[str, ...]
    protocol_hints: tuple[str, ...]
    aliases: tuple[str, ...]
    optional_indicators: tuple[str, ...]
    conflict_indicators: tuple[str, ...]
    evidence_groups: tuple[WeightedEvidenceGroup, ...]
    confidence: float
    source: FingerprintSource = "builtin"
    priority: int = 0


@dataclass(frozen=True)
class FingerprintDecision:
    entry: FingerprintEntry
    confidence: float
    matched_services: tuple[str, ...]
    matched_products: tuple[str, ...]
    matched_metadata: tuple[str, ...]
    matched_endpoints: tuple[str, ...]
    matched_protocols: tuple[str, ...]
    matched_aliases: tuple[str, ...]
    matched_optional_indicators: tuple[str, ...]
    matched_group_names: tuple[str, ...]
    conflict_penalty: float
    penalties: tuple[str, ...]
    score_breakdown: dict[str, float]
    explanation: str
    evidence: tuple[RawEvidence, ...]
    specificity: int


def apply_device_fingerprint(
    device: DeviceEvidence,
    workspace: Path | None = None,
    *,
    user_override_paths: list[Path] | None = None,
) -> DeviceEvidence:
    decisions = rank_fingerprint_candidates(device, workspace, user_override_paths=user_override_paths)
    if not decisions:
        return device

    best = decisions[0]
    updated_protocols = sorted(set(device.protocols).union(best.entry.protocol_hints))
    fingerprint_evidence = list(device.fingerprint_evidence)
    for decision in decisions:
        fingerprint_evidence.extend(decision.evidence)
        fingerprint_evidence.append(
            RawEvidence(
                source=decision.entry.name,
                kind="fingerprint-candidate-summary",
                value=decision.entry.name,
                confidence=decision.confidence,
                attributes={
                    "rank": decisions.index(decision) + 1,
                    "category": decision.entry.category,
                    "family": decision.entry.family,
                    "source": decision.entry.source,
                    "specificity": decision.specificity,
                    "explanation": decision.explanation,
                    "score_breakdown": decision.score_breakdown,
                    "penalties": list(decision.penalties),
                },
            )
        )
    fingerprint_evidence.sort(key=lambda item: (item.source, item.kind, item.value))
    return device.model_copy(
        update={
            "vendor": device.vendor or best.entry.vendor,
            "model": device.model or best.entry.model,
            "protocols": updated_protocols,
            "fingerprint_confidence": max(device.fingerprint_confidence, best.confidence),
            "fingerprint_matches": [decision.entry.name for decision in decisions],
            "fingerprint_evidence": fingerprint_evidence,
        }
    )


def rank_fingerprint_candidates(
    device: DeviceEvidence,
    workspace: Path | None = None,
    *,
    user_override_paths: list[Path] | None = None,
) -> list[FingerprintDecision]:
    entries = load_fingerprint_entries(workspace, user_override_paths=user_override_paths)
    return _match_entries(device, entries)


def load_fingerprint_entries(
    workspace: Path | None = None,
    *,
    user_override_paths: list[Path] | None = None,
) -> list[FingerprintEntry]:
    entries_by_name: dict[str, FingerprintEntry] = {}
    for source, payload, priority, category in _iter_raw_fingerprint_sources(workspace, user_override_paths=user_override_paths):
        entry = _parse_entry(payload, source=source, priority=priority, category=category)
        current = entries_by_name.get(entry.name)
        if current is None or (entry.priority, _SOURCE_PRIORITY[entry.source], entry.confidence) >= (
            current.priority,
            _SOURCE_PRIORITY[current.source],
            current.confidence,
        ):
            entries_by_name[entry.name] = entry
    return sorted(
        entries_by_name.values(),
        key=lambda item: (-item.priority, -item.confidence, -_specificity_score(item), item.category, item.name),
    )


def _iter_raw_fingerprint_sources(
    workspace: Path | None,
    *,
    user_override_paths: list[Path] | None,
) -> list[tuple[FingerprintSource, dict[str, object], int, str]]:
    items: list[tuple[FingerprintSource, dict[str, object], int, str]] = []
    for category, payload in _load_defaults():
        items.append(("builtin", payload, 0, category))

    if workspace is not None:
        for path, source, priority in (
            (fingerprint_override_path(workspace), "workspace", 10),
            (user_fingerprint_override_path(workspace), "user", 20),
        ):
            if path.exists():
                payload = load_json(path)
                if isinstance(payload, list):
                    items.extend((source, item, priority, f"{source}-override") for item in payload if isinstance(item, dict))

    for index, path in enumerate(user_override_paths or []):
        resolved = normalize_path(path, field_name="fingerprint override")
        if not resolved.exists():
            raise FileNotFoundError(f"Fingerprint override file not found: {resolved}")
        payload = load_json(resolved)
        if isinstance(payload, list):
            items.extend(("user", item, 30 + index, "user-override") for item in payload if isinstance(item, dict))
    return items


def _load_defaults() -> list[tuple[str, dict[str, object]]]:
    manifest_data = resources.files("sentra.fingerprints").joinpath("defaults.json").read_text(encoding="utf-8")
    payload = json.loads(manifest_data)
    if isinstance(payload, list):
        return [("legacy-defaults", item) for item in payload if isinstance(item, dict)]
    if not isinstance(payload, dict):
        return []

    categories = payload.get("categories", [])
    items: list[tuple[str, dict[str, object]]] = []
    if not isinstance(categories, list):
        return items
    package_root = resources.files("sentra.fingerprints")
    for category_name in categories:
        if not isinstance(category_name, str) or not category_name.strip():
            continue
        resource_name = category_name.strip()
        category_key = normalize_token(resource_name.removesuffix(".json").removeprefix("builtin-"))
        category_data = package_root.joinpath(resource_name).read_text(encoding="utf-8")
        category_payload = json.loads(category_data)
        if isinstance(category_payload, list):
            items.extend((category_key, item) for item in category_payload if isinstance(item, dict))
    return items


def _parse_entry(item: dict[str, object], *, source: FingerprintSource, priority: int, category: str) -> FingerprintEntry:
    aliases = _parse_aliases(item.get("aliases", []))
    evidence_groups = _parse_evidence_groups(item.get("evidence_groups", []))
    return FingerprintEntry(
        name=str(item.get("name", "Unknown Fingerprint")).strip() or "Unknown Fingerprint",
        vendor=str(item.get("vendor", "Unknown")).strip() or "Unknown",
        model=str(item.get("model", "Unknown")).strip() or "Unknown",
        category=normalize_token(str(item.get("category", category)).strip() or category or "general"),
        family=normalize_token(str(item.get("family", item.get("model", ""))).strip() or str(item.get("model", "unknown"))),
        class_context=normalize_token(str(item.get("class_context", item.get("category", category))).strip() or category or "general"),
        services=_parse_token_list(item.get("services", [])),
        products=_parse_token_list(item.get("products", [])),
        metadata_tokens=_parse_token_list(item.get("metadata_tokens", [])),
        endpoint_patterns=_parse_endpoint_patterns(item.get("endpoint_patterns", [])),
        protocol_hints=_parse_token_list(item.get("protocol_hints", [])),
        aliases=aliases,
        optional_indicators=_parse_token_list(item.get("optional_indicators", [])),
        conflict_indicators=_parse_token_list(item.get("conflict_indicators", [])),
        evidence_groups=evidence_groups,
        confidence=round(min(0.99, max(0.1, float(item.get("confidence", 0.5)))), 2),
        source=source,
        priority=int(item.get("priority", priority)),
    )


def _parse_evidence_groups(value: object) -> tuple[WeightedEvidenceGroup, ...]:
    groups: list[WeightedEvidenceGroup] = []
    if not isinstance(value, list):
        return ()
    for index, item in enumerate(value):
        if not isinstance(item, dict):
            continue
        name = str(item.get("name", f"group-{index + 1}")).strip() or f"group-{index + 1}"
        weight = max(0.05, min(0.5, float(item.get("weight", 0.12))))
        groups.append(
            WeightedEvidenceGroup(
                name=name,
                weight=round(weight, 3),
                optional=bool(item.get("optional", False)),
                services=_parse_token_list(item.get("services", [])),
                products=_parse_token_list(item.get("products", [])),
                metadata_tokens=_parse_token_list(item.get("metadata_tokens", [])),
                endpoint_patterns=_parse_endpoint_patterns(item.get("endpoint_patterns", [])),
                protocol_hints=_parse_token_list(item.get("protocol_hints", [])),
                aliases=_parse_aliases(item.get("aliases", [])),
            )
        )
    return tuple(groups)


def _parse_token_list(value: object) -> tuple[str, ...]:
    if not isinstance(value, list):
        return ()
    return tuple(sorted({normalize_token(str(item)) for item in value if str(item).strip()}))


def _parse_endpoint_patterns(value: object) -> tuple[str, ...]:
    if not isinstance(value, list):
        return ()
    return tuple(sorted({str(item).strip().lower() for item in value if str(item).strip()}))


def _parse_aliases(value: object) -> tuple[str, ...]:
    if not isinstance(value, list):
        return ()
    return tuple(sorted({normalize_token(str(item)) for item in value if str(item).strip()}))


def _match_entries(device: DeviceEvidence, entries: list[FingerprintEntry]) -> list[FingerprintDecision]:
    observed = _collect_observed_signals(device)
    decisions: list[FingerprintDecision] = []

    for entry in entries:
        matched_services = tuple(sorted(set(entry.services).intersection(observed["services"])))
        matched_products = tuple(sorted(set(entry.products).intersection(observed["products"])))
        matched_metadata = tuple(sorted(set(entry.metadata_tokens).intersection(observed["metadata_tokens"])))
        matched_endpoints = tuple(
            sorted(pattern for pattern in entry.endpoint_patterns if any(pattern in endpoint for endpoint in observed["endpoints"]))
        )
        matched_protocols = tuple(sorted(set(entry.protocol_hints).intersection(observed["protocols"])))
        matched_aliases = tuple(sorted(alias for alias in entry.aliases if _alias_matches(alias, observed)))
        matched_optional = tuple(
            sorted(indicator for indicator in entry.optional_indicators if indicator in observed["products"] or indicator in observed["metadata_tokens"])
        )
        matched_groups, group_score = _evaluate_evidence_groups(entry.evidence_groups, observed)
        penalties, conflict_penalty = _evaluate_conflicts(entry, observed)

        if not any(
            (
                matched_services,
                matched_products,
                matched_metadata,
                matched_endpoints,
                matched_protocols,
                matched_aliases,
                matched_optional,
                matched_groups,
            )
        ):
            continue

        service_score = _coverage_score(matched_services, entry.services, 0.16)
        product_score = _coverage_score(matched_products, entry.products, 0.33)
        metadata_score = _coverage_score(matched_metadata, entry.metadata_tokens, 0.08)
        endpoint_score = _coverage_score(matched_endpoints, entry.endpoint_patterns, 0.14)
        protocol_score = _coverage_score(matched_protocols, entry.protocol_hints, 0.07)
        alias_score = _coverage_score(matched_aliases, entry.aliases, 0.16)
        optional_score = _coverage_score(matched_optional, entry.optional_indicators, 0.06)
        combo_score = _combo_bonus(entry, matched_services, matched_products, matched_endpoints, matched_aliases, matched_groups)
        generic_penalty = _generic_penalty(entry, matched_products, matched_aliases, matched_groups)
        specificity = _specificity_score(entry)
        source_bonus = {"builtin": 0.0, "workspace": 0.04, "user": 0.06}[entry.source]
        priority_bonus = min(0.08, entry.priority * 0.003)

        raw_score = (
            entry.confidence * 0.28
            + service_score
            + product_score
            + metadata_score
            + endpoint_score
            + protocol_score
            + alias_score
            + optional_score
            + group_score
            + combo_score
            + source_bonus
            + priority_bonus
            - conflict_penalty
            - generic_penalty
        )
        confidence = round(min(0.99, max(0.15, raw_score)), 2)
        decisions.append(
            FingerprintDecision(
                entry=entry,
                confidence=confidence,
                matched_services=matched_services,
                matched_products=matched_products,
                matched_metadata=matched_metadata,
                matched_endpoints=matched_endpoints,
                matched_protocols=matched_protocols,
                matched_aliases=matched_aliases,
                matched_optional_indicators=matched_optional,
                matched_group_names=matched_groups,
                conflict_penalty=round(conflict_penalty + generic_penalty, 3),
                penalties=tuple(penalties),
                score_breakdown={
                    "service_score": round(service_score, 3),
                    "product_score": round(product_score, 3),
                    "metadata_score": round(metadata_score, 3),
                    "endpoint_score": round(endpoint_score, 3),
                    "protocol_score": round(protocol_score, 3),
                    "alias_score": round(alias_score, 3),
                    "optional_score": round(optional_score, 3),
                    "group_score": round(group_score, 3),
                    "combo_bonus": round(combo_score, 3),
                    "priority_bonus": round(priority_bonus + source_bonus, 3),
                    "conflict_penalty": round(conflict_penalty, 3),
                    "generic_penalty": round(generic_penalty, 3),
                },
                explanation="",
                evidence=(),
                specificity=specificity,
            )
        )

    decisions.sort(
        key=lambda item: (
            -item.confidence,
            -item.specificity,
            -len(item.matched_group_names),
            -len(item.matched_products),
            -len(item.matched_aliases),
            len(item.penalties),
            item.entry.category,
            item.entry.name,
        )
    )
    decisions = _dedupe_conflicting_decisions(decisions)
    return _finalize_explanations(decisions)


def _collect_observed_signals(device: DeviceEvidence) -> dict[str, object]:
    services = {normalize_token(service.service) for service in device.services}
    protocols = {normalize_token(protocol) for protocol in device.protocols}
    products: set[str] = set()
    metadata_tokens: set[str] = set()
    phrases: set[str] = set()
    endpoints = {endpoint.url.lower() for endpoint in device.endpoints}

    for service in device.services:
        for field in (service.service, service.product or "", service.normalized_product or "", service.banner or "", service.version or ""):
            products.update(token for token in _tokenize(field) if token)
            phrase = normalize_token(field)
            if phrase:
                phrases.add(phrase)
        metadata_tokens.update(_flatten_tokens(service.metadata))
        for fact in service.observed_facts:
            metadata_tokens.update(_tokenize(fact.value))
            phrase = normalize_token(fact.value)
            if phrase:
                phrases.add(phrase)
        for hint in service.inference_hints:
            metadata_tokens.update(_tokenize(hint.value))
            metadata_tokens.update(_tokenize(hint.rationale))
            for field in (hint.value, hint.rationale):
                phrase = normalize_token(field)
                if phrase:
                    phrases.add(phrase)

    for endpoint in endpoints:
        phrases.add(normalize_token(endpoint))

    phrases.update(products)
    phrases.update(metadata_tokens)
    return {
        "services": services,
        "protocols": protocols,
        "products": products,
        "metadata_tokens": metadata_tokens,
        "endpoints": endpoints,
        "phrases": phrases,
    }


def normalize_token(value: str) -> str:
    return " ".join(value.strip().lower().replace("/", " ").replace("-", " ").replace("_", " ").split())


def _tokenize(value: str) -> list[str]:
    normalized = normalize_token(value)
    return [token for token in normalized.split() if token]


def _flatten_tokens(value: object) -> set[str]:
    tokens: set[str] = set()
    if isinstance(value, dict):
        for key, item in value.items():
            tokens.update(_tokenize(str(key)))
            tokens.update(_flatten_tokens(item))
    elif isinstance(value, list):
        for item in value:
            tokens.update(_flatten_tokens(item))
    elif isinstance(value, (str, int, float)):
        tokens.update(_tokenize(str(value)))
    return tokens


def _coverage_score(matches: tuple[str, ...], expected: tuple[str, ...], weight: float) -> float:
    if not expected:
        return 0.0
    return weight * (len(matches) / len(expected))


def _alias_matches(alias: str, observed: dict[str, object]) -> bool:
    phrases = observed["phrases"]
    if alias in phrases:
        return True
    alias_parts = set(alias.split())
    return alias_parts.issubset(set(observed["products"]).union(observed["metadata_tokens"]))


def _evaluate_evidence_groups(
    groups: tuple[WeightedEvidenceGroup, ...],
    observed: dict[str, object],
) -> tuple[tuple[str, ...], float]:
    matched_groups: list[str] = []
    total = 0.0
    for group in groups:
        hits = 0
        checks = 0
        for values, observed_values in (
            (group.services, observed["services"]),
            (group.products, observed["products"]),
            (group.metadata_tokens, observed["metadata_tokens"]),
            (group.protocol_hints, observed["protocols"]),
        ):
            if values:
                checks += 1
                if set(values).intersection(observed_values):
                    hits += 1
        if group.endpoint_patterns:
            checks += 1
            if any(pattern in endpoint for pattern in group.endpoint_patterns for endpoint in observed["endpoints"]):
                hits += 1
        if group.aliases:
            checks += 1
            if any(_alias_matches(alias, observed) for alias in group.aliases):
                hits += 1
        if checks == 0:
            continue
        ratio = hits / checks
        if ratio >= (0.34 if group.optional else 0.5):
            matched_groups.append(group.name)
            total += group.weight * ratio
    return tuple(sorted(matched_groups)), round(total, 3)


def _evaluate_conflicts(entry: FingerprintEntry, observed: dict[str, object]) -> tuple[list[str], float]:
    penalties: list[str] = []
    penalty = 0.0
    observed_tokens = set(observed["products"]).union(observed["metadata_tokens"]).union(observed["services"])

    matched_conflicts = sorted(set(entry.conflict_indicators).intersection(observed_tokens))
    if matched_conflicts:
        penalty += min(0.28, 0.08 * len(matched_conflicts))
        penalties.append(f"conflict indicators: {', '.join(matched_conflicts[:3])}")

    if entry.services and observed["services"] and not set(entry.services).intersection(observed["services"]):
        penalty += 0.08
        penalties.append("service profile mismatch")
    if entry.products and observed["products"] and not set(entry.products).intersection(observed["products"]):
        penalty += 0.12
        penalties.append("product markers mismatch")
    return penalties, round(penalty, 3)


def _combo_bonus(
    entry: FingerprintEntry,
    matched_services: tuple[str, ...],
    matched_products: tuple[str, ...],
    matched_endpoints: tuple[str, ...],
    matched_aliases: tuple[str, ...],
    matched_groups: tuple[str, ...],
) -> float:
    bonus = 0.0
    if matched_products and matched_services:
        bonus += 0.11
    if matched_aliases and (matched_products or matched_endpoints):
        bonus += 0.08
    if matched_groups:
        bonus += min(0.12, len(matched_groups) * 0.035)
    if entry.vendor.lower() != "unknown" and matched_aliases:
        bonus += 0.03
    return round(bonus, 3)


def _generic_penalty(
    entry: FingerprintEntry,
    matched_products: tuple[str, ...],
    matched_aliases: tuple[str, ...],
    matched_groups: tuple[str, ...],
) -> float:
    is_generic = entry.vendor.lower() == "unknown" or entry.class_context in {"general", "legacy defaults"}
    if not is_generic:
        return 0.0
    if matched_aliases or matched_groups:
        return 0.0
    if len(matched_products) <= 1:
        return 0.05
    return 0.02


def _specificity_score(entry: FingerprintEntry) -> int:
    specificity = 0
    specificity += len(entry.services) * 2
    specificity += len(entry.products) * 3
    specificity += len(entry.endpoint_patterns) * 3
    specificity += len(entry.aliases) * 4
    specificity += len(entry.evidence_groups) * 5
    specificity += len(entry.conflict_indicators) * 2
    if entry.vendor.lower() != "unknown":
        specificity += 4
    if entry.model.lower() != "unknown":
        specificity += 3
    if entry.family and entry.family != "unknown":
        specificity += 2
    return specificity


def _finalize_explanations(decisions: list[FingerprintDecision]) -> list[FingerprintDecision]:
    finalized: list[FingerprintDecision] = []
    for index, decision in enumerate(decisions):
        nearby = decisions[index + 1] if index + 1 < len(decisions) else None
        explanation = _build_explanation(decision, nearby)
        evidence = _build_evidence(decision, explanation)
        finalized.append(
            FingerprintDecision(
                entry=decision.entry,
                confidence=decision.confidence,
                matched_services=decision.matched_services,
                matched_products=decision.matched_products,
                matched_metadata=decision.matched_metadata,
                matched_endpoints=decision.matched_endpoints,
                matched_protocols=decision.matched_protocols,
                matched_aliases=decision.matched_aliases,
                matched_optional_indicators=decision.matched_optional_indicators,
                matched_group_names=decision.matched_group_names,
                conflict_penalty=decision.conflict_penalty,
                penalties=decision.penalties,
                score_breakdown=decision.score_breakdown,
                explanation=explanation,
                evidence=tuple(sorted(evidence, key=lambda item: (item.kind, item.value))),
                specificity=decision.specificity,
            )
        )
    return finalized


def _build_explanation(decision: FingerprintDecision, nearby: FingerprintDecision | None) -> str:
    strongest: list[str] = []
    if decision.matched_products:
        strongest.append(f"product markers {', '.join(decision.matched_products[:3])}")
    if decision.matched_aliases:
        strongest.append(f"family aliases {', '.join(decision.matched_aliases[:2])}")
    if decision.matched_group_names:
        strongest.append(f"evidence groups {', '.join(decision.matched_group_names[:2])}")
    if decision.matched_endpoints:
        strongest.append(f"endpoints {', '.join(decision.matched_endpoints[:2])}")
    if decision.matched_services:
        strongest.append(f"services {', '.join(decision.matched_services[:3])}")
    if decision.matched_protocols:
        strongest.append(f"protocol posture {', '.join(decision.matched_protocols[:3])}")
    if decision.matched_metadata and len(strongest) < 3:
        strongest.append(f"metadata tokens {', '.join(decision.matched_metadata[:3])}")

    if decision.confidence >= 0.85:
        confidence_reasoning = "confidence is high because multiple high-signal markers align."
    elif decision.confidence >= 0.7:
        confidence_reasoning = "confidence is moderate to high because service and product evidence align cleanly."
    elif decision.confidence >= 0.55:
        confidence_reasoning = "confidence is moderate because the match relies on some strong signals with limited confirmation."
    else:
        confidence_reasoning = "confidence is cautious because the match is supported mostly by partial or generic overlap."

    penalty_text = "no major conflicts observed"
    if decision.penalties:
        penalty_text = "; ".join(decision.penalties[:2])

    comparison = "no nearby alternative candidate was close enough to change the outcome"
    if nearby is not None:
        comparison = _comparison_reason(decision, nearby)

    strongest_text = ", ".join(strongest[:3]) or "limited overlapping indicators"
    return (
        f"{decision.entry.name} [{decision.entry.category} / {decision.entry.family}] matched strongest on {strongest_text}; "
        f"penalties: {penalty_text}; {confidence_reasoning}; {comparison}."
    )


def _comparison_reason(winner: FingerprintDecision, nearby: FingerprintDecision) -> str:
    reasons: list[str] = []
    if len(winner.matched_products) > len(nearby.matched_products):
        reasons.append("more product markers")
    if len(winner.matched_aliases) > len(nearby.matched_aliases):
        reasons.append("stronger family aliases")
    if len(winner.matched_group_names) > len(nearby.matched_group_names):
        reasons.append("richer evidence-group coverage")
    if winner.specificity > nearby.specificity:
        reasons.append("a more specific family profile")
    if len(winner.penalties) < len(nearby.penalties):
        reasons.append("fewer conflicts")
    if not reasons:
        reasons.append("a higher weighted evidence total")
    return f"it outranked nearby candidate {nearby.entry.name} due to {', '.join(reasons[:3])}"


def _build_evidence(decision: FingerprintDecision, explanation: str) -> list[RawEvidence]:
    evidence: list[RawEvidence] = []
    for kind, values in (
        ("fingerprint-service-match", decision.matched_services),
        ("fingerprint-product-match", decision.matched_products),
        ("fingerprint-metadata-match", decision.matched_metadata),
        ("fingerprint-endpoint-match", decision.matched_endpoints),
        ("fingerprint-protocol-match", decision.matched_protocols),
        ("fingerprint-alias-match", decision.matched_aliases),
        ("fingerprint-optional-match", decision.matched_optional_indicators),
        ("fingerprint-evidence-group-match", decision.matched_group_names),
    ):
        for value in values:
            evidence.append(
                RawEvidence(
                    source=decision.entry.name,
                    kind=kind,
                    value=value,
                    confidence=decision.confidence,
                    attributes={
                        "vendor": decision.entry.vendor,
                        "model": decision.entry.model,
                        "category": decision.entry.category,
                        "family": decision.entry.family,
                        "class_context": decision.entry.class_context,
                        "source": decision.entry.source,
                        "priority": decision.entry.priority,
                        "explanation": explanation,
                    },
                )
            )
    return evidence


def _dedupe_conflicting_decisions(decisions: list[FingerprintDecision]) -> list[FingerprintDecision]:
    kept: list[FingerprintDecision] = []
    seen_vendor_model: set[tuple[str, str]] = set()
    for decision in decisions:
        key = (decision.entry.vendor, decision.entry.model)
        if key in seen_vendor_model:
            continue
        seen_vendor_model.add(key)
        kept.append(decision)
    return kept[:5]

