<?php

namespace MintyPHP\Service\Audit;

use DateTimeImmutable;
use DateTimeZone;
use MintyPHP\Domain\Taxonomy\SystemAuditOutcome;
use MintyPHP\Http\RequestContext;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Service\Security\RateLimiterService;
use MintyPHP\Service\Settings\SettingsFrontendTelemetryGateway;

class FrontendTelemetryIngestService
{
    private const INGEST_RATE_SCOPE = 'frontend.telemetry.ingest';
    private const SESSION_STATE_KEY = 'frontend_telemetry_state';

    private const INGEST_MAX_HITS = 80;
    private const INGEST_WINDOW_SECONDS = 300;
    private const INGEST_BLOCK_SECONDS = 120;
    private const FAST_DROP_MAX_HITS = 30;
    private const FAST_DROP_WINDOW_SECONDS = 60;

    private const DEDUPE_WINDOW_SECONDS = 300;

    private const MESSAGE_MAX_LENGTH = 280;
    private const FINGERPRINT_MAX_LENGTH = 96;
    private const META_MAX_ENTRIES = 12;
    private const META_VALUE_MAX_LENGTH = 140;

    private const EVENT_WARN_ONCE = 'frontend.warn_once';
    private const EVENT_AJAX_ERROR = 'frontend.ajax_error';

    /** @var array<string, string> */
    private const EVENT_KEY_MAP = [
        self::EVENT_WARN_ONCE => 'warn_once',
        self::EVENT_AJAX_ERROR => 'ajax_error',
    ];

    /** @var array<int, string> */
    private const ALLOWED_META_KEYS = [
        'action',
        'code',
        'component',
        'error_code',
        'feature',
        'http_method',
        'http_status',
        'location',
        'module',
        'page_request_id',
        'request_path',
        'source',
        'status_text',
    ];

    public function __construct(
        private readonly SystemAuditService $systemAuditService,
        private readonly SettingsFrontendTelemetryGateway $settingsFrontendTelemetryGateway,
        private readonly RateLimiterService $rateLimiterService,
        private readonly SessionStoreInterface $sessionStore
    ) {
    }

    /**
     * @param array<string, mixed> $input
     * @return array{accepted:bool, reason:string}
     */
    public function ingest(array $input): array
    {
        if (!$this->settingsFrontendTelemetryGateway->isFrontendTelemetryEnabled()) {
            return ['accepted' => false, 'reason' => 'disabled'];
        }

        $payload = $this->normalizePayload($input);
        if ($payload === null) {
            return ['accepted' => false, 'reason' => 'invalid'];
        }

        if (!$this->isAllowedEvent($payload['event_type'])) {
            return ['accepted' => false, 'reason' => 'event_not_allowed'];
        }

        if (!$this->shouldSample($this->settingsFrontendTelemetryGateway->getFrontendTelemetrySampleRate())) {
            return ['accepted' => false, 'reason' => 'sampled_out'];
        }

        $now = time();
        $sessionState = $this->loadSessionState();
        if ($this->applyBurstLimit($sessionState, $now)) {
            $this->saveSessionState($sessionState);
            return ['accepted' => false, 'reason' => 'burst_limited'];
        }

        $subject = $this->subjectKey();

        $rateDecision = $this->rateLimiterService->hit(
            self::INGEST_RATE_SCOPE,
            $subject,
            self::INGEST_MAX_HITS,
            self::INGEST_WINDOW_SECONDS,
            self::INGEST_BLOCK_SECONDS
        );
        if (!(bool) ($rateDecision['allowed'] ?? true)) {
            $this->saveSessionState($sessionState);
            return ['accepted' => false, 'reason' => 'rate_limited'];
        }

        if ($this->isDuplicateAndTouch($sessionState, $payload['fingerprint_hash'], $now)) {
            $this->saveSessionState($sessionState);
            return ['accepted' => false, 'reason' => 'duplicate'];
        }

        $this->saveSessionState($sessionState);

        $outcome = $payload['severity'] === 'error'
            ? SystemAuditOutcome::Failed->value
            : SystemAuditOutcome::Success->value;

        $recordId = $this->systemAuditService->record($payload['event_type'], $outcome, [
            'target_type' => 'frontend',
            'metadata' => [
                'severity' => $payload['severity'],
                'message' => $payload['message'],
                'fingerprint_hash' => $payload['fingerprint_hash'],
                'occurred_at' => $payload['occurred_at'],
                'meta' => $payload['meta'],
            ],
        ]);

        if ($recordId === null) {
            return ['accepted' => false, 'reason' => 'record_failed'];
        }

        return ['accepted' => true, 'reason' => 'logged'];
    }

