from __future__ import annotations

import asyncio
import csv
import io
import hashlib
import json
import logging
import os
import re
import time
import uuid
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple, Union

import pandas as pd
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from redis.asyncio import Redis
from sqlalchemy.ext.asyncio import AsyncSession

from ...auth_security import require_account, require_user
from ...database import get_redis_client, session_context
from ...db import get_session
from ...models import Job, JobStatus, JobType
from ...repos.salesforce_schema_index_repo import SalesforceSchemaIndexRepo
from ...services.domain_utils import (
    extract_domains_vectorized,
    extract_registrable_domains,
    normalize_domain_series,
)
from ...intelligence.similarity import fuzz
from ...services.job_service import JobService, JobCreationError
from ...services.salesforce_gateway import SalesforceGateway, get_salesforce_gateway
from ...storage import get_storage_backend, resolve_object_path
from ...adapters.id_hunter_adapter import IDHunterGatewayAdapter

router = APIRouter(prefix="/api/v2/id-hunter", tags=["id-hunter"])
log = logging.getLogger(__name__)

# Allow test override via environment variable (sync org cutoff)
SYNC_ORG_THRESHOLD = int(
    os.getenv("ID_HUNTER_SYNC_ORG_THRESHOLD", os.getenv("TEST_FORCE_ASYNC", "5000"))
)
MAX_ROWS = int(os.getenv("ID_HUNTER_MAX_ROWS", "10000"))
ASYNC_ROW_THRESHOLD = max(
    1, int(os.getenv("ID_HUNTER_ASYNC_ROW_THRESHOLD", "2500"))
)
EXPORT_MAX_ROWS = max(
    1, int(os.getenv("ID_HUNTER_EXPORT_MAX_ROWS", str(MAX_ROWS)))
)
MAX_ORG_ACCOUNTS = int(os.getenv("ID_HUNTER_MAX_ORG_ACCOUNTS", "20000"))
SF_ACCOUNT_FIELDS = ["Id", "Name", "Website", "BillingCity", "BillingCountry"]
SF_ACCOUNT_DEFAULT_DOMAIN_FIELD = "Website"
ALLOWED_ADDITIONAL_FIELDS = {
    "Phone",
    "Industry",
    "Type",
    "AnnualRevenue",
    "NumberOfEmployees",
    "Description",
    "OwnerId",
    "Owner.Name",
    "Owner.IsActive",
    "ParentId",
    "LastModifiedDate",
    "BillingStreet",
    "BillingState",
    "BillingPostalCode",
    "ShippingStreet",
    "ShippingCity",
    "ShippingState",
    "ShippingCountry",
    "Fax",
    "AccountNumber",
    "Rating",
    "Ownership",
    "Sic",
    "TickerSymbol",
}
SF_ACCOUNT_COUNT_QUERY = "SELECT COUNT() FROM Account WHERE IsDeleted = false"
REFERENCE_CACHE_TTL = 3600  # seconds
DEFAULT_LOW_CONFIDENCE = 0.5
DEFAULT_REVIEW_THRESHOLD = 0.7
DEFAULT_MATCH_THRESHOLD = 0.85
EXACT_DOMAIN_NAME_LOW_CONFIDENCE = 0.25
ID_HUNTER_ESTIMATE_TIMEOUT_S = 3.0
ESTIMATED_ASYNC_DURATION_S = 120
# Upper bound to avoid loading enormous orgs into memory
ID_HUNTER_MAX_ACCOUNTS = int(
    os.getenv("ID_HUNTER_MAX_ACCOUNTS", str(MAX_ORG_ACCOUNTS))
)
# Hard timeout for reference fetch to avoid front-end 524s (sync path)
ID_HUNTER_FETCH_TIMEOUT_S = float(os.getenv("ID_HUNTER_FETCH_TIMEOUT_S", "60"))
# Longer timeout for async jobs (reference fetch)
ID_HUNTER_ASYNC_FETCH_TIMEOUT_S = float(
    os.getenv("ID_HUNTER_ASYNC_FETCH_TIMEOUT_S", "120")
)
ID_HUNTER_INPUT_CACHE_TTL = int(os.getenv("ID_HUNTER_INPUT_CACHE_TTL", "3600"))
ID_HUNTER_REPORT_CACHE_TTL = int(os.getenv("ID_HUNTER_REPORT_CACHE_TTL", "600"))
ID_HUNTER_REPORT_IDS_CACHE_TTL = int(os.getenv("ID_HUNTER_REPORT_IDS_CACHE_TTL", "900"))
ID_HUNTER_DOMAIN_PROBE_TIMEOUT_S = float(
    os.getenv("ID_HUNTER_DOMAIN_PROBE_TIMEOUT_S", "3")
)
ID_HUNTER_DOMAIN_PROBE_MAX_FIELDS = max(
    1, int(os.getenv("ID_HUNTER_DOMAIN_PROBE_MAX_FIELDS", "4"))
)
_SFDC_ID_PATTERN = re.compile(r"[A-Za-z0-9]{15,18}")
_RUN_ID_PATTERN = re.compile(r"[A-Za-z0-9:_-]{8,128}")
_SF_FIELD_API_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")

MATCH_EXPLANATIONS = {
    "domain_exact": "Website matched exactly",
    "registrable_domain": "Root domain matched",
    "email_domain": "Email domain matched",
    "name_exact": "Company name matched exactly",
    "name_fuzzy": "Company name was similar ({score}%)",
}

_INLINE_TRUE_VALUES = {"1", "true", "yes", "on"}


def _is_inline_processing_enabled() -> bool:
    value = os.getenv("FM_PROCESS_IN_APP", "0")
    return str(value).strip().lower() in _INLINE_TRUE_VALUES


def _hunter_results_to_dicts(results: List[IDHunterMatch]) -> List[Dict[str, Any]]:
    formatted: List[Dict[str, Any]] = []
    for row in results:
        if hasattr(row, "model_dump"):
            formatted.append(row.model_dump())
        elif isinstance(row, dict):
            formatted.append(row)
        else:
            try:
                formatted.append(dict(row))
            except Exception:
                formatted.append({"value": row})
    return formatted


def _persist_hunter_results(job_id: str, results: List[Dict[str, Any]]) -> str:
    object_id = f"uploads/results/{job_id}.parquet"
    path = resolve_object_path(object_id)
    Path(path).parent.mkdir(parents=True, exist_ok=True)

    df = pd.DataFrame(results)
    if df.empty:
        df = pd.DataFrame(
            columns=[
                "row_index",
                "salesforce_id",
                "confidence",
                "match_score",
                "match_method",
                "match_explanation",
                "display_status",
                "name_score",
                "matched_name",
                "matched_website",
                "record_url",
                "custom_fields",
                "alternative_match_count",
                "warnings",
                "status",
            ]
        )
    df.to_parquet(path, index=False)
    return object_id


async def _execute_inline_id_hunter_job(
    job_id: str,
    job_config: Dict[str, Any],
    account_id: str,
    redis_client: Optional[Redis],
) -> None:
    started_at = time.time()
    await _update_job_progress(
        redis_client,
        job_id,
        status="processing",
        progress=5,
        message="Starting Account Hunter job",
        extra={"rows_total": int(job_config.get("rows_total") or 0)},
    )
    try:
        results, summary, warnings = await run_id_hunter_job(
            job_id, {**(job_config or {}), "account_id": account_id}
        )
        results_list = _hunter_results_to_dicts(results)
        result_object_id = _persist_hunter_results(job_id, results_list)

        matches_found = (
            summary.get("matched", 0) if isinstance(summary, dict) else len(results_list)
        )
        elapsed_s = max(0.0, time.time() - started_at)
        await _update_job_progress(
            redis_client,
            job_id,
            status="completed",
            progress=100,
            message="Account Hunter completed",
            extra={
                "matches_found": int(matches_found),
                "processed_rows": len(results_list),
                "rows_processed": len(results_list),
                "result_rows": len(results_list),
                "result_object_id": result_object_id,
                "results_path": result_object_id,
                "results": results_list,
                "summary": summary,
                "warnings": warnings,
                "elapsed_s": elapsed_s,
            },
        )

        async with session_context() as db_session:
            rec = await db_session.get(Job, job_id)
            if rec:
                rec.status = JobStatus.COMPLETED
                rec.results_path = result_object_id
                rec.matches_found = int(matches_found)
                rec.error = None
                rec.results = {"summary": summary} if summary else None
                db_session.add(rec)
                await db_session.commit()
    except Exception as exc:
        log.exception("Inline Account Hunter job failed for %s", job_id)
        await _update_job_progress(
            redis_client,
            job_id,
            status="failed",
            progress=100,
            message=f"Account Hunter failed: {exc}",
            extra={"error": str(exc)},
        )
        try:
            async with session_context() as db_session:
                rec = await db_session.get(Job, job_id)
                if rec:
                    rec.status = JobStatus.FAILED
                    rec.error = str(exc)
                    db_session.add(rec)
                    await db_session.commit()
        except Exception:
            log.exception("Failed to persist inline Account Hunter failure for %s", job_id)


def _schedule_inline_job_execution(
    job_id: str,
    job_config: Dict[str, Any],
    account_id: str,
    redis_client: Optional[Redis],
) -> asyncio.Task:
    """Run Account Hunter in an async task on the current event loop."""
    loop = asyncio.get_running_loop()
    return loop.create_task(
        _execute_inline_id_hunter_job(job_id, job_config, account_id, redis_client),
        name=f"id_hunter_inline:{job_id}",
    )


def _escape_soql_literal(value: str) -> str:
    return str(value).replace("\\", "\\\\").replace("'", "\\'")


def _is_valid_account_id_text(value: str) -> bool:
    text = str(value or "").strip()
    return text.startswith("001") and bool(_SFDC_ID_PATTERN.fullmatch(text))


class IDHunterRow(BaseModel):
    row_index: int = Field(..., description="1-based row index in the sheet")
    values: Dict[str, Any] = Field(
        ...,
        description="Map from canonical key (e.g., 'company', 'website', 'email') to cell value",
    )


class IDHunterMappings(BaseModel):
    company_column: str = Field(..., description="Sheet header used as company name input")
    website_column: Optional[str] = Field(
        None, description="Sheet header used as website/domain input"
    )
    email_column: Optional[str] = Field(
        None, description="Sheet header used as email input"
    )


