<?php

declare(strict_types=1);

final class MyronVerificationResult
{
    /**
     * @param list<string> $reasons
     * @param array<string, mixed> $metadata
     */
    public function __construct(
        public readonly bool $allowed,
        public readonly int $riskScore,
        public readonly array $reasons = [],
        public readonly array $metadata = [],
    ) {
    }

    /**
     * @return array<string, mixed>
     */
    public function toArray(): array
    {
        return [
            'allowed' => $this->allowed,
            'riskScore' => $this->riskScore,
            'reasons' => $this->reasons,
            'metadata' => $this->metadata,
        ];
    }
}

final class MyronVerificationSettingsRepository
{
    private const DEFAULTS = [
        'verification_enabled' => true,
        'verification_provider' => 'native',
        'verification_risk_threshold' => 50,
        'verification_challenge_enabled' => true,
        'verification_rate_limit_per_hour' => 10,
        'verification_turnstile_site_key' => '',
        'verification_turnstile_secret_key' => '',
        'verification_fail_open' => true,
    ];

    public function __construct(private readonly PDO $pdo)
    {
    }

    /**
     * @return array<string, mixed>
     */
    public function getSettings(): array
    {
        $statement = $this->pdo->query('SELECT * FROM site_settings WHERE id = 1 LIMIT 1');
        $row = $statement ? $statement->fetch(PDO::FETCH_ASSOC) : false;
        if (!is_array($row)) {
            return self::DEFAULTS;
        }

        return [
            'verification_enabled' => (int) ($row['verification_enabled'] ?? 1) === 1,
            'verification_provider' => $this->normalizeProvider((string) ($row['verification_provider'] ?? 'native')),
            'verification_risk_threshold' => $this->boundInt((int) ($row['verification_risk_threshold'] ?? 50), 1, 100, 50),
            'verification_challenge_enabled' => (int) ($row['verification_challenge_enabled'] ?? 1) === 1,
            'verification_rate_limit_per_hour' => $this->boundInt((int) ($row['verification_rate_limit_per_hour'] ?? 10), 1, 200, 10),
            'verification_turnstile_site_key' => trim((string) ($row['verification_turnstile_site_key'] ?? '')),
            'verification_turnstile_secret_key' => trim((string) ($row['verification_turnstile_secret_key'] ?? '')),
            'verification_fail_open' => (int) ($row['verification_fail_open'] ?? 1) === 1,
        ];
    }

    /**
     * @param array<string, mixed> $input
     * @return array<string, mixed>
     */
    public function saveSettings(array $input): array
    {
        $settings = $this->normalizeSettings($input);
        $this->pdo->prepare(
            'UPDATE site_settings
             SET verification_enabled = :enabled,
                 verification_provider = :provider,
                 verification_risk_threshold = :risk_threshold,
                 verification_challenge_enabled = :challenge_enabled,
                 verification_rate_limit_per_hour = :rate_limit,
                 verification_turnstile_site_key = :turnstile_site_key,
                 verification_turnstile_secret_key = :turnstile_secret_key,
                 verification_fail_open = :fail_open,
                 updated_at = :updated_at
             WHERE id = 1'
        )->execute([
            ':enabled' => $settings['verification_enabled'] ? 1 : 0,
            ':provider' => $settings['verification_provider'],
            ':risk_threshold' => $settings['verification_risk_threshold'],
            ':challenge_enabled' => $settings['verification_challenge_enabled'] ? 1 : 0,
            ':rate_limit' => $settings['verification_rate_limit_per_hour'],
            ':turnstile_site_key' => $settings['verification_turnstile_site_key'],
            ':turnstile_secret_key' => $settings['verification_turnstile_secret_key'],
            ':fail_open' => $settings['verification_fail_open'] ? 1 : 0,
            ':updated_at' => myronNow(),
        ]);

        return $this->getSettings();
    }

