"""Integration test: Solo → Join → Sync → Leave → Solo lifecycle.

Tests the complete cluster lifecycle using mocked HTTP responses,
verifying state transitions, data sync, and clean departure.
"""
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from greymatter_core.cluster.join import JoinProtocol, JoinStep
from greymatter_core.cluster.leave import LeaveProtocol
from greymatter_core.cluster.state import NodeState, NodeStateMachine
from greymatter_core.db import SQLiteDatabase
from greymatter_core.schema.migrations import apply_all


@pytest.fixture
def local_db():
    """Local node database (the spoke)."""
    d = SQLiteDatabase(":memory:")
    apply_all(d)
    return d


@pytest.fixture
def hub_db():
    """Hub coordinator database."""
    d = SQLiteDatabase(":memory:")
    apply_all(d)
    return d


@pytest.fixture
def state_machine():
    return NodeStateMachine()


def _mock_response(status_code=200, json_data=None, text=""):
    resp = MagicMock()
    resp.status_code = status_code
    resp.text = text
    resp.json.return_value = json_data or {}
    return resp


class _MockClient:
    """Mock httpx.AsyncClient that routes via a handler function."""

    def __init__(self, handler):
        self._handler = handler

    async def get(self, url, **kwargs):
        return await self._handler("GET", url, **kwargs)

    async def post(self, url, **kwargs):
        return await self._handler("POST", url, **kwargs)

    async def __aenter__(self):
        return self

    async def __aexit__(self, *args):
        pass


def _hub_join_handler():
    """Simulate hub responses for the join protocol."""
    async def _request(method, url, **kwargs):
        if method == "GET" and "/api/status" in url:
            return _mock_response(200, {"status": "ok"})
        if method == "POST" and "/cluster/hello" in url:
            return _mock_response(200, {
                "hub_id": "coordinator",
                "hub_schema_version": 1,
                "compatible": True,
                "capabilities": ["sync", "certs", "raft"],
            })
        if method == "POST" and "/cluster/auth" in url:
            return _mock_response(200, {"authenticated": True})
        if method == "POST" and "/cluster/certs" in url:
            return _mock_response(200, {
                "ca_cert": "-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----",
                "node_cert": "-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----",
                "node_key": "-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----",
            })
        if method == "POST" and "/cluster/register" in url:
            return _mock_response(200, {"registered": True})
        if method == "POST" and "/api/sync/pull" in url:
            return _mock_response(200, {
                "changes": [],
                "server_version": 1,
                "has_more": False,
                "server_time": "2026-01-01T00:00:00Z",
                "tables_synced": 0,
                "next_log_id": 0,
            })
        return _mock_response(404, text="Not found")
    return _request


def _hub_leave_handler():
    """Simulate hub responses for the leave protocol."""
    async def _request(method, url, **kwargs):
        if method == "POST" and "/cluster/deregister" in url:
            return _mock_response(200, {"deregistered": True})
        return _mock_response(404)
    return _request