class IDHunterOptions(BaseModel):
    use_domain: bool = True
    use_fuzzy: bool = True
    low_confidence_threshold: Optional[float] = Field(
        None,
        description="Optional 0-1 threshold used to label matches as low confidence",
    )
    review_threshold: Optional[float] = Field(
        default=DEFAULT_REVIEW_THRESHOLD,
        description="Optional 0-1 threshold used to label matches as needs review",
    )
    ultimate_parents_only: bool = Field(
        default=False,
        description="If true, only match against top-level (ultimate parent) Accounts",
    )
    additional_fields: Optional[List[str]] = Field(
        default=None,
        description="Additional Salesforce Account fields to include in results",
    )
    report_id: Optional[str] = Field(
        default=None,
        description="Optional Salesforce report ID to scope Account Hunter reference set",
    )


class IDHunterRequest(BaseModel):
    salesforce_object: str = Field(
        "Account", description="For v1, must be 'Account'"
    )
    rows: List[IDHunterRow]
    mappings: IDHunterMappings
    run_id: Optional[str] = Field(
        default=None,
        description=(
            "Optional client-provided run ID. When omitted, the server generates one "
            "and returns it in the response."
        ),
        pattern=r"^[A-Za-z0-9:_-]{8,128}$",
    )
    options: IDHunterOptions = IDHunterOptions()


class IDHunterExportRequest(BaseModel):
    run_id: str = Field(
        ...,
        description="Account Hunter run ID returned by /accounts; used to fetch run-scoped input cache.",
        pattern=r"^[A-Za-z0-9:_-]{8,128}$",
    )
    row_indices: List[int]
    schema_preset: Optional[str] = Field(
        default="salesforce",
        description="CSV schema preset (salesforce | hubspot)",
    )


class IDHunterMatch(BaseModel):
    row_index: int
    salesforce_id: Optional[str]
    confidence: Optional[float]
    company_fg_id: Optional[str] = Field(
        default=None,
        description="FoundryGraph fg_id for matched company (if available)",
    )
    match_score: Optional[int] = Field(
        default=None,
        description="Normalized match score (0-100) for display",
    )
    match_method: Optional[str] = Field(
        default=None,
        description="Internal match method identifier",
    )
    match_explanation: Optional[str] = Field(
        default=None,
        description="Human-readable match explanation",
    )
    display_status: Optional[str] = Field(
        default=None,
        description="Human-readable match status (Matched | Needs Review | No Match)",
    )
    name_score: Optional[float] = Field(
        default=None,
        description="Name similarity score (0-1) for matched account",
    )
    matched_name: Optional[str]
    matched_website: Optional[str]
    record_url: Optional[str]
    custom_fields: Optional[Dict[str, Any]] = Field(
        default=None,
        description="Additional requested fields from matched Account",
    )
    alternative_match_count: Optional[int] = Field(
        default=None,
        description="Number of alternative matches found for this row",
    )
    warnings: Optional[List[str]] = Field(
        default=None,
        description="Warnings for routing/data quality issues",
    )
    status: str = Field(
        ...,
        description="Match status enum: 'matched' | 'unmatched' | 'low_confidence'",
    )


class IDHunterWarning(BaseModel):
    code: str
    message: str


class IDHunterSummary(BaseModel):
    total: int
    matched: int
    needs_review: int
    no_match: int
    multiple_candidates: int


class IDHunterResponse(BaseModel):
    results: List[IDHunterMatch]
    summary: IDHunterSummary
    run_id: Optional[str] = Field(
        default=None,
        description="Run identifier for exporting unmatched rows.",
    )
    warnings: Optional[List[IDHunterWarning]] = None


class IDHunterAsyncSignal(BaseModel):
    requires_async: bool = True
    account_count: int
    estimated_time_seconds: int


class IDHunterPreflightRequest(BaseModel):
    salesforce_object: str = Field(
        "Account", description="For v1, must be 'Account'"
    )
    row_count: Optional[int] = Field(
        default=None,
        ge=0,
        description="Optional number of sheet rows to validate against max row limits.",
    )
    options: IDHunterOptions = IDHunterOptions()


class IDHunterPreflightResponse(BaseModel):
    salesforce_object: str = "Account"
    connected: bool = True
    row_count: int = 0
    requires_async: bool = False
    scope_required: bool = False
    reason_code: Optional[str] = None
    message: Optional[str] = None
    recommended_action: Optional[str] = None
    account_count: Optional[int] = None
    report_id: Optional[str] = None
    report_row_count: Optional[int] = None
    max_unscoped_accounts: int = MAX_ORG_ACCOUNTS
    sync_org_threshold: int = SYNC_ORG_THRESHOLD
    async_row_threshold: int = ASYNC_ROW_THRESHOLD
    max_rows: int = MAX_ROWS
    estimated_time_seconds: int = ESTIMATED_ASYNC_DURATION_S
    warnings: Optional[List[IDHunterWarning]] = None


def _build_account_soql_where(ultimate_parents_only: bool) -> str:
    base = "WHERE IsDeleted = false"
    if ultimate_parents_only:
        return f"{base} AND ParentId = null"
    return base


def _build_account_query(fields: List[str], ultimate_parents_only: bool) -> str:
    unique = list(
        dict.fromkeys(["Id"] + [field for field in fields if field and field != "Id"])
    )
    where = _build_account_soql_where(ultimate_parents_only)
    return f"SELECT {', '.join(unique)} FROM Account {where}"


def _build_account_count_soql(ultimate_parents_only: bool) -> str:
    where = _build_account_soql_where(ultimate_parents_only)
    return f"SELECT COUNT() FROM Account {where}"


def _merge_reference_fields(
    additional_fields: Optional[List[str]] = None,
    *,
    domain_source_field: Optional[str] = None,
) -> List[str]:
    fields = list(SF_ACCOUNT_FIELDS)
    selected_domain = str(domain_source_field or "").strip()
    if selected_domain and selected_domain not in fields:
        fields.append(selected_domain)
    for field in additional_fields or []:
        if field and field not in fields:
            fields.append(field)
    return fields


def _normalize_additional_fields(
    additional_fields: Optional[List[str]],
    *,
    allowed_fields: Optional[Set[str]] = None,
) -> tuple[List[str], List[str]]:
    cleaned: List[str] = []
    invalid: List[str] = []
    allowed = allowed_fields or set(ALLOWED_ADDITIONAL_FIELDS)
    if not additional_fields:
        return cleaned, invalid
    seen = set()
    for raw in additional_fields:
        if raw is None:
            continue
        field = str(raw).strip()
        if not field:
            continue
        if field not in allowed:
            invalid.append(field)
            continue
        if field in seen:
            continue
        seen.add(field)
        cleaned.append(field)
    return cleaned, invalid


def _extract_soql_count(result: Any) -> Optional[int]:
    raw_count = None
    if isinstance(result, dict):
        records = result.get("records") or []
        if records and isinstance(records[0], dict):
            raw_count = records[0].get("expr0") or records[0].get("count")
        if raw_count is None:
            raw_count = result.get("count")
        if raw_count is None:
            raw_count = result.get("totalSize") or result.get("total_size")
    else:
        raw_count = result
    if raw_count is None:
        return None
    try:
        return max(0, int(raw_count))
    except (TypeError, ValueError):
        return None


def _is_domain_like_account_field(
    field_api: str,
    *,
    label: Optional[str] = None,
    data_type: Optional[str] = None,
) -> bool:
    api = str(field_api or "").strip()
    if not api or not _SF_FIELD_API_RE.match(api):
        return False
    api_lower = api.lower()
    if api_lower == SF_ACCOUNT_DEFAULT_DOMAIN_FIELD.lower():
        return True

    dtype = str(data_type or "").strip().lower()
    allowed_types = {
        "string",
        "url",
        "email",
        "textarea",
        "phone",
        "encryptedstring",
    }
    if dtype and dtype not in allowed_types:
        return False

    text = f"{api} {label or ''}".lower().replace("_", " ")
    return (
        "domain" in text
        or "website" in text
        or "web site" in text
        or "url" in text
    )


def _domain_field_rank(field_api: str, label: Optional[str]) -> int:
    api = str(field_api or "").lower()
    lbl = str(label or "").lower()
    score = 0
    if api == SF_ACCOUNT_DEFAULT_DOMAIN_FIELD.lower():
        score += 1000
    if "domain" in api:
        score += 300
    if "domain" in lbl:
        score += 250
    if "website" in api or "web site" in api:
        score += 200
    if "website" in lbl or "web site" in lbl:
        score += 150
    if "url" in api:
        score += 120
    if "url" in lbl:
        score += 80
    if api.endswith("__c"):
        score += 10
    return score


def _build_account_non_null_count_soql(
    field_api: str, *, ultimate_parents_only: bool
) -> str:
    where = _build_account_soql_where(ultimate_parents_only)
    return f"SELECT COUNT() FROM Account {where} AND {field_api} != null"


def _format_fill_percent(count: Optional[int], total: Optional[int]) -> str:
    if count is None or total is None or total <= 0:
        return "n/a"
    return f"{(float(count) / float(total)) * 100.0:.1f}%"


async def _select_reference_domain_field(
    sf_client: SalesforceGateway,
    *,
    ultimate_parents_only: bool = False,
) -> Tuple[str, Dict[str, Any]]:
    selected = SF_ACCOUNT_DEFAULT_DOMAIN_FIELD
    details: Dict[str, Any] = {
        "selected_field": selected,
        "counts": {},
        "total_accounts": None,
    }

    describe_payload: Dict[str, Any] = {}
    if hasattr(sf_client, "describe"):
        try:
            describe_result = await sf_client.describe("Account")
            if isinstance(describe_result, dict):
                describe_payload = describe_result
        except Exception as exc:
            log.debug("Account describe unavailable for domain source probe: %s", exc)

    ranked_candidates: List[Tuple[int, str]] = []
    for raw in describe_payload.get("fields") or []:
        if not isinstance(raw, dict):
            continue
        field_api = str(raw.get("name") or "").strip()
        if not field_api or not _SF_FIELD_API_RE.match(field_api):
            continue
        if raw.get("queryable") is False:
            continue
        if not _is_domain_like_account_field(
            field_api,
            label=raw.get("label"),
            data_type=raw.get("type"),
        ):
            continue
        ranked_candidates.append(
            (_domain_field_rank(field_api, raw.get("label")), field_api)
        )

    ranked_candidates.sort(key=lambda item: item[0], reverse=True)
    candidate_fields: List[str] = []
    seen_fields: Set[str] = set()
    for _, field_api in ranked_candidates:
        if field_api in seen_fields:
            continue
        candidate_fields.append(field_api)
        seen_fields.add(field_api)

    if SF_ACCOUNT_DEFAULT_DOMAIN_FIELD not in seen_fields:
        candidate_fields.insert(0, SF_ACCOUNT_DEFAULT_DOMAIN_FIELD)

    candidate_fields = candidate_fields[:ID_HUNTER_DOMAIN_PROBE_MAX_FIELDS]
    if not candidate_fields:
        candidate_fields = [SF_ACCOUNT_DEFAULT_DOMAIN_FIELD]

    total_accounts = await _estimate_account_count(
        sf_client, ultimate_parents_only=ultimate_parents_only
    )
    details["total_accounts"] = total_accounts

    counts: Dict[str, int] = {}
    for field_api in candidate_fields:
        if not _SF_FIELD_API_RE.match(field_api):
            continue
        soql = _build_account_non_null_count_soql(
            field_api, ultimate_parents_only=ultimate_parents_only
        )
        try:
            result = await asyncio.wait_for(
                sf_client.soql(soql),
                timeout=ID_HUNTER_DOMAIN_PROBE_TIMEOUT_S,
            )
            count = _extract_soql_count(result)
            if count is not None:
                counts[field_api] = count
        except Exception as exc:
            log.debug(
                "Domain source probe failed for Account.%s: %s", field_api, exc
            )

    details["counts"] = counts
    if not counts:
        return selected, details

    website_count = counts.get(SF_ACCOUNT_DEFAULT_DOMAIN_FIELD)
    best_field = SF_ACCOUNT_DEFAULT_DOMAIN_FIELD
    best_count = website_count if website_count is not None else -1
    for field_api, count in counts.items():
        if count > best_count:
            best_field = field_api
            best_count = count

    selected = best_field
    details["selected_field"] = selected

    selected_fill = _format_fill_percent(best_count, total_accounts)
    website_fill = _format_fill_percent(website_count, total_accounts)
    website_count_text = (
        str(website_count) if website_count is not None else "n/a"
    )
    selection_msg = (
        f"Domain source: {selected} (fill={selected_fill}, non_null={best_count})"
        f", {SF_ACCOUNT_DEFAULT_DOMAIN_FIELD} (fill={website_fill}, non_null={website_count_text})"
        f" -- using {selected}"
    )
    details["message"] = selection_msg
    log.info("Account Hunter %s", selection_msg)
    return selected, details


