"""Ed25519 sign/verify for license blobs.

Asymmetric model:

- **Private key** (32 bytes) lives with the seller only. It signs the
  buyer's name/email/tier/etc into a license blob via
  ``scripts/generate_license.py``.
- **Public key** (32 bytes) is embedded in every shipped binary. The
  binary uses it to verify blobs at activation time.

The split means a motivated reverse engineer who pulls everything out
of the binary still can't sign new licenses — they'd need the private
key, which never leaves the seller's environment. This is the key
upgrade vs. the v1 HMAC scheme: HMAC's symmetric secret was trivially
extractable, so anyone with the binary could mint blobs for any tier.

Keys come from (in priority order):

1. ``$DATATOOLS_LICENSE_PRIVKEY`` / ``$DATATOOLS_LICENSE_PUBKEY`` —
   hex-encoded raw bytes. The build pipeline sets the pubkey here.
2. The dev-only constants in ``_dev_keypair.py`` — deterministic from
   a seed, embedded in the source tree for local development and
   testing. **Never** ship a binary that still uses these.

A frozen / shipped build verifying against the dev key is a build
configuration error — ``assert_production_safe`` (see
``.manager``) fires loudly on startup in that case.

Blob format: ``DTLIC2:`` + base64-encoded JSON. The version prefix
bumped from ``DTLIC1`` to ``DTLIC2`` when we switched from HMAC to
Ed25519, so old v1 blobs surface a clear "old format" error rather
than a confusing "signature mismatch".
"""

from __future__ import annotations

import base64
import json
import os
from typing import Any

from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
    Ed25519PrivateKey,
    Ed25519PublicKey,
)

from ._dev_keypair import DEV_PRIVATE_KEY_HEX, DEV_PUBLIC_KEY_HEX


# ---------------------------------------------------------------------------
# Key material
# ---------------------------------------------------------------------------

def _privkey_hex() -> str:
    """Hex-encoded raw Ed25519 private-key bytes.

    Read from ``$DATATOOLS_LICENSE_PRIVKEY`` first (where the seller
    stashes their real key), falling back to the dev seed-derived
    constant. The dev fallback only matters during testing /
    development; a shipped build calling :func:`sign` is a bug (only
    the seller's key-gen script does that).
    """
    return os.environ.get("DATATOOLS_LICENSE_PRIVKEY") or DEV_PRIVATE_KEY_HEX


def _pubkey_hex() -> str:
    """Hex-encoded raw Ed25519 public-key bytes.

    Read from ``$DATATOOLS_LICENSE_PUBKEY`` first (the build
    pipeline sets this), falling back to the dev key.
    """
    return os.environ.get("DATATOOLS_LICENSE_PUBKEY") or DEV_PUBLIC_KEY_HEX


def _privkey() -> Ed25519PrivateKey:
    return Ed25519PrivateKey.from_private_bytes(bytes.fromhex(_privkey_hex()))


def _pubkey() -> Ed25519PublicKey:
    return Ed25519PublicKey.from_public_bytes(bytes.fromhex(_pubkey_hex()))


def is_using_dev_key() -> bool:
    """True when the active **public** key matches the embedded dev key.

    Used by :func:`.manager.assert_production_safe` to catch frozen
    builds whose pubkey wasn't overridden at build time.
    """
    return _pubkey_hex() == DEV_PUBLIC_KEY_HEX


# ---------------------------------------------------------------------------
# Canonical encoding (shared with v1 — same bytes, same hash, same sig)
# ---------------------------------------------------------------------------

def _canonical_bytes(payload: dict[str, Any]) -> bytes:
    """Canonical JSON encoding for the signature input.

    ``sort_keys=True`` + ``separators=(",", ":")`` produce a byte-for-
    byte deterministic representation across Python versions and OS
    locales. Without that, two structurally-identical dicts could
    produce different signatures.
    """
    return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")


# ---------------------------------------------------------------------------
# Sign / verify
# ---------------------------------------------------------------------------

def sign(payload: dict[str, Any]) -> str:
    """Produce an Ed25519 signature over *payload*, hex-encoded.

    Caller must strip any existing ``signature`` field — the function
    signs whatever it's given, including a stale signature, which
    would never verify because verify recomputes from a fresh
    no-``signature`` canonical form.
    """
    sig_bytes = _privkey().sign(_canonical_bytes(payload))
    return sig_bytes.hex()


def verify(payload: dict[str, Any], signature_hex: str) -> bool:
    """Verify *signature_hex* against *payload*. Returns True/False;
    never raises (a missing or malformed signature is just False)."""
    try:
        sig_bytes = bytes.fromhex(signature_hex)
    except ValueError:
        return False
    try:
        _pubkey().verify(sig_bytes, _canonical_bytes(payload))
        return True
    except InvalidSignature:
        return False


# ---------------------------------------------------------------------------
# Blob encoding / decoding
# ---------------------------------------------------------------------------

# Buyers paste this whole token into the activation page. The prefix
# is the version marker:
#   DTLIC1  — old HMAC scheme (no longer accepted)
#   DTLIC2  — Ed25519 (current)
_BLOB_PREFIX = "DTLIC2:"
_OLD_PREFIX = "DTLIC1:"


def encode_blob(payload_with_signature: dict[str, Any]) -> str:
    """Wrap a signed payload into the buyer-facing blob form."""
    raw = json.dumps(
        payload_with_signature, sort_keys=True, separators=(",", ":"),
    ).encode("utf-8")
    return _BLOB_PREFIX + base64.urlsafe_b64encode(raw).decode("ascii")


def decode_blob(blob: str) -> dict[str, Any]:
    """Reverse of :func:`encode_blob`. Raises ``ValueError`` on a
    blob that doesn't carry the expected prefix, doesn't decode
    cleanly, or carries the v1 prefix (which we no longer accept)."""
    s = blob.strip()
    if s.startswith(_OLD_PREFIX):
        raise ValueError(
            f"License blob is the old {_OLD_PREFIX!r} format. v1 blobs "
            "used a symmetric secret that has since been retired — "
            "request a new blob from support."
        )
    if not s.startswith(_BLOB_PREFIX):
        raise ValueError(
            f"License blob missing {_BLOB_PREFIX!r} prefix. "
            "Did you paste the wrong text?"
        )
    encoded = s[len(_BLOB_PREFIX):]
    try:
        raw = base64.urlsafe_b64decode(encoded.encode("ascii"))
    except (ValueError, TypeError) as e:
        raise ValueError(f"License blob is not valid base64: {e}") from e
    try:
        return json.loads(raw.decode("utf-8"))
    except (json.JSONDecodeError, UnicodeDecodeError) as e:
        raise ValueError(f"License blob contains invalid JSON: {e}") from e
