"""Financial behavior graph risk scoring engine."""

from __future__ import annotations

from decimal import ROUND_CEILING, ROUND_FLOOR, ROUND_HALF_UP, Decimal
from typing import Any

from .consistency import evaluate_claim_consistency
from .decisioning import DecisionTraceBuilder
from .graph import build_claim_graph, claim_node_key, employee_node_key
from .models import (
    ALGORITHM_VERSION,
    AUTOMATION_ASSIST,
    AUTOMATION_AUTO_HOLD,
    AUTOMATION_MANUAL_REVIEW,
    AUTOMATION_SEMI_AUTO_REVIEW,
    LEVEL_CRITICAL,
    LEVEL_HIGH,
    LEVEL_LOW,
    LEVEL_MEDIUM,
    PeerBaseline,
    RiskEvidence,
    RiskGraphClaimSnapshot,
    RiskGraphEdge,
    RiskGraphEvaluationContext,
    RiskGraphEvaluationResult,
    RiskGraphNode,
    RiskHistoryStats,
    RiskObservationDraft,
)
from .ontology import map_ontology_to_risk_graph
from .quality import RiskDataQualityGate
from .sampling import RiskSamplingPlanner
from .signals import (
    NormalizedRiskSignal,
    normalize_risk_signals,
    policy_refs_for_signal,
)

ZERO = Decimal("0")
ONE = Decimal("1")
HUNDRED = Decimal("100")
DATA_QUALITY_GATE = RiskDataQualityGate()
SAMPLING_PLANNER = RiskSamplingPlanner()
DECISION_TRACE_BUILDER = DecisionTraceBuilder()


