from __future__ import annotations

from typing import Any, Dict, List, Optional, Literal

import json
import logging

from fastapi import APIRouter, Depends, Header, HTTPException, Body
from fastapi.responses import JSONResponse, Response
from typing import Tuple
import os
import traceback
from pydantic import BaseModel, Field, model_validator, ConfigDict

from .deps import require_api_key, IdempotencyStore
from ...utils.cache_utils import SimpleCache
from ...services.public_match_service import PublicMatchService

try:
    from ...services.ollama_rerank import choose as ollama_choose
except Exception:  # pragma: no cover - optional dependency
    ollama_choose = None
from .b2c_constants import B2C_TOP
import time


router = APIRouter(prefix="/api/v1", tags=["PublicAPI"])


def _root_domain(val: str) -> str:
    v = (str(val or "").strip()).lower()
    if v.startswith("http://") or v.startswith("https://"):
        v = v.split("://", 1)[1]
    v = v.split("/", 1)[0]
    if v.startswith("www."):
        v = v[4:]
    parts = v.split(".")
    return ".".join(parts[-2:]) if len(parts) >= 2 else v


@router.get("/ping")
async def ping():
    """Health check endpoint for Google Sheets addon and other integrations - no auth required for basic ping."""
    return {"ok": True, "service": "fm-api", "version": "1.0.0"}


class MatchItem(BaseModel):
    record: Dict[str, Any]


class BlockingOptions(BaseModel):
    model_config = ConfigDict(extra="ignore")
    enabled: bool = False
    column: Optional[str] = None  # e.g., "Domain", "EmailDomain", "Company_clean"


class MatchOptions(BaseModel):
    model_config = ConfigDict(extra="ignore")
    threshold: float = Field(0.65, ge=0.0, le=1.0)
    max_candidates_per_source: int = 3
    returnExplanations: bool = True
    explanations_mode: Literal["brief", "full"] = "brief"
    blocking: BlockingOptions = BlockingOptions()
    widen_if_zero: bool = True
    # New flags
    b2c_filter: bool = False
    use_domain_family: bool = False


class MatchRequest(BaseModel):
    profile: str = Field(
        "balanced",
        pattern=r"^(high_precision|balanced|high_recall|leads_to_accounts)$",
    )
    source: Optional[Dict[str, Any]] = None  # single-source mode
    candidates: Optional[List[Dict[str, Any]]] = None  # pairwise (<= 100 for sync)
    source_url: Optional[str] = None  # large/batch
    reference_url: Optional[str] = None
    # dev-safe: accept storage keys (local/S3) instead of URLs
    source_storage_key: Optional[str] = None
    reference_storage_key: Optional[str] = None
    options: Optional[MatchOptions] = MatchOptions()
    # Optional: reference candidates cache id (avoids sending large candidate arrays inline)
    candidates_cache_id: Optional[str] = None

    @model_validator(mode="after")
    def _require_valid_input(self):
        # Allow any ONE of: (source + candidates), (source_url + reference_url), (source_storage_key + reference_storage_key)
        have_inline = bool(self.source) and isinstance(self.candidates, list)
        have_urls = bool(self.source_url and self.reference_url)
        have_keys = bool(self.source_storage_key and self.reference_storage_key)
        if not (have_inline or have_urls or have_keys):
            raise ValueError(
                "Provide either (source+candidates), or both URLs, or both storage keys."
            )
        return self


class SyncMatchResponse(BaseModel):
    best_match: Dict[str, Any]
    confidence: float
    explanations: List[Dict[str, Any]] = []
    all_scores: List[float] = []
    recommendation: str  # merge | review | no_match
    decision_id: Optional[str] = None


class AsyncAccepted(BaseModel):
    status: str = "processing"
    job_id: str
    poll_url: str
    webhook_url: Optional[str] = None


idem_store = IdempotencyStore()
# Short-lived candidate set cache to reduce per-row payloads in dev/tunnel flows
_candidates_cache = SimpleCache(ttl_seconds=600)
_cand_keys_by_acct: dict[str, list[str]] = {}


# Internal helpers (double underscore per convention)
def __rows_fingerprint__(headers: list[str], rows: list[list]) -> str:
    import hashlib
    import json

    digests = []
    for row in rows:
        m = {
            str(headers[i]): (row[i] if i < len(row) else None)
            for i in range(len(headers))
        }
        digests.append(
            hashlib.sha256(
                json.dumps(m, sort_keys=True, separators=(",", ":")).encode("utf-8")
            ).digest()
        )
    digests.sort()
    return hashlib.sha256(b"".join(digests)).hexdigest()


# LLM budget guards (simple per-process budget)
_LLM_LIMIT = int(os.getenv("LLM_RERANK_LIMIT", "500") or 500)
_LLM_WINDOW = int(os.getenv("LLM_RERANK_WINDOW_SEC", "3600") or 3600)
_LLM_BUDGET = {"start": 0.0, "count": 0}


def _llm_budget_allows() -> bool:
    now = time.time()
    if now - _LLM_BUDGET["start"] > _LLM_WINDOW:
        _LLM_BUDGET["start"] = now
        _LLM_BUDGET["count"] = 0
    if _LLM_BUDGET["count"] >= _LLM_LIMIT:
        return False
    _LLM_BUDGET["count"] += 1
    return True


from ...utils.cache_utils import SimpleCache as _SC

_llm_vote_cache = _SC(ttl_seconds=86400)


def _needs_llm(
    top: List[Dict[str, Any]], threshold: float, margin: float = 0.03
) -> bool:
    try:
        if not top or len(top) < 2:
            return False
        s1 = float(top[0].get("score", 0) or 0)
        s2 = float(top[1].get("score", 0) or 0)
        if not (0.55 <= s1 < float(threshold)):
            return False
        if (s1 - s2) > margin:
            return False
        return True
    except Exception:
        return False


def _getv(obj: Dict[str, Any], *, keys: List[str], fuzzy: bool = True) -> str:
    for k in keys:
        v = obj.get(k)
        if v not in (None, ""):
            return str(v)
    if not fuzzy:
        return ""
    # fuzzy scan
    for k in obj.keys():
        low = str(k or "").lower()
        if any(s in low for s in ("domain", "website", "url")):
            v = obj.get(k)
            if v not in (None, ""):
                return str(v)
    for k in obj.keys():
        low = str(k or "").lower()
        if ("account" in low and "name" in low) or ("company" in low) or low == "name":
            v = obj.get(k)
            if v not in (None, ""):
                return str(v)
    return ""


def _canon_record(rec: Dict[str, Any]) -> Dict[str, Any]:
    email = _getv(rec, keys=["Email"], fuzzy=True)
    email_d = _getv(rec, keys=["EmailDomain"], fuzzy=True)
    website = _getv(rec, keys=["Website"], fuzzy=True)
    domain = _getv(rec, keys=["Domain"], fuzzy=True)
    company = _getv(
        rec, keys=["Company", "Account Name", "Name", "Company Name"], fuzzy=True
    )
    return {
        "Email": email,
        "EmailDomain": email_d,
        "Website": website,
        "Domain": domain,
        "Company": company,
        "Name": _getv(rec, keys=["Name", "Account Name", "Company"], fuzzy=True),
    }


class CandidatesCacheRequest(BaseModel):
    reference: Dict[str, Any]  # expects { headers: [...], rows: [...] }


