# (c) 2026 Applied Brain Research
#
# All information contained herein is and remains the property of Applied Brain
# Research. The intellectual and technical concepts contained herein are
# proprietary to Applied Brain Research and may be covered by U.S. and Foreign
# Patents, patents in process, and are protected by trade secret or copyright
# law. Dissemination of this information or reproduction of this material is
# strictly forbidden unless prior written permission is obtained from Applied
# Brain Research. Access to the source code contained herein is hereby
# forbidden to anyone except current Applied Brain Research employees,
# contractors or other outside parties that have executed Confidentiality
# and/or Non-disclosure agreements explicitly covering such access.
#
# The copyright notice above does not evidence any actual or intended
# publication or disclosure of this source code, which includes information
# that is confidential and/or proprietary, and is a trade secret, of Applied
# Brain Research. ANY REPRODUCTION, MODIFICATION, DISTRIBUTION, PUBLIC
# PERFORMANCE, OR PUBLIC DISPLAY OF OR THROUGH USE OF THIS SOURCE CODE WITHOUT
# THE EXPRESS WRITTEN CONSENT OF APPLIED BRAIN RESEARCH IS STRICTLY PROHIBITED,
# AND IN VIOLATION OF APPLICABLE LAWS AND INTERNATIONAL TREATIES. THE RECEIPT
# OR POSSESSION OF THIS SOURCE CODE AND/OR RELATED INFORMATION DOES NOT CONVEY
# OR IMPLY ANY RIGHTS TO REPRODUCE, DISCLOSE OR DISTRIBUTE ITS CONTENTS, OR TO
# MANUFACTURE, USE, OR SELL ANYTHING THAT IT MAY DESCRIBE, IN WHOLE OR IN PART.

"""
Keygen HTTPS client for on-device license activation.

A device activates by trading its license key plus a hardware fingerprint for a
signed *machine file* (the envelope), which the native SDK then verifies and
installs. Activation is three Keygen calls, wrapped by
:meth:`KeygenClient.activate`:

  1. validate-key - confirm the key is valid; learn the license id.
  2. machine       - find this fingerprint's machine, or register a new one.
  3. check-out     - download the signed machine file for that machine.

All requests use Keygen's JSON:API dialect (``application/vnd.api+json``) and
the customer-facing ``Authorization: License <key>`` scheme. The request and
response shape consumed at each step is documented on the method that makes the
call; only the fields named there are read. The downloaded envelope is handed
to the SDK as-is; this module does not inspect or verify its contents.

Author: Trevor Bekolay (Applied Brain Research)
"""

from __future__ import annotations

import base64
import binascii
import contextlib
import enum
import json
import urllib.error
import urllib.parse
import urllib.request
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any, Final

from abr_sdk.exceptions import AbrSdkError

DEFAULT_API_BASE: Final[str] = "https://api.keygen.sh/v1"

# ABR's Keygen account. The account is the top-level tenant and is not secret
# (it appears in the path of every API request); environments live inside it
# and are selected per request by the Keygen-Environment header.
_KEYGEN_ACCOUNT_ID: Final[str] = "721630ba-ba6b-466b-a558-5b74f7f27b17"

# base64+ed25519 returns the machine file as a JSON envelope of {enc, sig, alg}:
# the license payload base64-encoded plus an Ed25519 signature over it. The
# native SDK verifies the signature offline.
_CHECKOUT_ALGORITHM: Final[str] = "base64+ed25519"

_TIMEOUT_S: Final[int] = 30

# validate-key returns HTTP 200 with meta.valid == false for several states.
# These two mean "key is fine, this device just isn't registered yet", so
# activation continues to machine registration rather than failing.
_RESUMABLE_VALIDATION_CODES: Final[frozenset[str]] = frozenset(
    {"FINGERPRINT_SCOPE_MISMATCH", "NO_MACHINES"}
)


