<?php
declare(strict_types=1);

header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');

const THUMB_WIDTH = 420;
const THUMB_QUALITY = 72;
const THUMB_CACHE_RELATIVE = 'system/cache/thumbs';

function out(array $payload): void
{
    echo json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
    exit;
}

$action = isset($_GET['action']) ? (string) $_GET['action'] : 'init';
$scriptName = isset($_SERVER['SCRIPT_NAME']) ? (string) $_SERVER['SCRIPT_NAME'] : '/system/api.php';
$appBase = str_replace('\\', '/', dirname(dirname($scriptName)));
if ($appBase === '.' || $appBase === '/' || $appBase === '\\') {
    $appBase = '';
}

function with_base(string $appBase, string $path): string
{
    $suffix = '/' . ltrim($path, '/');

    return $appBase === '' ? $suffix : $appBase . $suffix;
}

function is_inside_root(string $root, string $path): bool
{
    $rootNorm = rtrim($root, DIRECTORY_SEPARATOR);
    if ($path === $rootNorm) {
        return true;
    }

    $prefix = $rootNorm . DIRECTORY_SEPARATOR;

    return strncmp($path, $prefix, strlen($prefix)) === 0;
}

function is_safe_name(string $name): bool
{
    if ($name === '') {
        return false;
    }

    if (strlen($name) > 255) {
        return false;
    }

    if (preg_match('/^[\p{L}0-9 _.\-]+$/u', $name) !== 1) {
        return false;
    }

    if ($name[0] === '.') {
        return false;
    }

    return true;
}

function encode_path(string $relativePath): string
{
    $segments = explode('/', trim(str_replace('\\', '/', $relativePath), '/'));
    $segments = array_map(static fn(string $part): string => rawurlencode($part), $segments);

    return implode('/', $segments);
}

function ensure_dir(string $directory): bool
{
    if (is_dir($directory)) {
        return true;
    }

    return @mkdir($directory, 0775, true) || is_dir($directory);
}

function cache_segment(string $value): string
{
    $segment = strtolower((string) preg_replace('/[^a-z0-9_-]+/i', '-', $value));
    $segment = trim($segment, '-');

    if ($segment === '') {
        return 'album';
    }

    return $segment;
}

function album_cache_key(string $albumName): string
{
    $segment = cache_segment($albumName);
    $suffix = substr(sha1($albumName), 0, 8);

    return $segment . '-' . $suffix;
}

function should_run_gc(int $oneIn): bool
{
    if ($oneIn <= 1) {
        return true;
    }

    try {
        return random_int(1, $oneIn) === 1;
    } catch (Throwable $_err) {
        return mt_rand(1, $oneIn) === 1;
    }
}

function random_suffix(): string
{
    try {
        return bin2hex(random_bytes(6));
    } catch (Throwable $_err) {
        return dechex((int) (microtime(true) * 1000000)) . '-' . getmypid();
    }
}

function locate_imagemagick_binary(): ?string
{
    static $checked = false;
    static $binary = '';

    if ($checked) {
        return $binary !== '' ? $binary : null;
    }

    $checked = true;
    $candidates = [
        '/usr/bin/magick',
        '/usr/local/bin/magick',
        '/opt/homebrew/bin/magick',
        '/usr/bin/convert',
        '/usr/local/bin/convert',
        '/opt/homebrew/bin/convert',
    ];

    foreach ($candidates as $candidate) {
        if (is_file($candidate) && is_executable($candidate)) {
            $binary = $candidate;

            return $binary;
        }
    }

    return null;
}