def evaluate_financial_risk_graph(
    context: RiskGraphEvaluationContext,
) -> RiskGraphEvaluationResult:
    nodes, edges = build_claim_graph(context.claims)
    ontology_mapping = map_ontology_to_risk_graph(
        context.ontology_parse,
        ontology_parse_id=context.ontology_parse_id,
        ontology_version=context.ontology_version,
    )
    nodes = _merge_nodes(nodes, ontology_mapping.nodes)
    edges = _merge_edges(edges, ontology_mapping.edges)

    target_ids = context.target_claim_ids or {claim.claim_id for claim in context.claims}
    target_claims = [claim for claim in context.claims if claim.claim_id in target_ids]
    observations: list[RiskObservationDraft] = []

    for claim in target_claims:
        baseline = _resolve_peer_baseline(claim, context.claims, context.min_peer_sample_size)
        rule_score, rule_evidence, rule_signals = _score_rule_signals(claim)
        anomaly_score, anomaly_evidence = _score_amount_anomaly(claim, baseline)
        graph_score, graph_evidence, graph_signals = _score_graph_anomaly(claim, context)
        policy_score, policy_evidence, policy_refs = _score_policy_relevance(
            rule_signals + graph_signals + ontology_mapping.risk_signals,
        )
        history_score, history_evidence, history = _score_history(
            claim,
            rule_signals + graph_signals + ontology_mapping.risk_signals,
            context.history_stats,
        )

        contribution_scores = {
            "S_rule": rule_score,
            "S_anomaly": anomaly_score,
            "S_graph": graph_score,
            "S_policy": policy_score,
            "S_history": history_score,
        }
        raw_risk_score = _weighted_risk_score(contribution_scores)
        quality_result = DATA_QUALITY_GATE.evaluate_claim(claim)
        evidence = [
            *rule_evidence,
            *anomaly_evidence,
            *graph_evidence,
            *policy_evidence,
            *history_evidence,
        ]
        risk_score, evidence_source_gate = _apply_evidence_source_gate(
            raw_risk_score,
            evidence,
        )
        risk_score, data_quality_gate = DATA_QUALITY_GATE.apply_score_cap(
            risk_score,
            quality_result,
        )
        if risk_score < context.observation_threshold and ontology_mapping.gate != "candidate_only":
            continue
        if risk_score < context.observation_threshold and not ontology_mapping.risk_signals:
            continue

        evidence_source_count = _evidence_source_count(evidence)
        primary_signal = _select_primary_signal(
            rule_signals + graph_signals + ontology_mapping.risk_signals,
            fallback_score=risk_score,
        )
        confidence = _calculate_confidence(
            evidence=evidence,
            baseline=baseline,
            ontology_confidence=ontology_mapping.confidence,
            history=history,
            data_quality_ok=quality_result.passed,
        )
        automation_mode = _resolve_automation_mode(
            risk_score=risk_score,
            confidence=confidence,
            evidence_count=len(evidence),
            history=history,
        )
        sampling_decision = SAMPLING_PLANNER.plan(
            risk_score=risk_score,
            confidence=confidence,
            evidence_source_count=evidence_source_count,
            data_quality_passed=quality_result.passed,
            data_quality_gate=data_quality_gate,
            history=history,
        )
        risk_level = _level_from_score(risk_score)
        decision_metadata = {
            "raw_risk_score": raw_risk_score,
            "evidence_source_count": evidence_source_count,
            "evidence_source_gate": evidence_source_gate,
            "data_quality_gate": data_quality_gate,
            "data_quality": quality_result.as_dict(),
            "sampling_strategy": sampling_decision.as_dict(),
            "contribution_scores": contribution_scores,
            "baseline_scope": baseline.scope,
            "ontology_gate": ontology_mapping.gate,
        }
        decision_trace = DECISION_TRACE_BUILDER.build(
            algorithm_version=ALGORITHM_VERSION,
            risk_signal=primary_signal.code,
            risk_level=risk_level,
            raw_risk_score=raw_risk_score,
            risk_score=risk_score,
            contribution_scores=contribution_scores,
            evidence=evidence,
            baseline=baseline,
            confidence=confidence,
            metadata=decision_metadata,
        )
        graph_node_keys = _claim_related_node_keys(claim, nodes)
        graph_edge_keys = _claim_related_edge_keys(claim, edges)
        similar_case_ids = _similar_case_ids(claim, context.claims)

        observations.append(
            RiskObservationDraft(
                observation_key=f"risk:{claim.claim_id}:{primary_signal.code}",
                subject_type="expense_claim",
                subject_key=f"claim:{claim.claim_id}",
                subject_label=claim.claim_no or claim.claim_id,
                claim_id=claim.claim_id,
                claim_no=claim.claim_no,
                risk_type=primary_signal.code,
                risk_signal=primary_signal.code,
                title=f"{primary_signal.label} risk",
                description=_build_description(claim, primary_signal, risk_score, evidence),
                risk_score=risk_score,
                risk_level=risk_level,
                confidence_score=confidence,
                control_stage="reimbursement",
                control_mode="risk_observation",
                automation_mode=automation_mode,
                source="financial_risk_graph",
                algorithm_version=ALGORITHM_VERSION,
                contribution_scores=contribution_scores,
                baseline=baseline,
                evidence=evidence,
                graph_node_keys=graph_node_keys,
                graph_edge_keys=graph_edge_keys,
                policy_refs=policy_refs,
                similar_case_claim_ids=similar_case_ids,
                ontology_json=ontology_mapping.as_dict(),
                decision_trace=decision_trace.as_dict(),
            )
        )

    return RiskGraphEvaluationResult(
        observations=sorted(observations, key=lambda item: item.risk_score, reverse=True),
        nodes=nodes,
        edges=edges,
    )


def _score_rule_signals(
    claim: RiskGraphClaimSnapshot,
) -> tuple[int, list[RiskEvidence], list[NormalizedRiskSignal]]:
    signals = normalize_risk_signals(claim.risk_flags, source="rule")
    if not signals:
        return 0, [], []
    score = min(100, max(item.score for item in signals) + max(0, len(signals) - 1) * 5)
    evidence = [
        RiskEvidence(
            code="rule_signal",
            title="Rule signal",
            detail=f"{signal.label}: {signal.severity}",
            source="rule",
            score=signal.score,
            metadata=signal.as_dict(),
        )
        for signal in signals
    ]
    return score, evidence, signals


