"""Request signing for the TaxPilot CMS Agent API.

This module is the Python half of a scheme whose other half lives in
``App\\Http\\Middleware\\VerifyAgentSignature`` in the CMS. The two must agree
byte for byte; a cross-language test pins that rather than trusting that two
implementations of the same sentence ended up the same.

The canonical message is::

    timestamp \\n nonce \\n METHOD \\n path \\n rawBody

Covering the method, path *and* body means none of them can be altered in
flight. The timestamp bounds the replay window; the nonce is single-use inside
it, enforced server-side.
"""

from __future__ import annotations

import hashlib
import hmac
import secrets
import time
from dataclasses import dataclass

# Header names the CMS middleware reads. Constants because a typo here fails as
# "missing_signature_headers" — technically accurate, unhelpfully far from the
# actual mistake.
HEADER_KEY = "X-Agent-Key"
HEADER_TIMESTAMP = "X-Timestamp"
HEADER_NONCE = "X-Nonce"
HEADER_SIGNATURE = "X-Signature"


@dataclass(frozen=True, slots=True)
class SignedHeaders:
    """The four headers a signed request carries."""

    key: str
    timestamp: str
    nonce: str
    signature: str

    def as_dict(self) -> dict[str, str]:
        return {
            HEADER_KEY: self.key,
            HEADER_TIMESTAMP: self.timestamp,
            HEADER_NONCE: self.nonce,
            HEADER_SIGNATURE: self.signature,
        }


def canonical_message(timestamp: str, nonce: str, method: str, path: str, body: str) -> str:
    """Build the exact string the CMS will rebuild and verify against.

    ``path`` is the routed path with no leading slash and no query string — the
    value PHP's ``$request->path()`` returns. Signing a leading slash or the
    query string is the most likely way to produce a signature that is perfectly
    valid and still rejected, so both are normalised here rather than left to
    each caller to remember.
    """
    return "\n".join(
        [
            timestamp,
            nonce,
            method.upper(),
            path.lstrip("/").split("?", 1)[0],
            body,
        ]
    )


def sign(
    secret: str,
    method: str,
    path: str,
    body: str = "",
    *,
    api_key: str = "",
    timestamp: str | None = None,
    nonce: str | None = None,
) -> SignedHeaders:
    """Sign one request.

    ``timestamp`` and ``nonce`` are injectable for tests only. In production
    both are generated here: a caller-supplied nonce is a caller-supplied replay
    waiting to happen.
    """
    timestamp = timestamp if timestamp is not None else str(int(time.time()))
    # 32 hex characters — far beyond collision range within a 5-minute window,
    # and generated with secrets rather than random, which is predictable.
    nonce = nonce if nonce is not None else secrets.token_hex(16)

    message = canonical_message(timestamp, nonce, method, path, body)
    signature = hmac.new(
        secret.encode("utf-8"),
        message.encode("utf-8"),
        hashlib.sha256,
    ).hexdigest()

    return SignedHeaders(key=api_key, timestamp=timestamp, nonce=nonce, signature=signature)


def verify(secret: str, signature: str, message: str) -> bool:
    """Check a signature.

    Not used against the CMS — it verifies ours — but present so that anything
    signing back to this service has one correct comparison to reach for.
    ``compare_digest`` is constant-time; ``==`` leaks the signature one byte at
    a time to an attacker who can measure.
    """
    expected = hmac.new(secret.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest()

    return hmac.compare_digest(expected, signature)
