"""
Enterprise Account dedupe planning helpers.

This module implements a conservative Account-specific planning workflow for
Salesforce Account exports. It reuses the existing MergePlan/v2 artifact shape
but keeps Account blocking, scoring, and hierarchy-risk classification separate
from the Contact/Lead planner.
"""

from __future__ import annotations

import asyncio
import csv
import json
import logging
import re
from collections import defaultdict
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple

from g_gremlin.dedup_enterprise import Cluster, SourceSpec, _UnionFind, _extract_pairs, _load_csv

LOGGER = logging.getLogger(__name__)

ACCOUNT_DEFAULT_PROFILE = "b2b_account"
ACCOUNT_DEFAULT_KEY_COLUMNS = ["name", "domain", "phone", "postal", "street", "external_id"]
ACCOUNT_DEFAULT_BLOCKING_KEYS = ["external_id", "phone", "domain_family", "name_postal"]
ACCOUNT_CANONICAL_KEY_COLUMNS = {
    "name": "__match_account_name",
    "family": "__match_account_family",
    "domain": "__match_domain",
    "phone": "__match_phone",
    "postal": "__match_billing_postal",
    "street": "__match_billing_street",
    "city": "__match_billing_city",
    "state": "__match_billing_state",
    "external_id": "__match_external_ids_joined",
}
ACCOUNT_ALLOWED_BLOCKING_KEYS = set(ACCOUNT_DEFAULT_BLOCKING_KEYS)
ACCOUNT_SUPPORTED_SOURCE_OBJECT_TYPES = {"account"}
MAX_HIERARCHY_TRAVERSAL = 100
ACCOUNT_PROFILE_DEFAULTS = {
    ACCOUNT_DEFAULT_PROFILE: {
        "key_columns": ACCOUNT_DEFAULT_KEY_COLUMNS,
        "blocking_keys": ACCOUNT_DEFAULT_BLOCKING_KEYS,
        "threshold": 92,
        "notes": [
            "Account dedupe uses conservative blocking and hierarchy-risk classification before recommending merge.",
            "Domain-family anchors can generate candidates but are not sufficient on their own for auto-merge.",
        ],
    }
}

ACCOUNT_ENGINE_CAPABILITIES = {
    "multi_algorithm": True,
    "algorithm_families": [
        "Exact",
        "JaroWinkler",
        "WRatio",
        "TokenSetRatio",
        "TokenSortRatio",
        "PartialRatio",
        "Domain",
    ],
    "field_ensembles": {
        "external_id": ["Exact"],
        "domain": ["Exact", "TokenSetRatio", "JaroWinkler"],
        "phone": ["Exact", "JaroWinkler"],
        "name": ["WRatio", "TokenSetRatio", "PartialRatio"],
        "address": ["WRatio", "TokenSetRatio"],
    },
    "notes": [
        "Blocking-first candidate generation keeps large Account dedupe jobs tractable.",
        "Hierarchy-risk classification is a separate gate from fuzzy scoring.",
    ],
}

LEGAL_SUFFIXES = {
    "inc",
    "incorporated",
    "corp",
    "corporation",
    "co",
    "company",
    "llc",
    "ltd",
    "limited",
    "plc",
    "gmbh",
    "bv",
    "ag",
    "sa",
    "srl",
}

BRANCH_TOKENS = {
    "west",
    "east",
    "north",
    "south",
    "regional",
    "region",
    "branch",
    "office",
    "location",
    "store",
    "franchise",
    "hq",
    "headquarters",
    "emea",
    "apac",
    "amer",
}

STREET_TOKEN_NORMALIZATIONS = {
    "street": "st",
    "st": "st",
    "avenue": "ave",
    "ave": "ave",
    "road": "rd",
    "rd": "rd",
    "boulevard": "blvd",
    "blvd": "blvd",
    "drive": "dr",
    "dr": "dr",
    "lane": "ln",
    "ln": "ln",
    "court": "ct",
    "ct": "ct",
    "suite": "ste",
    "ste": "ste",
}

FULL_SCAN_FALLBACK_MAX_ROWS = 250


def _normalize_text(value: Any) -> str:
    return re.sub(r"[^a-z0-9]+", " ", str(value or "").lower()).strip()


def _normalize_phone(value: Any) -> str:
    digits = re.sub(r"\D+", "", str(value or ""))
    if len(digits) == 11 and digits.startswith("1"):
        return digits[1:]
    return digits


def _normalize_domain(value: Any) -> str:
    text = str(value or "").strip().lower()
    if not text:
        return ""
    text = re.sub(r"^https?://", "", text)
    text = text.split("/", 1)[0].split("?", 1)[0].split("#", 1)[0].strip()
    if text.startswith("www."):
        text = text[4:]
    parts = [part for part in text.split(".") if part]
    if len(parts) >= 2:
        return ".".join(parts[-2:])
    return text


def _normalize_postal(value: Any) -> str:
    return re.sub(r"[^a-z0-9]+", "", str(value or "").lower())


def _normalize_street(value: Any) -> str:
    tokens = _normalize_text(value).split()
    if not tokens:
        return ""
    normalized = [STREET_TOKEN_NORMALIZATIONS.get(token, token) for token in tokens]
    return " ".join(normalized)


def _normalize_bool(value: Any) -> Optional[bool]:
    text = str(value or "").strip().lower()
    if not text:
        return None
    if text in {"true", "1", "yes", "y"}:
        return True
    if text in {"false", "0", "no", "n"}:
        return False
    return None


def _safe_int(value: Any) -> int:
    try:
        return int(str(value or "").strip())
    except Exception:
        return 0


def _header_index(headers: Iterable[str]) -> Dict[str, str]:
    return {str(header).strip().lower(): str(header) for header in headers}


def _resolve_header(headers: Iterable[str], candidates: Sequence[str]) -> Optional[str]:
    header_map = _header_index(headers)
    for candidate in candidates:
        actual = header_map.get(candidate.lower())
        if actual:
            return actual
    return None


def _first_non_blank(row: Dict[str, Any], columns: Sequence[Optional[str]]) -> str:
    for column in columns:
        if not column:
            continue
        value = str(row.get(column, "") or "").strip()
        if value:
            return value
    return ""


def _ordered_unique(values: Iterable[str]) -> List[str]:
    ordered: List[str] = []
    seen: set[str] = set()
    for value in values:
        text = str(value or "").strip()
        if not text or text in seen:
            continue
        seen.add(text)
        ordered.append(text)
    return ordered


def _strip_legal_suffixes(text: str) -> str:
    tokens = [token for token in _normalize_text(text).split() if token not in LEGAL_SUFFIXES]
    return " ".join(tokens).strip()


