from __future__ import annotations

import logging
import os
from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING
import re
import unicodedata
from collections import defaultdict

from sqlalchemy.ext.asyncio import AsyncSession

from .cache import HybridCache, make_cache_key

if TYPE_CHECKING:
    from .salesforce_gateway import SalesforceGateway
    from .gremlin_intent_router import ResolutionTarget
from ..settings import API_CACHE_TTL_SEC
from .batch_enrichment import _batch_enrich_from_foundrygraph
from ..operators.company_domain_resolver import get_override_for_name
from ..operators import (
    normalize_company as op_normalize_company,
    normalize_company_batch_async as op_normalize_company_batch_async,
    assign_global_region as op_assign_global_region,
    normalize_state as op_normalize_state,
    normalize_title as op_normalize_title,
    is_business_email as op_is_business_email,
    company_to_domain as op_company_to_domain,
    resolve_company_to_domain_batch as op_company_to_domain_batch,
    fix_phone_format as op_fix_phone_format,
    extract_domain_root as op_extract_domain_root,
    clean_website as op_clean_website,
    split_fullname as op_split_fullname,
    normalize_country as op_normalize_country,
    clean_name as op_clean_name,
    email_domain_match as op_email_domain_match,
    extract_id_from_url as op_extract_id_from_url,
    extract_subdomain as op_extract_subdomain,
    extract_domain_from_email as op_extract_domain_from_email,
    normalize_industry as op_normalize_industry,
    parse_email_pattern as op_parse_email_pattern,
    company_size_bucket as op_company_size_bucket,
    normalize_job_function as op_normalize_job_function,
    is_role_based_email as op_is_role_based_email,
    parse_address_components as op_parse_address_components,
    calculate_completeness as op_calculate_completeness,
    detect_data_quality_issues as op_detect_data_quality_issues,
    detect_duplicate_likelihood as op_detect_duplicate_likelihood,
    detect_technographics as op_detect_technographics,
    infer_employee_count as op_infer_employee_count,
    normalize_phone_to_e164 as op_normalize_phone_to_e164,
    calculate_lead_score as op_calculate_lead_score,
    assign_territory as op_assign_territory,
    assign_us_region as op_assign_us_region,
    classify_revenue_band as op_classify_revenue_band,
    generate_dedup_key as op_generate_dedup_key,
    parse_naics as op_parse_naics,
    parse_sic as op_parse_sic,
    detect_parent_company as op_detect_parent_company,
    detect_parent_company_batch as op_detect_parent_company_batch,
    classify_lead_source as op_classify_lead_source,
    state_name_to_code as op_state_name_to_code,
    enrich_company_industry as op_enrich_company_industry,
    enrich_company_country as op_enrich_company_country,
    enrich_company_headquarters as op_enrich_company_headquarters,
    enrich_company_revenue as op_enrich_company_revenue,
    enrich_from_foundrygraph as op_enrich_from_foundrygraph,
    enrich_social_handles as op_enrich_social_handles,
    enrich_stock_info as op_enrich_stock_info,
    enrich_subsidiaries as op_enrich_subsidiaries,
)

logger = logging.getLogger(__name__)

TRANSFORM_CACHE = HybridCache(
    namespace="transform", lru_size=1024, ttl=API_CACHE_TTL_SEC
)

DEFAULT_COMPANY_DOMAIN_THRESHOLDS = {"auto_link": 0.80, "suggest": 0.60}


_WS = re.compile(r"\s+")


def _normalize_confidence_value(value: Any) -> float:
    if value is None:
        return 0.0
    if isinstance(value, (int, float)):
        return float(value)
    if isinstance(value, str):
        lowered = value.strip().lower()
        if lowered in {"high", "hi"}:
            return 0.9
        if lowered in {"med", "medium"}:
            return 0.7
        if lowered in {"low", "lo"}:
            return 0.5
        try:
            return float(lowered)
        except ValueError:
            return 0.0
    try:
        return float(value)
    except (TypeError, ValueError):
        return 0.0


def _append_resolution_note(existing: str, note: str) -> str:
    existing = (existing or "").strip()
    note = (note or "").strip()
    if not note:
        return existing
    if not existing:
        return note
    if note in existing:
        return existing
    return f"{existing}; {note}"

SUPPORTED_OPERATIONS = {
    "assign_global_region",
    "assign_territory",
    "assign_us_region",
    "calculate_completeness",
    "calculate_lead_score",
    "classify_lead_source",
    "classify_revenue_band",
    "clean_name",
    "clean_website",
    "company_size_bucket",
    "company_to_domain",
    "detect_data_quality_issues",
    "detect_duplicate_likelihood",
    "detect_parent_company",
    "detect_technographics",
    "email_domain_match",
    "enrich_company_country",
    "enrich_company_headquarters",
    "enrich_company_industry",
    "enrich_company_name",
    "enrich_company_revenue",
    "enrich_from_foundrygraph",
    "enrich_social_handles",
    "enrich_stock_info",
    "enrich_subsidiaries",
    "extract_domain_from_email",
    "extract_domain_root",
    "extract_id_from_url",
    "extract_subdomain",
    "fix_phone_format",
    "generate_dedup_key",
    "infer_employee_count",
    "is_business_email",
    "is_role_based_email",
    "normalize_company",
    "normalize_country",
    "normalize_industry",
    "normalize_job_function",
    "normalize_phone_to_e164",
    "normalize_state",
    "normalize_title",
    "parse_address_components",
    "parse_email_pattern",
    "parse_naics",
    "parse_sic",
    "split_fullname",
    "state_name_to_code",
}


def _canon_for_cache(op: str, s: str, params: Dict[str, Any]) -> str:
    """Canonicalize string inputs to improve cache hit rate without changing semantics."""
    # Normalize unicode, trim, lowercase
    s = unicodedata.normalize("NFKC", (s or "")).strip().lower()
    # Collapse internal whitespace
    s = _WS.sub(" ", s)
    # Domain-ish ops: strip protocol and www for better hits
    if op in {
        "company_to_domain",
        "extract_domain_root",
        "is_business_email",
        "email_domain_match",
    }:
        if s.startswith("http://"):
            s = s[7:]
        elif s.startswith("https://"):
            s = s[8:]
        if s.startswith("www."):
            s = s[4:]
    return s




