from __future__ import annotations

import asyncio
from collections import OrderedDict
import logging
import os
import re
import threading
import time
import unicodedata
from typing import Any, Dict, List, Optional

# Lazy import for LLM provider to avoid GCP credential discovery at startup
_llm_provider_getter = None


def _get_llm_provider():
    """Lazy loader for get_llm_provider to avoid startup hang."""
    global _llm_provider_getter
    if _llm_provider_getter is None:
        from ..providers import get_llm_provider as _glp

        _llm_provider_getter = _glp
    return _llm_provider_getter()


from fmatch.core.heuristics import LEGAL_SUFFIXES, ABBREV_EXPANSIONS, COMPANY_STOPWORDS
from fmatch.core.normalization.domain import normalize_domain
from ..core.psl_root import registrable_root
from ..services.foundrygraph_bq import (
    lookup_company_by_domain,
    lookup_companies_by_domain,
)
from ..services.sfdc_field_profiler import get_sfdc_field_profile


# Match non-word characters except &, +
# \w includes Unicode letters/digits, so this preserves international company names
_TOKEN_SPLIT = re.compile(r"[^\w&+]+", re.UNICODE)
_DOMAIN_RE = re.compile(r"([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})")

logger = logging.getLogger(__name__)

# Heuristics to reject obviously bad FoundryGraph labels for academic/health domains.
_ORG_KEYWORDS = {
    "hospital",
    "health",
    "healthcare",
    "medical",
    "medicine",
    "clinic",
    "system",
    "center",
    "centre",
    "university",
    "college",
    "institute",
    "school",
    "foundation",
    "children",
    "childrens",
    "kids",
    "department",
    "dept",
    "service",
    "services",
    "group",
    "association",
    "society",
    "network",
    "care",
}

_PUBLICATION_KEYWORDS = {
    "journal",
    "review",
    "update",
    "bulletin",
    "report",
    "newsletter",
    "proceedings",
    "magazine",
    "publication",
}

_NAME_SUFFIXES = {"jr", "sr", "ii", "iii", "iv"}

# Thresholds
_REGISTRY_MATCH_CONFIDENCE = float(os.getenv("FM_REGISTRY_MATCH_CONFIDENCE", "0.85"))
_REGISTRY_CONFIDENCE_EXACT = float(os.getenv("FM_REGISTRY_CONFIDENCE_EXACT", "0.90"))
_REGISTRY_CONFIDENCE_NORMALIZED = float(
    os.getenv("FM_REGISTRY_CONFIDENCE_NORMALIZED", "0.85")
)
_SFDC_MATCH_THRESHOLD = float(os.getenv("FM_SFDC_MATCH_THRESHOLD", "0.90"))
_SFDC_MATCH_HIGH = float(os.getenv("FM_SFDC_MATCH_HIGH", "0.95"))
_SFDC_HIGH_CONFIDENCE = float(os.getenv("FM_SFDC_MATCH_HIGH_CONFIDENCE", "0.85"))
_SFDC_FLOOR = float(os.getenv("FM_SFDC_MATCH_FLOOR", "0.70"))
_FG_OVERRIDE_THRESHOLD = float(os.getenv("FM_FG_OVERRIDE_THRESHOLD", "0.90"))

# Cache settings
_FG_DOMAIN_CACHE_TTL = int(os.getenv("FM_FOUNDRYGRAPH_DOMAIN_CACHE_TTL", "3600"))
_SFDC_ACCOUNT_CACHE_TTL = int(os.getenv("FM_SFDC_ACCOUNT_CACHE_TTL", "3600"))
_LOCAL_REGISTRY_TTL = int(os.getenv("FM_LOCAL_REGISTRY_TTL", "300"))
_FG_REDIRECT_CACHE_TTL = int(os.getenv("FM_FG_REDIRECT_CACHE_TTL", "86400"))
_FG_REDIRECT_TIMEOUT = float(os.getenv("FM_FG_REDIRECT_TIMEOUT", "3"))
_FG_REDIRECT_MAX = int(os.getenv("FM_FG_REDIRECT_MAX", "50"))
_FG_REDIRECT_RETRIES = int(os.getenv("FM_FG_REDIRECT_RETRIES", "2"))
_FG_REDIRECT_CONCURRENCY = int(os.getenv("FM_FG_REDIRECT_CONCURRENCY", "8"))
_FG_REDIRECT_FALLBACK = os.getenv("FM_FG_REDIRECT_FALLBACK", "true").lower() == "true"
_FG_DOMAIN_CACHE_MAX = int(os.getenv("FM_FOUNDRYGRAPH_DOMAIN_CACHE_MAX", "2048"))
_FG_REDIRECT_CACHE_MAX = int(os.getenv("FM_FG_REDIRECT_CACHE_MAX", "2048"))
_SFDC_ACCOUNT_CACHE_MAX = int(os.getenv("FM_SFDC_ACCOUNT_CACHE_MAX", "32"))
_SFDC_DOMAIN_MATCH_BASE = float(os.getenv("FM_SFDC_DOMAIN_MATCH_BASE", "0.92"))
_SFDC_DOMAIN_MATCH_EXPLICIT_BONUS = float(
    os.getenv("FM_SFDC_DOMAIN_MATCH_EXPLICIT_BONUS", "0.01")
)
_SFDC_DOMAIN_MATCH_NAME_BONUS = float(
    os.getenv("FM_SFDC_DOMAIN_MATCH_NAME_BONUS", "0.03")
)

_CACHE_LOCK = threading.Lock()
_ASYNC_RUNNER_LOCK = threading.Lock()
_ASYNC_RUNNER_READY = threading.Event()
_ASYNC_RUNNER_LOOP: Optional[asyncio.AbstractEventLoop] = None
_ASYNC_RUNNER_THREAD: Optional[threading.Thread] = None
_CACHE_MISS = object()
_FG_DOMAIN_CACHE: OrderedDict[str, tuple[float, Dict[str, Any]]] = OrderedDict()
_FG_REDIRECT_CACHE: OrderedDict[str, tuple[float, Optional[str]]] = OrderedDict()
_SFDC_ACCOUNT_CACHE: OrderedDict[str, tuple[float, Dict[str, Any]]] = OrderedDict()
_LOCAL_REGISTRY_CACHE: Dict[str, Any] = {"last_load": 0.0, "registry": None}
_CACHE_LIMITS = {
    id(_FG_DOMAIN_CACHE): _FG_DOMAIN_CACHE_MAX,
    id(_FG_REDIRECT_CACHE): _FG_REDIRECT_CACHE_MAX,
    id(_SFDC_ACCOUNT_CACHE): _SFDC_ACCOUNT_CACHE_MAX,
}