def _family_name(text: str) -> str:
    base_tokens = [token for token in _strip_legal_suffixes(text).split() if token not in BRANCH_TOKENS]
    if base_tokens:
        return " ".join(base_tokens)
    return _strip_legal_suffixes(text)


def _has_branch_tokens(text: str) -> bool:
    tokens = set(_normalize_text(text).split())
    return any(token in BRANCH_TOKENS for token in tokens)


def _detect_columns(
    headers: Sequence[str],
    hierarchy_fields: Sequence[str] | None = None,
) -> Dict[str, Any]:
    requested_hierarchy_fields = _ordered_unique(hierarchy_fields or [])
    direct_hierarchy_fields: List[str] = []
    ultimate_hierarchy_fields: List[str] = []
    missing_hierarchy_fields: List[str] = []
    for field_name in requested_hierarchy_fields:
        resolved = _resolve_header(headers, [field_name])
        if not resolved:
            missing_hierarchy_fields.append(field_name)
            continue
        if "ultimate" in field_name.lower():
            if resolved not in ultimate_hierarchy_fields:
                ultimate_hierarchy_fields.append(resolved)
        elif resolved not in direct_hierarchy_fields:
            direct_hierarchy_fields.append(resolved)
    if missing_hierarchy_fields:
        raise ValueError(f"Hierarchy fields not found: {', '.join(missing_hierarchy_fields)}")

    return {
        "id": _resolve_header(headers, ["Id", "RecordId", "record_id", "ID", "sfid"]),
        "name": _resolve_header(headers, ["Name", "Account Name", "AccountName", "Company", "Company Name"]),
        "website": _resolve_header(headers, ["Website", "Domain", "Website Domain", "Company Domain", "URL"]),
        "phone": _resolve_header(headers, ["Phone", "Phone Number", "Main Phone", "Business Phone"]),
        "billing_street": _resolve_header(headers, ["BillingStreet", "Billing Street", "Street", "Address"]),
        "billing_city": _resolve_header(headers, ["BillingCity", "Billing City", "City"]),
        "billing_state": _resolve_header(headers, ["BillingState", "Billing State", "State", "Province"]),
        "billing_postal": _resolve_header(headers, ["BillingPostalCode", "Billing Postal Code", "PostalCode", "Zip"]),
        "billing_country": _resolve_header(headers, ["BillingCountry", "Billing Country", "Country"]),
        "parent_id": _resolve_header(headers, ["ParentId", "Parent Id", "ParentAccountId"]),
        "account_number": _resolve_header(headers, ["AccountNumber", "Account Number"]),
        "duns_number": _resolve_header(headers, ["DunsNumber", "DUNS", "DUNS Number", "DUNSNumber"]),
        "last_activity_date": _resolve_header(headers, ["LastActivityDate", "Last Activity Date"]),
        "is_person_account": _resolve_header(headers, ["IsPersonAccount", "Is Person Account"]),
        "hierarchy_fields_requested": requested_hierarchy_fields,
        "direct_hierarchy_fields": direct_hierarchy_fields,
        "ultimate_hierarchy_fields": ultimate_hierarchy_fields,
    }


def _external_ids(row: Dict[str, Any], detected: Dict[str, Optional[str]]) -> List[str]:
    values = []
    for key in ("account_number", "duns_number"):
        value = _first_non_blank(row, [detected.get(key)])
        normalized = _normalize_text(value)
        if normalized:
            values.append(f"{key}:{normalized}")
    return _ordered_unique(values)


def _prepare_source_rows(
    rows: Sequence[Dict[str, str]],
    headers: Sequence[str],
    source: SourceSpec,
    hierarchy_fields: Sequence[str] | None = None,
) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
    detected = _detect_columns(headers, hierarchy_fields=hierarchy_fields)
    prepared: List[Dict[str, Any]] = []
    for index, row in enumerate(rows, start=1):
        display_name = _first_non_blank(row, [detected["name"]])
        display_domain = _normalize_domain(_first_non_blank(row, [detected["website"]]))
        display_phone = _first_non_blank(row, [detected["phone"]])
        billing_street = _first_non_blank(row, [detected["billing_street"]])
        billing_city = _first_non_blank(row, [detected["billing_city"]])
        billing_state = _first_non_blank(row, [detected["billing_state"]])
        billing_postal = _first_non_blank(row, [detected["billing_postal"]])
        record_id = _first_non_blank(row, [detected["id"]]) or f"{source.object_type}:{source.csv_path.stem}:{index}"
        external_ids = _external_ids(row, detected)

        prepared_row = dict(row)
        prepared_row.update(
            {
                "__object_type": source.object_type,
                "__source_file": source.csv_path.name,
                "__record_id": record_id,
                "__display_name": display_name,
                "__display_domain": display_domain,
                "__display_phone": display_phone,
                "__billing_city": billing_city,
                "__billing_state": billing_state,
                "__billing_postal": billing_postal,
                "__billing_street": billing_street,
                "__parent_id": _first_non_blank(
                    row,
                    [*detected.get("direct_hierarchy_fields", []), detected["parent_id"]],
                ),
                "__ultimate_parent_id_hint": _first_non_blank(row, detected.get("ultimate_hierarchy_fields", [])),
                "__is_person_account": _normalize_bool(_first_non_blank(row, [detected["is_person_account"]])),
                "__match_account_name": _strip_legal_suffixes(display_name),
                "__match_account_family": _family_name(display_name),
                "__match_domain": display_domain,
                "__match_phone": _normalize_phone(display_phone),
                "__match_billing_street": _normalize_street(billing_street),
                "__match_billing_city": _normalize_text(billing_city),
                "__match_billing_state": _normalize_text(billing_state),
                "__match_billing_postal": _normalize_postal(billing_postal),
                "__external_ids": external_ids,
                "__match_external_ids_joined": " ".join(external_ids),
                "__name_has_branch_tokens": _has_branch_tokens(display_name),
            }
        )
        prepared.append(prepared_row)

    source_meta = {
        "object_type": source.object_type,
        "path": str(source.csv_path),
        "rows": len(prepared),
        "detected_columns": detected,
    }
    return prepared, source_meta


def _resolve_key_columns(requested: Sequence[str], available_columns: Iterable[str]) -> List[str]:
    available_map = _header_index(available_columns)
    resolved: List[str] = []
    missing: List[str] = []
    for token in requested:
        key = str(token or "").strip()
        if not key:
            continue
        canonical = ACCOUNT_CANONICAL_KEY_COLUMNS.get(key.lower())
        if canonical:
            resolved.append(canonical)
            continue
        actual = available_map.get(key.lower())
        if actual:
            resolved.append(actual)
            continue
        missing.append(key)
    if missing:
        raise ValueError(f"Key columns not found: {', '.join(missing)}")
    if not resolved:
        raise ValueError("At least one match key column is required.")
    return resolved