def _flatten_value(op: str, obj: Dict[str, Any]) -> Any:
    op = op.lower()
    if op in (
        "normalize_company",
        "normalize_state",
        "state_name_to_code",
        "normalize_country",
        "fix_phone_format",
        "clean_website",
        "extract_domain_root",
        "clean_name",
        "extract_subdomain",
        "normalize_industry",
        "normalize_job_function",
        "company_size_bucket",
        "normalize_phone_to_e164",
        "assign_territory",
        "generate_dedup_key",
        "detect_parent_company",
        "classify_lead_source",
        "assign_global_region",
        "assign_us_region",
    ):
        return obj.get("value", "")
    if op == "normalize_title":
        val = obj.get("value") or {}
        return [
            val.get("title", ""),
            val.get("department", ""),
            val.get("seniority", ""),
        ]
    if op in ("is_business_email", "email_domain_match", "is_role_based_email"):
        return bool(obj.get("value", False))
    if op == "company_to_domain":
        return _flatten_company_to_domain(obj)
    if op == "split_fullname":
        val = obj.get("value") or ["", ""]
        return val if isinstance(val, list) else ["", ""]
    if op == "parse_email_pattern":
        # Return the full dict for email pattern analysis
        return obj.get("value") or {}
    if op == "calculate_lead_score":
        # Return score and grade
        return obj.get("value", 0)
    if op == "classify_revenue_band":
        # Return the band label
        return obj.get("value", "")
    if op == "calculate_completeness":
        val = obj.get("value") or ["", ""]
        return val if isinstance(val, list) else ["", ""]
    if op == "detect_data_quality_issues":
        val = obj.get("value") or [False, ""]
        if isinstance(val, list):
            return [
                (bool(val[0]) if len(val) > 0 else False),
                (val[1] if len(val) > 1 else ""),
            ]
        return [bool(val), ""]
    if op == "detect_duplicate_likelihood":
        val = obj.get("value") or ["", ""]
        if isinstance(val, list):
            return (val + ["", ""])[:2]
        return ["", ""]
    if op == "infer_employee_count":
        val = obj.get("value") or ["", ""]
        return val if isinstance(val, list) else ["", ""]
    if op == "detect_technographics":
        val = obj.get("value") or ["", "", ""]
        if isinstance(val, list):
            return (val + ["", "", ""])[:3]
        return ["", "", ""]
    if op == "extract_id_from_url":
        val = obj.get("value")
        if isinstance(val, list):
            return (val + ["", "", ""])[:3]
        if isinstance(val, dict):
            return [val.get("provider", ""), val.get("type", ""), val.get("id", "")]
        return ["", "", ""]
    if op == "parse_address_components":
        val = obj.get("value")
        if isinstance(val, list):
            return (val + ["", "", "", ""])[:4]
        return ["", "", "", ""]
    if op == "parse_naics":
        val = obj.get("value")
        if isinstance(val, list):
            return (val + ["", "", ""])[:3]
        return ["", "", ""]
    if op == "parse_sic":
        val = obj.get("value")
        if isinstance(val, list):
            return (val + ["", ""])[:2]
        return ["", ""]
    if op == "enrich_company_industry":
        # Returns: industry, all_industries
        return [obj.get("industry", ""), obj.get("all_industries", "")]
    if op == "enrich_company_name":
        # Returns: company_name (canonical name from domain)
        return [obj.get("company_name", "")]
    if op == "enrich_company_country":
        # Returns: country, all_countries
        return [obj.get("country", ""), obj.get("all_countries", "")]
    if op == "enrich_company_headquarters":
        # Returns: headquarters, all_headquarters
        return [obj.get("headquarters", ""), obj.get("all_headquarters", "")]
    if op == "enrich_company_revenue":
        # Returns: revenue, currency, year
        return [obj.get("revenue", ""), obj.get("currency", ""), obj.get("year", "")]
    if op == "enrich_from_foundrygraph":
        return [
            obj.get("canonical_name", ""),
            obj.get("domain", ""),
            obj.get("wikidata_id", ""),
            obj.get("industries", ""),
            obj.get("headquarters", ""),
            obj.get("country", ""),
            obj.get("revenue_amount", ""),
            obj.get("revenue_currency", ""),
            obj.get("revenue_year", ""),
            obj.get("employee_count", ""),
            obj.get("stock_ticker", ""),
            obj.get("stock_exchange", ""),
            obj.get("parent_company", ""),
            obj.get("parent_domain", ""),
            obj.get("twitter_handle", ""),
            obj.get("youtube_channel", ""),
        ]
    if op == "enrich_social_handles":
        # Returns: twitter, youtube
        return [obj.get("twitter", ""), obj.get("youtube", "")]
    if op == "enrich_stock_info":
        # Returns: ticker, exchange, isin
        return [obj.get("ticker", ""), obj.get("exchange", ""), obj.get("isin", "")]
    if op == "enrich_subsidiaries":
        # Returns: subsidiary_count, subsidiaries
        return [obj.get("subsidiary_count", 0), obj.get("subsidiaries", "")]
    return obj


def _canon_pair_for_cache(o: str, v: Any, params: Dict[str, Any]) -> Any:
    # Canonicalize dict payloads (email_domain_match)
    if isinstance(v, dict):
        email = (v.get("email", "") or "").strip().lower()
        company = (v.get("company", "") or "").strip().lower()
        domain = (v.get("domain", "") or "").strip().lower()
        return f"{email}|{company}|{domain}"
    return v  # strings handled by _canon_for_cache




log = logging.getLogger(__name__)


def validate_output_shape(op: str, result: Dict[str, Any]) -> Dict[str, Any]:
    """Enforce expected output shapes based on registry metadata."""
    from ..operators.registry import registry

    if not isinstance(result, dict):
        return result

    op_def = registry.get(op)
    if not op_def:
        return result

    expected_outputs = op_def.get("outputs", 1)
    value = result.get("value")

    if expected_outputs == 1:
        if isinstance(value, list):
            log.warning("Operation %s returned list but expects single value", op)
            result["value"] = value[0] if value else ""
        return result

    if not isinstance(value, list):
        log.error(
            "Operation %s expected %s outputs, got single value", op, expected_outputs
        )
        result["value"] = [""] * expected_outputs
        return result

    if len(value) != expected_outputs:
        log.warning(
            "Operation %s returned %s outputs, expected %s",
            op,
            len(value),
            expected_outputs,
        )
        result["value"] = (value + [""] * expected_outputs)[:expected_outputs]

    return result


def validate_transform_input(values: List[Any]) -> List[Any]:
    """Validate and sanitize transform input at service boundary."""
    validated = []

    for idx, value in enumerate(values):
        # Reject non-dict, non-row payloads for row-based operations
        if isinstance(value, dict):
            # Check for row-based payload
            if "row" in value and not isinstance(value["row"], (list, tuple)):
                log.warning(
                    f"Invalid row payload at index {idx}: row must be list/tuple"
                )
                validated.append({"error": "invalid_row_type"})
                continue

            # Check for excessive nesting
            if _check_excessive_nesting(value, max_depth=5):
                log.warning(f"Excessively nested payload at index {idx}")
                validated.append({"error": "excessive_nesting"})
                continue

        # Sanitize string inputs
        elif isinstance(value, str):
            # Truncate extremely long strings
            if len(value) > 10000:
                log.warning(
                    f"Truncating long string at index {idx}: {len(value)} chars"
                )
                value = value[:10000]

        # Allow None, numbers, booleans
        elif value is not None and not isinstance(value, (int, float, bool)):
            # Reject other types
            log.warning(f"Invalid value type at index {idx}: {type(value)}")
            validated.append({"error": f"unsupported_type_{type(value).__name__}"})
            continue

        validated.append(value)

    return validated