    /**
     * @param array<string, mixed> $input
     * @return array{
     *   event_type:string,
     *   severity:string,
     *   message:string,
     *   fingerprint_hash:string,
     *   occurred_at:string,
     *   meta: array<string, mixed>
     * }|null
     */
    private function normalizePayload(array $input): ?array
    {
        $eventType = $this->normalizeEventType((string) ($input['event_type'] ?? ''));
        if ($eventType === '') {
            return null;
        }

        $message = $this->sanitizeText((string) ($input['message'] ?? ''), self::MESSAGE_MAX_LENGTH);
        if ($message === '') {
            return null;
        }

        $meta = $this->normalizeMeta($input['meta'] ?? []);
        $route = trim((string) ($meta['location'] ?? ''));

        $fingerprint = $this->sanitizeFingerprint((string) ($input['fingerprint'] ?? ''));
        if ($fingerprint === '') {
            $fingerprint = $this->sanitizeFingerprint(
                'v1_' . substr(hash('sha256', $eventType . '|' . $route . '|' . $message), 0, 24)
            );
        }
        if ($fingerprint === '') {
            return null;
        }

        return [
            'event_type' => $eventType,
            'severity' => $this->normalizeSeverity((string) ($input['severity'] ?? ''), $eventType),
            'message' => $message,
            'fingerprint_hash' => RequestContext::hashValue($fingerprint),
            'occurred_at' => $this->normalizeOccurredAt((string) ($input['occurred_at'] ?? '')),
            'meta' => $meta,
        ];
    }

    private function normalizeEventType(string $eventType): string
    {
        $eventType = strtolower(trim($eventType));
        if ($eventType === '') {
            return '';
        }

        if (str_starts_with($eventType, 'frontend.')) {
            return isset(self::EVENT_KEY_MAP[$eventType]) ? $eventType : '';
        }

        foreach (self::EVENT_KEY_MAP as $name => $short) {
            if ($eventType === $short) {
                return $name;
            }
        }

        return '';
    }

    private function normalizeSeverity(string $severity, string $eventType): string
    {
        $severity = strtolower(trim($severity));
        if (in_array($severity, ['info', 'warning', 'error'], true)) {
            return $severity;
        }

        return $eventType === self::EVENT_AJAX_ERROR ? 'error' : 'warning';
    }