class _LocalRegistry:
    def __init__(self, seed_path: str, data: List[Dict[str, Any]]) -> None:
        self.seed_path = seed_path
        self.raw_name_to_family: Dict[str, str] = {}
        self.name_to_family: Dict[str, str] = {}
        self.family_display_name: Dict[str, str] = {}
        self.family_to_domains: Dict[str, set[str]] = {}
        self.family_parent_domain: Dict[str, str] = {}
        self._load(data)

    def _load(self, data: List[Dict[str, Any]]) -> None:
        for row in data or []:
            family_id = (row.get("family_id") or row.get("family") or "").strip().lower()
            if not family_id:
                continue

            parent = _normalize_domain(row.get("parent") or "")
            if parent:
                self.family_to_domains.setdefault(family_id, set()).add(parent)
                self.family_parent_domain.setdefault(family_id, parent)

            for alias in row.get("aliases") or []:
                domain = _normalize_domain(alias)
                if domain:
                    self.family_to_domains.setdefault(family_id, set()).add(domain)

            names = row.get("names") or []
            historical = row.get("historical_names") or []
            all_names = list(names) + list(historical)
            if names:
                display = row.get("display_name") or names[0]
                if display:
                    self.family_display_name.setdefault(family_id, str(display).strip())

            for name in all_names:
                if not name:
                    continue
                raw = str(name).strip().lower()
                if raw:
                    self.raw_name_to_family[raw] = family_id
                norm = _normalize_name_key(str(name))
                if norm:
                    self.name_to_family[norm] = family_id

    def lookup_family_by_name(self, name: str) -> tuple[Optional[str], float]:
        if not name:
            return None, 0.0
        raw = name.strip().lower()
        if raw in self.raw_name_to_family:
            return self.raw_name_to_family[raw], _REGISTRY_CONFIDENCE_EXACT
        norm = _normalize_name_key(name)
        if norm and norm in self.name_to_family:
            return self.name_to_family[norm], _REGISTRY_CONFIDENCE_NORMALIZED
        return None, 0.0

    def get_display_name(self, family_id: str) -> Optional[str]:
        return self.family_display_name.get(family_id)


def _get_local_registry() -> Optional[_LocalRegistry]:
    now = time.time()
    with _CACHE_LOCK:
        cached = _LOCAL_REGISTRY_CACHE.get("registry")
        last_load = float(_LOCAL_REGISTRY_CACHE.get("last_load", 0.0))
        if cached and (now - last_load) <= _LOCAL_REGISTRY_TTL:
            return cached

        try:
            import yaml  # type: ignore
        except Exception as exc:
            logger.debug("YAML unavailable for local registry: %s", exc)
            _LOCAL_REGISTRY_CACHE.update({"last_load": now, "registry": None})
            return None

        seed_path = os.getenv("DOMAIN_FAMILY_SEED", "config/domain_families.yaml")
        if not os.path.exists(seed_path):
            _LOCAL_REGISTRY_CACHE.update({"last_load": now, "registry": None})
            return None

        try:
            with open(seed_path, "r", encoding="utf-8") as handle:
                data = yaml.safe_load(handle) or []
        except Exception as exc:
            logger.debug("Failed to load local registry from %s: %s", seed_path, exc)
            _LOCAL_REGISTRY_CACHE.update({"last_load": now, "registry": None})
            return None

        registry = _LocalRegistry(seed_path, data)
        _LOCAL_REGISTRY_CACHE.update({"last_load": now, "registry": registry})
        return registry


def _cache_get(cache: Dict[str, tuple[float, Any]], key: str, ttl_s: int) -> Any:
    if ttl_s <= 0:
        return None
    with _CACHE_LOCK:
        entry = cache.get(key)
        if not entry:
            return None
        ts, value = entry
        if ttl_s and (time.time() - ts) > ttl_s:
            cache.pop(key, None)
            return None
        move_to_end = getattr(cache, "move_to_end", None)
        if move_to_end is not None:
            move_to_end(key)
        return value


def _cache_set(cache: Dict[str, tuple[float, Any]], key: str, value: Any) -> None:
    if value is None:
        return
    with _CACHE_LOCK:
        cache[key] = (time.time(), value)
        move_to_end = getattr(cache, "move_to_end", None)
        if move_to_end is not None:
            move_to_end(key)
        max_size = _CACHE_LIMITS.get(id(cache), 0)
        while max_size and len(cache) > max_size:
            popitem = getattr(cache, "popitem", None)
            if popitem is None:
                break
            popitem(last=False)


def _redirect_cache_get(key: str) -> Any:
    with _CACHE_LOCK:
        entry = _FG_REDIRECT_CACHE.get(key)
    if not entry:
        return _CACHE_MISS
    ts, value = entry
    if (time.time() - ts) > _FG_REDIRECT_CACHE_TTL:
        with _CACHE_LOCK:
            _FG_REDIRECT_CACHE.pop(key, None)
        return _CACHE_MISS
    with _CACHE_LOCK:
        _FG_REDIRECT_CACHE.move_to_end(key)
    return value


def _redirect_cache_set(key: str, value: Optional[str]) -> None:
    with _CACHE_LOCK:
        _FG_REDIRECT_CACHE[key] = (time.time(), value)
        _FG_REDIRECT_CACHE.move_to_end(key)
        while _FG_REDIRECT_CACHE_MAX and len(_FG_REDIRECT_CACHE) > _FG_REDIRECT_CACHE_MAX:
            _FG_REDIRECT_CACHE.popitem(last=False)


def _normalize_text(s: str) -> str:
    """
    Normalize text while preserving international characters.

    Uses NFKC normalization which handles:
    - Composed/decomposed characters (é vs e + ́)
    - Full-width/half-width characters (Japanese)
    - Compatibility characters
    - Preserves CJK, Cyrillic, Arabic, Hebrew, and all Unicode scripts
    """
    if not s:
        return ""
    return unicodedata.normalize("NFKC", s)


def _normalize_domain(domain: str) -> str:
    raw = (domain or "").strip().lower()
    if raw.startswith("www."):
        raw = raw[4:]
    root = registrable_root(raw)
    return root or raw


def _domain_needs_label_sanity(domain: str) -> bool:
    if not domain:
        return False
    d = _normalize_domain(domain)
    if not d:
        return False
    return d.endswith(".edu") or any(
        key in d
        for key in (
            "health",
            "hospital",
            "clinic",
            "med",
            "medicine",
            "child",
            "children",
            "care",
            "univ",
            "university",
        )
    )


def _has_org_keyword(label: str) -> bool:
    lowered = (label or "").lower()
    return any(word in lowered for word in _ORG_KEYWORDS)


def _is_publication_like(label: str) -> bool:
    lowered = (label or "").lower()
    return any(word in lowered for word in _PUBLICATION_KEYWORDS)


def _name_tokens_unicode(label: str) -> List[str]:
    tokens: List[str] = []
    current: List[str] = []
    for ch in _normalize_text(label):
        category = unicodedata.category(ch)
        if category.startswith(("L", "M")):
            current.append(ch)
            continue
        if ch in ".-'" and current:
            current.append(ch)
            continue
        if current:
            token = "".join(current).strip(".-'")
            if token and any(part.isalpha() for part in token):
                tokens.append(token)
            current = []
    if current:
        token = "".join(current).strip(".-'")
        if token and any(part.isalpha() for part in token):
            tokens.append(token)
    return tokens


def _token_has_case(token: str) -> bool:
    for ch in token:
        if ch.isalpha() and ch.lower() != ch.upper():
            return True
    return False