async def _resolve_allowed_additional_fields(
    account_id: str, db: AsyncSession
) -> Set[str]:
    allowed = set(ALLOWED_ADDITIONAL_FIELDS)
    try:
        repo = SalesforceSchemaIndexRepo(db)
        fields = await repo.list_fields(str(account_id), "Account")
    except Exception as exc:  # pragma: no cover - schema index optional
        log.warning("Failed to load Salesforce schema index: %s", exc)
        return allowed
    for field in fields:
        if field.queryable and field.is_custom:
            allowed.add(field.field_api)
    return allowed


def _extract_custom_field_value(record: Dict[str, Any], field_path: str) -> Any:
    if field_path in record:
        value = record.get(field_path)
    else:
        value = record
        for part in field_path.split("."):
            if not isinstance(value, dict):
                return None
            value = value.get(part)
    if value is None:
        return None
    try:
        if pd.isna(value):
            return None
    except Exception:
        pass
    return value


def _cache_key(
    tenant_id: str,
    fields: Optional[List[str]] = None,
    *,
    ultimate_parents_only: bool = False,
    report_id: Optional[str] = None,
) -> str:
    field_list = fields or SF_ACCOUNT_FIELDS
    scope = "ultimate_only" if ultimate_parents_only else "all"
    report_scope = f"report:{report_id}" if report_id else "report:none"
    field_sig = f"{scope}:{report_scope}:{','.join(sorted(field_list))}"
    digest = hashlib.md5(field_sig.encode("utf-8")).hexdigest()[:8]
    return f"sf_ref:{tenant_id}:Account:{digest}"


def _input_cache_key(run_id: str) -> str:
    return f"id_hunter:input:{run_id}"


def _report_list_cache_key(account_id: str, user_id: str) -> str:
    # Versioned key to bypass stale cached payloads from older report filters.
    return f"id_hunter:reports:v2:{account_id}:{user_id}"


def _report_ids_cache_key(account_id: str, report_id: str) -> str:
    return f"id_hunter:report_ids:{account_id}:{report_id}"


def _maybe_get_redis_client() -> Optional[Redis]:
    try:
        return get_redis_client()
    except Exception as exc:  # pragma: no cover - redis optional
        log.warning("Redis unavailable for ID Hunter cache: %s", exc)
        return None


async def _update_job_progress(
    redis_client: Optional[Redis],
    job_id: Optional[str],
    *,
    status: str = "processing",
    progress: float = 0,
    message: Optional[str] = None,
    extra: Optional[Dict[str, Any]] = None,
) -> None:
    if not job_id or redis_client is None:
        return
    try:
        job_key = f"job:{job_id}"
        base: Dict[str, Any] = {
            "status": status,
            "state": status,
            "progress": progress,
            "message": message or "",
            "updated_at": datetime.utcnow().isoformat(),
        }
        if extra:
            base.update(extra)
        try:
            current_raw = await redis_client.get(job_key)
            if current_raw:
                current = json.loads(current_raw)
                if isinstance(current, dict):
                    base = {**current, **base}
        except Exception:
            pass
        await redis_client.setex(job_key, 86400, json.dumps(base))
    except Exception:
        # best-effort only
        return


async def _estimate_account_count(
    sf_client: SalesforceGateway,
    *,
    ultimate_parents_only: bool = False,
) -> Optional[int]:
    try:
        query = _build_account_count_soql(ultimate_parents_only)
        result = await asyncio.wait_for(
            sf_client.soql(query),
            timeout=ID_HUNTER_ESTIMATE_TIMEOUT_S,
        )
        parsed_count = _extract_soql_count(result)
        if parsed_count is None:
            return None
        return parsed_count
    except Exception as exc:
        log.warning("Account count estimation failed; defaulting to async: %s", exc)
        return None


def _is_account_id(value: Any) -> bool:
    if value is None:
        return False
    text = str(value).strip()
    if not text:
        return False
    return _is_valid_account_id_text(text)


def _build_scope_required_detail(account_count: Optional[int]) -> Dict[str, Any]:
    detected_text = (
        f"{int(account_count):,}" if isinstance(account_count, (int, float)) else "unknown"
    )
    return {
        "code": "id_hunter_scope_required",
        "scope_required": True,
        "recommended_action": "select_report",
        "max_unscoped_accounts": MAX_ORG_ACCOUNTS,
        "account_count": int(account_count)
        if isinstance(account_count, (int, float))
        else None,
        "message": (
            f"Unscoped Account Hunter currently supports up to {MAX_ORG_ACCOUNTS:,} "
            f"Accounts (detected {detected_text}). Select a Salesforce report "
            "to scope the reference set and try again."
        ),
    }


async def _assess_hunter_run_shape(
    *,
    sf: SalesforceGateway,
    redis_client: Optional[Redis],
    account_id: str,
    options: IDHunterOptions,
    row_count: Optional[int] = None,
) -> Dict[str, Any]:
    try:
        normalized_row_count = int(row_count or 0)
    except (TypeError, ValueError):
        normalized_row_count = 0
    row_requires_async = normalized_row_count >= ASYNC_ROW_THRESHOLD

    report_id = options.report_id if options else None
    if report_id:
        report_ids, report_meta, report_warnings = await _load_report_account_ids(
            sf,
            redis_client,
            account_id=str(account_id),
            report_id=report_id,
        )
        report_count = len(report_ids)
        return {
            "report_id": report_id,
            "account_count": report_count,
            "report_row_count": int(report_meta.get("row_count") or 0),
            "requires_async": (
                report_count >= SYNC_ORG_THRESHOLD or row_requires_async
            ),
            "scope_required": False,
            "warnings": report_warnings,
        }

    account_count = await _estimate_account_count(
        sf, ultimate_parents_only=options.ultimate_parents_only
    )
    scope_required = account_count is not None and account_count > MAX_ORG_ACCOUNTS
    requires_async = (
        account_count is None
        or account_count >= SYNC_ORG_THRESHOLD
        or scope_required
        or row_requires_async
    )
    return {
        "report_id": None,
        "account_count": account_count,
        "report_row_count": None,
        "requires_async": requires_async,
        "scope_required": scope_required,
        "warnings": [],
    }


def _parse_report_csv(csv_content: str) -> tuple[List[str], List[List[str]]]:
    if not csv_content:
        return [], []
    try:
        reader = csv.reader(io.StringIO(csv_content))
        rows = list(reader)
    except Exception:
        return [], []
    if not rows:
        return [], []
    headers = rows[0]
    data_rows = rows[1:] if len(rows) > 1 else []
    return headers, data_rows


def _detect_account_id_columns(
    headers: List[str],
    *,
    detail_columns: Optional[List[str]] = None,
    detail_info: Optional[Dict[str, Any]] = None,
) -> List[int]:
    detail_columns = detail_columns or []
    detail_info = detail_info or {}
    candidates: List[int] = []

    def is_account_id_ref(ref: str) -> bool:
        ref_norm = str(ref or "").strip().lower()
        if not ref_norm:
            return False
        if ref_norm == "account.id" or ref_norm == "accountid":
            return True
        if ref_norm.endswith(".accountid") or ref_norm.endswith(".account.id"):
            return True
        return False

    for idx, col_key in enumerate(detail_columns):
        info = detail_info.get(col_key) or {}
        for candidate in (
            info.get("fullyQualifiedName"),
            info.get("name"),
            col_key,
            info.get("label"),
            info.get("displayName"),
        ):
            if is_account_id_ref(candidate):
                candidates.append(idx)
                break

    if candidates:
        return sorted(set(candidates))

    for idx, header in enumerate(headers):
        header_norm = str(header or "").strip().lower()
        if not header_norm:
            continue
        if header_norm == "account id" or "account id" in header_norm:
            candidates.append(idx)

    return sorted(set(candidates))


def _extract_account_ids_from_rows(
    rows: List[List[str]],
    candidate_indices: List[int],
) -> List[str]:
    if not rows or not candidate_indices:
        return []
    ids: List[str] = []
    seen: Set[str] = set()
    for row in rows:
        for idx in candidate_indices:
            if idx >= len(row):
                continue
            value = row[idx]
            if _is_account_id(value):
                text = str(value).strip()
                if text not in seen:
                    seen.add(text)
                    ids.append(text)
                break
    return ids


def _detect_account_id_columns_from_values(
    headers: List[str], rows: List[List[str]]
) -> List[int]:
    if not headers or not rows:
        return []
    max_scan = min(len(rows), 200)
    scores: Dict[int, int] = {}
    for idx in range(len(headers)):
        score = 0
        for row in rows[:max_scan]:
            if idx >= len(row):
                continue
            if _is_account_id(row[idx]):
                score += 1
        if score:
            scores[idx] = score
    if not scores:
        return []
    best_score = max(scores.values())
    return [idx for idx, score in scores.items() if score == best_score]


