<?php

/**
 * HMAC Server for the Site Export API.
 *
 * This class verifies the HMAC-authenticated control requests generated by
 * Site_Export_HMAC_Client. It validates the required X-Auth-* headers and
 * checks request freshness before any caller-provided body hash is evaluated.
 */
final class Site_Export_HMAC_Server {

    /**
     * Value of the X-Auth-Content-Hash header when the request body is
     * deliberately not signed: this literal string stands where a body hash
     * would otherwise be. Must match Site_Export_HMAC_Client::UNSIGNED_PAYLOAD.
     */
    public const UNSIGNED_PAYLOAD = 'UNSIGNED-PAYLOAD';

    private const DEFAULT_MAX_CONTROL_BODY_BYTES = 1048576;

    /** @var string */
    private $secret;

    /** @var int */
    private $timestamp_tolerance;

    public function __construct(string $secret, int $timestamp_tolerance = 300) {
        $this->secret = $secret;
        $this->timestamp_tolerance = $timestamp_tolerance;
    }

    /**
     * Verify a small command request with a bounded body.
     *
     * HMAC is the guard for small protocol commands such as preflight, session
     * start, plan confirmation, and abort/resume. Large data uploads should use
     * authenticated sessions plus per-chunk hashes instead of signing one
     * multi-megabyte request body.
     */
    public function verify_control_request(array $headers = [], string $body = '', ?float $now = null, ?int $max_body_bytes = null): ?string {
        $body_limit = $max_body_bytes ?? self::DEFAULT_MAX_CONTROL_BODY_BYTES;
        if (strlen($body) > $body_limit) {
            return sprintf(
                'HMAC control request body exceeds %d bytes',
                $body_limit
            );
        }

        return $this->verify($headers, $body, [], $now);
    }

    /**
     * Verify a request using explicit inputs.
     *
     * Returns null on success, or an error string on failure. This convenience
     * method receives the full body as a string and is only appropriate for
     * compatibility with existing small request flows. New protocol code should
     * use verify_control_request() for HMAC-protected commands and avoid
     * HMAC-signing large data uploads.
     *
     * When $files is non-empty, the content hash is computed from uploaded file
     * contents rather than $body so multipart uploads verify consistently.
     */
    public function verify(array $headers = [], ?string $body = null, array $files = [], ?float $now = null): ?string {
        return $this->verify_content_hash_callback(
            $headers,
            function () use ($body, $files): string {
                return $this->compute_received_content_hash($body, $files);
            },
            $now
        );
    }

    /**
     * Verify a request when the caller already computed the received digest.
     *
     * This exists for bounded protocol code that has already computed the body
     * digest. It does not read a request body. Push chunk uploads should use
     * session/request capabilities plus per-chunk hashes instead of routing
     * large bodies through HMAC verification.
     */
    public function verify_content_hash(array $headers, string $received_content_hash, ?float $now = null): ?string {
        return $this->verify_content_hash_callback(
            $headers,
            function () use ($received_content_hash): string {
                return $received_content_hash;
            },
            $now
        );
    }

    /**
     * Verify the signed content hash header before the request body is read.
     *
     * This only authenticates the claim in X-Auth-Content-Hash; it does not
     * authenticate any request bytes by itself. Callers must still compare the
     * returned hash with the digest of a bounded control payload before acting
     * on that payload. Do not use this as a large-upload authentication scheme.
     *
     * @return array{error:?string,content_hash:?string}
     */
    public function verify_signed_content_hash(array $headers, ?float $now = null): array {
        $auth = $this->collect_auth_headers($headers);
        $auth_error = $this->verify_auth_headers($auth, $now);
        if ($auth_error !== null) {
            return [
                'error' => $auth_error,
                'content_hash' => null,
            ];
        }

        return [
            'error' => null,
            'content_hash' => $auth['content_hash'],
        ];
    }

    /**
     * The received content hash must not be computed until the timestamp,
     * nonce, and signature checks pass: bodies and multipart uploads can be
     * large and live on slow disks, and unauthenticated callers should not be
     * able to force the server to hash them.
     */
    private function verify_content_hash_callback(array $headers, callable $received_content_hash, ?float $now = null): ?string {
        $auth = $this->collect_auth_headers($headers);
        $auth_error = $this->verify_auth_headers($auth, $now);
        if ($auth_error !== null) {
            return $auth_error;
        }

        try {
            $actual_content_hash = $received_content_hash();
        } catch (RuntimeException $e) {
            return $e->getMessage();
        }

        if (!hash_equals($auth['content_hash'], $actual_content_hash)) {
            return 'Content hash mismatch: body was modified in transit';
        }

        return null;
    }

    /**
     * Verify a request whose body is deliberately not signed.
     *
     * Instead of a body hash, the signature covers exactly four values:
     * the nonce, the timestamp, the HTTP method, and the request target
     * (the "path?query" part of the URL). A request body of any size can then
     * stream through without either side hashing it, and a captured set of
     * auth headers still cannot be reused for a different endpoint or
     * method. Protecting the body from tampering is TLS's job.
     *
     * The X-Auth-Content-Hash header must be the literal string
     * UNSIGNED-PAYLOAD. Because of that, headers signed for this check can
     * never pass the body-signed checks and vice versa — the two signatures
     * are computed over strings that can never be equal. Each route decides
     * which check it calls, so a client cannot make a command endpoint
     * that requires verify_control_request() accept this body-less check.
     *
     * @param string $request_target The "path?query" form of the request URL.
     */
    public function verify_envelope(array $headers, string $method, string $request_target, ?float $now = null): ?string {
        $auth = $this->collect_auth_headers($headers);
        if ($auth['content_hash'] !== self::UNSIGNED_PAYLOAD) {
            return 'Envelope verification requires the literal UNSIGNED-PAYLOAD content hash';
        }

        $freshness_error = $this->verify_freshness($auth, $now);
        if ($freshness_error !== null) {
            return $freshness_error;
        }

        $message = $auth['nonce'] . $auth['timestamp'] . self::UNSIGNED_PAYLOAD . "\n" . strtoupper($method) . "\n" . $request_target;
        $expected_signature = hash_hmac('sha256', $message, $this->secret);
        if (!hash_equals($expected_signature, $auth['signature'])) {
            return 'HMAC signature verification failed';
        }

        return null;
    }