def _check_excessive_nesting(obj: Any, max_depth: int, current_depth: int = 0) -> bool:
    """Check if object has excessive nesting."""
    if current_depth > max_depth:
        return True

    if isinstance(obj, dict):
        for value in obj.values():
            if _check_excessive_nesting(value, max_depth, current_depth + 1):
                return True
    elif isinstance(obj, (list, tuple)):
        for item in obj:
            if _check_excessive_nesting(item, max_depth, current_depth + 1):
                return True

    return False


async def batch_transform(
    op: str,
    values: List[Any],
    params: Optional[Dict[str, Any]] = None,
    account_id: Optional[str] = None,
    db: Optional[AsyncSession] = None,
    resolution_target: Optional["ResolutionTarget"] = None,
) -> Dict[str, Any]:
    """Process batch transform with input validation.

    For SFDC-aware operations (enrich_company_name), pass account_id and db
    to enable CRM-first resolution via the waterfall.

    Args:
        resolution_target: For company resolution ops:
            - SMART: Try SFDC first, fall back to FoundryGraph
            - SALESFORCE: SFDC only, no fallback
            - FOUNDRYGRAPH: FoundryGraph only, skip SFDC
            - None: defaults to SMART
    """
    from .gremlin_intent_router import ResolutionTarget
    # Validate inputs at service boundary
    values = validate_transform_input(values)
    params = params or {}
    op_key = (op or "").strip().lower()

    if op_key not in SUPPORTED_OPERATIONS:
        raise ValueError(f"Operation '{op_key}' is not implemented")

    # Use optimized batch processing for company_to_domain, normalize_company, and detect_parent_company
    if op_key == "company_to_domain":
        return await _batch_company_to_domain(values, params)

    data: List[Any] = []
    cache_hits = 0
    llm_metrics = None  # Track LLM metrics from any LLM call
    llm_calls = 0  # Count how many items used LLM
    company_stats = None
    if op_key == "company_to_domain":
        company_stats = {
            "source_counts": defaultdict(int),
            "confidence_counts": defaultdict(int),
            "miss_counts": defaultdict(int),
        }

    local_results: Dict[str, Any] = {}

    # Resolution metadata tracking
    effective_target = resolution_target or ResolutionTarget.SMART
    sfdc_unavailable = False
    sfdc_error_reason: Optional[str] = None
    resolution_stats = {
        "sfdc_matches": 0,
        "fg_matches": 0,
        "no_matches": 0,
        "errors": 0,
    }
    row_meta: List[Dict[str, Any]] = []  # Per-row metadata for enrichment ops

    # Lazily build SFDC gateway for SFDC-aware operations
    sfdc_gateway: Optional["SalesforceGateway"] = None
    sfdc_ops = {"normalize_company", "enrich_company_name", "enrich_from_foundrygraph"}
    if op_key in sfdc_ops and account_id and db:
        # Skip SFDC if target is FOUNDRYGRAPH only
        if effective_target != ResolutionTarget.FOUNDRYGRAPH:
            try:
                from .salesforce_gateway import SalesforceGateway
                sfdc_gateway = await SalesforceGateway.for_tenant(account_id, db)
                logger.info("SFDC gateway initialized for %s batch", op_key)
            except Exception as exc:
                logger.warning("SFDC gateway not available for batch: %s", exc)
                sfdc_unavailable = True
                sfdc_error_reason = str(exc)[:200]
                # Only error if target was SALESFORCE-only (no fallback)
                if effective_target == ResolutionTarget.SALESFORCE:
                    logger.error("SFDC gateway required but unavailable for target=SALESFORCE")

    if op_key == "normalize_company":
        return await _batch_normalize_company(
            values,
            params,
            sfdc_gateway=sfdc_gateway,
            resolution_target=effective_target,
            sfdc_unavailable=sfdc_unavailable,
            sfdc_error_reason=sfdc_error_reason,
        )

    if op_key == "enrich_from_foundrygraph":
        return await _batch_enrich_from_foundrygraph(
            values,
            params,
            sfdc_gateway=sfdc_gateway,
            resolution_target=effective_target,
            sfdc_unavailable=sfdc_unavailable,
            sfdc_error_reason=sfdc_error_reason,
            cache=TRANSFORM_CACHE,
            canon_func=_canon_for_cache,
        )

    if op_key == "detect_parent_company":
        return await _batch_detect_parent_company(values, params)

    def _resolution_cache_key(canon_value: str) -> str:
        cache_payload: Dict[str, Any] = {
            "op": op_key,
            "value": canon_value,
            "params": params,
        }
        if op_key == "enrich_company_name":
            cache_payload["resolution_target"] = effective_target.value
            cache_payload["sfdc_available"] = bool(sfdc_gateway) and not sfdc_unavailable
        return make_cache_key("transform", cache_payload)

    def _local_key(canon_value: str) -> str:
        return f"{canon_value}|target={effective_target.value}|sfdc={'1' if (sfdc_gateway and not sfdc_unavailable) else '0'}"

    def _update_resolution_tracking(row_output: Dict[str, Any]) -> None:
        nonlocal sfdc_unavailable, sfdc_error_reason
        if op_key != "enrich_company_name" or not isinstance(row_output, dict):
            return
        source = row_output.get("resolution_source") or row_output.get("source") or "none"
        if row_output.get("resolution_error"):
            resolution_stats["errors"] += 1
        elif source == "salesforce":
            resolution_stats["sfdc_matches"] += 1
        elif source == "foundrygraph":
            resolution_stats["fg_matches"] += 1
        else:
            resolution_stats["no_matches"] += 1

        meta_entry = {
            "source": source,
            "confidence": row_output.get("resolution_confidence", 0.0),
            "notes": row_output.get("resolution_notes", ""),
            "sfdc_account_id": row_output.get("sfdc_account_id"),
            "sfdc_account_name": row_output.get("sfdc_account_name"),
            "sfdc_unavailable": row_output.get("sfdc_unavailable", False),
            "sfdc_error_reason": row_output.get("sfdc_error_reason"),
        }
        row_meta.append(meta_entry)

        if meta_entry["sfdc_unavailable"]:
            sfdc_unavailable = True
            if meta_entry["sfdc_error_reason"] and not sfdc_error_reason:
                sfdc_error_reason = meta_entry["sfdc_error_reason"]

    for v in values:
        raw = v
        sv = "" if v is None else str(v)
        canon_base = _canon_pair_for_cache(op_key, raw, params)
        canon = _canon_for_cache(
            op_key, canon_base if isinstance(canon_base, str) else sv, params
        )
        cache_key = _resolution_cache_key(canon)
        cached = await TRANSFORM_CACHE.get(cache_key)
        if cached is not None:
            data.append(cached)
            cache_hits += 1
            if op_key == "enrich_company_name":
                _update_resolution_tracking(cached)
            continue

        local_cache_key = _local_key(canon)
        if local_cache_key in local_results:
            cached_local = local_results[local_cache_key]
            data.append(cached_local)
            if op_key == "enrich_company_name":
                _update_resolution_tracking(cached_local)
            continue

        if op_key == "normalize_company":
            res = op_normalize_company(
                sv,
                force_llm=bool(params.get("force_llm")),
                sfdc_gateway=sfdc_gateway,
                resolution_target=getattr(effective_target, "value", effective_target),
                salesforce_connected=not sfdc_unavailable,
            )
        elif op_key == "state_name_to_code":
            res = op_state_name_to_code(sv)
        elif op_key == "normalize_state":
            res = op_normalize_state(
                sv,
                country=(params.get("country") or os.getenv("DEFAULT_COUNTRY", "US")),
            )
        elif op_key == "normalize_title":
            res = op_normalize_title(sv)
        elif op_key == "is_business_email":
            res = op_is_business_email(sv)
        elif op_key == "company_to_domain":
            res = op_company_to_domain(sv)
        elif op_key == "fix_phone_format":
            res = op_fix_phone_format(sv, country=(params.get("country") or "US"))
        elif op_key == "extract_domain_root":
            res = op_extract_domain_root(sv)
        elif op_key == "clean_website":
            res = op_clean_website(sv)
        elif op_key == "split_fullname":
            res = op_split_fullname(sv)
        elif op_key == "normalize_country":
            res = op_normalize_country(sv)
        elif op_key == "clean_name":
            res = op_clean_name(sv)
        elif op_key == "email_domain_match":
            res = op_email_domain_match(raw)
        elif op_key == "extract_domain_from_email":
            strip_b2c = params.get("strip_b2c")
            if isinstance(strip_b2c, str):
                strip_b2c = strip_b2c.lower() == "true"
            res = op_extract_domain_from_email(sv, strip_b2c=bool(strip_b2c))
        elif op_key == "extract_subdomain":
            res = op_extract_subdomain(sv)
        elif op_key == "normalize_industry":
            res = op_normalize_industry(sv, force_llm=bool(params.get("force_llm")))
        elif op_key == "parse_email_pattern":
            res = op_parse_email_pattern(sv)
        elif op_key == "calculate_completeness":
            res = op_calculate_completeness(
                raw if isinstance(raw, dict) else {"row": raw}
            )
        elif op_key == "detect_data_quality_issues":
            res = op_detect_data_quality_issues(
                raw if isinstance(raw, dict) else {"value": raw}
            )
        elif op_key == "detect_duplicate_likelihood":
            if isinstance(raw, dict):
                payload = dict(raw)
            else:
                payload = {"row": raw}
            mode_override = (
                params.get("duplicateMode") or params.get("mode") or payload.get("mode")
            )
            if mode_override:
                payload.setdefault("mode", mode_override)
            threshold_override = None
            if "duplicateThreshold" in params:
                threshold_override = params.get("duplicateThreshold")
            elif "threshold" in params:
                threshold_override = params.get("threshold")
            if threshold_override is not None:
                try:
                    threshold_value = float(threshold_override)
                except (TypeError, ValueError):
                    threshold_value = threshold_override
                payload.setdefault("threshold", threshold_value)
            res = op_detect_duplicate_likelihood(payload)
        elif op_key == "infer_employee_count":
            res = op_infer_employee_count(
                raw if isinstance(raw, dict) else {"row": raw}
            )
        elif op_key == "detect_technographics":
            res = op_detect_technographics(
                raw if isinstance(raw, dict) else {"row": raw}
            )
        elif op_key == "company_size_bucket":
            res = op_company_size_bucket(raw)  # Can be string or number
        elif op_key == "normalize_job_function":
            res = op_normalize_job_function(sv, force_llm=bool(params.get("force_llm")))
        elif op_key == "is_role_based_email":
            res = op_is_role_based_email(sv)
        elif op_key == "normalize_phone_to_e164":
            res = op_normalize_phone_to_e164(
                sv, country=(params.get("country") or "US")
            )
        elif op_key == "calculate_lead_score":
            res = op_calculate_lead_score(raw)  # Expects dict input
        elif op_key == "assign_territory":
            res = op_assign_territory(raw)  # Expects dict input
        elif op_key == "classify_revenue_band":
            res = op_classify_revenue_band(raw, **params)  # Can handle various formats
        elif op_key == "generate_dedup_key":
            res = op_generate_dedup_key(raw)  # Expects dict input
        elif op_key == "detect_parent_company":
            # Handle both dict and string inputs
            if isinstance(raw, dict):
                company = raw.get("company")
                website = raw.get("website")
                domain = raw.get("domain")
            else:
                # If string input, treat as company name
                company = str(raw) if raw else None
                website = None
                domain = None
            res = op_detect_parent_company(
                company=company, website=website, domain=domain
            )
        elif op_key == "classify_lead_source":
            res = op_classify_lead_source(
                source=sv, utm=params.get("utm"), referrer=params.get("referrer")
            )
        elif op_key == "assign_global_region":
            res = op_assign_global_region(sv)
        elif op_key == "assign_us_region":
            res = op_assign_us_region(sv)
        elif op_key == "extract_id_from_url":
            res = op_extract_id_from_url(sv)
        elif op_key == "parse_address_components":
            res = op_parse_address_components(sv)
        elif op_key == "parse_naics":
            res = op_parse_naics(sv)
        elif op_key == "parse_sic":
            res = op_parse_sic(sv)
        elif op_key == "enrich_company_industry":
            # Handle both dict and string inputs
            if isinstance(raw, dict):
                company = raw.get("company")
                website = raw.get("website")
                domain = raw.get("domain")
            else:
                company = None
                website = None
                domain = str(raw) if raw else None
            res = op_enrich_company_industry(
                company=company, website=website, domain=domain
            )
        elif op_key == "enrich_company_country":
            # Handle both dict and string inputs
            if isinstance(raw, dict):
                company = raw.get("company")
                website = raw.get("website")
                domain = raw.get("domain")
            else:
                company = None
                website = None
                domain = str(raw) if raw else None
            res = op_enrich_company_country(
                company=company, website=website, domain=domain
            )
        elif op_key == "enrich_company_revenue":
            # Handle both dict and string inputs
            if isinstance(raw, dict):
                company = raw.get("company")
                website = raw.get("website")
                domain = raw.get("domain")
            else:
                company = None
                website = None
                domain = str(raw) if raw else None
            res = op_enrich_company_revenue(
                company=company, website=website, domain=domain
            )
        elif op_key == "enrich_from_foundrygraph":
            input_type = params.get("input_type") or "domain"
            if isinstance(raw, dict):
                value = raw.get("domain") or raw.get("company") or raw.get("website") or ""
                if raw.get("input_type"):
                    input_type = raw.get("input_type")
            else:
                value = str(raw) if raw else ""
            res = op_enrich_from_foundrygraph(value=value, input_type=input_type)
        elif op_key == "enrich_company_name":
            # Handle both dict and string inputs
            if isinstance(raw, dict):
                company = raw.get("company")
                website = raw.get("website")
                domain = raw.get("domain")
                # Support pre-locked SFDC account ID (skips lookup)
                locked_account_id = raw.get("sfdc_account_id")
            else:
                company = None
                website = None
                domain = str(raw) if raw else None
                locked_account_id = None

            # Per-row metadata tracking
            this_row_meta: Dict[str, Any] = {
                "source": "none",
                "confidence": 0.0,
                "notes": "",
                "sfdc_account_id": None,
                "sfdc_account_name": None,
                "sfdc_unavailable": False,
                "sfdc_error_reason": None,
            }

            # If we have a pre-locked account ID, skip resolution
            if locked_account_id:
                this_row_meta["source"] = "salesforce"
                this_row_meta["confidence"] = 1.0
                this_row_meta["sfdc_account_id"] = locked_account_id
                this_row_meta["notes"] = "Used pre-locked Account ID"
                # Return input as-is for locked rows
                res = {
                    "company_name": company or "",
                    "source": "salesforce",
                    "confidence": 1.0,
                }
            # Use waterfall resolution for all targets (SFDC-aware inside resolve_company)
            else:
                from ..operators.resolve_company import resolve_company

                try:
                    resolution = await resolve_company(
                        company_name=company or "",
                        domain=domain,
                        target=effective_target,
                        sfdc_connection=sfdc_gateway,
                    )
                    # Convert resolve_company result to enrich_company_name format
                    resolved_source = resolution.get("resolved_source", "none")
                    res = {
                        "company_name": resolution.get("resolved_name", ""),
                        "source": resolved_source,
                        "confidence": resolution.get("resolved_confidence", 0.0),
                        # Propagate B2C skip flag
                        "skipped": resolution.get("skipped", False),
                        "skip_reason": resolution.get("skip_reason"),
                    }
                    # Track metadata
                    this_row_meta["source"] = resolved_source
                    this_row_meta["confidence"] = resolution.get("resolved_confidence", 0.0)
                    this_row_meta["notes"] = resolution.get("resolution_notes", "")
                    this_row_meta["sfdc_account_id"] = resolution.get("sfdc_account_id")
                    this_row_meta["sfdc_account_name"] = resolution.get("sfdc_account_name")
                    this_row_meta["sfdc_unavailable"] = resolution.get("sfdc_unavailable", False)
                    this_row_meta["sfdc_error_reason"] = resolution.get("sfdc_error_reason")
                except Exception as exc:
                    logger.warning("Resolution failed for '%s': %s", company or domain, exc)
                    this_row_meta["notes"] = f"Error: {str(exc)[:100]}"
                    this_row_meta["sfdc_unavailable"] = True if effective_target != ResolutionTarget.FOUNDRYGRAPH else False
                    this_row_meta["sfdc_error_reason"] = str(exc)[:100]
                    res = {"company_name": "", "source": "none", "confidence": 0.0}

            # Normalize-only fallback when no resolvable key is found
            # Skip fallback for B2C domains (already handled by resolve_company)
            resolved_company_name = res.get("company_name", "") if isinstance(res, dict) else ""
            was_b2c_skipped = isinstance(res, dict) and res.get("skipped") and res.get("skip_reason") == "b2c_domain"
            if (
                not resolved_company_name
                and (company or domain)
                and effective_target != ResolutionTarget.SALESFORCE
                and not was_b2c_skipped  # Don't try to normalize B2C domains
            ):
                normalized = op_normalize_company(
                    company or "",
                    force_llm=False,
                    domain=domain,
                    resolution_target="foundrygraph",
                )
                normalized_value = ""
                normalized_conf = None
                normalized_source = None
                if isinstance(normalized, dict):
                    normalized_value = (normalized.get("value") or "").strip()
                    normalized_conf = normalized.get("confidence")
                    normalized_source = (normalized.get("source") or "").strip().lower()

                if normalized_value:
                    if not isinstance(res, dict):
                        res = {}
                    res["company_name"] = normalized_value
                    if isinstance(normalized, dict) and normalized.get("wikidata_id"):
                        res["wikidata_id"] = normalized.get("wikidata_id")
                    resolved_company_name = normalized_value
                    source_label = "normalized"
                    if normalized_source.startswith("foundrygraph"):
                        source_label = "foundrygraph"
                    elif normalized_source == "salesforce":
                        source_label = "salesforce"
                        if isinstance(normalized, dict):
                            this_row_meta["sfdc_account_id"] = normalized.get("account_id")
                            this_row_meta["sfdc_account_name"] = normalized.get("account_name")
                    elif normalized_source == "registry":
                        source_label = "registry"
                    elif normalized_source:
                        source_label = normalized_source
                    this_row_meta["source"] = source_label
                    this_row_meta["confidence"] = _normalize_confidence_value(
                        normalized_conf
                    )
                    note = "Normalized name (no resolvable key)"
                    if normalized_source:
                        note = f"Normalized name via {normalized_source}"
                    existing_note = this_row_meta.get("notes", "")
                    if "FoundryGraph lookup requires a domain" in (existing_note or ""):
                        this_row_meta["notes"] = note
                    else:
                        this_row_meta["notes"] = _append_resolution_note(
                            existing_note, note
                        )

            # Build row output (dict) for API consumers
            row_output = {
                "company_name": resolved_company_name,
                "resolution_source": this_row_meta.get("source", "none"),
                "resolution_confidence": this_row_meta.get("confidence", 0.0),
                "resolution_notes": this_row_meta.get("notes", ""),
                "sfdc_account_id": this_row_meta.get("sfdc_account_id"),
                "sfdc_account_name": this_row_meta.get("sfdc_account_name"),
                "sfdc_unavailable": this_row_meta.get("sfdc_unavailable", False),
                "sfdc_error_reason": this_row_meta.get("sfdc_error_reason"),
                "source": this_row_meta.get("source", "none"),
                "confidence": this_row_meta.get("confidence", 0.0),
                "skipped": res.get("skipped", False) if isinstance(res, dict) else False,
                "skip_reason": res.get("skip_reason") if isinstance(res, dict) else None,
            }
            if this_row_meta.get("sfdc_error_reason"):
                row_output["resolution_error"] = this_row_meta["sfdc_error_reason"]
            if isinstance(res, dict):
                row_output["foundrygraph_company_id"] = res.get("foundrygraph_company_id") or res.get("wikidata_id")

            data.append(row_output)
            _update_resolution_tracking(row_output)

            skip_cache = row_output.get("sfdc_unavailable", False) and effective_target in (
                ResolutionTarget.SMART,
                ResolutionTarget.SALESFORCE,
            )
            if not skip_cache:
                await TRANSFORM_CACHE.set(cache_key, row_output)
            local_results[local_cache_key] = row_output
            continue
        elif op_key == "enrich_company_headquarters":
            # Handle both dict and string inputs
            if isinstance(raw, dict):
                company = raw.get("company")
                website = raw.get("website")
                domain = raw.get("domain")
            else:
                company = None
                website = None
                domain = str(raw) if raw else None
            res = op_enrich_company_headquarters(
                company=company, website=website, domain=domain
            )
        elif op_key == "enrich_social_handles":
            # Handle both dict and string inputs
            if isinstance(raw, dict):
                company = raw.get("company")
                website = raw.get("website")
                domain = raw.get("domain")
            else:
                company = None
                website = None
                domain = str(raw) if raw else None
            res = op_enrich_social_handles(
                company=company, website=website, domain=domain
            )
        elif op_key == "enrich_stock_info":
            # Handle both dict and string inputs
            if isinstance(raw, dict):
                company = raw.get("company")
                website = raw.get("website")
                domain = raw.get("domain")
            else:
                company = None
                website = None
                domain = str(raw) if raw else None
            res = op_enrich_stock_info(company=company, website=website, domain=domain)
        elif op_key == "enrich_subsidiaries":
            # Handle both dict and string inputs
            if isinstance(raw, dict):
                company = raw.get("company")
                website = raw.get("website")
                domain = raw.get("domain")
            else:
                company = None
                website = None
                domain = str(raw) if raw else None
            res = op_enrich_subsidiaries(
                company=company, website=website, domain=domain
            )
        else:
            raise ValueError(f"Operation '{op_key}' is not implemented")

        # Extract metrics if this operation used LLM
        if company_stats is not None:
            value = res.get("value", {}) if isinstance(res, dict) else {}
            domain = str(value.get("domain", "") or "").strip()
            source = value.get("source") or ("missing" if not domain else "unknown")
            confidence = (value.get("confidence") or "unknown").lower()
            if domain:
                company_stats["source_counts"][source] += 1
            else:
                miss_reason = value.get("reason") or "no_domain"
                company_stats["miss_counts"][miss_reason] += 1
            company_stats["confidence_counts"][confidence] += 1

        if isinstance(res, dict) and "metrics" in res:
            llm_metrics = res.get("metrics")  # Keep the last LLM metrics
            llm_calls += 1

        res = validate_output_shape(op_key, res)

        flat = _flatten_value(op_key, res)
        data.append(flat)
        await TRANSFORM_CACHE.set(cache_key, flat)
        local_results[local_cache_key] = flat

    # Collect metrics for monitoring
    meta_out = {
        "provider": os.getenv("LLM_PROVIDER", "heuristics"),
        "model": os.getenv("LOCAL_LLM_MODEL", os.getenv("OLLAMA_MODEL", "")),
        "cache_hits": cache_hits,
        "total_rows": len(values),
        "op_name": op_key,
    }

    # Add cache hit rate if we have data
    if cache_hits and values:
        meta_out["cache_hit_rate"] = round(cache_hits / len(values), 3)

    # Add LLM metrics if we used LLM
    if company_stats is not None:
        meta_out["company_to_domain_metrics"] = {
            "source_counts": dict(company_stats["source_counts"]),
            "confidence_counts": dict(company_stats["confidence_counts"]),
            "miss_counts": dict(company_stats["miss_counts"]),
        }

    if llm_metrics:
        meta_out["llm_metrics"] = llm_metrics
        meta_out["llm_calls"] = llm_calls

    # Add resolution metadata for enrich_company_name
    if op_key == "enrich_company_name":
        meta_out["resolution_target"] = effective_target.value
        meta_out["resolution_stats"] = resolution_stats
        if not sfdc_unavailable and any(r.get("sfdc_unavailable") for r in row_meta):
            sfdc_unavailable = True
            # pick first non-empty error reason from rows if gateway init did not set
            if not sfdc_error_reason:
                sfdc_error_reason = next(
                    (r.get("sfdc_error_reason") for r in row_meta if r.get("sfdc_error_reason")), None
                )
        if sfdc_unavailable:
            meta_out["sfdc_unavailable"] = True
            meta_out["sfdc_error_reason"] = sfdc_error_reason
        if row_meta:
            meta_out["row_meta"] = row_meta

    return {"data": data, "meta": meta_out}