def _resolve_profile_settings(
    *,
    profile: str,
    key_columns: Sequence[str] | None,
    blocking_keys: Sequence[str] | None,
    threshold: int | None,
) -> Dict[str, Any]:
    profile_name = str(profile or ACCOUNT_DEFAULT_PROFILE).strip().lower() or ACCOUNT_DEFAULT_PROFILE
    if profile_name not in ACCOUNT_PROFILE_DEFAULTS:
        raise ValueError(f"Unsupported dedupe profile: {profile_name}")

    defaults = ACCOUNT_PROFILE_DEFAULTS[profile_name]
    key_column_source = defaults["key_columns"] if key_columns is None else key_columns
    blocking_key_source = defaults["blocking_keys"] if blocking_keys is None else blocking_keys
    resolved_key_columns = [str(key).strip() for key in key_column_source if str(key).strip()]
    resolved_blocking_keys = [str(key).strip().lower() for key in blocking_key_source if str(key).strip()]
    resolved_threshold = int(defaults["threshold"] if threshold is None else threshold)
    return {
        "profile": profile_name,
        "key_columns": resolved_key_columns,
        "blocking_keys": resolved_blocking_keys,
        "threshold": resolved_threshold,
        "notes": list(defaults.get("notes") or []),
        "defaults_applied": {
            "key_columns": key_columns is None,
            "blocking_keys": blocking_keys is None,
            "threshold": threshold is None,
        },
    }


def _annotate_hierarchy(rows: Sequence[Dict[str, Any]], warnings: List[str]) -> None:
    row_by_id = {
        str(row.get("__record_id") or "").strip(): row
        for row in rows
        if str(row.get("__record_id") or "").strip()
    }
    parent_counts: Dict[str, int] = defaultdict(int)
    for row in rows:
        parent_id = str(row.get("__parent_id") or "").strip()
        if parent_id:
            parent_counts[parent_id] += 1
    memo: Dict[str, Tuple[str, int, bool, bool]] = {}
    cycle_ids: set[str] = set()
    truncated_ids: set[str] = set()

    def _resolve(record_id: str, trail: Tuple[str, ...] = ()) -> Tuple[str, int, bool, bool]:
        normalized_record_id = str(record_id or "").strip()
        if not normalized_record_id:
            return "", 0, False, False
        if normalized_record_id in memo:
            return memo[normalized_record_id]
        if normalized_record_id in trail:
            cycle_path = set(trail[trail.index(normalized_record_id) :]) | {normalized_record_id}
            cycle_ids.update(cycle_path)
            result = (normalized_record_id, len(trail), True, False)
            memo[normalized_record_id] = result
            return result

        row = row_by_id.get(normalized_record_id)
        if row is None:
            return normalized_record_id, 0, False, False

        hint = str(row.get("__ultimate_parent_id_hint") or "").strip()
        parent_id = str(row.get("__parent_id") or "").strip()
        if not parent_id:
            result = (hint or normalized_record_id, 0, False, False)
            memo[normalized_record_id] = result
            return result

        if len(trail) >= MAX_HIERARCHY_TRAVERSAL:
            truncated_ids.add(normalized_record_id)
            result = (hint or parent_id, len(trail) + 1, False, True)
            memo[normalized_record_id] = result
            return result

        if parent_id not in row_by_id:
            result = (hint or parent_id, 1, False, False)
            memo[normalized_record_id] = result
            return result

        ultimate_parent_id, parent_depth, cycle_detected, traversal_truncated = _resolve(
            parent_id,
            trail + (normalized_record_id,),
        )
        result = (
            hint or ultimate_parent_id or parent_id,
            parent_depth + 1,
            cycle_detected,
            traversal_truncated,
        )
        memo[normalized_record_id] = result
        return result

    for row in rows:
        record_id = str(row.get("__record_id") or "").strip()
        parent_id = str(row.get("__parent_id") or "").strip()
        ultimate_parent_id, hierarchy_depth, cycle_detected, traversal_truncated = _resolve(record_id)
        sibling_count = max(parent_counts.get(parent_id, 0) - 1, 0) if parent_id else 0
        row["__ultimate_parent_id"] = ultimate_parent_id
        row["__hierarchy_depth"] = hierarchy_depth
        row["__hierarchy_cycle_detected"] = cycle_detected
        row["__hierarchy_traversal_truncated"] = traversal_truncated
        row["__parent_in_export"] = bool(parent_id and parent_id in row_by_id)
        row["__sibling_parent_id"] = parent_id if sibling_count > 0 else ""
        row["__sibling_count"] = sibling_count

    if cycle_ids:
        sample = ", ".join(sorted(cycle_ids)[:5])
        warnings.append(
            "Hierarchy cycle detected while resolving Account parents. "
            f"Held impacted clusters for manual review. Sample record IDs: {sample}."
        )
    if truncated_ids:
        sample = ", ".join(sorted(truncated_ids)[:5])
        warnings.append(
            "Hierarchy traversal exceeded the maximum depth while resolving Account parents. "
            f"Sample record IDs: {sample}."
        )


def _candidate_anchor_keys(row: Dict[str, Any], blocking_keys: Sequence[str]) -> List[Tuple[str, str]]:
    anchors: List[Tuple[str, str]] = []
    family = str(row.get("__match_account_family") or "")
    domain = str(row.get("__match_domain") or "")
    phone = str(row.get("__match_phone") or "")
    billing_postal = str(row.get("__match_billing_postal") or "")
    external_ids = list(row.get("__external_ids") or [])

    if "external_id" in blocking_keys:
        for external_id in external_ids:
            anchors.append(("external_id", external_id))
    if "phone" in blocking_keys and len(phone) >= 7:
        anchors.append(("phone", phone))
    if "domain_family" in blocking_keys and domain and family:
        anchors.append(("domain_family", f"{domain}|{family}"))
    if "name_postal" in blocking_keys and family and billing_postal:
        anchors.append(("name_postal", f"{family}|{billing_postal}"))
    return anchors


