<?php

namespace App\Services;

use App\Models\MediaFile;
use App\Models\StorageSetting;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;

/**
 * Canonical wrapper for upload / URL / delete against whichever cloud disk
 * the admin has enabled. Local/S3 upload + URL resolution behind one API; the
 * per-file registry lives on the MediaFile model (`media_files`).
 *
 * All public methods route through `Storage::disk('public')` — the disk
 * config is overlaid by AppServiceProvider::applyStorageSettings at boot, so
 * the `public` alias points at whichever provider is enabled. The only
 * place this service inspects the active disk by name is `url()` (signed
 * URLs are S3-specific) and `moveToActiveDisk()` (cross-disk copy).
 *
 * Audit batching: a single export job uploading 50 tiles shouldn't write 50
 * audit rows. Wrap the batch in `beginBatch('media.export.tiles')` /
 * `endBatch()`; per-call audit writes are suppressed and a single summary
 * row lands at endBatch with the count + first/last paths in context.
 */
class MediaStorage
{
    /** Signed-URL lifetime for cloud disks (60 minutes). */
    public const SIGNED_URL_TTL_MINUTES = 60;

    /** Request-scoped memo of the active-disk filesystem name. */
    private static ?string $activeDiskMemo = null;

    /** @var null|array{action: string, ops: int, paths: array<int, string>} */
    private static ?array $batch = null;

    /**
     * Upload a file via Storage and write a registry row tagged with the
     * currently-active disk. Returns the storage path (`{folder}/{filename}`)
     * — caller stores this in their domain row.
     *
     * Idempotent on (disk, path): a second put with the same args UPDATES
     * the registry row (size / mime / owner / purpose can change) rather
     * than inserting a duplicate.
     *
     * @param  \Illuminate\Http\UploadedFile|string  $source  Either the uploaded
     *   file from an HTTP request or raw bytes (jobs creating files in-process).
     * @param  string  $folder  Relative folder under the disk (e.g. `projects/42`).
     * @param  string  $filename  Final filename (e.g. UUID.ext). Caller decides.
     * @param  array{owner_type?: string, owner_id?: int, purpose?: string}  $metadata
     * @return string  Storage path: `{folder}/{filename}`.
     */
    public static function put($source, string $folder, string $filename, array $metadata = []): string
    {
        $folder = trim($folder, '/');
        $filename = ltrim($filename, '/');
        $path = $folder === '' ? $filename : "{$folder}/{$filename}";

        $disk = Storage::disk('public');

        if ($source instanceof UploadedFile) {
            $disk->putFileAs($folder, $source, $filename);
            $size = $source->getSize() ?: null;
            $mime = $source->getClientMimeType() ?: $source->getMimeType() ?: null;
            $originalName = $source->getClientOriginalName() ?: null;
        } else {
            $disk->put($path, (string) $source);
            $size = strlen((string) $source) ?: null;
            $mime = null;
            $originalName = null;
        }

        $activeDisk = self::activeDisk();

        // Drop the cached URL — the underlying bytes just changed, so a
        // previously-cached signed URL still works for the OLD object
        // version (S3 versioned puts). Cheap to invalidate.
        Cache::forget(self::cacheKey($activeDisk, $path));

        $row = MediaFile::query()->updateOrCreate(
            ['disk' => $activeDisk, 'path' => $path],
            [
                'original_name' => $originalName,
                'mime_type' => $mime,
                'size_bytes' => $size,
                'owner_type' => $metadata['owner_type'] ?? null,
                'owner_id' => $metadata['owner_id'] ?? null,
                'purpose' => $metadata['purpose'] ?? null,
            ],
        );

        self::audit('media_files.create', $row, "Uploaded {$path}", [
            'disk' => $activeDisk,
            'path' => $path,
            'size_bytes' => $size,
            'mime_type' => $mime,
            'owner_type' => $metadata['owner_type'] ?? null,
            'owner_id' => $metadata['owner_id'] ?? null,
            'purpose' => $metadata['purpose'] ?? null,
        ]);

        return $path;
    }

