"""Client-side configuration sync engine.

Pulls skill/agent/workflow configs from the server on startup and polls
for changes every 5 minutes. Configs are cached in a local SQLite DB so
the client can operate offline.

Usage::

    from agentkit.client.sync import ConfigSync

    sync = ConfigSync(
        server_url="http://localhost:18001",
        token_provider=lambda: jwt_token,  # or None for dev mode
        cache_db_path="~/.agentkit/config_cache.db",
    )
    await sync.start()          # Initial full pull
    await sync.poll_loop()      # Background polling (or run in a task)
    configs = sync.get_configs()  # Returns cached configs
    await sync.stop()

Design (KTD3):
    - Polling, not WebSocket push (config changes are weekly/monthly)
    - Full pull on version change (not incremental diff)
    - SQLite cache for offline operation
    - 5-minute poll interval (configurable)
"""

from __future__ import annotations

import asyncio
import json
import logging
import os
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from typing import Callable, TypeAlias

import httpx

logger = logging.getLogger(__name__)

# 缓存的配置项 — 技能/工作流配置为 JSON 反序列化后的字典，值为标量或嵌套结构。
# 服务器返回的 skills/workflows 列表元素是 dict（model_dump/to_dict 输出），
# 其中可能包含 list/dict 等容器，因此使用 object 作为值类型。
SkillConfigDict: TypeAlias = dict[str, object]
WorkflowConfigDict: TypeAlias = dict[str, object]
SyncedConfigPayload: TypeAlias = dict[str, object]


# ── Defaults ──────────────────────────────────────────────────────────

DEFAULT_POLL_INTERVAL = 300  # 5 minutes
DEFAULT_TIMEOUT = 30.0
DEFAULT_CACHE_DB_PATH = Path(
    os.environ.get("AGENTKIT_CONFIG_CACHE", str(Path.home() / ".agentkit" / "config_cache.db"))
)


# ── SQLite cache schema ───────────────────────────────────────────────

_CACHE_SCHEMA = """
CREATE TABLE IF NOT EXISTS config_cache (
    key TEXT PRIMARY KEY,
    value TEXT NOT NULL,
    updated_at TEXT NOT NULL
);
"""


# ── ConfigSync ────────────────────────────────────────────────────────