def _build_candidate_components(
    rows: Sequence[Dict[str, Any]],
    blocking_keys: Sequence[str],
    block_size_cap: int,
) -> Tuple[List[List[int]], Dict[str, Any], Dict[int, set[str]]]:
    anchor_groups: Dict[Tuple[str, str], List[int]] = defaultdict(list)
    row_anchor_kinds: Dict[int, set[str]] = defaultdict(set)
    active_rows_by_kind: Dict[str, set[int]] = defaultdict(set)
    group_counts: Dict[str, int] = defaultdict(int)
    ignored_counts: Dict[str, int] = defaultdict(int)
    ignored_examples: List[Dict[str, Any]] = []

    for row in rows:
        row_index = int(row.get("__row_index", -1))
        if row_index < 0:
            continue
        seen_for_row: set[Tuple[str, str]] = set()
        for anchor in _candidate_anchor_keys(row, blocking_keys):
            if anchor in seen_for_row:
                continue
            seen_for_row.add(anchor)
            anchor_groups[anchor].append(row_index)

    union_find = _UnionFind(range(len(rows)))

    for (anchor_kind, anchor_value), member_ids in anchor_groups.items():
        unique_ids = sorted(set(member_ids))
        if len(unique_ids) < 2:
            continue
        if len(unique_ids) > block_size_cap:
            ignored_counts[anchor_kind] += 1
            if len(ignored_examples) < 10:
                ignored_examples.append(
                    {"anchor": anchor_kind, "size": len(unique_ids), "sample": str(anchor_value)[:80]}
                )
            continue

        group_counts[anchor_kind] += 1
        active_rows_by_kind[anchor_kind].update(unique_ids)
        for member_id in unique_ids:
            row_anchor_kinds[member_id].add(anchor_kind)
        for member_id in unique_ids[1:]:
            union_find.union(unique_ids[0], member_id)

    components_map: Dict[int, List[int]] = defaultdict(list)
    for member_id in sorted(row_anchor_kinds):
        components_map[union_find.find(member_id)].append(member_id)

    components = [sorted(member_ids) for member_ids in components_map.values() if len(member_ids) > 1]
    fallback_full_scan = False
    if not components and len(rows) <= FULL_SCAN_FALLBACK_MAX_ROWS and not ignored_examples:
        components = [list(range(len(rows)))] if len(rows) > 1 else []
        fallback_full_scan = bool(components)
        for idx in range(len(rows)):
            row_anchor_kinds.setdefault(idx, set())

    summary = {
        "blocking_keys": list(blocking_keys),
        "candidate_components": len(components),
        "candidate_rows": len({member_id for component in components for member_id in component}),
        "largest_component": max((len(component) for component in components), default=0),
        "fallback_full_scan": fallback_full_scan,
        "anchors": {
            key: {
                "groups": group_counts.get(key, 0),
                "rows": len(active_rows_by_kind.get(key, set())),
                "ignored_groups": ignored_counts.get(key, 0),
            }
            for key in blocking_keys
        },
        "ignored_examples": ignored_examples,
    }
    return components, summary, row_anchor_kinds


def _sequence_similarity(left: str, right: str) -> float:
    from difflib import SequenceMatcher

    return SequenceMatcher(None, left, right).ratio() * 100.0


def _external_id_similarity(left: Dict[str, Any], right: Dict[str, Any]) -> Optional[float]:
    left_ids = set(str(value) for value in left.get("__external_ids") or [])
    right_ids = set(str(value) for value in right.get("__external_ids") or [])
    if not left_ids or not right_ids:
        return None
    if left_ids & right_ids:
        return 100.0
    return 0.0


def _column_similarity(column: str, left: Dict[str, Any], right: Dict[str, Any]) -> Optional[float]:
    lowered = column.lower()

    if column == "__match_external_ids_joined":
        return _external_id_similarity(left, right)

    left_text = str(left.get(column, "") or "").strip()
    right_text = str(right.get(column, "") or "").strip()
    if not left_text or not right_text:
        return None

    if "phone" in lowered:
        return 100.0 if left_text == right_text else _sequence_similarity(left_text, right_text)
    if "postal" in lowered:
        return 100.0 if left_text == right_text else 0.0
    if "state" in lowered or "city" in lowered:
        return 100.0 if left_text == right_text else _sequence_similarity(left_text, right_text)
    if "domain" in lowered:
        return 100.0 if left_text == right_text else _sequence_similarity(left_text, right_text)
    return _sequence_similarity(left_text, right_text)


def _key_weight(column: str) -> float:
    if column == "__match_external_ids_joined":
        return 4.0
    lowered = column.lower()
    if "domain" in lowered:
        return 2.5
    if "phone" in lowered:
        return 2.5
    if "postal" in lowered:
        return 2.0
    if "street" in lowered:
        return 1.5
    if "name" in lowered:
        return 3.0
    if "city" in lowered or "state" in lowered:
        return 1.0
    return 1.0


def _account_row_similarity(left: Dict[str, Any], right: Dict[str, Any], key_columns: Sequence[str]) -> float:
    weighted_total = 0.0
    weight_sum = 0.0
    shared_external_id = False
    exact_phone = False
    same_postal = False
    same_domain = False
    name_similarity = 0.0
    family_similarity = 0.0
    street_similarity = 0.0

    for column in key_columns:
        score = _column_similarity(column, left, right)
        if score is None:
            continue
        weight = _key_weight(column)
        weighted_total += score * weight
        weight_sum += weight
        if column == "__match_external_ids_joined" and score >= 100.0:
            shared_external_id = True
        lowered = column.lower()
        if "phone" in lowered and score >= 100.0:
            exact_phone = True
        if "postal" in lowered and score >= 100.0:
            same_postal = True
        if "domain" in lowered and score >= 100.0:
            same_domain = True
        if column == "__match_account_name":
            name_similarity = max(name_similarity, score)
        if column == "__match_account_family":
            family_similarity = max(family_similarity, score)
        if "street" in lowered:
            street_similarity = max(street_similarity, score)

    if weight_sum <= 0.0:
        return 0.0

    blended = weighted_total / weight_sum
    entity_name_similarity = max(name_similarity, family_similarity)
    if shared_external_id:
        return max(blended, 99.0)
    if exact_phone and entity_name_similarity >= 92.0:
        return max(blended, 97.0)
    if same_postal and entity_name_similarity >= 95.0:
        return max(blended, 95.0)
    if same_domain and same_postal and entity_name_similarity >= 92.0:
        return max(blended, 94.0)
    if same_domain and street_similarity >= 94.0 and entity_name_similarity >= 92.0:
        return max(blended, 93.0)
    return blended


def _cluster_rows(
    rows: Sequence[Dict[str, Any]],
    key_columns: Sequence[str],
    threshold: int,
    survivor_policy: str,
) -> List[Cluster]:
    union_find = _UnionFind(range(len(rows)))

    for left_index in range(len(rows)):
        for right_index in range(left_index + 1, len(rows)):
            similarity = _account_row_similarity(rows[left_index], rows[right_index], key_columns)
            if similarity >= threshold:
                union_find.union(left_index, right_index)

    groups: Dict[int, List[Dict[str, Any]]] = defaultdict(list)
    for index, row in enumerate(rows):
        groups[union_find.find(index)].append(row)

    clusters: List[Cluster] = []
    for members in groups.values():
        golden_index = _choose_golden(members, survivor_policy=survivor_policy)
        clusters.append(Cluster(members=list(members), golden_index=golden_index, merge_confidence=1.0))
    return clusters