def _is_person_like(label: str) -> bool:
    if not label:
        return False
    tokens = _name_tokens_unicode(label)
    if len(tokens) < 2 or len(tokens) > 4:
        return False
    name_like = 0
    for token in tokens:
        cleaned = token.strip(".-'")
        if not cleaned:
            continue
        lowered = cleaned.lower()
        if lowered in _NAME_SUFFIXES:
            continue
        letters = [ch for ch in cleaned if ch.isalpha()]
        if len(letters) == 1:
            name_like += 1
            continue
        alpha_only = "".join(letters)
        if _token_has_case(alpha_only):
            if alpha_only[:1].isupper() and alpha_only[1:].islower():
                name_like += 1
        elif len(alpha_only) > 1:
            name_like += 1
    return name_like >= 2


def _should_reject_fg_label(domain: str, label: str) -> bool:
    if not label:
        return False
    if _has_org_keyword(label):
        return False
    if _domain_needs_label_sanity(domain) and _is_publication_like(label):
        return True
    return _is_person_like(label)


def _resolve_redirect_domain(domain: str) -> Optional[str]:
    """Resolve canonical domain via HTTP redirects."""
    if not domain:
        return None
    cached = _redirect_cache_get(domain)
    if cached is not _CACHE_MISS:
        return cached
    try:
        import requests
    except Exception as exc:
        logger.debug("Requests unavailable for redirect lookup %s: %s", domain, exc)
        _redirect_cache_set(domain, None)
        return None

    attempts = max(1, _FG_REDIRECT_RETRIES)
    for attempt in range(attempts):
        resp = None
        try:
            url = f"https://{domain}"
            resp = requests.get(
                url,
                timeout=_FG_REDIRECT_TIMEOUT,
                allow_redirects=True,
                headers={"User-Agent": "FoundryOps/1.0"},
                stream=True,
            )
            final_url = resp.url or url
            canonical = normalize_domain(final_url)
            if canonical and canonical != domain:
                _redirect_cache_set(domain, canonical)
                return canonical
            break
        except Exception as exc:
            if attempt + 1 >= attempts:
                logger.debug("Redirect lookup failed for %s: %s", domain, exc)
            else:
                time.sleep(min(0.05 * (attempt + 1), 0.25))
        finally:
            if resp is not None:
                try:
                    resp.close()
                except Exception:
                    pass
    _redirect_cache_set(domain, None)
    return None


def _extract_domain_from_text(text: str) -> str:
    raw = (text or "").strip()
    if not raw:
        return ""

    candidate = ""
    if "@" in raw:
        candidate = raw.rsplit("@", 1)[-1]
    else:
        match = _DOMAIN_RE.search(raw)
        candidate = match.group(1) if match else raw

    candidate = (
        candidate.split("/", 1)[0]
        .split("?", 1)[0]
        .split("#", 1)[0]
        .split(":", 1)[0]
        .strip()
        .lower()
    )
    if not candidate or " " in candidate or "." not in candidate:
        return ""
    return _normalize_domain(candidate)


def _normalize_name_key(name: str) -> str:
    s = _normalize_text(name).strip().lower()
    if not s:
        return ""
    s = s.replace("&", " and ")
    tokens = [t for t in _TOKEN_SPLIT.split(s) if t]
    tokens = [ABBREV_EXPANSIONS.get(t, t) for t in tokens]
    if tokens and tokens[-1] in LEGAL_SUFFIXES:
        tokens = tokens[:-1]
    trailing_stopwords = {"the", "group", "holdings"}
    while tokens and tokens[-1] in trailing_stopwords:
        tokens = tokens[:-1]
    tokens = [t for t in tokens if t not in COMPANY_STOPWORDS]
    tokens = [t for t in tokens if not all(ch.isdigit() for ch in t)]
    tokens = [_cleanup_single_token(t) for t in tokens]
    tokens = [t for t in tokens if t]
    return " ".join(tokens).lower()


def _name_tokens(name_key: str) -> List[str]:
    tokens = [t for t in name_key.split() if t]
    if not tokens:
        return []
    out = [t for t in tokens if len(t) >= 3]
    acronym = "".join(t[:1] for t in tokens if t)
    if len(acronym) >= 2:
        out.append(acronym)
    return list(dict.fromkeys(out))


def _generate_search_terms(name: str) -> List[str]:
    base = _normalize_name_key(name)
    if not base:
        return []

    terms = {base}
    if base.endswith("s") and len(base) > 3:
        terms.add(base[:-1])
    if base.endswith("ers") and len(base) > 5:
        terms.add(base[:-3])
    if base.endswith("er") and len(base) > 4:
        terms.add(base[:-2])
    if len(base) > 4:
        terms.add(base[:-1])

    tokens = [t for t in base.split() if len(t) >= 3]
    terms.update(tokens)
    if tokens:
        acronym = "".join(t[:1] for t in tokens if t)
        if len(acronym) >= 2:
            terms.add(acronym)

    return [t for t in terms if len(t) >= 3]


def _title_case(tokens: list[str]) -> str:
    out = []
    for t in tokens:
        if t in {"and", "of", "the", "for", "in"}:
            out.append(t)
        else:
            out.append(t[:1].upper() + t[1:])
    return " ".join(out)


def _cleanup_single_token(token: str) -> str:
    cleaned = token or ""
    lowered = cleaned.lower()
    protected_suffix_tokens = {
        "reuters",
        "twitter",
        "pfizer",
        "bayer",
        "mercer",
        "pioneer",
        "kroger",
    }
    if lowered in protected_suffix_tokens:
        return cleaned

    removed_suffix: Optional[str] = None
    if lowered.endswith("ers") and len(cleaned) > 6 and lowered not in {"pioneers"}:
        cleaned = cleaned[:-3]
        removed_suffix = "ers"
    elif lowered.endswith("er") and len(cleaned) > 5 and lowered not in {"discover"}:
        cleaned = cleaned[:-2]
        removed_suffix = "er"
    elif lowered.endswith("s") and len(cleaned) > 4 and not lowered.endswith(("ss", "us", "is")):
        cleaned = cleaned[:-1]
        removed_suffix = "s"

    if removed_suffix in {"er", "ers"} and cleaned:
        tail = cleaned[-1].lower()
        prev = cleaned[-2].lower() if len(cleaned) > 1 else ""
        if tail not in "aeiouy" and prev not in "aeiou":
            cleaned = cleaned + "e"

    if len(cleaned) > 3:
        last = cleaned[-1].lower()
        run_len = 1
        for ch in reversed(cleaned[:-1]):
            if ch.lower() == last:
                run_len += 1
            else:
                break
        if run_len > 2 or (removed_suffix and run_len > 1):
            cleaned = cleaned[: -(run_len - 1)]

    return cleaned


def _looks_like_acronym(token: str) -> bool:
    if not token:
        return False
    if token.isupper():
        return True

    letters = [ch for ch in token if ch.isalpha()]
    if not letters:
        return False
    if len(letters) <= 3:
        return True
    if not any(ch.lower() in "aeiou" for ch in letters):
        return True
    return False