function run_thumb_command(string $sourcePath, string $targetPath, int $width, int $quality): bool
{
    $binary = locate_imagemagick_binary();
    if ($binary === null) {
        return false;
    }

    $safeWidth = max(64, min(1200, $width));
    $safeQuality = max(35, min(92, $quality));
    $command = sprintf(
        '%s %s -auto-orient -thumbnail %dx -strip -interlace Plane -quality %d %s 2>/dev/null',
        escapeshellarg($binary),
        escapeshellarg($sourcePath),
        $safeWidth,
        $safeQuality,
        escapeshellarg($targetPath)
    );

    $output = [];
    $exitCode = 1;
    exec($command, $output, $exitCode);

    return $exitCode === 0;
}

function generate_thumb(string $sourcePath, string $targetPath, int $width, int $quality): bool
{
    if (is_file($targetPath) && (int) @filesize($targetPath) > 0) {
        return true;
    }

    $targetDirectory = dirname($targetPath);
    if (!ensure_dir($targetDirectory)) {
        return false;
    }

    $lockPath = $targetPath . '.lock';
    $lockHandle = @fopen($lockPath, 'c');
    if ($lockHandle === false) {
        return false;
    }

    try {
        if (!flock($lockHandle, LOCK_EX)) {
            return false;
        }

        clearstatcache(true, $targetPath);
        if (is_file($targetPath) && (int) @filesize($targetPath) > 0) {
            return true;
        }

        $tempPath = $targetPath . '.tmp-' . random_suffix();
        $ok = run_thumb_command($sourcePath, $tempPath, $width, $quality);

        if (!$ok || !is_file($tempPath) || (int) @filesize($tempPath) < 1) {
            @unlink($tempPath);

            return false;
        }

        if (!@rename($tempPath, $targetPath)) {
            if (!@copy($tempPath, $targetPath)) {
                @unlink($tempPath);

                return false;
            }

            @unlink($tempPath);
        }

        return is_file($targetPath) && (int) @filesize($targetPath) > 0;
    } finally {
        flock($lockHandle, LOCK_UN);
        fclose($lockHandle);
        @unlink($lockPath);
    }
}

function directory_is_empty(string $directory): bool
{
    if (!is_dir($directory)) {
        return false;
    }

    $handle = @opendir($directory);
    if ($handle === false) {
        return false;
    }

    while (($entry = readdir($handle)) !== false) {
        if ($entry === '.' || $entry === '..') {
            continue;
        }

        closedir($handle);

        return false;
    }

    closedir($handle);

    return true;
}

function remove_tree(string $path): void
{
    if (!is_dir($path)) {
        @unlink($path);

        return;
    }

    $it = new RecursiveIteratorIterator(
        new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS),
        RecursiveIteratorIterator::CHILD_FIRST
    );

    foreach ($it as $entry) {
        if ($entry->isDir()) {
            @rmdir($entry->getPathname());
        } else {
            @unlink($entry->getPathname());
        }
    }

    @rmdir($path);
}

/**
 * @param array<int, string> $validHashes
 */
function cleanup_album_cache(string $albumCacheDir, array $validHashes): void
{
    if (!is_dir($albumCacheDir) || !should_run_gc(4)) {
        return;
    }

    $validLookup = array_fill_keys($validHashes, true);
    $it = new RecursiveIteratorIterator(
        new RecursiveDirectoryIterator($albumCacheDir, FilesystemIterator::SKIP_DOTS),
        RecursiveIteratorIterator::CHILD_FIRST
    );

    foreach ($it as $entry) {
        $path = $entry->getPathname();

        if ($entry->isFile()) {
            $file = $entry->getFilename();

            if (str_ends_with($file, '.lock') || str_contains($file, '.tmp-')) {
                $mtime = @filemtime($path);
                if ($mtime !== false && (time() - $mtime) > 1800) {
                    @unlink($path);
                }

                continue;
            }

            if ($entry->getExtension() !== 'jpg') {
                continue;
            }

            $hash = (string) pathinfo($file, PATHINFO_FILENAME);
            if (!isset($validLookup[$hash])) {
                @unlink($path);
            }

            continue;
        }

        if ($entry->isDir() && directory_is_empty($path)) {
            @rmdir($path);
        }
    }
}

