"""
Core protocol types for Nemoria.

This module defines:
- `Action`: the set of request/response verbs exchanged over the wire
- `Frame`: a single protocol message (request or response) with optional
           correlation (`reply_to`)
- `Connection`: lightweight metadata for a connected peer (id/host/port),
                plus its bound asyncio streams (reader/writer)
"""

from __future__ import annotations

import asyncio
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Optional, Hashable, Dict, Self
from nemoria.cryptography import uniqID
from nemoria.route import Route


# JSON-safe type alias
JSON = Dict[Hashable, Any]


class Action(Enum):
    """
    Enumeration of all supported frame actions.

    Each member represents a basic operation in the client-server protocol,
    covering key-value CRUD and a few control verbs.
    """

    GET = "GET"
    ALL = "ALL"
    SET = "SET"
    DELETE = "DELETE"
    DROP = "DROP"
    PURGE = "PURGE"
    SAVE = "SAVE"
    PING = "PING"
    HANDSHAKE = "HANDSHAKE"
    SHUTDOWN = "SHUTDOWN"

    @classmethod
    def from_str(cls, name: str) -> Action:
        """
        Convert a string into an `Action`.

        The string is matched against the enum **name**, e.g. "GET", "PING".
        (Our wire format uses these names, so this is sufficient.)

        Args:
            name: Action name (e.g., "GET", "PING").

        Returns:
            The corresponding `Action` enum.

        Raises:
            ValueError: If the name is not a valid action.
        """
        try:
            return cls[name]
        except KeyError:
            raise ValueError("invalid frame action")


@dataclass(frozen=True)
class Frame:
    """
    Protocol frame exchanged between client and server.

    A frame encapsulates a single request or response. Correlation between a
    response and the originating request is achieved via the `reply_to` field,
    which may itself contain a serialized `Frame` (typically only its `id`
    matters to correlate).

    Fields:
        id:       Unique identifier (auto-generated by default).
        action:   The verb for this frame (None for pure ACKs if desired).
        route:    Optional path for operations that act on a key/namespace.
        value:    Optional payload (must be JSON-serializable by your transport).
        reply_to: Optional original request frame for correlation.
    """

    id: str = field(default_factory=uniqID)
    action: Optional[Action] = None
    route: Optional[Route] = None
    value: Optional[Any] = None
    reply_to: Optional[Frame] = None

    def __eq__(self, other: object) -> bool:
        """
        Compare frames by their unique IDs.
        """
        if not isinstance(other, Frame):
            return NotImplemented
        return self.id == other.id

    def __hash__(self) -> int:
        """
        Hash a frame by its ID (enables use in sets and as dict keys).
        """
        return hash(self.id)

    def serialize(self) -> JSON:
        """
        Convert the frame into a JSON-safe dict for transmission.

        Notes:
            - `action` is serialized as its string name (e.g., "GET").
            - `route` is serialized via `Route.encode()`.
            - `reply_to` is serialized **recursively**. In practice, sending a
              minimal reply stub (only `id`, `action`) is sufficient, but this
              method preserves the full structure.

        Returns:
            A JSON-serializable dictionary representing the frame.
        """
        return {
            "id": self.id,
            "action": None if self.action is None else self.action.value,
            "route": None if self.route is None else self.route.encode(),
            "value": self.value,
            "reply_to": None if self.reply_to is None else self.reply_to.serialize(),
        }

    @classmethod
    def deserialize(cls, obj: JSON) -> Self:
        """
        Reconstruct a `Frame` from its serialized dict form.

        Args:
            obj: JSON-safe dict produced by `serialize()`.

        Returns:
            A `Frame` instance reconstructed from the payload.

        Raises:
            KeyError: If required keys are missing from `obj`.
            ValueError: If `action` or `route` fail to decode.
            TypeError: If `obj` is not a dict-like structure.
        """
        return cls(
            id=obj["id"],
            action=None if obj["action"] is None else Action.from_str(obj["action"]),
            route=None if obj["route"] is None else Route.decode(obj["route"]),
            value=obj["value"],
            reply_to=(
                None if obj["reply_to"] is None else Frame.deserialize(obj["reply_to"])
            ),
        )


@dataclass(frozen=True)
class Connection:
    """
    Represents an active peer connection.

    Contains basic peer metadata (`id`, `host`, `port`) and the bound asyncio
    stream endpoints used by the transport layer.

    Warning:
        `reader` and `writer` are live stream objects and are **not**
        serializable; `serialize()` only includes metadata.
    """

    id: str
    host: str
    port: int
    reader: asyncio.StreamReader
    writer: asyncio.StreamWriter

    def __repr__(self) -> str:
        """
        Compact, log-friendly representation.
        """
        return f"Connection(Port: {self.port})"

    def serialize(self) -> JSON:
        """
        Convert the connection metadata into a JSON-safe dict.

        Returns:
            Dict with `id`, `host`, and `port`. The stream endpoints are excluded.
        """
        return {"id": self.id, "host": self.host, "port": self.port}

    @classmethod
    def deserialize(
        cls, obj: JSON, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
    ) -> Self:
        """
        Reconstruct a `Connection` from serialized metadata plus streams.

        Args:
            obj: Dict with keys "id", "host", "port".
            reader: Bound asyncio `StreamReader`.
            writer: Bound asyncio `StreamWriter`.

        Returns:
            A `Connection` instance.

        Raises:
            TypeError: If `obj` is not a dict.
            KeyError: If required keys are missing from `obj`.
        """
        if not isinstance(obj, dict):
            raise TypeError("Connection JSON must be a dict.")
        return cls(obj["id"], obj["host"], obj["port"], reader, writer)