    /**
     * @param array<string, mixed> $input
     * @return array<string, mixed>
     */
    private function normalizeSettings(array $input): array
    {
        $current = $this->getSettings();
        $merged = array_merge($current, $input);
        $provider = $this->normalizeProvider((string) ($merged['verification_provider'] ?? 'native'));

        return [
            'verification_enabled' => !empty($merged['verification_enabled']),
            'verification_provider' => $provider,
            'verification_risk_threshold' => $this->boundInt((int) ($merged['verification_risk_threshold'] ?? 50), 1, 100, 50),
            'verification_challenge_enabled' => !empty($merged['verification_challenge_enabled']),
            'verification_rate_limit_per_hour' => $this->boundInt((int) ($merged['verification_rate_limit_per_hour'] ?? 10), 1, 200, 10),
            'verification_turnstile_site_key' => substr(trim((string) ($merged['verification_turnstile_site_key'] ?? '')), 0, 255),
            'verification_turnstile_secret_key' => substr(trim((string) ($merged['verification_turnstile_secret_key'] ?? '')), 0, 255),
            'verification_fail_open' => !empty($merged['verification_fail_open']),
        ];
    }

    private function normalizeProvider(string $provider): string
    {
        $provider = strtolower(trim($provider));
        return in_array($provider, ['native', 'turnstile', 'recaptcha', 'hcaptcha'], true) ? $provider : 'native';
    }

    private function boundInt(int $value, int $min, int $max, int $default): int
    {
        if ($value < $min || $value > $max) {
            return $default;
        }
        return $value;
    }
}

final class MyronVerificationLogRepository
{
    public function __construct(private readonly PDO $pdo)
    {
    }

    /**
     * @param list<string> $reasons
     */
    public function record(string $formKey, MyronVerificationResult $result, string $provider, string $ipAddress, string $userAgent): void
    {
        try {
            $this->pdo->prepare(
                'INSERT INTO verification_logs (
                    id, form_key, outcome, risk_score, provider, reasons_json,
                    ip_hash, user_agent_hash, created_at
                 ) VALUES (
                    :id, :form_key, :outcome, :risk_score, :provider, :reasons_json,
                    :ip_hash, :user_agent_hash, :created_at
                 )'
            )->execute([
                ':id' => myronGenerateId('vlog'),
                ':form_key' => substr(trim($formKey), 0, 120),
                ':outcome' => $result->allowed ? 'allowed' : 'blocked',
                ':risk_score' => $result->riskScore,
                ':provider' => substr(trim($provider), 0, 40),
                ':reasons_json' => json_encode($result->reasons, JSON_UNESCAPED_SLASHES),
                ':ip_hash' => self::hashIdentifier($ipAddress),
                ':user_agent_hash' => self::hashIdentifier($userAgent),
                ':created_at' => myronNow(),
            ]);
        } catch (Throwable) {
        }
    }

    /**
     * @return list<array<string, mixed>>
     */
    public function recent(int $limit = 25): array
    {
        $statement = $this->pdo->prepare(
            'SELECT id, form_key, outcome, risk_score, provider, reasons_json, created_at
             FROM verification_logs
             ORDER BY created_at DESC
             LIMIT :limit'
        );
        $statement->bindValue(':limit', max(1, min(100, $limit)), PDO::PARAM_INT);
        $statement->execute();
        $rows = $statement->fetchAll(PDO::FETCH_ASSOC) ?: [];
        return array_map(static function (array $row): array {
            $row['risk_score'] = (int) ($row['risk_score'] ?? 0);
            $decoded = json_decode((string) ($row['reasons_json'] ?? '[]'), true);
            $row['reasons'] = is_array($decoded) ? array_values(array_map('strval', $decoded)) : [];
            unset($row['reasons_json']);
            return $row;
        }, $rows);
    }

    public function purgeOlderThanDays(int $days): int
    {
        $cutoff = (new DateTimeImmutable('now', new DateTimeZone('UTC')))
            ->modify('-' . max(1, $days) . ' days')
            ->format('Y-m-d H:i:s');
        $statement = $this->pdo->prepare('DELETE FROM verification_logs WHERE created_at < :cutoff');
        $statement->execute([':cutoff' => $cutoff]);
        return $statement->rowCount();
    }

    public static function hashIdentifier(string $value): string
    {
        $secret = trim((string) (getenv('MYRON_APP_KEY') ?: 'myroncms-local'));
        return hash_hmac('sha256', trim($value), $secret);
    }
}

