<?php

declare(strict_types=1);

namespace ShieldX\Storage;

defined('ABSPATH') || exit;

/**
 * Stores resumable job checkpoints in non-autoloaded WordPress options.
 *
 * Private storage remains useful as a local mirror, but it may not be shared
 * by every web request on clustered hosting. This store keeps large checkpoint
 * payloads in bounded chunks and publishes them by updating one metadata option
 * after every chunk has been verified.
 */
final class DurableJobStore
{
    private const OPTION_PREFIX = 'shieldx_durable_job_';
    private const CHUNK_BYTES = 524288;
    private const MAX_CHUNKS = 1024;
    private const MAX_JSON_BYTES = 536870912;

    /**
     * @param array<string,mixed> $payload
     */
    public function save(string $type, string $jobId, array $payload, int $ttl): bool
    {
        $key = $this->jobKey($type, $jobId);
        if ($key === '') {
            return false;
        }

        $json = wp_json_encode($payload, JSON_UNESCAPED_SLASHES);
        if (!is_string($json) || $json === '') {
            return false;
        }

        $chunks = str_split($json, self::CHUNK_BYTES);
        if (empty($chunks) || count($chunks) > self::MAX_CHUNKS) {
            return false;
        }

        try {
            $revision = bin2hex(random_bytes(8));
        } catch (\Throwable $error) {
            unset($error);

            return false;
        }

        foreach ($chunks as $index => $chunk) {
            if (!$this->writeOption($this->chunkOptionName($key, $revision, (int) $index), $chunk)) {
                $this->deleteRevision($key, $revision, count($chunks));

                return false;
            }
        }

        $meta = [
            'version' => 1,
            'type' => $type,
            'job_id' => $jobId,
            'revision' => $revision,
            'chunks' => count($chunks),
            'bytes' => strlen($json),
            'sha256' => hash('sha256', $json),
            'expires_at' => time() + max(0, $ttl),
        ];
        $oldMeta = $this->readMeta($key);

        if (!$this->writeOption($this->metaOptionName($key), $meta)) {
            $this->deleteRevision($key, $revision, count($chunks));

            return false;
        }

        if ($this->isValidMeta($oldMeta, $type, $jobId)) {
            $this->deleteRevision($key, (string) $oldMeta['revision'], (int) $oldMeta['chunks']);
        }

        return true;
    }

    /**
     * @return array<string,mixed>|null
     */
    public function read(string $type, string $jobId): ?array
    {
        $key = $this->jobKey($type, $jobId);
        if ($key === '') {
            return null;
        }

        $meta = $this->readMeta($key);
        if (!$this->isValidMeta($meta, $type, $jobId)) {
            return null;
        }

        if ((int) $meta['expires_at'] <= time()) {
            $this->delete($type, $jobId);

            return null;
        }

        $chunks = [];
        for ($index = 0; $index < (int) $meta['chunks']; $index++) {
            $chunk = get_option($this->chunkOptionName($key, (string) $meta['revision'], $index), null);
            if (!is_string($chunk)) {
                return null;
            }
            $chunks[] = $chunk;
        }

        $json = implode('', $chunks);
        if (strlen($json) !== (int) $meta['bytes'] || !hash_equals((string) $meta['sha256'], hash('sha256', $json))) {
            return null;
        }

        $payload = \ShieldX\Support\Json::decodeArrayRaw($json, self::MAX_JSON_BYTES);

        return is_array($payload) ? $payload : null;
    }

    public function delete(string $type, string $jobId): void
    {
        $key = $this->jobKey($type, $jobId);
        if ($key === '') {
            return;
        }

        $meta = $this->readMeta($key);
        delete_option($this->metaOptionName($key));
        if ($this->isValidMeta($meta, $type, $jobId)) {
            $this->deleteRevision($key, (string) $meta['revision'], (int) $meta['chunks']);
        }
    }

    private function jobKey(string $type, string $jobId): string
    {
        $type = trim(strtolower($type));
        $jobId = trim($jobId);
        if (!preg_match('/^[a-z][a-z0-9_-]{0,31}$/', $type) || $jobId === '' || strlen($jobId) > 191) {
            return '';
        }

        return self::OPTION_PREFIX . $type . '_' . substr(hash('sha256', $jobId), 0, 32);
    }

    private function metaOptionName(string $key): string
    {
        return $key . '_meta';
    }

    private function chunkOptionName(string $key, string $revision, int $index): string
    {
        return $key . '_r' . $revision . '_c' . $index;
    }

    /**
     * @return array<string,mixed>
     */
    private function readMeta(string $key): array
    {
        $meta = get_option($this->metaOptionName($key), []);

        return is_array($meta) ? $meta : [];
    }

    /**
     * @param array<string,mixed> $meta
     */
    private function isValidMeta(array $meta, string $type, string $jobId): bool
    {
        return (int) ($meta['version'] ?? 0) === 1
            && (string) ($meta['type'] ?? '') === $type
            && (string) ($meta['job_id'] ?? '') === $jobId
            && preg_match('/^[a-f0-9]{16}$/', (string) ($meta['revision'] ?? '')) === 1
            && (int) ($meta['chunks'] ?? 0) > 0
            && (int) ($meta['chunks'] ?? 0) <= self::MAX_CHUNKS
            && (int) ($meta['bytes'] ?? -1) >= 0
            && preg_match('/^[a-f0-9]{64}$/', (string) ($meta['sha256'] ?? '')) === 1
            && isset($meta['expires_at']);
    }

    private function writeOption(string $name, $value): bool
    {
        $existing = get_option($name, null);
        $written = $existing === null
            ? add_option($name, $value, '', 'no')
            : update_option($name, $value, false);

        if (!$written && $existing !== $value) {
            return false;
        }

        return get_option($name, null) === $value;
    }

    private function deleteRevision(string $key, string $revision, int $chunks): void
    {
        if (preg_match('/^[a-f0-9]{16}$/', $revision) !== 1) {
            return;
        }

        for ($index = 0; $index < max(0, min(self::MAX_CHUNKS, $chunks)); $index++) {
            delete_option($this->chunkOptionName($key, $revision, $index));
        }
    }
}
