<?php

declare(strict_types=1);

namespace App\Services;

use App\Core\Logger;

/**
 * MediaService — Central Media Upload, Delete, and Path Engine (G004)
 *
 * This service is the single source of truth for all media file operations
 * in INVEETAIRE. Every module that needs to handle file uploads should use
 * this service instead of implementing its own upload logic.
 *
 * Supported operations:
 *   - upload()       Upload and validate a file into the correct slot directory.
 *   - delete()       Remove a file from disk given its relative storage path.
 *   - getSlotConfig() Return limits and MIME constraints for a named slot.
 *   - buildStorageDir() Return the absolute disk path for a given couple+slot.
 *   - getRelativePath() Convert absolute path to storage-relative path.
 *   - serveFile()    Stream a private file to the browser (ownership must be
 *                    verified by the caller before invoking this method).
 *
 * Storage hierarchy:
 *   storage/uploads/couples/{workspace_slug}/{slot}/{uuid}.{ext}
 *
 * Security:
 *   - Files are stored outside the web root (storage/ is .htaccess denied).
 *   - MIME type is verified via finfo, NOT by file extension alone.
 *   - Original filenames are NEVER used on disk — UUIDs are generated.
 *   - Slot path traversal is prevented by allowlist-only slot resolution.
 *
 * Extension points (future sprints):
 *   - Cropper.js integration: pass file through CropperService before upload().
 *   - WebP conversion: pipe through ImageOptimiserService before writing to disk.
 *   - Music player: AudioPlayerService reads music_file path via this service.
 *
 * @package Inveetaire
 * @ref     G004
 */
class MediaService
{
    /**
     * Absolute path to the media base directory.
     * e.g. /var/www/inveetaire/storage/uploads/couples
     */
    private string $mediaBase;

    /**
     * Slot configuration map loaded from config/storage.php.
     * @var array<string, array>
     */
    private array $slots;

    public function __construct()
    {
        $this->mediaBase = (string) config('storage.media_base');
        $this->slots     = (array)  config('storage.media_slots', []);
    }