def _clusters_from_pairs(
    rows: Sequence[Dict[str, Any]],
    pairs: Sequence[Tuple[str, str]],
    survivor_policy: str,
) -> List[Cluster]:
    id_to_row = {str(row["__row_index"]): row for row in rows}
    union_find = _UnionFind(id_to_row)

    for left, right in pairs:
        if left in id_to_row and right in id_to_row:
            union_find.union(left, right)

    groups: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
    for row_id, row in id_to_row.items():
        groups[union_find.find(row_id)].append(row)

    clusters: List[Cluster] = []
    for members in groups.values():
        golden_index = _choose_golden(members, survivor_policy=survivor_policy)
        clusters.append(Cluster(members=list(members), golden_index=golden_index, merge_confidence=1.0))
    return clusters


def _build_engine_mappings(key_columns: Sequence[str]) -> List[Dict[str, Any]]:
    mappings: List[Dict[str, Any]] = []
    for column in key_columns:
        mapping: Dict[str, Any] = {"source": column, "reference": column, "weight": _key_weight(column)}
        if column == "__match_external_ids_joined":
            mapping["algorithms"] = [{"name": "Exact", "weight": 1.0}]
        else:
            lowered = column.lower()
            if "phone" in lowered:
                mapping["algorithms"] = [{"name": "Exact", "weight": 0.7}, {"name": "JaroWinkler", "weight": 0.3}]
            elif "domain" in lowered:
                mapping["algorithms"] = [
                    {"name": "Exact", "weight": 0.4},
                    {"name": "TokenSetRatio", "weight": 0.4},
                    {"name": "JaroWinkler", "weight": 0.2},
                ]
            elif "postal" in lowered:
                mapping["algorithms"] = [{"name": "Exact", "weight": 1.0}]
            elif "street" in lowered:
                mapping["algorithms"] = [{"name": "WRatio", "weight": 0.7}, {"name": "TokenSetRatio", "weight": 0.3}]
            else:
                mapping["algorithms"] = [
                    {"name": "WRatio", "weight": 0.5},
                    {"name": "TokenSetRatio", "weight": 0.3},
                    {"name": "PartialRatio", "weight": 0.2},
                ]
        mapping["preferred_algo"] = mapping["algorithms"][0]["name"]
        mappings.append(mapping)
    return mappings


def _engine_cluster(
    rows: Sequence[Dict[str, Any]],
    key_columns: Sequence[str],
    threshold: int,
    survivor_policy: str,
) -> List[Cluster]:
    try:
        import pandas as pd  # type: ignore
        from fmatch.saas.services.match_gateway import MatchGateway  # type: ignore
    except Exception as exc:  # pragma: no cover - optional dependency
        raise RuntimeError("MatchGateway is not available") from exc

    df = pd.DataFrame(rows)
    id_field = "__source_id"
    df[id_field] = [str(row["__row_index"]) for row in rows]
    mappings = _build_engine_mappings(key_columns)

    async def _run() -> Any:
        gateway = await MatchGateway.create()
        return await gateway.dedup(df, threshold=threshold, mappings=mappings)

    try:
        result = asyncio.run(_run())
    except Exception as exc:  # pragma: no cover - runtime depends on engine env
        raise RuntimeError(f"Gateway dedup failed: {exc}") from exc

    matches_df = getattr(result, "matches", None)
    if matches_df is None:
        raise RuntimeError("Engine dedup did not return matches DataFrame.")

    pairs = _extract_pairs(matches_df, id_field)
    return _clusters_from_pairs(rows, pairs, survivor_policy=survivor_policy)


def _has_shared_domain(members: Sequence[Dict[str, Any]]) -> bool:
    domains = {str(member.get("__match_domain") or "") for member in members if str(member.get("__match_domain") or "")}
    return len(domains) == 1 and bool(domains)


def _distinct_non_blank(members: Sequence[Dict[str, Any]], key: str) -> set[str]:
    return {str(member.get(key) or "").strip() for member in members if str(member.get(key) or "").strip()}


def _has_parent_link(members: Sequence[Dict[str, Any]]) -> bool:
    member_ids = {str(member.get("__record_id") or "").strip() for member in members}
    return any(str(member.get("__parent_id") or "").strip() in member_ids for member in members)


def _has_hierarchy_cycle(members: Sequence[Dict[str, Any]]) -> bool:
    return any(bool(member.get("__hierarchy_cycle_detected")) for member in members)


def _shared_sibling_parent_id(members: Sequence[Dict[str, Any]]) -> str:
    member_ids = {str(member.get("__record_id") or "").strip() for member in members}
    parent_groups: Dict[str, int] = defaultdict(int)
    for member in members:
        parent_id = str(member.get("__parent_id") or "").strip()
        if not parent_id or parent_id in member_ids:
            continue
        parent_groups[parent_id] += 1
    return next((parent_id for parent_id, count in parent_groups.items() if count >= 2), "")


def _has_shared_ultimate_parent_at_different_depths(members: Sequence[Dict[str, Any]]) -> bool:
    by_root: Dict[str, set[int]] = defaultdict(set)
    for member in members:
        root_id = str(member.get("__ultimate_parent_id") or "").strip()
        if not root_id:
            continue
        by_root[root_id].add(_safe_int(member.get("__hierarchy_depth")))
    return any(len(depths) > 1 for depths in by_root.values())


def _has_name_qualifier_gap(members: Sequence[Dict[str, Any]]) -> bool:
    candidate_families = sorted(
        {
            str(member.get("__match_account_family") or "").strip()
            for member in members
            if str(member.get("__match_account_family") or "").strip()
        },
        key=lambda value: (len(value.split()), len(value)),
    )
    if not candidate_families:
        return False

    for family in candidate_families:
        family_pattern = re.compile(rf"\b{re.escape(family)}\b")
        for member in members:
            account_name = str(member.get("__match_account_name") or "").strip()
            if not account_name or account_name == family:
                continue
            if family_pattern.search(account_name):
                return True
    return False


