<?php

declare(strict_types=1);

function readJsonFile(string $path, array $default = []): array
{
    return withJsonFileLock($path, LOCK_SH, static function () use ($path, $default): array {
        return readJsonFileUnlocked($path, $default);
    });
}

function writeJsonFile(string $path, array $data): void
{
    withJsonFileLock($path, LOCK_EX, static function () use ($path, $data): void {
        writeJsonFileUnlocked($path, $data);
    });
}

function updateJsonFile(string $path, array $default, callable $mutator): array
{
    return withJsonFileLock($path, LOCK_EX, static function () use ($path, $default, $mutator): array {
        $data = readJsonFileUnlocked($path, $default);
        $updated = $mutator($data);
        unset($data);
        if (!is_array($updated)) {
            throw new RuntimeException('JSON mutator must return an array for file: ' . $path);
        }

        writeJsonFileUnlocked($path, $updated);

        return $updated;
    });
}

function readJsonFileUnlocked(string $path, array $default = []): array
{
    if (!is_file($path)) {
        return $default;
    }

    $raw = file_get_contents($path);
    if ($raw === false || trim($raw) === '') {
        return $default;
    }

    $decoded = json_decode($raw, true);
    if (!is_array($decoded)) {
        throw new RuntimeException(sprintf(
            'Invalid JSON in %s: %s',
            $path,
            json_last_error_msg()
        ));
    }

    return $decoded;
}

function writeJsonFileUnlocked(string $path, array $data): void
{
    $dir = dirname($path);
    ensureJsonDirectory($dir);

    $temporaryPath = tempnam($dir, basename($path) . '.tmp.');
    if ($temporaryPath === false) {
        throw new RuntimeException('Cannot create temporary JSON file for: ' . $path);
    }

    $stream = null;
    try {
        $stream = fopen($temporaryPath, 'wb');
        if ($stream === false) throw new RuntimeException('Cannot open temporary JSON file: ' . $path);
        $buffer = '';
        streamJsonValue($stream, $data, $buffer);
        flushJsonBuffer($stream, $buffer);
        if (!fflush($stream)) throw new RuntimeException('Cannot flush temporary JSON file: ' . $path);
        fclose($stream);
        $stream = null;

        @chmod($temporaryPath, 0664);
        if (!@rename($temporaryPath, $path)) {
            throw new RuntimeException('Cannot atomically replace JSON file: ' . $path);
        }
        @chmod($path, 0664);
    } finally {
        if (is_resource($stream)) fclose($stream);
        if (is_file($temporaryPath)) {
            @unlink($temporaryPath);
        }
    }
}

function flushJsonBuffer($stream, string &$buffer): void
{
    $length = strlen($buffer);
    $offset = 0;
    while ($offset < $length) {
        $written = fwrite($stream, substr($buffer, $offset));
        if ($written === false || $written === 0) throw new RuntimeException('Cannot write JSON stream.');
        $offset += $written;
    }
    $buffer = '';
}

function streamJsonValue($stream, $value, string &$buffer, int $depth = 0): void
{
    if ($depth > 512) throw new RuntimeException('JSON nesting limit exceeded.');
    if (!is_array($value)) {
        $buffer .= json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
    } else {
        // Encode incrementally rather than allocating a second string for the entire history.
        $list = array_is_list($value);
        $buffer .= $list ? '[' : '{';
        $first = true;
        foreach ($value as $key => $item) {
            if (!$first) $buffer .= ',';
            $first = false;
            if (!$list) $buffer .= json_encode((string) $key, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . ':';
            streamJsonValue($stream, $item, $buffer, $depth + 1);
        }
        $buffer .= $list ? ']' : '}';
    }
    if (strlen($buffer) >= 65536) flushJsonBuffer($stream, $buffer);
}

function withJsonFileLock(string $path, int $operation, callable $callback)
{
    $dir = dirname($path);
    ensureJsonDirectory($dir);
    $lockPath = $path . '.lock';
    $handle = fopen($lockPath, 'c');
    if ($handle === false) {
        throw new RuntimeException('Cannot open JSON lock file: ' . $lockPath);
    }

    @chmod($lockPath, 0664);
    try {
        if (!flock($handle, $operation)) {
            throw new RuntimeException('Cannot acquire JSON lock: ' . $lockPath);
        }

        return $callback();
    } finally {
        flock($handle, LOCK_UN);
        fclose($handle);
    }
}

function ensureJsonDirectory(string $dir): void
{
    if (!is_dir($dir)) {
        if (!mkdir($dir, 02775, true) && !is_dir($dir)) {
            throw new RuntimeException('Cannot create directory: ' . $dir);
        }
        @chmod($dir, 02775);
    }
}

function isEventProcessed(string $eventId): bool
{
    $stateFile = (string) config('STATE_FILE');
    $state = readJsonFile($stateFile, ['processed_events' => []]);

    return isset($state['processed_events'][$eventId]);
}

function claimEventForProcessing(string $eventId): bool
{
    $stateFile = (string) config('STATE_FILE');
    $claimed = false;
    updateJsonFile($stateFile, ['processed_events' => []], static function (array $state) use (
        $eventId,
        &$claimed
    ): array {
        if (isset($state['processed_events'][$eventId])) {
            return $state;
        }

        $claimed = true;
        $state['processed_events'][$eventId] = time();
        if (count($state['processed_events']) > 500) {
            asort($state['processed_events']);
            $state['processed_events'] = array_slice($state['processed_events'], -500, null, true);
        }

        return $state;
    });

    return $claimed;
}

function markEventProcessed(string $eventId): void
{
    $stateFile = (string) config('STATE_FILE');
    updateJsonFile($stateFile, ['processed_events' => []], static function (array $state) use ($eventId): array {
        $state['processed_events'][$eventId] = time();

        if (count($state['processed_events']) > 500) {
            asort($state['processed_events']);
            $state['processed_events'] = array_slice($state['processed_events'], -500, null, true);
        }

        return $state;
    });
}

function unmarkEventProcessed(string $eventId): void
{
    $stateFile = (string) config('STATE_FILE');
    updateJsonFile($stateFile, ['processed_events' => []], static function (array $state) use ($eventId): array {
        unset($state['processed_events'][$eventId]);

        return $state;
    });
}
