"""
Server-contract HTTP client for the Togen Windows Display Agent (DVI-1230).

Implements the frozen contract from the DVI-1227 plan exactly as specified in
the P2 ticket:
  - POST /signage/agents/register  (enroll token -> permanent agent_token)
  - POST /signage/checkin          (~30s heartbeat)
  - GET  /display/config           (~15s poll; the agent reads only the
    `window` block to drive winlayer.LayerEngine -- everything else in the
    response (dashboard/interface/splash/layout) is rendered by the WebView2
    browser surface itself, never parsed here (plan D3: the agent stays thin).

Token storage mirrors vision_agent.py's convention (HKLM machine-wide, HKCU
fallback, Windows Credential Manager via keyring when available) -- reusing
an established Ops convention rather than inventing a new one. winreg is
imported lazily (never at module scope) so this module -- and its unit tests
-- import cleanly on non-Windows dev machines too.
"""

from __future__ import annotations

import logging
import socket
import sys
import uuid

import requests

IS_WINDOWS = sys.platform == "win32"

AGENT_VERSION = "1.0.0"
_REG_PATH = r"Software\Togen\Display"
_REG_MACHINE_ID = "MachineId"
_REG_TOKEN_KEY = "AgentToken"
_KEYRING_SERVICE = "TogenDisplayAgent"
_KEYRING_USERNAME = "agent_token"

log = logging.getLogger("display_agent.server_client")

try:
    import keyring
    _HAS_KEYRING = True
except ImportError:
    _HAS_KEYRING = False


def _winreg():
    import winreg
    return winreg


def _reg_read(hive, path, key):
    winreg = _winreg()
    try:
        h = winreg.OpenKey(hive, path)
        val, _ = winreg.QueryValueEx(h, key)
        winreg.CloseKey(h)
        return val
    except FileNotFoundError:
        return None


def _reg_write(hive, path, key, value):
    winreg = _winreg()
    h = winreg.CreateKeyEx(hive, path, access=winreg.KEY_WRITE)
    winreg.SetValueEx(h, key, 0, winreg.REG_SZ, value)
    winreg.CloseKey(h)


def get_machine_id() -> str:
    """Stable machine UUID, generated + persisted on first call. Canonical
    location HKLM\\Software\\Togen\\Display\\MachineId, HKCU fallback."""
    if not IS_WINDOWS:
        return str(uuid.uuid4())
    winreg = _winreg()
    for hive in (winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER):
        val = _reg_read(hive, _REG_PATH, _REG_MACHINE_ID)
        if val:
            return val
    machine_id = str(uuid.uuid4())
    for hive in (winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER):
        try:
            _reg_write(hive, _REG_PATH, _REG_MACHINE_ID, machine_id)
            return machine_id
        except PermissionError:
            continue
    return machine_id


def store_agent_token(token: str):
    if _HAS_KEYRING:
        try:
            keyring.set_password(_KEYRING_SERVICE, _KEYRING_USERNAME, token)
        except Exception:
            log.debug("keyring store failed", exc_info=True)
    if not IS_WINDOWS:
        return
    for hive in (_winreg().HKEY_LOCAL_MACHINE, _winreg().HKEY_CURRENT_USER):
        try:
            _reg_write(hive, _REG_PATH, _REG_TOKEN_KEY, token)
            return
        except PermissionError:
            continue


def load_agent_token() -> "str | None":
    if _HAS_KEYRING:
        try:
            val = keyring.get_password(_KEYRING_SERVICE, _KEYRING_USERNAME)
            if val:
                return val
        except Exception:
            log.debug("keyring load failed", exc_info=True)
    if not IS_WINDOWS:
        return None
    winreg = _winreg()
    for hive in (winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER):
        val = _reg_read(hive, _REG_PATH, _REG_TOKEN_KEY)
        if val:
            return val
    return None


def clear_agent_token():
    """Used when the server reports the token as permanently invalid
    (distinct from a transient network failure) so a stale token is never
    silently retried forever without an operator noticing."""
    if _HAS_KEYRING:
        try:
            keyring.delete_password(_KEYRING_SERVICE, _KEYRING_USERNAME)
        except Exception:
            pass
    if not IS_WINDOWS:
        return
    winreg = _winreg()
    for hive in (winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER):
        try:
            h = winreg.OpenKey(hive, _REG_PATH, 0, winreg.KEY_WRITE)
            winreg.DeleteValue(h, _REG_TOKEN_KEY)
            winreg.CloseKey(h)
        except (FileNotFoundError, OSError):
            pass


def _primary_local_ip() -> "str | None":
    """Best-effort primary outbound IPv4 (UDP route lookup; no packet sent)."""
    s = None
    try:
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        s.connect(("8.8.8.8", 80))
        return s.getsockname()[0]
    except Exception:
        return None
    finally:
        if s is not None:
            try:
                s.close()
            except Exception:
                pass