class Failure(enum.Enum):
    """Coarse activation-failure category, mapped to a CLI exit code by the caller."""

    NETWORK = "network"
    LICENSE = "license"
    PROTOCOL = "protocol"


class ActivationError(AbrSdkError):
    """
    An activation step failed.

    Carries a coarse :class:`Failure` *kind* for the CLI exit code and a
    human-readable message for stderr; the specific Keygen reason lives in the
    message, not in a numeric code.
    """

    def __init__(self, kind: Failure, message: str) -> None:
        self.kind = kind
        super().__init__(message)


def unwrap_machine_file_pem(pem_text: str) -> bytes:
    """
    Strip the PEM machine-file markers and base64-decode the body.

    Raises ValueError if the markers are missing or the body is not base64.
    """
    begin = "-----BEGIN MACHINE FILE-----"
    end = "-----END MACHINE FILE-----"
    if begin not in pem_text or end not in pem_text:
        raise ValueError("certificate is missing PEM MACHINE FILE markers")
    i = pem_text.index(begin) + len(begin)
    j = pem_text.index(end, i)
    body = "".join(pem_text[i:j].split())
    try:
        return base64.b64decode(body, validate=True)
    except binascii.Error as e:
        raise ValueError("certificate body is not valid base64") from e


@dataclass(frozen=True)
class _Response:
    """One HTTP response: status code and parsed JSON object (None if not an object)."""

    status: int
    body: dict[str, Any] | None


def _parse_json_object(raw: bytes) -> dict[str, Any] | None:
    """Parse bytes as a JSON object; return None for non-objects or parse errors."""
    try:
        obj = json.loads(raw)
    except (json.JSONDecodeError, UnicodeDecodeError):
        return None
    return obj if isinstance(obj, dict) else None


def _first_error_detail(body: Mapping[str, Any] | None) -> str:
    """Return a short reason from the first JSON:API error object, or ''."""
    if not isinstance(body, Mapping):
        return ""
    errors = body.get("errors")
    if not isinstance(errors, list) or len(errors) == 0:
        return ""
    first = errors[0]
    if not isinstance(first, dict):
        return ""
    for field in ("detail", "title", "code"):
        value = first.get(field)
        if isinstance(value, str) and value:
            return value
    return ""


def _resource_id(body: Mapping[str, Any] | None) -> str | None:
    """Return ``data.id`` from a JSON:API single-resource body, if present."""
    if not isinstance(body, Mapping):
        return None
    data = body.get("data")
    if isinstance(data, dict):
        rid = data.get("id")
        if isinstance(rid, str) and rid:
            return rid
    return None


def _match_machine_id(rows: object, fingerprint: str) -> str | None:
    """Return the id of the machine in *rows* matching *fingerprint*, or None."""
    if not isinstance(rows, list):
        return None
    for row in rows:
        if not isinstance(row, dict):
            continue
        attrs = row.get("attributes")
        if isinstance(attrs, dict) and attrs.get("fingerprint") == fingerprint:
            machine_id = row.get("id")
            if isinstance(machine_id, str) and machine_id:
                return machine_id
    return None


