"""Shared sync engine for the universal Fetch Data Toggle system.

The engine is the single entrypoint used by:

* the admin router (``routers/module_sync.py``) — for manual "Fetch
  Clients Now" / "Fetch All Now" actions;
* the cron tick (``run_cron_tick``) — invoked from a background loop or
  external scheduler; respects the three toggle flags;
* the diagnostics endpoint — runs a lightweight check per sync type.

Every call is recorded in the ``module_sync_logs`` table and the
settings row is updated with the latest status/timestamp/counters. The
raw response is masked (any long-ish hex/jwt/token-looking string is
replaced with ``"***"``) before persistence so secrets never reach the
log viewer.

Adapters live in :mod:`services.module_sync.adapters`; each one exposes
``fetch_clients``, ``fetch_accounts``, and ``fetch_domains`` coroutines
that return a normalised :class:`SyncAttempt` result.
"""
from __future__ import annotations

import json
import logging
import re
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional

from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession

from models.module_sync import ModuleSyncLog, ModuleSyncSettings

logger = logging.getLogger(__name__)

# Sync type constants — kept in sync with the frontend.
SYNC_CLIENTS = "clients"
SYNC_ACCOUNTS = "accounts"
SYNC_DOMAINS = "domains"
SYNC_TYPES = (SYNC_CLIENTS, SYNC_ACCOUNTS, SYNC_DOMAINS)

# Status strings.
STATUS_OK = "ok"
STATUS_FAILED = "failed"
STATUS_SKIPPED = "skipped"
STATUS_NEVER = "never"

# Masking — redact anything that looks like a secret token in raw logs.
# This catches: hex strings ≥16 chars, bearer tokens, UR "m123-xxxx" keys
# with a long trailing alnum, and common API-key-looking alnum ≥24 chars.
_SECRET_PATTERNS = [
    re.compile(r"(?i)(bearer\s+)([a-z0-9._\-]{12,})"),
    re.compile(r"(?i)(api[_-]?key[\"':=\s]+)([a-z0-9._\-]{8,})"),
    re.compile(r"(?i)(token[\"':=\s]+)([a-z0-9._\-]{8,})"),
    re.compile(r"(?i)(password[\"':=\s]+)([^\s,\"}']{4,})"),
    re.compile(r"\b([a-f0-9]{32,})\b"),
    re.compile(r"\b(ur-[A-Za-z0-9]{10,})\b"),
]


def _mask(raw: Any) -> str:
    """Best-effort masking of tokens/keys inside a raw response string."""
    try:
        if raw is None:
            return ""
        if not isinstance(raw, str):
            raw = json.dumps(raw, default=str)[:20000]
        masked = raw
        for pat in _SECRET_PATTERNS:
            if pat.groups == 2:
                masked = pat.sub(lambda m: m.group(1) + "***", masked)
            else:
                masked = pat.sub("***", masked)
        # Keep raw logs bounded so the DB row stays readable.
        if len(masked) > 8000:
            masked = masked[:8000] + "\n…[truncated]"
        return masked
    except Exception:  # noqa: BLE001
        return "***"


@dataclass
class SyncAttempt:
    """Normalised per-sync-type result returned by adapters."""

    status: str = STATUS_OK
    items_fetched: int = 0
    error_message: Optional[str] = None
    raw_response: Optional[str] = None
    # Only used by ``fetch_domains`` adapters to report linked/unlinked.
    extra: Dict[str, int] = field(default_factory=dict)


async def get_or_create_settings(
    db: AsyncSession, module_name: str
) -> ModuleSyncSettings:
    """Return the settings row for a module, creating it if missing.

    All toggles default to ``False`` so brand-new modules start opted-out
    of automatic syncing (safer default — admins must opt in).
    """
    row = (
        await db.execute(
            select(ModuleSyncSettings).where(ModuleSyncSettings.module_name == module_name)
        )
    ).scalar_one_or_none()
    if row is None:
        row = ModuleSyncSettings(module_name=module_name)
        db.add(row)
        await db.flush()
    return row


def _apply_attempt(
    settings: ModuleSyncSettings,
    sync_type: str,
    attempt: SyncAttempt,
    now: datetime,
) -> None:
    """Mutate the settings row to reflect the latest sync attempt."""
    if sync_type == SYNC_CLIENTS:
        settings.clients_sync_status = attempt.status
        if attempt.status != STATUS_SKIPPED:
            settings.last_clients_sync = now
        if attempt.status == STATUS_OK:
            settings.clients_count = attempt.items_fetched
    elif sync_type == SYNC_ACCOUNTS:
        settings.accounts_sync_status = attempt.status
        if attempt.status != STATUS_SKIPPED:
            settings.last_accounts_sync = now
        if attempt.status == STATUS_OK:
            settings.accounts_count = attempt.items_fetched
    elif sync_type == SYNC_DOMAINS:
        settings.domains_sync_status = attempt.status
        if attempt.status != STATUS_SKIPPED:
            settings.last_domains_sync = now
        if attempt.status == STATUS_OK:
            settings.domains_count = attempt.items_fetched
            settings.domains_linked_count = int(attempt.extra.get("linked", 0))
            settings.domains_unlinked_count = int(attempt.extra.get("unlinked", 0))

    if attempt.status == STATUS_FAILED and attempt.error_message:
        settings.last_error = attempt.error_message[:4000]


