"""Tests for U7: configuration sync engine.

Covers:
  - Server-side config sync API (version, all, skills, agents, workflows)
  - Client-side ConfigSync (start, poll, cache, offline)
"""

from __future__ import annotations

import asyncio
import json
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

import pytest
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient

from agentkit.client.sync import ConfigSync
from agentkit.server.routes import config_sync


# ── Test fixtures ─────────────────────────────────────────────────────


class _FakeSkillConfig:
    """Minimal skill config mock with to_dict()."""

    def __init__(self, name: str, version: str = "1.0.0"):
        self.name = name
        self.version = version

    def to_dict(self) -> dict[str, Any]:
        return {"name": self.name, "version": self.version, "description": f"Skill {self.name}"}


class _FakeSkill:
    def __init__(self, name: str, version: str = "1.0.0"):
        self.config = _FakeSkillConfig(name, version)


class _FakeSkillRegistry:
    """Minimal SkillRegistry mock."""

    def __init__(self, skills: dict[str, _FakeSkill] | None = None):
        self._skills = skills or {}

    def list_skills(self) -> list[str]:
        return sorted(self._skills.keys())

    def get(self, name: str) -> _FakeSkill | None:
        return self._skills.get(name)


class _FakeWorkflow:
    """Minimal workflow definition mock with model_dump()."""

    def __init__(self, workflow_id: str, name: str, version: int = 1):
        self.workflow_id = workflow_id
        self.name = name
        self.version = version
        self.stages: list = []
        self.triggers: list = []
        self.created_at = "2026-01-01T00:00:00Z"
        self.updated_at = "2026-01-01T00:00:00Z"

    def model_dump(self) -> dict[str, Any]:
        return {
            "workflow_id": self.workflow_id,
            "name": self.name,
            "version": self.version,
            "stages": self.stages,
            "triggers": self.triggers,
            "created_at": self.created_at,
            "updated_at": self.updated_at,
        }


class _FakeWorkflowStore:
    def __init__(self, workflows: list[_FakeWorkflow] | None = None):
        self._workflows = workflows or []

    def list_workflows(self) -> list[_FakeWorkflow]:
        return list(self._workflows)


@pytest.fixture
def config_app_with_data() -> FastAPI:
    """App with mock skill registry + workflow store."""
    app = FastAPI()
    app.state.skill_registry = _FakeSkillRegistry({
        "react_agent": _FakeSkill("react_agent", "1.0.0"),
        "code_reviewer": _FakeSkill("code_reviewer", "2.1.0"),
    })
    app.state.workflow_store = _FakeWorkflowStore([
        _FakeWorkflow("wf-1", "CI Pipeline", 1),
        _FakeWorkflow("wf-2", "Deploy", 3),
    ])
    app.include_router(config_sync.router, prefix="/api/v1")

    # Dev-admin middleware
    @app.middleware("http")
    async def _set_dev_admin_user(request, call_next):
        request.state.current_user = {
            "user_id": "dev-admin",
            "username": "dev-admin",
            "role": "admin",
            "dev_mode": True,
        }
        return await call_next(request)

    return app


@pytest.fixture
def config_app_empty() -> FastAPI:
    """App with no skills or workflows."""
    app = FastAPI()
    app.state.skill_registry = _FakeSkillRegistry()
    app.state.workflow_store = _FakeWorkflowStore()
    app.include_router(config_sync.router, prefix="/api/v1")

    @app.middleware("http")
    async def _set_dev_admin_user(request, call_next):
        request.state.current_user = {
            "user_id": "dev-admin",
            "username": "dev-admin",
            "role": "admin",
            "dev_mode": True,
        }
        return await call_next(request)

    return app


@pytest.fixture
async def config_client(config_app_with_data: FastAPI):
    transport = ASGITransport(app=config_app_with_data)
    async with AsyncClient(transport=transport, base_url="http://test") as client:
        yield client


# ── Server API tests ──────────────────────────────────────────────────


class TestConfigVersionEndpoint:
    """Test GET /api/v1/config/version."""

    @pytest.mark.asyncio
    async def test_version_returns_hash(self, config_client: AsyncClient):
        resp = await config_client.get("/api/v1/config/version")
        assert resp.status_code == 200
        data = resp.json()
        assert "version" in data
        assert len(data["version"]) == 64  # SHA-256 hex
        assert data["skill_count"] == 2
        assert data["workflow_count"] == 2
        assert "computed_at" in data

    @pytest.mark.asyncio
    async def test_version_is_stable(self, config_client: AsyncClient):
        """Same configs → same version hash."""
        resp1 = await config_client.get("/api/v1/config/version")
        resp2 = await config_client.get("/api/v1/config/version")
        assert resp1.json()["version"] == resp2.json()["version"]

    @pytest.mark.asyncio
    async def test_version_empty_app(self, config_app_empty: FastAPI):
        transport = ASGITransport(app=config_app_empty)
        async with AsyncClient(transport=transport, base_url="http://test") as client:
            resp = await client.get("/api/v1/config/version")
            assert resp.status_code == 200
            data = resp.json()
            assert data["skill_count"] == 0
            assert data["workflow_count"] == 0
            assert len(data["version"]) == 64