final class MyronVerificationRateLimiter
{
    public function __construct(private readonly string $storageDir)
    {
    }

    public function recordAndCheck(string $formKey, string $ipAddress, int $limit, int $windowSeconds = 3600): bool
    {
        if (!$this->ensureStorage()) {
            return true;
        }
        $key = preg_replace('/[^A-Za-z0-9_-]+/', '-', $formKey) ?: 'form';
        $hash = substr(MyronVerificationLogRepository::hashIdentifier($ipAddress), 0, 32);
        $path = $this->storageDir . '/rate-' . $key . '-' . $hash . '.json';
        $now = time();
        $events = [];
        if (is_file($path)) {
            $decoded = json_decode((string) file_get_contents($path), true);
            $events = is_array($decoded) ? array_values(array_map('intval', $decoded)) : [];
        }
        $events = array_values(array_filter($events, static fn (int $ts): bool => $ts >= $now - $windowSeconds));
        $events[] = $now;
        file_put_contents($path, json_encode($events, JSON_UNESCAPED_SLASHES), LOCK_EX);
        return count($events) <= max(1, $limit);
    }

    private function ensureStorage(): bool
    {
        if (is_dir($this->storageDir)) {
            return is_writable($this->storageDir);
        }
        return @mkdir($this->storageDir, 0775, true) || is_dir($this->storageDir);
    }
}

final class MyronVerificationChallengeStore
{
    public function __construct(private readonly string $storageDir)
    {
    }

    /**
     * @return array<string, mixed>
     */
    public function issue(string $formKey): array
    {
        $id = myronGenerateId('vch');
        $question = 'Which option names the color of a clear daytime sky?';
        $answer = 'blue';
        $options = ['blue' => 'Blue', 'green' => 'Green', 'orange' => 'Orange'];
        $expiresAt = time() + 1800;
        $signature = $this->sign($id, $formKey, $answer, $expiresAt);
        $payload = [
            'id' => $id,
            'form_key' => $formKey,
            'answer' => $answer,
            'expires_at' => $expiresAt,
            'used_at' => null,
            'signature' => $signature,
        ];
        if ($this->ensureStorage()) {
            file_put_contents($this->path($id), json_encode($payload, JSON_UNESCAPED_SLASHES), LOCK_EX);
        }

        return [
            'id' => $id,
            'question' => $question,
            'options' => $options,
            'expiresAt' => $expiresAt,
            'signature' => $signature,
        ];
    }

    public function isAvailable(): bool
    {
        return $this->ensureStorage();
    }

    public function verify(string $formKey, string $id, string $answer, string $signature): bool
    {
        if ($id === '' || $answer === '' || $signature === '') {
            return false;
        }
        $path = $this->path($id);
        $decoded = is_file($path) ? json_decode((string) file_get_contents($path), true) : null;
        if (!is_array($decoded)) {
            return false;
        }
        $expectedAnswer = (string) ($decoded['answer'] ?? '');
        $expiresAt = (int) ($decoded['expires_at'] ?? 0);
        $expectedSignature = $this->sign($id, $formKey, $expectedAnswer, $expiresAt);
        if (!hash_equals($expectedSignature, $signature) || !hash_equals((string) ($decoded['signature'] ?? ''), $signature)) {
            return false;
        }
        if ((string) ($decoded['form_key'] ?? '') !== $formKey || $expiresAt < time() || !empty($decoded['used_at'])) {
            return false;
        }
        if (!hash_equals($expectedAnswer, strtolower(trim($answer)))) {
            return false;
        }
        $decoded['used_at'] = time();
        file_put_contents($path, json_encode($decoded, JSON_UNESCAPED_SLASHES), LOCK_EX);
        return true;
    }

