"""Swarm announce loop — this Navi's heartbeat to the hive registry.

The hive (see ``hive/``) is the swarm's address book: a tiny standalone service
on the main server. Each Navi announces itself (name, uuid, address, machine
info) every ANNOUNCE_INTERVAL_SEC seconds.

Design rules:
  - The hive is NEVER a blocking dependency. Startup, agent turns and all
    tools work with the hive down; the loop just keeps trying on its next tick.
  - The Navi must KNOW about reachability, though: state transitions are logged
    (up→down, down→up — not every tick), /health carries the status, and the
    ``hive_status`` context provider tells the agent when the registry is down
    so it can act (this system is supposed to fix its own network).
"""

from __future__ import annotations

import asyncio
import hmac
import os
import platform
import socket
from datetime import datetime, timezone
from pathlib import Path

import httpx
import structlog

from navi import __version__
from navi.identity import InstanceIdentity

log = structlog.get_logger()


def read_swarm_key(path: str | Path) -> str | None:
    """The swarm PSK from a key file, or None if the file is missing/empty.

    A ``.swarm-key.previous`` file next to the main key is NOT read here — the
    rotation window applies to the receiving side (hive/peer endpoints accept
    both); the announcer always sends the current key.
    """
    try:
        value = Path(path).read_text(encoding="utf-8").strip()
    except OSError:
        return None
    return value or None


def verify_swarm_key(provided: str | None) -> bool:
    """Receiving-side check of an X-Swarm-Key header.

    Accepts the current key AND the ``.previous`` one (rotation window — the
    sender may still be on the old key while it propagates). Constant-time.
    """
    from navi.config import settings

    if not provided:
        return False
    current = read_swarm_key(settings.swarm_key_file)
    previous = read_swarm_key(Path(settings.swarm_key_file).with_name(
        Path(settings.swarm_key_file).name + ".previous"
    ))
    return any(k and hmac.compare_digest(k, provided) for k in (current, previous))


def _machine_meta() -> dict:
    """Basic machine facts for the address book (the mini knowledge base)."""
    meta: dict = {
        "hostname": socket.gethostname(),
        "os": f"{platform.system()} {platform.release()}",
        "cpu_cores": os.cpu_count(),
    }
    try:  # Linux-only, cheap; absent fields are fine
        with open("/proc/meminfo") as f:
            for line in f:
                if line.startswith("MemTotal:"):
                    meta["ram_mb"] = round(int(line.split()[1]) / 1024)
                    break
    except OSError:
        pass
    return meta


def _now_iso() -> str:
    return datetime.now(timezone.utc).isoformat(timespec="seconds")


