<?php

declare(strict_types=1);

namespace CuyZ\Valinor\Cache;

use CuyZ\Valinor\Cache\Exception\CacheDirectoryNotWritable;
use CuyZ\Valinor\Cache\Exception\CompiledPhpCacheFileNotWritten;
use CuyZ\Valinor\Cache\Exception\CorruptedCompiledPhpCacheFile;
use Error;

use FilesystemIterator;

use function bin2hex;
use function chmod;
use function file_exists;
use function file_put_contents;
use function is_dir;
use function mkdir;
use function random_bytes;
use function rename;
use function rmdir;
use function str_contains;
use function umask;
use function unlink;

/**
 * @api
 *
 * @template EntryType
 * @implements Cache<EntryType>
 */
final class FileSystemCache implements Cache
{
    private const GENERATED_MESSAGE = 'Generated by ' . self::class;

    public function __construct(
        private string $cacheDir,
    ) {}

    /** @internal */
    public function get(string $key, mixed ...$arguments): mixed
    {
        $filename = $this->cacheDir . DIRECTORY_SEPARATOR . $key . '.php';

        if (! file_exists($filename)) {
            return null;
        }

        try {
            return (require $filename)(...$arguments); // @phpstan-ignore callable.nonCallable
        } catch (Error) {
            throw new CorruptedCompiledPhpCacheFile($filename);
        }
    }

    /** @internal */
    public function set(string $key, CacheEntry $entry): void
    {
        $tmpDir = $this->cacheDir . DIRECTORY_SEPARATOR . '.valinor.tmp';
        $filename = $this->cacheDir . DIRECTORY_SEPARATOR . $key . '.php';

        /**
         * The third `is_dir()` check confirms the directory truly doesn't exist
         * after `mkdir()` failure, distinguishing a genuine error (permissions,
         * disk full) from a concurrent creation by another process.
         *
         * @infection-ignore-all
         */
        if (! is_dir($tmpDir) && ! @mkdir($tmpDir, 0777, true) && ! is_dir($tmpDir)) {
            throw new CacheDirectoryNotWritable($this->cacheDir);
        }

        /** @infection-ignore-all */
        $tmpFilename = $tmpDir . DIRECTORY_SEPARATOR . bin2hex(random_bytes(16));

        try {
            $code = '<?php // ' . self::GENERATED_MESSAGE . PHP_EOL . "return $entry->code;" . PHP_EOL;

            if (! @file_put_contents($tmpFilename, $code)) {
                throw new CompiledPhpCacheFileNotWritten($tmpFilename);
            }

            if (! @rename($tmpFilename, $filename)) {
                throw new CompiledPhpCacheFileNotWritten($filename);
            }

            @chmod($filename, 0666 & ~umask());
        } finally {
            if (file_exists($tmpFilename)) {
                unlink($tmpFilename);
            }
        }
    }

    public function clear(): void
    {
        if (! is_dir($this->cacheDir)) {
            return;
        }

        $shouldDeleteRootDir = true;

        /** @var FilesystemIterator $file */
        foreach (new FilesystemIterator($this->cacheDir) as $file) {
            if ($file->getFilename() === '.valinor.tmp') {
                @rmdir($file->getPathname());
                continue;
            }

            if (! $file->isFile()) {
                $shouldDeleteRootDir = false;
                continue;
            }

            $line = $file->openFile()->getCurrentLine();

            if (! $line || ! str_contains($line, self::GENERATED_MESSAGE)) {
                $shouldDeleteRootDir = false;
                continue;
            }

            @unlink($file->getPathname());
        }

        if ($shouldDeleteRootDir) {
            @rmdir($this->cacheDir);
        }
    }
}