class ConfigSync:
    """Client-side configuration sync engine.

    Pulls configs from the server, caches them locally, and polls for
    changes on a configurable interval.

    Attributes:
        server_url: Base URL of the AgentKit server (e.g. ``http://localhost:18001``).
        token_provider: Callable that returns the current JWT access token
            (or ``None`` if not authenticated). Called on each request.
        cache_db_path: Path to the local SQLite cache file.
        poll_interval: Seconds between version polls (default 300 = 5 min).
        timeout: HTTP request timeout in seconds.
    """

    def __init__(
        self,
        *,
        server_url: str,
        token_provider: Callable[[], str | None] | None = None,
        cache_db_path: str | Path | None = None,
        poll_interval: int = DEFAULT_POLL_INTERVAL,
        timeout: float = DEFAULT_TIMEOUT,
    ) -> None:
        self.server_url = server_url.rstrip("/")
        self.token_provider = token_provider
        self.cache_db_path = Path(cache_db_path) if cache_db_path else DEFAULT_CACHE_DB_PATH
        self.poll_interval = poll_interval
        self.timeout = timeout

        self._client: httpx.AsyncClient | None = None
        self._poll_task: asyncio.Task | None = None
        self._stopped = False

        # In-memory cache (mirrors the SQLite cache for fast access)
        self._version: str | None = None
        self._skills: list[SkillConfigDict] = []
        self._workflows: list[WorkflowConfigDict] = []
        self._last_synced_at: str | None = None

    # ── Lifecycle ─────────────────────────────────────────────────

    async def start(self) -> bool:
        """Perform an initial full sync.

        Tries to pull configs from the server. On failure, loads the
        local cache. Returns ``True`` if the server was reachable.
        """
        self._init_cache_db()
        self._client = httpx.AsyncClient(timeout=self.timeout)

        # Try server sync first
        success = await self._pull_all()
        if not success:
            logger.info("Server unreachable, loading cached configs")
            self._load_from_cache()

        return success

    async def stop(self) -> None:
        """Stop the polling loop and close the HTTP client."""
        self._stopped = True
        if self._poll_task and not self._poll_task.done():
            self._poll_task.cancel()
            try:
                await self._poll_task
            except asyncio.CancelledError:
                pass
        if self._client:
            await self._client.aclose()
            self._client = None

    async def poll_loop(self) -> None:
        """Background polling loop.

        Every ``poll_interval`` seconds, checks the server's config
        version. If it differs from the cached version, re-pulls all
        configs. On network errors, keeps the existing cache and retries
        on the next interval.
        """
        while not self._stopped:
            try:
                await asyncio.sleep(self.poll_interval)
                if self._stopped:
                    break
                await self._check_and_sync()
            except asyncio.CancelledError:
                break
            except Exception as e:
                logger.warning(f"Config poll error: {e}")

    def start_polling(self) -> asyncio.Task:
        """Start the polling loop as a background task."""
        self._poll_task = asyncio.create_task(self.poll_loop())
        return self._poll_task

    # ── Sync operations ───────────────────────────────────────────

    async def _check_and_sync(self) -> bool:
        """Check the server version; re-pull if changed.

        Returns ``True`` if configs were re-pulled, ``False`` if the
        version was unchanged or the server was unreachable.
        """
        if not self._client:
            return False

        try:
            resp = await self._client.get(
                f"{self.server_url}/api/v1/config/version",
                headers=self._build_headers(),
            )
            if resp.status_code != 200:
                logger.warning(f"Version check failed: HTTP {resp.status_code}")
                return False

            server_version = resp.json().get("version")
            if server_version == self._version:
                logger.debug("Config version unchanged, skipping sync")
                return False

            logger.info(f"Config version changed: {self._version} → {server_version}")
            return await self._pull_all()

        except (httpx.HTTPError, OSError) as e:
            logger.warning(f"Version check network error: {e}")
            return False

    async def _pull_all(self) -> bool:
        """Pull all configs from the server and update the cache.

        Returns ``True`` on success, ``False`` on failure.
        """
        if not self._client:
            return False

        try:
            resp = await self._client.get(
                f"{self.server_url}/api/v1/config/all",
                headers=self._build_headers(),
            )
            if resp.status_code != 200:
                logger.warning(f"Config pull failed: HTTP {resp.status_code}")
                return False

            data = resp.json()
            self._version = data.get("version")
            self._skills = data.get("skills", [])
            self._workflows = data.get("workflows", [])
            self._last_synced_at = data.get("synced_at")

            self._save_to_cache(data)
            logger.info(
                f"Synced {len(self._skills)} skills, {len(self._workflows)} workflows "
                f"(version={self._version})"
            )
            return True

        except (httpx.HTTPError, OSError, json.JSONDecodeError) as e:
            logger.warning(f"Config pull error: {e}")
            return False

    # ── Cache access ──────────────────────────────────────────────

    def get_version(self) -> str | None:
        """Return the current cached config version hash."""
        return self._version

    def get_skills(self) -> list[SkillConfigDict]:
        """Return the cached skill configs."""
        return list(self._skills)

    def get_workflows(self) -> list[WorkflowConfigDict]:
        """Return the cached workflow configs."""
        return list(self._workflows)

    def get_all(self) -> SyncedConfigPayload:
        """Return all cached configs as a single dict."""
        return {
            "version": self._version,
            "skills": list(self._skills),
            "workflows": list(self._workflows),
            "synced_at": self._last_synced_at,
        }

    def get_skill(self, name: str) -> SkillConfigDict | None:
        """Return a single skill config by name, or ``None``."""
        for skill in self._skills:
            if skill.get("name") == name:
                return skill
        return None

    def get_workflow(self, workflow_id: str) -> WorkflowConfigDict | None:
        """Return a single workflow config by ID, or ``None``."""
        for wf in self._workflows:
            if wf.get("workflow_id") == workflow_id:
                return wf
        return None

    # ── Internal helpers ──────────────────────────────────────────

    def _build_headers(self) -> dict[str, str]:
        """Build HTTP headers with JWT if available."""
        headers: dict[str, str] = {"Accept": "application/json"}
        if self.token_provider:
            token = self.token_provider()
            if token:
                headers["Authorization"] = f"Bearer {token}"
        return headers

    def _init_cache_db(self) -> None:
        """Initialize the SQLite cache database."""
        self.cache_db_path.parent.mkdir(parents=True, exist_ok=True)
        with sqlite3.connect(str(self.cache_db_path)) as conn:
            conn.executescript(_CACHE_SCHEMA)
            conn.commit()

    def _save_to_cache(self, data: SyncedConfigPayload) -> None:
        """Save the synced configs to the local SQLite cache."""
        now = datetime.now(timezone.utc).isoformat()
        with sqlite3.connect(str(self.cache_db_path)) as conn:
            conn.executescript(_CACHE_SCHEMA)
            entries = [
                ("version", json.dumps(data.get("version")), now),
                ("skills", json.dumps(data.get("skills", [])), now),
                ("workflows", json.dumps(data.get("workflows", [])), now),
                ("synced_at", json.dumps(data.get("synced_at")), now),
            ]
            conn.executemany(
                "INSERT OR REPLACE INTO config_cache (key, value, updated_at) VALUES (?, ?, ?)",
                entries,
            )
            conn.commit()

    def _load_from_cache(self) -> bool:
        """Load configs from the local SQLite cache.

        Returns ``True`` if the cache had data, ``False`` otherwise.
        """
        try:
            with sqlite3.connect(str(self.cache_db_path)) as conn:
                conn.row_factory = sqlite3.Row
                cursor = conn.execute(
                    "SELECT key, value FROM config_cache WHERE key IN (?, ?, ?, ?)",
                    ("version", "skills", "workflows", "synced_at"),
                )
                rows = {row["key"]: row["value"] for row in cursor.fetchall()}

            if not rows:
                return False

            self._version = json.loads(rows.get("version", "null"))
            self._skills = json.loads(rows.get("skills", "[]"))
            self._workflows = json.loads(rows.get("workflows", "[]"))
            self._last_synced_at = json.loads(rows.get("synced_at", "null"))

            logger.info(
                f"Loaded {len(self._skills)} skills, {len(self._workflows)} workflows "
                f"from cache (version={self._version})"
            )
            return True

        except Exception as e:
            logger.warning(f"Failed to load config cache: {e}")
            return False