def _score_amount_anomaly(
    claim: RiskGraphClaimSnapshot,
    baseline: PeerBaseline,
) -> tuple[int, list[RiskEvidence]]:
    amount = _to_decimal(claim.amount)
    if baseline.sample_size <= 0 or baseline.p75_amount <= ZERO:
        return 0, [
            RiskEvidence(
                code="baseline_unavailable",
                title="Baseline unavailable",
                detail=baseline.fallback_reason or "No comparable peer sample.",
                source="baseline",
            )
        ]

    ratio = _safe_ratio(amount, baseline.p75_amount)
    score = _score_ratio(
        ratio,
        [
            (Decimal("1.00"), 0),
            (Decimal("1.25"), 30),
            (Decimal("1.50"), 55),
            (Decimal("2.00"), 75),
            (Decimal("3.00"), 95),
        ],
    )
    if score <= 0:
        return 0, []
    return score, [
        RiskEvidence(
            code="peer_amount_deviation",
            title="Peer amount deviation",
            detail=(
                f"Claim amount {amount} is {ratio.quantize(Decimal('0.0001'))} "
                f"times peer p75 {baseline.p75_amount}."
            ),
            source="baseline",
            score=score,
            metadata={"ratio": str(ratio), "baseline": baseline.as_dict()},
        )
    ]


def _score_graph_anomaly(
    claim: RiskGraphClaimSnapshot,
    context: RiskGraphEvaluationContext,
) -> tuple[int, list[RiskEvidence], list[NormalizedRiskSignal]]:
    evidence: list[RiskEvidence] = []
    signals: list[NormalizedRiskSignal] = []

    duplicate_claims = _duplicate_invoice_claims(claim, context.claims)
    if duplicate_claims:
        evidence.append(
            RiskEvidence(
                code="duplicate_invoice_graph",
                title="Duplicate invoice graph",
                detail="Same invoice appears in multiple claims.",
                source="graph",
                score=95,
                related_entity_keys=[f"claim:{item.claim_id}" for item in duplicate_claims],
            )
        )
        signals.extend(normalize_risk_signals(["duplicate_invoice"], source="graph"))

    split_claims = _split_billing_claims(claim, context.claims, context.near_threshold_amount)
    if len(split_claims) >= 3:
        evidence.append(
            RiskEvidence(
                code="split_billing_graph",
                title="Split billing graph",
                detail="Same employee submitted several near-threshold claims in 7 days.",
                source="graph",
                score=78,
                related_entity_keys=[f"claim:{item.claim_id}" for item in split_claims],
            )
        )
        signals.extend(normalize_risk_signals(["split_billing"], source="graph"))

    frequency_claims = _employee_frequency_claims(claim, context.claims)
    if len(frequency_claims) >= 4:
        score = min(88, 52 + len(frequency_claims) * 6)
        evidence.append(
            RiskEvidence(
                code="frequency_graph",
                title="Frequency graph",
                detail="Same employee has dense claims under the same expense type.",
                source="graph",
                score=score,
                related_entity_keys=[f"claim:{item.claim_id}" for item in frequency_claims],
            )
        )
        signals.extend(normalize_risk_signals(["frequency_anomaly"], source="graph"))

    consistency_evidence, consistency_signals = evaluate_claim_consistency(claim)
    evidence.extend(consistency_evidence)
    signals.extend(consistency_signals)

    cluster_claims = _cross_department_cluster_claims(claim, context.claims)
    if len(cluster_claims) >= 3:
        evidence.append(
            RiskEvidence(
                code="cross_department_cluster",
                title="Cross-department cluster",
                detail="Multiple departments produced similar high-value claims together.",
                source="graph",
                score=74,
                related_entity_keys=[f"claim:{item.claim_id}" for item in cluster_claims],
            )
        )
        signals.extend(normalize_risk_signals(["cross_department_cluster"], source="graph"))

    if not evidence:
        return 0, [], []
    score = min(100, max(item.score for item in evidence) + max(0, len(evidence) - 1) * 6)
    return score, evidence, _dedupe_signals(signals)


