<?php
/**
 * ════════════════════════════════════════════════════════════════════
 * ScreenshotStorage — every image upload in this app (trade screenshots,
 * Colex chat screenshots, and any future upload type), behind one driver
 * seam.
 * ────────────────────────────────────────────────────────────────────
 * Built 2026-08-18 for trade screenshots (trade_screenshots table),
 * generalized 2026-08-19 (pre-launch security audit remediation) to also
 * back Colex screenshots (colex_screenshots table) through the SAME
 * validation/storage/serving code, rather than a second, parallel
 * implementation — closes the audit's last open item ("Colex's own
 * screenshot upload system has no per-image DB row to check ownership
 * against") by giving it the same first-class ownership row
 * trade_screenshots already has (see api/colex/view-screenshot.php,
 * which mirrors api/journal/view-screenshot.php exactly).
 *
 * DRIVER SEAM — the reason this is a class and not free functions
 * scattered across every upload/delete endpoint: **per explicit
 * instruction (2026-08-19), ALL of this app's screenshots/images —
 * trade screenshots and Colex screenshots alike — move to Cloudflare R2
 * once this app goes live, connected via a real API/route added at that
 * point. NOT now, and not as part of this change.** Every current call
 * site only ever talks to THIS class, never to the filesystem directly,
 * so that future R2 work is a change to exactly this one file: implement
 * 'r2' branches in storeGeneric()/urlFor()/delete() below, backfill
 * existing 'local' rows if/when Kevin wants them migrated, and every
 * consumer (both upload endpoints, both ownership-checked serving
 * endpoints, the trade-screenshots list endpoint, both pages' UI) keeps
 * working unmodified.
 *
 * Today: driver is always 'local' — files live under
 * public/uploads/{namespace}/... (trade screenshots:
 * trade-screenshots/{userId}/{tradeId}/{filename}; Colex screenshots:
 * screenshots/{userId}/{filename}, matching the exact directory layout
 * api/colex/upload-screenshot.php already used before this generalization
 * — no migration of already-uploaded files needed). Neither namespace is
 * ever served as a direct static file any more (both directories now have
 * their own .htaccess `Require all denied`, see each one) — every real
 * read goes through an ownership-checked PHP endpoint
 * (api/journal/view-screenshot.php, api/colex/view-screenshot.php), which
 * is also exactly where an 'r2' branch would generate a short-lived
 * signed URL and redirect, once that's built.
 * ════════════════════════════════════════════════════════════════════
 */
class ScreenshotStorage
{
    public const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
    public const ALLOWED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
    public const MAX_SIZE_BYTES = 10 * 1024 * 1024; // 10MB, matches Colex's own upload limit.

    /** The namespace store()/localPathFor()/delete() default to when no explicit namespace is given — preserves every pre-2026-08-19 call site's exact behavior with zero changes needed at those call sites. */
    private const DEFAULT_NAMESPACE = 'trade-screenshots';

    /**
     * Validates and saves one uploaded file (a raw $_FILES['screenshot']
     * entry) for a given user/trade. Returns
     * ['storage_driver'=>'local', 'storage_key'=>.., 'mime_type'=>..,
     * 'file_size_bytes'=>.., 'original_filename'=>..] on success.
     * Throws \RuntimeException with a user-safe message on any validation
     * or filesystem failure — callers should catch and turn that message
     * into a 400/500 JSON error, never let it surface as a raw exception.
     *
     * Thin wrapper over storeGeneric() — kept as its own method (rather
     * than making every trade-screenshot call site pass a namespace) so
     * the two existing, already-verified call sites
     * (api/journal/upload-screenshot.php, the trade-screenshot delete
     * path) never needed to change during the 2026-08-19 generalization.
     */
    public static function store(string $userId, string $tradeId, array $file): array
    {
        return self::storeGeneric(self::DEFAULT_NAMESPACE, $userId . '/' . $tradeId, $file);
    }

