"""Join protocol: 9-step sequence for a Solo node to join a cluster.

Steps:
  1. Discovery — find hub endpoint
  2. Hello — handshake with hub, exchange capabilities
  3. Auth — verify join token
  4. Schema — verify schema compatibility
  5. Certs — receive/install TLS certificates
  6. CRDB — start CockroachDB join (if applicable)
  7. Raft — register with Overseer Raft cluster
  8. Sync — full sync from hub
  9. Ready — transition to Spoke state
"""
from __future__ import annotations

import logging
import os
import socket
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Any

import httpx

from greymatter_core.http_client import AsyncRetryClient, RetryConfig
from greymatter_core.sync.http_sync import _upsert_row, _safe_commit

logger = logging.getLogger(__name__)

_HTTP_TIMEOUT = 30.0


class JoinStep(str, Enum):
    """Steps in the join protocol."""
    DISCOVERY = "discovery"
    HELLO = "hello"
    AUTH = "auth"
    SCHEMA = "schema"
    CERTS = "certs"
    CRDB = "crdb"
    RAFT = "raft"
    SYNC = "sync"
    READY = "ready"


@dataclass
class JoinContext:
    """Holds state accumulated during the join protocol."""
    hub_url: str = ""
    join_token: str = ""
    node_id: str = ""
    hub_schema_version: int = 0
    local_schema_version: int = 0
    ca_cert_pem: bytes = b""
    node_cert_pem: bytes = b""
    node_key_pem: bytes = b""
    sync_result: Any = None
    completed_steps: list[JoinStep] = field(default_factory=list)
    error: str = ""

    @property
    def current_step(self) -> JoinStep | None:
        if not self.completed_steps:
            return JoinStep.DISCOVERY
        all_steps = list(JoinStep)
        last_idx = all_steps.index(self.completed_steps[-1])
        if last_idx + 1 < len(all_steps):
            return all_steps[last_idx + 1]
        return None

    @property
    def is_complete(self) -> bool:
        return JoinStep.READY in self.completed_steps

    def to_dict(self) -> dict[str, Any]:
        return {
            "hub_url": self.hub_url,
            "node_id": self.node_id,
            "completed_steps": [s.value for s in self.completed_steps],
            "current_step": self.current_step.value if self.current_step else None,
            "is_complete": self.is_complete,
            "error": self.error,
        }