def _score_policy_relevance(
    signals: list[NormalizedRiskSignal],
) -> tuple[int, list[RiskEvidence], list[str]]:
    refs: list[str] = []
    for signal in signals:
        for ref in policy_refs_for_signal(signal.code):
            if ref not in refs:
                refs.append(ref)
    if not refs:
        return 0, [], []
    score = min(88, 45 + len(refs) * 12)
    return score, [
        RiskEvidence(
            code="policy_relevance",
            title="Policy relevance",
            detail="Risk signal is bound to policy or control clause.",
            source="policy",
            score=score,
            metadata={"policy_refs": refs},
        )
    ], refs


def _score_history(
    claim: RiskGraphClaimSnapshot,
    signals: list[NormalizedRiskSignal],
    history_stats: list[RiskHistoryStats],
) -> tuple[int, list[RiskEvidence], RiskHistoryStats | None]:
    signal_codes = {item.code for item in signals}
    expense_type = _canonical_key(claim.expense_type)
    matched = [
        item
        for item in history_stats
        if item.risk_signal in signal_codes
        and (not item.expense_type or _canonical_key(item.expense_type) == expense_type)
    ]
    if not matched:
        return 0, [], None
    history = max(matched, key=lambda item: item.similar_case_count)
    total = max(1, history.similar_case_count)
    confirmed_rate = Decimal(history.confirmed_count) / Decimal(total)
    returned_rate = Decimal(history.returned_count) / Decimal(total)
    false_positive_rate = Decimal(history.false_positive_count) / Decimal(total)
    score = _clamp_score(
        HUNDRED * (confirmed_rate * Decimal("0.65") + returned_rate * Decimal("0.35"))
        - HUNDRED * false_positive_rate * Decimal("0.50")
    )
    if score <= 0:
        return 0, [], history
    return score, [
        RiskEvidence(
            code="history_feedback",
            title="History feedback",
            detail="Similar historical cases contain confirmed or returned risks.",
            source="feedback",
            score=score,
            metadata=history.as_dict(),
        )
    ], history


def _resolve_peer_baseline(
    target: RiskGraphClaimSnapshot,
    claims: list[RiskGraphClaimSnapshot],
    min_sample_size: int,
) -> PeerBaseline:
    candidates = [claim for claim in claims if claim.claim_id != target.claim_id]
    scopes = [
        (
            "department_grade_expense_type",
            [
                claim
                for claim in candidates
                if _same(claim.department_name, target.department_name)
                and _same(claim.employee_grade, target.employee_grade)
                and _same(claim.expense_type, target.expense_type)
            ],
        ),
        (
            "department_expense_type",
            [
                claim
                for claim in candidates
                if _same(claim.department_name, target.department_name)
                and _same(claim.expense_type, target.expense_type)
            ],
        ),
        (
            "expense_type",
            [claim for claim in candidates if _same(claim.expense_type, target.expense_type)],
        ),
        ("all_claims", candidates),
    ]
    for scope, scoped_claims in scopes:
        amounts = [
            _to_decimal(claim.amount)
            for claim in scoped_claims
            if _to_decimal(claim.amount) > ZERO
        ]
        if len(amounts) >= min_sample_size:
            return _build_baseline(scope, amounts)
    return PeerBaseline(
        scope="insufficient_sample",
        sample_size=0,
        fallback_reason="Peer sample is below minimum threshold.",
    )