async def _batch_normalize_company(
    values: List[Any],
    params: Dict[str, Any],
    *,
    sfdc_gateway: Optional["SalesforceGateway"] = None,
    resolution_target: Optional["ResolutionTarget"] = None,
    sfdc_unavailable: bool = False,
    sfdc_error_reason: Optional[str] = None,
) -> Dict[str, Any]:
    """
    Optimized batch processing for normalize_company.
    Loads registry once and processes all uncached values together.
    """
    op_key = "normalize_company"
    string_inputs = ["" if v is None else str(v) for v in values]
    data: List[Any] = [None] * len(values)
    cache_hits = 0
    target_value = getattr(resolution_target, "value", resolution_target) or "smart"
    target_value = str(target_value).lower()

    # Track indices that need processing
    missing_indices: List[int] = []
    missing_names: List[str] = []
    cache_keys: Dict[int, str] = {}

    # Phase 1: Check cache for all values
    for idx, raw in enumerate(values):
        sv = string_inputs[idx]
        canon = _canon_for_cache(op_key, sv, params)
        cache_key = make_cache_key(
            "transform",
            {
                "op": op_key,
                "value": canon,
                "params": params,
                "resolution_target": target_value,
            },
        )
        cached = await TRANSFORM_CACHE.get(cache_key)

        if cached is not None:
            data[idx] = cached
            cache_hits += 1
        else:
            missing_indices.append(idx)
            missing_names.append(sv)
            cache_keys[idx] = cache_key

    sfdc_matches = 0
    fg_matches = 0
    timed_out = False

    if missing_names:
        force_llm = bool(params.get("force_llm")) and len(values) <= 500
        batch_results = await op_normalize_company_batch_async(
            missing_names,
            force_llm=force_llm,
            sfdc_gateway=sfdc_gateway,
            resolution_target=target_value,
            salesforce_connected=not sfdc_unavailable,
        )

        for local_idx, global_idx in enumerate(missing_indices):
            result_obj = (
                batch_results[local_idx]
                if local_idx < len(batch_results)
                else {"value": "", "confidence": "low"}
            )
            flat = result_obj.get("value", "")
            data[global_idx] = flat

            source = (result_obj.get("source") or "").lower()
            if source == "salesforce":
                sfdc_matches += 1
            elif flat:
                fg_matches += 1

            cache_key = cache_keys.get(global_idx)
            if cache_key:
                await TRANSFORM_CACHE.set(cache_key, flat)

    # Build response metadata
    meta_out = {
        "provider": ("salesforce" if target_value == "salesforce" else "heuristics"),
        "model": "",
        "cache_hits": cache_hits,
        "total_rows": len(values),
        "op_name": op_key,
        "resolution_target": target_value,
        "sfdc_matches": sfdc_matches,
        "foundrygraph_matches": fg_matches,
        "timed_out": timed_out,
    }

    if len(values) > 0:
        meta_out["cache_hit_rate"] = round(cache_hits / len(values), 3)

    if sfdc_unavailable:
        meta_out["sfdc_unavailable"] = True
        meta_out["sfdc_error_reason"] = sfdc_error_reason

    return {"data": data, "meta": meta_out}


