"""Central API client.

The rest of the agent talks to the control plane only through ApiClient, so the
real HTTP implementation can be swapped for the development mock without
touching call sites. Choose the implementation with get_api_client(config):
WOLFPANEL_API_MOCK=1 selects the mock.

Endpoints (frozen v1 contract):
  POST /api/v1/agent/register             (no auth; install token in body)
  POST /api/v1/agent/heartbeat            (X-Agent-Token header)
  POST /api/v1/agent/discovery/sessions   (X-Agent-Token header)
  POST /api/v1/agent/discovery/items      (X-Agent-Token header)
  POST /api/v1/agent/discovery/complete   (X-Agent-Token header)
  POST /api/v1/agent/discovery/failed     (X-Agent-Token header)
  GET  <manifest_url>                     (update check)
"""

from __future__ import annotations

import json
import urllib.error
import urllib.request
from typing import Any

from config import Config
from logger import get_logger, redact

log = get_logger("api")

_TIMEOUT = 15  # seconds; the agent must never block forever on the network.


class ApiError(Exception):
    """Raised for non-recoverable API errors (4xx / malformed responses)."""

    def __init__(self, message: str, status_code: int | None = None) -> None:
        super().__init__(message)
        self.status_code = status_code


class ApiClient:
    """Interface every client implementation satisfies."""

    def register(self, payload: dict[str, Any]) -> dict[str, Any]:
        raise NotImplementedError

    def heartbeat(self, payload: dict[str, Any], token: str | None) -> None:
        raise NotImplementedError

    def open_discovery_session(self, payload: dict[str, Any], token: str | None) -> dict[str, Any]:
        raise NotImplementedError

    def upload_discovery_items(self, payload: dict[str, Any], token: str | None) -> dict[str, Any]:
        raise NotImplementedError

    def complete_discovery(self, payload: dict[str, Any], token: str | None) -> dict[str, Any]:
        raise NotImplementedError

    def fail_discovery(self, payload: dict[str, Any], token: str | None) -> dict[str, Any]:
        raise NotImplementedError

    def fetch_manifest(self, url: str) -> dict[str, Any]:
        raise NotImplementedError

    def get_pending_commands(self, token: str | None) -> list[dict[str, Any]]:
        raise NotImplementedError

    def post_command_result(self, command_id: int, payload: dict[str, Any], token: str | None) -> dict[str, Any]:
        raise NotImplementedError

    def update_pipeline_run(self, run_id: int, status: str, logs: list[dict[str, Any]], token: str | None) -> dict[str, Any]:
        raise NotImplementedError

    def update_site_status(self, site_id: int, payload: dict[str, Any], token: str | None) -> dict[str, Any]:
        raise NotImplementedError


class RealApiClient(ApiClient):
    """Talks to the live Central API over HTTPS using only the stdlib."""

    def __init__(self, base_url: str) -> None:
        self.base_url = base_url.rstrip("/")

    def _post(self, path: str, payload: dict[str, Any], token: str | None) -> dict[str, Any]:
        url = f"{self.base_url}{path}"
        data = json.dumps(payload).encode("utf-8")
        headers = {"Content-Type": "application/json", "User-Agent": "wolfpanel-agent"}
        if token:
            # Frozen agent auth scheme: the agent token travels in X-Agent-Token,
            # never as a Bearer credential.
            # TODO(prod): also sign the request body (request signing) so a
            # leaked token alone is not sufficient to impersonate the agent.
            headers["X-Agent-Token"] = token

        request = urllib.request.Request(url, data=data, headers=headers, method="POST")
        try:
            with urllib.request.urlopen(request, timeout=_TIMEOUT) as response:
                body = response.read().decode("utf-8")
                return json.loads(body) if body else {}
        except urllib.error.HTTPError as exc:
            raise ApiError(f"{path} -> HTTP {exc.code}", status_code=exc.code) from exc
        except (urllib.error.URLError, TimeoutError) as exc:
            # Network/transport failures are recoverable; callers decide whether
            # to retry. Re-raise as ApiError so they only catch one type.
            raise ApiError(f"{path} -> transport error: {exc}") from exc

    def _get(self, path: str, token: str | None) -> Any:
        url = f"{self.base_url}{path}"
        headers = {"User-Agent": "wolfpanel-agent"}
        if token:
            headers["X-Agent-Token"] = token

        request = urllib.request.Request(url, headers=headers, method="GET")
        try:
            with urllib.request.urlopen(request, timeout=_TIMEOUT) as response:
                body = response.read().decode("utf-8")
                return json.loads(body) if body else {}
        except urllib.error.HTTPError as exc:
            raise ApiError(f"{path} -> HTTP {exc.code}", status_code=exc.code) from exc
        except (urllib.error.URLError, TimeoutError) as exc:
            raise ApiError(f"{path} -> transport error: {exc}") from exc

    def register(self, payload: dict[str, Any]) -> dict[str, Any]:
        return self._post("/api/v1/agent/register", payload, token=None)

    def heartbeat(self, payload: dict[str, Any], token: str | None) -> None:
        self._post("/api/v1/agent/heartbeat", payload, token)

    def open_discovery_session(self, payload: dict[str, Any], token: str | None) -> dict[str, Any]:
        return self._post("/api/v1/agent/discovery/sessions", payload, token)

    def upload_discovery_items(self, payload: dict[str, Any], token: str | None) -> dict[str, Any]:
        return self._post("/api/v1/agent/discovery/items", payload, token)

    def complete_discovery(self, payload: dict[str, Any], token: str | None) -> dict[str, Any]:
        return self._post("/api/v1/agent/discovery/complete", payload, token)

    def fail_discovery(self, payload: dict[str, Any], token: str | None) -> dict[str, Any]:
        return self._post("/api/v1/agent/discovery/failed", payload, token)

    def fetch_manifest(self, url: str) -> dict[str, Any]:
        request = urllib.request.Request(url, headers={"User-Agent": "wolfpanel-agent"})
        try:
            with urllib.request.urlopen(request, timeout=_TIMEOUT) as response:
                return json.loads(response.read().decode("utf-8"))
        except (urllib.error.URLError, TimeoutError, ValueError) as exc:
            raise ApiError(f"manifest fetch failed: {exc}") from exc

    def get_pending_commands(self, token: str | None) -> list[dict[str, Any]]:
        return self._get("/api/v1/agent/commands/pending", token)

    def post_command_result(self, command_id: int, payload: dict[str, Any], token: str | None) -> dict[str, Any]:
        return self._post(f"/api/v1/agent/commands/{command_id}/result", payload, token)

    def update_pipeline_run(self, run_id: int, status: str, logs: list[dict[str, Any]], token: str | None) -> dict[str, Any]:
        payload = {"status": status, "logs": logs}
        return self._post(f"/api/v1/agent/pipeline/{run_id}/update", payload, token)

    def update_site_status(self, site_id: int, payload: dict[str, Any], token: str | None) -> dict[str, Any]:
        return self._post(f"/api/v1/agent/sites/{site_id}/status", payload, token)


