"""Tests for join protocol."""
import asyncio
from unittest.mock import AsyncMock, patch, MagicMock

import pytest

from greymatter_core.cluster.join import JoinContext, JoinProtocol, JoinStep
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 db():
    d = SQLiteDatabase(":memory:")
    apply_all(d)
    return d


@pytest.fixture
def state_machine():
    sm = NodeStateMachine()
    sm.transition(NodeState.JOINING)  # Must be in JOINING to transition to SPOKE
    return sm


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


def _hub_side_effects():
    """Return a side-effect function that simulates hub responses."""
    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


class _MockClient:
    """Mock httpx.AsyncClient that routes requests via a side-effect 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


class TestJoinContext:
    def test_initial_step_is_discovery(self):
        ctx = JoinContext()
        assert ctx.current_step == JoinStep.DISCOVERY

    def test_not_complete_initially(self):
        ctx = JoinContext()
        assert not ctx.is_complete

    def test_complete_after_all_steps(self):
        ctx = JoinContext()
        ctx.completed_steps = list(JoinStep)
        assert ctx.is_complete

    def test_to_dict(self):
        ctx = JoinContext(hub_url="http://hub:8000", node_id="node-1")
        d = ctx.to_dict()
        assert d["hub_url"] == "http://hub:8000"
        assert d["node_id"] == "node-1"
        assert not d["is_complete"]


class TestJoinProtocol:
    @pytest.mark.asyncio
    async def test_full_join_succeeds(self, db, state_machine, tmp_path):
        handler = _hub_side_effects()
        mock_client = _MockClient(handler)

        with patch("greymatter_core.cluster.join.httpx.AsyncClient", return_value=mock_client), \
             patch("greymatter_core.cluster.certs.save_cert_files", return_value={}):
            protocol = JoinProtocol(db, state_machine, cert_dir=tmp_path / "certs")
            ctx = await protocol.execute(
                hub_url="http://hub:8000",
                join_token="test-token-123",
                node_id="node-1",
            )
        assert ctx.is_complete
        assert not ctx.error
        assert state_machine.state == NodeState.SPOKE

    @pytest.mark.asyncio
    async def test_join_without_token_fails_at_auth(self, db):
        """Auth step raises ValueError when join_token is empty."""
        handler = _hub_side_effects()
        mock_client = _MockClient(handler)

        with patch("greymatter_core.cluster.join.httpx.AsyncClient", return_value=mock_client):
            protocol = JoinProtocol(db)
            ctx = await protocol.execute(
                hub_url="http://hub:8000",
                join_token="",
                node_id="node-1",
            )
        assert not ctx.is_complete
        assert "auth" in ctx.error

    @pytest.mark.asyncio
    async def test_join_without_url_fails_at_discovery(self, db):
        protocol = JoinProtocol(db)
        ctx = await protocol.execute(
            hub_url="",
            join_token="token",
            node_id="node-1",
        )
        assert not ctx.is_complete
        assert "discovery" in ctx.error

    @pytest.mark.asyncio
    async def test_records_schema_version(self, db, tmp_path):
        handler = _hub_side_effects()
        mock_client = _MockClient(handler)

        with patch("greymatter_core.cluster.join.httpx.AsyncClient", return_value=mock_client), \
             patch("greymatter_core.cluster.certs.save_cert_files", return_value={}):
            protocol = JoinProtocol(db, cert_dir=tmp_path / "certs")
            ctx = await protocol.execute(
                hub_url="http://hub:8000",
                join_token="token",
                node_id="node-1",
            )
        assert ctx.local_schema_version >= 1

    @pytest.mark.asyncio
    async def test_completed_steps_tracked(self, db, tmp_path):
        handler = _hub_side_effects()
        mock_client = _MockClient(handler)

        with patch("greymatter_core.cluster.join.httpx.AsyncClient", return_value=mock_client), \
             patch("greymatter_core.cluster.certs.save_cert_files", return_value={}):
            protocol = JoinProtocol(db, cert_dir=tmp_path / "certs")
            ctx = await protocol.execute(
                hub_url="http://hub:8000",
                join_token="token",
                node_id="node-1",
            )
        assert JoinStep.DISCOVERY in ctx.completed_steps
        assert JoinStep.SCHEMA in ctx.completed_steps
        assert JoinStep.READY in ctx.completed_steps

    @pytest.mark.asyncio
    async def test_join_without_state_machine(self, db, tmp_path):
        handler = _hub_side_effects()
        mock_client = _MockClient(handler)

        with patch("greymatter_core.cluster.join.httpx.AsyncClient", return_value=mock_client), \
             patch("greymatter_core.cluster.certs.save_cert_files", return_value={}):
            protocol = JoinProtocol(db, cert_dir=tmp_path / "certs")
            ctx = await protocol.execute(
                hub_url="http://hub:8000",
                join_token="token",
                node_id="node-1",
            )
        assert ctx.is_complete

    @pytest.mark.asyncio
    async def test_hub_unreachable_fails_discovery(self, db):
        """Hub returning non-200 causes discovery to fail."""
        async def _bad_hub(method, url, **kwargs):
            return _mock_response(503, text="Service Unavailable")

        mock_client = _MockClient(_bad_hub)

        with patch("greymatter_core.cluster.join.httpx.AsyncClient", return_value=mock_client):
            protocol = JoinProtocol(db)
            ctx = await protocol.execute(
                hub_url="http://hub:8000",
                join_token="token",
                node_id="node-1",
            )
        assert "discovery" in ctx.error
        assert not ctx.is_complete

    @pytest.mark.asyncio
    async def test_auth_rejected_with_403(self, db):
        """Hub returning 403 on auth causes PermissionError."""
        async def _auth_reject(method, url, **kwargs):
            if "/api/status" in url:
                return _mock_response(200)
            if "/cluster/hello" in url:
                return _mock_response(200, {"compatible": True, "hub_schema_version": 1})
            if "/cluster/auth" in url:
                return _mock_response(403, text="Invalid join token")
            return _mock_response(404)

        mock_client = _MockClient(_auth_reject)

        with patch("greymatter_core.cluster.join.httpx.AsyncClient", return_value=mock_client):
            protocol = JoinProtocol(db)
            ctx = await protocol.execute(
                hub_url="http://hub:8000",
                join_token="bad-token",
                node_id="node-1",
            )
        assert "auth" in ctx.error
        assert JoinStep.DISCOVERY in ctx.completed_steps
        assert JoinStep.HELLO in ctx.completed_steps
        assert JoinStep.AUTH not in ctx.completed_steps

    @pytest.mark.asyncio
    async def test_schema_incompatible_fails_hello(self, db):
        """Hub reporting incompatible schema version fails at hello."""
        async def _incompat(method, url, **kwargs):
            if "/api/status" in url:
                return _mock_response(200)
            if "/cluster/hello" in url:
                return _mock_response(200, {"compatible": False, "hub_schema_version": 99})
            return _mock_response(404)

        mock_client = _MockClient(_incompat)

        with patch("greymatter_core.cluster.join.httpx.AsyncClient", return_value=mock_client):
            protocol = JoinProtocol(db)
            ctx = await protocol.execute(
                hub_url="http://hub:8000",
                join_token="token",
                node_id="node-1",
            )
        assert "hello" in ctx.error
        assert "incompatible" in ctx.error.lower()