@router.post("/match/candidates/cache")
async def cache_candidates(
    body: CandidatesCacheRequest, api_ctx=Depends(require_api_key)
):
    """Cache reference candidates and return a cache_id. Adds simple per-account LRU (max 3)."""
    ref = body.reference or {}
    headers = ref.get("headers") or []
    rows = ref.get("rows") or []
    if not isinstance(headers, list) or not isinstance(rows, list):
        raise HTTPException(400, "Invalid reference payload")
    if len(rows) > 50000:
        raise HTTPException(413, "Reference too large for cache")

    # Preindex by root domain for faster narrowing in batch calls
    by_domain: dict[str, list[int]] = {}
    MAX_PER_DOMAIN = 200
    MAX_B2C_BUCKET = 500
    try:
        hidx = {h: i for i, h in enumerate(headers)}
    except Exception:
        hidx = {}
    for i, row in enumerate(rows):
        try:
            dom_val = None
            for key in ("Email", "EmailDomain", "Domain", "Website"):
                j = hidx.get(key)
                if j is not None and j < len(row):
                    dom_val = row[j]
                    break
            d = _root_domain(str(dom_val or ""))
        except Exception:
            d = ""
        if d:
            if d in B2C_TOP:
                bucket = by_domain.setdefault("__b2c__", [])
                if len(bucket) < MAX_B2C_BUCKET:
                    bucket.append(i)
            else:
                bucket = by_domain.setdefault(d, [])
                if len(bucket) < MAX_PER_DOMAIN:
                    bucket.append(i)

    # Stable, order-insensitive cache id: account + headers + count + rows fingerprint
    import hashlib
    import json

    fp = __rows_fingerprint__(headers, rows)
    key_input = json.dumps(
        {
            "acct": api_ctx.account_id,
            "h": headers,
            "n": len(rows),
            "fp": fp,
        },
        separators=(",", ":"),
        ensure_ascii=False,
    ).encode("utf-8")
    cache_id = hashlib.md5(key_input).hexdigest()
    key = f"cand:{api_ctx.account_id}:{cache_id}"
    _candidates_cache.set(
        key, {"headers": headers, "rows": rows, "by_domain": by_domain}
    )

    # Per-account LRU (max 3)
    lst = _cand_keys_by_acct.setdefault(api_ctx.account_id, [])
    if key in lst:
        lst.remove(key)
    lst.append(key)
    while len(lst) > 3:
        old_key = lst.pop(0)
        try:
            if old_key in _candidates_cache._cache:  # type: ignore[attr-defined]
                del _candidates_cache._cache[old_key]  # type: ignore[attr-defined]
        except Exception:
            pass
    return {"cache_id": cache_id}