async def _batch_detect_parent_company(
    values: List[Any], params: Dict[str, Any]
) -> Dict[str, Any]:
    """
    Optimized batch processing for detect_parent_company.
    Loads registry once and opens DuckDB connection once for all queries.
    """
    op_key = "detect_parent_company"
    data: List[Any] = [None] * len(values)
    cache_hits = 0

    # Track indices that need processing
    missing_indices: List[int] = []
    missing_inputs: List[Dict[str, Optional[str]]] = []
    cache_keys: Dict[int, str] = {}

    # Phase 1: Check cache for all values
    for idx, raw in enumerate(values):
        # Normalize input to dict format
        if isinstance(raw, dict):
            company = raw.get("company")
            website = raw.get("website")
            domain = raw.get("domain")
        else:
            company = str(raw) if raw else None
            website = None
            domain = None

        # Create cache key from normalized inputs
        cache_value = f"{company or ''}|{website or ''}|{domain or ''}"
        canon = _canon_for_cache(op_key, cache_value, params)
        cache_key = make_cache_key(
            "transform", {"op": op_key, "value": canon, "params": params}
        )
        cached = await TRANSFORM_CACHE.get(cache_key)

        if cached is not None:
            data[idx] = cached
            cache_hits += 1
        else:
            missing_indices.append(idx)
            missing_inputs.append(
                {"company": company, "website": website, "domain": domain}
            )
            cache_keys[idx] = cache_key

    # Phase 2: Batch process uncached values
    if missing_inputs:
        batch_results = op_detect_parent_company_batch(missing_inputs)

        # Phase 3: Flatten results and populate cache
        for local_idx, global_idx in enumerate(missing_indices):
            result_obj = (
                batch_results[local_idx]
                if local_idx < len(batch_results)
                else {
                    "value": None,
                    "child": "",
                    "parent": None,
                    "ultimate_parent": None,
                    "relationship": "unknown",
                    "confidence": 0.0,
                }
            )
            flat = _flatten_detect_parent_company(result_obj)
            data[global_idx] = flat

            # Cache the result
            cache_key = cache_keys.get(global_idx)
            if cache_key:
                await TRANSFORM_CACHE.set(cache_key, flat)

    # Build response metadata
    meta_out = {
        "provider": "registry",
        "model": "",
        "cache_hits": cache_hits,
        "total_rows": len(values),
        "op_name": op_key,
    }

    if len(values) > 0:
        meta_out["cache_hit_rate"] = round(cache_hits / len(values), 3)

    return {"data": data, "meta": meta_out}