    /**
     * Resolve a public URL for a stored path. Local: `{APP_URL}/user-uploads/{path}`
     * (files live in the web root — no symlink). S3-compatible: temporary signed URL cached for
     * 60 minutes — a single page render with N images mustn't issue N
     * signing calls.
     *
     * Cache key includes the active disk name so swapping providers doesn't
     * serve stale URLs from the previous provider's cache.
     */
    public static function url(string $path): string
    {
        $path = ltrim($path, '/');
        $activeDisk = self::activeDisk();

        if (! in_array($activeDisk, StorageSetting::S3_COMPATIBLE_FILESYSTEMS, true)) {
            // Local — no signing, no cache, just the public/user-uploads URL.
            return Storage::disk('public')->url($path);
        }

        return Cache::remember(
            self::cacheKey($activeDisk, $path),
            now()->addMinutes(self::SIGNED_URL_TTL_MINUTES),
            fn () => Storage::disk('public')->temporaryUrl(
                $path,
                now()->addMinutes(self::SIGNED_URL_TTL_MINUTES),
            ),
        );
    }

    /**
     * Read the file's bytes from whichever disk currently holds it. Mirrors
     * the disk-resolution logic in {@see delete()}: the registry row's
     * `disk` column wins (so files that lived on an old disk before admin
     * switched still resolve), falling back to the active disk when no
     * row exists.
     *
     * Returns `null` when the file is missing or unreadable — callers
     * should treat that as a 404 rather than a 500. Used by the thumb
     * pipeline + any future server-side image processing that needs to
     * pull bytes regardless of where they live.
     */
    public static function get(string $path): ?string
    {
        $path = ltrim($path, '/');
        $row = MediaFile::query()->where('path', $path)->first();
        $diskName = $row?->disk ?? self::activeDisk();

        try {
            $diskInstance = $diskName === self::activeDisk()
                ? Storage::disk('public')
                : Storage::disk($diskName);

            if (! $diskInstance->exists($path)) {
                return null;
            }

            $bytes = $diskInstance->get($path);

            return is_string($bytes) ? $bytes : null;
        } catch (\Throwable $e) {
            Log::warning('MediaStorage::get provider error', [
                'disk' => $diskName,
                'path' => $path,
                'error' => $e->getMessage(),
            ]);

            return null;
        }
    }

    /**
     * Delete the file from its disk + drop the registry row. Idempotent —
     * already-missing files return true silently. The registry row is
     * looked up by `path` regardless of which disk we're currently on; the
     * registered `disk` column drives the actual delete (so a row that
     * tracks an old s3 file still gets deleted from s3 even after admin
     * switched the active disk back to local).
     */
    public static function delete(string $path): bool
    {
        $path = ltrim($path, '/');

        $row = MediaFile::query()->where('path', $path)->first();
        $rowDisk = $row?->disk;

        // If we have a registry row, prefer its disk for the actual file
        // delete (handles the "file lives on old disk, admin switched
        // active to a new one" case). If no row exists, fall back to the
        // currently-active disk.
        $diskName = $rowDisk ?? self::activeDisk();

        try {
            // Storage::disk('public') is overlaid to the *active* disk by
            // applyStorageSettings; resolving by name lets us hit a
            // non-active S3 disk if `$rowDisk` differs from active. For the
            // common case (rowDisk === active), 'public' is correct.
            $diskInstance = $diskName === self::activeDisk()
                ? Storage::disk('public')
                : Storage::disk($diskName);

            if ($diskInstance->exists($path)) {
                $diskInstance->delete($path);
            }
        } catch (\Throwable $e) {
            // Provider-side failure — don't bubble; admin will eventually
            // see the orphan via the registry. Log so it's traceable.
            Log::warning('MediaStorage::delete provider error', [
                'disk' => $diskName,
                'path' => $path,
                'error' => $e->getMessage(),
            ]);
        }

        // Always drop the registry row + cached URL + thumbnail variants.
        Cache::forget(self::cacheKey($diskName, $path));
        MediaThumb::invalidate($path);
        if ($row !== null) {
            self::audit('media_files.delete', $row, "Deleted {$path}", [
                'disk' => $diskName,
                'path' => $path,
            ]);
            $row->delete();
        }

        return true;
    }

