<?php
/**
 * Media library — plugin bootstrap and shared API.
 *
 * Filesystem-of-truth, DB-as-cache. Files live under
 * SLATE_ROOT/uploads/shop/products (and its subfolders: variants,
 * gallery). We scan that tree directly; the medialibrary_files table
 * just remembers dimensions and original filenames so we don't have
 * to getimagesize() on every page render.
 *
 * Why no folders/tags/alt-text/etc:
 *   At 30-product scale you have ~150 images. Linear browse with
 *   pagination is fine. Folders and tags become useful past ~500
 *   images; revisit then.
 *
 * Why scan filesystem instead of being DB-driven:
 *   The store already has product images on disk from before this
 *   plugin existed. A pure-DB media library would require a backfill
 *   migration to discover them, which is fragile (what if there are
 *   files the migration missed? Then they're invisible). Scanning
 *   means everything that exists is visible, always.
 *
 * Where this plugin reads from:
 *   uploads/shop/products/                  — main product images
 *   uploads/shop/products/variants/         — per-variant images
 *   uploads/shop/products/gallery/          — product gallery images
 *
 * We do NOT touch uploads/branding/ (the Slate logo) or any other
 * plugin's uploads/<slug>/ tree. Cross-plugin storage is out of scope.
 */

class MediaLibrary extends Plugin {

    /**
     * The set of upload subfolders, relative to SLATE_ROOT/uploads/.
     * Each is rendered as one "location" in the library and scanned
     * independently. We intentionally enumerate these rather than
     * auto-discovering so an out-of-band sibling folder (e.g. some
     * other plugin dropping files in uploads/shop/random/) doesn't
     * silently appear in the media list.
     *
     * 'branding' is included so the Slate brand logo can be managed
     * from here too — Settings → Branding looks up brand_logo_path
     * from the settings table, and that path is exact-string matched
     * against this scan in buildUsageMap() so the current logo is
     * marked "In use" and can't be accidentally deleted.
     */
    public const SCAN_FOLDERS = [
        'shop/products',
        'shop/products/gallery',
        'shop/products/variants',
        'branding',
    ];

