<?php

declare(strict_types=1);

namespace Ekdos\N8n;

use Redis;

/**
 * Kurzlebiger Redis-Cache vor n8n.
 *
 * Das Buero laesst mehrere Ansichten gleichzeitig offen, und die Ticketliste
 * aktualisiert sich alle 20 Sekunden von selbst. Ohne Cache landet jeder dieser
 * Takte als eigener Aufruf bei n8n. Drei Mechanismen verhindern das:
 *
 *  1. Frischer Eintrag  Innerhalb der TTL wird ohne Ruecksprache geantwortet.
 *
 *  2. Single-Flight     Laeuft der Cache ab, waehrend drei Ansichten gleichzeitig
 *                       fragen, holt genau eine die Daten. Die anderen warten
 *                       kurz auf deren Ergebnis, statt parallel loszurennen.
 *
 *  3. Stale-on-Error    Neben dem frischen Eintrag liegt eine deutlich laenger
 *                       haltbare Kopie. Ist n8n nicht erreichbar, sieht das Buero
 *                       die letzten bekannten Daten statt einer Fehlerseite.
 *
 * Schreibende Aufrufe verwerfen ihre Gruppe sofort (invalidate), damit eine
 * Aenderung nicht bis zum Ablauf der TTL unsichtbar bleibt.
 *
 * Schluessel:
 *   <prefix>n8n:fresh:<hash>   die kurzlebige Antwort
 *   <prefix>n8n:stale:<hash>   die Rueckfallkopie
 *   <prefix>n8n:lock:<hash>    Single-Flight-Sperre
 *   <prefix>n8n:group:<name>   Menge aller Schluessel einer Gruppe
 */
final readonly class Cache
{
    /** Wie lange ein wartender Aufrufer auf das Ergebnis des Single-Flight hofft. */
    private const int WAIT_TOTAL_MS = 400;
    private const int WAIT_STEP_MS = 25;
    private const int LOCK_TTL_SECONDS = 15;

    public function __construct(
        private Redis $redis,
        private string $prefix,
    ) {}

    /**
     * @param string   $group     Gruppe fuer die gezielte Verwerfung, z. B. "tickets".
     * @param string   $key       Eindeutig fuer diese Abfrage inklusive Parameter.
     * @param int      $ttl       Sekunden, die die Antwort als frisch gilt.
     * @param int      $staleTtl  Sekunden, die die Rueckfallkopie vorgehalten wird.
     * @param callable():Reply $fetch
     */
    public function remember(string $group, string $key, int $ttl, int $staleTtl, callable $fetch): Reply
    {
        $hash = sha1($key);

        if (($hit = $this->readEntry($this->freshKey($hash))) !== null) {
            return $hit;
        }

        if (!$this->acquireLock($hash)) {
            // Jemand anderes holt gerade. Kurz auf dessen Ergebnis warten.
            if (($shared = $this->waitForFresh($hash)) !== null) {
                return $shared;
            }

            // Warten hat nichts gebracht: lieber etwas Veraltetes als gar nichts.
            if (($stale = $this->readEntry($this->staleKey($hash))) !== null) {
                return $stale->asStale();
            }
        }

        try {
            $reply = $fetch();

            if ($reply->ok()) {
                $this->store($group, $hash, $reply, $ttl, $staleTtl);

                return $reply;
            }

            // n8n antwortet, aber mit einem Fehler. Alte Daten schlagen eine Fehlerseite.
            return $this->readEntry($this->staleKey($hash))?->asStale() ?? $reply;
        } finally {
            $this->releaseLock($hash);
        }
    }

    /** Verwirft alle Eintraege der genannten Gruppen. Nach jedem schreibenden Aufruf. */
    public function invalidate(string ...$groups): void
    {
        foreach ($groups as $group) {
            $groupKey = $this->groupKey($group);
            $members = $this->redis->sMembers($groupKey);

            if (is_array($members) && $members !== []) {
                $this->redis->del($members);
            }

            $this->redis->del($groupKey);
        }
    }

    /** Leert alles, was dieser Cache angelegt hat. Nur fuer die Wartungs-CLI. */
    public function flushAll(): int
    {
        $removed = 0;
        $pattern = $this->prefix . 'n8n:*';
        $cursor = null;

        do {
            $keys = $this->redis->scan($cursor, $pattern, 500);

            if (is_array($keys) && $keys !== []) {
                $removed += (int) $this->redis->del($keys);
            }
        } while ($cursor > 0);

        return $removed;
    }

    private function store(string $group, string $hash, Reply $reply, int $ttl, int $staleTtl): void
    {
        $payload = json_encode(
            ['status' => $reply->status, 'body' => $reply->body, 'contentType' => $reply->contentType],
            JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR,
        );

        $this->redis->setex($this->freshKey($hash), max(1, $ttl), $payload);
        $this->redis->setex($this->staleKey($hash), max($ttl, $staleTtl), $payload);
        $this->redis->sAdd($this->groupKey($group), $this->freshKey($hash), $this->staleKey($hash));
        $this->redis->expire($this->groupKey($group), max($ttl, $staleTtl) + 60);
    }

    private function readEntry(string $key): ?Reply
    {
        $raw = $this->redis->get($key);

        if (!is_string($raw) || $raw === '') {
            return null;
        }

        $decoded = json_decode($raw, true);

        if (!is_array($decoded) || !isset($decoded['status'], $decoded['body'])) {
            return null;
        }

        return new Reply((int) $decoded['status'], (string) $decoded['body'], (string) ($decoded['contentType'] ?? ''));
    }

    private function waitForFresh(string $hash): ?Reply
    {
        $deadline = microtime(true) + self::WAIT_TOTAL_MS / 1000;

        while (microtime(true) < $deadline) {
            usleep(self::WAIT_STEP_MS * 1000);

            if (($hit = $this->readEntry($this->freshKey($hash))) !== null) {
                return $hit;
            }
        }

        return null;
    }

    private function acquireLock(string $hash): bool
    {
        return (bool) $this->redis->set($this->lockKey($hash), '1', ['NX', 'EX' => self::LOCK_TTL_SECONDS]);
    }

    private function releaseLock(string $hash): void
    {
        $this->redis->del($this->lockKey($hash));
    }

    private function freshKey(string $hash): string
    {
        return $this->prefix . 'n8n:fresh:' . $hash;
    }

    private function staleKey(string $hash): string
    {
        return $this->prefix . 'n8n:stale:' . $hash;
    }

    private function lockKey(string $hash): string
    {
        return $this->prefix . 'n8n:lock:' . $hash;
    }

    private function groupKey(string $group): string
    {
        return $this->prefix . 'n8n:group:' . $group;
    }
}