    /**
     * Verify the current PHP request using superglobals.
     *
     * Returns null on success, or an error string on failure. This legacy
     * convenience path buffers php://input and should only be used for small
     * request bodies. New command endpoints should bound the body and call
     * verify_control_request(). Large data routes should not use whole-body
     * HMAC verification.
     */
    public function verify_globals(?float $now = null): ?string {
        $body = file_get_contents('php://input');
        if ($body === false) {
            $body = '';
        }

        return $this->verify($this->collect_global_headers(), $body, $_FILES, $now);
    }

    private function collect_auth_headers(array $headers): array {
        return [
            'signature' => $this->get_header($headers, 'X-Auth-Signature'),
            'nonce' => $this->get_header($headers, 'X-Auth-Nonce'),
            'timestamp' => $this->get_header($headers, 'X-Auth-Timestamp'),
            'content_hash' => $this->get_header($headers, 'X-Auth-Content-Hash'),
        ];
    }

    private function verify_auth_headers(array $auth, ?float $now = null): ?string {
        $freshness_error = $this->verify_freshness($auth, $now);
        if ($freshness_error !== null) {
            return $freshness_error;
        }

        $expected_signature = hash_hmac('sha256', $auth['nonce'] . $auth['timestamp'] . $auth['content_hash'], $this->secret);
        if (!hash_equals($expected_signature, $auth['signature'])) {
            return 'HMAC signature verification failed';
        }

        return null;
    }

    /**
     * Checks header presence, timestamp tolerance, and nonce length —
     * everything except the signature. Body-signed and envelope-signed
     * requests compute their signatures over different strings, so each
     * caller does its own signature check after this passes.
     */
    private function verify_freshness(array $auth, ?float $now = null): ?string {
        $signature = $auth['signature'];
        $nonce = $auth['nonce'];
        $timestamp = $auth['timestamp'];
        $signed_content_hash = $auth['content_hash'];
        if ($signature === null || $signature === '') {
            return 'Missing X-Auth-Signature header';
        }
        if ($nonce === null || $nonce === '') {
            return 'Missing X-Auth-Nonce header';
        }
        if ($timestamp === null || $timestamp === '') {
            return 'Missing X-Auth-Timestamp header';
        }
        if ($signed_content_hash === null || $signed_content_hash === '') {
            return 'Missing X-Auth-Content-Hash header';
        }

        if (!is_numeric($timestamp)) {
            return 'Invalid timestamp format';
        }

        $request_time = (float) $timestamp;
        $current_time = $now ?? microtime(true);
        $time_diff = abs($current_time - $request_time);

        if ($time_diff > $this->timestamp_tolerance) {
            return sprintf(
                'Request timestamp expired. Difference: %.2f seconds, max allowed: %d seconds',
                $time_diff,
                $this->timestamp_tolerance
            );
        }

        if (strlen($nonce) < 16) {
            return 'Nonce must be at least 16 characters';
        }

        return null;
    }

    private function collect_global_headers(): array {
        $headers = [];

        if (function_exists('getallheaders')) {
            $all_headers = getallheaders();
            if (is_array($all_headers)) {
                $headers = $all_headers;
            }
        }

        foreach ($_SERVER as $key => $value) {
            if (strpos($key, 'HTTP_') !== 0 || !is_string($value)) {
                continue;
            }

            $headers[$key] = $value;
        }

        return $headers;
    }

    private function get_header(array $headers, string $name): ?string {
        foreach ($headers as $key => $value) {
            if (!is_string($value)) {
                continue;
            }

            if (strcasecmp($key, $name) === 0) {
                return $value;
            }

            if (strcasecmp($key, 'HTTP_' . strtoupper(str_replace('-', '_', $name))) === 0) {
                return $value;
            }
        }

        return null;
    }

    private function compute_received_content_hash(?string $body, array $files): string {
        if (empty($files)) {
            return hash('sha256', $body ?? '');
        }

        $context = hash_init('sha256');
        $this->append_file_hashes($context, $files);
        return hash_final($context);
    }

    /**
     * Walk a PHP $_FILES-style structure in a deterministic order.
     */
    private function append_file_hashes($context, array $files): void {
        ksort($files);

        foreach ($files as $file_info) {
            if (!is_array($file_info)) {
                continue;
            }

            $tmp_name = $file_info['tmp_name'] ?? null;
            $this->append_tmp_name_hash($context, $tmp_name);
        }
    }

    private function append_tmp_name_hash($context, $tmp_name): void {
        if (is_array($tmp_name)) {
            ksort($tmp_name);
            foreach ($tmp_name as $nested_tmp_name) {
                $this->append_tmp_name_hash($context, $nested_tmp_name);
            }
            return;
        }

        if (!is_string($tmp_name) || $tmp_name === '' || !is_readable($tmp_name)) {
            return;
        }

        if (!@hash_update_file($context, $tmp_name)) {
            throw new RuntimeException('Cannot hash uploaded file.');
        }
    }
}
