"""Peer discovery for the ALP network.

Two discovery modes:
A) LOCAL NETWORK — UDP broadcast on port 51966 (0xCAFE). Broadcasts agent
   card every 10 seconds and listens for other agents. Peers expire after
   30 seconds of no heartbeat.
B) MANUAL CONNECT — Fetch a peer's agent card via HTTP and add to registry
   as a permanent (non-expiring) peer.
"""

from __future__ import annotations

import json
import socket
import threading
import time
from typing import Any

from alive.network.conversation_log import log_event

DISCOVERY_PORT = 51966  # 0xCAFE
BROADCAST_INTERVAL = 10  # seconds
PEER_TIMEOUT = 30  # seconds


class PeerRegistry:
    """Thread-safe registry of discovered peers."""

    def __init__(self) -> None:
        self.peers: dict[str, dict[str, Any]] = {}
        self._lock = threading.Lock()

    def register(self, ip: str, announcement: dict[str, Any]) -> bool:
        """Register or update a peer from an announcement.

        Returns:
            True if this is a newly discovered peer, False if update.
        """
        agent_id: str = announcement["agent"]["agent_id"]
        is_new = False
        with self._lock:
            if agent_id not in self.peers:
                is_new = True
            self.peers[agent_id] = {
                "ip": ip,
                "port": announcement["port"],
                "agent": announcement["agent"],
                "last_seen": time.time(),
                "permanent": False,
            }
        return is_new

    def add_manual(self, ip: str, port: int, agent_card: dict[str, Any]) -> None:
        """Add a manually connected peer (never expires)."""
        agent_id = agent_card["agent_id"]
        with self._lock:
            self.peers[agent_id] = {
                "ip": ip,
                "port": port,
                "agent": agent_card,
                "last_seen": time.time(),
                "permanent": True,
            }

    def get_active_peers(self, timeout: int = PEER_TIMEOUT) -> list[dict[str, Any]]:
        """Return list of peers that are still alive or permanent."""
        now = time.time()
        with self._lock:
            return [
                p
                for p in self.peers.values()
                if p.get("permanent") or (now - p["last_seen"] < timeout)
            ]

    def find_peer(self, identifier: str) -> dict[str, Any] | None:
        """Find a peer by @alias or agent_id prefix."""
        alias_query = identifier.lstrip("@").lower()
        with self._lock:
            for p in self.peers.values():
                agent = p.get("agent", {})
                if agent.get("alias", "").lower() == alias_query:
                    return p
                if agent.get("agent_id", "").startswith(identifier):
                    return p
            return None

    def cleanup(self, timeout: int = PEER_TIMEOUT) -> None:
        """Remove expired non-permanent peers."""
        now = time.time()
        with self._lock:
            expired = [
                k
                for k, v in self.peers.items()
                if not v.get("permanent") and now - v["last_seen"] >= timeout
            ]
            for k in expired:
                del self.peers[k]


# Module-level singleton
_peer_registry: PeerRegistry | None = None


def get_peer_registry() -> PeerRegistry:
    """Get or create the global peer registry."""
    global _peer_registry
    if _peer_registry is None:
        _peer_registry = PeerRegistry()
    return _peer_registry


def _broadcaster(
    agent_card_fn: Any,
    port: int,
    own_agent_id: str,
    stop_event: threading.Event,
) -> None:
    """Broadcast thread: announce this agent every BROADCAST_INTERVAL seconds."""
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    sock.settimeout(1.0)

    while not stop_event.is_set():
        try:
            card = agent_card_fn()
            message = json.dumps(
                {"type": "announce", "agent": card, "port": port}
            ).encode("utf-8")
            sock.sendto(message, ("<broadcast>", DISCOVERY_PORT))
        except OSError:
            pass
        # Sleep in small increments so we can respond to stop_event
        for _ in range(BROADCAST_INTERVAL * 10):
            if stop_event.is_set():
                break
            time.sleep(0.1)
    sock.close()


def _listener(
    own_agent_id: str,
    registry: PeerRegistry,
    stop_event: threading.Event,
) -> None:
    """Listener thread: receive announcements from other agents."""
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    if hasattr(socket, "SO_REUSEPORT"):
        try:
            sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
        except OSError:
            pass
    sock.settimeout(1.0)

    try:
        sock.bind(("", DISCOVERY_PORT))
    except OSError:
        # Port in use — still try to listen
        try:
            sock.bind(("0.0.0.0", DISCOVERY_PORT))
        except OSError:
            stop_event.set()
            return

    while not stop_event.is_set():
        try:
            data, (ip, _) = sock.recvfrom(65535)
            announcement = json.loads(data.decode("utf-8"))
            if announcement.get("type") != "announce":
                continue
            agent = announcement.get("agent", {})
            peer_id = agent.get("agent_id", "")
            if peer_id == own_agent_id:
                continue  # Ignore own broadcasts

            is_new = registry.register(ip, announcement)
            if is_new:
                alias = agent.get("alias") or "anon"
                skill_count = agent.get("skill_count", 0)
                log_event(
                    "DISCOVERED",
                    f"@{alias} ({skill_count} skills) at {ip}:{announcement['port']}",
                )
        except socket.timeout:
            continue
        except (json.JSONDecodeError, OSError, KeyError):
            continue

        # Periodic cleanup
        registry.cleanup()

    sock.close()


def start_discovery(
    agent_card_fn: Any,
    http_port: int,
    own_agent_id: str,
) -> tuple[threading.Event, PeerRegistry]:
    """Start broadcast and listener daemon threads.

    Args:
        agent_card_fn: Callable that returns the current agent card dict.
        http_port: The HTTP server port to advertise.
        own_agent_id: This agent's ID (to filter own broadcasts).

    Returns:
        Tuple of (stop_event, peer_registry). Set the event to shut down.
    """
    stop_event = threading.Event()
    registry = get_peer_registry()

    broadcast_thread = threading.Thread(
        target=_broadcaster,
        args=(agent_card_fn, http_port, own_agent_id, stop_event),
        daemon=True,
        name="alp-broadcaster",
    )
    listener_thread = threading.Thread(
        target=_listener,
        args=(own_agent_id, registry, stop_event),
        daemon=True,
        name="alp-listener",
    )

    broadcast_thread.start()
    listener_thread.start()
    log_event("ANNOUNCE", f"Broadcasting to network on port {http_port}")

    return stop_event, registry


def connect_to_peer(address: str) -> dict[str, Any] | None:
    """Manually connect to a peer by fetching their agent card.

    Args:
        address: IP:port string (e.g. "192.168.1.50:8585").

    Returns:
        The peer's agent card, or None on failure.
    """
    import httpx

    if ":" not in address:
        address = f"{address}:8484"

    ip, port_str = address.rsplit(":", 1)
    port = int(port_str)
    url = f"http://{ip}:{port}/alp/card"

    try:
        resp = httpx.get(url, timeout=30)
        resp.raise_for_status()
        card = resp.json()

        registry = get_peer_registry()
        registry.add_manual(ip, port, card)

        alias = card.get("alias") or "anon"
        log_event("CONNECTED", f"@{alias} at {address} (manual)")
        return card
    except Exception:
        return None
