"""AdapterRegistry — lifecycle, startup sync, and background refresh for external CLI adapters.

Manages a set of ExternalCliSyncAdapter instances with per-adapter circuit
breakers. Provides:
  - startup(): concurrent detect+sync with global timeout
  - run_refresh_loop(): background refresh respecting TTL and breaker state
  - CircuitBreaker: dataclass tracking failures and half-open recovery
"""

from __future__ import annotations

import asyncio
import logging
import time
from dataclasses import dataclass, field
from datetime import UTC, datetime

from nexus.bricks.auth.external_sync.base import (
    ExternalCliSyncAdapter,
    SyncResult,
)
from nexus.bricks.auth.profile import (
    AuthProfile,
    AuthProfileStore,
    ProfileUsageStats,
)

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Circuit breaker (per-adapter)
# ---------------------------------------------------------------------------


@dataclass
class CircuitBreaker:
    """Per-adapter circuit breaker with failure counting and half-open recovery.

    States:
      - Closed: failure_count < failure_threshold (normal operation)
      - Open (tripped): failure_count >= threshold AND reset timeout NOT elapsed
      - Half-open: tripped AND reset timeout elapsed (allow one probe)
    """

    failure_threshold: int = 3
    reset_timeout_seconds: float = 60.0
    failure_count: int = field(default=0, init=False)
    tripped_at: float | None = field(default=None, init=False)  # time.monotonic()

    @property
    def is_tripped(self) -> bool:
        """True if open and reset timeout NOT elapsed."""
        if self.tripped_at is None:
            return False
        return (time.monotonic() - self.tripped_at) < self.reset_timeout_seconds

    @property
    def is_half_open(self) -> bool:
        """True if tripped but reset timeout elapsed (allow probe)."""
        if self.tripped_at is None:
            return False
        return (time.monotonic() - self.tripped_at) >= self.reset_timeout_seconds

    def record_success(self) -> None:
        self.failure_count = 0
        self.tripped_at = None

    def record_failure(self) -> None:
        self.failure_count += 1
        if self.failure_count >= self.failure_threshold:
            self.tripped_at = time.monotonic()


# ---------------------------------------------------------------------------
# Adapter registry
# ---------------------------------------------------------------------------


