from __future__ import annotations

import os
import time
import unicodedata
import logging
import csv
from collections import defaultdict, deque
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple

# Lazy import for FoundryGraph service to avoid startup hang
_foundrygraph_connection_getter = None


def _use_local_foundrygraph() -> bool:
    value = os.getenv("FM_DISABLE_LOCAL_FOUNDRYGRAPH", "").strip().lower()
    if value in ("1", "true", "yes", "on"):
        return False
    env = os.getenv("ENV", "").strip().lower()
    return env in ("development", "dev", "local")


def _get_foundrygraph_connection():
    """Lazy loader for FoundryGraph connection to avoid GCP/duckdb import at startup."""
    if not _use_local_foundrygraph():
        return None
    global _foundrygraph_connection_getter
    if _foundrygraph_connection_getter is None:
        from ..services.foundrygraph_serving import get_foundrygraph_connection
        _foundrygraph_connection_getter = get_foundrygraph_connection
    return _foundrygraph_connection_getter()

from ..core.psl_root import registrable_root
from ..services.domain_family import get_registry
from fmatch.core.heuristics import KNOWN_COMPANY_DOMAINS
from .domain_validators import (
    generate_domain_candidates,
    probe_domain_dns,
)
from .wikidata_index import lookup_company_domain
from .llm_domain_predictor import predict_domain_with_llm
from . import wikidata_profiles

logger = logging.getLogger(__name__)

OVERRIDE_PATH = (
    Path(__file__).resolve().parents[4] / "data" / "company_domain_overrides.csv"
)
ALIAS_PATH = Path(__file__).resolve().parents[4] / "data" / "fortune_1000_aliases.csv"


def _slug(name: str) -> str:
    s = (name or "").strip()
    s = unicodedata.normalize("NFKC", s)
    s = s.lower()
    s = s.replace("&", "and")
    # Keep unicode letters/digits, drop punctuation
    return "".join(ch for ch in s if ch.isalnum())


def _normalize_key(name: str) -> str:
    if not name:
        return ""
    slugged = _slug(name)
    if slugged:
        return slugged
    return unicodedata.normalize("NFKC", name).strip().lower()


def _maybe_attach_profile(
    value: Dict[str, Any], domain: Optional[str], enabled: bool
) -> None:
    if not enabled or not domain:
        return
    enrichment = wikidata_profiles.get_profile_for_domain(domain.lower())
    if enrichment:
        metadata = value.setdefault("metadata", {})
        metadata["wikidata_profile"] = enrichment


def _normalize_for_registry_lookup(name: str) -> str:
    """Normalize company name for registry lookup - strip plurals/agent suffixes."""
    s = (name or "").strip().lower()
    if not s:
        return ""

    if s.endswith("ers") and len(s) > 6:
        s = s[:-3]
    elif s.endswith("er") and len(s) > 4:
        s = s[:-2]
    elif s.endswith("s") and len(s) > 4 and not s.endswith(("ss", "us", "is")):
        s = s[:-1]

    if s.endswith("i") and len(s) > 4:
        s = s[:-1]

    if len(s) > 3 and s[-1:] and s[-1] == s[-2]:
        s = s[:-1]

    return s


def _get_primary_domain_from_family(family_id: str) -> Optional[str]:
    try:
        registry = get_registry()
        parent = getattr(registry, "family_parent_domain", {}).get(family_id)
        if parent:
            return parent
        domains = registry.family_to_domains.get(family_id, set()) if registry else set()
        if not domains:
            return None
        return sorted(domains, key=lambda d: (len(d), d))[0]
    except Exception:
        return None


@dataclass
class ResolutionResult:
    value: Dict[str, Any]
    cacheable: bool = True


@dataclass
class ResolverMetrics:
    mode: str
    total_unique: int = 0
    registry_hits: int = 0
    wikidata_hits: int = 0
    hardcoded_hits: int = 0
    override_hits: int = 0
    alias_hits: int = 0
    ticker_hits: int = 0
    cache_hits: int = 0
    heuristic_hits: int = 0
    dns_verified: int = 0
    needs_dns: int = 0
    unresolved: int = 0
    dns_attempts: int = 0
    dns_timeouts: int = 0
    dns_failures: int = 0
    breaker_tripped: bool = False
    elapsed_ms: float = 0.0