@router.post("/match", response_model=None)
async def match_endpoint(
    body: Optional[MatchRequest] = Body(None),
    api_ctx=Depends(require_api_key),
    idem_key: Optional[str] = Header(default=None, alias="Idempotency-Key"),
):
    # Many upstream probes POST to /api/v1/match with an empty body.
    # Treat empty/missing payloads as no-op to avoid 422 validation spam.
    if body is None:
        return Response(status_code=204)

    try:
        # If caller provided a cache id and inline candidates are missing, resolve from cache (scoped to account)
        if (
            body.source
            and not body.candidates
            and getattr(body, "candidates_cache_id", None)
        ):
            key = f"cand:{api_ctx.account_id}:{body.candidates_cache_id}"
            cached = _candidates_cache.get(key)
            if cached and isinstance(cached, dict):
                hdrs = [str(h or "") for h in (cached.get("headers") or [])]
                rows = cached.get("rows") or []
                try:
                    body.candidates = [
                        {hdrs[i]: row[i] for i in range(min(len(hdrs), len(row)))}
                        for row in rows
                    ]
                except Exception:
                    body.candidates = None
                # Touch TTL to refresh lifetime
                try:
                    _candidates_cache.set(key, cached)
                except Exception:
                    pass
        # Fast inline path for (source + candidates) that honors options
        have_inline = bool(body.source) and isinstance(body.candidates, list)
        if have_inline:
            # === Gateway Routing Check ===
            # Routes through MatchGateway when feature flags are enabled.
            # See: dynamic-purring-sutherland.md for migration plan.
            from ...settings import (
                V1_MATCH_USE_GATEWAY,
                V1_MATCH_GATEWAY_SHADOW_MODE,
                V1_MATCH_GATEWAY_KILL_SWITCH,
                V1_MATCH_GATEWAY_SHADOW_SAMPLE_PCT,
                V1_MATCH_GATEWAY_SHADOW_ACCOUNTS,
                V1_MATCH_GATEWAY_ROLLOUT_ACCOUNTS,
                V1_MATCH_GATEWAY_THRESHOLD,
            )

            # Check rollout allowlist - if list is non-empty, only route listed accounts
            account_in_rollout = (
                not V1_MATCH_GATEWAY_ROLLOUT_ACCOUNTS  # Empty list = all accounts
                or api_ctx.account_id in V1_MATCH_GATEWAY_ROLLOUT_ACCOUNTS
            )

            use_gateway = (
                V1_MATCH_USE_GATEWAY
                and not V1_MATCH_GATEWAY_KILL_SWITCH
                and account_in_rollout
            )
            shadow_mode = V1_MATCH_GATEWAY_SHADOW_MODE and not V1_MATCH_GATEWAY_KILL_SWITCH

            if use_gateway or shadow_mode:
                try:
                    from ...adapters.v1_gateway_adapter import V1GatewayAdapter
                    from ...adapters.v1_parity_logger import log_parity, should_shadow

                    opts = body.options or MatchOptions()
                    threshold = opts.threshold if opts.threshold != 0.65 else V1_MATCH_GATEWAY_THRESHOLD

                    # Deterministic sampling for shadow mode
                    request_id = idem_key or f"{api_ctx.account_id}:{time.time()}"
                    do_shadow = should_shadow(
                        api_ctx.account_id,
                        request_id,
                        V1_MATCH_GATEWAY_SHADOW_SAMPLE_PCT,
                        V1_MATCH_GATEWAY_SHADOW_ACCOUNTS,
                    )

                    if use_gateway and not shadow_mode:
                        # Full gateway mode - return gateway result directly
                        adapter = await V1GatewayAdapter.create()
                        gateway_result = await adapter.match(
                            source=body.source,
                            candidates=body.candidates,
                            threshold=threshold,
                            top_k=opts.max_candidates_per_source,
                            options={
                                "blocking": {
                                    "enabled": opts.blocking.enabled,
                                    "column": opts.blocking.column,
                                },
                                "b2c_filter": opts.b2c_filter,
                                "use_domain_family": opts.use_domain_family,
                            },
                        )

                        result = {
                            "matches": gateway_result.matches,
                            "best_match": gateway_result.best_match,
                            "confidence": gateway_result.confidence,
                            "explanations": gateway_result.explanations,
                            "recommendation": gateway_result.recommendation,
                            "ambiguous": gateway_result.ambiguous,
                            "margin": gateway_result.margin,
                            "llm": gateway_result.llm,
                            "applied_options": gateway_result.applied_options,
                        }
                        resp = JSONResponse(content=result)
                        resp.headers["Cache-Control"] = "no-store"
                        return resp

                    elif shadow_mode and do_shadow:
                        # Shadow mode - run gateway in parallel, log parity, return v1 result
                        gateway_start = time.time()
                        try:
                            adapter = await V1GatewayAdapter.create()
                            gateway_result = await adapter.match(
                                source=body.source,
                                candidates=body.candidates,
                                threshold=threshold,
                                top_k=opts.max_candidates_per_source,
                                options={
                                    "blocking": {
                                        "enabled": opts.blocking.enabled,
                                        "column": opts.blocking.column,
                                    },
                                    "b2c_filter": opts.b2c_filter,
                                    "use_domain_family": opts.use_domain_family,
                                },
                            )
                            gateway_score = gateway_result.confidence
                            gateway_recommendation = gateway_result.recommendation
                            gateway_latency_ms = int((time.time() - gateway_start) * 1000)
                        except Exception as gw_err:
                            # Gateway failed - continue to v1 path, don't log parity
                            import traceback as tb
                            logging.getLogger(__name__).warning(
                                "Gateway shadow failed: %s\n%s", gw_err, tb.format_exc()
                            )
                            gateway_score = None
                            gateway_recommendation = None
                            gateway_latency_ms = None
                except Exception as import_err:
                    # Adapter import failed - continue to v1 path
                    import traceback as tb
                    logging.getLogger(__name__).warning(
                        "Gateway adapter import failed: %s\n%s", import_err, tb.format_exc()
                    )
                    gateway_score = None
                    gateway_recommendation = None
                    gateway_latency_ms = None
            else:
                gateway_score = None
                gateway_recommendation = None
                gateway_latency_ms = None
            # === End Gateway Routing Check ===

            # Track v1 path timing for parity comparison
            v1_start = time.time()

            def _clean(s: Any) -> str:
                return (str(s or "").strip()).lower()

            def _root_domain(val: str) -> str:
                v = _clean(val)
                if v.startswith("http://") or v.startswith("https://"):
                    v = v.split("://", 1)[1]
                v = v.split("/", 1)[0]
                if v.startswith("www."):
                    v = v[4:]
                parts = v.split(".")
                return ".".join(parts[-2:]) if len(parts) >= 2 else v

            B2C_TOP = frozenset(
                {
                    "gmail.com",
                    "yahoo.com",
                    "hotmail.com",
                    "outlook.com",
                    "live.com",
                    "aol.com",
                    "icloud.com",
                    "me.com",
                    "msn.com",
                    "comcast.net",
                    "verizon.net",
                    "att.net",
                    "sbcglobal.net",
                    "ymail.com",
                    "proton.me",
                    "protonmail.com",
                    "zoho.com",
                    "gmx.com",
                    "mail.com",
                    "yandex.ru",
                    "qq.com",
                    "163.com",
                    "126.com",
                    "sina.com",
                    "sohu.com",
                    "naver.com",
                    "daum.net",
                    "orange.fr",
                    "wanadoo.fr",
                    "free.fr",
                    "laposte.net",
                    "web.de",
                    "t-online.de",
                    "btinternet.com",
                    "sky.com",
                    "virginmedia.com",
                    "blueyonder.co.uk",
                    "tiscali.co.uk",
                    "seznam.cz",
                    "wp.pl",
                    "o2.pl",
                    "shaw.ca",
                    "rogers.com",
                    "telus.net",
                    "bigpond.com",
                    "optusnet.com.au",
                    "bellsouth.net",
                }
            )

            def is_b2c_domain(dom: str) -> bool:
                rd = _root_domain(dom)
                return rd in B2C_TOP

            def blocker_value(
                row: Dict[str, Any], column: str, *, b2c_filter: bool
            ) -> Optional[str]:
                val = row.get(column)
                if val is None:
                    return None
                col = (column or "").lower()
                if col in ("domain", "website", "emaildomain", "email"):
                    v = str(val)
                    if col == "email" and "@" in v:
                        v = v.split("@", 1)[1]
                    rd = _root_domain(v)
                    if b2c_filter and is_b2c_domain(rd):
                        return None
                    return rd
                return _clean(val)

            def apply_blocking_if_requested(
                src: Dict[str, Any], cands: List[Dict[str, Any]]
            ) -> List[Dict[str, Any]]:
                opts = body.options or MatchOptions()
                if not (opts.blocking.enabled and opts.blocking.column):
                    return cands
                key = blocker_value(
                    src, opts.blocking.column, b2c_filter=opts.b2c_filter
                )
                if not key:
                    return cands
                return [
                    c
                    for c in cands
                    if blocker_value(
                        c, opts.blocking.column, b2c_filter=opts.b2c_filter
                    )
                    == key
                ]

            def domain_family(dom: str) -> str:
                # TODO: replace with DuckDB-backed lookup; fallback = root domain
                return _root_domain(dom)

            def clean_company(name: str) -> str:
                s = _clean(name)
                # strip common suffixes
                SUFFIXES = (
                    " inc",
                    " inc.",
                    " llc",
                    " llc.",
                    " ltd",
                    " ltd.",
                    " limited",
                    " corp",
                    " corp.",
                    " co",
                    " co.",
                    " gmbh",
                    " s.a.",
                    " s.a",
                    " srl",
                )
                for suf in SUFFIXES:
                    if s.endswith(suf):
                        s = s[: -len(suf)]
                        break
                s = s.replace("&", "and").replace(",", " ")
                return " ".join(s.split())

            async def score_one_vs_candidates(
                profile: str,
                source: Dict[str, Any],
                candidates: List[Dict[str, Any]],
                threshold: float,
                top_k: int,
                return_explanations: bool,
                explanations_mode: str,
                use_family: bool,
            ) -> Dict[str, Any]:
                # Minimal heuristic; real path should call EngineClient
                def score_pair(s, c):
                    sc = _canon_record(s)
                    cc = _canon_record(c)
                    s_name = clean_company(sc.get("Company") or sc.get("Name") or "")
                    c_name = clean_company(cc.get("Company") or cc.get("Name") or "")
                    s_dom = _root_domain(
                        sc.get("Domain")
                        or sc.get("Website")
                        or sc.get("EmailDomain")
                        or sc.get("Email")
                        or ""
                    )
                    c_dom = _root_domain(
                        cc.get("Domain")
                        or cc.get("Website")
                        or cc.get("EmailDomain")
                        or cc.get("Email")
                        or ""
                    )
                    base = 0.0
                    if s_dom and c_dom and s_dom == c_dom:
                        base += 0.7
                    if s_name and c_name and s_name == c_name:
                        base += 0.4
                    if (
                        use_family
                        and s_dom
                        and c_dom
                        and domain_family(s_dom) == domain_family(c_dom)
                    ):
                        base = max(base, 0.75)
                    return min(base, 0.99)

                scored = []
                for c in candidates:
                    sc = score_pair(source, c)
                    if sc >= threshold:
                        item = {"candidate": c, "score": sc}
                        if return_explanations:
                            # Normalize explanations using current source/candidate
                            scn = _canon_record(source)
                            ccn = _canon_record(c)
                            s_name = clean_company(
                                scn.get("Company") or scn.get("Name") or ""
                            )
                            c_name = clean_company(
                                ccn.get("Company") or ccn.get("Name") or ""
                            )
                            s_dom = _root_domain(
                                scn.get("Domain")
                                or scn.get("Website")
                                or scn.get("EmailDomain")
                                or scn.get("Email")
                                or ""
                            )
                            c_dom = _root_domain(
                                ccn.get("Domain")
                                or ccn.get("Website")
                                or ccn.get("EmailDomain")
                                or ccn.get("Email")
                                or ""
                            )
                            exps: List[Dict[str, Any]] = []
                            if not s_dom and not s_name:
                                exps.append(
                                    {
                                        "field": "record",
                                        "signal": "no_fields",
                                        "score": 0.0,
                                        "display": "No domain/company in source",
                                    }
                                )
                            if not c_dom and not c_name:
                                exps.append(
                                    {
                                        "field": "candidate",
                                        "signal": "no_fields",
                                        "score": 0.0,
                                        "display": "No domain/company in candidate",
                                    }
                                )
                            if s_dom and c_dom and s_dom == c_dom:
                                exps.append(
                                    {
                                        "field": "domain",
                                        "signal": "exact_match",
                                        "score": 1.0,
                                        "display": "Domain: exact",
                                    }
                                )
                            if s_name and c_name and s_name == c_name:
                                exps.append(
                                    {
                                        "field": "company",
                                        "signal": "clean_exact",
                                        "score": 1.0,
                                        "display": "Company: exact",
                                    }
                                )
                            if (
                                use_family
                                and s_dom
                                and c_dom
                                and domain_family(s_dom) == domain_family(c_dom)
                                and not (s_dom and c_dom and s_dom == c_dom)
                            ):
                                exps.append(
                                    {
                                        "field": "domain",
                                        "signal": "family_match",
                                        "score": 0.75,
                                        "display": "Domain: family",
                                    }
                                )
                            item["explanations"] = exps
                        scored.append(item)
                scored.sort(key=lambda x: x["score"], reverse=True)
                top = scored[:top_k]
                best = top[0]["candidate"] if top else None
                best_score = top[0]["score"] if top else 0.0
                margin = (
                    (
                        len(top) >= 2
                        and (float(top[0]["score"]) - float(top[1]["score"]))
                    )
                    if top
                    else None
                )
                out = {
                    "matches": top,
                    "best_match": best,  # null when no matches
                    "confidence": best_score,
                    "explanations": (
                        top[0].get("explanations", [])
                        if (top and return_explanations)
                        else []
                    ),
                    "recommendation": "review"
                    if best_score >= (body.options.threshold if body.options else 0.65)
                    else "no_match",
                    "ambiguous": _needs_llm(top, threshold),
                    "margin": margin,
                }

                # Optional LLM re-rank (gated)
                try:
                    ENABLE = os.getenv("ENABLE_LLM_RERANK", "0") == "1" or os.getenv("ENABLE_OLLAMA_RERANK", "0") == "1"
                    if (
                        ENABLE
                        and ollama_choose
                        and _needs_llm(top, threshold)
                        and _llm_budget_allows()
                    ):
                        # Build a small candidate list with ids
                        cands_for_llm = []
                        ids = []
                        for idx, item in enumerate(top):
                            c = item.get("candidate", {})
                            cid = str(c.get("Id") or c.get("id") or idx)
                            ids.append(cid)
                            cands_for_llm.append(
                                {
                                    "id": cid,
                                    "name": str(
                                        c.get("Account Name")
                                        or c.get("Company")
                                        or c.get("Name")
                                        or ""
                                    ),
                                    "domain": str(
                                        c.get("Domain")
                                        or c.get("Website")
                                        or c.get("EmailDomain")
                                        or ""
                                    ),
                                    "alt": c.get("AKA") or [],
                                    "upa": c.get("UltimateParent") or "",
                                }
                            )
                        sig_src = _canon_record(source)
                        sig_key = json.dumps(
                            {"src": sig_src, "ids": ids},
                            separators=(",", ":"),
                            sort_keys=True,
                        )
                        cached = _llm_vote_cache.get(sig_key)
                        vote = cached
                        if not vote:
                            vote = await ollama_choose(sig_src, cands_for_llm)
                            if vote:
                                _llm_vote_cache.set(sig_key, vote)
                        if vote and isinstance(vote.get("choice_id"), str):
                            cid = vote["choice_id"]
                            if cid in ids and cid != "NONE":
                                pick_idx = ids.index(cid)
                                new_best = top[pick_idx]["candidate"]
                                new_score = float(top[pick_idx]["score"])
                                out["best_match"] = new_best
                                out["confidence"] = new_score
                                out["llm"] = {
                                    "used": True,
                                    "choice_id": cid,
                                    "reason": vote.get("reason", ""),
                                    "overrode": True,
                                }
                            else:
                                out["llm"] = {
                                    "used": True,
                                    "choice_id": cid,
                                    "reason": vote.get("reason", ""),
                                    "overrode": False,
                                }
                except Exception:
                    pass

                return out

            # 1) Apply blocking if requested
            filtered = apply_blocking_if_requested(body.source, body.candidates)
            opts = body.options or MatchOptions()
            # 2) Score with provided threshold/top-k
            result = await score_one_vs_candidates(
                profile=body.profile,
                source=body.source,
                candidates=filtered,
                threshold=opts.threshold,
                top_k=opts.max_candidates_per_source,
                return_explanations=opts.returnExplanations,
                explanations_mode=opts.explanations_mode,
                use_family=opts.use_domain_family,
            )
            # 3) Widen-if-zero (blocking off, slightly lower threshold)
            if opts.widen_if_zero and not result.get("matches"):
                fallback_threshold = max(0.60, opts.threshold - 0.05)
                result = await score_one_vs_candidates(
                    profile=body.profile,
                    source=body.source,
                    candidates=body.candidates,
                    threshold=fallback_threshold,
                    top_k=opts.max_candidates_per_source,
                    return_explanations=opts.returnExplanations,
                    explanations_mode=opts.explanations_mode,
                    use_family=opts.use_domain_family,
                )
            # Echo back applied options for debugging in Sheets
            # Report whether the client explicitly provided a threshold
            client_set_threshold = False
            try:
                client_set_threshold = bool(
                    hasattr(body, "options")
                    and hasattr(body.options, "model_fields_set")
                    and ("threshold" in body.options.model_fields_set)
                )
            except Exception:
                client_set_threshold = False
            result["applied_options"] = {
                "threshold": opts.threshold,
                "blocking": {
                    "enabled": opts.blocking.enabled,
                    "column": opts.blocking.column,
                },
                "b2c_filter": opts.b2c_filter,
                "use_domain_family": opts.use_domain_family,
                "threshold_defaulted": (not client_set_threshold),
            }

            # === Parity Logging for Shadow Mode ===
            if shadow_mode and gateway_score is not None:
                try:
                    v1_latency_ms = int((time.time() - v1_start) * 1000)
                    log_parity(
                        account_id=api_ctx.account_id,
                        request_id=request_id,
                        source=body.source,
                        candidate_count=len(body.candidates or []),
                        v1_score=result.get("confidence", 0.0),
                        v1_recommendation=result.get("recommendation", "no_match"),
                        v1_latency_ms=v1_latency_ms,
                        gateway_score=gateway_score,
                        gateway_recommendation=gateway_recommendation,
                        gateway_latency_ms=gateway_latency_ms or 0,
                    )
                except Exception:
                    pass  # Don't fail the request on parity logging errors
            # === End Parity Logging ===

            # Prevent caching preview responses
            resp = JSONResponse(content=result)
            resp.headers["Cache-Control"] = "no-store"
            return resp

        # Default path (URLs/keys/other): defer to service
        svc = PublicMatchService(api_ctx)
        # Idempotency cache only for async acceptance
        cached = await idem_store.get(api_ctx.api_key_prefix, idem_key)
        if cached:
            return cached
        resp = await svc.route_match(body, idem_key)
        if isinstance(resp, dict) and resp.get("status") == "processing" and idem_key:
            await idem_store.set(api_ctx.api_key_prefix, idem_key, resp)
        return resp
    except Exception as e:
        if os.getenv("ENV", "development") != "production":
            return JSONResponse(
                {"ok": False, "error": str(e), "trace": traceback.format_exc()},
                status_code=500,
            )
        raise