async def _load_report_account_ids(
    sf: SalesforceGateway,
    redis_client: Optional[Redis],
    *,
    account_id: str,
    report_id: str,
) -> Tuple[List[str], Dict[str, Any], List[Dict[str, str]]]:
    cache_key = _report_ids_cache_key(account_id, report_id)
    warnings: List[Dict[str, str]] = []
    if redis_client:
        try:
            cached = await redis_client.get(cache_key)
            if cached:
                payload = json.loads(cached)
                ids = payload.get("account_ids")
                meta = payload.get("meta") or {}
                cached_warnings = payload.get("warnings")
                if isinstance(cached_warnings, list):
                    warnings.extend(
                        [
                            w
                            for w in cached_warnings
                            if isinstance(w, dict) and w.get("message")
                        ]
                    )
                if isinstance(ids, list):
                    return [str(item) for item in ids if item], meta, warnings
        except Exception as exc:  # pragma: no cover
            log.warning("Failed to read report cache: %s", exc)

    export_result = await sf.export_report(report_id)
    if not export_result or not export_result.get("success"):
        error_message = (
            export_result.get("error") if isinstance(export_result, dict) else None
        )
        raise HTTPException(
            status_code=400,
            detail=error_message or "Unable to export Salesforce report.",
        )

    row_count = int(export_result.get("rows") or 0)
    report_name = export_result.get("report_name") or ""
    if row_count > 50000:
        raise HTTPException(
            status_code=400,
            detail=(
                f"Report has {row_count} rows. Please use a more specific report "
                "with fewer than 50,000 rows."
            ),
        )

    headers, rows = _parse_report_csv(export_result.get("csv") or "")
    detail_columns = export_result.get("detail_columns") or []
    detail_info = export_result.get("detail_column_info") or {}
    candidate_indices = _detect_account_id_columns(
        headers, detail_columns=detail_columns, detail_info=detail_info
    )
    if not candidate_indices:
        candidate_indices = _detect_account_id_columns_from_values(headers, rows)

    invalid_ids: List[str] = []
    for row in rows:
        for idx in candidate_indices:
            if idx >= len(row):
                continue
            raw = str(row[idx] if row[idx] is not None else "").strip()
            if not raw:
                continue
            if not _is_valid_account_id_text(raw):
                invalid_ids.append(raw)

    if invalid_ids:
        raise HTTPException(
            status_code=400,
            detail=(
                "Invalid Salesforce Account ID in report scope: "
                + ", ".join(sorted(set(invalid_ids[:10])))
            ),
        )

    account_ids = _extract_account_ids_from_rows(rows, candidate_indices)
    if not account_ids:
        warnings.append(
            {
                "code": "report_missing_account_ids",
                "message": (
                    "The selected report does not include Account IDs. "
                    "Please add Account ID to the report and try again."
                ),
            }
        )

    meta = {"row_count": row_count, "report_name": report_name}
    if redis_client:
        try:
            payload = {"account_ids": account_ids, "meta": meta, "warnings": warnings}
            await redis_client.setex(
                cache_key, ID_HUNTER_REPORT_IDS_CACHE_TTL, json.dumps(payload)
            )
        except Exception as exc:  # pragma: no cover
            log.warning("Failed to cache report IDs: %s", exc)

    return account_ids, meta, warnings


async def _fetch_reference_accounts(
    sf: SalesforceGateway,
    redis_client: Optional[Redis],
    job_id: Optional[str] = None,
    *,
    timeout_s: float = ID_HUNTER_FETCH_TIMEOUT_S,
    additional_fields: Optional[List[str]] = None,
    ultimate_parents_only: bool = False,
    report_id: Optional[str] = None,
) -> Tuple[List[Dict[str, Any]], List[Dict[str, str]], str]:
    domain_source_field, domain_probe = await _select_reference_domain_field(
        sf, ultimate_parents_only=ultimate_parents_only
    )
    fields = _merge_reference_fields(
        additional_fields, domain_source_field=domain_source_field
    )
    progress_denominator = float(max(1, ID_HUNTER_MAX_ACCOUNTS))
    cache_key = _cache_key(
        sf.tenant_id,
        fields,
        ultimate_parents_only=ultimate_parents_only,
        report_id=report_id,
    )
    cached_records: Optional[List[Dict[str, Any]]] = None
    warnings: List[Dict[str, str]] = []
    if domain_source_field != SF_ACCOUNT_DEFAULT_DOMAIN_FIELD:
        probe_message = domain_probe.get("message")
        if probe_message:
            warnings.append(
                {
                    "code": "domain_source_overridden",
                    "message": str(probe_message),
                }
            )
    if redis_client:
        try:
            cached = await redis_client.get(cache_key)
            if cached:
                payload = json.loads(cached)
                records = payload.get("records")
                if isinstance(records, list):
                    cached_records = records
                cached_warnings = payload.get("warnings")
                if isinstance(cached_warnings, list):
                    warnings.extend(
                        [
                            w
                            for w in cached_warnings
                            if isinstance(w, dict) and w.get("message")
                        ]
                    )
        except Exception as exc:  # pragma: no cover - cache is best-effort
            log.warning("Failed to read Salesforce ref cache: %s", exc)

    # If cache is present but suspiciously small, force a refresh to avoid stale samples
    if cached_records is not None and len(cached_records) >= 100:
        log.info(
            "Using cached Salesforce reference (records=%d, ttl=%s)",
            len(cached_records),
            REFERENCE_CACHE_TTL,
        )
        return cached_records, warnings, domain_source_field
    elif cached_records is not None:
        log.info(
            "Reference cache size (%d) below minimum; refetching full dataset",
            len(cached_records),
        )
        warnings = []

    records: List[Dict[str, Any]] = []
    total_fetched = 0
    started = time.time()
    try:
        if report_id:
            account_ids, report_meta, report_warnings = await _load_report_account_ids(
                sf,
                redis_client,
                account_id=sf.tenant_id,
                report_id=report_id,
            )
            warnings.extend(report_warnings)
            row_count = int(report_meta.get("row_count") or 0)
            if row_count == 0:
                warnings.append(
                    {
                        "code": "report_empty",
                        "message": "Report contains no rows to match.",
                    }
                )
            if row_count > MAX_ORG_ACCOUNTS:
                warnings.append(
                    {
                        "code": "report_truncated",
                        "message": (
                            f"Report has {row_count} rows. Only first "
                            f"{MAX_ORG_ACCOUNTS} will be matched."
                        ),
                    }
                )
                account_ids = account_ids[:MAX_ORG_ACCOUNTS]
            if not account_ids:
                return [], warnings, domain_source_field

            chunk_size = 500
            for start_idx in range(0, len(account_ids), chunk_size):
                chunk = account_ids[start_idx : start_idx + chunk_size]
                if not chunk:
                    continue
                invalid_chunk_ids = [
                    cid for cid in chunk if not _is_valid_account_id_text(str(cid))
                ]
                if invalid_chunk_ids:
                    raise HTTPException(
                        status_code=400,
                        detail=(
                            "Invalid Salesforce Account ID in report scope: "
                            + ", ".join(sorted(set(invalid_chunk_ids[:10])))
                        ),
                    )
                chunk_ids = ", ".join([f"'{_escape_soql_literal(cid)}'" for cid in chunk])
                where = f"WHERE Id IN ({chunk_ids})"
                if ultimate_parents_only:
                    where += " AND ParentId = null"
                # Security gateway enforces LIMIT on non-COUNT queries.
                soql = (
                    f"SELECT {', '.join(fields)} FROM Account {where} "
                    f"LIMIT {max(1, len(chunk))}"
                )
                result = await sf.soql(soql)
                if isinstance(result, dict):
                    records.extend(result.get("records") or [])
                total_fetched = len(records)
                if total_fetched and total_fetched % 1000 == 0:
                    await _update_job_progress(
                        redis_client,
                        job_id,
                        status="processing",
                        progress=min(
                            40,
                            10 + int((total_fetched / progress_denominator) * 30),
                        ),
                        message="Fetching Salesforce Accounts",
                        extra={"reference_rows": total_fetched},
                    )
                if time.time() - started > timeout_s:
                    warnings.append(
                        {
                            "code": "reference_timeout",
                            "message": (
                                "Salesforce reference fetch timed out after "
                                f"{timeout_s:.0f}s; using {total_fetched} Accounts. "
                                "Results may be incomplete."
                            ),
                        }
                    )
                    if total_fetched == 0:
                        raise HTTPException(
                            status_code=504,
                            detail="Timed out fetching Salesforce Accounts; please retry or reduce scope.",
                        )
                    break
        else:
            soql = _build_account_query(fields, ultimate_parents_only)
            async for batch in sf.paginate_soql(soql):
                if isinstance(batch, list):
                    records.extend(batch)
                elif isinstance(batch, dict):
                    records.extend(batch.get("records") or [])
                total_fetched = len(records)
                if total_fetched and total_fetched % 1000 == 0:
                    log.info(
                        "Hunter reference fetch progress: %d Accounts", total_fetched
                    )
                    await _update_job_progress(
                        redis_client,
                        job_id,
                        status="processing",
                        progress=min(
                            40,
                            10
                            + int(
                                (total_fetched / progress_denominator) * 30
                            ),
                        ),
                        message="Fetching Salesforce Accounts",
                        extra={"reference_rows": total_fetched},
                    )
                if total_fetched >= ID_HUNTER_MAX_ACCOUNTS:
                    log.warning(
                        "Hunter reference capped at %d Accounts (truncating larger org)",
                        ID_HUNTER_MAX_ACCOUNTS,
                    )
                    warnings.append(
                        {
                            "code": "reference_truncated",
                            "message": (
                                "Salesforce reference data was capped at "
                                f"{ID_HUNTER_MAX_ACCOUNTS} Accounts. Results may be incomplete."
                            ),
                        }
                    )
                    records = records[:ID_HUNTER_MAX_ACCOUNTS]
                    break
                if time.time() - started > timeout_s:
                    log.warning(
                        "Hunter reference fetch timed out after %.1fs; using %d Accounts",
                        timeout_s,
                        total_fetched,
                    )
                    warnings.append(
                        {
                            "code": "reference_timeout",
                            "message": (
                                "Salesforce reference fetch timed out after "
                                f"{timeout_s:.0f}s; using {total_fetched} Accounts. "
                                "Results may be incomplete."
                            ),
                        }
                    )
                    if total_fetched == 0:
                        raise HTTPException(
                            status_code=504,
                            detail="Timed out fetching Salesforce Accounts; please retry or reduce scope.",
                        )
                    break
    except HTTPException:
        raise
    except Exception as exc:
        log.error("Salesforce Account pagination failed: %s", exc)
        raise HTTPException(
            status_code=502,
            detail="Failed to fetch Salesforce Accounts from Salesforce. "
            "Please refresh the connection and try again.",
        ) from exc

    if redis_client:
        try:
            payload = {
                "records": records,
                "cached_at": datetime.utcnow().isoformat(),
                "warnings": warnings,
            }
            await redis_client.setex(cache_key, REFERENCE_CACHE_TTL, json.dumps(payload))
        except Exception as exc:  # pragma: no cover - cache write best-effort
            log.warning("Failed to cache Salesforce ref data: %s", exc)
    log.info(
        "Fetched %d Salesforce Account records for Hunter reference (cap=%d, domain_source=%s)",
        len(records),
        ID_HUNTER_MAX_ACCOUNTS,
        domain_source_field,
    )
    return records, warnings, domain_source_field


