<?php

namespace CaptureRelay;

use WP_Error;
use WP_REST_Request;

final class Security
{
    public const CLOCK_SKEW_SECONDS = 300;

    public static function randomToken(int $bytes = 32): string
    {
        return rtrim(strtr(base64_encode(random_bytes($bytes)), '+/', '-_'), '=');
    }

    public static function websiteId(): string
    {
        return substr(hash('sha256', self::siteIdentity()), 0, 32);
    }

    public static function siteIdentity(): string
    {
        $configured = (string) get_option('home', home_url('/'));
        $host = strtolower((string) wp_parse_url($configured, PHP_URL_HOST));
        $port = wp_parse_url($configured, PHP_URL_PORT);
        $path = '/' . trim((string) wp_parse_url($configured, PHP_URL_PATH), '/');
        if ($host === '') {
            $host = strtolower((string) wp_parse_url(home_url('/'), PHP_URL_HOST));
        }
        return $host . ($port ? ':' . (int) $port : '') . rtrim($path, '/');
    }

    public static function hashIp(string $ip): string
    {
        return hash_hmac('sha256', $ip, wp_salt('nonce'));
    }

    public static function clientIp(): string
    {
        // REMOTE_ADDR is intentionally preferred over spoofable forwarding headers.
        $remote = sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'] ?? 'unknown'));
        $candidate = (string) apply_filters('capture_relay_client_ip', $remote, $remote);
        return filter_var($candidate, FILTER_VALIDATE_IP) ? $candidate : $remote;
    }

    public static function validateFreshProof(string $principal, int $timestamp, string $nonce): true|WP_Error
    {
        if ($timestamp < (time() - self::CLOCK_SKEW_SECONDS) || $timestamp > (time() + self::CLOCK_SKEW_SECONDS)) {
            return new WP_Error('capture_relay_stale_request', __('The request timestamp is outside the allowed window.', 'capture-relay'), ['status' => 401]);
        }
        if (!preg_match('/^[A-Za-z0-9_-]{16,96}$/', $nonce)) {
            return new WP_Error('capture_relay_invalid_nonce', __('The request nonce is invalid.', 'capture-relay'), ['status' => 400]);
        }

        global $wpdb;
        $inserted = $wpdb->insert(Config::table('nonces'), [
            'principal' => substr($principal, 0, 80),
            'nonce_hash' => hash('sha256', $nonce),
            'created_at' => current_time('mysql', true),
            'expires_at' => gmdate('Y-m-d H:i:s', time() + self::CLOCK_SKEW_SECONDS + 60),
        ], ['%s', '%s', '%s', '%s']);
        if (!$inserted) {
            Log::write('replay_blocked', 'A reused request nonce was rejected.', ['principal' => $principal], 'warning');
            return new WP_Error('capture_relay_replayed_request', __('This request has already been used.', 'capture-relay'), ['status' => 409]);
        }
        return true;
    }

    public static function verifyPendingProof(object $record, array $input): true|WP_Error
    {
        $secret = (string) ($input['request_secret'] ?? '');
        if ($secret === '' || !password_verify($secret, $record->secret_hash)) {
            return new WP_Error('capture_relay_invalid_request_secret', __('The connection request proof is invalid.', 'capture-relay'), ['status' => 401]);
        }
        return self::validateFreshProof('request:' . $record->request_id, (int) ($input['timestamp'] ?? 0), (string) ($input['nonce'] ?? ''));
    }

    public static function authenticate(WP_REST_Request $request, string $expectedBodyHash): object
    {
        $deviceId = sanitize_text_field($request->get_header('x-capturerelay-device-id'));
        $websiteId = sanitize_text_field($request->get_header('x-capturerelay-website-id'));
        $timestamp = (int) $request->get_header('x-capturerelay-timestamp');
        $nonce = sanitize_text_field($request->get_header('x-capturerelay-nonce'));
        $claimedBodyHash = strtolower(sanitize_text_field($request->get_header('x-capturerelay-content-sha256')));
        $authorization = trim((string) $request->get_header('authorization'));

        if (!preg_match('/^CaptureRelay-HMAC\s+([a-f0-9]{64})$/i', $authorization, $matches)) {
            return new WP_Error('capture_relay_missing_signature', __('Device authentication is required.', 'capture-relay'), ['status' => 401]);
        }
        if (!hash_equals($expectedBodyHash, $claimedBodyHash)) {
            return new WP_Error('capture_relay_content_mismatch', __('The signed request content does not match.', 'capture-relay'), ['status' => 401]);
        }

        global $wpdb;
        $device = $wpdb->get_row($wpdb->prepare(
            'SELECT * FROM %i WHERE device_id = %s LIMIT 1',
            Config::table('devices'),
            $deviceId
        ));
        if (!$device || $device->status !== 'approved') {
            return new WP_Error('capture_relay_device_not_approved', __('This device is not approved.', 'capture-relay'), ['status' => 403]);
        }
        if (!hash_equals((string) $device->website_id, $websiteId)) {
            return new WP_Error('capture_relay_wrong_website', __('The device credential belongs to another website.', 'capture-relay'), ['status' => 403]);
        }

        try {
            $credential = CredentialVault::open((string) $device->credential_cipher);
        } catch (\RuntimeException $exception) {
            Log::write('credential_open_failed', 'A protected device credential could not be opened.', ['device_id' => $deviceId], 'error');
            return new WP_Error('capture_relay_server_credential_error', __('The website could not validate this device.', 'capture-relay'), ['status' => 500]);
        }

        $canonical = strtoupper($request->get_method()) . "\n"
            . $request->get_route() . "\n"
            . $timestamp . "\n"
            . $nonce . "\n"
            . $expectedBodyHash . "\n"
            . $deviceId . "\n"
            . $websiteId;
        $expectedSignature = hash_hmac('sha256', $canonical, $credential);
        if (!hash_equals($expectedSignature, strtolower($matches[1]))) {
            Log::write('invalid_signature', 'A protected request had an invalid signature.', ['device_id' => $deviceId], 'warning');
            return new WP_Error('capture_relay_invalid_signature', __('Device authentication failed.', 'capture-relay'), ['status' => 401]);
        }

        $fresh = self::validateFreshProof('device:' . $deviceId, $timestamp, $nonce);
        if (is_wp_error($fresh)) {
            return $fresh;
        }

        $wpdb->update(Config::table('devices'), ['last_request_at' => current_time('mysql', true)], ['device_id' => $deviceId], ['%s'], ['%s']);
        unset($credential);
        return $device;
    }

    public static function bodyHash(array $body): string
    {
        return hash('sha256', wp_json_encode(self::sortRecursive($body), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
    }

    public static function emptyBodyHash(): string
    {
        return hash('sha256', '');
    }

    private static function sortRecursive(array $value): array
    {
        foreach ($value as $key => $item) {
            if (is_array($item)) {
                $value[$key] = self::sortRecursive($item);
            }
        }
        if (!array_is_list($value)) {
            ksort($value, SORT_STRING);
        }
        return $value;
    }
}