class FeedbackBody(BaseModel):
    decision_id: Optional[str] = None  # opaque id from sync response
    job_id: Optional[str] = None
    match_id: Optional[str] = None
    outcome: str = Field(..., pattern=r"^(accepted|reverted)$")
    corrected_target: Optional[str] = None
    profile: Optional[str] = None


@router.post("/feedback")
async def submit_feedback(body: FeedbackBody, api_ctx=Depends(require_api_key)):
    # Validate presence
    if not body.decision_id and not (body.job_id and body.match_id):
        raise HTTPException(
            status_code=400,
            detail={
                "error": "INVALID_SCHEMA",
                "message": "Provide decision_id or job_id+match_id",
            },
        )

    # Log into DecisionLogger
    try:
        from ...intelligence.decision_logger import DecisionLogger
    except Exception as e:
        raise HTTPException(
            status_code=500,
            detail={
                "error": "INTERNAL",
                "message": f"Decision logger unavailable: {e}",
            },
        )

    logger = DecisionLogger()
    user_id = f"api:{api_ctx.api_key_prefix}"

    await logger.log_feedback(
        job_id=body.job_id or body.decision_id or "",
        match_id=body.match_id or body.decision_id or "",
        outcome=body.outcome,
        user_id=user_id,
        corrected_target=body.corrected_target,
    )
    return {"ok": True}