    // ─────────────────────────────────────────────────────────────────────────
    // PUBLIC API
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * Upload a file to the designated slot directory.
     *
     * Validates MIME type and size against slot constraints, generates a
     * UUID-based filename, and moves the file from the PHP temp dir to the
     * permanent slot directory.
     *
     * @param string $slot         Slot name (e.g. 'hero', 'gallery', 'music_file').
     * @param array  $file         Entry from $_FILES['field']. Must contain
     *                             'tmp_name', 'name', 'size', 'error' keys.
     * @param string $coupleCode   workspace.slug — used as storage subdirectory.
     * @return string              Relative storage path (relative to storage/uploads/couples/).
     *                             e.g. "abc123/hero/550e8400-e29b-41d4-a716-446655440000.jpg"
     * @throws \RuntimeException   When validation fails or the file cannot be moved.
     */
    public function upload(string $slot, array $file, string $coupleCode): string
    {
        $slotConfig = $this->getSlotConfig($slot);

        // 1. PHP upload error check
        if ((int) ($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
            throw new \RuntimeException($this->phpUploadError((int) $file['error']));
        }

        // 2. Validate MIME type via finfo (not extension)
        $mime = $this->detectMime((string) ($file['tmp_name'] ?? ''));
        if (!in_array($mime, $slotConfig['allowed'], true)) {
            $allowed = implode(', ', $slotConfig['allowed']);
            throw new \RuntimeException(
                "File type '{$mime}' is not allowed for {$slotConfig['label']}. Allowed: {$allowed}."
            );
        }

        // 3. Validate file size
        $size = (int) ($file['size'] ?? 0);
        if ($size > $slotConfig['max_size']) {
            $maxMb = round($slotConfig['max_size'] / 1024 / 1024, 1);
            throw new \RuntimeException(
                "File exceeds the maximum size of {$maxMb} MB for {$slotConfig['label']}."
            );
        }

        if ($size === 0) {
            throw new \RuntimeException('Uploaded file is empty.');
        }

        // 4. Resolve extension from MIME (not from original filename)
        $isImage = (strncmp($mime, 'image/', 6) === 0);
        if ($isImage) {
            $ext = 'webp';
        } else {
            $ext = $this->mimeToExtension($mime);
        }

        // 5. Generate UUID filename
        $uuid     = $this->generateUuid();
        $filename = $uuid . '.' . $ext;

        // 6. Ensure target directory exists
        $dir = $this->buildStorageDir($coupleCode, $slot);
        $this->ensureDirectory($dir);

        // 7. Process image or move file to permanent location
        $destination = $dir . DIRECTORY_SEPARATOR . $filename;

        if ($isImage) {
            try {
                $imageProcessor = new ImageProcessor();
                $cropParams = null;
                $postData = $_POST;
                if (isset($postData['crop_x'], $postData['crop_y'], $postData['crop_w'], $postData['crop_h'])) {
                    $cropParams = [
                        'x'      => (int) $postData['crop_x'],
                        'y'      => (int) $postData['crop_y'],
                        'width'  => (int) $postData['crop_w'],
                        'height' => (int) $postData['crop_h'],
                    ];
                }
                $imageProcessor->process((string) ($file['tmp_name'] ?? ''), $destination, $slotConfig, $cropParams);
                $size = (int) filesize($destination);
            } catch (\Throwable $e) {
                Logger::error('MediaService: Image processing failed', [
                    'slot'        => $slot,
                    'couple_code' => $coupleCode,
                    'error'       => $e->getMessage()
                ]);
                throw new \RuntimeException('Image processing failed: ' . $e->getMessage());
            }
        } else {
            if (!move_uploaded_file((string) ($file['tmp_name'] ?? ''), $destination)) {
                Logger::error('MediaService: move_uploaded_file failed', [
                    'slot'        => $slot,
                    'couple_code' => $coupleCode,
                    'destination' => $destination,
                ]);
                throw new \RuntimeException('Failed to save uploaded file. Please try again.');
            }
        }

        Logger::info('MediaService: file uploaded and processed', [
            'slot'          => $slot,
            'couple_code'   => $coupleCode,
            'filename'      => $filename,
            'mime'          => $isImage ? 'image/webp' : $mime,
            'size'          => $size,
        ]);

        // Return relative path: {coupleCode}/{slot}/{filename}
        return $coupleCode . '/' . $slot . '/' . $filename;
    }

    /**
     * Delete a file from disk given its relative storage path.
     *
     * The relative path is as returned by upload() — e.g.
     * "abc123/hero/550e8400-e29b-41d4-a716-446655440000.jpg"
     *
     * This method is idempotent: it returns true even if the file does not
     * exist on disk (it may have been cleaned up already).
     *
     * @param string $relativePath  Relative path within media_base.
     * @return bool                 True on success or if file was already absent.
     */
    public function delete(string $relativePath): bool
    {
        if (empty(trim($relativePath))) {
            return true;
        }

        // Prevent path traversal by resolving against the media base
        $absolutePath = $this->toAbsolutePath($relativePath);
        if (!$this->isWithinMediaBase($absolutePath)) {
            Logger::warning('MediaService: delete() rejected path traversal attempt', [
                'relative_path' => $relativePath,
                'absolute_path' => $absolutePath,
            ]);
            return false;
        }

        if (!file_exists($absolutePath)) {
            return true;  // Already gone — idempotent
        }

        $result = @unlink($absolutePath);

        if (!$result) {
            Logger::error('MediaService: unlink() failed', [
                'relative_path' => $relativePath,
                'absolute_path' => $absolutePath,
            ]);
        } else {
            Logger::info('MediaService: file deleted', [
                'relative_path' => $relativePath,
            ]);
        }

        return $result;
    }

    /**
     * Stream a private storage file to the browser.
     *
     * IMPORTANT: The caller MUST validate workspace ownership before calling
     * this method. This method does NOT check access rights — it only streams.
     *
     * @param string $relativePath  Relative path within media_base.
     * @return void
     * @throws \RuntimeException    If the file does not exist.
     */
    public function serveFile(string $relativePath): void
    {
        $absolutePath = $this->toAbsolutePath($relativePath);

        if (!$this->isWithinMediaBase($absolutePath) || !file_exists($absolutePath)) {
            throw new \RuntimeException('File not found.');
        }

        $mime = $this->detectMime($absolutePath);
        $size = filesize($absolutePath);

        header('Content-Type: ' . $mime);
        header('Content-Length: ' . $size);
        header('Cache-Control: private, max-age=86400');
        header('Accept-Ranges: bytes');
        header('X-Content-Type-Options: nosniff');

        readfile($absolutePath);
        exit;
    }

    /**
     * Return the slot configuration for a given slot name.
     *
     * @param string $slot  Slot name (e.g. 'hero', 'gallery').
     * @return array        Slot config array.
     * @throws \InvalidArgumentException  If the slot is not registered.
     */
    public function getSlotConfig(string $slot): array
    {
        if (!isset($this->slots[$slot])) {
            throw new \InvalidArgumentException("Unknown media slot: '{$slot}'.");
        }
        return $this->slots[$slot];
    }

    /**
     * Return all registered slot configurations.
     *
     * @return array<string, array>
     */
    public function getAllSlots(): array
    {
        return $this->slots;
    }

    /**
     * Build the absolute disk path for a given couple + slot combination.
     *
     * e.g. /var/www/inveetaire/storage/uploads/couples/abc123/hero
     *
     * @param string $coupleCode  workspace.slug
     * @param string $slot        Slot name
     * @return string             Absolute directory path (no trailing slash)
     */
    public function buildStorageDir(string $coupleCode, string $slot): string
    {
        // Sanitize coupleCode — only allow alphanumeric, hyphens, underscores
        $safeCode = preg_replace('/[^a-zA-Z0-9_\-]/', '', $coupleCode);
        if (empty($safeCode)) {
            throw new \InvalidArgumentException('Invalid couple code.');
        }

        // Slot must be registered — prevents directory traversal via slot name
        $this->getSlotConfig($slot);
        $safeSlot = preg_replace('/[^a-zA-Z0-9_\-]/', '', $slot);

        return $this->mediaBase . DIRECTORY_SEPARATOR . $safeCode . DIRECTORY_SEPARATOR . $safeSlot;
    }

    /**
     * Convert a relative storage path to an absolute filesystem path.
     *
     * @param string $relativePath  e.g. "abc123/hero/uuid.jpg"
     * @return string               Absolute path
     */
    public function toAbsolutePath(string $relativePath): string
    {
        // Normalise separators and strip leading slashes
        $clean = ltrim(str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $relativePath), DIRECTORY_SEPARATOR);
        return $this->mediaBase . DIRECTORY_SEPARATOR . $clean;
    }