    private function normalizeOccurredAt(string $occurredAt): string
    {
        $occurredAt = trim($occurredAt);
        if ($occurredAt !== '') {
            try {
                $date = new DateTimeImmutable($occurredAt);
                return $date->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d\TH:i:s\Z');
            } catch (\Throwable) {
                // Fall back to now below.
            }
        }

        return (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format('Y-m-d\TH:i:s\Z');
    }

    private function sanitizeFingerprint(string $value): string
    {
        $value = strtolower(trim($value));
        if ($value === '') {
            return '';
        }

        $value = preg_replace('/[^a-z0-9:_-]/', '', $value) ?? '';
        if ($value === '') {
            return '';
        }

        return substr($value, 0, self::FINGERPRINT_MAX_LENGTH);
    }

    /**
     * @return array<string, mixed>
     */
    private function normalizeMeta(mixed $rawMeta): array
    {
        $source = [];
        if (is_array($rawMeta)) {
            $source = $rawMeta;
        } elseif (is_string($rawMeta) && trim($rawMeta) !== '') {
            $decoded = json_decode($rawMeta, true);
            if (is_array($decoded)) {
                $source = $decoded;
            }
        }

        if ($source === []) {
            return [];
        }

        $meta = [];
        $count = 0;
        foreach ($source as $rawKey => $rawValue) {
            if ($count >= self::META_MAX_ENTRIES) {
                break;
            }

            $key = strtolower(trim((string) $rawKey));
            if ($key === '' || !in_array($key, self::ALLOWED_META_KEYS, true)) {
                continue;
            }

            if ($key === 'location' || $key === 'request_path') {
                $normalizedPath = $this->sanitizePath((string) $rawValue);
                if ($normalizedPath === '') {
                    continue;
                }
                $meta[$key] = $normalizedPath;
                $count++;
                continue;
            }

            if ($key === 'http_status') {
                $status = (int) $rawValue;
                if ($status < 100 || $status > 599) {
                    continue;
                }
                $meta[$key] = $status;
                $count++;
                continue;
            }

            if ($key === 'page_request_id') {
                $requestId = strtolower(trim((string) $rawValue));
                if (preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/', $requestId) !== 1) {
                    continue;
                }
                $meta[$key] = $requestId;
                $count++;
                continue;
            }

            $value = $this->sanitizeText((string) $rawValue, self::META_VALUE_MAX_LENGTH);
            if ($value === '') {
                continue;
            }

            $meta[$key] = $value;
            $count++;
        }

        return $meta;
    }

    private function sanitizePath(string $value): string
    {
        $value = trim($value);
        if ($value === '') {
            return '';
        }

        if (filter_var($value, FILTER_VALIDATE_URL)) {
            $parsedPath = parse_url($value, PHP_URL_PATH);
            $value = is_string($parsedPath) ? $parsedPath : '';
        }

        $value = (string) preg_replace('/[?#].*/', '', $value);
        $value = preg_replace('/\/[0-9]{3,}(?=\/|$)/', '/:id', $value) ?? $value;
        $value = preg_replace(
            '/\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/i',
            ':uuid',
            $value
        ) ?? $value;

        $value = trim($value);
        if ($value === '') {
            return '';
        }
        if (!str_starts_with($value, '/')) {
            $value = '/' . $value;
        }

        return substr($value, 0, 255);
    }

    private function sanitizeText(string $value, int $maxLength): string
    {
        $value = trim($value);
        if ($value === '') {
            return '';
        }

        $value = preg_replace('/\s+/', ' ', $value) ?? $value;
        $value = preg_replace('/([?&][a-z0-9_.-]{1,64}=)[^&#\s]+/i', '$1[REDACTED]', $value) ?? $value;
        $value = preg_replace('/(^|\s)(token|access_token|refresh_token|id_token|authorization|api_key|secret)=([^\s]+)/i', '$1$2=[REDACTED]', $value) ?? $value;
        $value = preg_replace('/[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}/i', '[REDACTED_EMAIL]', $value) ?? $value;
        $value = preg_replace(
            '/\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/i',
            '[UUID]',
            $value
        ) ?? $value;
        $value = preg_replace('/\b[a-f0-9]{24,}\b/i', '[TOKEN]', $value) ?? $value;

        return substr($value, 0, max(1, $maxLength));
    }

    private function isAllowedEvent(string $eventType): bool
    {
        $eventKey = self::EVENT_KEY_MAP[$eventType] ?? null;
        if ($eventKey === null) {
            return false;
        }

        $allowed = $this->settingsFrontendTelemetryGateway->getFrontendTelemetryAllowedEvents();
        return in_array($eventKey, $allowed, true);
    }

    private function shouldSample(float $sampleRate): bool
    {
        if ($sampleRate <= 0.0) {
            return false;
        }
        if ($sampleRate >= 1.0) {
            return true;
        }

        try {
            $max = 10000;
            $threshold = (int) floor($sampleRate * $max);
            return random_int(1, $max) <= max(1, $threshold);
        } catch (\Throwable) {
            $threshold = (int) floor($sampleRate * 10000);
            return mt_rand(1, 10000) <= max(1, $threshold);
        }
    }

    private function subjectKey(): string
    {
        $session = $this->sessionStore->all();
        $userId = (int) ($session['user']['id'] ?? 0);
        if ($userId > 0) {
            return 'user:' . $userId;
        }

        $sessionId = trim(session_id());
        if ($sessionId !== '') {
            return 'session:' . $sessionId;
        }

        $requestContext = RequestContext::context();
        $ip = trim((string) ($requestContext['ip'] ?? ''));
        if ($ip !== '') {
            return 'ip:' . RequestContext::hashValue($ip);
        }

        return 'anonymous';
    }

    /**
     * @return array{dedupe: array<string,int>, burst: array{window_start:int,count:int}}
     */
    private function loadSessionState(): array
    {
        $rawState = $this->sessionStore->get(self::SESSION_STATE_KEY, []);
        if (!is_array($rawState)) {
            $rawState = [];
        }

        $dedupe = is_array($rawState['dedupe'] ?? null) ? $rawState['dedupe'] : [];
        $burst = is_array($rawState['burst'] ?? null) ? $rawState['burst'] : [];

        return [
            'dedupe' => $dedupe,
            'burst' => [
                'window_start' => is_int($burst['window_start'] ?? null) ? (int) $burst['window_start'] : 0,
                'count' => is_int($burst['count'] ?? null) ? max(0, (int) $burst['count']) : 0,
            ],
        ];
    }

    /**
     * @param array{dedupe: array<string,int>, burst: array{window_start:int,count:int}} $state
     */
    private function saveSessionState(array $state): void
    {
        $this->sessionStore->set(self::SESSION_STATE_KEY, $state);
    }

    /**
     * @param array{dedupe: array<string,int>, burst: array{window_start:int,count:int}} $state
     */
    private function applyBurstLimit(array &$state, int $now): bool
    {
        $windowStart = $state['burst']['window_start'];
        $count = max(0, $state['burst']['count']);

        if ($windowStart <= 0 || ($now - $windowStart) >= self::FAST_DROP_WINDOW_SECONDS) {
            $windowStart = $now;
            $count = 0;
        }

        if ($count >= self::FAST_DROP_MAX_HITS) {
            $state['burst'] = [
                'window_start' => $windowStart,
                'count' => $count,
            ];
            return true;
        }

        $state['burst'] = [
            'window_start' => $windowStart,
            'count' => $count + 1,
        ];
        return false;
    }

    /**
     * @param array{dedupe: array<string,int>, burst: array{window_start:int,count:int}} $state
     */
    private function isDuplicateAndTouch(array &$state, string $fingerprintHash, int $now): bool
    {
        $seen = $state['dedupe'];

        $cutoff = $now - self::DEDUPE_WINDOW_SECONDS;
        foreach ($seen as $key => $timestamp) {
            if (!is_string($key)) {
                unset($seen[$key]);
                continue;
            }
            if (!is_int($timestamp) || $timestamp < $cutoff) {
                unset($seen[$key]);
            }
        }

        if (isset($seen[$fingerprintHash]) && (int) $seen[$fingerprintHash] >= $cutoff) {
            $seen[$fingerprintHash] = $now;
            $isDuplicate = true;
        } else {
            $seen[$fingerprintHash] = $now;
            $isDuplicate = false;
        }

        if (count($seen) > 120) {
            arsort($seen, SORT_NUMERIC);
            $seen = array_slice($seen, 0, 120, true);
        }
        $state['dedupe'] = $seen;
        return $isDuplicate;
    }
}
