<?php

declare(strict_types=1);

namespace PpPhp\Core\Storage;

/**
 * StorageRef — the portable file reference stored in a record column.
 *
 * A File/Image column no longer holds a bare path; it holds a compact JSON
 * reference (or a JSON array of them for multi-file fields) so the app is
 * storage-agnostic and can render without a second query:
 *
 *   {"v":1,"driver":"local","key":"2026/08/ab12cd.jpg","name":"beach.jpg",
 *    "size":18234,"mime":"image/jpeg","w":1600,"h":1200,"thumb":"2026/08/ab12cd_t.jpg"}
 *
 * BACKWARD COMPATIBILITY is a hard requirement: millions of existing rows (and
 * PHPRunner-style imports) hold a bare filename / path / URL. decode() detects
 * that and wraps it as a local ref via legacy(), so nothing needs migrating.
 *
 * This class is pure value-logic (no I/O) so it is safe in both the console and
 * the generated app.
 */
final class StorageRef
{
    public const VERSION = 1;

    /** Fields we keep in a normalised ref (others are dropped on normalise). */
    private const KEYS = ['v', 'driver', 'key', 'name', 'size', 'mime', 'w', 'h', 'thumb', 'url', 'alt'];

    /**
     * Decode a stored column value into a LIST of ref arrays (0..n).
     * Accepts: a JSON object, a JSON array of objects, a bare string
     * (legacy path/filename/URL), a comma/semicolon/newline-separated list of
     * legacy paths (PHPRunner multi-upload style), or empty.
     *
     * @return array<int,array<string,mixed>>
     */
    public static function decode(mixed $value): array
    {
        if ($value === null || $value === '' || $value === false) {
            return [];
        }
        if (is_array($value)) {
            // Already an array: could be one ref or a list of refs.
            if (self::looksLikeRef($value)) {
                return [self::normalise($value)];
            }
            $out = [];
            foreach ($value as $item) {
                foreach (self::decode($item) as $r) { $out[] = $r; }
            }
            return $out;
        }

        $s = trim((string) $value);
        if ($s === '') return [];

        // JSON object or array?
        if ($s[0] === '{' || $s[0] === '[') {
            $j = json_decode($s, true);
            if (is_array($j)) {
                if (self::looksLikeRef($j)) return [self::normalise($j)];
                $out = [];
                foreach ($j as $item) {
                    if (is_array($item) && self::looksLikeRef($item)) {
                        $out[] = self::normalise($item);
                    } elseif (is_string($item) && trim($item) !== '') {
                        $out[] = self::legacy($item);
                    }
                }
                if ($out !== []) return $out;
            }
            // Fell through: treat the whole thing as a legacy string.
        }

        // Legacy: a bare path/filename/URL, possibly a delimited list.
        $parts = preg_split('/[\r\n;,]+/', $s) ?: [$s];
        $out = [];
        foreach ($parts as $p) {
            $p = trim($p);
            if ($p !== '') $out[] = self::legacy($p);
        }
        return $out;
    }

    /**
     * Encode a list of refs back into the column string.
     * $multi controls the shape: a single-file field stores one object; a
     * multi-file field always stores a JSON array (even for one file) so the
     * reader knows the field is a collection.
     */
    public static function encode(array $refs, bool $multi = false): string
    {
        $refs = array_values(array_filter(array_map(
            static fn($r) => is_array($r) ? self::normalise($r) : null,
            $refs
        )));
        if ($refs === []) return '';
        if (!$multi && count($refs) === 1) {
            return (string) json_encode($refs[0], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
        }
        return (string) json_encode($refs, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
    }

    /** Wrap a legacy bare path/filename/URL as a local reference. */
    public static function legacy(string $path): array
    {
        $path = trim($path);
        $isUrl = (bool) preg_match('#^(https?:)?//#i', $path) || str_starts_with($path, 'data:');
        $name  = $isUrl ? (basename(parse_url($path, PHP_URL_PATH) ?: $path) ?: $path) : basename($path);
        $ext   = strtolower((string) pathinfo($name, PATHINFO_EXTENSION));
        $ref = [
            'v'      => self::VERSION,
            'driver' => 'local',
            'key'    => $path,          // key == the stored path/URL for legacy rows
            'name'   => $name,
            'mime'   => self::mimeForExt($ext),
        ];
        if ($isUrl) $ref['url'] = $path; // external URL: render directly
        return self::normalise($ref);
    }

    /** True when a value that could be image content should be rendered as <img>. */
    public static function isImage(array $ref): bool
    {
        $mime = (string) ($ref['mime'] ?? '');
        if (str_starts_with($mime, 'image/')) return true;
        $ext = strtolower((string) pathinfo((string) ($ref['name'] ?? $ref['key'] ?? ''), PATHINFO_EXTENSION));
        return in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'avif', 'ico'], true);
    }

    /** A short human file-size label, e.g. "1.4 MB". */
    public static function humanSize(int $bytes): string
    {
        if ($bytes <= 0) return '';
        $units = ['B', 'KB', 'MB', 'GB', 'TB'];
        $i = (int) floor(log($bytes, 1024));
        $i = max(0, min($i, count($units) - 1));
        $n = $bytes / (1024 ** $i);
        return ($i === 0 ? (string) $bytes : number_format($n, $n >= 100 ? 0 : 1)) . ' ' . $units[$i];
    }

    /** Normalise/whitelist a ref array (stamp version, coerce ints, drop junk). */
    public static function normalise(array $ref): array
    {
        $out = [];
        foreach (self::KEYS as $k) {
            if (!array_key_exists($k, $ref)) continue;
            $v = $ref[$k];
            if (in_array($k, ['size', 'w', 'h'], true)) {
                $v = (int) $v;
                if ($v <= 0) continue;
            } elseif (is_string($v)) {
                $v = trim($v);
                if ($v === '') continue;
            }
            $out[$k] = $v;
        }
        $out['v'] = self::VERSION;
        $out['driver'] = (string) ($out['driver'] ?? 'local');
        if (($out['key'] ?? '') === '' && ($out['url'] ?? '') !== '') {
            $out['key'] = $out['url'];
        }
        if (($out['name'] ?? '') === '') {
            $out['name'] = basename((string) ($out['key'] ?? '')) ?: 'file';
        }
        return $out;
    }

    /** Heuristic: does this array look like a single ref (vs a list)? */
    private static function looksLikeRef(array $a): bool
    {
        if ($a === []) return false;
        // A list has 0-based sequential int keys AND its first item is itself a ref/string.
        $isList = array_keys($a) === range(0, count($a) - 1);
        if (!$isList) {
            return isset($a['key']) || isset($a['url']) || isset($a['driver']) || isset($a['name']);
        }
        return false;
    }

    private static function mimeForExt(string $ext): string
    {
        static $map = [
            'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png',
            'gif' => 'image/gif', 'webp' => 'image/webp', 'svg' => 'image/svg+xml',
            'bmp' => 'image/bmp', 'avif' => 'image/avif', 'ico' => 'image/x-icon',
            'pdf' => 'application/pdf', 'txt' => 'text/plain', 'csv' => 'text/csv',
            'doc' => 'application/msword', 'xls' => 'application/vnd.ms-excel',
            'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
            'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
            'ppt' => 'application/vnd.ms-powerpoint',
            'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
            'zip' => 'application/zip', 'mp3' => 'audio/mpeg', 'wav' => 'audio/wav',
            'ogg' => 'audio/ogg', 'mp4' => 'video/mp4', 'webm' => 'video/webm', 'mov' => 'video/quicktime',
        ];
        return $map[$ext] ?? 'application/octet-stream';
    }
}