    /** Allowed image extensions for upload + display. */
    public const ALLOWED_EXTS  = ['jpg', 'jpeg', 'png', 'webp', 'gif'];
    public const ALLOWED_MIMES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];

    public function boot(): void {
        Hook::addFilter('admin_nav_items', [$this, 'addAdminNav']);
        Hook::addFilter('admin_dashboard_widgets', [$this, 'addDashboardWidget']);
    }

    public function addAdminNav(array $items): array {
        if (!Auth::can('media.view') && !Auth::isSuperAdmin()) {
            return $items;
        }
        $items[] = [
            'slug'   => 'media-library',
            'label'  => __('media_library', 'Media'),
            'href'   => $this->url('admin/index.php'),
            'icon'   => 'image',
            'order'  => 518,
            'group'  => 'shop',
        ];
        return $items;
    }

    /** Dashboard widget — library size + recent uploads. Only while active. */
    public function addDashboardWidget(array $widgets): array {
        if (!Auth::can('media.view') && !Auth::isSuperAdmin()) return $widgets;
        $tid = current_tenant_id();
        try {
            $files  = (int) Database::value("SELECT COUNT(*) FROM medialibrary_files WHERE tenant_id = ?", [$tid]);
            $bytes  = (int) Database::value("SELECT COALESCE(SUM(size_bytes),0) FROM medialibrary_files WHERE tenant_id = ?", [$tid]);
            $recent = (int) Database::value("SELECT COUNT(*) FROM medialibrary_files WHERE tenant_id = ? AND uploaded_at >= NOW() - INTERVAL 7 DAY", [$tid]);
            $latest = Database::rows("SELECT original_name, path, mime, size_bytes, uploaded_at FROM medialibrary_files WHERE tenant_id = ? ORDER BY id DESC LIMIT 5", [$tid]);
        } catch (\Throwable $e) {
            return $widgets;
        }
        $fmt = static function (int $b): string {
            $units = ['B', 'KB', 'MB', 'GB', 'TB']; $s = (float)$b; $u = 0;
            while ($s >= 1024 && $u < count($units) - 1) { $s /= 1024; $u++; }
            return ($u === 0 ? (string)(int)$s : number_format($s, 1)) . ' ' . $units[$u];
        };
        $libUrl = $this->url('admin/index.php');

        ob_start(); ?>
        <div class="card">
            <div class="card-header">
                <h2><?= __('media_library', 'Media') ?></h2>
                <a href="<?= e($libUrl) ?>" class="dwidget-all"><?= __('view_all', 'View all') ?> →</a>
            </div>
            <div class="dwidget-kpis">
                <div class="dwidget-kpi">
                    <div class="dwidget-kpi-k"><?= __('media_files', 'Files') ?></div>
                    <div class="dwidget-kpi-v"><?= $files ?></div>
                </div>
                <div class="dwidget-kpi">
                    <div class="dwidget-kpi-k"><?= __('media_storage', 'Storage') ?></div>
                    <div class="dwidget-kpi-v"><?= e($fmt($bytes)) ?></div>
                </div>
            </div>
            <?php if ($latest): ?>
                <div class="dlist">
                    <?php foreach ($latest as $m):
                        $name = (string)($m['original_name'] ?? '') ?: basename((string)$m['path']);
                        $ext  = strtoupper(pathinfo($name, PATHINFO_EXTENSION) ?: '?');
                        slate_dlist_row([
                            'avatar'       => $ext,
                            'avatar_color' => 'muted',
                            'title'        => $name,
                            'sub'          => (string)($m['mime'] ?? ''),
                            'amount'       => $fmt((int)$m['size_bytes']),
                            'time'         => $m['uploaded_at'] ? date('M j', strtotime($m['uploaded_at'])) : '',
                            'href'         => $libUrl,
                        ]);
                    endforeach; ?>
                </div>
            <?php else: ?>
                <div class="dlist-empty"><?= __('media_no_files', 'No files yet') ?></div>
            <?php endif; ?>
        </div>
        <?php
        $widgets[] = ob_get_clean();
        return $widgets;
    }

    // ──────────────────────────────────────────────────────────
    // Scan + metadata
    // ──────────────────────────────────────────────────────────

    /**
     * List media files. Returns an array of items, each with:
     *   path           '/uploads/shop/products/abc.webp'
     *   url            absolute URL
     *   mime           sniffed from disk
     *   size_bytes
     *   width, height  (may be null if getimagesize() fails)
     *   original_name  (from cache, or '' if pre-existing)
     *   uploaded_at    (from cache, or filemtime fallback)
     *   in_use         bool — does anything reference this path?
     *
     * The full result is built per request; for the store's scale
     * (hundreds of images) it's fast enough. Pagination is done by
     * the caller slicing the array.
     */
    public static function listAll(): array {
        $tid = current_tenant_id();
        $files = [];

        // Pre-load every cache row into memory so we don't N+1 the
        // metadata lookup. The cache is small (~100s of rows) so this
        // is a single small SELECT.
        $cache = [];
        try {
            $rows = Database::rows(
                "SELECT * FROM medialibrary_files WHERE tenant_id = ?",
                [$tid]
            );
            foreach ($rows as $r) {
                $cache[$r['path']] = $r;
            }
        } catch (\Throwable $e) {
            // Plugin probably not activated yet; degrade to filesystem-only.
            slate_log('media-library cache lookup failed: ' . $e->getMessage(), 'warning');
        }

        $usageMap = self::buildUsageMap();

        foreach (self::SCAN_FOLDERS as $folder) {
            $dir = SLATE_ROOT . '/uploads/' . $folder;
            if (!is_dir($dir)) continue;
            $entries = @scandir($dir) ?: [];
            foreach ($entries as $name) {
                if ($name === '' || $name[0] === '.') continue;
                $full = $dir . '/' . $name;
                if (!is_file($full)) continue;
                $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
                if (!in_array($ext, self::ALLOWED_EXTS, true)) continue;

                $rel = '/uploads/' . $folder . '/' . $name;
                $row = $cache[$rel] ?? null;

                // Lazily compute size/dimensions if no cache row.
                if ($row === null) {
                    $size = @filesize($full) ?: 0;
                    $w = null; $h = null; $mime = '';
                    $imageInfo = @getimagesize($full);
                    if (is_array($imageInfo)) {
                        $w = $imageInfo[0] ?? null;
                        $h = $imageInfo[1] ?? null;
                        $mime = $imageInfo['mime'] ?? '';
                    }
                    $row = [
                        'path'          => $rel,
                        'size_bytes'    => $size,
                        'width'         => $w,
                        'height'        => $h,
                        'mime'          => $mime,
                        'original_name' => $name, // fall back to stored name
                        'uploaded_at'   => date('Y-m-d H:i:s', @filemtime($full) ?: time()),
                    ];
                }

                $files[] = [
                    'path'          => $rel,
                    'url'           => SLATE_URL . $rel,
                    'mime'          => $row['mime'] ?? '',
                    'size_bytes'    => (int)($row['size_bytes'] ?? 0),
                    'width'         => $row['width'] !== null ? (int)$row['width'] : null,
                    'height'        => $row['height'] !== null ? (int)$row['height'] : null,
                    'original_name' => (string)($row['original_name'] ?? $name),
                    'uploaded_at'   => (string)($row['uploaded_at'] ?? ''),
                    'in_use'        => isset($usageMap[$rel]) && $usageMap[$rel] > 0,
                    'usage_count'   => $usageMap[$rel] ?? 0,
                ];
            }
        }

        // Newest first — admins are usually looking for what they just
        // uploaded. Pre-existing files (with no cache row) fall back to
        // filemtime, which is good enough for ordering.
        usort($files, fn($a, $b) => strcmp($b['uploaded_at'], $a['uploaded_at']));
        return $files;
    }

    /**
     * Register a newly uploaded file in the metadata cache. Called
     * from the upload handler in admin/index.php. Idempotent — if a
     * row already exists for this path (e.g. someone re-uploaded a
     * file with the same name, which Uploads::handle() prevents but
     * paranoia is cheap), we just update.
     */
    public static function register(string $path, array $meta): void {
        $tid = current_tenant_id();
        Database::query(
            "INSERT INTO medialibrary_files
                (tenant_id, path, mime, size_bytes, width, height, original_name, uploaded_by)
             VALUES (?, ?, ?, ?, ?, ?, ?, ?)
             ON DUPLICATE KEY UPDATE
                mime = VALUES(mime),
                size_bytes = VALUES(size_bytes),
                width = VALUES(width),
                height = VALUES(height),
                original_name = VALUES(original_name),
                uploaded_by = VALUES(uploaded_by)",
            [
                $tid, $path,
                (string)($meta['mime']         ?? ''),
                (int)   ($meta['size_bytes']   ?? 0),
                $meta['width']  ?? null,
                $meta['height'] ?? null,
                (string)($meta['original_name'] ?? ''),
                Auth::userId(),
            ]
        );
    }

    /**
     * Drop the cache row for a path. Used after the file is deleted.
     */
    public static function unregister(string $path): void {
        $tid = current_tenant_id();
        Database::delete('medialibrary_files',
            'tenant_id = ? AND path = ?', [$tid, $path]);
    }

    // ──────────────────────────────────────────────────────────
    // Usage queries
    // ──────────────────────────────────────────────────────────

    /**
     * Build a map of path → reference count across all known users.
     * Currently four users of image paths:
     *   - shop_products.image_url             (single path)
     *   - shop_products.gallery_urls          (JSON array of paths)
     *   - shop_product_variants.image_url     (single path)
     *   - settings(brand_logo_path)           (single path, Slate core)
     *
     * We do this in one pass so listAll() doesn't have to query per
     * file. With ~30 products + variants the total scan is well under
     * 100 rows.
     *
     * Branding logo paths are stored without a leading slash in some
     * legacy installations (e.g. 'uploads/branding/abc.png' rather
     * than '/uploads/branding/abc.png'). We normalise both shapes
     * before bumping the count so the live logo is correctly marked
     * "in use" no matter which convention is in the DB.
     *
     * Returns ['/uploads/shop/products/abc.webp' => 3, ...]
     */
    public static function buildUsageMap(): array {
        return self::collectReferences()['by_path'];
    }

    /**
     * Walk every reference in the DB and return both the usage map
     * (for the listing) and a list of broken references (for the
     * diagnostics panel). One pass; both consumers re-use the result.
     *
     * The 'broken' list is references in the DB that point to a path
     * the filesystem scan doesn't see — i.e. an image_url whose file
     * was deleted out from under it. This is the most common
     * "phantom" reason for products to lose their thumbnails.
     *
     * Returns:
     *   [
     *     'by_path' => ['/uploads/shop/products/abc.webp' => 2, ...],
     *     'broken'  => [
     *       ['source'=>'product', 'id'=>15, 'name'=>'Widget', 'path'=>'/uploads/shop/products/missing.webp'],
     *       ...
     *     ],
     *   ]
     */
    public static function collectReferences(): array {
        $tid = current_tenant_id();
        $byPath = [];
        $broken = [];

        // Build the set of paths that exist on disk so we can flag
        // missing references. We use the same scan logic as listAll()
        // but only collect paths — no metadata.
        $diskPaths = [];
        foreach (self::SCAN_FOLDERS as $folder) {
            $dir = SLATE_ROOT . '/uploads/' . $folder;
            if (!is_dir($dir)) continue;
            $entries = @scandir($dir) ?: [];
            foreach ($entries as $name) {
                if ($name === '' || $name[0] === '.') continue;
                if (!is_file($dir . '/' . $name)) continue;
                $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
                if (!in_array($ext, self::ALLOWED_EXTS, true)) continue;
                $diskPaths['/uploads/' . $folder . '/' . $name] = true;
            }
        }

        $normalize = function ($p) {
            $p = trim((string)$p);
            if ($p === '') return '';
            if ($p[0] !== '/' && !preg_match('#^https?://#i', $p)) {
                $p = '/' . $p;
            }
            return $p;
        };
        $bump = function ($p) use (&$byPath) {
            if ($p !== '') $byPath[$p] = ($byPath[$p] ?? 0) + 1;
        };
        $flagBroken = function ($source, $id, $name, $p) use (&$broken, $diskPaths) {
            if ($p === '' || preg_match('#^https?://#i', $p)) return; // external URLs aren't local files
            if (!isset($diskPaths[$p])) {
                $broken[] = ['source' => $source, 'id' => $id, 'name' => $name, 'path' => $p];
            }
        };

        // Products: main image + gallery
        try {
            $rows = Database::rows(
                "SELECT id, name, image_url, gallery_urls FROM shop_products WHERE tenant_id = ?",
                [$tid]
            );
            foreach ($rows as $r) {
                $p = $normalize($r['image_url'] ?? '');
                if ($p !== '') {
                    $bump($p);
                    $flagBroken('product', (int)$r['id'], (string)$r['name'], $p);
                }
                $gallery = (string)($r['gallery_urls'] ?? '');
                if ($gallery !== '') {
                    $decoded = json_decode($gallery, true);
                    if (is_array($decoded)) {
                        foreach ($decoded as $g) {
                            if (!is_string($g)) continue;
                            $p = $normalize($g);
                            $bump($p);
                            $flagBroken('product.gallery', (int)$r['id'],
                                (string)$r['name'], $p);
                        }
                    }
                }
            }
        } catch (\Throwable $e) { /* shop_products may not exist */ }

        // Variants
        try {
            $rows = Database::rows(
                "SELECT v.id, v.image_url, v.attribute, v.value, p.name AS product_name
                   FROM shop_product_variants v
                   JOIN shop_products p ON p.id = v.product_id
                  WHERE p.tenant_id = ?
                    AND v.image_url IS NOT NULL
                    AND v.image_url <> ''",
                [$tid]
            );
            foreach ($rows as $r) {
                $p = $normalize($r['image_url']);
                $bump($p);
                $label = $r['product_name'] . ' [' . $r['attribute'] . ': ' . $r['value'] . ']';
                $flagBroken('variant', (int)$r['id'], $label, $p);
            }
        } catch (\Throwable $e) { /* may not exist */ }

        // Branding logo
        try {
            $logo = Database::setting('brand_logo_path');
            if (is_string($logo) && $logo !== '') {
                $p = $normalize($logo);
                $bump($p);
                $flagBroken('branding', 0, 'Site logo', $p);
            }
        } catch (\Throwable $e) { /* unlikely */ }

        return ['by_path' => $byPath, 'broken' => $broken];
    }

    // ──────────────────────────────────────────────────────────
    // Delete with safety
    // ──────────────────────────────────────────────────────────

    /**
     * Delete a media file. Refuses if anything still references it.
     *
     * Returns ['ok' => bool, 'error' => string|null, 'usage_count' => int|null].
     */
    public static function delete(string $path): array {
        // Validate the path shape — we only ever accept paths under
        // /uploads/shop/products/ (and its known subfolders). Anything
        // else is rejected, which means a forged form field can't trick
        // us into deleting from outside the library's domain.
        if (!self::isManagedPath($path)) {
            return ['ok' => false, 'error' => 'Path is not managed by the media library.'];
        }

        $usage = self::buildUsageMap();
        $count = $usage[$path] ?? 0;
        if ($count > 0) {
            return [
                'ok'          => false,
                'error'       => sprintf(
                    'Cannot delete: this image is used by %d product%s. Remove the references first.',
                    $count, $count === 1 ? '' : 's'
                ),
                'usage_count' => $count,
            ];
        }

        // Use Slate's Uploads::remove() — it has its own
        // "refuse to delete outside /uploads/" sanity check.
        $removed = Uploads::remove($path);
        if (!$removed) {
            return ['ok' => false, 'error' => 'File could not be deleted (may already be gone).'];
        }

        self::unregister($path);
        return ['ok' => true, 'error' => null, 'usage_count' => 0];
    }

    /**
     * Is $path one we'll manage? Strict because we use this for
     * delete-target validation.
     */
    public static function isManagedPath(string $path): bool {
        if ($path === '') return false;
        // No traversal, no absolute-disk paths.
        if (str_contains($path, '..')) return false;
        if (str_contains($path, '\\')) return false;
        foreach (self::SCAN_FOLDERS as $folder) {
            $prefix = '/uploads/' . $folder . '/';
            if (str_starts_with($path, $prefix)) {
                // Whatever follows the prefix must NOT contain another
                // slash — files only live directly in the scan folder,
                // not in subfolders.
                $tail = substr($path, strlen($prefix));
                if (strpos($tail, '/') === false && $tail !== '') return true;
            }
        }
        return false;
    }
}