    /**
     * Move a registry row's underlying file from its current disk to the
     * currently-active disk. No-op when already on active. Used by the
     * admin "Migrate local files" button — writes one audit row per
     * migration so a partial sweep is auditable.
     *
     * Cross-disk copy goes via in-process bytes (`get` then `put`) since
     * Flysystem doesn't have a built-in cross-adapter copy. For files in
     * the megabyte range this is fine on shared hosts; the migrate UI is
     * batched + fire-and-forget so a slow file doesn't block the request.
     */
    public static function moveToActiveDisk(MediaFile $row): bool
    {
        $active = self::activeDisk();
        if ($row->disk === $active) {
            return true;
        }

        try {
            // The `disk` column is informational ('local'/'s3'/'minio'/...),
            // NOT a Laravel disk alias. The `public` disk is now overlaid
            // to the active cloud target — we can't use it to read the
            // source. Use the pinned `local_public` disk for local-source
            // rows, and Storage::disk($row->disk) for future cloud→cloud
            // migration paths (where the disk name IS a Laravel alias).
            $sourceDisk = $row->disk === 'local'
                ? Storage::disk('local_public')
                : Storage::disk($row->disk);
            $sourceExists = $sourceDisk->exists($row->path);
            $sourceRead = static fn () => $sourceDisk->get($row->path);
            $sourceDelete = static fn () => $sourceDisk->delete($row->path);

            if (! $sourceExists) {
                // Source file is gone but the registry row is still here —
                // leave the row in place so admin can see the orphan, but
                // log an audit row so the partial migration is visible.
                self::audit('media_files.migrate', $row, "Skipped {$row->path}: source missing", [
                    'from' => $row->disk,
                    'to' => $active,
                    'path' => $row->path,
                    'result' => 'source_missing',
                ]);

                return false;
            }

            $bytes = $sourceRead();
            // Storage::get() returns null (not throws) on a transient read
            // failure even after exists() was true — writing that would put an
            // empty object and then we'd delete the only real copy. Bail first.
            if (! is_string($bytes) || $bytes === '') {
                self::audit('media_files.migrate', $row, "Skipped {$row->path}: source unreadable", [
                    'from' => $row->disk,
                    'to' => $active,
                    'path' => $row->path,
                    'result' => 'source_unreadable',
                ]);

                return false; // source left untouched
            }

            // active = 'public' (overlaid) — write via the public alias so
            // the S3 visibility / config travels with the put.
            // FilesystemAdapter::put() RETURNS false (does not throw) on a write
            // failure (S3 throttle / permission / quota). Treat that as fatal —
            // never delete the source on an unconfirmed write.
            $wrote = Storage::disk('public')->put($row->path, $bytes);
            $destDisk = Storage::disk('public');
            $destOk = $wrote
                && $destDisk->exists($row->path)
                && (int) $destDisk->size($row->path) === strlen($bytes);
            if (! $destOk) {
                // Roll back a partial/empty destination write so it doesn't
                // masquerade as a migrated file, and KEEP the source.
                try {
                    if ($destDisk->exists($row->path)) {
                        $destDisk->delete($row->path);
                    }
                } catch (\Throwable) {
                    // best-effort; the failed dest write is already the error
                }
                self::audit('media_files.migrate', $row, "Failed {$row->path}: destination write unverified", [
                    'from' => $row->disk,
                    'to' => $active,
                    'path' => $row->path,
                    'result' => 'dest_unverified',
                ]);

                return false; // source left untouched — no data loss
            }

            // Destination is confirmed byte-for-byte — now safe to drop the source.
            try {
                $sourceDelete();
            } catch (\Throwable $e) {
                Log::warning('MediaStorage::moveToActiveDisk source-delete error', [
                    'disk' => $row->disk,
                    'path' => $row->path,
                    'error' => $e->getMessage(),
                ]);
            }

            $previousDisk = $row->disk;
            $row->disk = $active;
            $row->save();

            // Invalidate cached URLs for both keys.
            Cache::forget(self::cacheKey($previousDisk, $row->path));
            Cache::forget(self::cacheKey($active, $row->path));

            self::audit('media_files.migrate', $row->refresh(), "Migrated {$row->path}", [
                'from' => $previousDisk,
                'to' => $active,
                'path' => $row->path,
                'result' => 'ok',
            ]);

            return true;
        } catch (\Throwable $e) {
            self::audit('media_files.migrate', $row, "Failed {$row->path}: {$e->getMessage()}", [
                'from' => $row->disk,
                'to' => $active,
                'path' => $row->path,
                'result' => 'error',
                'error' => $e->getMessage(),
            ]);

            return false;
        }
    }