class TestClusterLifecycle:
    """Full Solo → Join → Sync → Leave → Solo integration test."""

    def test_initial_state_is_solo(self, state_machine):
        assert state_machine.state == NodeState.SOLO
        assert not state_machine.is_clustered

    @pytest.mark.asyncio
    async def test_full_lifecycle(self, local_db, hub_db, state_machine):
        """End-to-end: SOLO → join → SPOKE → sync data → leave → SOLO."""

        # -- Phase 1: Verify SOLO state --
        assert state_machine.state == NodeState.SOLO

        # Seed some data on the hub to sync down
        hub_db.execute(
            "INSERT INTO observations (id, session_id, trigger, category, title, content) "
            "VALUES (?, ?, ?, ?, ?, ?)",
            ("obs-hub-1", "session-1", "manual", "insight", "Hub Insight", "Data from hub"),
        )

        # Verify local has no observations yet
        local_obs = local_db.execute("SELECT COUNT(*) as cnt FROM observations")
        assert local_obs[0]["cnt"] == 0

        # -- Phase 2: Join cluster --
        # Transition to JOINING before executing protocol (mirrors CLI behavior)
        state_machine.transition(NodeState.JOINING)

        join_client = _MockClient(_hub_join_handler())

        with patch("greymatter_core.cluster.join.httpx.AsyncClient", return_value=join_client), \
             patch("greymatter_core.cluster.certs.save_cert_files", return_value={}):
            join = JoinProtocol(
                local_db=local_db,
                state_machine=state_machine,
            )
            result = await join.execute(
                hub_url="http://localhost:8000",
                join_token="test-token-123",
                node_id="test-node-1",
            )

        assert result.is_complete, f"Join failed at {result.current_step}: {result.error}"
        assert state_machine.state == NodeState.SPOKE
        assert state_machine.is_clustered

        # -- Phase 3: Simulate sync (data arrives from hub) --
        # In production, the sync scheduler pulls delta from hub.
        # Here we simulate the result: data appears in local DB.
        local_db.execute(
            "INSERT INTO observations (id, session_id, trigger, category, title, content) "
            "VALUES (?, ?, ?, ?, ?, ?)",
            ("obs-hub-1", "session-1", "manual", "insight", "Hub Insight", "Data from hub"),
        )

        # Also create local data that should sync back to hub
        local_db.execute(
            "INSERT INTO observations (id, session_id, trigger, category, title, content) "
            "VALUES (?, ?, ?, ?, ?, ?)",
            ("obs-local-1", "session-local", "manual", "pattern", "Local Pattern", "Discovered locally"),
        )

        local_obs = local_db.execute("SELECT COUNT(*) as cnt FROM observations")
        assert local_obs[0]["cnt"] == 2

        # -- Phase 4: Leave cluster --
        # Transition to LEAVING before executing protocol (mirrors CLI behavior)
        state_machine.transition(NodeState.LEAVING)

        leave_client = _MockClient(_hub_leave_handler())

        with patch("greymatter_core.cluster.leave.httpx.AsyncClient", return_value=leave_client):
            leave = LeaveProtocol(
                local_db=local_db,
                hub_url="http://localhost:8000",
                node_id="test-node-1",
                state_machine=state_machine,
            )
            leave_result = await leave.execute()

        assert leave_result.success, f"Leave failed: {leave_result.error}"
        assert state_machine.state == NodeState.SOLO
        assert not state_machine.is_clustered

        # -- Phase 5: Verify data preserved after leaving --
        # Local data should still be intact after leaving the cluster
        final_obs = local_db.execute("SELECT COUNT(*) as cnt FROM observations")
        assert final_obs[0]["cnt"] == 2, "Data should be preserved after leaving cluster"

        # Verify both hub-synced and local data survived
        hub_data = local_db.execute_one(
            "SELECT title FROM observations WHERE id = ?", ("obs-hub-1",)
        )
        assert hub_data is not None
        assert hub_data["title"] == "Hub Insight"

        local_data = local_db.execute_one(
            "SELECT title FROM observations WHERE id = ?", ("obs-local-1",)
        )
        assert local_data is not None
        assert local_data["title"] == "Local Pattern"

    def test_state_transitions_are_valid(self, state_machine):
        """Verify the state machine enforces valid transitions."""
        # SOLO → JOINING: valid
        assert state_machine.can_transition(NodeState.JOINING)
        state_machine.transition(NodeState.JOINING)
        assert state_machine.state == NodeState.JOINING

        # JOINING → SPOKE: valid
        assert state_machine.can_transition(NodeState.SPOKE)
        state_machine.transition(NodeState.SPOKE)
        assert state_machine.state == NodeState.SPOKE
        assert state_machine.is_clustered

        # SPOKE → LEAVING: valid
        assert state_machine.can_transition(NodeState.LEAVING)
        state_machine.transition(NodeState.LEAVING)
        assert state_machine.state == NodeState.LEAVING

        # LEAVING → SOLO: valid
        assert state_machine.can_transition(NodeState.SOLO)
        state_machine.transition(NodeState.SOLO)
        assert state_machine.state == NodeState.SOLO
        assert not state_machine.is_clustered

    def test_invalid_transitions_rejected(self, state_machine):
        """SOLO cannot go directly to SPOKE or LEAVING."""
        assert not state_machine.can_transition(NodeState.SPOKE)
        assert not state_machine.can_transition(NodeState.LEAVING)

    def test_error_state_recovery(self, state_machine):
        """Error state can recover to SOLO or retry JOINING."""
        state_machine.transition(NodeState.JOINING)
        state_machine.transition(NodeState.ERROR, error_info="network timeout")

        assert state_machine.state == NodeState.ERROR
        assert state_machine.error_info == "network timeout"

        # Can recover to SOLO
        assert state_machine.can_transition(NodeState.SOLO)
        state_machine.transition(NodeState.SOLO)
        assert state_machine.state == NodeState.SOLO

    def test_history_tracks_transitions(self, state_machine):
        """State machine records transition history."""
        state_machine.transition(NodeState.JOINING)
        state_machine.transition(NodeState.SPOKE)
        state_machine.transition(NodeState.LEAVING)
        state_machine.transition(NodeState.SOLO)

        history = state_machine.history
        assert len(history) >= 4