async def _batch_company_to_domain(
    values: List[Any], params: Dict[str, Any]
) -> Dict[str, Any]:
    op_key = "company_to_domain"
    string_inputs = ["" if v is None else str(v) for v in values]
    data: List[Any] = [None] * len(values)
    cache_hits = 0
    auto_link_threshold, suggest_threshold = _extract_company_domain_thresholds(params)

    default_mode = (
        os.getenv("FM_COMPANY_TO_DOMAIN_DEFAULT_MODE", "fast") or "fast"
    ).lower()
    if default_mode not in {"fast", "balanced", "strict"}:
        default_mode = "fast"

    raw_mode = (
        params.get("company_to_domain_mode")
        or params.get("companyToDomainMode")
        or params.get("mode")
        or params.get("writeMode")
        or ""
    )
    if isinstance(raw_mode, str):
        raw_mode = raw_mode.lower()
    else:
        raw_mode = ""

    mode = raw_mode if raw_mode in {"fast", "balanced", "strict"} else default_mode
    use_registry = bool(params.get("use_registry", True))
    use_llm = bool(params.get("use_llm", False))
    dns_override = params.get("use_dns")
    if isinstance(dns_override, str):
        dns_override = dns_override.lower() == "true"
    elif not isinstance(dns_override, bool):
        dns_override = None

    env_dns_default = (
        os.getenv("FM_COMPANY_TO_DOMAIN_USE_DNS", "false") or "false"
    ).lower() == "true"
    dns_enabled = dns_override is True or (dns_override is None and env_dns_default)
    allow_dns = (mode != "fast") and dns_enabled

    missing_indices: List[int] = []
    missing_names: List[str] = []
    cache_keys: Dict[int, str] = {}

    for idx, raw in enumerate(values):
        canon_pair = _canon_pair_for_cache(op_key, raw, params)
        base_value = canon_pair if isinstance(canon_pair, str) else string_inputs[idx]
        canon = _canon_for_cache(op_key, base_value, params)
        cache_key = make_cache_key(
            "transform", {"op": op_key, "value": canon, "params": params}
        )
        cached = await TRANSFORM_CACHE.get(cache_key)
        if cached is not None:
            data[idx] = _normalize_cached_company_domain(
                cached, auto_link_threshold, suggest_threshold
            )
            cache_hits += 1
        else:
            missing_indices.append(idx)
            missing_names.append(string_inputs[idx])
            cache_keys[idx] = cache_key

    metrics: Dict[str, Any] = {}
    if missing_names:
        batch_result = op_company_to_domain_batch(
            missing_names,
            mode=mode,
            use_registry=use_registry,
            use_llm=use_llm,
            use_dns=allow_dns,
            use_heuristics=True,
        )
        result_values: List[Dict[str, Any]] = batch_result.get("values", [])
        metrics = batch_result.get("metrics", {})
        for local_idx, global_idx in enumerate(missing_indices):
            result_obj = (
                result_values[local_idx]
                if local_idx < len(result_values)
                else {"value": {"domain": "", "confidence": "low"}}
            )
            override_info = get_override_for_name(missing_names[local_idx])
            if override_info:
                result_obj = _build_override_result(override_info)
                metrics["override_hits"] = metrics.get("override_hits", 0) + 1
            flat = _flatten_value(op_key, result_obj)
            data[global_idx] = flat
            cache_key = cache_keys.get(global_idx)
            if cache_key:
                await TRANSFORM_CACHE.set(cache_key, flat)
    else:
        metrics = {
            "mode": mode,
            "total_unique": 0,
            "registry_hits": 0,
            "wikidata_hits": 0,
            "hardcoded_hits": 0,
            "cache_hits": len(values),
            "heuristic_hits": 0,
            "dns_verified": 0,
            "needs_dns": 0,
            "unresolved": 0,
            "dns_attempts": 0,
            "dns_timeouts": 0,
            "dns_failures": 0,
            "breaker_tripped": False,
            "elapsed_ms": 0.0,
        }

    metrics["cache_hits"] = metrics.get("cache_hits", 0) + cache_hits

    meta_out: Dict[str, Any] = {
        "provider": "resolver",
        "model": os.getenv("LOCAL_LLM_MODEL", ""),
        "cache_hits": cache_hits,
        "total_rows": len(values),
        "op_name": op_key,
        "company_to_domain_progress": metrics,
    }

    if len(values):
        meta_out["cache_hit_rate"] = round(cache_hits / len(values), 3)

    meta_out["thresholds"] = {
        "auto_link": auto_link_threshold,
        "suggest": suggest_threshold,
    }

    return {"data": data, "meta": meta_out}