    /**
     * Begin an audit batch. Per-call `put` / `delete` / `moveToActiveDisk`
     * audit rows are suppressed until `endBatch()`, which writes one
     * summary row with the operation count + first/last paths.
     *
     * Caller is responsible for matching begin/end. A second begin without
     * an end overwrites the in-progress batch (the previous batch's
     * suppressed events are dropped) — caller bug, fail loud in the log.
     */
    public static function beginBatch(string $action): void
    {
        if (self::$batch !== null) {
            Log::warning('MediaStorage::beginBatch called while batch active', [
                'previous_action' => self::$batch['action'],
                'new_action' => $action,
            ]);
        }

        self::$batch = ['action' => $action, 'ops' => 0, 'paths' => []];
    }

    /**
     * End the current audit batch and write a single summary row. No-op if
     * no batch is active. Returns the operation count for the closed batch
     * (0 when no batch was open).
     */
    public static function endBatch(?string $summary = null): int
    {
        if (self::$batch === null) {
            return 0;
        }

        $batch = self::$batch;
        self::$batch = null;

        $ops = (int) $batch['ops'];
        if ($ops === 0) {
            // Nothing happened during the batch — don't pollute the audit
            // log with zero-op rows.
            return 0;
        }

        $paths = $batch['paths'];
        $first = $paths[0] ?? null;
        $last = end($paths) ?: null;

        AuditLogger::record(
            $batch['action'],
            null,
            $summary ?? "Batched {$ops} media operation(s)",
            array_filter([
                'ops' => $ops,
                'first_path' => $first,
                'last_path' => $last,
            ], fn ($v) => $v !== null),
        );

        return $ops;
    }

    /**
     * Drop the request-scoped active-disk memo. Tests that swap the
     * StorageSetting in-flight (StorageSetting::flushCache + reseed) call
     * this so the next put reads the new active row.
     */
    public static function flushActiveDiskMemo(): void
    {
        self::$activeDiskMemo = null;
    }

    /**
     * Resolve the currently-active disk's filesystem name. Memoized
     * request-scoped so N uploads in one request don't re-hit the DB.
     * Falls through to 'local' when no row exists (fresh installs / tests
     * without seeding).
     */
    private static function activeDisk(): string
    {
        if (self::$activeDiskMemo !== null) {
            return self::$activeDiskMemo;
        }

        try {
            $row = StorageSetting::active();
            self::$activeDiskMemo = $row?->filesystem ?? 'local';
        } catch (\Throwable) {
            // Fresh checkout / pre-migration — local is the safe fallback.
            self::$activeDiskMemo = 'local';
        }

        return self::$activeDiskMemo;
    }

    private static function cacheKey(string $disk, string $path): string
    {
        return "media_url:{$disk}:{$path}";
    }

    /**
     * Internal audit shim. When a batch is open, accumulate stats and skip
     * the per-op AuditLogger call; otherwise pass straight through.
     */
    private static function audit(string $action, ?MediaFile $row, ?string $summary, array $context): void
    {
        if (self::$batch !== null) {
            self::$batch['ops']++;
            $path = $context['path'] ?? null;
            if (is_string($path)) {
                self::$batch['paths'][] = $path;
            }

            return;
        }

        AuditLogger::record($action, $row, $summary, $context);
    }
}