class MockApiClient(ApiClient):
    """Development stand-in. Logs payloads and returns canned responses.

    Lets the whole agent be exercised end-to-end with no Central API present.
    """

    def register(self, payload: dict[str, Any]) -> dict[str, Any]:
        log.info(
            "[mock] register hostname=%s os=%s fingerprint=%s",
            payload.get("hostname"),
            payload.get("os_name"),
            payload.get("fingerprint"),
        )
        # Frozen v1 register response shape: no agent_id / refresh_token.
        return {
            "agent_token": "wp_agent_dev_token",
            "server_id": 1,
            "status": "active",
        }

    def heartbeat(self, payload: dict[str, Any], token: str | None) -> None:
        log.info(
            "[mock] heartbeat server_id=%s status=%s token=%s",
            payload.get("server_id"),
            payload.get("status"),
            redact(token),
        )

    def open_discovery_session(self, payload: dict[str, Any], token: str | None) -> dict[str, Any]:
        log.info(
            "[mock] discovery session open scan_type=%s categories=%s",
            payload.get("scan_type"),
            payload.get("expected_categories"),
        )
        return {"session_id": 1, "status": "running"}

    def upload_discovery_items(self, payload: dict[str, Any], token: str | None) -> dict[str, Any]:
        items = payload.get("items", [])
        log.info(
            "[mock] discovery items session_id=%s category=%s count=%d",
            payload.get("session_id"),
            payload.get("category"),
            len(items),
        )
        return {
            "session_id": payload.get("session_id"),
            "accepted": len(items),
            "item_count": len(items),
        }

    def complete_discovery(self, payload: dict[str, Any], token: str | None) -> dict[str, Any]:
        log.info(
            "[mock] discovery complete session_id=%s succeeded=%s failed=%s",
            payload.get("session_id"),
            payload.get("succeeded_categories"),
            payload.get("failed_categories"),
        )
        failed = payload.get("failed_categories") or []
        return {
            "session_id": payload.get("session_id"),
            "status": "partial" if failed else "completed",
            "item_count": 0,
        }

    def fail_discovery(self, payload: dict[str, Any], token: str | None) -> dict[str, Any]:
        log.info(
            "[mock] discovery failed session_id=%s",
            payload.get("session_id"),
        )
        return {"session_id": payload.get("session_id"), "status": "failed"}

    def fetch_manifest(self, url: str) -> dict[str, Any]:
        log.info("[mock] fetch manifest %s", url)
        return {
            "manifest_version": 1,
            "channel": "stable",
            "version": "0.1.2",
            "file": "wolfpanel-agent-0.1.2.tar.gz",
            "sha256": "0" * 64,
            "signature": "",
            "size_bytes": 0,
            "published_at": "2026-07-04T00:00:00Z",
        }

    def get_pending_commands(self, token: str | None) -> list[dict[str, Any]]:
        log.info("[mock] get pending commands")
        return []

    def post_command_result(self, command_id: int, payload: dict[str, Any], token: str | None) -> dict[str, Any]:
        log.info("[mock] post command result id=%s status=%s", command_id, payload.get("status"))
        return {"status": payload.get("status")}

    def update_pipeline_run(self, run_id: int, status: str, logs: list[dict[str, Any]], token: str | None) -> dict[str, Any]:
        log.info("[mock] update pipeline run id=%s status=%s", run_id, status)
        return {"status": status}

    def update_site_status(self, site_id: int, payload: dict[str, Any], token: str | None) -> dict[str, Any]:
        log.info("[mock] update site status site_id=%s payload=%s", site_id, payload)
        return {"status": "success"}


def get_api_client(config: Config) -> ApiClient:
    """Factory: mock in development, real client otherwise."""
    if config.api_mock:
        log.info("using MockApiClient (WOLFPANEL_API_MOCK enabled)")
        return MockApiClient()
    return RealApiClient(config.api_url)