def _public_ip() -> str:
    """Mirrors the Linux signage agent's api.ipify.org lookup; fails soft to
    "" (never blocks or fails the caller) -- the only outbound third-party
    call this agent makes."""
    try:
        r = requests.get("https://api.ipify.org", timeout=3)
        if r.ok:
            return r.text.strip()
    except Exception:
        pass
    return ""


def screen_resolution() -> str:
    """"WxH" of the primary monitor, e.g. "1920x1080". Blank if undetectable
    (headless/disconnected session, or running off-Windows)."""
    if not IS_WINDOWS:
        return ""
    try:
        import ctypes
        user32 = ctypes.windll.user32
        w = user32.GetSystemMetrics(0)   # SM_CXSCREEN
        h = user32.GetSystemMetrics(1)   # SM_CYSCREEN
        if w and h:
            return f"{w}x{h}"
    except Exception:
        log.debug("screen_resolution failed", exc_info=True)
    return ""


class DisplayAgentClient:
    """Thin HTTP client for the frozen /signage + /display server contract."""

    def __init__(self, base_url: str, session: "requests.Session | None" = None):
        self.base_url = base_url.rstrip("/")
        self.session = session or requests.Session()

    def register(self, enrollment_token: str, hostname: str) -> dict:
        """POST /signage/agents/register. Returns
        {"agent_token","agent_id","status", ...}. Raises
        requests.HTTPError on a non-2xx response -- callers decide whether
        that's fatal (see display_agent.py's enroll() wrapper)."""
        url = f"{self.base_url}/signage/agents/register"
        payload = {
            "enrollment_token": enrollment_token,
            "hostname": hostname,
            # platform/ips/version/resolution: sent opportunistically for the
            # P1 server-side sibling implementing this same frozen contract in
            # parallel -- harmless extra fields if not yet recognized server-side.
            "platform": "windows",
            "local_ip": _primary_local_ip() or "",
            "public_ip": _public_ip(),
            "agent_version": AGENT_VERSION,
            "screen": screen_resolution(),
        }
        resp = self.session.post(url, json=payload, timeout=(10, 30))
        resp.raise_for_status()
        data = resp.json()
        if "agent_token" not in data:
            raise ValueError(f"register response missing agent_token: {data}")
        return data

    def checkin(self, agent_token: str, effective_layer: "str | None" = None) -> dict:
        """POST /signage/checkin (Bearer). Returns the parsed JSON body on
        success. Raises requests.HTTPError(401) on a revoked/invalid token --
        callers treat that as the ticket's "close window, keep retrying
        registration state" signal."""
        url = f"{self.base_url}/signage/checkin"
        payload = {
            "hostname": socket.gethostname(),
            "local_ip": _primary_local_ip() or "",
            "public_ip": _public_ip(),
            "agent_version": AGENT_VERSION,
            "screen": screen_resolution(),
        }
        if effective_layer:
            # Not yet in the documented /signage/checkin field allowlist --
            # the ticket asks to "report the effective layer in checkin" for
            # the wallpaper-fallback case. Extra JSON fields are silently
            # ignored by today's route, so this is forward-safe; flagged to
            # the P1 sibling via a comment on the parent issue (DVI-1227).
            payload["window_layer_effective"] = effective_layer
        resp = self.session.post(
            url, json=payload,
            headers={"Authorization": f"Bearer {agent_token}"},
            timeout=(10, 30))
        resp.raise_for_status()
        return resp.json()

    def get_display_config(self, agent_token: str) -> dict:
        """GET /display/config (Bearer -- the docstring on the server route
        explicitly supports this for "a headless agent"). Raises
        requests.HTTPError(401) when the token is invalid/revoked."""
        url = f"{self.base_url}/display/config"
        resp = self.session.get(
            url, headers={"Authorization": f"Bearer {agent_token}"}, timeout=(10, 20))
        resp.raise_for_status()
        return resp.json()

    def display_url(self, agent_token: str) -> str:
        """URL the WebView2 window renders -- all dashboard/interface/splash/
        layout/Present logic lives server- and browser-side behind this."""
        return f"{self.base_url}/display?token={agent_token}"


def sanitize_window_config(cfg: "dict | None") -> dict:
    """Defensive client-side defaults for the server's `window` block, in
    case a config predates the field (P1 hasn't shipped it yet, or an older
    server) -- the agent must never crash on a missing/malformed block."""
    cfg = cfg if isinstance(cfg, dict) else {}
    windowed = cfg.get("windowed")
    return {
        "layer": cfg.get("layer") or "top",
        "monitor": cfg.get("monitor") or "primary",
        "windowed": windowed if isinstance(windowed, dict) else {},
    }
