<?php

namespace App\Services;

use App\Master\Models\ApiToken;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Str;
use Mtc\MercuryDataModels\ApiUsage;
use Mtc\MercuryDataModels\ApiUsageEndpoint;
use Throwable;

/**
 * Buffers API request stats in Redis and flushes them to the api_usage table on a
 * schedule, instead of running an updateOrCreate (+ follow-up update) against the
 * same hot hourly row on every request. Each request is a cheap Redis read + a
 * pipelined batch of writes; the scheduled flush writes one row per active bucket
 * per minute.
 *
 * Redis here is a non-persistent cache, so a Redis restart loses at most one flush
 * window of stats — an accepted trade-off for usage analytics.
 */
class ApiUsageTracker
{
    public function record(
        ?string $tenantId,
        ?string $uri,
        ?array $input,
        ?string $method,
        ?int $statusCode,
        ?float $requestLength,
        ?ApiToken $apiToken = null
    ): void {
        try {
            $length = (float) ($requestLength ?? 0);
            $identity = [
                'tenant_id' => $tenantId,
                'endpoint' => Str::limit((string) $uri, 191, ''),
                'method' => $method,
                'time_window' => now()->format('Y-m-d H'),
                'api_user_id' => $apiToken?->api_user_id,
                'api_token_id' => $apiToken?->type === 'persistent' ? $apiToken?->id : null,
            ];
            $hash = sha1((string) json_encode($identity));
            $prefix = $this->prefix();
            $bucket = "{$prefix}:bucket:{$hash}";
            $ttl = $this->ttl();

            // Read the bucket once, then apply all writes in a single pipeline.
            // hits/sum are blind increments; min/max are compared in PHP (a concurrent
            // request could nudge them slightly — benign for analytics); identity is
            // written only when the bucket is first created.
            $current = Redis::hgetall($bucket);

            Redis::pipeline(function ($pipe) use ($bucket, $prefix, $hash, $length, $identity, $current, $ttl) {
                $pipe->hincrby($bucket, 'hits', 1);
                $pipe->hincrbyfloat($bucket, 'sum', $length);

                if (!isset($current['min']) || (float) $current['min'] > $length) {
                    $pipe->hset($bucket, 'min', $length);
                }
                if (!isset($current['max']) || (float) $current['max'] < $length) {
                    $pipe->hset($bucket, 'max', $length);
                }
                if ($length >= 3) {
                    $pipe->hincrby($bucket, 'slow_3s', 1);
                }
                if ($length >= 10) {
                    $pipe->hincrby($bucket, 'slow_10s', 1);
                }

                if (empty($current)) {
                    foreach ($identity as $field => $value) {
                        $pipe->hset($bucket, $field, (string) ($value ?? ''));
                    }
                }

                $pipe->expire($bucket, $ttl);
                // Register the bucket last so a flush that drains it mid-pipeline still
                // re-lists it for the next pass rather than dropping the data.
                $pipe->sadd("{$prefix}:buckets", $hash);
            });

            $this->recordSnapshotCandidate($identity, $input, $method, $length);
        } catch (Throwable $exception) {
            // Usage tracking must never break the actual API request.
            Log::warning('ApiUsageTracker::record failed', ['error' => $exception->getMessage()]);
        }
    }

    /**
     * Drain every buffered bucket into the api_usage table, one row per bucket.
     */
    public function flush(): void
    {
        $prefix = $this->prefix();
        $token = uniqid('', true);

        foreach (Redis::smembers("{$prefix}:buckets") as $hash) {
            Redis::srem("{$prefix}:buckets", $hash);

            $data = [];
            $tmp = "{$prefix}:flushing:{$hash}:{$token}";
            try {
                Redis::rename("{$prefix}:bucket:{$hash}", $tmp);
                $data = Redis::hgetall($tmp);
                Redis::del($tmp);
            } catch (Throwable) {
                // Bucket already gone (drained/expired) — nothing to merge.
            }

            if (!empty($data['hits'])) {
                $this->mergeBucket($data);
            }
        }

        $this->flushSnapshots($token);
    }