def _build_baseline(scope: str, amounts: list[Decimal]) -> PeerBaseline:
    return PeerBaseline(
        scope=scope,
        sample_size=len(amounts),
        median_amount=_percentile(amounts, 50),
        p75_amount=_percentile(amounts, 75),
        p90_amount=_percentile(amounts, 90),
        mean_amount=sum(amounts, ZERO) / Decimal(len(amounts)),
    )


def _weighted_risk_score(scores: dict[str, int]) -> int:
    weighted = (
        Decimal(scores["S_rule"]) * Decimal("0.35")
        + Decimal(scores["S_anomaly"]) * Decimal("0.25")
        + Decimal(scores["S_graph"]) * Decimal("0.20")
        + Decimal(scores["S_policy"]) * Decimal("0.15")
        + Decimal(scores["S_history"]) * Decimal("0.05")
    )
    return _clamp_score(weighted)


def _evidence_source_count(evidence: list[RiskEvidence]) -> int:
    return len(
        {
            str(item.source or "").strip()
            for item in evidence
            if str(item.source or "").strip()
        }
    )


def _apply_evidence_source_gate(
    risk_score: int,
    evidence: list[RiskEvidence],
) -> tuple[int, str]:
    if risk_score >= 70 and _evidence_source_count(evidence) < 2:
        return 69, "capped_high_risk_single_source"
    return risk_score, "passed"


def _select_primary_signal(
    signals: list[NormalizedRiskSignal],
    *,
    fallback_score: int,
) -> NormalizedRiskSignal:
    deduped = _dedupe_signals(signals)
    if deduped:
        return max(deduped, key=lambda item: (item.score, item.confidence, item.code))
    fallback = normalize_risk_signals(
        [{"risk_signal": "amount_limit_exceeded", "score": fallback_score}],
        source="algorithm",
    )
    return fallback[0]


def _calculate_confidence(
    *,
    evidence: list[RiskEvidence],
    baseline: PeerBaseline,
    ontology_confidence: Decimal,
    history: RiskHistoryStats | None,
    data_quality_ok: bool,
) -> Decimal:
    source_count = len({item.source for item in evidence})
    confidence = Decimal("0.42") + min(Decimal("0.30"), Decimal(source_count) * Decimal("0.10"))
    confidence += min(Decimal("0.16"), Decimal(baseline.sample_size) / Decimal("50"))
    confidence += ontology_confidence * Decimal("0.08")
    if history and history.similar_case_count:
        false_positive_rate = Decimal(history.false_positive_count) / Decimal(
            history.similar_case_count
        )
        confidence -= min(Decimal("0.18"), false_positive_rate * Decimal("0.30"))
    if not data_quality_ok:
        confidence -= Decimal("0.20")
    return max(Decimal("0.05"), min(Decimal("0.98"), confidence.quantize(Decimal("0.0001"))))


def _resolve_automation_mode(
    *,
    risk_score: int,
    confidence: Decimal,
    evidence_count: int,
    history: RiskHistoryStats | None,
) -> str:
    false_positive_rate = Decimal("0")
    if history and history.similar_case_count:
        false_positive_rate = Decimal(history.false_positive_count) / Decimal(
            history.similar_case_count
        )
    if (
        risk_score >= 90
        and confidence >= Decimal("0.90")
        and evidence_count >= 3
        and false_positive_rate <= Decimal("0.10")
    ):
        return AUTOMATION_AUTO_HOLD
    if risk_score >= 75 and confidence >= Decimal("0.72") and evidence_count >= 2:
        return AUTOMATION_SEMI_AUTO_REVIEW
    if risk_score >= 40:
        return AUTOMATION_MANUAL_REVIEW
    return AUTOMATION_ASSIST


