<?php
declare(strict_types=1);

namespace App\Json;

/**
 * Thread-safe JSON file store for single-file collection documents.
 * - flock() for concurrency
 * - Atomic writes via temp file + rename
 * - Re-read inside lock before modifying to avoid lost updates
 */
class FileStore
{
    public function __construct(private string $path) {}

    /** Read the full document. Returns [] if file is missing or empty. */
    public function read(): array
    {
        if (!is_file($this->path)) return [];
        $fp = fopen($this->path, 'rb');
        if (!$fp) return [];
        try {
            flock($fp, LOCK_SH);
            $raw = stream_get_contents($fp);
            flock($fp, LOCK_UN);
        } finally {
            fclose($fp);
        }
        if (!$raw) return [];
        $decoded = json_decode($raw, true);
        return is_array($decoded) ? $decoded : [];
    }

    /** Write the full document atomically. */
    public function write(array $data): void
    {
        $this->ensureDir();
        $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
        if ($json === false) {
            throw new \RuntimeException('Failed to encode JSON for ' . $this->path);
        }
        $tmp = $this->path . '.tmp.' . bin2hex(random_bytes(4));
        if (file_put_contents($tmp, $json, LOCK_EX) === false) {
            throw new \RuntimeException('Failed to write ' . $tmp);
        }
        if (!rename($tmp, $this->path)) {
            @unlink($tmp);
            throw new \RuntimeException('Failed to move ' . $tmp . ' to ' . $this->path);
        }
        @chmod($this->path, 0600);
    }

    /**
     * Safely mutate the document under an exclusive lock.
     * The callback receives the current array and must return the new array.
     */
    public function transaction(callable $fn): array
    {
        $this->ensureDir();
        $fp = fopen($this->path, 'c+b');
        if (!$fp) {
            throw new \RuntimeException('Cannot open ' . $this->path);
        }
        try {
            if (!flock($fp, LOCK_EX)) {
                throw new \RuntimeException('Failed to acquire exclusive lock on ' . $this->path);
            }
            rewind($fp);
            $raw = stream_get_contents($fp);
            $data = $raw ? (json_decode($raw, true) ?: []) : [];
            $next = $fn($data);
            if (!is_array($next)) {
                throw new \RuntimeException('Transaction callback must return array');
            }
            $json = json_encode($next, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
            if ($json === false) {
                throw new \RuntimeException('Failed to encode JSON for ' . $this->path);
            }
            // Truncate and write in place (lock is held)
            ftruncate($fp, 0);
            rewind($fp);
            fwrite($fp, $json);
            fflush($fp);
            flock($fp, LOCK_UN);
            @chmod($this->path, 0600);
            return $next;
        } finally {
            fclose($fp);
        }
    }

    private function ensureDir(): void
    {
        $dir = dirname($this->path);
        if (!is_dir($dir)) @mkdir($dir, 0755, true);
    }
}