def _normalize_score(value: Any) -> Optional[float]:
    if value is None:
        return None
    try:
        score = float(value)
    except (TypeError, ValueError):
        return None
    if score > 1.0:
        score = score / 100.0
    score = max(0.0, min(score, 1.0))
    return score


def _score_to_percent(value: Optional[float]) -> Optional[int]:
    if value is None:
        return None
    try:
        return int(round(max(0.0, min(float(value), 1.0)) * 100))
    except (TypeError, ValueError):
        return None


def _compute_name_score(source_name: Any, reference_name: Any) -> Optional[float]:
    src = str(source_name or "").strip()
    ref = str(reference_name or "").strip()
    if not src or not ref:
        return None
    try:
        return float(fuzz.WRatio(src, ref)) / 100.0
    except Exception:
        return None


def _detect_match_method(
    source_domain: Optional[str],
    reference_domain: Optional[str],
    source_registrable: Optional[str],
    reference_registrable: Optional[str],
    name_score: Optional[float],
    source_domain_origin: Optional[str] = None,
) -> Optional[str]:
    if source_domain and reference_domain and source_domain == reference_domain:
        if source_domain_origin == "email":
            return "email_domain"
        return "domain_exact"
    if (
        source_registrable
        and reference_registrable
        and source_registrable == reference_registrable
    ):
        return "registrable_domain"
    if name_score is not None:
        if name_score >= 0.98:
            return "name_exact"
        if name_score >= 0.85:
            return "name_fuzzy"
    return None


def _build_match_explanation(
    method: Optional[str], *, score: Optional[int] = None
) -> Optional[str]:
    if not method:
        return None
    template = MATCH_EXPLANATIONS.get(method)
    if not template:
        return None
    if "{score}" in template:
        if score is None:
            return None
        return template.format(score=score)
    return template


def _build_unmatched_csv(
    rows_by_index: Dict[int, Dict[str, Any]],
    row_indices: List[int],
    *,
    schema_preset: str = "salesforce",
) -> str:
    preset = (schema_preset or "salesforce").strip().lower()
    if preset not in {"salesforce", "hubspot"}:
        raise HTTPException(
            status_code=400, detail=f"Unsupported schema preset: {schema_preset}"
        )

    if preset == "hubspot":
        headers = ["Company Name", "Website", "Notes"]
        def map_row(values: Dict[str, Any]) -> List[str]:
            return [
                str(values.get("company") or values.get("name") or ""),
                str(values.get("website") or ""),
                str(values.get("notes") or values.get("description") or ""),
            ]
    else:
        headers = ["Name", "Website", "Description"]

        def map_row(values: Dict[str, Any]) -> List[str]:
            return [
                str(values.get("company") or values.get("name") or ""),
                str(values.get("website") or ""),
                str(values.get("description") or ""),
            ]

    output = io.StringIO()
    writer = csv.writer(output)
    writer.writerow(headers)

    for idx in row_indices:
        values = rows_by_index.get(idx) or {}
        writer.writerow(map_row(values))

    return output.getvalue()


def _build_source_dataframe(
    rows: List[IDHunterRow],
    use_domain: bool,
    mappings: Optional[IDHunterMappings] = None,
) -> pd.DataFrame:
    def _get_value(values: Dict[str, Any], *keys: Optional[str]) -> Any:
        if not isinstance(values, dict):
            return None
        lowered = {str(k).strip().lower(): v for k, v in values.items()}
        for key in keys:
            if not key:
                continue
            key_text = str(key).strip()
            if key_text in values and values[key_text] not in (None, ""):
                return values[key_text]
            key_lower = key_text.lower()
            if key_lower in lowered and lowered[key_lower] not in (None, ""):
                return lowered[key_lower]
        return None

    company_key = mappings.company_column if mappings else None
    website_key = mappings.website_column if mappings else None
    email_key = mappings.email_column if mappings else None

    source_rows: List[Dict[str, Any]] = []
    for row in rows:
        rid = str(row.row_index)
        values = row.values or {}
        source_rows.append(
            {
                "row_id": rid,
                "company_name": _get_value(values, "company", "name", company_key),
                "website": _get_value(values, "website", website_key),
                "email": _get_value(values, "email", email_key),
            }
        )

    df = pd.DataFrame(source_rows)
    if df.empty:
        df = pd.DataFrame(
            columns=["row_id", "company_name", "website", "email"]
        )

    for col in ("company_name", "website", "email"):
        if col not in df.columns:
            df[col] = None

    if use_domain and not df.empty:
        website_domains = extract_domains_vectorized(
            df["website"] if "website" in df else pd.Series(dtype=object)
        )
        email_domains = extract_domains_vectorized(
            df["email"] if "email" in df else pd.Series(dtype=object),
            source_type="email",
        )
        df["website_domain"] = website_domains
        df["email_domain"] = email_domains
        df["domain"] = website_domains.combine_first(email_domains)
        df["domain_origin"] = None
        df.loc[website_domains.notna(), "domain_origin"] = "website"
        df.loc[(website_domains.isna()) & (email_domains.notna()), "domain_origin"] = (
            "email"
        )
    else:
        df["domain"] = None
        df["website_domain"] = None
        df["email_domain"] = None
        df["domain_origin"] = None

    return df


def _build_reference_dataframe(
    records: List[Dict[str, Any]],
    use_domain: bool,
    additional_fields: Optional[List[str]] = None,
    domain_source_field: str = SF_ACCOUNT_DEFAULT_DOMAIN_FIELD,
) -> tuple[pd.DataFrame, Dict[str, Dict[str, Any]]]:
    df = pd.DataFrame(records)
    if df.empty:
        df = pd.DataFrame(
            columns=_merge_reference_fields(
                additional_fields, domain_source_field=domain_source_field
            )
        )

    if "Id" not in df.columns:
        df["Id"] = None

    df["sf_id"] = df["Id"].astype(str)
    df = df[
        df["sf_id"].notna()
        & (~df["sf_id"].isin(["None", "nan", "NaN", ""]))
    ]

    df["sf_name"] = df.get("Name")
    df["company_name"] = df.get("Name")
    df["sf_website"] = df.get("Website")
    df["website"] = df.get("Website")
    selected_domain_raw = df.get(domain_source_field)
    if selected_domain_raw is None and domain_source_field != "Website":
        selected_domain_raw = df.get("Website")
    df["domain_source_raw"] = selected_domain_raw
    df["sf_city"] = df.get("BillingCity")
    df["sf_country"] = df.get("BillingCountry")

    if use_domain and "domain_source_raw" in df.columns:
        df["domain"] = extract_domains_vectorized(df["domain_source_raw"])
    else:
        df["domain"] = None

    lookup: Dict[str, Dict[str, Any]] = {}
    field_list = additional_fields or []
    for _, row in df.iterrows():
        sf_id = str(row.get("sf_id") or "").strip()
        if not sf_id:
            continue
        record = row.to_dict()
        owner_id = _extract_custom_field_value(record, "OwnerId")
        owner_is_active = _extract_custom_field_value(record, "Owner.IsActive")
        meta: Dict[str, Any] = {
            "matched_name": row.get("sf_name"),
            "matched_website": row.get("sf_website"),
            "sf_city": row.get("sf_city"),
            "sf_country": row.get("sf_country"),
            "domain": row.get("domain"),
            "domain_source_field": domain_source_field,
            "owner_id": owner_id,
            "owner_is_active": owner_is_active,
            "owner_fields_available": owner_id is not None
            or owner_is_active is not None
            or ("OwnerId" in record or "Owner" in record),
            "company_fg_id": row.get("company_fg_id") or row.get("fg_id"),
        }
        if field_list:
            custom_fields: Dict[str, Any] = {}
            for field in field_list:
                custom_fields[field] = _extract_custom_field_value(record, field)
            meta["custom_fields"] = custom_fields
        lookup[sf_id] = meta

    return df, lookup


def _best_match_by_row(
    matches: pd.DataFrame,
    *,
    source_domains: Optional[Dict[str, Optional[str]]] = None,
    reference_domains: Optional[Dict[str, Optional[str]]] = None,
    source_registrable: Optional[Dict[str, Optional[str]]] = None,
    reference_registrable: Optional[Dict[str, Optional[str]]] = None,
    source_names: Optional[Dict[str, Optional[str]]] = None,
    reference_names: Optional[Dict[str, Optional[str]]] = None,
) -> Tuple[Dict[str, Dict[str, Any]], Dict[str, int]]:
    if matches is None or matches.empty:
        return {}, {}
    df = matches.copy()
    if "score" not in df.columns and "match_score" in df.columns:
        df["score"] = df["match_score"]
    if "source_id" not in df.columns and "src_id" in df.columns:
        df["source_id"] = df["src_id"]
    if "reference_id" not in df.columns and "ref_id" in df.columns:
        df["reference_id"] = df["ref_id"]
    if "target_id" not in df.columns and "ref_id" in df.columns:
        df["target_id"] = df["ref_id"]

    df["__source_id"] = df["source_id"].astype(str)
    df["__reference_id"] = df["reference_id"].astype(str)

    source_domains = source_domains or {}
    reference_domains = reference_domains or {}
    source_registrable = source_registrable or {}
    reference_registrable = reference_registrable or {}
    source_names = source_names or {}
    reference_names = reference_names or {}

    df["__source_domain"] = df["__source_id"].map(source_domains)
    df["__reference_domain"] = df["__reference_id"].map(reference_domains)
    df["__source_reg"] = df["__source_id"].map(source_registrable)
    df["__reference_reg"] = df["__reference_id"].map(reference_registrable)

    df["__domain_exact"] = (
        df["__source_domain"].notna()
        & df["__reference_domain"].notna()
        & (df["__source_domain"] == df["__reference_domain"])
    )
    df["__registrable_match"] = (
        df["__source_reg"].notna()
        & df["__reference_reg"].notna()
        & (df["__source_reg"] == df["__reference_reg"])
    )

    def _lookup_name_score(row: pd.Series) -> Optional[float]:
        src_name = source_names.get(row["__source_id"]) if source_names else None
        ref_name = (
            reference_names.get(row["__reference_id"]) if reference_names else None
        )
        return _compute_name_score(src_name, ref_name)

    df["__name_score"] = df.apply(_lookup_name_score, axis=1)
    df["__name_score_sort"] = df["__name_score"].apply(
        lambda value: value if value is not None else -1.0
    )
    df["__confidence"] = df["score"].apply(_normalize_score)

    df = df.sort_values(
        by=[
            "__domain_exact",
            "__registrable_match",
            "__name_score_sort",
            "__confidence",
            "__reference_id",
        ],
        ascending=[False, False, False, False, True],
        na_position="last",
    )

    winners: Dict[str, Dict[str, Any]] = {}
    counts: Dict[str, int] = {}
    for _, row in df.iterrows():
        src_id = row.get("source_id")
        if src_id is None:
            continue
        key = str(src_id)
        counts[key] = counts.get(key, 0) + 1
        if key in winners:
            continue
        winners[key] = row.to_dict()

    alternative_counts = {key: max(0, count - 1) for key, count in counts.items()}
    return winners, alternative_counts