class TestConfigAllEndpoint:
    """Test GET /api/v1/config/all."""

    @pytest.mark.asyncio
    async def test_all_returns_skills_and_workflows(self, config_client: AsyncClient):
        resp = await config_client.get("/api/v1/config/all")
        assert resp.status_code == 200
        data = resp.json()

        assert "version" in data
        assert len(data["version"]) == 64
        assert "synced_at" in data

        skills = data["skills"]
        assert len(skills) == 2
        skill_names = {s["name"] for s in skills}
        assert skill_names == {"react_agent", "code_reviewer"}

        workflows = data["workflows"]
        assert len(workflows) == 2
        wf_ids = {w["workflow_id"] for w in workflows}
        assert wf_ids == {"wf-1", "wf-2"}

    @pytest.mark.asyncio
    async def test_all_version_matches_version_endpoint(self, config_client: AsyncClient):
        all_resp = await config_client.get("/api/v1/config/all")
        ver_resp = await config_client.get("/api/v1/config/version")
        assert all_resp.json()["version"] == ver_resp.json()["version"]


class TestConfigSkillsEndpoint:
    """Test GET /api/v1/config/skills."""

    @pytest.mark.asyncio
    async def test_skills_returns_list(self, config_client: AsyncClient):
        resp = await config_client.get("/api/v1/config/skills")
        assert resp.status_code == 200
        data = resp.json()
        assert len(data["skills"]) == 2
        assert data["count"] == 2
        assert "synced_at" in data


class TestConfigAgentsEndpoint:
    """Test GET /api/v1/config/agents."""

    @pytest.mark.asyncio
    async def test_agents_returns_list(self, config_client: AsyncClient):
        resp = await config_client.get("/api/v1/config/agents")
        assert resp.status_code == 200
        data = resp.json()
        assert len(data["agents"]) == 2
        assert data["count"] == 2


class TestConfigWorkflowsEndpoint:
    """Test GET /api/v1/config/workflows."""

    @pytest.mark.asyncio
    async def test_workflows_returns_list(self, config_client: AsyncClient):
        resp = await config_client.get("/api/v1/config/workflows")
        assert resp.status_code == 200
        data = resp.json()
        assert len(data["workflows"]) == 2
        assert data["count"] == 2


# ── Client-side ConfigSync tests ──────────────────────────────────────


@pytest.fixture
async def sync_server(config_app_with_data: FastAPI):
    """Start the config app as an ASGI server on a random port."""
    import uvicorn

    config = uvicorn.Config(
        config_app_with_data,
        host="127.0.0.1",
        port=0,  # Random port
        log_level="warning",
    )
    server = uvicorn.Server(config)

    task = asyncio.create_task(server.serve())
    # Wait for server to start
    while not server.started:
        await asyncio.sleep(0.01)

    # Get the actual port
    sockets = list(server.servers[0].sockets)
    port = sockets[0].getsockname()[1]

    yield f"http://127.0.0.1:{port}"

    server.should_exit = True
    await task