def _assess_account_resolution(members: Sequence[Dict[str, Any]], anchors: Sequence[str]) -> Tuple[str, List[str]]:
    hierarchy_signals: List[str] = []
    person_account_values = {
        member.get("__is_person_account")
        for member in members
        if member.get("__is_person_account") is not None
    }
    shared_domain = _has_shared_domain(members)
    geo_divergence = len(_distinct_non_blank(members, "__match_billing_state")) > 1 or len(
        _distinct_non_blank(members, "__match_billing_postal")
    ) > 1
    phone_divergence = len(_distinct_non_blank(members, "__match_phone")) > 1
    branch_tokens = any(bool(member.get("__name_has_branch_tokens")) for member in members)
    qualifier_gap = _has_name_qualifier_gap(members)
    parent_link = _has_parent_link(members)
    sibling_parent_id = _shared_sibling_parent_id(members)
    shared_ultimate_parent = _has_shared_ultimate_parent_at_different_depths(members)
    hierarchy_cycle = _has_hierarchy_cycle(members)

    if len(person_account_values) > 1:
        hierarchy_signals.append("person_account_mix")
        return "manual_review", hierarchy_signals

    if hierarchy_cycle:
        hierarchy_signals.append("hierarchy_cycle")
        return "manual_review", hierarchy_signals

    if parent_link:
        hierarchy_signals.append("parent_id_link")
        return "possible_parent_child", hierarchy_signals

    if sibling_parent_id:
        hierarchy_signals.append("sibling_accounts")
        return "possible_parent_child", hierarchy_signals

    if shared_ultimate_parent:
        hierarchy_signals.append("shared_ultimate_parent")
        return "possible_parent_child", hierarchy_signals

    if shared_domain and geo_divergence:
        hierarchy_signals.append("shared_domain_geo_divergence")
    if phone_divergence:
        hierarchy_signals.append("phone_divergence")
    if branch_tokens:
        hierarchy_signals.append("branch_tokens")
    if qualifier_gap:
        hierarchy_signals.append("family_name_variants")

    if branch_tokens and (geo_divergence or phone_divergence):
        return "possible_franchise_or_branch", hierarchy_signals
    if shared_domain and geo_divergence and (phone_divergence or qualifier_gap):
        return "possible_parent_child", hierarchy_signals

    strong_anchor_present = any(anchor in {"external_id", "phone", "name_postal"} for anchor in anchors)
    if not strong_anchor_present and "domain_family" in anchors:
        hierarchy_signals.append("weak_domain_family_only")
        return "manual_review", hierarchy_signals

    return "safe_duplicate", hierarchy_signals


def _golden_details(row: Dict[str, Any], *, index: int, survivor_policy: str) -> Tuple[float, List[str]]:
    score = 0.0
    evidence: List[str] = []

    for key, value in row.items():
        if key.startswith("__"):
            continue
        if str(value or "").strip():
            score += 0.5

    external_ids = list(row.get("__external_ids") or [])
    if external_ids:
        score += 8.0
        evidence.append("external IDs present")
    if row.get("__display_domain"):
        score += 4.0
        evidence.append("website/domain present")
    if row.get("__display_phone"):
        score += 3.0
        evidence.append("phone present")
    if row.get("__billing_street") and row.get("__billing_city") and row.get("__billing_state") and row.get("__billing_postal"):
        score += 3.0
        evidence.append("complete billing address")

    if survivor_policy in {"crm_account", "crm_person"}:
        contact_count = _safe_int(row.get("__contact_count") or row.get("ContactCount"))
        opportunity_count = _safe_int(row.get("__open_opportunity_count") or row.get("OpenOpportunityCount"))
        case_count = _safe_int(row.get("__case_count") or row.get("CaseCount"))
        child_count = _safe_int(row.get("__child_account_count") or row.get("ChildAccountCount"))
        activity_count = _safe_int(row.get("__activity_count") or row.get("ActivityCount"))
        downstream_score = min(contact_count, 10) + min(opportunity_count, 10) + min(case_count, 5) + min(child_count, 5)
        if downstream_score:
            score += float(downstream_score)
            evidence.append("more downstream references")
        if activity_count > 0 or str(row.get("LastActivityDate") or row.get("Last Activity Date") or "").strip():
            score += 2.0
            evidence.append("recent activity present")

    score -= 0.001 * index
    return score, evidence


def _choose_golden(members: Sequence[Dict[str, Any]], survivor_policy: str) -> int:
    best_index = 0
    best_score = float("-inf")
    for index, row in enumerate(members):
        score, _ = _golden_details(row, index=index, survivor_policy=survivor_policy)
        if score > best_score:
            best_score = score
            best_index = index
    return best_index


def _anchor_evidence(anchors: Sequence[str]) -> List[str]:
    evidence = []
    if "external_id" in anchors:
        evidence.append("shared exact external ID")
    if "phone" in anchors:
        evidence.append("shared normalized phone")
    if "name_postal" in anchors:
        evidence.append("shared account family plus billing postal")
    if "domain_family" in anchors:
        evidence.append("shared registrable domain plus account family")
    return evidence


def _review_notes(
    *,
    account_resolution_type: str,
    hierarchy_signals: Sequence[str],
    anchors: Sequence[str],
) -> List[str]:
    notes: List[str] = []
    if account_resolution_type == "possible_parent_child":
        notes.append("Hierarchy-risk signals suggest a parent/child relationship. Hold for manual review.")
    elif account_resolution_type == "possible_franchise_or_branch":
        notes.append("Hierarchy-risk signals suggest branch, franchise, or regional-office separation. Hold for manual review.")
    elif account_resolution_type == "manual_review":
        notes.append("Cluster lacks a strong enough Account anchor for auto-merge. Keep behind a human review gate.")

    if "domain_family" in anchors and "external_id" not in anchors and "phone" not in anchors and "name_postal" not in anchors:
        notes.append("Domain-family blocking generated the candidate cluster, but domain alone is not sufficient for merge approval.")
    if "external_id" not in anchors and "phone" not in anchors:
        notes.append("No exact external ID or phone anchor present.")
    if hierarchy_signals:
        notes.append(f"Hierarchy signals: {', '.join(hierarchy_signals)}.")
    return notes


def _estimate_confidence(anchors: Sequence[str], resolution_type: str, size: int) -> float:
    if resolution_type != "safe_duplicate":
        return 0.0
    confidence = 0.82
    if "external_id" in anchors:
        confidence = 0.99
    elif "phone" in anchors:
        confidence = 0.95
    elif "name_postal" in anchors:
        confidence = 0.91
    elif "domain_family" in anchors:
        confidence = 0.86
    if size >= 3 and confidence < 0.99:
        confidence = min(confidence + 0.02, 0.99)
    return round(confidence, 2)


def _public_record(row: Dict[str, Any]) -> Dict[str, Any]:
    payload = {key: value for key, value in row.items() if not key.startswith("__")}
    payload.update(
        {
            "record_id": row.get("__record_id", ""),
            "object_type": row.get("__object_type", ""),
            "source_file": row.get("__source_file", ""),
            "match_name": row.get("__display_name", ""),
            "match_domain": row.get("__display_domain", ""),
            "match_phone": row.get("__display_phone", ""),
            "billing_city": row.get("__billing_city", ""),
            "billing_state": row.get("__billing_state", ""),
            "billing_postal_code": row.get("__billing_postal", ""),
            "billing_street": row.get("__billing_street", ""),
            "parent_id": row.get("__parent_id", ""),
            "ultimate_parent_id": row.get("__ultimate_parent_id", ""),
            "hierarchy_depth": row.get("__hierarchy_depth", 0),
            "sibling_parent_id": row.get("__sibling_parent_id", ""),
        }
    )
    return payload