async def _run_hunter_logic(
    sf_client: SalesforceGateway,
    params: IDHunterRequest,
    job_id: Optional[str] = None,
    redis_client: Optional[Redis] = None,
    *,
    fetch_timeout_s: float = ID_HUNTER_FETCH_TIMEOUT_S,
    allowed_additional_fields: Optional[Set[str]] = None,
    run_id: Optional[str] = None,
    account_id: Optional[str] = None,
    user_id: Optional[str] = None,
) -> Tuple[List[IDHunterMatch], Dict[str, int], List[Dict[str, str]]]:
    redis_client = redis_client or _maybe_get_redis_client()
    effective_run_id = str(
        run_id or params.run_id or job_id or f"hunter_{uuid.uuid4().hex}"
    )
    if not _RUN_ID_PATTERN.fullmatch(effective_run_id):
        raise HTTPException(status_code=400, detail="Invalid run_id.")
    total_rows = len(params.rows) if params and params.rows else 0
    if total_rows == 0:
        return (
            [],
            {
                "total": 0,
                "matched": 0,
                "needs_review": 0,
                "no_match": 0,
                "multiple_candidates": 0,
            },
            [],
        )

    if redis_client is not None:
        try:
            cache_key = _input_cache_key(effective_run_id)
            rows_by_index: Dict[int, Dict[str, Any]] = {}
            cached_raw = await redis_client.get(cache_key)
            if cached_raw:
                try:
                    cached_payload = json.loads(cached_raw)
                    cached_rows = (
                        cached_payload.get("rows")
                        if isinstance(cached_payload, dict)
                        else []
                    )
                    if isinstance(cached_rows, list):
                        for cached_row in cached_rows:
                            if not isinstance(cached_row, dict):
                                continue
                            idx = cached_row.get("row_index")
                            try:
                                idx_int = int(idx)
                            except (TypeError, ValueError):
                                continue
                            rows_by_index[idx_int] = cached_row
                except Exception:
                    rows_by_index = {}

            for row in params.rows:
                row_payload = row.model_dump(mode="python")
                idx = row_payload.get("row_index")
                try:
                    idx_int = int(idx)
                except (TypeError, ValueError):
                    continue
                rows_by_index[idx_int] = row_payload

            payload = {
                "run_id": effective_run_id,
                "account_id": str(account_id) if account_id else None,
                "user_id": str(user_id) if user_id else None,
                "rows": [rows_by_index[idx] for idx in sorted(rows_by_index.keys())],
                "cached_at": datetime.utcnow().isoformat(),
            }
            await redis_client.setex(
                cache_key,
                ID_HUNTER_INPUT_CACHE_TTL,
                json.dumps(payload, default=str),
            )
        except Exception as exc:  # pragma: no cover - cache best effort
            log.warning("Failed to cache ID Hunter input rows: %s", exc)

    await _update_job_progress(
        redis_client,
        job_id,
        status="processing",
        progress=10,
        message="Fetching Salesforce Accounts",
        extra={"total_rows": total_rows},
    )

    source_df = _build_source_dataframe(
        params.rows, use_domain=params.options.use_domain, mappings=params.mappings
    )

    additional_fields, invalid_fields = _normalize_additional_fields(
        params.options.additional_fields, allowed_fields=allowed_additional_fields
    )
    ultimate_parents_only = bool(getattr(params.options, "ultimate_parents_only", False))
    reference_records, warnings, domain_source_field = await _fetch_reference_accounts(
        sf_client,
        redis_client,
        job_id,
        timeout_s=fetch_timeout_s,
        additional_fields=additional_fields,
        ultimate_parents_only=ultimate_parents_only,
        report_id=params.options.report_id,
    )
    if invalid_fields:
        warnings.append(
            {
                "code": "invalid_additional_fields",
                "message": (
                    "Ignored additional fields not in allowlist: "
                    + ", ".join(sorted(set(invalid_fields)))
                ),
            }
        )
    await _update_job_progress(
        redis_client,
        job_id,
        status="processing",
        progress=50,
        message="Fetched Accounts, running matcher",
        extra={
            "reference_rows": len(reference_records),
            "reference_domain_field": domain_source_field,
        },
    )

    reference_df, reference_lookup = _build_reference_dataframe(
        reference_records,
        use_domain=params.options.use_domain,
        additional_fields=additional_fields,
        domain_source_field=domain_source_field,
    )

    if reference_df.empty:
        results = [
            IDHunterMatch(
                row_index=row.row_index,
                salesforce_id=None,
                confidence=None,
                match_score=None,
                match_method=None,
                match_explanation=None,
                display_status="No Match",
                name_score=None,
                matched_name=None,
                matched_website=None,
                record_url=None,
                custom_fields=None,
                alternative_match_count=0,
                warnings=None,
                status="unmatched",
            )
            for row in params.rows
        ]
        summary_counts = {
            "total": len(params.rows),
            "matched": 0,
            "needs_review": 0,
            "no_match": len(params.rows),
            "multiple_candidates": 0,
        }
        return results, summary_counts, warnings

    source_domain_norm: Dict[str, Optional[str]] = {}
    source_registrable: Dict[str, Optional[str]] = {}
    source_domain_origin: Dict[str, Optional[str]] = {}
    reference_domain_norm: Dict[str, Optional[str]] = {}
    reference_registrable: Dict[str, Optional[str]] = {}
    source_names: Dict[str, Optional[str]] = {}
    reference_names: Dict[str, Optional[str]] = {}
    if params.options.use_domain:
        if "domain" in source_df.columns:
            source_domains = normalize_domain_series(source_df["domain"])
            source_domain_norm = dict(
                zip(source_df["row_id"].astype(str), source_domains)
            )
            source_reg_series = extract_registrable_domains(source_df["domain"])
            source_registrable = dict(
                zip(source_df["row_id"].astype(str), source_reg_series)
            )
        if "domain_origin" in source_df.columns:
            source_domain_origin = dict(
                zip(source_df["row_id"].astype(str), source_df["domain_origin"])
            )
        if "domain" in reference_df.columns:
            reference_domains = normalize_domain_series(reference_df["domain"])
            reference_domain_norm = dict(
                zip(reference_df["sf_id"].astype(str), reference_domains)
            )
            reference_reg_series = extract_registrable_domains(reference_df["domain"])
            reference_registrable = dict(
                zip(reference_df["sf_id"].astype(str), reference_reg_series)
            )
    if "company_name" in source_df.columns:
        source_names = dict(
            zip(source_df["row_id"].astype(str), source_df["company_name"])
        )
    if "company_name" in reference_df.columns:
        reference_names = dict(
            zip(reference_df["sf_id"].astype(str), reference_df["company_name"])
        )

    threshold = DEFAULT_MATCH_THRESHOLD
    stage_progress = {
        "pass1_start": 55,
        "pass1_done": 70,
        "pass2_start": 76,
        "pass2_heartbeat": 80,
        "pass2_done": 86,
        "pass2_skipped": 86,
    }

    async def _match_progress_update(event: Dict[str, Any]) -> None:
        stage = str(event.get("stage") or "").strip().lower()
        progress_raw = event.get("progress")
        progress = stage_progress.get(stage, 75)
        try:
            if progress_raw is not None:
                progress = int(float(progress_raw))
        except (TypeError, ValueError):
            progress = stage_progress.get(stage, progress)
        progress = max(50, min(89, progress))

        message = str(event.get("message") or "Running Account Hunter matcher.")
        extra: Dict[str, Any] = {"matcher_stage": stage}
        for key in (
            "reason",
            "source_rows",
            "reference_rows",
            "pass1_matches",
            "pass1_matched_rows",
            "pass1_unmatched_rows",
            "pass2_scope_rows",
            "pass2_source_rows",
            "pass2_reference_rows",
            "pass2_matches",
        ):
            if key in event:
                extra[key] = event.get(key)

        await _update_job_progress(
            redis_client,
            job_id,
            status="processing",
            progress=progress,
            message=message,
            extra=extra,
        )

    try:
        adapter = await IDHunterGatewayAdapter.create()
        gateway_result = await adapter.match_accounts(
            source_df,
            reference_df,
            threshold=threshold,
            use_domain=params.options.use_domain,
            use_fuzzy=params.options.use_fuzzy,
            progress_hook=_match_progress_update,
        )
    except ValueError as exc:
        raise HTTPException(status_code=502, detail=str(exc)) from exc

    best_matches, alternative_counts = (
        _best_match_by_row(
            gateway_result.matches,
            source_domains=source_domain_norm,
            reference_domains=reference_domain_norm,
            source_registrable=source_registrable,
            reference_registrable=reference_registrable,
            source_names=source_names,
            reference_names=reference_names,
        )
        if gateway_result and gateway_result.matches is not None
        else ({}, {})
    )
    instance_url = (sf_client.instance_url or "").rstrip("/")
    low_threshold = (
        params.options.low_confidence_threshold
        if params.options.low_confidence_threshold is not None
        else DEFAULT_LOW_CONFIDENCE
    )
    review_threshold = (
        params.options.review_threshold
        if params.options.review_threshold is not None
        else DEFAULT_REVIEW_THRESHOLD
    )
    try:
        low_threshold = max(0.0, min(1.0, float(low_threshold)))
    except (TypeError, ValueError):
        low_threshold = DEFAULT_LOW_CONFIDENCE
    try:
        review_threshold = max(0.0, min(1.0, float(review_threshold)))
    except (TypeError, ValueError):
        review_threshold = DEFAULT_REVIEW_THRESHOLD

    results: List[IDHunterMatch] = []
    summary_counts = {
        "total": len(params.rows),
        "matched": 0,
        "needs_review": 0,
        "no_match": 0,
        "multiple_candidates": 0,
    }

    for row in params.rows:
        key = str(row.row_index)
        match_row = best_matches.get(key)
        salesforce_id = None
        confidence = None
        match_status = "unmatched"
        display_status = "No Match"
        matched_name = None
        matched_website = None
        company_fg_id = None
        record_url = None
        custom_fields = None
        name_score = None
        match_score = None
        match_method = None
        match_explanation = None
        alternative_match_count = alternative_counts.get(key, 0)
        row_warnings: List[str] = []

        if match_row:
            salesforce_id = (
                match_row.get("target_id")
                or match_row.get("sf_id")
                or match_row.get("reference_id")
                or match_row.get("ref_id")
            )
            if salesforce_id:
                salesforce_id = str(salesforce_id)
            confidence = _normalize_score(
                match_row.get("score") or match_row.get("match_score")
            )
            ref_meta = reference_lookup.get(str(salesforce_id)) if salesforce_id else None
            matched_name = ref_meta.get("matched_name") if ref_meta else None
            matched_website = ref_meta.get("matched_website") if ref_meta else None
            company_fg_id = ref_meta.get("company_fg_id") if ref_meta else None
            if additional_fields and ref_meta:
                custom_fields = ref_meta.get("custom_fields")
            source_name = row.values.get("company") if row.values else None
            raw_name_score = match_row.get("__name_score")
            if isinstance(raw_name_score, (int, float)):
                name_score = float(raw_name_score)
            else:
                name_score = _compute_name_score(source_name, matched_name)
            source_domain = source_domain_norm.get(key)
            ref_domain = (
                reference_domain_norm.get(str(salesforce_id)) if salesforce_id else None
            )
            source_reg = source_registrable.get(key)
            ref_reg = (
                reference_registrable.get(str(salesforce_id)) if salesforce_id else None
            )
            domain_origin = source_domain_origin.get(key)
            exact_domain_match = (
                source_domain is not None
                and ref_domain is not None
                and source_domain == ref_domain
            )
            if salesforce_id:
                match_score = _score_to_percent(confidence)
                if match_score is None and name_score is not None:
                    match_score = _score_to_percent(name_score)

                if confidence is None:
                    display_status = "Matched"
                elif confidence < low_threshold:
                    display_status = "No Match"
                elif confidence < review_threshold:
                    display_status = "Needs Review"
                else:
                    display_status = "Matched"

                if (
                    exact_domain_match
                    and name_score is not None
                    and name_score < EXACT_DOMAIN_NAME_LOW_CONFIDENCE
                ):
                    if display_status == "Matched":
                        display_status = "Needs Review"

                match_status = (
                    "matched"
                    if display_status == "Matched"
                    else "low_confidence"
                    if display_status == "Needs Review"
                    else "unmatched"
                )

                if display_status == "No Match":
                    salesforce_id = None
                    matched_name = None
                    matched_website = None
                    company_fg_id = None
                    record_url = None
                    custom_fields = None
                    match_method = None
                    match_explanation = None
                    match_score = None
                    name_score = None
                    alternative_match_count = 0
                    row_warnings = []
                else:
                    match_method = _detect_match_method(
                        source_domain,
                        ref_domain,
                        source_reg,
                        ref_reg,
                        name_score,
                        domain_origin,
                    )

                    explanation_score = (
                        _score_to_percent(name_score)
                        if match_method == "name_fuzzy"
                        else match_score
                    )
                    match_explanation = _build_match_explanation(
                        match_method, score=explanation_score
                    )

                    if ref_meta and ref_meta.get("owner_fields_available"):
                        owner_id = ref_meta.get("owner_id")
                        owner_is_active = ref_meta.get("owner_is_active")
                        if not owner_id:
                            row_warnings.append("Owner missing")
                        elif owner_is_active is False:
                            row_warnings.append("Owner inactive")

                    if instance_url and salesforce_id:
                        record_url = f"{instance_url}/{salesforce_id}"
            else:
                match_status = "unmatched"
                display_status = "No Match"

        if alternative_match_count and display_status != "No Match":
            summary_counts["multiple_candidates"] += 1

        if display_status == "Matched":
            summary_counts["matched"] += 1
        elif display_status == "Needs Review":
            summary_counts["needs_review"] += 1
        else:
            summary_counts["no_match"] += 1

        results.append(
            IDHunterMatch(
                row_index=row.row_index,
                salesforce_id=salesforce_id,
                confidence=confidence,
                company_fg_id=company_fg_id,
                match_score=match_score,
                match_method=match_method,
                match_explanation=match_explanation,
                display_status=display_status,
                name_score=name_score,
                matched_name=matched_name,
                matched_website=matched_website,
                record_url=record_url,
                custom_fields=custom_fields,
                alternative_match_count=alternative_match_count,
                warnings=row_warnings or None,
                status=match_status,
            )
        )

    await _update_job_progress(
        redis_client,
        job_id,
        status="processing",
        progress=90,
        message="Finalizing Account Hunter results",
        extra={
            "matches_found": summary_counts.get("matched", 0),
            "rows_processed": len(results),
            "result_rows": len(results),
        },
    )

    return results, summary_counts, warnings