class CircuitBreaker:
    def __init__(self, threshold: int = 20, window_seconds: int = 120):
        self.threshold = threshold
        self.window_seconds = window_seconds
        self._events: deque[float] = deque()

    def record_timeout(self) -> bool:
        now = time.time()
        self._events.append(now)
        self._evict(now)
        return self.tripped

    def _evict(self, now: float) -> None:
        while self._events and now - self._events[0] > self.window_seconds:
            self._events.popleft()

    @property
    def tripped(self) -> bool:
        return len(self._events) >= self.threshold


class CompanyDomainResolver:
    def __init__(self) -> None:
        self._name_cache: Dict[str, Tuple[ResolutionResult, float]] = {}
        self._domain_cache: Dict[str, Tuple[Dict[str, Any], float]] = {}
        self._name_ttl = int(os.getenv("FM_COMPANY_TO_DOMAIN_NAME_CACHE_TTL", "0"))
        self._domain_ttl = int(os.getenv("FM_COMPANY_TO_DOMAIN_DOMAIN_CACHE_TTL", "0"))
        self._max_dns_per_name = int(os.getenv("FM_COMPANY_TO_DOMAIN_DNS_PER_NAME", 1))
        self._dns_timeout = float(os.getenv("FM_COMPANY_TO_DOMAIN_DNS_TIMEOUT", "0.3"))
        self._dns_workers = int(os.getenv("FM_COMPANY_TO_DOMAIN_DNS_WORKERS", 8))
        self._breaker_threshold = int(
            os.getenv("FM_COMPANY_TO_DOMAIN_DNS_BREAKER_THRESHOLD", 25)
        )
        self._breaker_window = int(
            os.getenv("FM_COMPANY_TO_DOMAIN_DNS_BREAKER_WINDOW", 120)
        )
        self._overrides = self._load_overrides()
        self._aliases = self._load_aliases()
        self._ticker_map = self._load_ticker_map()

    @staticmethod
    def _detect_org_type(lowered_name: str) -> Optional[str]:
        if any(
            term in lowered_name
            for term in ("university", "college", "school", "academy", "institute")
        ):
            return "edu"
        if any(
            term in lowered_name for term in ("hospital", "health", "clinic", "medical")
        ):
            return "health"
        if any(
            term in lowered_name
            for term in (
                "department",
                "city of",
                "county",
                "state of",
                "ministry",
                "united states",
            )
        ):
            return "gov"
        return None

    def _load_overrides(self) -> Dict[str, Dict[str, Any]]:
        overrides: Dict[str, Dict[str, Any]] = {}
        if not OVERRIDE_PATH.exists():
            return overrides
        try:
            with OVERRIDE_PATH.open("r", encoding="utf-8") as handle:
                reader = csv.DictReader(handle)
                for row in reader:
                    raw_name = (row.get("name") or "").strip()
                    domain = (row.get("domain") or "").strip().lower()
                    if not raw_name or not domain:
                        continue
                    normalized_domain = registrable_root(domain) or domain
                    try:
                        confidence = float(row.get("confidence") or 0.95)
                    except (TypeError, ValueError):
                        confidence = 0.95
                    source = (row.get("source") or "override").strip() or "override"
                    aliases = [raw_name]
                    extra = row.get("aliases")
                    if extra:
                        aliases.extend(a.strip() for a in extra.split("|") if a.strip())
                    for alias in aliases:
                        key = _normalize_key(alias)
                        if not key:
                            continue
                        overrides[key] = {
                            "domain": normalized_domain,
                            "confidence": confidence,
                            "source": source,
                            "raw_name": raw_name,
                        }
        except Exception as exc:
            logger.debug(
                "Failed to load domain overrides from %s: %s", OVERRIDE_PATH, exc
            )
        return overrides

    def _load_aliases(self) -> Dict[str, Dict[str, Any]]:
        alias_map: Dict[str, Dict[str, Any]] = {}
        if not ALIAS_PATH.exists():
            return alias_map
        try:
            with ALIAS_PATH.open("r", encoding="utf-8") as handle:
                reader = csv.DictReader(handle)
                for row in reader:
                    brand = (row.get("brand_name") or "").strip()
                    legal = (row.get("legal_name") or "").strip()
                    domain_raw = (row.get("domain") or "").strip().lower()
                    ticker = (row.get("ticker") or "").strip().upper()
                    if not brand or not domain_raw:
                        continue
                    normalized_brand = _normalize_key(brand)
                    if not normalized_brand:
                        continue
                    domain = registrable_root(domain_raw) or domain_raw
                    alias_map[normalized_brand] = {
                        "domain": domain,
                        "confidence": float(row.get("confidence") or 0.9),
                        "source": row.get("source") or "fortune_alias",
                        "brand": brand,
                        "legal": legal,
                        "ticker": ticker,
                    }
        except Exception as exc:
            logger.debug("Failed to load alias mappings from %s: %s", ALIAS_PATH, exc)
        return alias_map

    def _load_ticker_map(self) -> Dict[str, Dict[str, Any]]:
        """Load ticker-to-domain mappings from FoundryGraph service."""
        mapping: Dict[str, Dict[str, Any]] = {}
        if not _use_local_foundrygraph():
            return mapping
        try:
            con = _get_foundrygraph_connection()
            try:
                rows = con.execute(
                    "SELECT normalized_name, domain, source, confidence FROM ticker_domains"
                ).fetchall()
            except Exception:
                # Table may not exist in all FoundryGraph versions
                rows = []
            for normalized, domain, source, confidence in rows:
                if not normalized or not domain:
                    continue
                mapping[str(normalized)] = {
                    "domain": str(domain).lower(),
                    "source": source or "ticker_manual",
                    "confidence": float(confidence or 0.9),
                }
            # Don't close connection - FoundryGraph service manages its lifecycle
        except Exception as exc:
            logger.debug("Unable to load ticker_domains from FoundryGraph: %s", exc)
        return mapping

    def resolve_many(
        self,
        names: List[str],
        mode: str = "balanced",
        *,
        use_registry: bool = True,
        use_llm: bool = False,
        allow_dns: Optional[bool] = None,
        use_wikidata: Optional[bool] = None,
        use_heuristics: Optional[bool] = None,
    ) -> Tuple[List[ResolutionResult], ResolverMetrics]:
        mode = (mode or "balanced").lower()
        if mode not in {"fast", "balanced", "strict"}:
            mode = "balanced"
        if allow_dns is None:
            allow_dns = mode != "fast"
        firmographics_enabled = (
            os.getenv("FM_COMPANY_TO_DOMAIN_FIRMOGRAPHICS", "true").lower() == "true"
        )

        start = time.time()
        canonical_names: List[str] = [_normalize_key(n) for n in names]
        mapping: Dict[str, List[int]] = defaultdict(list)
        for idx, c in enumerate(canonical_names):
            mapping[c].append(idx)

        metrics = ResolverMetrics(mode=mode, total_unique=len(mapping))
        breaker = CircuitBreaker(
            threshold=self._breaker_threshold, window_seconds=self._breaker_window
        )

        results_by_key: Dict[str, ResolutionResult] = {}

        for key, indices in mapping.items():
            raw_name = names[indices[0]] if indices else ""
            result = self._resolve_single(
                raw_name,
                canonical_key=key,
                mode=mode,
                metrics=metrics,
                breaker=breaker,
                use_registry=use_registry,
                use_llm=use_llm,
                allow_dns=allow_dns,
                use_wikidata=use_wikidata,
                use_heuristics=use_heuristics,
                firmographics_enabled=firmographics_enabled,
            )
            results_by_key[key] = result

        final: List[ResolutionResult] = []
        for key in canonical_names:
            final.append(
                results_by_key.get(
                    key,
                    ResolutionResult({"value": {"domain": "", "confidence": "low"}}),
                )
            )

        metrics.elapsed_ms = (time.time() - start) * 1000.0
        return final, metrics

    def _resolve_single(
        self,
        name: str,
        *,
        canonical_key: str,
        mode: str,
        metrics: ResolverMetrics,
        breaker: CircuitBreaker,
        use_registry: bool,
        use_llm: bool,
        allow_dns: bool,
        use_wikidata: Optional[bool],
        use_heuristics: Optional[bool],
        firmographics_enabled: bool,
    ) -> ResolutionResult:
        now = time.time()
        cached = self._name_cache.get(canonical_key)
        if cached and now - cached[1] <= self._name_ttl:
            metrics.cache_hits += 1
            return cached[0]

        clean_name = (name or "").strip()
        if not clean_name:
            res = ResolutionResult(
                {"value": {"domain": "", "confidence": "low", "reason": "empty_name"}}
            )
            self._name_cache[canonical_key] = (res, now)
            return res

        slugged = _slug(clean_name)

        # Stage 1: Curated overrides (highest priority - user corrections)
        override = self._overrides.get(canonical_key) or self._overrides.get(slugged)
        if override:
            domain = override["domain"]
            confidence_score = override.get("confidence", 0.95)
            if confidence_score >= 0.85:
                confidence_label = "high"
            elif confidence_score >= 0.6:
                confidence_label = "medium"
            else:
                confidence_label = "low"
            metrics.override_hits += 1
            source_type = override.get("source", "override")
            value: Dict[str, Any] = {
                "domain": domain,
                "confidence": confidence_label,
                "confidence_score": confidence_score,
                "source": source_type,
                "chips": ["override", source_type]
                if source_type != "override"
                else ["override"],
            }
            if override.get("raw_name"):
                metadata = value.setdefault("metadata", {})
                metadata["override_name"] = override["raw_name"]
            _maybe_attach_profile(value, domain, firmographics_enabled)
            result = ResolutionResult({"value": value})
            self._name_cache[canonical_key] = (result, now)
            return result

        # Stage 2: Registry lookup (automated data)
        if use_registry:
            registry_enabled = (
                os.getenv("FM_COMPANY_TO_DOMAIN_REGISTRY", "true").lower() == "true"
            )
            if registry_enabled:
                try:
                    registry = get_registry()
                    family_id, conf = registry.lookup_family_by_name(clean_name)
                    normalized = _normalize_for_registry_lookup(clean_name)

                    if (not family_id or conf < 0.85) and normalized and normalized != clean_name.lower():
                        family_id_norm, conf_norm = registry.lookup_family_by_name(
                            normalized
                        )
                        if family_id_norm and conf_norm >= 0.85:
                            family_id, conf = family_id_norm, conf_norm

                    logger.debug(
                        "Registry lookup company_to_domain: original='%s' normalized='%s' family_id=%s conf=%s",
                        clean_name,
                        normalized,
                        family_id,
                        conf,
                    )

                    if family_id and conf >= 0.85:
                        domain = _get_primary_domain_from_family(family_id)
                        if domain:
                            metrics.registry_hits += 1
                            value = {
                                "domain": domain,
                                "confidence": "high",
                                "source": "registry",
                                "family_id": family_id,
                                "chips": ["registry"],
                            }
                            _maybe_attach_profile(value, domain, firmographics_enabled)
                            result = ResolutionResult({"value": value})
                            self._name_cache[canonical_key] = (result, now)
                            return result
                except Exception as exc:
                    logger.debug(f"Registry lookup failed for {clean_name}: {exc}")

        # Stage 2b: Hardcoded dictionary
        if slugged in KNOWN_COMPANY_DOMAINS:
            dom = KNOWN_COMPANY_DOMAINS[slugged]
            root_domain = registrable_root(dom)
            metrics.hardcoded_hits += 1
            value = {
                "domain": root_domain,
                "confidence": "high",
                "source": "hardcoded",
                "chips": ["hardcoded"],
            }
            _maybe_attach_profile(value, root_domain, firmographics_enabled)
            result = ResolutionResult({"value": value})
            self._name_cache[canonical_key] = (result, now)
            return result

        # Stage 2c: alias mappings (brand -> canonical domain)
        alias_entry = self._aliases.get(canonical_key)
        if alias_entry:
            domain = alias_entry["domain"]
            confidence_score = alias_entry.get("confidence", 0.9)
            if confidence_score >= 0.85:
                confidence_label = "high"
            elif confidence_score >= 0.6:
                confidence_label = "medium"
            else:
                confidence_label = "low"
            metrics.alias_hits += 1
            value: Dict[str, Any] = {
                "domain": domain,
                "confidence": confidence_label,
                "confidence_score": confidence_score,
                "source": alias_entry.get("source", "alias"),
                "chips": ["alias"],
            }
            metadata = value.setdefault("metadata", {})
            if alias_entry.get("brand"):
                metadata["brand_name"] = alias_entry["brand"]
            if alias_entry.get("legal"):
                metadata["legal_name"] = alias_entry["legal"]
            if alias_entry.get("ticker"):
                metadata["ticker"] = alias_entry["ticker"]
            _maybe_attach_profile(value, domain, firmographics_enabled)
            result = ResolutionResult({"value": value})
            self._name_cache[canonical_key] = (result, now)
            return result

        # Stage 2d: ticker derived mapping
        ticker_entry = self._ticker_map.get(canonical_key)
        if ticker_entry:
            domain = ticker_entry["domain"]
            confidence_score = ticker_entry.get("confidence", 0.9)
            if confidence_score >= 0.85:
                confidence_label = "high"
            elif confidence_score >= 0.6:
                confidence_label = "medium"
            else:
                confidence_label = "low"
            metrics.ticker_hits += 1
            value: Dict[str, Any] = {
                "domain": domain,
                "confidence": confidence_label,
                "confidence_score": confidence_score,
                "source": ticker_entry.get("source", "ticker_manual"),
                "chips": ["ticker"],
            }
            _maybe_attach_profile(value, domain, firmographics_enabled)
            result = ResolutionResult({"value": value})
            self._name_cache[canonical_key] = (result, now)
            return result

        # Stage 3: Wikidata
        # V1 guardrails: Skip Wikidata if use_wikidata is explicitly False
        if use_wikidata is False:
            wikidata_enabled = False
        else:
            wikidata_enabled = (
                os.getenv("FM_COMPANY_TO_DOMAIN_WIKIDATA", "true").lower() == "true"
            )
        if wikidata_enabled:
            try:
                wikidata_domain = lookup_company_domain(clean_name)
                if wikidata_domain:
                    metrics.wikidata_hits += 1
                    value = {
                        "domain": wikidata_domain,
                        "confidence": "high",
                        "source": "wikidata",
                        "chips": ["wikidata"],
                    }
                    _maybe_attach_profile(value, wikidata_domain, firmographics_enabled)
                    result = ResolutionResult({"value": value})
                    self._name_cache[canonical_key] = (result, now)
                    return result
            except Exception as exc:
                logger.debug(f"Wikidata lookup failed for {clean_name}: {exc}")

        # Stage 4: Heuristic candidates
        # V1 guardrails: Skip heuristics if use_heuristics is explicitly False
        if use_heuristics is False:
            # Return empty result immediately for v1 mode
            metrics.unresolved += 1
            value = {
                "domain": "",
                "confidence": "low",
                "source": "none",
                "reason": "not_in_registry",
                "chips": ["v1_guardrails"],
            }
            result = ResolutionResult({"value": value}, cacheable=True)
            self._name_cache[canonical_key] = (result, now)
            return result

        lowered = clean_name.lower()
        org_hint = self._detect_org_type(lowered)

        candidates = generate_domain_candidates(
            clean_name, max_candidates=8, org_hint=org_hint
        )
        candidate_roots: List[str] = []
        for cand in candidates:
            root = registrable_root(cand)
            if root and root not in candidate_roots:
                candidate_roots.append(root)

        # When no candidates produced, bail early
        if not candidate_roots:
            metrics.unresolved += 1
            value = {
                "domain": "",
                "confidence": "low",
                "source": "none",
                "reason": "no_candidates",
                "chips": ["needs_dns"],
                "needs_dns": True,
            }
            result = ResolutionResult({"value": value}, cacheable=False)
            self._name_cache[canonical_key] = (result, now)
            return result

        # Evaluate candidates with DNS if allowed
        verified_domain: Optional[str] = None
        verified_status: Optional[Dict[str, Any]] = None
        dns_checked = 0
        needs_dns = False

        strong_sources: List[Tuple[str, str]] = []  # (domain, reason)

        for root_domain in candidate_roots:
            # Quick cached hit
            cached_domain_status = self._domain_cache.get(root_domain)
            if (
                cached_domain_status
                and now - cached_domain_status[1] <= self._domain_ttl
            ):
                status = cached_domain_status[0]
            else:
                status = None

            if status and status.get("ok"):
                verified_domain = root_domain
                verified_status = status
                break

            if (
                not allow_dns
                or breaker.tripped
                or dns_checked >= self._max_dns_per_name
            ):
                needs_dns = True
                continue

            dns_checked += 1
            info = probe_domain_dns(root_domain, timeout=self._dns_timeout)
            self._domain_cache[root_domain] = (info, time.time())
            metrics.dns_attempts += 1
            if info.get("status") == "timeout":
                if breaker.record_timeout():
                    metrics.breaker_tripped = True
            if info.get("status") == "timeout":
                metrics.dns_timeouts += 1
            elif not info.get("ok"):
                metrics.dns_failures += 1

            if info.get("ok"):
                verified_domain = root_domain
                verified_status = info
                break

            if breaker.tripped:
                metrics.breaker_tripped = True

        if verified_domain:
            metrics.dns_verified += 1
            chips = ["dns_verified"]
            if org_hint:
                chips.append(org_hint)
            value = {
                "domain": verified_domain,
                "confidence": "high",
                "source": "dns_verified",
                "chips": chips,
            }
            if verified_status:
                value["verification"] = {
                    "status": verified_status.get("status"),
                    "latency_ms": round(verified_status.get("latency_ms", 0.0), 2),
                    "record": verified_status.get("record"),
                }
            _maybe_attach_profile(value, verified_domain, firmographics_enabled)
            result = ResolutionResult({"value": value})
            self._name_cache[canonical_key] = (result, time.time())
            return result

        # No verified domain found
        guess_domain = candidate_roots[0]
        chips = ["heuristic"]
        if org_hint:
            chips.append(org_hint)
        if needs_dns or allow_dns:
            chips.append("needs_dns")
        metrics.heuristic_hits += 1
        metrics.needs_dns += 1
        value = {
            "domain": guess_domain,
            "confidence": "medium" if org_hint else "low",
            "source": "heuristic",
            "chips": chips,
            "needs_dns": True,
            "reason": "dns_unverified",
        }

        # Optional LLM fallback (only if allowed)
        llm_enabled = (
            use_llm or os.getenv("FM_COMPANY_TO_DOMAIN_LLM", "false").lower() == "true"
        )
        if llm_enabled and mode != "fast" and not breaker.tripped:
            try:
                llm_domain = predict_domain_with_llm(clean_name)
                if llm_domain:
                    value["llm_suggestion"] = llm_domain
            except Exception as exc:
                logger.debug(f"LLM prediction failed for {clean_name}: {exc}")

        _maybe_attach_profile(value, value.get("domain"), firmographics_enabled)
        result = ResolutionResult({"value": value}, cacheable=False)
        self._name_cache[canonical_key] = (result, time.time())
        return result