def _cluster_payload(
    cluster: Cluster,
    *,
    row_anchor_kinds: Dict[int, set[str]],
    survivor_policy: str,
    cluster_id: str,
) -> Dict[str, Any]:
    member_indexes = [int(member.get("__row_index", -1)) for member in cluster.members]
    anchors = sorted({kind for idx in member_indexes for kind in row_anchor_kinds.get(idx, set())})
    account_resolution_type, hierarchy_signals = _assess_account_resolution(cluster.members, anchors)
    golden_record = cluster.members[cluster.golden_index]
    _, survivor_evidence = _golden_details(
        golden_record,
        index=cluster.golden_index,
        survivor_policy=survivor_policy,
    )
    public_members = [_public_record(member) for member in cluster.members]
    return {
        "cluster_id": cluster_id,
        "cluster_type": "same_object",
        "recommended_action": "merge" if account_resolution_type == "safe_duplicate" else "manual_review",
        "survivor_policy": survivor_policy,
        "object_types": ["Account"],
        "anchors": anchors,
        "anchor_evidence": _anchor_evidence(anchors),
        "merge_confidence": _estimate_confidence(anchors, account_resolution_type, len(public_members)),
        "review_notes": _review_notes(
            account_resolution_type=account_resolution_type,
            hierarchy_signals=hierarchy_signals,
            anchors=anchors,
        ),
        "members": public_members,
        "golden_record": public_members[cluster.golden_index],
        "account_resolution_type": account_resolution_type,
        "hierarchy_signals": hierarchy_signals,
        "survivor_evidence": survivor_evidence,
    }


def _write_review_worksheet(plan: Dict[str, Any], output_path: Path) -> Path:
    output_path.parent.mkdir(parents=True, exist_ok=True)
    fieldnames = [
        "cluster_id",
        "recommended_action",
        "account_resolution_type",
        "merge_confidence",
        "record_id",
        "object_type",
        "match_name",
        "match_domain",
        "match_phone",
        "billing_city",
        "billing_state",
        "billing_postal_code",
        "billing_street",
        "parent_id",
        "ultimate_parent_id",
        "hierarchy_depth",
        "sibling_parent_id",
        "anchors",
        "anchor_evidence",
        "hierarchy_signals",
        "review_notes",
    ]
    with output_path.open("w", newline="", encoding="utf-8") as handle:
        writer = csv.DictWriter(handle, fieldnames=fieldnames)
        writer.writeheader()
        for cluster in plan.get("clusters") or []:
            anchors = "; ".join(cluster.get("anchors") or [])
            anchor_evidence = "; ".join(cluster.get("anchor_evidence") or [])
            hierarchy_signals = "; ".join(cluster.get("hierarchy_signals") or [])
            review_notes = "; ".join(cluster.get("review_notes") or [])
            for member in cluster.get("members") or []:
                writer.writerow(
                    {
                        "cluster_id": cluster.get("cluster_id", ""),
                        "recommended_action": cluster.get("recommended_action", ""),
                        "account_resolution_type": cluster.get("account_resolution_type", ""),
                        "merge_confidence": cluster.get("merge_confidence", ""),
                        "record_id": member.get("record_id", ""),
                        "object_type": member.get("object_type", ""),
                        "match_name": member.get("match_name", ""),
                        "match_domain": member.get("match_domain", ""),
                        "match_phone": member.get("match_phone", ""),
                        "billing_city": member.get("billing_city", ""),
                        "billing_state": member.get("billing_state", ""),
                        "billing_postal_code": member.get("billing_postal_code", ""),
                        "billing_street": member.get("billing_street", ""),
                        "parent_id": member.get("parent_id", ""),
                        "ultimate_parent_id": member.get("ultimate_parent_id", ""),
                        "hierarchy_depth": member.get("hierarchy_depth", ""),
                        "sibling_parent_id": member.get("sibling_parent_id", ""),
                        "anchors": anchors,
                        "anchor_evidence": anchor_evidence,
                        "hierarchy_signals": hierarchy_signals,
                        "review_notes": review_notes,
                    }
                )
    return output_path