@router.get("/available-fields")
async def get_available_hunter_fields(
    account_id: str = Depends(require_account),
    db: AsyncSession = Depends(get_session),
) -> List[Dict[str, str]]:
    """Get available Account fields for ID Hunter."""
    repo = SalesforceSchemaIndexRepo(db)
    fields = await repo.list_fields(str(account_id), "Account")
    entries = [
        {"api_name": field.field_api, "label": field.label or field.field_api}
        for field in fields
        if field.queryable
        and (field.field_api in ALLOWED_ADDITIONAL_FIELDS or field.is_custom)
    ]
    return sorted(entries, key=lambda item: item["label"].lower())


@router.get("/reports")
async def list_hunter_reports(
    account_id: str = Depends(require_account),
    user_id: str = Depends(require_user),
    sf: SalesforceGateway = Depends(get_salesforce_gateway),
    redis_client: Optional[Redis] = Depends(_maybe_get_redis_client),
) -> Dict[str, Any]:
    cache_key = _report_list_cache_key(str(account_id), str(user_id))
    if redis_client:
        try:
            cached = await redis_client.get(cache_key)
            if cached:
                payload = json.loads(cached)
                if isinstance(payload, dict):
                    return payload
        except Exception as exc:  # pragma: no cover
            log.warning("Failed to read report list cache: %s", exc)

    # Do not force report_type filtering here. Salesforce report metadata exposed
    # via SOQL is inconsistent for account scoping, and strict type filters can
    # hide valid Account reports (for example custom QA reports).
    result = await sf.list_reports(limit=200)
    payload = {
        "items": result.get("items", []) if isinstance(result, dict) else [],
        "next_page_token": result.get("next_page_token", ""),
    }
    if redis_client:
        try:
            await redis_client.setex(
                cache_key, ID_HUNTER_REPORT_CACHE_TTL, json.dumps(payload)
            )
        except Exception as exc:  # pragma: no cover
            log.warning("Failed to cache report list: %s", exc)
    return payload


@router.get("/reports/{report_id}/preview")
async def preview_hunter_report(
    report_id: str,
    account_id: str = Depends(require_account),
    user_id: str = Depends(require_user),
    sf: SalesforceGateway = Depends(get_salesforce_gateway),
    redis_client: Optional[Redis] = Depends(_maybe_get_redis_client),
) -> Dict[str, Any]:
    account_ids, meta, warnings = await _load_report_account_ids(
        sf, redis_client, account_id=str(account_id), report_id=report_id
    )
    row_count = int(meta.get("row_count") or 0)
    report_name = meta.get("report_name") or ""
    account_count = len(account_ids)
    truncated = row_count > MAX_ORG_ACCOUNTS
    requires_async = account_count >= SYNC_ORG_THRESHOLD
    if row_count > MAX_ORG_ACCOUNTS:
        warnings.append(
            {
                "code": "report_truncated",
                "message": (
                    f"Report has {row_count} rows. Only first "
                    f"{MAX_ORG_ACCOUNTS} will be matched."
                ),
            }
        )
    if row_count == 0:
        warnings.append(
            {"code": "report_empty", "message": "Report contains no rows."}
        )
    return {
        "report_id": report_id,
        "report_name": report_name,
        "row_count": row_count,
        "account_count": account_count,
        "requires_async": requires_async,
        "truncated": truncated,
        "warnings": warnings,
    }


@router.post("/preflight", response_model=IDHunterPreflightResponse)
async def id_hunter_preflight(
    body: IDHunterPreflightRequest,
    account_id: str = Depends(require_account),
    _user_id: str = Depends(require_user),
    sf: SalesforceGateway = Depends(get_salesforce_gateway),
    redis_client: Optional[Redis] = Depends(_maybe_get_redis_client),
) -> IDHunterPreflightResponse:
    if body.salesforce_object.lower() != "account":
        raise HTTPException(
            status_code=400,
            detail="Account Hunter currently supports Salesforce Account lookups only.",
        )

    row_count = int(body.row_count or 0)
    if row_count > MAX_ROWS:
        raise HTTPException(
            status_code=400,
            detail={
                "code": "id_hunter_row_limit_exceeded",
                "message": (
                    f"Account Hunter supports up to {MAX_ROWS:,} rows per run. "
                    f"This request has {row_count:,} rows."
                ),
                "max_rows": MAX_ROWS,
                "row_count": row_count,
            },
        )

    status = await sf.get_status()
    if not status.get("connected") or not status.get("instance_url"):
        detail = (
            status.get("message")
            or "Salesforce connection not available. Please reconnect Salesforce before running Account Hunter."
        )
        raise HTTPException(status_code=400, detail=detail)

    shape = await _assess_hunter_run_shape(
        sf=sf,
        redis_client=redis_client,
        account_id=str(account_id),
        options=body.options,
        row_count=row_count,
    )
    scope_detail = (
        _build_scope_required_detail(shape.get("account_count"))
        if shape.get("scope_required")
        else {}
    )

    return IDHunterPreflightResponse(
        salesforce_object="Account",
        connected=True,
        row_count=row_count,
        requires_async=bool(shape.get("requires_async")),
        scope_required=bool(shape.get("scope_required")),
        reason_code=scope_detail.get("code"),
        message=scope_detail.get("message"),
        recommended_action=scope_detail.get("recommended_action"),
        account_count=shape.get("account_count"),
        report_id=shape.get("report_id"),
        report_row_count=shape.get("report_row_count"),
        warnings=shape.get("warnings") or None,
    )


