"""HMAC-SHA256 signing and verification for bundled-artifact content URLs.

Signed URLs are generated by ArtifactManager._bundle_content_url() when
ARTIFACT_CONTENT_HMAC_KEYRING is set, and validated by the
GET /artifacts/{artifact_id}/content endpoint.

Canonical string (newline-separated):
    GET
    /v1/artifacts/{artifact_id}/content
    {expiry_unix_seconds}
    {kid}

The kid is part of the canonical string so that a signature produced with one
key cannot be replayed under a different key ID.

Key rotation
------------
Add the new key to the keyring's `keys` dict and set `current_kid` to it.
New URLs are signed with the new key immediately; old URLs carry the old kid
and verify against the old key (still present in `keys`) until they expire
(12 h max).  After that window remove the old key entry.
"""

import base64
import hashlib
import hmac
import json
import time
from functools import lru_cache
from urllib.parse import urlencode

from pydantic import BaseModel, model_validator

ARTIFACT_URL_EXPIRY_SECONDS = 12 * 60 * 60  # 12 hours
_ARTIFACT_CONTENT_PATH_TEMPLATE = "/v1/artifacts/{artifact_id}/content"


class HmacKeyEntry(BaseModel):
    secret: str
    """HMAC secret as a plain string."""
    created_at: str | None = None
    """ISO date string, informational only — not used in signature logic."""


class ArtifactHmacKeyring(BaseModel):
    current_kid: str
    """ID of the key used to sign new URLs."""
    keys: dict[str, HmacKeyEntry]
    """All known keys, including retired ones kept for in-flight URL validation."""

    @model_validator(mode="after")
    def _current_kid_must_be_in_keys(self) -> "ArtifactHmacKeyring":
        if self.current_kid not in self.keys:
            raise ValueError(f"current_kid '{self.current_kid}' is not present in keys")
        return self

    def get_secret_bytes(self, kid: str) -> bytes | None:
        """Return the key bytes for the given kid, or None if the kid is unknown."""
        entry = self.keys.get(kid)
        if entry is None:
            return None
        return entry.secret.encode()


@lru_cache(maxsize=8)
def parse_keyring(keyring_json: str) -> ArtifactHmacKeyring:
    """Parse and validate the ARTIFACT_CONTENT_HMAC_KEYRING JSON string.

    Cached (keyed on the raw JSON) to avoid repeated parsing on every request.
    Different JSON strings (e.g. after a config reload in tests) each get their
    own cached entry.
    """
    data = json.loads(keyring_json)
    return ArtifactHmacKeyring.model_validate(data)


def _canonical_string(method: str, path: str, expiry: int, kid: str) -> str:
    return f"{method}\n{path}\n{expiry}\n{kid}"


def _hmac_b64(secret_bytes: bytes, canonical: str) -> str:
    """Return the URL-safe base64 (no padding) of the HMAC-SHA256 digest."""
    digest = hmac.new(secret_bytes, canonical.encode(), hashlib.sha256).digest()
    return base64.urlsafe_b64encode(digest).rstrip(b"=").decode()


def sign_artifact_url(
    base_url: str,
    artifact_id: str,
    keyring: ArtifactHmacKeyring,
    artifact_name: str | None = None,
    artifact_type: str | None = None,
) -> str:
    """Return a fully-signed URL for the artifact content endpoint.

    Signs with keyring.current_kid.  The URL is valid for
    ARTIFACT_URL_EXPIRY_SECONDS (12 hours) from the time this function is called.

    The signature is URL-safe base64 (no padding), 43 characters for SHA-256.

    artifact_name and artifact_type are appended as informational query params
    for client use only — they are not part of the canonical string or signature.
    """
    kid = keyring.current_kid
    secret_bytes = keyring.get_secret_bytes(kid)
    if secret_bytes is None:
        raise ValueError(f"No secret found for kid '{kid}'")

    expiry = int(time.time()) + ARTIFACT_URL_EXPIRY_SECONDS
    path = _ARTIFACT_CONTENT_PATH_TEMPLATE.format(artifact_id=artifact_id)
    canonical = _canonical_string("GET", path, expiry, kid)
    sig = _hmac_b64(secret_bytes, canonical)
    sig_params: dict[str, str | int] = {"expiry": expiry, "kid": kid, "sig": sig}
    if artifact_name is not None:
        sig_params["artifact_name"] = artifact_name
    if artifact_type is not None:
        sig_params["artifact_type"] = artifact_type
    params = urlencode(sig_params)
    return f"{base_url.rstrip('/')}{path}?{params}"


def verify_artifact_signature(
    artifact_id: str,
    expiry: str,
    kid: str,
    sig: str,
    keyring: ArtifactHmacKeyring,
) -> bool:
    """Return True if the HMAC signature is valid and the URL has not expired.

    Looks up the kid in the keyring so old URLs remain valid while the old key
    is still present, enabling zero-downtime rotation.  All comparisons use
    constant-time equality to prevent timing attacks.
    """
    try:
        expiry_ts = int(expiry)
    except ValueError:
        return False

    if expiry_ts < int(time.time()):
        return False

    secret_bytes = keyring.get_secret_bytes(kid)
    if secret_bytes is None:
        return False

    path = _ARTIFACT_CONTENT_PATH_TEMPLATE.format(artifact_id=artifact_id)
    canonical = _canonical_string("GET", path, expiry_ts, kid)
    expected = _hmac_b64(secret_bytes, canonical)
    return hmac.compare_digest(expected, sig)
