"""DuckDB-backed Salesforce Account cache with per-org isolation and Redis metadata.

Key goals:
- Single-writer (Redis lock) with heartbeat + stale-lock breaker
- Many-reader via thread-local DuckDB connections
- Normalized fields (domain/name) computed on ingest so lookups stay fast
"""

from __future__ import annotations

import asyncio
import contextlib
import json
import logging
import os
import tempfile
import threading
import time
from contextlib import contextmanager
from datetime import datetime, timedelta
from decimal import Decimal
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional
from urllib.parse import urlparse

import duckdb

try:  # pragma: no cover - optional dependency during unit-only runs
    import redis.asyncio as redis_async
except Exception:  # pragma: no cover - defensive
    redis_async = None

from .salesforce_gateway import SalesforceGateway
from ..operators.match_salesforce_account import (
    _normalize_company_name,
    _normalize_domain,
)

logger = logging.getLogger(__name__)


class SfdcAccountCache:
    """Per-org DuckDB cache of Salesforce Accounts."""

    CACHE_TTL_S = 24 * 3600
    LOCK_TTL_S = 300  # 5 minutes
    HEARTBEAT_INTERVAL_S = 15
    STALE_LOCK_S = 300

    def __init__(
        self,
        account_id: str,
        integration_id: str,
        redis=None,
        base_path: Optional[str] = None,
    ) -> None:
        self.account_id = str(account_id)
        self.integration_id = str(integration_id)
        self.redis = redis
        self._thread_local = threading.local()
        root = (
            Path(base_path)
            if base_path
            else Path(os.getenv("SFDC_CACHE_PATH") or tempfile.gettempdir())
            / "fmatch_sfdc_cache"
        )
        root.mkdir(parents=True, exist_ok=True)
        safe_key = f"{self.account_id}_{self.integration_id}".replace(":", "_")
        self.db_path = root / f"{safe_key}.duckdb"
        self._meta_key = f"sfdc_cache:{self.account_id}:{self.integration_id}:meta"
        self._lock_key = f"sfdc_cache:{self.account_id}:{self.integration_id}:lock"
        self._lock_ts_key = (
            f"sfdc_cache:{self.account_id}:{self.integration_id}:lock:ts"
        )
        self._last_refresh: Optional[float] = None

    # ─────────────────── Locking ────────────────────
    async def _acquire_lock(self, wait: bool = True, timeout_s: int = 300) -> bool:
        """Acquire distributed lock with heartbeat + stale breaker."""
        if not self.redis:
            return True

        deadline = time.time() + timeout_s
        while True:
            now = int(time.time())
            acquired = await self.redis.set(
                self._lock_key, "1", nx=True, ex=self.LOCK_TTL_S
            )
            if acquired:
                await self.redis.set(self._lock_ts_key, str(now), ex=self.LOCK_TTL_S)
                return True

            # Check for stale lock (no heartbeat for STALE_LOCK_S)
            try:
                last_ts_raw = await self.redis.get(self._lock_ts_key)
                last_ts = int(last_ts_raw) if last_ts_raw else 0
            except Exception:
                last_ts = 0
            if last_ts and (now - last_ts) > self.STALE_LOCK_S:
                logger.warning(
                    "Breaking stale SFDC cache lock for account=%s integration=%s",
                    self.account_id,
                    self.integration_id,
                )
                try:
                    await self.redis.delete(self._lock_key)
                except Exception:
                    logger.debug("Lock delete failed during stale-breaker", exc_info=True)
                continue

            if not wait or time.time() > deadline:
                return False

            await asyncio.sleep(1.0)

    async def _release_lock(self) -> None:
        if not self.redis:
            return
        try:
            await self.redis.delete(self._lock_key)
            await self.redis.delete(self._lock_ts_key)
        except Exception:
            logger.debug("Failed to release SFDC cache lock", exc_info=True)

    async def _heartbeat(self, stop_event: asyncio.Event) -> None:
        """Refresh lock TTL while a refresh is running."""
        if not self.redis:
            return
        while not stop_event.is_set():
            try:
                await self.redis.expire(self._lock_key, self.LOCK_TTL_S)
                await self.redis.set(
                    self._lock_ts_key,
                    str(int(time.time())),
                    ex=self.LOCK_TTL_S,
                )
            except Exception:
                logger.debug("Heartbeat refresh failed", exc_info=True)
            try:
                await asyncio.wait_for(
                    stop_event.wait(), timeout=self.HEARTBEAT_INTERVAL_S
                )
            except asyncio.TimeoutError:
                continue

    # ─────────────────── Connections ────────────────────
    @contextmanager
    def _get_connection(self, read_only: bool = True):
        """Thread-local DuckDB connections to avoid reopen cost."""
        attr = "conn_ro" if read_only else "conn_rw"
        conn = getattr(self._thread_local, attr, None)

        # DuckDB cannot always open the same file concurrently with different
        # read_only flags (notably on Windows). Reuse or close conflicting
        # connections in this thread to keep access stable.
        if conn is None and read_only:
            conn = getattr(self._thread_local, "conn_rw", None)
        elif conn is None and not read_only:
            conn_ro = getattr(self._thread_local, "conn_ro", None)
            if conn_ro is not None:
                with contextlib.suppress(Exception):
                    conn_ro.close()
                setattr(self._thread_local, "conn_ro", None)

        if conn is None:
            self.db_path.parent.mkdir(parents=True, exist_ok=True)
            try:
                conn = duckdb.connect(str(self.db_path), read_only=read_only)
                setattr(self._thread_local, attr, conn)
            except duckdb.Error as exc:
                # Last-resort fallback for read-only callers when a read-write
                # handle is already the only viable mode for this process.
                if read_only and "different configuration" in str(exc).lower():
                    conn = getattr(self._thread_local, "conn_rw", None)
                    if conn is None:
                        conn = duckdb.connect(str(self.db_path), read_only=False)
                        setattr(self._thread_local, "conn_rw", conn)
                else:
                    raise
        yield conn

    def _ensure_schema(self, conn) -> None:
        conn.execute(
            """
            CREATE TABLE IF NOT EXISTS accounts (
                account_id TEXT PRIMARY KEY,
                account_name TEXT NOT NULL,
                website TEXT,
                normalized_domain TEXT,
                normalized_name TEXT,
                last_modified_date TEXT,
                parent_id TEXT,
                number_of_employees BIGINT,
                annual_revenue DECIMAL,
                billing_country TEXT,
                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
            );
            """
        )
        conn.execute(
            "CREATE INDEX IF NOT EXISTS idx_domain ON accounts(normalized_domain);"
        )
        conn.execute("CREATE INDEX IF NOT EXISTS idx_name ON accounts(normalized_name);")

    # ─────────────────── Metadata ────────────────────
    async def _write_meta(self, account_count: int) -> None:
        if not self.redis:
            return
        now = datetime.utcnow()
        payload = {
            "created_at": now.isoformat(),
            "expires_at": (now + timedelta(seconds=self.CACHE_TTL_S)).isoformat(),
            "account_count": account_count,
        }
        try:
            await self.redis.set(
                self._meta_key,
                json.dumps(payload),
                ex=self.CACHE_TTL_S,
            )
        except Exception:
            logger.debug("Failed to write cache metadata", exc_info=True)

    async def _read_meta(self) -> Optional[Dict[str, Any]]:
        if not self.redis:
            return None
        try:
            raw = await self.redis.get(self._meta_key)
            if raw:
                return json.loads(raw)
        except Exception:
            logger.debug("Failed to read cache metadata", exc_info=True)
        return None

    async def get_metadata(self) -> Optional[Dict[str, Any]]:
        """Public accessor for cache metadata."""
        return await self._read_meta()

    async def is_expired(self) -> bool:
        """Check TTL metadata; fallback to file age."""
        meta = await self._read_meta()
        if meta:
            try:
                expires_at = datetime.fromisoformat(meta.get("expires_at", ""))
                return expires_at < datetime.utcnow()
            except Exception:
                return True
        if not self.db_path.exists():
            return True
        if self._last_refresh:
            return (time.time() - self._last_refresh) > self.CACHE_TTL_S
        try:
            mtime = self.db_path.stat().st_mtime
            return (time.time() - mtime) > self.CACHE_TTL_S
        except OSError:
            return True

    # ─────────────────── Public API ────────────────────
    async def get_or_prefetch(
        self,
        gateway: SalesforceGateway,
        force_refresh: bool = False,
        progress_callback: Optional[callable] = None,
    ) -> bool:
        """Ensure cache is populated, refreshing via Bulk API when needed."""
        if not force_refresh and not await self.is_expired():
            return True

        lock_acquired = await self._acquire_lock(wait=True)
        if not lock_acquired:
            # Wait for other writer to finish up to LOCK_TTL_S
            await asyncio.sleep(2)
            return not await self.is_expired()

        stop_event = asyncio.Event()
        hb_task = asyncio.create_task(self._heartbeat(stop_event))
        try:
            await self._refresh_cache(gateway, progress_callback)
            return True
        finally:
            stop_event.set()
            with contextlib.suppress(Exception):
                await hb_task
            await self._release_lock()

    def query_by_domain(self, domain: str) -> Optional[Dict[str, Any]]:
        if not domain:
            return None
        normalized = self._canonicalize_domain(_normalize_domain(domain))
        if not normalized:
            return None
        with self._get_connection(read_only=True) as conn:
            self._ensure_schema(conn)
            try:
                row = conn.execute(
                    """
                    SELECT * FROM accounts
                    WHERE normalized_domain = ?
                    LIMIT 1
                    """,
                    [normalized],
                ).fetchone()
            except duckdb.Error:
                return None
        if not row:
            return None
        return self._row_to_dict(row)

    def query_by_name_prefix(self, name: str, limit: int = 50) -> List[Dict[str, Any]]:
        if not name:
            return []
        normalized = _normalize_company_name(name)
        prefix = normalized[:8] if len(normalized) >= 8 else normalized
        with self._get_connection(read_only=True) as conn:
            self._ensure_schema(conn)
            try:
                rows = conn.execute(
                    """
                    SELECT * FROM accounts
                    WHERE normalized_name LIKE ?
                    ORDER BY normalized_name
                    LIMIT ?
                    """,
                    [f"{prefix}%", limit],
                ).fetchall()
            except duckdb.Error:
                return []
        return [self._row_to_dict(r) for r in rows or []]

    # ─────────────────── Internals ────────────────────
    def _row_to_dict(self, row) -> Dict[str, Any]:
        return {
            "account_id": row[0],
            "account_name": row[1],
            "website": row[2],
            "normalized_domain": row[3],
            "normalized_name": row[4],
            "last_modified_date": row[5],
            "parent_id": row[6],
            "number_of_employees": row[7],
            "annual_revenue": row[8],
            "billing_country": row[9],
        }

    @staticmethod
    def _canonicalize_domain(value: Any) -> Optional[str]:
        text = str(value or "").strip().lower()
        if not text:
            return None
        if "://" in text:
            with contextlib.suppress(Exception):
                parsed = urlparse(text)
                if parsed.hostname:
                    text = parsed.hostname.lower()
        if text.startswith("www."):
            text = text[4:]
        return text or None

    async def _refresh_cache(
        self,
        gateway: SalesforceGateway,
        progress_callback: Optional[callable] = None,
    ) -> int:
        """Pull accounts via Bulk API and ingest into DuckDB."""
        await self._emit_progress(progress_callback, 5, "prefetching")

        records = await self._fetch_accounts(gateway)
        await self._emit_progress(progress_callback, 45, "indexing")

        account_count = await asyncio.to_thread(self._persist_records, records)
        self._last_refresh = time.time()
        await self._write_meta(account_count)

        await self._emit_progress(progress_callback, 100, "done")
        return account_count

    async def _fetch_accounts(self, gateway: SalesforceGateway) -> List[Dict[str, Any]]:
        soql = (
            "SELECT Id, Name, Website, Domain_Append__c, ParentId, "
            "NumberOfEmployees, AnnualRevenue, BillingCountry, LastModifiedDate "
            "FROM Account WHERE IsDeleted = false"
        )
        res = await gateway.bulk_query(soql, "Account")
        if isinstance(res, str):
            import pandas as pd  # Lazy import
            from io import StringIO

            df = pd.read_csv(StringIO(res))
            return df.to_dict(orient="records")
        if isinstance(res, list):
            return res
        if isinstance(res, dict) and res.get("records"):
            return res["records"]
        return []

    def _persist_records(self, records: Iterable[Dict[str, Any]]) -> int:
        import pandas as pd  # Lazy import

        normalized_rows: List[Dict[str, Any]] = []
        for rec in records or []:
            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 ""
            domain_append = rec.get("Domain_Append__c") or rec.get("domain_append__c")
            normalized_domain = self._canonicalize_domain(
                _normalize_domain(domain_append or website)
            )
            normalized_name = _normalize_company_name(name)
            last_modified = (
                rec.get("LastModifiedDate")
                or rec.get("lastmodifieddate")
                or rec.get("SystemModstamp")
            )
            parent_id = rec.get("ParentId") or rec.get("parentid")
            employees = self._safe_int(rec.get("NumberOfEmployees"))
            revenue = self._safe_decimal(rec.get("AnnualRevenue"))
            billing_country = rec.get("BillingCountry") or rec.get("billingcountry")

            normalized_rows.append(
                {
                    "account_id": account_id,
                    "account_name": name,
                    "website": website,
                    "normalized_domain": normalized_domain,
                    "normalized_name": normalized_name,
                    "last_modified_date": last_modified,
                    "parent_id": parent_id,
                    "number_of_employees": employees,
                    "annual_revenue": revenue,
                    "billing_country": billing_country,
                }
            )

        columns = [
            "account_id",
            "account_name",
            "website",
            "normalized_domain",
            "normalized_name",
            "last_modified_date",
            "parent_id",
            "number_of_employees",
            "annual_revenue",
            "billing_country",
        ]
        df = pd.DataFrame(normalized_rows, columns=columns)
        with self._get_connection(read_only=False) as conn:
            self._ensure_schema(conn)
            conn.register("accounts_stage", df)
            try:
                conn.execute("BEGIN TRANSACTION")
                conn.execute("DELETE FROM accounts")
                conn.execute(
                    """
                    INSERT INTO accounts
                    SELECT
                        account_id,
                        account_name,
                        website,
                        normalized_domain,
                        normalized_name,
                        last_modified_date,
                        parent_id,
                        number_of_employees,
                        annual_revenue,
                        billing_country,
                        CURRENT_TIMESTAMP
                    FROM accounts_stage
                    """
                )
                conn.execute("COMMIT")
            except Exception:
                try:
                    conn.execute("ROLLBACK")
                except Exception:
                    pass
                raise
            finally:
                try:
                    conn.unregister("accounts_stage")
                except Exception:
                    pass

        return len(df)

    async def _emit_progress(
        self,
        progress_callback: Optional[callable],
        percent: int,
        phase: str,
    ) -> None:
        if not progress_callback:
            return
        try:
            res = progress_callback(percent, phase)
            if asyncio.iscoroutine(res):
                await res
        except Exception:
            logger.debug("Progress callback failed", exc_info=True)

    @staticmethod
    def _safe_int(value: Any) -> Optional[int]:
        try:
            if value is None or value == "":
                return None
            return int(value)
        except Exception:
            return None

    @staticmethod
    def _safe_decimal(value: Any) -> Optional[Decimal]:
        try:
            if value is None or value == "":
                return None
            return Decimal(str(value))
        except Exception:
            try:
                return Decimal(float(value))
            except Exception:
                return None