    public function cleanup(): int
    {
        if (!is_dir($this->storageDir)) {
            return 0;
        }
        $count = 0;
        foreach (glob($this->storageDir . '/challenge-*.json') ?: [] as $path) {
            $decoded = json_decode((string) file_get_contents($path), true);
            $expiresAt = is_array($decoded) ? (int) ($decoded['expires_at'] ?? 0) : 0;
            if ($expiresAt < time() - 3600) {
                @unlink($path);
                $count++;
            }
        }
        return $count;
    }

    private function sign(string $id, string $formKey, string $answer, int $expiresAt): string
    {
        return hash_hmac('sha256', implode('|', [$id, $formKey, $answer, (string) $expiresAt]), myronVerificationSecret());
    }

    private function path(string $id): string
    {
        return $this->storageDir . '/challenge-' . preg_replace('/[^A-Za-z0-9_-]+/', '', $id) . '.json';
    }

    private function ensureStorage(): bool
    {
        if (is_dir($this->storageDir)) {
            return is_writable($this->storageDir);
        }
        return @mkdir($this->storageDir, 0775, true) || is_dir($this->storageDir);
    }
}

interface MyronVerificationProvider
{
    /**
     * @param array<string, mixed> $input
     * @param array<string, mixed> $settings
     */
    public function verify(array $input, array $settings, string $ipAddress): MyronVerificationResult;
}

final class MyronNativeVerificationProvider implements MyronVerificationProvider
{
    public function __construct(
        private readonly MyronVerificationRateLimiter $rateLimiter,
        private readonly MyronVerificationChallengeStore $challengeStore,
    ) {
    }

    public function verify(array $input, array $settings, string $ipAddress): MyronVerificationResult
    {
        $formKey = substr(trim((string) ($input['myron_verification_form'] ?? 'default')), 0, 120) ?: 'default';
        $score = 0;
        $reasons = [];

        if (!myronVerificationSecretAvailable()) {
            return !empty($settings['verification_fail_open'])
                ? new MyronVerificationResult(true, 0, ['app-key-missing-fail-open'])
                : new MyronVerificationResult(false, 100, ['app-key-missing']);
        }

        $limit = (int) ($settings['verification_rate_limit_per_hour'] ?? 10);
        if (!$this->rateLimiter->recordAndCheck($formKey, $ipAddress, $limit)) {
            $score += 70;
            $reasons[] = 'rate-limit-exceeded';
        }

        if (trim((string) ($input['myron_company'] ?? '')) !== '') {
            $score += 90;
            $reasons[] = 'honeypot-filled';
        }

        $issuedAt = (int) ($input['myron_verification_issued_at'] ?? 0);
        $nonce = trim((string) ($input['myron_verification_nonce'] ?? ''));
        $signature = trim((string) ($input['myron_verification_signature'] ?? ''));
        if (!$this->validPassiveSignature($formKey, $issuedAt, $nonce, $signature)) {
            $score += 80;
            $reasons[] = 'passive-token-invalid';
        } else {
            $age = time() - $issuedAt;
            if ($age < 2) {
                $score += 25;
                $reasons[] = 'submitted-too-fast';
            }
            if ($age > 7200) {
                $score += 20;
                $reasons[] = 'passive-token-expired';
            }
        }

        $jsToken = trim((string) ($input['myron_verification_js'] ?? ''));
        if ($jsToken !== $nonce . ':active') {
            $score += 10;
            $reasons[] = 'js-token-missing';
        }

        if ((int) ($input['myron_verification_interactions'] ?? 0) < 1) {
            $score += 10;
            $reasons[] = 'interaction-signal-missing';
        }

        if (!empty($settings['verification_challenge_enabled'])) {
            if (!$this->challengeStore->isAvailable() && !empty($settings['verification_fail_open'])) {
                $reasons[] = 'challenge-storage-unavailable-fail-open';
            } else {
                $challengeOk = $this->challengeStore->verify(
                    $formKey,
                    trim((string) ($input['myron_challenge_id'] ?? '')),
                    trim((string) ($input['myron_challenge_answer'] ?? '')),
                    trim((string) ($input['myron_challenge_signature'] ?? ''))
                );
                if (!$challengeOk) {
                    $score += 35;
                    $reasons[] = 'challenge-invalid';
                }
            }
        }

        $threshold = (int) ($settings['verification_risk_threshold'] ?? 50);
        return new MyronVerificationResult($score < $threshold, min(100, $score), $reasons);
    }