@router.post(
    "/accounts", response_model=Union[IDHunterResponse, IDHunterAsyncSignal]
)
async def id_hunter_accounts(
    body: IDHunterRequest,
    account_id: str = Depends(require_account),
    user_id: str = Depends(require_user),
    sf: SalesforceGateway = Depends(get_salesforce_gateway),
    redis_client: Optional[Redis] = Depends(_maybe_get_redis_client),
    db: AsyncSession = Depends(get_session),
) -> Union[IDHunterResponse, IDHunterAsyncSignal]:
    if body.salesforce_object.lower() != "account":
        raise HTTPException(
            status_code=400,
            detail="Account Hunter currently supports Salesforce Account lookups only.",
        )

    if not body.rows:
        return IDHunterResponse(
            results=[],
            summary=IDHunterSummary(
                total=0,
                matched=0,
                needs_review=0,
                no_match=0,
                multiple_candidates=0,
            ),
        )

    if len(body.rows) > MAX_ROWS:
        raise HTTPException(
            status_code=400,
            detail=(
                f"Account Hunter beta supports up to {MAX_ROWS:,} input rows per lookup. "
                "Please filter or split your data."
            ),
        )

    status = await sf.get_status()
    if not status.get("connected") or not status.get("instance_url"):
        detail = (
            status.get("message")
            or "Salesforce connection not available. Please reconnect Salesforce before running Account Hunter."
        )
        raise HTTPException(status_code=400, detail=detail)

    shape = await _assess_hunter_run_shape(
        sf=sf,
        redis_client=redis_client,
        account_id=str(account_id),
        options=body.options,
        row_count=len(body.rows),
    )
    if shape.get("scope_required"):
        raise HTTPException(
            status_code=400,
            detail=_build_scope_required_detail(shape.get("account_count")),
        )
    if shape.get("requires_async"):
        account_count = shape.get("account_count")
        return IDHunterAsyncSignal(
            requires_async=True,
            account_count=(
                int(account_count)
                if isinstance(account_count, (int, float))
                else max(SYNC_ORG_THRESHOLD, ASYNC_ROW_THRESHOLD)
            ),
            estimated_time_seconds=ESTIMATED_ASYNC_DURATION_S,
        )

    allowed_fields = await _resolve_allowed_additional_fields(account_id, db)
    run_id = body.run_id or f"hunter_{uuid.uuid4().hex}"
    results, summary_counts, warnings = await _run_hunter_logic(
        sf,
        body,
        job_id=None,
        redis_client=redis_client,
        allowed_additional_fields=allowed_fields,
        run_id=run_id,
        account_id=str(account_id),
        user_id=str(user_id),
    )
    return IDHunterResponse(
        results=results,
        summary=IDHunterSummary(**summary_counts),
        run_id=run_id,
        warnings=warnings or None,
    )


@router.post("/export-unmatched")
async def export_hunter_unmatched(
    body: IDHunterExportRequest,
    account_id: str = Depends(require_account),
    user_id: str = Depends(require_user),
    redis_client: Optional[Redis] = Depends(_maybe_get_redis_client),
):
    if not body.row_indices:
        raise HTTPException(status_code=400, detail="row_indices is required.")
    if len(body.row_indices) > EXPORT_MAX_ROWS:
        raise HTTPException(
            status_code=400,
            detail=f"Export supports up to {EXPORT_MAX_ROWS:,} rows per request.",
        )
    if redis_client is None:
        raise HTTPException(status_code=503, detail="Cache service unavailable")

    cache_key = _input_cache_key(body.run_id)
    cached = await redis_client.get(cache_key)
    if not cached:
        raise HTTPException(
            status_code=404,
            detail="No cached Account Hunter input found. Please rerun Account Hunter.",
        )
    try:
        payload = json.loads(cached)
    except Exception as exc:
        raise HTTPException(status_code=500, detail="Cached data unreadable.") from exc

    if isinstance(payload, dict):
        cached_account_id = payload.get("account_id")
        if cached_account_id and str(cached_account_id) != str(account_id):
            raise HTTPException(status_code=404, detail="Account Hunter run not found.")
        cached_user_id = payload.get("user_id")
        if cached_user_id and str(cached_user_id) != str(user_id):
            raise HTTPException(status_code=404, detail="Account Hunter run not found.")

    rows = payload.get("rows") if isinstance(payload, dict) else None
    if not isinstance(rows, list):
        raise HTTPException(status_code=500, detail="Cached data missing rows.")

    rows_by_index: Dict[int, Dict[str, Any]] = {}
    for row in rows:
        if not isinstance(row, dict):
            continue
        idx = row.get("row_index")
        if idx is None:
            continue
        try:
            idx_int = int(idx)
        except (TypeError, ValueError):
            continue
        values = row.get("values") if isinstance(row.get("values"), dict) else {}
        rows_by_index[idx_int] = values

    selected_indices: List[int] = []
    for idx in body.row_indices:
        try:
            idx_int = int(idx)
        except (TypeError, ValueError):
            continue
        if idx_int in rows_by_index:
            selected_indices.append(idx_int)

    csv_content = _build_unmatched_csv(
        rows_by_index,
        selected_indices,
        schema_preset=body.schema_preset or "salesforce",
    )

    filename = f"id_hunter_unmatched_{datetime.utcnow().date().isoformat()}.csv"
    return StreamingResponse(
        io.BytesIO(csv_content.encode()),
        media_type="text/csv",
        headers={"Content-Disposition": f"attachment; filename={filename}"},
    )


@router.post("/accounts/async")
async def id_hunter_accounts_async(
    body: IDHunterRequest,
    account_id: str = Depends(require_account),
    user_id: str = Depends(require_user),
    sf: SalesforceGateway = Depends(get_salesforce_gateway),
    redis_client: Optional[Redis] = Depends(_maybe_get_redis_client),
    db: AsyncSession = Depends(get_session),
):
    if body.salesforce_object.lower() != "account":
        raise HTTPException(
            status_code=400,
            detail="Account Hunter currently supports Salesforce Account lookups only.",
        )
    if not body.rows:
        raise HTTPException(status_code=400, detail="No rows provided for Account Hunter.")
    if len(body.rows) > MAX_ROWS:
        raise HTTPException(
            status_code=400,
            detail=(
                f"Account Hunter beta supports up to {MAX_ROWS:,} input rows per lookup. "
                "Please filter or split your data."
            ),
        )

    status = await sf.get_status()
    if not status.get("connected") or not status.get("instance_url"):
        detail = (
            status.get("message")
            or "Salesforce connection not available. Please reconnect Salesforce before running Account Hunter."
        )
        raise HTTPException(status_code=400, detail=detail)

    shape = await _assess_hunter_run_shape(
        sf=sf,
        redis_client=redis_client,
        account_id=str(account_id),
        options=body.options,
        row_count=len(body.rows),
    )
    if shape.get("scope_required"):
        raise HTTPException(
            status_code=400,
            detail=_build_scope_required_detail(shape.get("account_count")),
        )

    if redis_client is None:
        raise HTTPException(status_code=503, detail="Cache service unavailable")

    storage = get_storage_backend()
    job_service = JobService(
        db_session=db, redis_client=redis_client, storage=storage, ml_detector=None
    )
    project = await job_service.get_or_create_default_project(account_id)

    job_config = {
        "request": body.model_dump(mode="python"),
        "account_id": account_id,
        "user_id": user_id,
        "rows_total": len(body.rows),
        "salesforce_object": body.salesforce_object,
    }

    inline_mode = _is_inline_processing_enabled()
    try:
        if inline_mode:
            job = await job_service.create_job(
                project.id,
                JobType.ID_HUNTER,
                job_config,
                status=JobStatus.PROCESSING,
            )
        else:
            job = await job_service.create_and_enqueue_job(
                project.id, JobType.ID_HUNTER, job_config
            )
    except JobCreationError as exc:
        raise HTTPException(status_code=500, detail=str(exc))

    if not job.account_id:
        job.account_id = str(account_id)
        db.add(job)
        await db.commit()
        await db.refresh(job)

    if inline_mode:
        await redis_client.setex(
            f"job:{job.id}",
            86400,
            json.dumps(
                {
                    "status": "processing",
                    "state": "processing",
                    "progress": 5,
                    "message": "Account Hunter started (inline mode)",
                    "rows_total": len(body.rows),
                }
            ),
        )
        try:
            _schedule_inline_job_execution(
                str(job.id), job_config, str(account_id), redis_client
            )
        except Exception as exc:
            log.exception("Failed to schedule inline Account Hunter job %s", job.id)
            await redis_client.setex(
                f"job:{job.id}",
                86400,
                json.dumps(
                    {
                        "status": "failed",
                        "state": "failed",
                        "progress": 100,
                        "message": "Failed to schedule inline Account Hunter job",
                        "error": str(exc),
                        "rows_total": len(body.rows),
                    }
                ),
            )
            raise HTTPException(
                status_code=500,
                detail="Failed to start Account Hunter job inline.",
            )
    else:
        await redis_client.setex(
            f"job:{job.id}",
            86400,
            json.dumps(
                {
                    "status": "queued",
                    "state": "queued",
                    "progress": 0,
                    "message": "Account Hunter job queued",
                    "rows_total": len(body.rows),
                }
            ),
        )

    poll_url = f"/api/v2/jobs/{job.id}/status"
    status_val = job.status.value if hasattr(job.status, "value") else str(job.status)

    return {"job_id": str(job.id), "status": status_val, "poll_url": poll_url}


async def run_id_hunter_job(
    job_id: str, job_config: Dict[str, Any]
) -> Tuple[List[IDHunterMatch], Dict[str, int], List[Dict[str, str]]]:
    if not job_config:
        raise ValueError("Missing configuration for Account Hunter job")

    payload = job_config.get("request") or job_config
    account_id = job_config.get("account_id") or payload.get("account_id")
    if not account_id:
        raise ValueError("account_id required for Account Hunter job")

    params = IDHunterRequest(**payload)
    redis_client = _maybe_get_redis_client()

    async with session_context() as db_session:
        sf_client = await SalesforceGateway.for_tenant(str(account_id), db_session)
        connection_status = await sf_client.get_status()
        if not connection_status.get("connected") or not connection_status.get(
            "instance_url"
        ):
            detail = connection_status.get("message") or "Salesforce connection not available."
            raise RuntimeError(detail)

        allowed_fields = await _resolve_allowed_additional_fields(
            str(account_id), db_session
        )
        user_id_val = job_config.get("user_id") or payload.get("user_id")
        return await _run_hunter_logic(
            sf_client,
            params,
            job_id=job_id,
            redis_client=redis_client,
            fetch_timeout_s=ID_HUNTER_ASYNC_FETCH_TIMEOUT_S,
            allowed_additional_fields=allowed_fields,
            run_id=job_id,
            account_id=str(account_id),
            user_id=str(user_id_val) if user_id_val else None,
        )
