<?php

/**
 * @package    Grav\Framework\Cache
 *
 * @copyright  Copyright (c) 2015 - 2026 Trilby Media, LLC. All rights reserved.
 * @license    MIT License; see LICENSE file for details.
 */

namespace Grav\Framework\Cache\Adapter;

use ErrorException;
use FilesystemIterator;
use Grav\Common\Security;
use Grav\Framework\Cache\AbstractCache;
use Grav\Framework\Cache\Exception\CacheException;
use Grav\Framework\Cache\Exception\InvalidArgumentException;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use RuntimeException;
use function strlen;

/**
 * Cache class for PSR-16 compatible "Simple Cache" implementation using file backend.
 *
 * Defaults to 1 year TTL. Does not support unlimited TTL.
 *
 * Cache files are HMAC-signed (sha256, key from Security::getNonceKey()) so that a
 * tampered or attacker-planted file is rejected as a cache miss instead of
 * being unserialized — closes GHSA-gwfr-jfjf-92vv. The on-disk format is:
 *
 *     v2\n<expires>\n<key>\n<hmac-hex>\n<serialized>
 *
 * Pre-v2 files (no version line) are treated as cache misses and rebuilt.
 *
 * @package Grav\Framework\Cache
 */
class FileCache extends AbstractCache
{
    private const FORMAT_VERSION = 'v2';

    /** @var string */
    private $directory;
    /** @var string|null */
    private $tmp;

    /**
     * FileCache constructor.
     * @param string $namespace
     * @param int|null $defaultLifetime
     * @param string|null $folder
     * @throws \Psr\SimpleCache\InvalidArgumentException|InvalidArgumentException
     */
    public function __construct($namespace = '', $defaultLifetime = null, $folder = null)
    {
        try {
            parent::__construct($namespace, $defaultLifetime ?: 31557600); // = 1 year

            $this->initFileCache($namespace, $folder ?? '');
        } catch (\Psr\SimpleCache\InvalidArgumentException $e) {
            throw new InvalidArgumentException($e->getMessage(), $e->getCode(), $e);
        }
    }

    /**
     * @inheritdoc
     */
    public function doGet($key, $miss)
    {
        $now = time();
        $file = $this->getFile($key);

        if (!file_exists($file) || !$h = @fopen($file, 'rb')) {
            return $miss;
        }

        $version = rtrim((string)fgets($h));
        if ($version !== self::FORMAT_VERSION) {
            // Pre-v2 file (or junk). Drop it; the caller will repopulate.
            fclose($h);
            @unlink($file);
            return $miss;
        }

        if ($now >= (int) $expiresAt = fgets($h)) {
            fclose($h);
            @unlink($file);
            return $miss;
        }

        $i = rawurldecode(rtrim((string)fgets($h)));
        $expectedHmac = rtrim((string)fgets($h));
        $value = stream_get_contents($h) ?: '';
        fclose($h);

        if ($i !== $key) {
            return $miss;
        }

        $actualHmac = hash_hmac('sha256', $value, Security::getNonceKey());
        if (!hash_equals($expectedHmac, $actualHmac)) {
            // Tampered or stale-secret payload — refuse to unserialize and
            // delete the file so it gets rebuilt cleanly next time.
            @unlink($file);
            return $miss;
        }

        return unserialize($value, ['allowed_classes' => true]);
    }

    /**
     * @inheritdoc
     * @throws CacheException
     */
    public function doSet($key, $value, $ttl)
    {
        $expiresAt = time() + (int)$ttl;
        $serialized = serialize($value);
        $hmac = hash_hmac('sha256', $serialized, Security::getNonceKey());

        $payload = self::FORMAT_VERSION . "\n"
            . $expiresAt . "\n"
            . rawurlencode($key) . "\n"
            . $hmac . "\n"
            . $serialized;

        $result = $this->write($this->getFile($key, true), $payload, $expiresAt);

        if (!$result && !is_writable($this->directory)) {
            throw new CacheException(sprintf('Cache directory is not writable (%s)', $this->directory));
        }

        return $result;
    }

