<?php

declare(strict_types=1);

namespace PpPhp\Core\Storage;

/**
 * LocalDriver — the self-hosted backend.
 *
 * Stores files under <app>/uploads/YYYY/MM (the same layout the legacy
 * pp_store_upload() helper uses, so old and new rows are interchangeable) and
 * keeps them confined to that root. Public files resolve to a direct
 * /uploads/… URL; private files are served through the app's api/file.php so
 * authorization is enforced.
 *
 * Construction is via a config array (from StorageConfig):
 *   root    — absolute uploads dir  (default: BASE_PATH/uploads, else cwd/uploads)
 *   baseUrl — public URL prefix     (default: APP_BASE . '/uploads')
 *   private — bool, serve via api/file.php instead of a direct link
 */
final class LocalDriver implements StorageDriver
{
    private string $root;
    private string $baseUrl;
    private bool $private;
    private string $folder;

    /** The base folder for all self-hosted uploads, at the app root. */
    public const BASE = 'Files';

    /** Extensions never accepted, regardless of the allow-list. */
    private const DENY = ['php', 'phtml', 'phar', 'php3', 'php4', 'php5', 'php7', 'php8', 'pht', 'cgi', 'pl', 'sh', 'htaccess', 'exe'];

    public function __construct(array $config = [])
    {
        $root = (string) ($config['root'] ?? '');
        if ($root === '') {
            $base = defined('BASE_PATH') ? \BASE_PATH : getcwd();
            $root = rtrim((string) $base, '/\\') . '/' . self::BASE;
        }
        $this->root = rtrim(str_replace('\\', '/', $root), '/');

        $baseUrl = (string) ($config['baseUrl'] ?? '');
        if ($baseUrl === '') {
            $baseUrl = (defined('APP_BASE') ? (string) \APP_BASE : '') . '/' . self::BASE;
        }
        $this->baseUrl = rtrim($baseUrl, '/');
        $this->private = (bool) ($config['private'] ?? false);
        // A per-field sub-folder inside Files/ (e.g. "avatars", "docs/contracts"),
        // set in the Edit control. Nested allowed; traversal + junk stripped.
        $this->folder = self::sanitizeFolder((string) ($config['folder'] ?? $config['subdir'] ?? ''));
    }

    /** Normalise a user sub-folder: strip traversal, junk, and stray slashes. */
    public static function sanitizeFolder(string $folder): string
    {
        $f = str_replace('\\', '/', $folder);
        $f = preg_replace('#[^a-zA-Z0-9_/ -]#', '', $f) ?? '';
        $parts = array_filter(array_map('trim', explode('/', $f)), static fn($p) => $p !== '' && $p !== '.' && $p !== '..');
        return implode('/', array_map(static fn($p) => str_replace(' ', '-', $p), $parts));
    }

    public static function id(): string { return 'local'; }

    public function put(string $tmpPath, array $meta): array
    {
        $name = (string) ($meta['name'] ?? basename($tmpPath));
        $ext  = strtolower((string) ($meta['ext'] ?? pathinfo($name, PATHINFO_EXTENSION)));
        if ($ext === '' || in_array($ext, self::DENY, true)) {
            throw new \RuntimeException('File type is not allowed.');
        }

        // Layout: Files/{public|private}/[<folder>/]<hash>.<ext> — FLAT and
        // predictable (no date nesting) so the Events module + Custom Files can
        // trace/render an attachment without walking dated sub-trees. <folder>
        // defaults to the table name (build-injected) or a user-supplied path.
        $vis = $this->private ? 'private' : 'public';
        $rel = $vis . ($this->folder !== '' ? '/' . $this->folder : '');
        $destDir = $this->ensureDir($this->root . '/' . $rel);
        $safeExt = preg_replace('/[^a-z0-9]/', '', $ext) ?: 'bin';
        $stem    = bin2hex(random_bytes(8));
        $key     = $rel . '/' . $stem . '.' . $safeExt;
        $abs     = $destDir . '/' . $stem . '.' . $safeExt;

        if (!@move_uploaded_file($tmpPath, $abs) && !@rename($tmpPath, $abs) && !@copy($tmpPath, $abs)) {
            throw new \RuntimeException('Could not store the uploaded file.');
        }
        @chmod($abs, 0644);

        $ref = [
            'v'      => StorageRef::VERSION,
            'driver' => 'local',
            'key'    => $key,
            'name'   => $name,
            'size'   => (int) ($meta['size'] ?? @filesize($abs) ?: 0),
            'mime'   => (string) ($meta['mime'] ?? ''),
        ];
        foreach (['w', 'h'] as $k) {
            if ((int) ($meta[$k] ?? 0) > 0) $ref[$k] = (int) $meta[$k];
        }

        // A generated thumbnail temp file → store alongside as <stem>_t.<ext>.
        $thumbTmp = (string) ($meta['thumbTmp'] ?? '');
        if ($thumbTmp !== '' && is_file($thumbTmp)) {
            $tkey = $rel . '/' . $stem . '_t.' . $safeExt;
            if (@rename($thumbTmp, $destDir . '/' . $stem . '_t.' . $safeExt)
                || @copy($thumbTmp, $destDir . '/' . $stem . '_t.' . $safeExt)) {
                @chmod($destDir . '/' . $stem . '_t.' . $safeExt, 0644);
                $ref['thumb'] = $tkey;
            }
        }

        return StorageRef::normalise($ref);
    }