@router.get("/jobs/{job_id}")
async def get_job_status(job_id: str):
    # Use existing Job model via repository
    from ...db import get_session
    from ...repositories.job_repo import JobRepository

    async def _inner():
        async for (
            session
        ) in get_session():  # runtime dependency without FastAPI Depends
            repo = JobRepository(session)
            job = await repo.get_job(job_id)
            if not job:
                raise HTTPException(
                    status_code=404,
                    detail={"error": "NOT_FOUND", "message": "Job not found"},
                )
            summary = job.results or job.config or {}
            status = str(
                job.status.value if hasattr(job.status, "value") else job.status
            )
            out: Dict[str, Any] = {
                "status": status,
                "pct_complete": summary.get("pct_complete", 0),
                "summary": summary,
            }
            if job.results_path:
                out["results_url"] = job.results_path
            elif job.results:
                out["results"] = job.results
            return out

    return await _inner()


# --------------------- Batch preview endpoint ---------------------
router_preview = APIRouter(prefix="/api/v1/match", tags=["match"])


@router_preview.post("/preview")
async def preview_batch(
    payload: Dict[str, Any],
    api_ctx=Depends(require_api_key),
    idem_key: Optional[str] = Header(default=None, alias="Idempotency-Key"),
):
    """
    Batch preview: accepts many source rows and a single reference set.
    Body:
      {
        "source": {"headers":[...], "rows":[...]},
        "reference": {"headers":[...], "rows":[...]},
        "options": { threshold, max_candidates_per_source, blocking:{...},
                      b2c_filter, use_domain_family, returnExplanations, explanations_mode }
      }
    Returns:
      { "preview":[{source_index, top:[{ref_index, score, reasons}], best_match, reasons_no_match? }...],
        "applied_options": {...} }
    """
    body = payload or {}
    src = body.get("source") or {}
    ref = body.get("reference") or {}
    opts_in = body.get("options") or {}

    class _Opts:
        threshold: float = float(opts_in.get("threshold", 0.65))
        maxk: int = int(opts_in.get("max_candidates_per_source", 3))
        blocking: Dict[str, Any] = opts_in.get("blocking") or {}
        block_enabled: bool = bool(blocking.get("enabled", False))
        block_col: Optional[str] = blocking.get("column") or blocking.get("suggested")
        b2c_filter: bool = bool(opts_in.get("b2c_filter", False))
        use_family: bool = bool(opts_in.get("use_domain_family", False))
        returnExplanations: bool = bool(opts_in.get("returnExplanations", True))
        explanations_mode: str = str(opts_in.get("explanations_mode", "brief"))
        include_best_only: bool = bool(opts_in.get("include_best_only", False))

    def _root_domain(v: str) -> str:
        v = str(v or "").strip().lower()
        if not v:
            return ""
        if "@" in v:
            v = v.split("@", 1)[1]
        if v.startswith("http://") or v.startswith("https://"):
            v = v.split("://", 1)[1]
        if v.startswith("www."):
            v = v[4:]
        v = v.split("/", 1)[0]
        parts = v.split(".")
        return ".".join(parts[-2:]) if len(parts) >= 2 else v

    B2C_TOP = frozenset(
        {
            "gmail.com",
            "yahoo.com",
            "hotmail.com",
            "outlook.com",
            "live.com",
            "aol.com",
            "icloud.com",
            "me.com",
            "msn.com",
            "comcast.net",
            "verizon.net",
            "att.net",
            "sbcglobal.net",
            "ymail.com",
            "proton.me",
            "protonmail.com",
            "zoho.com",
            "gmx.com",
            "mail.com",
        }
    )

    def is_b2c(dom: str) -> bool:
        return _root_domain(dom) in B2C_TOP

    def blocker_value(row: Dict[str, Any], col: Optional[str]) -> Optional[str]:
        if not col:
            return None
        # try different casings
        v = row.get(col) or row.get(col.title()) or row.get(col.capitalize())
        if v is None:
            return None
        c = col.lower()
        s = str(v)
        if c == "email" and "@" in s:
            s = s.split("@", 1)[1]
        rd = _root_domain(s)
        if _Opts.b2c_filter and is_b2c(rd):
            return None
        return (
            rd
            if c in ("domain", "website", "emaildomain", "email")
            else str(v).strip().lower()
        )

    def apply_blocking_if_requested(
        s: Dict[str, Any], candidates: List[Dict[str, Any]]
    ) -> List[Dict[str, Any]]:
        if not (_Opts.block_enabled and _Opts.block_col):
            return candidates
        key = blocker_value(s, _Opts.block_col)
        if not key:
            return candidates
        return [c for c in candidates if blocker_value(c, _Opts.block_col) == key]

    def clean_company(name: str) -> str:
        s = str(name or "").strip().lower()
        for suf in (
            " inc",
            " inc.",
            " llc",
            " llc.",
            " ltd",
            " ltd.",
            " limited",
            " corp",
            " corp.",
            " co",
            " co.",
            " gmbh",
            " s.a.",
            " s.a",
            " srl",
        ):
            if s.endswith(suf):
                s = s[: -len(suf)]
        s = s.replace("&", "and").replace(",", " ")
        return " ".join(s.split())

    def domain_family(dom: str) -> str:
        return _root_domain(dom)

    def score_pair(
        srow: Dict[str, Any], crow: Dict[str, Any]
    ) -> Tuple[float, List[str]]:
        reasons: List[str] = []
        s_dom = _root_domain(
            srow.get("Domain")
            or srow.get("Website")
            or srow.get("EmailDomain")
            or srow.get("Email")
            or ""
        )
        c_dom = _root_domain(
            crow.get("Domain")
            or crow.get("Website")
            or crow.get("EmailDomain")
            or crow.get("Email")
            or ""
        )
        s_name = clean_company(srow.get("Company") or srow.get("Name") or "")
        c_name = clean_company(
            crow.get("Name") or crow.get("Account Name") or crow.get("Company") or ""
        )
        score = 0.0
        if s_dom and c_dom and s_dom == c_dom:
            score += 0.70
            reasons.append("domain_exact")
        if s_name and c_name and s_name == c_name:
            score += 0.40
            reasons.append("company_clean_1.00")
        if (
            _Opts.use_family
            and s_dom
            and c_dom
            and domain_family(s_dom) == domain_family(c_dom)
        ):
            score = max(score, 0.75)
            reasons.append("domain_family_boost")
        return min(score, 0.99), reasons[:5]

    # Build reference objects
    ref_headers = [str(h or "") for h in (ref.get("headers") or [])]
    ref_rows: List[List[Any]] = ref.get("rows") or []
    # Guardrail: keep preview payloads predictable for Sheets clients
    try:
        if len(ref_rows) > 10_000:
            msg = {
                "ok": False,
                "error": "REFERENCE_TOO_LARGE",
                "message": "Reference too large for Sheets preview. Narrow your selection or use Desktop/Web.",
                "limit": 10000,
                "received": len(ref_rows),
            }
            resp = JSONResponse(content=msg, status_code=413)
            resp.headers["Cache-Control"] = "no-store"
            return resp
    except Exception:
        pass
    ref_objs: List[Dict[str, Any]] = [dict(zip(ref_headers, r)) for r in ref_rows]

    # Pick identifier and display name columns once for reference
    def _pick_id_and_type(cols: List[str]) -> Tuple[Optional[str], Optional[str]]:
        L = [str(c or "").lower() for c in cols]
        for c in ("id", "accountid", "contactid", "leadid"):
            if c in L:
                t = (
                    "Account"
                    if c == "accountid"
                    else "Contact"
                    if c == "contactid"
                    else "Lead"
                    if c == "leadid"
                    else None
                )
                return cols[L.index(c)], t
        for c in ("account id", "contact id", "lead id"):
            if c in L:
                t = (
                    "Account"
                    if "account" in c
                    else "Contact"
                    if "contact" in c
                    else "Lead"
                    if "lead" in c
                    else None
                )
                return cols[L.index(c)], t
        return None, None

    def _pick_display_name(cols: List[str]) -> Optional[str]:
        L = [str(c or "").lower() for c in cols]
        for c in ("name", "account name", "company"):
            if c in L:
                return cols[L.index(c)]
        return cols[0] if cols else None

    REF_ID_COL, REF_TYPE_DEFAULT = _pick_id_and_type(ref_headers)
    REF_NAME_COL = _pick_display_name(ref_headers)

    src_headers = [str(h or "") for h in (src.get("headers") or [])]
    src_rows: List[List[Any]] = src.get("rows") or []
    # Source size guardrail
    MAX_SRC_PREVIEW = 200
    try:
        if len(src_rows) > MAX_SRC_PREVIEW:
            msg2 = {
                "ok": False,
                "error": "SOURCE_TOO_LARGE",
                "message": f"Source too large for Sheets preview (max {MAX_SRC_PREVIEW} rows). Narrow your selection or run the full job.",
                "limit": MAX_SRC_PREVIEW,
                "received": len(src_rows),
            }
            resp2 = JSONResponse(content=msg2, status_code=413)
            resp2.headers["Cache-Control"] = "no-store"
            return resp2
    except Exception:
        pass

    # Precompute blocker collisions across reference for guardrail warnings
    ref_block_counts: Dict[str, int] = {}
    if _Opts.block_enabled and _Opts.block_col:
        try:
            for ro in ref_objs:
                k = blocker_value(ro, _Opts.block_col)
                if k:
                    ref_block_counts[k] = ref_block_counts.get(k, 0) + 1
        except Exception:
            ref_block_counts = {}

    preview: List[Dict[str, Any]] = []
    for i, r in enumerate(src_rows):
        s_obj = dict(zip(src_headers, r))
        cand_objs = apply_blocking_if_requested(s_obj, ref_objs)
        scored = []
        for j, cand in enumerate(cand_objs):
            sc, rs = score_pair(s_obj, cand)
            if sc >= _Opts.threshold or _Opts.explanations_mode == "brief":
                ref_id = str(cand.get(REF_ID_COL) or "") if REF_ID_COL else ""
                ref_type = REF_TYPE_DEFAULT or (
                    "Account" if cand.get("Account Name") else None
                )
                cand_name = str(cand.get(REF_NAME_COL) or "") if REF_NAME_COL else ""
                scored.append(
                    {
                        "ref_index": j,
                        "ref_id": ref_id,
                        "ref_type": ref_type,
                        "candidate_name": cand_name,
                        "score": sc,
                        "reasons": rs,
                    }
                )
        scored.sort(key=lambda x: x["score"], reverse=True)
        top = scored[: _Opts.maxk]
        result: Dict[str, Any] = {
            "source_index": i,
            "best_match": (top[0] if top else None),
        }
        if not _Opts.include_best_only:
            result["top"] = top
        if not top:
            why: List[str] = []
            s_dom = _root_domain(
                s_obj.get("Domain")
                or s_obj.get("Website")
                or s_obj.get("EmailDomain")
                or s_obj.get("Email")
                or ""
            )
            if not s_dom:
                why.append("no_domain_present")
            elif _Opts.b2c_filter and is_b2c(s_dom):
                why.append("domain_b2c_filtered")
            s_name = clean_company(s_obj.get("Company") or s_obj.get("Name") or "")
            if not s_name:
                why.append("no_company_name")
            if not why:
                why.append("no_candidate_above_threshold")
            result["reasons_no_match"] = why
        # Warn when multiple CRM records tie on blocker (quality guardrail)
        try:
            if _Opts.block_enabled and _Opts.block_col:
                key = blocker_value(s_obj, _Opts.block_col)
                if key and ref_block_counts.get(key, 0) > 1:
                    result.setdefault("warnings", []).append("multiple_block_key_ties")
        except Exception:
            pass
        preview.append(result)

    result_out = {
        "preview": preview,
        "applied_options": {
            "threshold": _Opts.threshold,
            "blocking": {"enabled": _Opts.block_enabled, "column": _Opts.block_col},
            "b2c_filter": _Opts.b2c_filter,
            "use_domain_family": _Opts.use_family,
        },
    }
    resp = JSONResponse(content=result_out)
    resp.headers["Cache-Control"] = "no-store"
    return resp