def _heuristic_company(name: str) -> str:
    """
    Normalize company name using heuristics, preserving international characters.

    Supports company names in all scripts:
    - Latin: "Société Générale Inc" -> "Société Générale"
    - Japanese: "日本電信電話株式会社" -> "日本電信電話"
    - Russian: "Яндекс ООО" -> "Яндекс"
    - Chinese: "阿里巴巴集團" -> "阿里巴巴集團"
    """
    raw = _normalize_text(name).strip()
    s = raw.lower()
    s = s.replace("&", " and ")
    tokens = [t for t in _TOKEN_SPLIT.split(s) if t]
    # expand common abbreviations
    exp = [ABBREV_EXPANSIONS.get(t, t) for t in tokens]
    # drop legal suffix at end, stopwords on boundaries
    if exp and exp[-1] in LEGAL_SUFFIXES:
        exp = exp[:-1]
    trimmed = [t for t in exp if t not in COMPANY_STOPWORDS]
    trimmed = [t for t in trimmed if not all(ch.isdigit() for ch in t)]
    cleaned_tokens: list[str] = []
    for token in trimmed:
        cleaned = _cleanup_single_token(token)
        if not cleaned:
            continue
        if cleaned.isascii() and _looks_like_acronym(cleaned):
            cleaned_tokens.append(cleaned.upper())
        else:
            cleaned_tokens.append(cleaned)

    if not cleaned_tokens:
        return ""
    return _title_case(cleaned_tokens)


def _registry_primary_domain(registry: object, family_id: str) -> str:
    parents = getattr(registry, "family_parent_domain", {}) or {}
    parent_domain = parents.get(family_id)
    if parent_domain:
        return parent_domain

    domains = getattr(registry, "family_to_domains", {}) or {}
    family_domains = domains.get(family_id, set()) or set()
    if not family_domains:
        return ""
    return sorted(family_domains, key=lambda d: (len(d), d))[0]


def _registry_domain_for_name(name: str, registry_enabled: bool) -> str:
    if not registry_enabled or not name:
        return ""
    registry = _get_local_registry()
    if not registry:
        return ""
    family_id, conf = registry.lookup_family_by_name(name)
    if not family_id or conf < _REGISTRY_MATCH_CONFIDENCE:
        return ""
    return _registry_primary_domain(registry, family_id)


def _lookup_canonical_name(domain: str) -> Optional[Dict[str, Any]]:
    normalized_domain = _normalize_domain(domain)
    if not normalized_domain:
        return None

    cached = _cache_get(_FG_DOMAIN_CACHE, normalized_domain, _FG_DOMAIN_CACHE_TTL)
    if cached:
        return cached

    row = lookup_company_by_domain(normalized_domain)
    if row and row.get("label"):
        if _should_reject_fg_label(normalized_domain, row.get("label", "")):
            logger.info("Filtered FoundryGraph label for %s: %s", normalized_domain, row.get("label"))
            return None
        result = {
            "name": row.get("label"),
            "wikidata_id": row.get("wikidata_id"),
            "source": row.get("source", "foundrygraph_bq"),
        }
        _cache_set(_FG_DOMAIN_CACHE, normalized_domain, result)
        return result

    return None


async def _resolve_redirect_domains(domains: List[str]) -> Dict[str, str]:
    if not domains:
        return {}

    semaphore = asyncio.Semaphore(max(1, _FG_REDIRECT_CONCURRENCY))

    async def _resolve(domain: str) -> tuple[str, Optional[str]]:
        async with semaphore:
            canonical = await asyncio.to_thread(_resolve_redirect_domain, domain)
            return domain, canonical

    pairs = await asyncio.gather(*(_resolve(domain) for domain in domains))
    return {
        domain: canonical
        for domain, canonical in pairs
        if canonical and canonical != domain
    }


async def _lookup_canonical_names(domains: List[str]) -> Dict[str, Dict[str, Any]]:
    normalized = sorted({_normalize_domain(domain) for domain in domains if domain})
    if not normalized:
        return {}

    results: Dict[str, Dict[str, Any]] = {}
    missing: List[str] = []
    for domain in normalized:
        cached = _cache_get(_FG_DOMAIN_CACHE, domain, _FG_DOMAIN_CACHE_TTL)
        if cached:
            results[domain] = cached
        else:
            missing.append(domain)

    if missing:
        batch_size = int(os.getenv("FM_FG_DOMAIN_BATCH_SIZE", "500"))
        for chunk in _chunked(missing, batch_size):
            bq_results = lookup_companies_by_domain(chunk)
            for domain in chunk:
                row = bq_results.get(domain)
                if row and row.get("label"):
                    if _should_reject_fg_label(domain, row.get("label", "")):
                        logger.info("Filtered FoundryGraph label for %s: %s", domain, row.get("label"))
                        continue
                    result = {
                        "name": row.get("label"),
                        "wikidata_id": row.get("wikidata_id"),
                        "source": row.get("source", "foundrygraph_bq"),
                    }
                    results[domain] = result
                    _cache_set(_FG_DOMAIN_CACHE, domain, result)
            if _FG_REDIRECT_FALLBACK:
                misses = [d for d in chunk if d not in results]
                if misses:
                    redirect_map = await _resolve_redirect_domains(
                        misses[:_FG_REDIRECT_MAX]
                    )
                    if redirect_map:
                        redirected = sorted(set(redirect_map.values()))
                        redirect_results = lookup_companies_by_domain(redirected)
                        for original, canonical in redirect_map.items():
                            row = redirect_results.get(canonical)
                            if row and row.get("label"):
                                if _should_reject_fg_label(original, row.get("label", "")):
                                    logger.info("Filtered FoundryGraph label for %s: %s", original, row.get("label"))
                                    continue
                                result = {
                                    "name": row.get("label"),
                                    "wikidata_id": row.get("wikidata_id"),
                                    "source": "foundrygraph_bq_redirect",
                                }
                                results[original] = result
                                _cache_set(_FG_DOMAIN_CACHE, original, result)
    return results


def _normalize_score(raw: Any) -> float:
    try:
        score = float(raw)
    except (TypeError, ValueError):
        return 0.0
    return score / 100.0 if score > 1.0 else score


def _confidence_score(value: Any) -> float:
    if value is None:
        return 0.0
    if isinstance(value, (int, float)):
        return _normalize_score(value)
    if isinstance(value, str):
        raw = value.strip().lower()
        if raw in {"high", "hi"}:
            return 0.95
        if raw in {"med", "medium"}:
            return 0.80
        if raw in {"low", "lo"}:
            return 0.60
        try:
            return _normalize_score(float(raw))
        except ValueError:
            return 0.0
    return 0.0


def _chunked(values: List[str], size: int) -> List[List[str]]:
    if size <= 0:
        return [values]
    return [values[i : i + size] for i in range(0, len(values), size)]