def build_account_dedupe_plan(
    source_specs: Sequence[Tuple[str, Path] | SourceSpec],
    *,
    key_columns: Optional[Sequence[str]] = None,
    threshold: int | None = None,
    output_path: Path,
    review_output_path: Optional[Path] = None,
    overwrite: bool = False,
    local: bool = False,
    survivor_policy: str = "crm_account",
    blocking_keys: Optional[Sequence[str]] = None,
    profile: str = ACCOUNT_DEFAULT_PROFILE,
    hierarchy_fields: Optional[Sequence[str]] = None,
    block_size_cap: int = 5000,
    limit_per_source: int | None = None,
    pairwise_warning_threshold: int = 1500,
) -> Dict[str, Any]:
    if output_path.exists() and not overwrite:
        raise FileExistsError(f"Output {output_path} exists. Use --overwrite.")
    if review_output_path and review_output_path.exists() and not overwrite:
        raise FileExistsError(f"Output {review_output_path} exists. Use --overwrite.")

    normalized_sources = [
        source if isinstance(source, SourceSpec) else SourceSpec(object_type=source[0], csv_path=source[1])
        for source in source_specs
    ]
    if not normalized_sources:
        raise ValueError("At least one source export is required.")

    unsupported_source_types = sorted(
        {
            str(source.object_type or "").strip()
            for source in normalized_sources
            if str(source.object_type or "").strip().lower() not in ACCOUNT_SUPPORTED_SOURCE_OBJECT_TYPES
        }
    )
    if unsupported_source_types:
        raise ValueError(
            "Enterprise Account dedupe planning currently supports only Account sources. "
            f"Unsupported source types: {', '.join(unsupported_source_types)}."
        )

    resolved_profile = _resolve_profile_settings(
        profile=profile,
        key_columns=key_columns,
        blocking_keys=blocking_keys,
        threshold=threshold,
    )
    requested_key_columns = list(resolved_profile["key_columns"])
    normalized_blocking_keys = list(resolved_profile["blocking_keys"])
    resolved_threshold = int(resolved_profile["threshold"])

    invalid_blocking_keys = sorted(set(normalized_blocking_keys) - ACCOUNT_ALLOWED_BLOCKING_KEYS)
    if invalid_blocking_keys:
        raise ValueError(f"Unsupported blocking keys: {', '.join(invalid_blocking_keys)}")
    if not normalized_blocking_keys:
        raise ValueError("At least one blocking key is required.")
    if block_size_cap < 2:
        raise ValueError("--block-size-cap must be at least 2.")
    if limit_per_source is not None and limit_per_source < 1:
        raise ValueError("--limit must be at least 1 when provided.")
    if pairwise_warning_threshold < 2:
        raise ValueError("pairwise_warning_threshold must be at least 2.")

    normalized_hierarchy_fields = _ordered_unique(hierarchy_fields or [])
    all_rows: List[Dict[str, Any]] = []
    sources_meta: List[Dict[str, Any]] = []
    warnings: List[str] = []

    for source in normalized_sources:
        rows, headers = _load_csv(source.csv_path, limit=limit_per_source)
        if not rows:
            raise ValueError(f"Input CSV is empty: {source.csv_path}")
        prepared_rows, source_meta = _prepare_source_rows(
            rows,
            headers,
            source,
            hierarchy_fields=normalized_hierarchy_fields,
        )
        if limit_per_source is not None:
            source_meta["row_limit_applied"] = limit_per_source
        sources_meta.append(source_meta)
        all_rows.extend(prepared_rows)

    for row_index, row in enumerate(all_rows):
        row["__row_index"] = row_index

    _annotate_hierarchy(all_rows, warnings)

    resolved_key_columns = _resolve_key_columns(requested_key_columns, all_rows[0].keys())
    components, blocking_summary, row_anchor_kinds = _build_candidate_components(
        all_rows,
        normalized_blocking_keys,
        block_size_cap,
    )

    used_modes: set[str] = set()
    duplicate_clusters: List[Cluster] = []
    engine_requested = not local

    normalized_survivor_policy = str(survivor_policy or "crm_account").strip().lower() or "crm_account"
    if normalized_survivor_policy == "crm_person":
        warnings.append("crm_person survivor policy is not valid for b2b_account; using crm_account instead.")
        normalized_survivor_policy = "crm_account"
    if normalized_survivor_policy not in {"crm_account", "most_complete"}:
        raise ValueError("Account dedupe supports survivor policies crm_account or most_complete.")

    for component in components:
        component_rows = [all_rows[index] for index in component]
        component_clusters: Optional[List[Cluster]] = None
        if engine_requested:
            try:
                component_clusters = _engine_cluster(
                    component_rows,
                    key_columns=resolved_key_columns,
                    threshold=resolved_threshold,
                    survivor_policy=normalized_survivor_policy,
                )
                used_modes.add("engine")
            except Exception as exc:
                warnings.append(f"Engine fallback on candidate block ({len(component_rows)} rows): {exc}")

        if component_clusters is None:
            if len(component_rows) > pairwise_warning_threshold:
                warnings.append(
                    "Large local candidate block encountered "
                    f"({len(component_rows)} rows). Tighten blocking keys or use engine mode for this segment."
                )
            component_clusters = _cluster_rows(
                component_rows,
                key_columns=resolved_key_columns,
                threshold=resolved_threshold,
                survivor_policy=normalized_survivor_policy,
            )
            used_modes.add("local")

        duplicate_clusters.extend(cluster for cluster in component_clusters if len(cluster.members) > 1)

    payload_clusters = [
        _cluster_payload(
            cluster,
            row_anchor_kinds=row_anchor_kinds,
            survivor_policy=normalized_survivor_policy,
            cluster_id=f"acct_{index:04d}",
        )
        for index, cluster in enumerate(duplicate_clusters, start=1)
    ]
    payload_clusters.sort(
        key=lambda cluster: (
            cluster.get("recommended_action", "") != "merge",
            -float(cluster.get("merge_confidence", 0.0)),
            -len(cluster.get("members", [])),
            str(cluster.get("cluster_id", "")),
        )
    )

    if blocking_summary.get("ignored_examples"):
        warnings.append(
            "Some blocking groups were skipped because they exceeded the block-size cap. "
            "Review stats.blocking.ignored_examples before execution."
        )
    if (blocking_summary.get("anchors") or {}).get("domain_family", {}).get("ignored_groups", 0):
        warnings.append(
            "Domain-family blocking hit the block-size cap. Validate these skipped groups against a real Account export before execution."
        )

    object_counts: Dict[str, int] = defaultdict(int)
    for row in all_rows:
        object_counts[str(row.get("__object_type", "") or "unknown")] += 1

    action_counts: Dict[str, int] = defaultdict(int)
    resolution_counts: Dict[str, int] = defaultdict(int)
    for cluster in payload_clusters:
        action_counts[str(cluster.get("recommended_action", "merge"))] += 1
        resolution_counts[str(cluster.get("account_resolution_type", "unknown"))] += 1

    if not used_modes:
        mode_used = "local" if local else "engine"
    elif used_modes == {"engine"}:
        mode_used = "engine"
    elif used_modes == {"local"}:
        mode_used = "local"
    else:
        mode_used = "mixed"

    plan = {
        "$schema": "MergePlan/v2",
        "job_type": "crm_dedupe",
        "sources": sources_meta,
        "matching_profile": {
            "profile": resolved_profile["profile"],
            "profile_defaults_applied": dict(resolved_profile["defaults_applied"]),
            "profile_notes": list(resolved_profile["notes"]),
            "mode_requested": "local" if local else "engine",
            "mode_used": mode_used,
            "threshold": resolved_threshold,
            "candidate_strategy": "blocking_first",
            "key_columns": requested_key_columns,
            "resolved_key_columns": resolved_key_columns,
            "blocking_keys": normalized_blocking_keys,
            "hierarchy_fields": normalized_hierarchy_fields,
            "limit_per_source": limit_per_source,
            "capabilities": ACCOUNT_ENGINE_CAPABILITIES,
        },
        "stats": {
            "records_total": len(all_rows),
            "candidate_rows": blocking_summary.get("candidate_rows", 0),
            "candidate_components": blocking_summary.get("candidate_components", 0),
            "largest_component": blocking_summary.get("largest_component", 0),
            "clusters_found": len(payload_clusters),
            "duplicates_identified": sum(max(len(cluster.get("members", [])) - 1, 0) for cluster in payload_clusters),
            "object_counts": dict(sorted(object_counts.items())),
            "actions": dict(sorted(action_counts.items())),
            "account_resolution_types": dict(sorted(resolution_counts.items())),
            "blocking": blocking_summary,
        },
        "warnings": warnings,
        "clusters": payload_clusters,
    }

    output_path.parent.mkdir(parents=True, exist_ok=True)
    output_path.write_text(json.dumps(plan, indent=2), encoding="utf-8")

    if review_output_path:
        _write_review_worksheet(plan, review_output_path)

    return plan