# Global resolver instance
_GLOBAL_RESOLVER = CompanyDomainResolver()


def resolve_company_domains(
    names: List[str],
    mode: str = "balanced",
    *,
    use_registry: bool = True,
    use_llm: bool = False,
    allow_dns: Optional[bool] = None,
    use_wikidata: Optional[bool] = None,
    use_heuristics: Optional[bool] = None,
) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
    results, metrics = _GLOBAL_RESOLVER.resolve_many(
        names,
        mode=mode,
        use_registry=use_registry,
        use_llm=use_llm,
        allow_dns=allow_dns,
        use_wikidata=use_wikidata,
        use_heuristics=use_heuristics,
    )
    return [r.value for r in results], metrics.__dict__


def resolve_company_domain(
    name: str, mode: str = "balanced", **kwargs: Any
) -> Dict[str, Any]:
    values, metrics = resolve_company_domains([name], mode=mode, **kwargs)
    return values[0] if values else {"value": {"domain": "", "confidence": "low"}}


def get_override_for_name(name: str) -> Optional[Dict[str, Any]]:
    if not name:
        return None
    canonical = _normalize_key(name)
    slugged = _slug(name)
    override = _GLOBAL_RESOLVER._overrides.get(
        canonical
    ) or _GLOBAL_RESOLVER._overrides.get(slugged)
    if override:
        return dict(override)
    return None