    /**
     * Build the public serve URL for a given relative path.
     * Used by the view to generate <img src="..."> and preview URLs.
     *
     * @param string $relativePath  e.g. "abc123/hero/uuid.jpg"
     * @return string               Full URL to the serve endpoint
     */
    public function buildServeUrl(string $relativePath): string
    {
        return url('/i/media/serve') . '?path=' . urlencode($relativePath);
    }

    // ─────────────────────────────────────────────────────────────────────────
    // PRIVATE HELPERS
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * Detect MIME type using PHP's finfo extension.
     * Falls back to 'application/octet-stream' if detection fails.
     */
    private function detectMime(string $path): string
    {
        if (!file_exists($path)) {
            return 'application/octet-stream';
        }
        $finfo = finfo_open(FILEINFO_MIME_TYPE);
        $mime  = (string) finfo_file($finfo, $path);
        finfo_close($finfo);
        return $mime ?: 'application/octet-stream';
    }

    /**
     * Map a MIME type to a safe file extension for storage.
     * Uses an allowlist — unknown types default to 'bin'.
     */
    private function mimeToExtension(string $mime): string
    {
        $map = [
            'image/jpeg'  => 'jpg',
            'image/png'   => 'png',
            'image/webp'  => 'webp',
            'audio/mpeg'  => 'mp3',
            'audio/mp3'   => 'mp3',
            'audio/x-mp3' => 'mp3',
        ];
        return $map[$mime] ?? 'bin';
    }

    /**
     * Generate a random UUID v4 string for use as a filename.
     */
    private function generateUuid(): string
    {
        $data    = random_bytes(16);
        $data[6] = chr(ord($data[6]) & 0x0f | 0x40);
        $data[8] = chr(ord($data[8]) & 0x3f | 0x80);
        return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
    }

    /**
     * Ensure a directory exists, creating it recursively if needed.
     *
     * @throws \RuntimeException If the directory cannot be created.
     */
    private function ensureDirectory(string $dir): void
    {
        if (!is_dir($dir)) {
            if (!mkdir($dir, 0755, true) && !is_dir($dir)) {
                throw new \RuntimeException("Failed to create storage directory: {$dir}");
            }
        }
    }

    /**
     * Verify that an absolute path resolves within the media_base.
     * Guards against path traversal attacks.
     */
    private function isWithinMediaBase(string $absolutePath): bool
    {
        $realBase = realpath($this->mediaBase);
        if ($realBase === false) {
            return false;
        }
        // Use realpath if the file exists, otherwise resolve dirname
        if (file_exists($absolutePath)) {
            $realTarget = realpath($absolutePath);
        } else {
            $realTarget = realpath(dirname($absolutePath));
            if ($realTarget !== false) {
                $realTarget .= DIRECTORY_SEPARATOR . basename($absolutePath);
            }
        }
        if ($realTarget === false) {
            return false;
        }
        return strpos($realTarget, $realBase) === 0;
    }

    /**
     * Return a human-readable description for a PHP upload error code.
     */
    private function phpUploadError(int $code): string
    {
        $messages = [
            UPLOAD_ERR_INI_SIZE   => 'File exceeds the server upload size limit.',
            UPLOAD_ERR_FORM_SIZE  => 'File exceeds the form upload size limit.',
            UPLOAD_ERR_PARTIAL    => 'File was only partially uploaded. Please try again.',
            UPLOAD_ERR_NO_FILE    => 'No file was uploaded.',
            UPLOAD_ERR_NO_TMP_DIR => 'Temporary upload directory is missing.',
            UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk.',
            UPLOAD_ERR_EXTENSION  => 'Upload blocked by a server extension.',
        ];
        return $messages[$code] ?? 'An unknown upload error occurred.';
    }
}