class JoinProtocol:
    """Orchestrates the 9-step join sequence.

    Each step is a method that can be overridden for testing.
    The protocol tracks progress and can resume from failures.
    """

    def __init__(
        self,
        local_db: Any,
        state_machine: Any = None,
        cert_dir: str | Path | None = None,
        hostname: str | None = None,
        address: str | None = None,
        port: int = 8083,
    ):
        self.local_db = local_db
        self.state_machine = state_machine
        self.cert_dir = Path(cert_dir or os.environ.get(
            "GM_CERT_DIR",
            str(Path.home() / ".greymatter" / "certs"),
        ))
        self.hostname = hostname or socket.gethostname()
        self.address = address or ""
        self.port = port
        self.context = JoinContext()

    async def execute(
        self,
        hub_url: str,
        join_token: str,
        node_id: str,
    ) -> JoinContext:
        """Run the complete join protocol.

        Returns JoinContext with results. On failure, context.error is set.
        """
        self.context.hub_url = hub_url
        self.context.join_token = join_token
        self.context.node_id = node_id

        steps = [
            (JoinStep.DISCOVERY, self._step_discovery),
            (JoinStep.HELLO, self._step_hello),
            (JoinStep.AUTH, self._step_auth),
            (JoinStep.SCHEMA, self._step_schema),
            (JoinStep.CERTS, self._step_certs),
            (JoinStep.CRDB, self._step_crdb),
            (JoinStep.RAFT, self._step_raft),
            (JoinStep.SYNC, self._step_sync),
            (JoinStep.READY, self._step_ready),
        ]

        for step, handler in steps:
            if step in self.context.completed_steps:
                continue
            try:
                logger.info("Join step: %s", step.value)
                await handler()
                self.context.completed_steps.append(step)
            except Exception as e:
                self.context.error = f"{step.value}: {e}"
                logger.error("Join failed at step %s: %s", step.value, e)
                break

        return self.context

    async def _step_discovery(self) -> None:
        """Verify hub is reachable."""
        if not self.context.hub_url:
            raise ValueError("No hub URL provided")
        async with AsyncRetryClient(timeout=_HTTP_TIMEOUT) as client:
            resp = await client.get(f"{self.context.hub_url}/api/status")
            if resp.status_code != 200:
                raise ConnectionError(
                    f"Hub unreachable at {self.context.hub_url}: {resp.status_code}"
                )

    async def _step_hello(self) -> None:
        """Exchange hello with hub — verify compatibility."""
        from greymatter_core.schema.migrations import get_current_version
        local_version = get_current_version(self.local_db)

        async with AsyncRetryClient(timeout=_HTTP_TIMEOUT) as client:
            resp = await client.post(
                f"{self.context.hub_url}/cluster/hello",
                json={
                    "node_id": self.context.node_id,
                    "hostname": self.hostname,
                    "address": self.address,
                    "port": self.port,
                    "schema_version": local_version,
                },
            )
            if resp.status_code != 200:
                raise ConnectionError(f"Hello failed: {resp.status_code} {resp.text}")
            data = resp.json()
            if not data.get("compatible", False):
                raise ValueError(
                    f"Schema incompatible: local={local_version}, "
                    f"hub={data.get('hub_schema_version')}"
                )
            self.context.hub_schema_version = data.get("hub_schema_version", 0)

    async def _step_auth(self) -> None:
        """Verify join token with hub."""
        if not self.context.join_token:
            raise ValueError("No join token provided")
        async with AsyncRetryClient(timeout=_HTTP_TIMEOUT) as client:
            resp = await client.post(
                f"{self.context.hub_url}/cluster/auth",
                json={
                    "node_id": self.context.node_id,
                    "join_token": self.context.join_token,
                },
            )
            if resp.status_code == 403:
                raise PermissionError("Invalid join token")
            if resp.status_code != 200:
                raise ConnectionError(f"Auth failed: {resp.status_code} {resp.text}")

    async def _step_schema(self) -> None:
        """Verify schema versions are compatible."""
        from greymatter_core.schema.migrations import get_current_version
        self.context.local_schema_version = get_current_version(self.local_db)
        # Schema compatibility was already checked in _step_hello;
        # this step records the local version for the context.

    async def _step_certs(self) -> None:
        """Receive and install TLS certificates from hub."""
        addresses = [self.address] if self.address else []
        async with AsyncRetryClient(timeout=_HTTP_TIMEOUT) as client:
            resp = await client.post(
                f"{self.context.hub_url}/cluster/certs",
                json={
                    "node_id": self.context.node_id,
                    "addresses": addresses,
                },
            )
            if resp.status_code != 200:
                raise ConnectionError(f"Certs failed: {resp.status_code} {resp.text}")
            data = resp.json()

        self.context.ca_cert_pem = data["ca_cert"].encode()
        self.context.node_cert_pem = data["node_cert"].encode()
        self.context.node_key_pem = data["node_key"].encode()

        # Save to disk
        from greymatter_core.cluster.certs import save_cert_files
        save_cert_files(
            self.cert_dir,
            self.context.ca_cert_pem,
            self.context.node_cert_pem,
            self.context.node_key_pem,
        )
        logger.info("Certificates installed to %s", self.cert_dir)

    async def _step_crdb(self) -> None:
        """Join CockroachDB cluster (if applicable).

        This step is a no-op for SQLite-only nodes. When CockroachDB is
        available, it would configure the local CRDB instance to join
        the cluster using the certs from step 5.
        """
        logger.info("CRDB join: skipped (SQLite-only mode)")

    async def _step_raft(self) -> None:
        """Register with Overseer Raft cluster."""
        async with AsyncRetryClient(timeout=_HTTP_TIMEOUT) as client:
            resp = await client.post(
                f"{self.context.hub_url}/cluster/register",
                json={
                    "node_id": self.context.node_id,
                    "hostname": self.hostname,
                    "address": self.address,
                    "port": self.port,
                    "schema_version": self.context.local_schema_version,
                },
            )
            if resp.status_code != 200:
                raise ConnectionError(
                    f"Registration failed: {resp.status_code} {resp.text}"
                )
            logger.info("Registered with cluster as %s", self.context.node_id)

    async def _step_sync(self) -> None:
        """Run full sync from hub via HTTP sync API.

        Pulls all changes from the hub's /api/sync/pull endpoint using
        cursor-based pagination. Applies changes to the local SQLite database.
        """
        hub_url = self.context.hub_url
        node_id = self.context.node_id
        since_log_id = 0
        total_applied = 0
        page = 0

        async with AsyncRetryClient(timeout=_HTTP_TIMEOUT) as client:
            while True:
                page += 1
                resp = await client.post(
                    f"{hub_url}/api/sync/pull",
                    json={
                        "since_log_id": since_log_id,
                        "node_id": node_id,
                        "limit": 1000,
                    },
                )
                if resp.status_code != 200:
                    raise ConnectionError(
                        f"Sync pull failed: {resp.status_code} {resp.text}"
                    )

                data = resp.json()
                changes = data.get("changes", [])
                has_more = data.get("has_more", False)
                since_log_id = data.get("next_log_id", 0)

                # Apply changes to local database
                for change in changes:
                    table = change.get("table", "")
                    row_id = change.get("row_id", "")
                    operation = change.get("operation", "")
                    row_data = change.get("data")

                    if not table or not row_id:
                        continue

                    try:
                        if operation == "delete":
                            self.local_db.execute(
                                f"DELETE FROM {table} WHERE id = ?", (row_id,)
                            )
                        elif row_data:
                            _upsert_row(self.local_db, table, row_data)
                        total_applied += 1
                    except Exception as e:
                        logger.warning("Sync apply error %s/%s: %s", table, row_id, e)

                _safe_commit(self.local_db)

                if not has_more or not changes:
                    break

        self.context.sync_result = {
            "pages": page,
            "applied": total_applied,
            "server_version": data.get("server_version", 0),
        }
        logger.info(
            "Full sync complete: %d changes applied over %d pages (server v%d)",
            total_applied, page, data.get("server_version", 0),
        )

    async def _step_ready(self) -> None:
        """Transition to Spoke state."""
        if self.state_machine:
            from greymatter_core.cluster.state import NodeState
            self.state_machine.transition(NodeState.SPOKE)
        logger.info("Node %s is now SPOKE", self.context.node_id)