def _get_async_runner_loop() -> asyncio.AbstractEventLoop:
    global _ASYNC_RUNNER_LOOP, _ASYNC_RUNNER_THREAD

    loop = _ASYNC_RUNNER_LOOP
    thread = _ASYNC_RUNNER_THREAD
    if loop is not None and loop.is_running() and thread is not None and thread.is_alive():
        return loop

    with _ASYNC_RUNNER_LOCK:
        loop = _ASYNC_RUNNER_LOOP
        thread = _ASYNC_RUNNER_THREAD
        if loop is not None and loop.is_running() and thread is not None and thread.is_alive():
            return loop

        _ASYNC_RUNNER_READY.clear()

        def _runner() -> None:
            global _ASYNC_RUNNER_LOOP
            runner_loop = asyncio.new_event_loop()
            asyncio.set_event_loop(runner_loop)
            _ASYNC_RUNNER_LOOP = runner_loop
            _ASYNC_RUNNER_READY.set()
            runner_loop.run_forever()

        _ASYNC_RUNNER_THREAD = threading.Thread(
            target=_runner,
            name="normalize-company-async-runner",
            daemon=True,
        )
        _ASYNC_RUNNER_THREAD.start()

    if not _ASYNC_RUNNER_READY.wait(timeout=5):
        raise RuntimeError("normalize_company async runner failed to start")
    if _ASYNC_RUNNER_LOOP is None:
        raise RuntimeError("normalize_company async runner unavailable")
    return _ASYNC_RUNNER_LOOP


def _run_async(coro: Any) -> Any:
    try:
        loop = asyncio.get_running_loop()
    except RuntimeError:
        return asyncio.run(coro)

    if not loop.is_running():
        return loop.run_until_complete(coro)

    runner_loop = _get_async_runner_loop()
    future = asyncio.run_coroutine_threadsafe(coro, runner_loop)
    return future.result()


async def _fetch_sfdc_accounts(
    sfdc_gateway: Any, profile: Optional[Dict[str, Any]] = None
) -> List[Dict[str, Any]]:
    fields = ["Id", "Name", "Website", "Domain_Append__c"]
    if profile:
        blocking = profile.get("blocking_fields") or []
        match_fields = profile.get("match_fields") or []
        fields = ["Id", "Name"] + list(blocking) + list(match_fields)
    # de-dupe while preserving order
    deduped: List[str] = []
    seen = set()
    for f in fields:
        if f in seen:
            continue
        seen.add(f)
        deduped.append(f)
    soql = "SELECT " + ", ".join(deduped) + " FROM Account WHERE IsDeleted = false"
    try:
        res = await sfdc_gateway.bulk_query(soql, "Account")
    except Exception as exc:
        logger.debug("SFDC bulk_query failed for dynamic fields: %s", exc)
        if profile:
            fallback = "SELECT Id, Name, Website, Domain_Append__c FROM Account WHERE IsDeleted = false"
            res = await sfdc_gateway.bulk_query(fallback, "Account")
        else:
            raise
    if isinstance(res, str):
        import csv
        from io import StringIO

        reader = csv.DictReader(StringIO(res))
        return list(reader)
    if isinstance(res, list):
        return res
    if isinstance(res, dict) and res.get("records"):
        return res["records"]
    return []


async def _get_sfdc_account_cache(sfdc_gateway: Any) -> Optional[Dict[str, Any]]:
    tenant_id = str(getattr(sfdc_gateway, "tenant_id", "default") or "default")
    cached = _cache_get(_SFDC_ACCOUNT_CACHE, tenant_id, _SFDC_ACCOUNT_CACHE_TTL)
    if cached:
        logger.info("SFDC cache hit: %d records", len(cached.get("records", [])))
        return cached

    # Cache miss: make sure integration is ready once per refresh
    try:
        if hasattr(sfdc_gateway, "_ensure_integration"):
            await sfdc_gateway._ensure_integration()
    except Exception as exc:
        logger.debug("SFDC ensure integration failed: %s", exc)
        # continue - we may still be able to query

    try:
        status = await sfdc_gateway.get_status()
        if not status.get("connected", False):
            return None
    except Exception as exc:
        logger.debug("Salesforce status check failed: %s", exc)
        return None

    profile = None
    try:
        profile = await get_sfdc_field_profile(sfdc_gateway)
    except Exception as exc:
        logger.debug("SFDC field profiler failed: %s", exc)
        profile = None

    records = await _fetch_sfdc_accounts(sfdc_gateway, profile)
    if not records:
        return None

    normalized_records: List[Dict[str, Any]] = []
    token_index: Dict[str, List[int]] = {}
    name_key_index: Dict[str, List[int]] = {}
    domain_index: Dict[str, Dict[str, Any]] = {}
    strict_id_index: Dict[str, Dict[str, Any]] = {}

    profile_fields = (profile or {}).get("fields") or {}
    domain_fields = [
        name for name, meta in profile_fields.items() if meta.get("role") == "domain"
    ]
    alias_fields = [
        name for name, meta in profile_fields.items() if meta.get("role") == "alias"
    ]
    strict_id_fields = [
        name
        for name, meta in profile_fields.items()
        if meta.get("role") == "strict_id"
    ]
    if not domain_fields:
        domain_fields = ["Domain_Append__c", "Website"]

    for idx, rec in enumerate(records):
        account_id = rec.get("Id") or rec.get("id")
        name = rec.get("Name") or rec.get("name") or ""
        website = rec.get("Website") or rec.get("website") or ""
        domains: List[str] = []
        for field in domain_fields:
            raw_value = rec.get(field) or rec.get(field.lower())
            if not raw_value:
                continue
            normalized_domain = _normalize_domain(raw_value)
            if normalized_domain:
                domains.append(normalized_domain)
        domain = domains[0] if domains else ""

        name_candidates = [name]
        for field in alias_fields:
            raw_alias = rec.get(field) or rec.get(field.lower())
            if raw_alias:
                name_candidates.append(raw_alias)

        entry = {
            "account_id": account_id,
            "account_name": name,
            "website": website,
            "domain": domain,
            "name_key": "",
            "tokens": [],
        }
        normalized_records.append(entry)

        for d in domains:
            if d and d not in domain_index:
                domain_index[d] = entry

        for candidate in name_candidates:
            name_key = _normalize_name_key(candidate)
            tokens = _name_tokens(name_key)
            if name_key:
                name_key_index.setdefault(name_key, []).append(idx)
            for token in tokens:
                token_index.setdefault(token, []).append(idx)

        for field in strict_id_fields:
            raw_id = rec.get(field) or rec.get(field.lower())
            if raw_id is None:
                continue
            value = str(raw_id).strip()
            if value and value not in strict_id_index:
                strict_id_index[value] = entry

    cache_entry = {
        "records": normalized_records,
        "token_index": token_index,
        "name_key_index": name_key_index,
        "domain_index": domain_index,
        "strict_id_index": strict_id_index,
        "total": len(normalized_records),
        "profile": profile or {},
    }
    logger.info(
        "SFDC cache built: records=%d, domains=%d, name_keys=%d, sample_keys=%s",
        len(normalized_records),
        len(domain_index),
        len(name_key_index),
        list(name_key_index.keys())[:5],
    )
    _cache_set(_SFDC_ACCOUNT_CACHE, tenant_id, cache_entry)
    return cache_entry


def _build_sfdc_result(
    account: Dict[str, Any],
    *,
    score: float,
    match_method: str,
    name: str,
) -> Dict[str, Any]:
    canonical = (account.get("account_name") or "").strip()
    result = {
        "value": canonical,
        "confidence": "high" if score >= _SFDC_MATCH_HIGH else "med",
        "confidence_score": score,
        "source": "salesforce",
        "account_id": account.get("account_id"),
        "account_name": account.get("account_name"),
        "website": account.get("website"),
        "domain": account.get("domain"),
        "match_method": match_method,
    }
    if canonical and name.strip() and canonical.casefold() != name.strip().casefold():
        result["corrected"] = True
        result["original"] = name
    return result


