<?php
declare(strict_types=1);

namespace SafeTun\Backend;

use SafeTun\Backend\Config\ConfigRepository;
use SafeTun\Backend\Crypto\Base64Url;
use SafeTun\Backend\Exception\PublicApiException;
use SafeTun\Backend\Http\Request;
use SafeTun\Backend\Http\Response;
use SafeTun\Backend\Net\ClientIpResolver;
use SafeTun\Backend\Service\AuthorizationService;
use SafeTun\Backend\Service\Ids;
use SafeTun\Backend\Service\ReleaseService;
use SafeTun\Backend\Storage\AuditLogger;
use SafeTun\Backend\Storage\RateLimiter;

final class Application
{
    public function __construct(
        private readonly string $configPath,
        private readonly ?string $documentRoot = null,
    ) {
    }

    public function handle(Request $request): Response
    {
        $requestId = Ids::requestId();
        $clientIp = null;
        $audit = null;
        try {
            $validated = (new ConfigRepository($this->configPath, $this->documentRoot))->load();
            $config = $validated->values;
            date_default_timezone_set($config['app']['timezone']);
            $audit = new AuditLogger($config['runtime']['audit_log_dir']);

            if ($request->query !== '') {
                throw new PublicApiException('INVALID_REQUEST', 400, 'Query parameters are not accepted.');
            }
            if ($config['api']['require_https'] && !$request->isHttps($config['authorization'])) {
                throw new PublicApiException('HTTPS_REQUIRED', 400, 'HTTPS is required.');
            }

            if (preg_match('~^/api/v1/releases/(stable|beta)/manifest$~D', $request->path, $matches)) {
                if ($request->method !== 'GET') {
                    throw new PublicApiException('METHOD_NOT_ALLOWED', 405, 'Release manifest requires GET.');
                }
                if ($request->body !== '') {
                    throw new PublicApiException('INVALID_REQUEST', 400, 'Release GET body must be empty.');
                }
                $clientIp = (new ClientIpResolver())->resolve(
                    $config['authorization'],
                    $request->server['remote_addr'] ?? '',
                    $request->headers,
                );
                $this->rateLimit(
                    $config,
                    'release:' . $this->ipHash($clientIp),
                    $config['api']['release_rate_limit'],
                );
                $manifest = (new ReleaseService($validated))->manifest($matches[1]);
                $audit->write([
                    'event' => 'release_manifest', 'outcome' => 'allowed', 'request_id' => $requestId,
                    'ip_hash' => $this->ipHash($clientIp), 'route' => $request->path,
                ]);
                return $this->response(200, $manifest, $requestId);
            }

            $knownPaths = [
                '/api/v1/auth/challenge', '/api/v1/auth/authorize', '/api/v1/auth/renew',
                '/api/v1/pairs/credential',
            ];
            if (!in_array($request->path, $knownPaths, true)) {
                throw new PublicApiException('NOT_FOUND', 404, 'Unknown API route.');
            }
            if ($request->method !== 'POST') {
                throw new PublicApiException('METHOD_NOT_ALLOWED', 405, 'Route requires POST.');
            }

            $body = $request->jsonObject($config['api']['max_body_bytes']);
            if (isset($body['request_id']) && is_string($body['request_id'])
                && \SafeTun\Backend\Crypto\HttpProof::isRequestId($body['request_id'])) {
                $requestId = $body['request_id'];
            }
            $clientIp = (new ClientIpResolver())->resolve(
                $config['authorization'],
                $request->server['remote_addr'] ?? '',
                $request->headers,
            );
            $service = new AuthorizationService($validated);
            $rateKey = $this->ipHash($clientIp);

            if ($request->path === '/api/v1/auth/challenge') {
                if ($body !== []) {
                    throw new PublicApiException('INVALID_REQUEST', 400, 'Challenge body must be empty.');
                }
                $this->rateLimit($config, 'challenge:' . $rateKey, $config['api']['challenge_rate_limit']);
                $result = $service->challenge($clientIp);
                $requestId = $result['request_id'];
                $audit->write([
                    'event' => 'auth_challenge', 'outcome' => 'allowed', 'request_id' => $requestId,
                    'ip_hash' => $rateKey, 'route' => $request->path,
                ]);
                return $this->response(200, $result, $requestId);
            }

            $this->rateLimit($config, 'protected:' . $rateKey, $config['api']['protected_rate_limit']);
            if ($request->path === '/api/v1/auth/authorize') {
                $result = $service->authorize($body, $request->path, $clientIp);
                $event = 'auth_authorize';
            } elseif ($request->path === '/api/v1/auth/renew') {
                $result = $service->renew($body, $request->path, $clientIp);
                $event = 'auth_renew';
            } else {
                $result = $service->pairCredential($body, $request->path, $clientIp);
                $event = 'pair_credential';
            }
            $nodeHash = null;
            if (isset($body['payload']['node_public_key']) && is_string($body['payload']['node_public_key'])) {
                try {
                    $nodeHash = hash('sha256', Base64Url::decode($body['payload']['node_public_key'], 32));
                } catch (\InvalidArgumentException) {
                    $nodeHash = null;
                }
            }
            $audit->write([
                'event' => $event, 'outcome' => 'allowed', 'request_id' => $requestId,
                'ip_hash' => $rateKey, 'node_key_hash' => $nodeHash, 'route' => $request->path,
            ]);
            return $this->response(200, $result, $requestId);
        } catch (PublicApiException $exception) {
            $this->auditFailure($audit, $request, $requestId, $clientIp, $exception->errorCode);
            return $this->error($exception->httpStatus, $exception->errorCode, $requestId);
        } catch (\Throwable $exception) {
            $this->auditFailure($audit, $request, $requestId, $clientIp, 'SERVICE_UNAVAILABLE');
            return $this->error(503, 'SERVICE_UNAVAILABLE', $requestId);
        }
    }

    /** @param array<string,mixed> $config */
    private function rateLimit(array $config, string $bucket, int $limit): void
    {
        try {
            $allowed = (new RateLimiter($config['runtime']['rate_limit_dir']))->allow(
                $bucket,
                $limit,
                $config['api']['rate_limit_window_seconds'],
            );
        } catch (\Throwable $exception) {
            throw new PublicApiException('SERVICE_UNAVAILABLE', 503, 'Rate-limit state is unavailable.');
        }
        if (!$allowed) {
            throw new PublicApiException('RATE_LIMITED', 429, 'Request rate exceeded.');
        }
    }

    private function response(int $status, array $body, string $requestId): Response
    {
        return new Response($status, $body, ['X-Request-ID' => $requestId]);
    }

    private function error(int $status, string $code, string $requestId): Response
    {
        return $this->response($status, [
            'error' => ['code' => $code, 'request_id' => $requestId],
            'request_id' => $requestId,
        ], $requestId);
    }

    private function ipHash(string $ip): string
    {
        return hash('sha256', "SAFETUN-AUDIT-IP-v1\0" . $ip);
    }

    private function auditFailure(
        ?AuditLogger $audit,
        Request $request,
        string $requestId,
        ?string $clientIp,
        string $errorCode,
    ): void {
        if ($audit === null) {
            return;
        }
        try {
            $audit->write([
                'event' => 'api_request', 'outcome' => 'denied', 'request_id' => $requestId,
                'ip_hash' => $clientIp === null ? null : $this->ipHash($clientIp),
                'error_code' => $errorCode, 'route' => $request->path,
            ]);
        } catch (\Throwable) {
            // Never disclose a secondary audit failure in the public error response.
        }
    }
}