    /**
     * Generic version of store() for any upload that isn't trade-scoped —
     * currently used by api/colex/upload-screenshot.php
     * (namespace='screenshots', subpath=$userId). Same validation, same
     * return shape, same random-filename convention; only the on-disk
     * directory differs.
     */
    public static function storeGeneric(string $namespace, string $subpath, array $file): array
    {
        if (!isset($file['error']) || $file['error'] !== UPLOAD_ERR_OK) {
            throw new \RuntimeException(match ($file['error'] ?? null) {
                UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'File is too large. Maximum size is 10MB.',
                UPLOAD_ERR_PARTIAL => 'File was only partially uploaded.',
                default => 'No file was uploaded.',
            });
        }

        $finfo = finfo_open(FILEINFO_MIME_TYPE);
        $mimeType = finfo_file($finfo, $file['tmp_name']);
        finfo_close($finfo);
        if (!in_array($mimeType, self::ALLOWED_MIME_TYPES, true)) {
            throw new \RuntimeException('Invalid file type. Only JPEG, PNG, GIF, and WebP images are allowed.');
        }

        $extension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
        if (!in_array($extension, self::ALLOWED_EXTENSIONS, true)) {
            throw new \RuntimeException('Invalid file extension. Only .jpg, .jpeg, .png, .gif, and .webp files are allowed.');
        }

        if ((int)$file['size'] > self::MAX_SIZE_BYTES) {
            throw new \RuntimeException('File is too large. Maximum size is 10MB.');
        }

        // 2026-08-19 security fix (pre-launch audit finding M-1): the
        // finfo/extension checks above only look at the file's leading
        // magic bytes — confirmed live-exploitable via a classic polyglot
        // (a file literally starting with the 6-byte "GIF89a" signature,
        // followed by arbitrary attacker content, including raw PHP
        // source) that satisfies both checks while carrying a non-image
        // payload all the way to disk. getimagesize() was evaluated as a
        // stronger check and rejected: verified live that it does NOT
        // catch this either — GIF's width/height live in the 4 bytes
        // immediately after the signature with no further structural
        // validation, so getimagesize() happily returns a (garbage but
        // non-false) width/height/mime for the exact same polyglot. This
        // server has neither GD nor Imagick installed (confirmed via
        // `php -m`), so re-encoding every upload through a real image
        // library — the strictly stronger fix, since it also strips any
        // trailing payload rather than just detecting it — isn't available
        // here; if either extension is ever enabled in this deployment,
        // switch to re-encoding instead of (not just in addition to) this
        // scan. Until then: reject any upload whose raw bytes contain a
        // PHP open tag (covers `<?php`, the always-on `<?=` short-echo
        // form, and a bare `<?` in case short_open_tag is ever enabled
        // somewhere this file is later served from), a legacy ASP-style
        // `<%` tag, or an inline `<script` tag — directly targets the
        // actual exploit primitive (embedded executable/script content)
        // rather than trying to fully validate 4 different binary image
        // formats by hand. This is a content-based denylist, not a proof
        // the file is a well-formed image — a real photo/screenshot never
        // legitimately contains any of these byte sequences, so this
        // never produces a false rejection on genuine screenshot uploads.
        $rawBytes = file_get_contents($file['tmp_name']);
        if ($rawBytes === false) {
            throw new \RuntimeException('Failed to read uploaded file.');
        }
        if (preg_match('/<\?|<%|<script\b/i', $rawBytes) === 1) {
            throw new \RuntimeException('Invalid file type. Only JPEG, PNG, GIF, and WebP images are allowed.');
        }

        $targetDir = self::localBaseDirFor($namespace) . '/' . $subpath;
        if (!is_dir($targetDir) && !mkdir($targetDir, 0755, true) && !is_dir($targetDir)) {
            throw new \RuntimeException('Failed to create upload directory.');
        }

        $filename = date('Ymd_His') . '_' . bin2hex(random_bytes(8)) . '.' . $extension;
        $destPath = $targetDir . '/' . $filename;
        if (!move_uploaded_file($file['tmp_name'], $destPath)) {
            throw new \RuntimeException('Failed to save uploaded file.');
        }

        return [
            'storage_driver' => 'local',
            'storage_key' => $subpath . '/' . $filename,
            'mime_type' => $mimeType,
            'file_size_bytes' => (int)$file['size'],
            'original_filename' => mb_substr(basename((string)$file['name']), 0, 255),
        ];
    }

    /**
     * Resolves a stored (driver, storage_key) pair into a URL the
     * browser can load directly. Not called by any live 'local'-driver
     * code path as of 2026-08-19 — both real consumers now stream through
     * their own ownership-checked endpoint instead (see this class's own
     * header comment) — kept as the seam an 'r2' branch would fill in
     * later with a real short-lived signed-URL generator, since a signed
     * R2 URL (unlike a local file) is commonly safe to hand to the browser
     * directly.
     */
    public static function urlFor(string $driver, string $storageKey, string $namespace = self::DEFAULT_NAMESPACE): string
    {
        if ($driver === 'local') {
            // DocumentRoot is C:/xampp/htdocs, one level ABOVE the
            // traderalign/ project folder, so a direct static URL would
            // need /public/ explicitly — moot today since both real
            // upload directories are .htaccess-blocked from direct access
            // regardless (2026-08-19 security fix) and nothing calls this
            // for a 'local' row any more; kept correct in case it's ever
            // needed for a debug/admin tool.
            return '/traderalign/public/uploads/' . $namespace . '/' . $storageKey;
        }
        // Deliberately not implemented — see header comment. A row with
        // an unrecognized/future driver value fails loudly rather than
        // silently returning a broken local path.
        throw new \RuntimeException("Unsupported screenshot storage driver: {$driver}");
    }

    /** Removes the underlying file for a (driver, storage_key) pair. Never throws on a missing file — deleting something already gone is not an error here. */
    public static function delete(string $driver, string $storageKey, string $namespace = self::DEFAULT_NAMESPACE): void
    {
        if ($driver !== 'local') {
            throw new \RuntimeException("Unsupported screenshot storage driver: {$driver}");
        }
        $path = self::localBaseDirFor($namespace) . '/' . $storageKey;
        if (is_file($path)) {
            @unlink($path);
        }
    }

    /**
     * Resolves a 'local'-driver storage_key to the real filesystem path —
     * used by api/journal/view-screenshot.php and
     * api/colex/view-screenshot.php (the two ownership-checked places a
     * screenshot is actually streamed from, 2026-08-19 security fix).
     * Guards against a storage_key containing '..' path-traversal
     * segments, even though every real caller only ever passes a value
     * this class itself generated (userId/tradeId-or-conversationId/
     * random-filename, never user-supplied) — defense-in-depth, not
     * because a live path has been found.
     */
    public static function localPathFor(string $storageKey, string $namespace = self::DEFAULT_NAMESPACE): string
    {
        if (strpos($storageKey, '..') !== false) {
            throw new \RuntimeException('Invalid storage key.');
        }
        return self::localBaseDirFor($namespace) . '/' . $storageKey;
    }

    private static function localBaseDirFor(string $namespace): string
    {
        return __DIR__ . '/../public/uploads/' . $namespace;
    }
}