    /**
     * @inheritdoc
     */
    public function doDelete($key)
    {
        $file = $this->getFile($key);

        $result = false;
        if (file_exists($file)) {
            $result = @unlink($file);
            $result &= !file_exists($file);
        }

        return $result;
    }

    /**
     * @inheritdoc
     */
    public function doClear()
    {
        $result = true;
        $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($this->directory, FilesystemIterator::SKIP_DOTS));

        foreach ($iterator as $file) {
            $result = ($file->isDir() || @unlink($file) || !file_exists($file)) && $result;
        }

        return $result;
    }

    /**
     * @inheritdoc
     */
    public function doHas($key)
    {
        $file = $this->getFile($key);

        return file_exists($file) && (@filemtime($file) > time() || $this->doGet($key, null));
    }

    /**
     * @param string $key
     * @param bool $mkdir
     * @return string
     */
    protected function getFile($key, $mkdir = false)
    {
        $hash = str_replace('/', '-', base64_encode(hash('sha256', static::class . $key, true)));
        $dir = $this->directory . $hash[0] . DIRECTORY_SEPARATOR . $hash[1] . DIRECTORY_SEPARATOR;

        if ($mkdir) {
            $this->mkdir($dir);
        }

        return $dir . substr($hash, 2, 20);
    }

    /**
     * @param string $namespace
     * @param string $directory
     * @return void
     * @throws InvalidArgumentException
     */
    protected function initFileCache($namespace, $directory)
    {
        if ($directory === '') {
            $directory = sys_get_temp_dir() . '/grav-cache';
        } else {
            $directory = realpath($directory) ?: $directory;
        }

        if (isset($namespace[0])) {
            if (preg_match('#[^-+_.A-Za-z0-9]#', $namespace, $match)) {
                throw new InvalidArgumentException(sprintf('Namespace contains "%s" but only characters in [-+_.A-Za-z0-9] are allowed.', $match[0]));
            }
            $directory .= DIRECTORY_SEPARATOR . $namespace;
        }

        $this->mkdir($directory);

        $directory .= DIRECTORY_SEPARATOR;
        // On Windows the whole path is limited to 258 chars
        if ('\\' === DIRECTORY_SEPARATOR && strlen($directory) > 234) {
            throw new InvalidArgumentException(sprintf('Cache folder is too long (%s)', $directory));
        }
        $this->directory = $directory;
    }

    /**
     * @param string $file
     * @param string $data
     * @param int|null $expiresAt
     * @return bool
     */
    private function write($file, $data, $expiresAt = null)
    {
        set_error_handler(self::class.'::throwError');

        try {
            if ($this->tmp === null) {
                $this->tmp = $this->directory . uniqid('', true);
            }

            file_put_contents($this->tmp, $data);

            if ($expiresAt !== null) {
                touch($this->tmp, $expiresAt);
            }

            return rename($this->tmp, $file);
        } finally {
            restore_error_handler();
        }
    }

    /**
     * @param  string  $dir
     * @return void
     * @throws RuntimeException
     */
    private function mkdir($dir)
    {
        // Silence error for open_basedir; should fail in mkdir instead.
        if (@is_dir($dir)) {
            return;
        }

        $success = @mkdir($dir, 0777, true);

        if (!$success) {
            // Take yet another look, make sure that the folder doesn't exist.
            clearstatcache(true, $dir);
            if (!@is_dir($dir)) {
                throw new RuntimeException(sprintf('Unable to create directory: %s', $dir));
            }
        }
    }

    /**
     * @param int $type
     * @param string $message
     * @param string $file
     * @param int $line
     * @return bool
     * @internal
     * @throws ErrorException
     */
    public static function throwError($type, $message, $file, $line): never
    {
        throw new ErrorException($message, 0, $type, $file, $line);
    }

    /**
     * @return void
     */
    #[\ReturnTypeWillChange]
    public function __destruct()
    {
        if ($this->tmp !== null && file_exists($this->tmp)) {
            unlink($this->tmp);
        }
    }
}