    public function url(array $ref, array $opts = []): string
    {
        // External URL stored verbatim (legacy URL rows / image-URL mode).
        $direct = (string) ($ref['url'] ?? '');
        if ($direct !== '' && preg_match('#^(https?:)?//|^data:#i', $direct)) {
            return $direct;
        }

        $key = (string) (($opts['thumb'] ?? false) && ($ref['thumb'] ?? '') !== ''
            ? $ref['thumb'] : ($ref['key'] ?? ''));
        if ($key === '') return '';

        // Legacy key that is itself a full path/URL.
        if (preg_match('#^(https?:)?//|^data:#i', $key)) return $key;

        $rel = $this->relKey($key);

        // Anything under Files/private is served ONLY through api/file.php (its
        // deny-all .htaccess blocks direct access), regardless of how this driver
        // instance was constructed — so private-by-default files load in views.
        if ($this->private || !empty($opts['proxy']) || str_starts_with($rel, 'private/')) {
            $base = (defined('APP_BASE') ? (string) \APP_BASE : '');
            $q = 'f=' . rawurlencode($rel);
            if (!empty($opts['inline'])) $q .= '&inline=1';
            return $base . '/api/file.php?' . $q;
        }
        return $this->baseUrl . '/' . $rel;
    }

    public function openStream(array $ref)
    {
        $abs = $this->resolve((string) ($ref['key'] ?? ''));
        if ($abs === null) return null;
        $fh = @fopen($abs, 'rb');
        return $fh ?: null;
    }

    public function delete(array $ref): bool
    {
        $ok = true;
        foreach (['key', 'thumb'] as $k) {
            $key = (string) ($ref[$k] ?? '');
            if ($key === '') continue;
            $abs = $this->resolve($key);
            if ($abs !== null) $ok = @unlink($abs) && $ok;
        }
        return $ok;
    }

    public function exists(array $ref): bool
    {
        return $this->resolve((string) ($ref['key'] ?? '')) !== null;
    }

    public function capabilities(): array
    {
        return ['directUpload' => false, 'publicUrl' => !$this->private,
                'signedUrl' => false, 'chunked' => true, 'maxBytes' => 0];
    }

    /** Absolute path for a stored file, confined to root, or null. */
    public function resolve(string $key): ?string
    {
        if ($key === '') return null;
        $base = realpath($this->root);
        if ($base === false) return null;
        $abs = realpath($base . '/' . $this->relKey($key));
        if ($abs === false) $abs = realpath($key);        // absolute legacy path
        if ($abs === false) return null;
        if ($abs !== $base && !str_starts_with($abs, $base . \DIRECTORY_SEPARATOR)) return null;
        return is_file($abs) ? $abs : null;
    }

    /** Reduce any stored key (relative, absolute, or …/Files|uploads/… URL) to a root-relative path. */
    private function relKey(string $key): string
    {
        $k = str_replace('\\', '/', $key);
        foreach (['/' . self::BASE . '/', '/uploads/'] as $marker) {
            if (str_contains($k, $marker)) { $k = substr($k, strpos($k, $marker) + strlen($marker)); break; }
        }
        if (str_starts_with($k, $this->root . '/')) {
            $k = substr($k, strlen($this->root) + 1);
        }
        return ltrim($k, '/');
    }

    private function ensureDir(string $dir): string
    {
        if (!is_dir($dir)) { @mkdir($dir, 0755, true); }
        // Belt-and-braces: keep the Files/ root non-executable.
        $ht = $this->root . '/.htaccess';
        if (!is_file($ht)) {
            @file_put_contents($ht, "php_flag engine off\nOptions -ExecCGI -Indexes\n<FilesMatch \"\\.(php|phtml|phar|php[0-9]|pht|cgi|pl|sh)$\">\n  Require all denied\n</FilesMatch>\n");
        }
        // Files/private denies ALL direct web access — private files are only
        // reachable through api/file.php, which streams them via PHP after an
        // authorization check (PHP filesystem reads bypass this Apache rule).
        if ($this->private) {
            $pdir = $this->root . '/private';
            if (!is_dir($pdir)) { @mkdir($pdir, 0755, true); }
            $pht = $pdir . '/.htaccess';
            if (!is_file($pht)) {
                @file_put_contents($pht, "php_flag engine off\nOptions -ExecCGI -Indexes\nRequire all denied\n<IfModule !mod_authz_core.c>\n  Order deny,allow\n  Deny from all\n</IfModule>\n");
            }
        }
        return $dir;
    }
}