class TestConfigSync:
    """Test the client-side ConfigSync engine."""

    @pytest.mark.asyncio
    async def test_start_pulls_configs(self, sync_server: str, tmp_path: Path):
        """ConfigSync.start() pulls configs from the server."""
        sync = ConfigSync(
            server_url=sync_server,
            token_provider=None,
            cache_db_path=tmp_path / "cache.db",
        )
        try:
            success = await sync.start()
            assert success is True
            assert sync.get_version() is not None
            assert len(sync.get_version()) == 64
            skills = sync.get_skills()
            assert len(skills) == 2
            workflows = sync.get_workflows()
            assert len(workflows) == 2
        finally:
            await sync.stop()

    @pytest.mark.asyncio
    async def test_get_skill_by_name(self, sync_server: str, tmp_path: Path):
        sync = ConfigSync(
            server_url=sync_server,
            cache_db_path=tmp_path / "cache.db",
        )
        try:
            await sync.start()
            skill = sync.get_skill("react_agent")
            assert skill is not None
            assert skill["name"] == "react_agent"

            missing = sync.get_skill("nonexistent")
            assert missing is None
        finally:
            await sync.stop()

    @pytest.mark.asyncio
    async def test_get_workflow_by_id(self, sync_server: str, tmp_path: Path):
        sync = ConfigSync(
            server_url=sync_server,
            cache_db_path=tmp_path / "cache.db",
        )
        try:
            await sync.start()
            wf = sync.get_workflow("wf-1")
            assert wf is not None
            assert wf["name"] == "CI Pipeline"

            missing = sync.get_workflow("nonexistent")
            assert missing is None
        finally:
            await sync.stop()

    @pytest.mark.asyncio
    async def test_cache_persists_across_instances(self, sync_server: str, tmp_path: Path):
        """Configs cached by one instance are loadable by another."""
        cache_path = tmp_path / "cache.db"

        # First instance: pull and cache
        sync1 = ConfigSync(server_url=sync_server, cache_db_path=cache_path)
        await sync1.start()
        version1 = sync1.get_version()
        assert version1 is not None
        await sync1.stop()

        # Second instance: no server, load from cache
        sync2 = ConfigSync(
            server_url="http://127.0.0.1:1",  # Unreachable port
            cache_db_path=cache_path,
        )
        try:
            success = await sync2.start()
            assert success is False  # Server unreachable
            # But cache should be loaded
            assert sync2.get_version() == version1
            assert len(sync2.get_skills()) == 2
            assert len(sync2.get_workflows()) == 2
        finally:
            await sync2.stop()

    @pytest.mark.asyncio
    async def test_poll_detects_version_change(
        self, config_app_with_data: FastAPI, tmp_path: Path
    ):
        """Polling detects when the server's config version changes."""
        import uvicorn

        config = uvicorn.Config(
            config_app_with_data,
            host="127.0.0.1",
            port=0,
            log_level="warning",
        )
        server = uvicorn.Server(config)
        task = asyncio.create_task(server.serve())
        while not server.started:
            await asyncio.sleep(0.01)
        port = list(server.servers[0].sockets)[0].getsockname()[1]
        server_url = f"http://127.0.0.1:{port}"

        try:
            sync = ConfigSync(
                server_url=server_url,
                cache_db_path=tmp_path / "cache.db",
                poll_interval=1,  # 1 second for fast testing
            )
            await sync.start()
            sync.start_polling()  # Start background polling
            version_before = sync.get_version()

            # Change the server's config
            config_app_with_data.state.skill_registry._skills["new_skill"] = _FakeSkill("new_skill")

            # Wait for the poll to detect the change
            await asyncio.sleep(2.5)

            version_after = sync.get_version()
            assert version_after != version_before
            assert len(sync.get_skills()) == 3  # Original 2 + new_skill

            await sync.stop()
        finally:
            server.should_exit = True
            await task

    @pytest.mark.asyncio
    async def test_offline_uses_cache(self, tmp_path: Path):
        """When the server is unreachable, the client uses cached configs."""
        cache_path = tmp_path / "cache.db"

        # Pre-populate the cache directly
        cache_path.parent.mkdir(parents=True, exist_ok=True)
        with sqlite3.connect(str(cache_path)) as conn:
            conn.executescript(
                "CREATE TABLE IF NOT EXISTS config_cache "
                "(key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT NOT NULL);"
            )
            now = datetime.now(timezone.utc).isoformat()
            conn.executemany(
                "INSERT OR REPLACE INTO config_cache (key, value, updated_at) VALUES (?, ?, ?)",
                [
                    ("version", json.dumps("cached-version-hash"), now),
                    ("skills", json.dumps([{"name": "cached_skill", "version": "1.0.0"}]), now),
                    ("workflows", json.dumps([{"workflow_id": "cached-wf", "name": "Cached"}]), now),
                    ("synced_at", json.dumps("2026-01-01T00:00:00Z"), now),
                ],
            )
            conn.commit()

        # Create a sync instance pointing to an unreachable server
        sync = ConfigSync(
            server_url="http://127.0.0.1:1",  # Unreachable
            cache_db_path=cache_path,
        )
        try:
            success = await sync.start()
            assert success is False  # Server unreachable
            # But cache loaded
            assert sync.get_version() == "cached-version-hash"
            skills = sync.get_skills()
            assert len(skills) == 1
            assert skills[0]["name"] == "cached_skill"
        finally:
            await sync.stop()

    @pytest.mark.asyncio
    async def test_offline_no_cache_returns_empty(self, tmp_path: Path):
        """When the server is unreachable and no cache exists, returns empty."""
        sync = ConfigSync(
            server_url="http://127.0.0.1:1",  # Unreachable
            cache_db_path=tmp_path / "nonexistent.db",
        )
        try:
            success = await sync.start()
            assert success is False
            assert sync.get_version() is None
            assert sync.get_skills() == []
            assert sync.get_workflows() == []
        finally:
            await sync.stop()

    @pytest.mark.asyncio
    async def test_token_provider_attaches_jwt(self, sync_server: str, tmp_path: Path):
        """The token_provider callable is used to attach JWT to requests."""
        token_holder = {"token": "test-jwt-token"}

        sync = ConfigSync(
            server_url=sync_server,
            token_provider=lambda: token_holder["token"],
            cache_db_path=tmp_path / "cache.db",
        )
        try:
            # Should work even with a fake token (dev mode doesn't check)
            success = await sync.start()
            assert success is True
        finally:
            await sync.stop()

    @pytest.mark.asyncio
    async def test_get_all_returns_combined_dict(self, sync_server: str, tmp_path: Path):
        sync = ConfigSync(
            server_url=sync_server,
            cache_db_path=tmp_path / "cache.db",
        )
        try:
            await sync.start()
            all_configs = sync.get_all()
            assert "version" in all_configs
            assert "skills" in all_configs
            assert "workflows" in all_configs
            assert "synced_at" in all_configs
            assert len(all_configs["skills"]) == 2
            assert len(all_configs["workflows"]) == 2
        finally:
            await sync.stop()