class HiveAnnouncer:
    """Periodic announce loop with reachable/unreachable state tracking."""

    def __init__(
        self,
        hive_url: str,
        key: str,
        identity: InstanceIdentity,
        port: int,
        interval: int = 120,
        client: httpx.AsyncClient | None = None,
        on_config_hint=None,
    ) -> None:
        self._hive_url = hive_url.rstrip("/")
        self._key = key
        self._identity = identity
        self._port = port
        self._interval = interval
        self._client = client  # injectable for tests; created lazily otherwise
        # Called with the hive's ``config`` hint when the announce reply says
        # this instance is behind. The announce loop is the reliable path: it
        # runs even when a push notification was missed while offline.
        self._on_config_hint = on_config_hint
        # state
        self._reachable: bool | None = None  # None = never announced yet
        self._last_success: str | None = None
        self._last_error: str | None = None
        self._down_since: str | None = None
        self._consecutive_failures = 0

    @property
    def payload(self) -> dict:
        meta = _machine_meta()
        try:
            from navi.overlay import status_summary

            # Which config this instance runs, so the hive can see which
            # machines are behind without waiting for an ack to arrive.
            meta["config"] = status_summary()
        except Exception:  # reporting must never break the announce
            log.debug("hive.config.status_summary_failed", exc_info=True)
        if self._identity.tags:
            meta["tags"] = list(self._identity.tags)
        return {
            "name": self._identity.name,
            "instance_id": self._identity.instance_id,
            "version": __version__,
            "port": self._port,
            "meta": meta,
        }

    async def announce_once(self) -> bool:
        """One announce attempt; updates and logs state transitions."""
        client = self._client or httpx.AsyncClient(timeout=5.0)
        try:
            resp = await client.post(
                f"{self._hive_url}/announce",
                json=self.payload,
                headers={"X-Swarm-Key": self._key},
            )
            resp.raise_for_status()
        except Exception as e:
            self._consecutive_failures += 1
            self._last_error = f"{type(e).__name__}: {e}"
            if self._reachable is not False:
                self._down_since = _now_iso()
                log.warning(
                    "hive.announce_failed",
                    hive=self._hive_url,
                    error=self._last_error,
                    hint="registry unreachable - other agents cannot discover this navi",
                )
            self._reachable = False
            return False
        finally:
            if self._client is None:
                await client.aclose()

        if self._reachable is not True:
            log.info("hive.announce_recovered" if self._reachable is False else "hive.announce_ok", hive=self._hive_url)
        self._reachable = True
        self._last_success = _now_iso()
        self._last_error = None
        self._down_since = None
        self._consecutive_failures = 0

        # The reply is how a machine that was offline learns it is behind: the
        # hive compares the config it serves with the one this instance just
        # reported and asks for a pull.
        try:
            data = resp.json()
        except ValueError:
            data = None
        hint = data.get("config") if isinstance(data, dict) else None
        if isinstance(hint, dict) and hint.get("fetch") and self._on_config_hint:
            log.info("hive.config.announce_hint", version=hint.get("version"))
            try:
                self._on_config_hint(hint)
            except Exception:
                log.warning("hive.config.hint_failed", exc_info=True)
        return True

    async def run(self) -> None:
        """Loop forever; the first announce fires immediately at startup."""
        log.info("hive.announce_started", hive=self._hive_url, interval=self._interval)
        while True:
            try:
                await self.announce_once()
            except asyncio.CancelledError:
                raise
            except Exception:  # never die — the loop outlives any single error
                log.exception("hive.announce_loop_error")
            await asyncio.sleep(self._interval)

    def get_status(self) -> dict:
        return {
            "configured": True,
            "url": self._hive_url,
            "reachable": self._reachable,
            "last_success": self._last_success,
            "last_error": self._last_error,
            "down_since": self._down_since,
            "consecutive_failures": self._consecutive_failures,
        }


# ── process-wide instance (lifespan creates it; provider/health read it) ──

_announcer: HiveAnnouncer | None = None


def set_announcer(announcer: HiveAnnouncer | None) -> None:
    global _announcer
    _announcer = announcer


def get_announcer() -> HiveAnnouncer | None:
    return _announcer


def _schedule_config_refresh(hint: dict) -> None:
    """Announce says we are behind → pull the config, off the loop."""
    from navi.overlay import get_state, refresh

    if not get_state().enabled:
        return
    asyncio.create_task(asyncio.to_thread(refresh, "announce"))


def build_announcer_from_settings() -> HiveAnnouncer | None:
    """Assemble the announcer from config; None (with a warning) if misconfigured."""
    from navi.config import settings
    from navi.identity import ensure_identity

    key = read_swarm_key(settings.swarm_key_file)
    if not key:
        log.warning(
            "hive.key_missing",
            key_file=settings.swarm_key_file,
            hint="HIVE_URL is set but the swarm key file is absent - announce disabled",
        )
        return None
    return HiveAnnouncer(
        hive_url=settings.hive_url,
        key=key,
        identity=ensure_identity(settings.instance_file),
        port=settings.navi_port,
        interval=settings.announce_interval_sec,
        on_config_hint=_schedule_config_refresh,
    )