class KeygenClient:
    """
    Customer-facing Keygen client bound to one license key and environment.

    Both activation calls share that context, so it is held once here rather
    than threaded through every method.
    """

    def __init__(
        self,
        key: str,
        *,
        env: str = "production",
        api_base: str = DEFAULT_API_BASE,
        timeout_s: int = _TIMEOUT_S,
    ) -> None:
        self._key = key
        self._env = env
        self._api_base = api_base.rstrip("/")
        self._timeout_s = timeout_s

    def activate(self, fingerprint: str) -> str:
        """
        Run the full activation flow and return the signed machine file envelope.

        The returned string is the decoded ``{enc, sig, alg}`` JSON to hand to
        the SDK; this client does not verify it. Raises :class:`ActivationError`
        on any network, license, or protocol failure.
        """
        license_id = self.validate_license_key(fingerprint)
        machine_id = self.get_or_create_machine(license_id, fingerprint)
        return self.checkout_machine_file(machine_id)

    def validate_license_key(self, fingerprint: str = "") -> str:
        """
        Validate the license key and return its license id.

        Request  POST /licenses/actions/validate-key
            ``{"meta": {"key": <key>, "scope": {"fingerprint": <fp>}}}``
            (the scope is included only when *fingerprint* is non-empty.)
        Response 200
            ``{"meta": {"valid": <bool>, "code": <str>}, "data": {"id": <license id>}}``

        Keygen returns 200 even for an invalid key; the verdict is meta.valid. A
        False verdict whose meta.code means "device not registered yet"
        (:data:`_RESUMABLE_VALIDATION_CODES`) is allowed through so the caller
        can register the machine.
        """
        meta: dict[str, Any] = {"key": self._key}
        if fingerprint:
            meta["scope"] = {"fingerprint": fingerprint}
        resp = self._request(
            "POST", "/licenses/actions/validate-key", payload={"meta": meta}
        )

        if resp.status != 200:
            raise self._http_error(resp, "validate-key")
        body = self._require_object(resp, "validate-key")

        resp_meta = body.get("meta")
        if isinstance(resp_meta, dict) and resp_meta.get("valid") is False:
            code = str(resp_meta.get("code") or "").upper()
            if code not in _RESUMABLE_VALIDATION_CODES:
                detail = str(resp_meta.get("detail") or "license key is not valid")
                if code:
                    detail = f"{detail} ({code})"
                raise ActivationError(Failure.LICENSE, detail)

        license_id = _resource_id(body)
        if license_id is None:
            raise ActivationError(
                Failure.PROTOCOL, "validate-key response is missing the license id"
            )
        return license_id

    def get_or_create_machine(self, license_id: str, fingerprint: str) -> str:
        """
        Return the machine id for *fingerprint*, registering it if absent.

        Request  GET  /licenses/<license id>/machines
        Response 200  ``{"data": [{"id": <machine id>, "attributes":
            {"fingerprint": <fp>}}, ...]}``

        The license's machines are listed and matched on fingerprint here (rather
        than via Keygen's filter[fingerprint], which is not reliably applied on
        this endpoint). A match means this device is already registered, so
        re-activation reuses the same machine and returns the same license.
        """
        # TODO: paginate this list once a license can exceed Keygen's default
        # page size (~10 machines). Today no license is provisioned with that
        # many machines, so this device's record is always on the first page;
        # without pagination a record past page 1 would be missed and the
        # device wrongly re-registered.
        resp = self._request("GET", f"/licenses/{license_id}/machines")
        if resp.status != 200:
            raise self._http_error(resp, "machine list")
        body = self._require_object(resp, "machine list")

        machine_id = _match_machine_id(body.get("data"), fingerprint)
        if machine_id is not None:
            return machine_id
        return self._create_machine(license_id, fingerprint)

    def checkout_machine_file(self, machine_id: str) -> str:
        r"""
        Check out and return the signed machine file envelope for *machine_id*.

        Request  POST /machines/<machine id>/actions/check-out?algorithm=base64+ed25519
        Response 200  ``{"data": {"attributes": {"certificate":
            "-----BEGIN MACHINE FILE-----\n<base64>\n-----END MACHINE FILE-----"}}}``

        The certificate's base64 body decodes to the ``{enc, sig, alg}`` envelope
        JSON, which is returned verbatim. No ttl is requested; the license's own
        expiry is already baked into the payload by the Keygen policy.
        """
        query = {"algorithm": _CHECKOUT_ALGORITHM}
        resp = self._request(
            "POST", f"/machines/{machine_id}/actions/check-out", query=query
        )
        if resp.status not in (200, 201):
            raise self._http_error(resp, "check-out")
        body = self._require_object(resp, "check-out")

        data = body.get("data")
        attrs = data.get("attributes") if isinstance(data, dict) else None
        certificate = attrs.get("certificate") if isinstance(attrs, dict) else None
        if not isinstance(certificate, str) or not certificate:
            raise ActivationError(
                Failure.PROTOCOL, "check-out response is missing the certificate"
            )
        try:
            return unwrap_machine_file_pem(certificate).decode("utf-8")
        except (ValueError, UnicodeDecodeError) as e:
            raise ActivationError(
                Failure.PROTOCOL, f"certificate could not be decoded: {e}"
            ) from e

    def _create_machine(self, license_id: str, fingerprint: str) -> str:
        """
        Register a machine for *fingerprint* and return its id.

        Request  POST /machines
            ``{"data": {"type": "machines", "attributes": {"fingerprint": <fp>,
            "name": <fp>}, "relationships": {"license": {"data": {"type":
            "licenses", "id": <license id>}}}}}``
        """
        payload = {
            "data": {
                "type": "machines",
                "attributes": {"fingerprint": fingerprint, "name": fingerprint},
                "relationships": {
                    "license": {"data": {"type": "licenses", "id": license_id}},
                },
            }
        }
        resp = self._request("POST", "/machines", payload=payload)
        if resp.status not in (200, 201):
            raise self._http_error(resp, "machine create")
        machine_id = _resource_id(resp.body)
        if machine_id is None:
            raise ActivationError(
                Failure.PROTOCOL, "machine create response is missing the id"
            )
        return machine_id

    def _url(self, path: str, query: Mapping[str, str] | None = None) -> str:
        url = f"{self._api_base}/accounts/{_KEYGEN_ACCOUNT_ID}{path}"
        if query:
            url += "?" + urllib.parse.urlencode(query)
        return url

    def _headers(self) -> dict[str, str]:
        headers = {
            "Authorization": f"License {self._key}",
            "Content-Type": "application/vnd.api+json",
            "Accept": "application/vnd.api+json",
        }
        if self._env:
            headers["Keygen-Environment"] = self._env
        return headers

    def _request(
        self,
        method: str,
        path: str,
        *,
        query: Mapping[str, str] | None = None,
        payload: Mapping[str, Any] | None = None,
    ) -> _Response:
        """Send one request, retrying once on a connection error or HTTP 5xx."""
        data = json.dumps(payload).encode() if payload is not None else None
        req = urllib.request.Request(
            self._url(path, query), data=data, headers=self._headers(), method=method
        )
        for attempt in range(2):
            # The try/except is the retry itself; its per-iteration cost is
            # negligible against a network round-trip (PERF203 is not apt here).
            try:
                with urllib.request.urlopen(req, timeout=self._timeout_s) as resp:
                    return _Response(resp.status, _parse_json_object(resp.read()))
            except urllib.error.HTTPError as exc:  # noqa: PERF203
                raw = b""
                with contextlib.suppress(OSError):
                    raw = exc.read()
                if exc.code >= 500 and attempt == 0:
                    continue
                return _Response(exc.code, _parse_json_object(raw))
            except urllib.error.URLError as exc:
                if attempt == 0:
                    continue
                raise ActivationError(
                    Failure.NETWORK, f"could not reach Keygen: {exc.reason}"
                ) from exc
        raise ActivationError(Failure.NETWORK, "Keygen still failing after one retry")

    def _require_object(self, resp: _Response, step: str) -> dict[str, Any]:
        """Return resp.body, or raise PROTOCOL if it was not a JSON object."""
        if resp.body is None:
            raise ActivationError(
                Failure.PROTOCOL, f"{step} response was not a JSON object"
            )
        return resp.body

    def _http_error(self, resp: _Response, step: str) -> ActivationError:
        """Build an :class:`ActivationError` for a non-success HTTP status."""
        kind = Failure.NETWORK if resp.status >= 500 else Failure.LICENSE
        detail = _first_error_detail(resp.body)
        suffix = f": {detail}" if detail else ""
        return ActivationError(kind, f"{step} failed (HTTP {resp.status}){suffix}")