    private function validPassiveSignature(string $formKey, int $issuedAt, string $nonce, string $signature): bool
    {
        if ($issuedAt <= 0 || $nonce === '' || $signature === '') {
            return false;
        }
        $expected = myronVerificationSignPassive($formKey, $issuedAt, $nonce);
        return hash_equals($expected, $signature);
    }
}

final class MyronTurnstileVerificationProvider implements MyronVerificationProvider
{
    public function verify(array $input, array $settings, string $ipAddress): MyronVerificationResult
    {
        $secret = trim((string) ($settings['verification_turnstile_secret_key'] ?? ''));
        $response = trim((string) ($input['cf-turnstile-response'] ?? ''));
        if ($secret === '' || $response === '') {
            return new MyronVerificationResult(false, 80, ['turnstile-token-missing']);
        }

        $payload = http_build_query([
            'secret' => $secret,
            'response' => $response,
            'remoteip' => $ipAddress,
        ]);
        $context = stream_context_create([
            'http' => [
                'method' => 'POST',
                'header' => "Content-Type: application/x-www-form-urlencoded\r\n",
                'content' => $payload,
                'timeout' => 3,
            ],
        ]);
        $raw = @file_get_contents('https://challenges.cloudflare.com/turnstile/v0/siteverify', false, $context);
        $decoded = is_string($raw) ? json_decode($raw, true) : null;
        if (is_array($decoded) && !empty($decoded['success'])) {
            return new MyronVerificationResult(true, 0, []);
        }
        return new MyronVerificationResult(false, 80, ['turnstile-verification-failed']);
    }
}

final class MyronStubVerificationProvider implements MyronVerificationProvider
{
    public function __construct(private readonly string $provider)
    {
    }

    public function verify(array $input, array $settings, string $ipAddress): MyronVerificationResult
    {
        return new MyronVerificationResult(false, 100, [$this->provider . '-provider-not-configured']);
    }
}

final class MyronVerificationEngine
{
    public function __construct(
        private readonly MyronVerificationSettingsRepository $settings,
        private readonly MyronNativeVerificationProvider $nativeProvider,
        private readonly MyronTurnstileVerificationProvider $turnstileProvider,
        private readonly MyronVerificationLogRepository $logs,
    ) {
    }

    /**
     * @param array<string, mixed> $input
     */
    public function verify(string $formKey, array $input, string $ipAddress = '', string $userAgent = ''): MyronVerificationResult
    {
        $settings = $this->settings->getSettings();
        if (empty($settings['verification_enabled'])) {
            $result = new MyronVerificationResult(true, 0, ['verification-disabled']);
            $this->logs->record($formKey, $result, 'disabled', $ipAddress, $userAgent);
            return $result;
        }

        $input['myron_verification_form'] = $formKey;
        $provider = (string) ($settings['verification_provider'] ?? 'native');
        $result = match ($provider) {
            'turnstile' => $this->verifyWithTurnstile($input, $settings, $ipAddress),
            'recaptcha' => (new MyronStubVerificationProvider('recaptcha'))->verify($input, $settings, $ipAddress),
            'hcaptcha' => (new MyronStubVerificationProvider('hcaptcha'))->verify($input, $settings, $ipAddress),
            default => $this->nativeProvider->verify($input, $settings, $ipAddress),
        };
        $this->logs->record($formKey, $result, $provider, $ipAddress, $userAgent);
        return $result;
    }

    /**
     * @param array<string, mixed> $input
     * @param array<string, mixed> $settings
     */
    private function verifyWithTurnstile(array $input, array $settings, string $ipAddress): MyronVerificationResult
    {
        $provider = $this->turnstileProvider->verify($input, $settings, $ipAddress);
        if (!$provider->allowed) {
            return $provider;
        }
        return $this->nativeProvider->verify($input, array_merge($settings, ['verification_challenge_enabled' => false]), $ipAddress);
    }
}