@router_preview.post("/batch")
async def batch_match(
    payload: Dict[str, Any],
    api_ctx=Depends(require_api_key),
    idem_key: Optional[str] = Header(default=None, alias="Idempotency-Key"),
):
    """
    Batch match: score many sources against one candidate set in one call.
    Body supports either:
      { "sources": [{...}, {...}], "candidates": [{...}], "profile":"balanced", "options": {...} }
    or with cached reference:
      { "sources": [{...}], "candidates_cache_id": "abc123", "profile":..., "options": {...} }
    Returns { "results": [ {best_match, confidence, explanations, recommendation}, ... ], "applied_options": {...} }
    """
    body = payload or {}
    profile = str(body.get("profile") or "balanced")
    options_in = body.get("options") or {}

    class _Opts:
        threshold: float = float(options_in.get("threshold", 0.65))
        maxk: int = int(options_in.get("max_candidates_per_source", 3))
        b2c_filter: bool = bool(options_in.get("b2c_filter", False))
        use_family: bool = bool(options_in.get("use_domain_family", False))
        returnExplanations: bool = bool(options_in.get("returnExplanations", True))
        explanations_mode: str = str(options_in.get("explanations_mode", "brief"))

    # Resolve sources
    raw_sources = body.get("sources") or []
    if not isinstance(raw_sources, list):
        raise HTTPException(400, "sources must be an array of objects")
    sources: List[Dict[str, Any]] = [s for s in raw_sources if isinstance(s, dict)]
    if not sources:
        return JSONResponse(
            {"results": [], "applied_options": {"threshold": _Opts.threshold}},
            headers={"Cache-Control": "no-store"},
        )

    # Resolve candidates list (and touch cache to refresh TTL when used)
    cand_objs: List[Dict[str, Any]] = []
    cand_cache_used = False
    cand_cache_id: Optional[str] = None
    cand_rows_for_index: List[List[Any]] = []
    cand_headers_for_index: List[str] = []
    cand_by_domain: Dict[str, List[int]] = {}
    if isinstance(body.get("candidates"), list):
        cand_objs = [c for c in (body.get("candidates") or []) if isinstance(c, dict)]
    elif body.get("candidates_cache_id"):
        cand_cache_id = str(body.get("candidates_cache_id"))
        cached = _candidates_cache.get(f"cand:{api_ctx.account_id}:{cand_cache_id}")
        if cached and isinstance(cached, dict):
            hdrs = [str(h or "") for h in (cached.get("headers") or [])]
            rows = cached.get("rows") or []
            _candidates_cache.set(
                f"cand:{api_ctx.account_id}:{cand_cache_id}", cached
            )  # touch
            cand_objs = [
                {hdrs[i]: row[i] for i in range(min(len(hdrs), len(row)))}
                for row in rows
            ]
            cand_rows_for_index = rows
            cand_headers_for_index = hdrs
            cand_by_domain = cached.get("by_domain") or {}
            cand_cache_used = True
    if not cand_objs:
        raise HTTPException(400, "Missing candidates or candidates_cache_id")

    # === Gateway Routing Check for Batch ===
    from ...settings import (
        V1_MATCH_USE_GATEWAY,
        V1_MATCH_GATEWAY_SHADOW_MODE,
        V1_MATCH_GATEWAY_KILL_SWITCH,
        V1_MATCH_GATEWAY_ROLLOUT_ACCOUNTS,
        V1_MATCH_GATEWAY_SHADOW_SAMPLE_PCT,
        V1_MATCH_GATEWAY_SHADOW_ACCOUNTS,
        V1_MATCH_GATEWAY_THRESHOLD,
    )

    # Check rollout allowlist - if list is non-empty, only route listed accounts
    account_in_rollout = (
        not V1_MATCH_GATEWAY_ROLLOUT_ACCOUNTS  # Empty list = all accounts
        or api_ctx.account_id in V1_MATCH_GATEWAY_ROLLOUT_ACCOUNTS
    )

    use_gateway = (
        V1_MATCH_USE_GATEWAY
        and not V1_MATCH_GATEWAY_KILL_SWITCH
        and account_in_rollout
    )
    shadow_mode = V1_MATCH_GATEWAY_SHADOW_MODE and not V1_MATCH_GATEWAY_KILL_SWITCH

    # Shadow mode variables for parity logging
    gateway_results_for_parity = None
    gateway_latency_ms = None

    if use_gateway or shadow_mode:
        try:
            from ...adapters.v1_gateway_adapter import V1GatewayAdapter
            from ...adapters.v1_parity_logger import should_shadow

            # Deterministic sampling for shadow mode
            request_id = idem_key or f"{api_ctx.account_id}:batch:{time.time()}"
            do_shadow = should_shadow(
                api_ctx.account_id,
                request_id,
                V1_MATCH_GATEWAY_SHADOW_SAMPLE_PCT,
                V1_MATCH_GATEWAY_SHADOW_ACCOUNTS,
            )

            # Only run gateway if full mode or sampled for shadow
            if use_gateway or (shadow_mode and do_shadow):
                gateway_start = time.time()
                adapter = await V1GatewayAdapter.create()
                threshold = (
                    _Opts.threshold if _Opts.threshold != 0.65 else V1_MATCH_GATEWAY_THRESHOLD
                )
                gateway_results = await adapter.match_batch(
                    sources=sources,
                    candidates=cand_objs,
                    threshold=threshold,
                    top_k=_Opts.maxk,
                    options={
                        "b2c_filter": _Opts.b2c_filter,
                        "use_domain_family": _Opts.use_family,
                    },
                )
                gateway_latency_ms = int((time.time() - gateway_start) * 1000)

                if use_gateway and not shadow_mode:
                    # Full gateway mode - return gateway result directly
                    results = []
                    for gr in gateway_results:
                        results.append({
                            "best_match": gr.best_match,
                            "confidence": gr.confidence,
                            "explanations": gr.explanations,
                            "recommendation": gr.recommendation,
                            "ambiguous": gr.ambiguous,
                            "margin": gr.margin,
                            "llm": gr.llm,
                        })

                    out = {
                        "results": results,
                        "applied_options": {
                            "threshold": threshold,
                            "b2c_filter": _Opts.b2c_filter,
                            "use_domain_family": _Opts.use_family,
                            "gateway_routed": True,
                        },
                    }
                    resp = JSONResponse(content=out)
                    resp.headers["Cache-Control"] = "no-store"
                    return resp
                else:
                    # Shadow mode - save for parity logging after v1 runs
                    gateway_results_for_parity = gateway_results
        except Exception as e:
            # Gateway failed - fall through to v1 path
            import traceback as tb
            logging.getLogger(__name__).warning(
                "Gateway batch failed, falling back to v1: %s\n%s", e, tb.format_exc()
            )
    # === End Gateway Routing Check ===

    # Track v1 path timing for parity comparison
    v1_start = time.time()

    # Helpers (dup minimal logic from inline/preview)
    def _root_domain(v: str) -> str:
        v = str(v or "").strip().lower()
        if not v:
            return ""
        if "@" in v:
            v = v.split("@", 1)[1]
        if v.startswith("http://") or v.startswith("https://"):
            v = v.split("://", 1)[1]
        if v.startswith("www."):
            v = v[4:]
        v = v.split("/", 1)[0]
        parts = v.split(".")
        return ".".join(parts[-2:]) if len(parts) >= 2 else v

    B2C_TOP = frozenset(
        {
            "gmail.com",
            "yahoo.com",
            "hotmail.com",
            "outlook.com",
            "live.com",
            "aol.com",
            "icloud.com",
            "me.com",
            "msn.com",
            "comcast.net",
            "verizon.net",
            "att.net",
            "sbcglobal.net",
            "ymail.com",
            "proton.me",
            "protonmail.com",
            "zoho.com",
            "gmx.com",
            "mail.com",
        }
    )

    def is_b2c(dom: str) -> bool:
        return _root_domain(dom) in B2C_TOP

    def clean_company(name: str) -> str:
        s = str(name or "").strip().lower()
        for suf in (
            " inc",
            " inc.",
            " llc",
            " llc.",
            " ltd",
            " ltd.",
            " limited",
            " corp",
            " corp.",
            " co",
            " co.",
        ):
            if s.endswith(suf):
                s = s[: -len(suf)]
        s = s.replace("&", "and").replace(",", " ")
        return " ".join(s.split())

    def domain_family(dom: str) -> str:
        return _root_domain(dom)

    def score_pair(
        srow: Dict[str, Any], crow: Dict[str, Any]
    ) -> tuple[float, List[Dict[str, Any]]]:
        reasons: List[Dict[str, Any]] = []
        sc = _canon_record(srow)
        cc = _canon_record(crow)
        s_dom = _root_domain(
            sc.get("Domain")
            or sc.get("Website")
            or sc.get("EmailDomain")
            or sc.get("Email")
            or ""
        )
        c_dom = _root_domain(
            cc.get("Domain")
            or cc.get("Website")
            or cc.get("EmailDomain")
            or cc.get("Email")
            or ""
        )
        s_name = clean_company(sc.get("Company") or sc.get("Name") or "")
        c_name = clean_company(cc.get("Company") or cc.get("Name") or "")
        score = 0.0
        if s_dom and c_dom and s_dom == c_dom:
            score += 0.70
            reasons.append(
                {
                    "field": "domain",
                    "signal": "exact_match",
                    "score": 1.0,
                    "display": "Domain: exact",
                }
            )
        if s_name and c_name and s_name == c_name:
            score += 0.40
            reasons.append(
                {
                    "field": "company",
                    "signal": "clean_exact",
                    "score": 1.0,
                    "display": "Company: exact",
                }
            )
        if (
            _Opts.use_family
            and s_dom
            and c_dom
            and domain_family(s_dom) == domain_family(c_dom)
            and not (s_dom and c_dom and s_dom == c_dom)
        ):
            score = max(score, 0.75)
            reasons.append(
                {
                    "field": "domain",
                    "signal": "family_match",
                    "score": 0.75,
                    "display": "Domain: family",
                }
            )
        if not s_dom and not s_name:
            reasons.append(
                {
                    "field": "record",
                    "signal": "no_fields",
                    "score": 0.0,
                    "display": "No domain/company in source",
                }
            )
        if not c_dom and not c_name:
            reasons.append(
                {
                    "field": "candidate",
                    "signal": "no_fields",
                    "score": 0.0,
                    "display": "No domain/company in candidate",
                }
            )
        return min(score, 0.99), reasons[:5]

    # Idempotency memo for batch requests
    import hashlib
    import json

    sig = hashlib.sha256(
        json.dumps(
            {
                "acct": api_ctx.account_id,
                "sources": sources,
                "cid": cand_cache_id,
                "opts": options_in,
                "profile": profile,
            },
            separators=(",", ":"),
            ensure_ascii=False,
        ).encode("utf-8")
    ).hexdigest()
    if idem_key:
        cached_resp = await idem_store.get(api_ctx.api_key_prefix, idem_key)
        if (
            cached_resp
            and isinstance(cached_resp, dict)
            and cached_resp.get("_sig") == sig
        ):
            return JSONResponse(cached_resp, headers={"Cache-Control": "no-store"})

    results: List[Dict[str, Any]] = []
    for s in sources:
        # Optional B2C filter pre-check
        try:
            dom = _root_domain(
                s.get("Email")
                or s.get("EmailDomain")
                or s.get("Domain")
                or s.get("Website")
                or ""
            )
            if _Opts.b2c_filter and is_b2c(dom):
                results.append(
                    {
                        "best_match": None,
                        "confidence": 0.0,
                        "explanations": [],
                        "recommendation": "no_match",
                    }
                )
                continue
        except Exception:
            pass
        # Narrow by domain index when available
        narrowed = cand_objs
        try:
            if cand_cache_used and cand_by_domain:
                idxs = cand_by_domain.get(dom) or cand_by_domain.get("__b2c__") or []
                if idxs:
                    narrowed = [
                        {
                            cand_headers_for_index[k]: cand_rows_for_index[i][k]
                            if k < len(cand_rows_for_index[i])
                            else None
                            for k in range(len(cand_headers_for_index))
                        }
                        for i in idxs
                    ]
        except Exception:
            narrowed = cand_objs
        top: List[Dict[str, Any]] = []
        for idx_c, c in enumerate(narrowed):
            sc, rs = score_pair(s, c)
            if sc >= _Opts.threshold:
                item = {"candidate": c, "score": sc}
                if _Opts.returnExplanations:
                    item["explanations"] = rs if isinstance(rs, list) else []
                top.append(item)
        top.sort(key=lambda x: x["score"], reverse=True)
        best = top[0] if top else None

        # Optional LLM rerank (gated)
        llm_info = None
        try:
            ENABLE = os.getenv("ENABLE_LLM_RERANK", "0") == "1" or os.getenv("ENABLE_OLLAMA_RERANK", "0") == "1"
            if (
                ENABLE
                and ollama_choose
                and _needs_llm(top, _Opts.threshold)
                and _llm_budget_allows()
            ):
                ids: List[str] = []
                cands_for_llm: List[Dict[str, Any]] = []
                for i, item in enumerate(top):
                    c0 = item.get("candidate", {})
                    cid = str(c0.get("Id") or c0.get("id") or i)
                    ids.append(cid)
                    cands_for_llm.append(
                        {
                            "id": cid,
                            "name": str(
                                c0.get("Account Name")
                                or c0.get("Company")
                                or c0.get("Name")
                                or ""
                            ),
                            "domain": str(
                                c0.get("Domain")
                                or c0.get("Website")
                                or c0.get("EmailDomain")
                                or ""
                            ),
                            "alt": c0.get("AKA") or [],
                            "upa": c0.get("UltimateParent") or "",
                        }
                    )
                sig_src = _canon_record(s)
                sig_key = json.dumps(
                    {"src": sig_src, "ids": ids}, separators=(",", ":"), sort_keys=True
                )
                vote = _llm_vote_cache.get(sig_key)
                if not vote:
                    vote = await ollama_choose(sig_src, cands_for_llm)
                    if vote:
                        _llm_vote_cache.set(sig_key, vote)
                if vote and isinstance(vote.get("choice_id"), str):
                    cid = vote["choice_id"]
                    if cid in ids and cid != "NONE":
                        over = ids.index(cid) != 0
                        best = top[ids.index(cid)]
                        llm_info = {
                            "used": True,
                            "choice_id": cid,
                            "reason": vote.get("reason", ""),
                            "overrode": over,
                        }
                    else:
                        llm_info = {
                            "used": True,
                            "choice_id": cid,
                            "reason": vote.get("reason", ""),
                            "overrode": False,
                        }
        except Exception:
            pass

        margin = (
            (len(top) >= 2 and (float(top[0]["score"]) - float(top[1]["score"])))
            if top
            else None
        )
        results.append(
            {
                "best_match": (best and best.get("candidate")) or None,
                "confidence": (best and best.get("score")) or 0.0,
                "explanations": (best and best.get("explanations", [])) or [],
                "recommendation": "review"
                if (best and (best.get("score", 0) >= _Opts.threshold))
                else "no_match",
                "ambiguous": _needs_llm(top, _Opts.threshold),
                "margin": margin,
                "llm": llm_info or {"used": False},
            }
        )

    out = {
        "results": results,
        "applied_options": {
            "threshold": _Opts.threshold,
            "b2c_filter": _Opts.b2c_filter,
            "use_domain_family": _Opts.use_family,
        },
    }
    out["_sig"] = sig

    # === Batch Shadow Parity Logging ===
    if gateway_results_for_parity and shadow_mode:
        try:
            from ...adapters.v1_parity_logger import log_parity

            v1_latency_ms = int((time.time() - v1_start) * 1000) if "v1_start" in dir() else 0
            request_id = idem_key or f"{api_ctx.account_id}:batch:{time.time()}"

            # Log parity for each source/result pair (sample first 10 to avoid log explosion)
            max_parity_logs = 10
            for i, (v1_result, gw_result) in enumerate(
                zip(results[:max_parity_logs], gateway_results_for_parity[:max_parity_logs])
            ):
                try:
                    log_parity(
                        account_id=api_ctx.account_id,
                        request_id=f"{request_id}:batch:{i}",
                        source=sources[i] if i < len(sources) else {},
                        candidate_count=len(cand_objs),
                        v1_score=v1_result.get("confidence", 0.0),
                        v1_recommendation=v1_result.get("recommendation", "no_match"),
                        v1_latency_ms=v1_latency_ms,
                        gateway_score=gw_result.confidence,
                        gateway_recommendation=gw_result.recommendation,
                        gateway_latency_ms=gateway_latency_ms or 0,
                    )
                except Exception:
                    pass
        except Exception:
            pass
    # === End Batch Shadow Parity Logging ===

    resp = JSONResponse(content=out)
    resp.headers["Cache-Control"] = "no-store"
    if idem_key:
        try:
            await idem_store.set(api_ctx.api_key_prefix, idem_key, out)
        except Exception:
            pass
    return resp


@router_preview.options("/batch")
def match_batch_options():
    return Response(status_code=204)


# Allow CORS preflight or simple sanity probes
@router.options("/match")
def match_options():
    return Response(status_code=204)