async def _record_log(
    db: AsyncSession,
    *,
    module_name: str,
    sync_type: str,
    attempt: SyncAttempt,
    triggered_by: str,
    actor_email: Optional[str],
    started_at: datetime,
    finished_at: datetime,
    duration_ms: int,
) -> ModuleSyncLog:
    log = ModuleSyncLog(
        module_name=module_name,
        sync_type=sync_type,
        status=attempt.status,
        items_fetched=attempt.items_fetched,
        error_message=(attempt.error_message or None),
        raw_response=_mask(attempt.raw_response) or None,
        triggered_by=triggered_by,
        actor_email=actor_email,
        duration_ms=duration_ms,
        started_at=started_at,
        finished_at=finished_at,
    )
    db.add(log)
    await db.flush()
    return log


async def run_sync(
    db: AsyncSession,
    *,
    module_name: str,
    sync_type: str,
    triggered_by: str = "manual",
    actor_email: Optional[str] = None,
    force: bool = False,
) -> SyncAttempt:
    """Run one sync for ``module_name`` / ``sync_type``.

    * If the module's toggle for this sync_type is OFF and ``force`` is
      False, the call is logged as ``skipped`` and no adapter runs.
    * Otherwise the adapter is invoked, the settings row is updated, and
      a log entry is appended. Adapter exceptions are caught and
      converted into ``status="failed"``.
    """
    from services.module_sync.adapters import registry

    if sync_type not in SYNC_TYPES:
        raise ValueError(f"Unknown sync_type: {sync_type}")

    adapter = registry.get_adapter(module_name)
    settings = await get_or_create_settings(db, module_name)

    enabled = {
        SYNC_CLIENTS: settings.fetch_clients_enabled,
        SYNC_ACCOUNTS: settings.fetch_accounts_enabled,
        SYNC_DOMAINS: settings.fetch_domains_enabled,
    }[sync_type]

    started_at = datetime.now(tz=timezone.utc)
    t0 = time.monotonic()

    if not enabled and not force:
        attempt = SyncAttempt(
            status=STATUS_SKIPPED,
            items_fetched=0,
            error_message=f"{sync_type} toggle disabled",
            raw_response=None,
        )
    else:
        try:
            handler = {
                SYNC_CLIENTS: adapter.fetch_clients,
                SYNC_ACCOUNTS: adapter.fetch_accounts,
                SYNC_DOMAINS: adapter.fetch_domains,
            }[sync_type]
            attempt = await handler(db)
            if attempt is None:  # defensive — adapters must return SyncAttempt
                attempt = SyncAttempt(status=STATUS_OK)
        except Exception as exc:  # noqa: BLE001
            logger.exception("module_sync %s/%s failed", module_name, sync_type)
            attempt = SyncAttempt(
                status=STATUS_FAILED,
                items_fetched=0,
                error_message=str(exc)[:500] or exc.__class__.__name__,
            )

    finished_at = datetime.now(tz=timezone.utc)
    duration_ms = int((time.monotonic() - t0) * 1000)

    _apply_attempt(settings, sync_type, attempt, finished_at)

    await _record_log(
        db,
        module_name=module_name,
        sync_type=sync_type,
        attempt=attempt,
        triggered_by=triggered_by,
        actor_email=actor_email,
        started_at=started_at,
        finished_at=finished_at,
        duration_ms=duration_ms,
    )
    await db.commit()
    return attempt


async def run_all(
    db: AsyncSession,
    *,
    module_name: str,
    triggered_by: str = "manual",
    actor_email: Optional[str] = None,
    force: bool = False,
) -> List[Dict[str, Any]]:
    """Run clients -> accounts -> domains in order for one module."""
    out: List[Dict[str, Any]] = []
    for sync_type in SYNC_TYPES:
        started = datetime.now(tz=timezone.utc)
        t0 = time.monotonic()
        attempt = await run_sync(
            db,
            module_name=module_name,
            sync_type=sync_type,
            triggered_by=triggered_by,
            actor_email=actor_email,
            force=force,
        )
        finished = datetime.now(tz=timezone.utc)
        out.append(
            {
                "module_name": module_name,
                "sync_type": sync_type,
                "status": attempt.status,
                "items_fetched": attempt.items_fetched,
                "error_message": attempt.error_message,
                "started_at": started,
                "finished_at": finished,
                "duration_ms": int((time.monotonic() - t0) * 1000),
            }
        )
    return out


async def run_cron_tick(db: AsyncSession) -> List[Dict[str, Any]]:
    """Called by the scheduler; runs every module, honouring toggles.

    For each registered module, iterates the three sync types. Because
    ``run_sync`` itself short-circuits OFF toggles into a ``skipped`` log
    entry, we do not need to re-check here — every tick yields an audit
    trail of what ran vs what was skipped.
    """
    from services.module_sync.adapters import registry

    results: List[Dict[str, Any]] = []
    for module_name in registry.list_modules():
        for sync_type in SYNC_TYPES:
            attempt = await run_sync(
                db,
                module_name=module_name,
                sync_type=sync_type,
                triggered_by="cron",
                actor_email=None,
                force=False,
            )
            results.append(
                {
                    "module_name": module_name,
                    "sync_type": sync_type,
                    "status": attempt.status,
                    "items_fetched": attempt.items_fetched,
                }
            )
    return results


async def purge_old_logs(db: AsyncSession, keep: int = 500) -> int:
    """Keep only the latest ``keep`` logs per module. Returns rows deleted."""
    deleted = 0
    from services.module_sync.adapters import registry

    for module_name in registry.list_modules():
        ids = [
            row.id
            for row in (
                await db.execute(
                    select(ModuleSyncLog.id)
                    .where(ModuleSyncLog.module_name == module_name)
                    .order_by(ModuleSyncLog.id.desc())
                    .offset(keep)
                )
            ).scalars()
        ]
        if ids:
            await db.execute(delete(ModuleSyncLog).where(ModuleSyncLog.id.in_(ids)))
            deleted += len(ids)
    if deleted:
        await db.commit()
    return deleted