def _build_override_result(override: Dict[str, Any]) -> Dict[str, Any]:
    domain = str(override.get("domain") or "").strip()
    confidence_score = override.get("confidence", 0.95)
    try:
        confidence_score = float(confidence_score)
    except (TypeError, ValueError):
        confidence_score = 0.95

    if confidence_score >= 0.85:
        confidence_label = "high"
    elif confidence_score >= 0.6:
        confidence_label = "medium"
    else:
        confidence_label = "low"

    source_type = str(override.get("source") or "override").strip() or "override"
    chips = ["override"]
    if source_type != "override":
        chips.append(source_type)

    value: Dict[str, Any] = {
        "domain": domain,
        "confidence": confidence_label,
        "confidence_score": confidence_score,
        "source": source_type,
        "chips": chips,
    }
    raw_name = override.get("raw_name")
    if raw_name:
        metadata = value.setdefault("metadata", {})
        metadata["override_name"] = raw_name

    return {"value": value}


def _extract_company_domain_thresholds(params: Dict[str, Any]) -> Tuple[float, float]:
    raw_thresholds = params.get("thresholds") or params.get(
        "company_to_domain_thresholds"
    )
    if not isinstance(raw_thresholds, dict):
        raw_thresholds = {}

    auto_link = _coerce_float(
        raw_thresholds.get("auto_link")
        or raw_thresholds.get("autoLink")
        or params.get("auto_link_threshold")
        or params.get("auto_link")
    )
    suggest = _coerce_float(
        raw_thresholds.get("suggest")
        or raw_thresholds.get("suggest_threshold")
        or params.get("suggest_threshold")
        or params.get("suggest")
    )

    if auto_link is None:
        auto_link = DEFAULT_COMPANY_DOMAIN_THRESHOLDS["auto_link"]
    if suggest is None:
        suggest = DEFAULT_COMPANY_DOMAIN_THRESHOLDS["suggest"]

    auto_link = _clamp(auto_link, 0.0, 1.0)
    suggest = _clamp(suggest, 0.0, 1.0)
    if auto_link < suggest:
        auto_link = suggest
    return auto_link, suggest