/**
 * @param array<int, string> $activeAlbumKeys
 */
function cleanup_removed_album_caches(string $cacheRoot, array $activeAlbumKeys): void
{
    if (!is_dir($cacheRoot) || !should_run_gc(24)) {
        return;
    }

    $activeLookup = array_fill_keys($activeAlbumKeys, true);
    foreach (new DirectoryIterator($cacheRoot) as $entry) {
        if ($entry->isDot() || !$entry->isDir()) {
            continue;
        }

        $albumKey = $entry->getFilename();
        if (isset($activeLookup[$albumKey])) {
            continue;
        }

        remove_tree($entry->getPathname());
    }
}

/**
 * @return array{url: string, hash: string, album: string}
 */
function resolve_thumb(
    string $cacheRoot,
    string $appBase,
    string $folder,
    string $filename,
    string $sourcePath
): array {
    $albumKey = album_cache_key($folder);
    $mtime = @filemtime($sourcePath);
    $size = @filesize($sourcePath);

    if ($mtime === false || $size === false) {
        return ['url' => '', 'hash' => '', 'album' => $albumKey];
    }

    $hashKey = implode(
        '|',
        [
            $folder,
            $filename,
            (string) $mtime,
            (string) $size,
            (string) THUMB_WIDTH,
            (string) THUMB_QUALITY,
        ]
    );
    $hash = sha1($hashKey);
    $prefix = substr($hash, 0, 2);
    $thumbPath = $cacheRoot . '/' . $albumKey . '/' . $prefix . '/' . $hash . '.jpg';
    $thumbRelative = THUMB_CACHE_RELATIVE . '/' . $albumKey . '/' . $prefix . '/' . $hash . '.jpg';

    if (!is_file($thumbPath) || (int) @filesize($thumbPath) < 1) {
        generate_thumb($sourcePath, $thumbPath, THUMB_WIDTH, THUMB_QUALITY);
    }

    if (is_file($thumbPath) && (int) @filesize($thumbPath) > 0) {
        return [
            'url' => with_base($appBase, encode_path($thumbRelative)),
            'hash' => $hash,
            'album' => $albumKey,
        ];
    }

    return ['url' => '', 'hash' => $hash, 'album' => $albumKey];
}

/**
 * @param array<int, string> $allowExt
 * @param array<string, bool> $activeAlbumKeys
 * @return array<int, array{name: string, count: int, cover: string}>
 */
function list_folders(
    string $picsRoot,
    array $allowExt,
    string $appBase,
    string $cacheRoot,
    array &$activeAlbumKeys
): array {
    $folders = [];

    foreach (new DirectoryIterator($picsRoot) as $folderEntry) {
        if ($folderEntry->isDot() || !$folderEntry->isDir()) {
            continue;
        }

        $folderName = $folderEntry->getFilename();
        if ($folderName === '' || $folderName[0] === '.') {
            continue;
        }

        $folderPath = realpath($folderEntry->getPathname());
        if ($folderPath === false || !is_dir($folderPath) || !is_inside_root($picsRoot, $folderPath)) {
            continue;
        }

        $count = 0;
        $firstFile = null;

        foreach (new DirectoryIterator($folderPath) as $fileEntry) {
            if ($fileEntry->isDot() || !$fileEntry->isFile()) {
                continue;
            }

            $filename = $fileEntry->getFilename();
            if ($filename === '' || $filename[0] === '.') {
                continue;
            }

            $ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
            if (!in_array($ext, $allowExt, true)) {
                continue;
            }

            $count++;
            if ($firstFile === null) {
                $firstFile = [
                    'name' => $filename,
                    'path' => $fileEntry->getPathname(),
                ];
            }
        }

        if ($count < 1 || $firstFile === null) {
            continue;
        }

        $albumKey = album_cache_key($folderName);
        $activeAlbumKeys[$albumKey] = true;

        $coverUrl = with_base($appBase, 'pics/' . rawurlencode($folderName) . '/' . rawurlencode($firstFile['name']));
        $thumb = resolve_thumb($cacheRoot, $appBase, $folderName, $firstFile['name'], $firstFile['path']);
        if ($thumb['url'] !== '') {
            $coverUrl = $thumb['url'];
        }

        $folders[] = [
            'name' => $folderName,
            'count' => $count,
            'cover' => $coverUrl,
        ];
    }

    usort($folders, static fn(array $a, array $b): int => strcasecmp($a['name'], $b['name']));

    return $folders;
}