    private function mergeBucket(array $data): void
    {
        $identity = $this->normalizeIdentity($data);
        $batchHits = (int) ($data['hits'] ?? 0);
        if ($batchHits <= 0) {
            return;
        }

        $batchSum = (float) ($data['sum'] ?? 0);
        $batchMin = isset($data['min']) ? (float) $data['min'] : null;
        $batchMax = isset($data['max']) ? (float) $data['max'] : null;

        $usage = ApiUsage::query()->firstOrNew($identity);
        $oldHits = (int) ($usage->hits ?? 0);
        $oldAvg = (float) ($usage->avg ?? 0);
        $newHits = $oldHits + $batchHits;

        $usage->hits = $newHits;
        $usage->avg = $newHits > 0 ? (($oldAvg * $oldHits) + $batchSum) / $newHits : 0;
        $usage->min = $oldHits > 0 && $usage->min !== null && $batchMin !== null
            ? min((float) $usage->min, $batchMin)
            : ($batchMin ?? $usage->min);
        $usage->max = max((float) ($usage->max ?? 0), $batchMax ?? 0);
        $usage->slow_3s = (int) ($usage->slow_3s ?? 0) + (int) ($data['slow_3s'] ?? 0);
        $usage->slow_10s = (int) ($usage->slow_10s ?? 0) + (int) ($data['slow_10s'] ?? 0);
        $usage->save();
    }

    private function recordSnapshotCandidate(array $identity, ?array $input, ?string $method, float $length): void
    {
        if ($method !== 'POST' || empty($input) || $length <= (float) config('app.slow_request_time')) {
            return;
        }

        $key = $this->prefix() . ':snapshots';
        Redis::rpush($key, (string) json_encode([
            'identity' => $identity,
            'input' => $input,
            'request_time' => $length,
        ]));
        // Bound the buffer so a burst of slow requests can't grow it without limit.
        Redis::ltrim($key, -200, -1);
    }

    private function flushSnapshots(string $token): void
    {
        $tmp = $this->prefix() . ':snapshots:flushing:' . $token;
        $items = [];
        try {
            Redis::rename($this->prefix() . ':snapshots', $tmp);
            $items = Redis::lrange($tmp, 0, -1);
            Redis::del($tmp);
        } catch (Throwable) {
            return;
        }

        foreach ($items as $json) {
            $payload = json_decode((string) $json, true);
            if (!is_array($payload)) {
                continue;
            }

            $usage = ApiUsage::query()->where($this->normalizeIdentity($payload['identity'] ?? []))->first();
            if ($usage) {
                $this->addSnapshot($usage, (array) ($payload['input'] ?? []), (float) ($payload['request_time'] ?? 0));
            }
        }
    }

    private function addSnapshot(ApiUsage $usage, array $input, float $requestTime): void
    {
        // Keep the 5 slowest snapshots: skip if we're full and this isn't slower
        // than the fastest one we've kept.
        if ($usage->snapshots()->count() >= 5) {
            $fastest = (float) $usage->snapshots()->min('request_time');
            if ($fastest >= $requestTime) {
                return;
            }
        }

        $usage->snapshots()->create(['input' => $input, 'request_time' => $requestTime]);

        if ($usage->snapshots()->count() > 5) {
            $usage->snapshots()->orderBy('request_time')->first()?->delete();
        }
    }

    /**
     * Translate a buffered bucket's identity into the api_usage row identity. The
     * endpoint string is resolved to its normalized endpoint_id here (the bucket
     * keeps the string so snapshot matching stays readable); rows are keyed by
     * endpoint_id, not the varchar.
     */
    private function normalizeIdentity(array $data): array
    {
        $nullable = fn ($value) => ($value ?? '') !== '' ? $value : null;

        return [
            'tenant_id' => $nullable($data['tenant_id'] ?? null),
            'endpoint_id' => ApiUsageEndpoint::resolveId($data['endpoint'] ?? null),
            'method' => $nullable($data['method'] ?? null),
            'time_window' => $data['time_window'] ?? '',
            'api_user_id' => $nullable($data['api_user_id'] ?? null),
            'api_token_id' => $nullable($data['api_token_id'] ?? null),
        ];
    }

    private function prefix(): string
    {
        return (string) config('api-usage.redis_prefix', 'api-usage');
    }

    private function ttl(): int
    {
        return (int) config('api-usage.bucket_ttl', 7200);
    }
}