def _duplicate_invoice_claims(
    target: RiskGraphClaimSnapshot,
    claims: list[RiskGraphClaimSnapshot],
) -> list[RiskGraphClaimSnapshot]:
    invoice_ids = {item.invoice_id for item in target.items if item.invoice_id}
    if not invoice_ids:
        return []
    matched = []
    for claim in claims:
        if claim.claim_id == target.claim_id:
            continue
        if any(item.invoice_id in invoice_ids for item in claim.items if item.invoice_id):
            matched.append(claim)
    return matched


def _split_billing_claims(
    target: RiskGraphClaimSnapshot,
    claims: list[RiskGraphClaimSnapshot],
    near_threshold_amount: Decimal,
) -> list[RiskGraphClaimSnapshot]:
    if target.occurred_at is None:
        return []
    matched = [
        claim
        for claim in claims
        if _same_employee(claim, target)
        and _same(claim.expense_type, target.expense_type)
        and _same(claim.location, target.location)
        and claim.occurred_at is not None
        and abs((claim.occurred_at.date() - target.occurred_at.date()).days) <= 7
        and _to_decimal(claim.amount) <= near_threshold_amount
        and _to_decimal(claim.amount) >= near_threshold_amount * Decimal("0.55")
    ]
    return matched


def _employee_frequency_claims(
    target: RiskGraphClaimSnapshot,
    claims: list[RiskGraphClaimSnapshot],
) -> list[RiskGraphClaimSnapshot]:
    if target.occurred_at is None:
        return []
    return [
        claim
        for claim in claims
        if _same_employee(claim, target)
        and _same(claim.expense_type, target.expense_type)
        and claim.occurred_at is not None
        and abs((claim.occurred_at.date() - target.occurred_at.date()).days) <= 30
    ]


def _cross_department_cluster_claims(
    target: RiskGraphClaimSnapshot,
    claims: list[RiskGraphClaimSnapshot],
) -> list[RiskGraphClaimSnapshot]:
    if target.occurred_at is None or not target.location:
        return []
    matched = [
        claim
        for claim in claims
        if claim.occurred_at is not None
        and claim.occurred_at.date() == target.occurred_at.date()
        and _same(claim.location, target.location)
        and _same(claim.expense_type, target.expense_type)
        and _to_decimal(claim.amount) >= _to_decimal(target.amount) * Decimal("0.65")
    ]
    departments = {
        _canonical_key(claim.department_name)
        for claim in matched
        if claim.department_name
    }
    return matched if len(departments) >= 2 else []


def _similar_case_ids(
    target: RiskGraphClaimSnapshot,
    claims: list[RiskGraphClaimSnapshot],
) -> list[str]:
    return [
        claim.claim_id
        for claim in _employee_frequency_claims(target, claims)
        if claim.claim_id != target.claim_id
    ][:8]


def _claim_related_node_keys(
    claim: RiskGraphClaimSnapshot,
    nodes: list[RiskGraphNode],
) -> list[str]:
    claim_key = claim_node_key(claim)
    employee_key = employee_node_key(claim)
    related = {claim_key}
    if employee_key:
        related.add(employee_key)
    for node in nodes:
        if str(node.key).startswith(("expense_type:", "department:", "location:")):
            if str(node.label or "").strip() in {
                claim.expense_type,
                claim.department_name,
                claim.location,
            }:
                related.add(node.key)
    return sorted(related)


def _claim_related_edge_keys(
    claim: RiskGraphClaimSnapshot,
    edges: list[RiskGraphEdge],
) -> list[dict[str, str]]:
    claim_key = claim_node_key(claim)
    return [
        {
            "source_key": edge.source_key,
            "target_key": edge.target_key,
            "edge_type": edge.edge_type,
        }
        for edge in edges
        if edge.source_key == claim_key or edge.target_key == claim_key
    ]