/**
 * @param array<int, string> $allowExt
 * @return array<int, array{name: string, url: string, thumb: string}>
 */
function list_images_in_folder(
    string $picsRoot,
    string $folder,
    array $allowExt,
    string $appBase,
    string $cacheRoot
): array {
    if (!is_safe_name($folder)) {
        return [];
    }

    $folderPath = realpath($picsRoot . DIRECTORY_SEPARATOR . $folder);
    if ($folderPath === false || !is_dir($folderPath) || !is_inside_root($picsRoot, $folderPath)) {
        return [];
    }

    $items = [];
    foreach (new DirectoryIterator($folderPath) as $fileEntry) {
        if ($fileEntry->isDot() || !$fileEntry->isFile()) {
            continue;
        }

        $filename = $fileEntry->getFilename();
        if ($filename === '' || $filename[0] === '.') {
            continue;
        }

        $ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
        if (!in_array($ext, $allowExt, true)) {
            continue;
        }

        $items[] = [
            'name' => $filename,
            'path' => $fileEntry->getPathname(),
        ];
    }

    usort($items, static fn(array $a, array $b): int => strnatcasecmp($a['name'], $b['name']));

    if ($items === []) {
        return [];
    }

    $albumKey = album_cache_key($folder);
    $validHashes = [];
    $images = [];

    foreach ($items as $item) {
        $sourceUrl = with_base($appBase, 'pics/' . rawurlencode($folder) . '/' . rawurlencode($item['name']));
        $thumb = resolve_thumb($cacheRoot, $appBase, $folder, $item['name'], $item['path']);

        if ($thumb['hash'] !== '') {
            $validHashes[] = $thumb['hash'];
        }

        $images[] = [
            'name' => $item['name'],
            'url' => $sourceUrl,
            'thumb' => $thumb['url'] !== '' ? $thumb['url'] : $sourceUrl,
        ];
    }

    cleanup_album_cache($cacheRoot . '/' . $albumKey, $validHashes);

    return $images;
}

$picsRoot = realpath(__DIR__ . '/../pics');
if ($picsRoot === false || !is_dir($picsRoot)) {
    out(['ok' => false, 'error' => with_base($appBase, 'pics/') . ' is missing or not readable']);
}

$allowExt = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'bmp', 'svg'];
$cacheRoot = __DIR__ . '/cache/thumbs';
ensure_dir($cacheRoot);

if ($action === 'init' || $action === 'albums') {
    $activeAlbumKeys = [];
    $folders = list_folders($picsRoot, $allowExt, $appBase, $cacheRoot, $activeAlbumKeys);
    cleanup_removed_album_caches($cacheRoot, array_keys($activeAlbumKeys));

    out(['ok' => true, 'folders' => $folders]);
}

if ($action === 'list' || $action === 'images') {
    $folderInput = isset($_GET['folder']) ? (string) $_GET['folder'] : (string) ($_GET['album'] ?? '');
    $folder = is_safe_name($folderInput) ? $folderInput : '';
    $images = list_images_in_folder($picsRoot, $folder, $allowExt, $appBase, $cacheRoot);

    out(['ok' => true, 'folder' => $folder, 'images' => $images]);
}

out(['ok' => false, 'error' => 'Unknown action']);