def _flatten_detect_parent_company(result_obj: Dict[str, Any]) -> List[str]:
    """
    Normalize detect_parent_company results into a consistent column layout.
    The Sheets addon currently treats the first element as the primary value,
    so the parent name always occupies index 0 with additional context packed
    into subsequent columns for future use.
    """

    if not isinstance(result_obj, dict):
        result_obj = {}

    parent = result_obj.get("parent")
    if isinstance(parent, dict):
        parent = parent.get("name") or parent.get("value")
    if not parent:
        parent = result_obj.get("value")

    ultimate_parent = result_obj.get("ultimate_parent")
    if isinstance(ultimate_parent, dict):
        ultimate_parent = ultimate_parent.get("name") or ultimate_parent.get("value")

    relationship = result_obj.get("relationship")
    parent_domain = result_obj.get("parent_domain")
    child = result_obj.get("child")

    raw_confidence = result_obj.get("confidence")
    confidence_score = _coerce_float(raw_confidence)
    if confidence_score is not None:
        confidence = f"{confidence_score:.3f}".rstrip("0").rstrip(".")
        if confidence == "":
            confidence = "0"
    else:
        confidence = (
            str(raw_confidence).strip() if raw_confidence not in (None, "") else ""
        )

    def _to_text(value: Any) -> str:
        if value in (None, ""):
            return ""
        return str(value).strip()

    return [
        _to_text(parent),
        _to_text(ultimate_parent) or _to_text(parent),
        _to_text(relationship),
        confidence,
        _to_text(parent_domain) or _to_text(child),
    ]


def _flatten_company_to_domain(
    result_obj: Dict[str, Any],
    auto_link_threshold: float = DEFAULT_COMPANY_DOMAIN_THRESHOLDS["auto_link"],
    suggest_threshold: float = DEFAULT_COMPANY_DOMAIN_THRESHOLDS["suggest"],
) -> List[str]:
    value = result_obj.get("value") or {}
    domain = str(value.get("domain") or "").strip()
    llm_suggestion = str(value.get("llm_suggestion") or "").strip()

    score = _coerce_float(value.get("confidence_score"))
    label = str(value.get("confidence") or "").strip()
    if score is None:
        score = _coerce_float(value.get("confidence"))
    if score is None and label:
        score = _score_from_label(label)
    if score is None:
        score = 0.0
    score = _clamp(score, 0.0, 1.0)

    label_title = label.title() if label else ""
    score_display = f"{score:.3f}".rstrip("0").rstrip(".") if score else "0"
    if label_title and score_display:
        match_confidence = f"{score_display} ({label_title})"
    elif score_display:
        match_confidence = score_display
    else:
        match_confidence = label_title

    chips = value.get("chips") or []
    if isinstance(chips, str):
        chips_list = [chips]
    else:
        chips_list = [str(chip) for chip in chips if chip not in (None, "")]
    source = value.get("source")
    if source:
        chips_list.append(str(source))
    reason = value.get("reason")
    if reason:
        chips_list.append(str(reason))
    chips_str = ", ".join(dict.fromkeys(chips_list))

    auto_link_threshold = _clamp(auto_link_threshold or 0.0, 0.0, 1.0)
    suggest_threshold = _clamp(suggest_threshold or 0.0, 0.0, 1.0)
    if auto_link_threshold < suggest_threshold:
        auto_link_threshold = suggest_threshold

    primary_domain = ""
    suggestion_domain = ""

    if domain:
        if score >= auto_link_threshold:
            primary_domain = domain
        else:
            suggestion_domain = domain
    elif llm_suggestion:
        suggestion_domain = llm_suggestion

    return [
        primary_domain,
        suggestion_domain,
        match_confidence,
        chips_str,
    ]


def _normalize_cached_company_domain(
    cached_value: Any, auto_link_threshold: float, suggest_threshold: float
) -> List[str]:
    if isinstance(cached_value, list):
        if len(cached_value) == 4:
            return [str(v or "") for v in cached_value]
        if len(cached_value) == 2:
            domain = str(cached_value[0] or "").strip()
            conf = cached_value[1]
            score = _coerce_float(conf)
            label = ""
            if score is None and isinstance(conf, str):
                label = conf.strip()
                score = _score_from_label(label)
            label = label or (conf if isinstance(conf, str) else "")
            score = _clamp(score or 0.0, 0.0, 1.0)
            label_title = label.title() if label else ""
            score_display = f"{score:.3f}".rstrip("0").rstrip(".") if score else "0"
            if label_title and score_display:
                match_confidence = f"{score_display} ({label_title})"
            elif score_display:
                match_confidence = score_display
            else:
                match_confidence = label_title
            primary = ""
            suggestion = ""
            if domain:
                if score >= auto_link_threshold:
                    primary = domain
                else:
                    suggestion = domain
            return [primary, suggestion, match_confidence, ""]
    return ["", "", "", ""]


def _coerce_float(value: Any) -> Optional[float]:
    try:
        if value is None or value == "":
            return None
        return float(value)
    except (TypeError, ValueError):
        return None


def _clamp(value: float, min_value: float, max_value: float) -> float:
    if value < min_value:
        return min_value
    if value > max_value:
        return max_value
    return value


def _score_from_label(label: str) -> Optional[float]:
    mapping = {"high": 0.95, "medium": 0.75, "low": 0.25}
    return mapping.get(label.strip().lower())