class AdapterRegistry:
    """Manages adapter lifecycle, startup sync, and background refresh.

    Each adapter gets its own CircuitBreaker. The registry handles:
      - Concurrent startup with a global timeout
      - Background refresh loop respecting TTL and breaker state
      - Upserting sync results into the AuthProfileStore
    """

    def __init__(
        self,
        adapters: list[ExternalCliSyncAdapter],
        profile_store: AuthProfileStore,
        *,
        startup_timeout: float = 3.0,
        loop_tick_seconds: float = 30.0,
    ) -> None:
        self._adapters: dict[str, ExternalCliSyncAdapter] = {a.adapter_name: a for a in adapters}
        self._store = profile_store
        self._startup_timeout = startup_timeout
        self._loop_tick_seconds = loop_tick_seconds
        self._breakers: dict[str, CircuitBreaker] = {
            a.adapter_name: CircuitBreaker(
                failure_threshold=a.failure_threshold,
                reset_timeout_seconds=a.reset_timeout_seconds,
            )
            for a in adapters
        }
        self._last_sync_times: dict[str, float] = {}

    def get_adapter(self, adapter_name: str) -> ExternalCliSyncAdapter | None:
        """Return the adapter for the given name, or None."""
        return self._adapters.get(adapter_name)

    # ------------------------------------------------------------------
    # Startup
    # ------------------------------------------------------------------

    async def startup(self) -> dict[str, SyncResult]:
        """Concurrent detect+sync for all adapters with a global timeout.

        Decision 15A: asyncio.gather + global timeout. Adapters that miss the
        timeout get SyncResult(error="timeout"). Successful results are
        upserted into the store immediately.
        """
        results: dict[str, SyncResult] = {}

        async def _detect_and_sync(name: str, adapter: ExternalCliSyncAdapter) -> SyncResult:
            detected = await adapter.detect()
            if not detected:
                return SyncResult(adapter_name=name, error="not detected")
            return await adapter.sync()

        tasks = {
            name: asyncio.create_task(_detect_and_sync(name, adapter))
            for name, adapter in self._adapters.items()
        }

        if tasks:
            # Use asyncio.wait with a timeout — it never cancels tasks itself,
            # so we can inspect each one individually afterwards.
            done, pending = await asyncio.wait(
                tasks.values(),
                timeout=self._startup_timeout,
            )

            # Cancel tasks that didn't finish in time
            for task in pending:
                task.cancel()
            # Suppress CancelledError from cancelled tasks
            if pending:
                await asyncio.gather(*pending, return_exceptions=True)

        for name, task in tasks.items():
            breaker = self._breakers[name]
            if task.done() and not task.cancelled():
                exc = task.exception()
                if exc is not None:
                    result = SyncResult(adapter_name=name, error=str(exc))
                    breaker.record_failure()
                else:
                    result = task.result()
                    # Always upsert discovered profiles, even when degraded
                    # (e.g. one file parsed OK, another failed). Partial
                    # discovery is better than losing all profiles. Only
                    # stamp last_sync_times + record breaker success when
                    # the store apply actually succeeded (R6-H3) — else
                    # the next refresh tick won't re-attempt the swap.
                    applied = True
                    if result.profiles:
                        applied = self._upsert_sync_results(result)
                    if not applied or result.error is not None:
                        breaker.record_failure()
                    else:
                        self._last_sync_times[name] = time.monotonic()
                        breaker.record_success()
            else:
                if not task.done():
                    task.cancel()
                result = SyncResult(adapter_name=name, error="timeout")
                breaker.record_failure()
            results[name] = result

        return results

    # ------------------------------------------------------------------
    # Background refresh loop
    # ------------------------------------------------------------------

    async def run_refresh_loop(self) -> None:
        """Background refresh loop. Cancel via task.cancel().

        Every loop_tick_seconds:
          1. Skip tripped (not half-open) breakers
          2. Skip adapters not past TTL
          3. Sync, upsert, update breaker
        """
        while True:
            await asyncio.sleep(self._loop_tick_seconds)
            for name, adapter in self._adapters.items():
                breaker = self._breakers[name]
                # Skip fully tripped breakers (not half-open)
                if breaker.is_tripped and not breaker.is_half_open:
                    continue
                # Check TTL
                last = self._last_sync_times.get(name)
                if last is not None:
                    elapsed = time.monotonic() - last
                    if elapsed < adapter.sync_ttl_seconds:
                        continue
                await self._sync_adapter(adapter)

    # ------------------------------------------------------------------
    # Internal helpers
    # ------------------------------------------------------------------

    async def _sync_adapter(self, adapter: ExternalCliSyncAdapter) -> SyncResult:
        """Sync one adapter, update breaker and store.

        ``_last_sync_times`` and breaker success are gated on the actual
        store apply succeeding (R6-H3). Without this, a failed atomic
        swap would silently mark the sync done and the next refresh tick
        would skip re-attempting it.
        """
        name = adapter.adapter_name
        breaker = self._breakers[name]
        try:
            result = await adapter.sync()
        except Exception as exc:
            result = SyncResult(adapter_name=name, error=str(exc))

        applied = True
        if result.profiles:
            applied = self._upsert_sync_results(result)
        if not applied or result.error is not None:
            breaker.record_failure()
        else:
            self._last_sync_times[name] = time.monotonic()
            breaker.record_success()

        return result

    def _upsert_sync_results(self, result: SyncResult) -> bool:
        """Map SyncedProfile -> AuthProfile and apply atomically.

        Returns True on a successful store apply (or true no-op), False
        when ``replace_owned_subset`` raised. Callers gate
        ``_last_sync_times`` and breaker success on this result so a
        failed swap is retried on the next refresh tick instead of
        being mistaken for a successful sync (R6-H3).

        - backend = "external-cli"
        - backend_key = sp.backend_key (already formatted by the adapter)
        - Preserve existing usage_stats if profile already in store
        - Use ProfileUsageStats() for new profiles

        **Atomic swap (Phase 3, #3740 Codex round 4):** upserts and
        tombstones go through ``store.replace_owned_subset()`` so concurrent
        readers see either the pre-sync snapshot or the post-sync snapshot,
        never a mix. The previous implementation committed each upsert and
        delete individually, opening a window where a reader could observe
        a fresh row alongside an about-to-be-tombstoned stale row.

        **Tombstoning (Codex round 3):** after writing the fresh profile
        set, delete any rows previously owned by *this adapter* that are
        not in the new set. We only delete rows whose ``backend_key``
        matches ``{adapter_name}/...`` — never touch other adapters' rows
        or rows with backend != "external-cli".
        """
        now = datetime.now(UTC)
        adapter = self._adapters.get(result.adapter_name)
        sync_ttl = int(adapter.sync_ttl_seconds) if adapter else 300

        adapter_prefix = f"{result.adapter_name}/"
        fresh_ids: set[str] = set()
        upserts: list[AuthProfile] = []

        for sp in result.profiles:
            profile_id = f"{sp.provider}/{sp.account_identifier}"
            fresh_ids.add(profile_id)
            existing = self._store.get(profile_id)
            usage_stats = existing.usage_stats if existing is not None else ProfileUsageStats()

            upserts.append(
                AuthProfile(
                    id=profile_id,
                    provider=sp.provider,
                    account_identifier=sp.account_identifier,
                    backend="external-cli",
                    backend_key=sp.backend_key,
                    last_synced_at=now,
                    sync_ttl_seconds=sync_ttl,
                    usage_stats=usage_stats,
                )
            )

        # Compute tombstones only on a clean sync — a transient parse error
        # shouldn't wipe valid history.
        deletes: list[str] = []
        if result.error is None:
            try:
                deletes = [
                    p.id
                    for p in self._store.list()
                    if p.backend == "external-cli"
                    and p.backend_key.startswith(adapter_prefix)
                    and p.id not in fresh_ids
                ]
            except Exception:
                logger.debug("Tombstone scan failed for %s", result.adapter_name, exc_info=True)
                deletes = []

        if not upserts and not deletes:
            return True

        # No fallback to per-row writes (R5-M1): the per-row path is the
        # exact torn-snapshot window R4 was meant to remove. If
        # replace_owned_subset fails (e.g. SQLite busy timeout), surface
        # the error and let the next refresh tick retry atomically — a
        # missed refresh is preferable to publishing a half-applied state.
        try:
            self._store.replace_owned_subset(upserts=upserts, deletes=deletes)
        except Exception:
            logger.warning(
                "Atomic replace failed for %s — store left at pre-sync snapshot; "
                "will retry on next refresh tick",
                result.adapter_name,
                exc_info=True,
            )
            return False
        return True