def _build_description(
    claim: RiskGraphClaimSnapshot,
    signal: NormalizedRiskSignal,
    risk_score: int,
    evidence: list[RiskEvidence],
) -> str:
    top_evidence = max(evidence, key=lambda item: item.score, default=None)
    if top_evidence is None:
        return (
            f"{claim.claim_no or claim.claim_id} produced "
            f"{signal.label} with score {risk_score}."
        )
    return (
        f"{claim.claim_no or claim.claim_id} produced {signal.label} "
        f"with score {risk_score}. Main evidence: {top_evidence.detail}"
    )


def _level_from_score(score: int) -> str:
    if score >= 90:
        return LEVEL_CRITICAL
    if score >= 70:
        return LEVEL_HIGH
    if score >= 45:
        return LEVEL_MEDIUM
    return LEVEL_LOW


def _score_ratio(value: Decimal, bands: list[tuple[Decimal, int]]) -> int:
    if not bands:
        return 0
    points = sorted(bands, key=lambda item: item[0])
    if value <= points[0][0]:
        return points[0][1]
    for index in range(1, len(points)):
        left_value, left_score = points[index - 1]
        right_value, right_score = points[index]
        if value > right_value:
            continue
        ratio = (value - left_value) / (right_value - left_value)
        return _clamp_score(Decimal(left_score) + ratio * Decimal(right_score - left_score))
    return points[-1][1]


def _percentile(values: list[Decimal], percent: int) -> Decimal:
    normalized = sorted(value for value in values if value >= ZERO)
    if not normalized:
        return ZERO
    if len(normalized) == 1:
        return normalized[0]
    position = Decimal(len(normalized) - 1) * Decimal(percent) / HUNDRED
    lower = int(position.to_integral_value(rounding=ROUND_FLOOR))
    upper = int(position.to_integral_value(rounding=ROUND_CEILING))
    if lower == upper:
        return normalized[lower]
    fraction = position - Decimal(lower)
    return normalized[lower] + (normalized[upper] - normalized[lower]) * fraction


def _safe_ratio(numerator: Any, denominator: Any) -> Decimal:
    denominator_value = _to_decimal(denominator)
    if denominator_value <= ZERO:
        return ZERO
    return (_to_decimal(numerator) / denominator_value).quantize(Decimal("0.0001"))


def _to_decimal(value: Any) -> Decimal:
    try:
        return Decimal(str(value or "0"))
    except Exception:
        return ZERO


def _clamp_score(value: Any) -> int:
    try:
        numeric = Decimal(str(value))
    except Exception:
        numeric = ZERO
    return max(0, min(100, int(numeric.quantize(ONE, rounding=ROUND_HALF_UP))))


def _same(left: Any, right: Any) -> bool:
    return _canonical_key(left) == _canonical_key(right)


def _same_employee(left: RiskGraphClaimSnapshot, right: RiskGraphClaimSnapshot) -> bool:
    left_key = left.employee_id or left.employee_name
    right_key = right.employee_id or right.employee_name
    return bool(left_key and _same(left_key, right_key))


def _canonical_key(value: Any) -> str:
    return "_".join(str(value or "").strip().lower().split())


def _dedupe_signals(signals: list[NormalizedRiskSignal]) -> list[NormalizedRiskSignal]:
    by_code: dict[str, NormalizedRiskSignal] = {}
    for signal in signals:
        current = by_code.get(signal.code)
        if current is None or signal.score > current.score:
            by_code[signal.code] = signal
    return list(by_code.values())


def _merge_nodes(
    first: list[RiskGraphNode],
    second: list[RiskGraphNode],
) -> list[RiskGraphNode]:
    by_key = {node.key: node for node in first}
    for node in second:
        by_key.setdefault(node.key, node)
    return list(by_key.values())


def _merge_edges(
    first: list[RiskGraphEdge],
    second: list[RiskGraphEdge],
) -> list[RiskGraphEdge]:
    by_key = {edge.edge_key(): edge for edge in first}
    for edge in second:
        by_key.setdefault(edge.edge_key(), edge)
    return list(by_key.values())