def _score_sfdc_domain_match(
    name: str, account: Dict[str, Any], *, explicit_domain: bool
) -> float:
    score = _SFDC_DOMAIN_MATCH_BASE
    if explicit_domain:
        score += _SFDC_DOMAIN_MATCH_EXPLICIT_BONUS

    input_norm = _normalize_name_key(name)
    account_norm = _normalize_name_key(account.get("account_name") or "")
    if input_norm and account_norm:
        if input_norm == account_norm:
            return max(score, _SFDC_MATCH_HIGH)

        input_tokens = set(_name_tokens(input_norm))
        account_tokens = set(_name_tokens(account_norm))
        if input_tokens and account_tokens and (
            input_tokens.issubset(account_tokens)
            or account_tokens.issubset(input_tokens)
        ):
            score = max(score, _SFDC_DOMAIN_MATCH_BASE + _SFDC_DOMAIN_MATCH_NAME_BONUS)

    return min(score, 0.99)


async def _match_sfdc_accounts(
    names: List[str],
    sfdc_gateway: Any,
    *,
    threshold: float,
    domains: Optional[List[Optional[str]]] = None,
) -> Dict[int, Dict[str, Any]]:
    if not names:
        return {}

    try:
        import pandas as pd
        from ..services.match_gateway import MatchGateway
    except Exception as exc:
        logger.debug("MatchGateway unavailable for SFDC matching: %s", exc)
        return {}

    cache = await _get_sfdc_account_cache(sfdc_gateway)
    if not cache:
        return {}

    records = cache["records"]
    if not records:
        return {}

    domain_index = cache["domain_index"]
    token_index = cache["token_index"]
    name_key_index = cache.get("name_key_index", {})
    total = cache.get("total", len(records))

    logger.debug(
        "SFDC matching: inputs=%d cache_records=%d domains=%d",
        len(names),
        total,
        len(domain_index),
    )

    output: Dict[int, Dict[str, Any]] = {}
    unresolved: List[str] = []
    unresolved_indices: List[int] = []

    domain_overrides: Optional[List[Optional[str]]] = None
    if domains is not None:
        domain_overrides = list(domains[: len(names)])
        if len(domain_overrides) < len(names):
            domain_overrides.extend([None] * (len(names) - len(domain_overrides)))

    norm_check = _normalize_name_key("Ciscos")
    norm_sfdc_sample = _normalize_name_key("Cisco Inc")
    logger.debug(
        "SFDC name normalization check: input='%s' -> '%s', sfdc='%s' -> '%s'",
        "Ciscos",
        norm_check,
        "Cisco Inc",
        norm_sfdc_sample,
    )

    for idx, name in enumerate(names):
        domain = ""
        explicit_domain = False
        if domain_overrides:
            override = domain_overrides[idx]
            if override:
                domain = _normalize_domain(str(override))
                explicit_domain = True
        if not domain:
            domain = _extract_domain_from_text(name)
        if domain and domain in domain_index:
            account = domain_index[domain]
            score = _score_sfdc_domain_match(
                name, account, explicit_domain=explicit_domain
            )
            output[idx] = _build_sfdc_result(
                account, score=score, match_method="domain", name=name
            )
            continue
        unresolved.append(name)
        unresolved_indices.append(idx)

    if not unresolved:
        return output

    filtered_unresolved: List[str] = []
    filtered_indices: List[int] = []
    for name, idx in zip(unresolved, unresolved_indices):
        if not name.strip():
            continue
        filtered_unresolved.append(name)
        filtered_indices.append(idx)

    unresolved = filtered_unresolved
    unresolved_indices = filtered_indices
    if not unresolved:
        return output

    # Normalized name and token containment matching
    still_unresolved: List[str] = []
    still_indices: List[int] = []
    for name, idx in zip(unresolved, unresolved_indices):
        norm = _normalize_name_key(name)
        if not norm:
            still_unresolved.append(name)
            still_indices.append(idx)
            continue

        matched_account: Optional[Dict[str, Any]] = None
        score = 0.0
        domain_for_log = ""
        if domain_overrides and idx < len(domain_overrides):
            if domain_overrides[idx]:
                domain_for_log = _normalize_domain(str(domain_overrides[idx]))
        if not domain_for_log:
            domain_for_log = _extract_domain_from_text(name)
        if norm in name_key_index:
            matched_account = records[name_key_index[norm][0]]
            score = 0.95
        else:
            input_tokens = set(t.lower() for t in norm.split())
            candidate_ids: List[int] = []
            for token in input_tokens:
                candidate_ids.extend(token_index.get(token, []))
            if candidate_ids:
                for cid in dict.fromkeys(candidate_ids):
                    rec = records[cid]
                    rec_tokens = set(t.lower() for t in (rec.get("tokens") or []))
                    if input_tokens and input_tokens.issubset(rec_tokens):
                        matched_account = rec
                        score = 0.90
                        break

        logger.debug(
            "SFDC match attempt: input='%s' norm='%s' domain='%s' domain_hit=%s name_key_hit=%s",
            name,
            norm,
            domain_for_log,
            bool(domain_for_log and domain_for_log in domain_index),
            norm in name_key_index,
        )

        if matched_account:
            output[idx] = _build_sfdc_result(
                matched_account, score=score, match_method="name_normalized", name=name
            )
        else:
            still_unresolved.append(name)
            still_indices.append(idx)

    unresolved = still_unresolved
    unresolved_indices = still_indices
    if not unresolved:
        return output

    speed_max = int(os.getenv("FM_NORMALIZE_COMPANY_SPEED_MAX_ROWS", "100"))
    if len(unresolved) <= speed_max:
        max_total = max(1, MatchGateway.SCALE_MODE_THRESHOLD - len(unresolved))
    else:
        max_total = max(1, MatchGateway.SCALE_MODE_THRESHOLD - 1)
    per_name_limit = max(20, max_total // max(1, len(unresolved)))
    per_name_limit = min(per_name_limit, 200)

    candidate_indices: List[int] = []
    seen_candidate: set[int] = set()

    for name in unresolved:
        terms = _generate_search_terms(name)
        indices: List[int] = []
        for term in terms:
            indices.extend(token_index.get(term, []))
        if not indices and total <= max_total:
            indices = list(range(total))

        if indices:
            deduped: List[int] = []
            seen_local: set[int] = set()
            for idx in indices:
                if idx in seen_local:
                    continue
                seen_local.add(idx)
                deduped.append(idx)
                if len(deduped) >= per_name_limit:
                    break
            indices = deduped

        for idx in indices:
            if idx in seen_candidate:
                continue
            seen_candidate.add(idx)
            candidate_indices.append(idx)
            if len(candidate_indices) >= max_total:
                break
        if len(candidate_indices) >= max_total:
            break

    if not candidate_indices:
        return output

    candidates = [records[i] for i in candidate_indices if i < len(records)]
    reference_df = pd.DataFrame(
        {
            "account_name": [c.get("account_name") or "" for c in candidates],
        }
    )
    source_df = pd.DataFrame({"company_name": unresolved})

    gateway = await MatchGateway.create()
    mappings = [
        {
            "source": "company_name",
            "reference": "account_name",
            "weight": 1.0,
            "algorithm": "WRatio",
        }
    ]
    result = await gateway.match(
        source_df,
        reference_df,
        source_col="company_name",
        target_col="account_name",
        threshold=threshold,
        mappings=mappings,
    )

    matches = result.matches
    if matches is None or matches.empty:
        return output

    matches = matches.copy()
    if "score" not in matches.columns:
        if "confidence_score" in matches.columns:
            matches["score"] = matches["confidence_score"]
        elif "match_score" in matches.columns:
            matches["score"] = matches["match_score"]
        else:
            matches["score"] = 0.0

    matches["score"] = matches["score"].apply(_normalize_score)
    matches = matches.sort_values("score", ascending=False)
    best = matches.drop_duplicates(subset=["source_id"], keep="first")

    for _, row in best.iterrows():
        source_id = row.get("source_id")
        reference_id = row.get("reference_id")
        if source_id is None or reference_id is None:
            continue
        try:
            source_idx = int(source_id)
            ref_idx = int(reference_id)
        except (TypeError, ValueError):
            continue

        score = _normalize_score(row.get("score"))
        if score < threshold:
            continue
        if ref_idx < 0 or ref_idx >= len(candidates):
            continue

        account = candidates[ref_idx]
        global_idx = unresolved_indices[source_idx]
        output[global_idx] = _build_sfdc_result(
            account, score=score, match_method="name_fuzzy", name=names[global_idx]
        )

    return output


def _maybe_enrich_sfdc_domain(match: Dict[str, Any]) -> Dict[str, Any]:
    if match.get("domain"):
        return match

    account_name = match.get("account_name") or ""
    if not account_name:
        return match

    registry_enabled = (
        os.getenv("FM_COMPANY_TO_DOMAIN_REGISTRY", "true").lower() == "true"
    )
    resolved_domain = _registry_domain_for_name(account_name, registry_enabled)
    if not resolved_domain:
        return match

    match["domain"] = resolved_domain
    canonical = _lookup_canonical_name(resolved_domain)
    if canonical and canonical.get("wikidata_id"):
        match["wikidata_id"] = canonical.get("wikidata_id")
    return match


def normalize_company(
    text: str,
    *,
    force_llm: bool = False,
    sfdc_gateway: Optional[Any] = None,
    resolution_target: Optional[str] = None,
    salesforce_connected: Optional[bool] = None,
    domain: Optional[str] = None,
) -> Dict[str, Any]:
    if text is None or not str(text).strip():
        if not domain:
            return {"value": "", "confidence": "high"}

    results = _run_async(
        normalize_company_batch_async(
            [str(text or "")],
            force_llm=force_llm,
            sfdc_gateway=sfdc_gateway,
            resolution_target=resolution_target,
            salesforce_connected=salesforce_connected,
            domains=[domain],
        )
    )
    if not results:
        return {"value": "", "confidence": "high"}
    return results[0]


async def normalize_company_batch_async(
    texts: List[str],
    *,
    force_llm: bool = False,
    sfdc_gateway: Optional[Any] = None,
    resolution_target: Optional[str] = None,
    salesforce_connected: Optional[bool] = None,
    domains: Optional[List[Optional[str]]] = None,
) -> List[Dict[str, Any]]:
    """
    Batch version of normalize_company with MatchGateway fuzzy matching.
    """
    if not texts:
        return []

    results: List[Optional[Dict[str, Any]]] = [None] * len(texts)
    registry_enabled = (
        os.getenv("FM_COMPANY_TO_DOMAIN_REGISTRY", "true").lower() == "true"
    )
    fallback_enabled = (
        os.getenv("TRANSFORM_ENABLE_LLM_FALLBACK", "true").lower() == "true"
    )
    llm_allowed = fallback_enabled and len(texts) <= 500
    if not llm_allowed:
        force_llm = False

    domain_inputs: List[Optional[str]] = []
    if domains is not None:
        domain_inputs = list(domains[: len(texts)])
        if len(domain_inputs) < len(texts):
            domain_inputs.extend([None] * (len(texts) - len(domain_inputs)))
    else:
        domain_inputs = [None] * len(texts)

    non_empty_indices: List[int] = []
    non_empty_names: List[str] = []
    non_empty_domains: List[Optional[str]] = []
    non_empty_has_name: List[bool] = []

    for idx, raw in enumerate(texts):
        text = "" if raw is None else str(raw)
        raw_domain = domain_inputs[idx]
        if not text.strip() and not (raw_domain or "").strip():
            results[idx] = {"value": "", "confidence": "high"}
            continue
        non_empty_indices.append(idx)
        non_empty_names.append(text)
        non_empty_domains.append(raw_domain)
        non_empty_has_name.append(bool(text.strip()))

    if not non_empty_names:
        return [r or {"value": "", "confidence": "high"} for r in results]

    target = (resolution_target or "smart").lower()
    use_sfdc = target in {"smart", "salesforce"}
    if salesforce_connected is not None:
        use_sfdc = use_sfdc and salesforce_connected
    use_sfdc = use_sfdc and sfdc_gateway is not None

    if target == "salesforce" and not use_sfdc:
        for global_idx in non_empty_indices:
            results[global_idx] = {"value": "", "confidence": "low"}
        return [r or {"value": "", "confidence": "high"} for r in results]

    # Phase 1: SFDC matching (if connected)
    sfdc_results: Dict[int, Dict[str, Any]] = {}
    sfdc_scores: Dict[int, float] = {}
    if use_sfdc:
        sfdc_chunk = int(os.getenv("FM_NORMALIZE_COMPANY_SFDC_CHUNK", "1000"))
        for offset in range(0, len(non_empty_names), max(1, sfdc_chunk)):
            chunk_names = non_empty_names[offset : offset + sfdc_chunk]
            chunk_domains = non_empty_domains[offset : offset + sfdc_chunk]
            try:
                sfdc_matches = await _match_sfdc_accounts(
                    chunk_names,
                    sfdc_gateway,
                    threshold=_SFDC_MATCH_THRESHOLD,
                    domains=chunk_domains,
                )
            except Exception as exc:
                logger.debug("normalize_company SFDC match failed: %s", exc)
                sfdc_matches = {}

            for local_idx, match in sfdc_matches.items():
                global_idx = non_empty_indices[offset + local_idx]
                match = _maybe_enrich_sfdc_domain(match)
                sfdc_results[global_idx] = match

        if target == "salesforce":
            for global_idx in non_empty_indices:
                match = sfdc_results.get(global_idx)
                if match:
                    results[global_idx] = match
                else:
                    results[global_idx] = {"value": "", "confidence": "low"}
            return [r or {"value": "", "confidence": "high"} for r in results]

    for local_idx, global_idx in enumerate(non_empty_indices):
        sfdc_match = sfdc_results.get(global_idx)
        sfdc_score = _confidence_score(
            sfdc_match.get("confidence_score") if sfdc_match else None
        )
        if sfdc_match and not sfdc_score:
            sfdc_score = _confidence_score(sfdc_match.get("confidence"))
        sfdc_scores[global_idx] = sfdc_score

    fg_local_indices: List[int] = []
    fg_names: List[str] = []
    fg_domains: List[Optional[str]] = []
    fg_has_name: List[bool] = []
    for local_idx, global_idx in enumerate(non_empty_indices):
        sfdc_score = sfdc_scores.get(global_idx, 0.0)
        if (global_idx not in sfdc_results) or (sfdc_score < _SFDC_FLOOR):
            fg_local_indices.append(local_idx)
            fg_names.append(non_empty_names[local_idx])
            fg_domains.append(non_empty_domains[local_idx])
            fg_has_name.append(non_empty_has_name[local_idx])

    fg_results: Dict[int, Dict[str, Any]] = {}
    fg_remaining_indices = list(range(len(fg_names)))
    fg_remaining_names = [fg_names[i] for i in fg_remaining_indices]
    fg_remaining_domains = [fg_domains[i] for i in fg_remaining_indices]

    # Phase 2: Registry exact/normalized match (FG candidate set)
    if fg_remaining_names and registry_enabled:
        registry = _get_local_registry()
        if registry:
            for local_idx, name in enumerate(fg_remaining_names):
                family_id, conf = registry.lookup_family_by_name(name)
                if not family_id or conf < _REGISTRY_MATCH_CONFIDENCE:
                    continue
                canonical = registry.get_display_name(family_id)
                if not canonical:
                    continue
                domain = _registry_primary_domain(registry, family_id)
                global_idx = non_empty_indices[fg_local_indices[local_idx]]
                fg_results[global_idx] = {
                    "value": canonical,
                    "confidence": "high",
                    "confidence_score": conf,
                    "source": "registry",
                    "family_id": family_id,
                    "domain": domain,
                }

    fg_remaining_indices = [
        i
        for i in range(len(fg_names))
        if non_empty_indices[fg_local_indices[i]] not in fg_results
    ]
    fg_remaining_names = [fg_names[i] for i in fg_remaining_indices]
    fg_remaining_domains = [fg_domains[i] for i in fg_remaining_indices]

    # Phase 3: Domain extraction then domain-anchored lookup (FG)
    if fg_remaining_names:
        domains_by_idx: Dict[int, str] = {}
        for local_idx, name in enumerate(fg_remaining_names):
            domain = ""
            if fg_remaining_domains[local_idx]:
                domain = _normalize_domain(str(fg_remaining_domains[local_idx]))
            if not domain:
                domain = _extract_domain_from_text(name)
            if domain:
                domains_by_idx[local_idx] = domain

        if domains_by_idx:
            canonical_map = await _lookup_canonical_names(list(domains_by_idx.values()))
            for local_idx, domain in domains_by_idx.items():
                canonical = canonical_map.get(domain)
                if not canonical or not canonical.get("name"):
                    continue
                global_idx = non_empty_indices[fg_local_indices[fg_remaining_indices[local_idx]]]
                fg_results[global_idx] = {
                    "value": canonical["name"],
                    "confidence": "high",
                    "confidence_score": 0.90,
                    "source": canonical.get("source", "foundrygraph"),
                    "domain": domain,
                    "wikidata_id": canonical.get("wikidata_id"),
                }

    fg_remaining_indices = [
        i
        for i in range(len(fg_names))
        if non_empty_indices[fg_local_indices[i]] not in fg_results
    ]
    fg_remaining_names = [fg_names[i] for i in fg_remaining_indices]
    fg_remaining_has_name = [fg_has_name[i] for i in fg_remaining_indices]

    # Phase 4: Deterministic heuristic cleanup (FG)
    for local_idx, _ in enumerate(fg_remaining_indices):
        global_idx = non_empty_indices[fg_local_indices[fg_remaining_indices[local_idx]]]
        text = fg_remaining_names[local_idx]
        if not fg_remaining_has_name[local_idx]:
            fg_results[global_idx] = {
                "value": "",
                "confidence": "low",
                "confidence_score": 0.0,
                "source": "heuristic",
            }
            continue
        result = _heuristic_company(text)
        used_llm = False

        if force_llm and llm_allowed:
            provider = _get_llm_provider()
            llm = provider
            if llm:
                schema = {
                    "type": "object",
                    "properties": {"name": {"type": "string"}},
                    "required": ["name"],
                }
                prompt = (
                    "Normalize the company name. Strip legal suffixes, expand common abbreviations, "
                    'and return only the canonical company name as JSON: {"name":"<canonical>"}.\n'
                    f'Input: "{text}"'
                )
                try:
                    out = llm.generate_json(prompt, schema=schema, num_predict=64)
                    name = (out.get("value", {}) or {}).get("name") or result or text
                    if name:
                        result = name
                        used_llm = True
                except Exception:
                    pass

        fg_results[global_idx] = {
            "value": result,
            "confidence": "low" if not result else "med",
            "confidence_score": 0.60 if result else 0.0,
            "source": "heuristic",
            "used_llm": used_llm,
        }

    # Final waterfall: SFDC priority with floor, FG override only if SFDC below floor
    for global_idx in non_empty_indices:
        sfdc_match = sfdc_results.get(global_idx)
        fg_match = fg_results.get(global_idx)
        sfdc_score = sfdc_scores.get(global_idx, 0.0)
        fg_score = _confidence_score(
            fg_match.get("confidence_score") if fg_match else None
        )
        if fg_match and not fg_score:
            fg_score = _confidence_score(fg_match.get("confidence"))

        chosen: Dict[str, Any]
        tier = "tier_5_none"
        flag = "no_match"

        if sfdc_match and sfdc_score >= _SFDC_HIGH_CONFIDENCE:
            chosen = sfdc_match
            tier = "tier_1_high_sfdc"
            flag = "auto_link"
        elif sfdc_match and sfdc_score >= _SFDC_FLOOR:
            chosen = sfdc_match
            tier = "tier_2_weak_sfdc"
            flag = "review_needed"
        elif fg_match and fg_score >= _FG_OVERRIDE_THRESHOLD:
            chosen = fg_match
            tier = "tier_3_high_fg"
            flag = "new_prospect"
        elif fg_match:
            chosen = fg_match
            tier = "tier_4_low_fg"
            flag = "low_confidence"
        else:
            chosen = {"value": "", "confidence": "low", "source": "none"}

        chosen["match_tier"] = tier
        chosen["match_flag"] = flag
        chosen["sfdc_score"] = sfdc_score
        chosen["fg_score"] = fg_score
        results[global_idx] = chosen

    return [r or {"value": "", "confidence": "high"} for r in results]


def normalize_company_batch(
    texts: List[str],
    *,
    force_llm: bool = False,
    sfdc_gateway: Optional[Any] = None,
    resolution_target: Optional[str] = None,
    salesforce_connected: Optional[bool] = None,
    domains: Optional[List[Optional[str]]] = None,
) -> List[Dict[str, Any]]:
    return _run_async(
        normalize_company_batch_async(
            texts,
            force_llm=force_llm,
            sfdc_gateway=sfdc_gateway,
            resolution_target=resolution_target,
            salesforce_connected=salesforce_connected,
            domains=domains,
        )
    )
