<?php

namespace App\Http\Controllers\Admin;

use App\Http\Controllers\Controller;
use App\Models\MediaFile;
use App\Models\StorageSetting;
use App\Providers\AppServiceProvider;
use App\Services\AuditLogger;
use App\Services\MediaStorage;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;

/**
 * Admin storage-settings surface.
 *
 * Reads / writes the `file_storage_settings` table behind the multi-cloud
 * disk picker on /admin/settings/storage. Credentials are never echoed back
 * to the client — index() returns has_key / has_secret booleans instead of
 * raw key/secret. AuditLogger entries strip credentials from context for the
 * same reason.
 */
class StorageSettingsController extends Controller
{
    /** @var array<int,string> */
    private const SUPPORTED_FILESYSTEMS = ['local', 's3', 'digitalocean', 'wasabi', 'minio'];

    public function index(Request $request): JsonResponse
    {
        $rows = StorageSetting::query()->get()->map(function (StorageSetting $row) {
            $keys = is_array($row->auth_keys) ? $row->auth_keys : [];

            return [
                'id' => $row->id,
                'filesystem' => $row->filesystem,
                'status' => $row->status,
                // Mask sensitive fields. Region / bucket / endpoint are not
                // secret — admins need to see them without re-entry.
                'has_key' => filled($keys['key'] ?? null),
                'has_secret' => filled($keys['secret'] ?? null),
                'region' => (string) ($keys['region'] ?? ''),
                'bucket' => (string) ($keys['bucket'] ?? ''),
                'endpoint' => (string) ($keys['endpoint'] ?? ''),
                'updated_at' => optional($row->updated_at)->toIso8601String(),
            ];
        })->values();

        $activeFs = optional(StorageSetting::active())->filesystem ?? 'local';

        return response()->json([
            'rows' => $rows,
            'active' => $activeFs,
            'regions' => [
                's3' => StorageSetting::AWS_REGIONS,
                'digitalocean' => StorageSetting::DIGITALOCEAN_REGIONS,
                'wasabi' => StorageSetting::WASABI_REGIONS,
            ],
            'supported' => self::SUPPORTED_FILESYSTEMS,
            // Drives the Migrate UI — count of rows still on a disk OTHER
            // than the active one. Direction-agnostic (cloud→local works
            // the same way as local→cloud).
            'pending_migration_count' => MediaFile::query()->where('disk', '!=', $activeFs)->count(),
            // Sample of which disks the pending rows live on, so the UI
            // can label "Migrate from {sources} to {active}".
            'pending_migration_sources' => MediaFile::query()
                ->where('disk', '!=', $activeFs)
                ->select('disk')
                ->distinct()
                ->pluck('disk')
                ->all(),
        ]);
    }

    public function update(Request $request): JsonResponse
    {
        $base = $request->validate([
            'filesystem' => ['required', 'string', Rule::in(self::SUPPORTED_FILESYSTEMS)],
        ]);

        $filesystem = $base['filesystem'];
        $row = StorageSetting::query()->firstOrNew(['filesystem' => $filesystem]);
        $existingKeys = is_array($row->auth_keys) ? $row->auth_keys : [];
        $authKeys = null;

        if ($filesystem === 'local') {
            // No credentials — `auth_keys` stays null.
        } elseif (in_array($filesystem, ['s3', 'digitalocean', 'wasabi', 'minio'], true)) {
            // key/secret are NULLABLE on update — admins editing only the
            // bucket / region / endpoint shouldn't be forced to re-enter
            // their secrets. The frontend masks already-saved values
            // behind a "Replace" affordance and only sends them when the
            // admin opts in. We merge present fields with existing
            // stored values and re-validate that the final shape is
            // complete.
            $rules = [
                'key' => ['nullable', 'string', 'max:255'],
                'secret' => ['nullable', 'string', 'max:255'],
                'region' => ['required', 'string', 'max:64'],
                'bucket' => ['required', 'string', 'max:255'],
            ];
            if ($filesystem === 'minio') {
                $rules['endpoint'] = ['required', 'url', 'max:255'];
            }

            $data = $request->validate($rules);

            $authKeys = [
                'driver' => 's3',
                'key' => filled($data['key'] ?? null)
                    ? $data['key']
                    : ($existingKeys['key'] ?? null),
                'secret' => filled($data['secret'] ?? null)
                    ? $data['secret']
                    : ($existingKeys['secret'] ?? null),
                'region' => $data['region'],
                'bucket' => $data['bucket'],
            ];
            if ($filesystem === 'minio') {
                $authKeys['endpoint'] = $data['endpoint'];
            }

            // Final-shape validation: after the merge, both creds must
            // be present. This 422s on a fresh row that omitted them
            // (whereas an edit-only of an already-saved row sails
            // through because the merge filled them in).
            $missing = [];
            if (! filled($authKeys['key'])) {
                $missing['key'] = ['The key field is required.'];
            }
            if (! filled($authKeys['secret'])) {
                $missing['secret'] = ['The secret field is required.'];
            }
            if ($missing !== []) {
                throw ValidationException::withMessages($missing);
            }
        }

        $row->filesystem = $filesystem;
        $row->auth_keys = $authKeys;
        // Keep status as-is on update — flipping the active provider is a
        // separate setActive endpoint. Default to 'disabled' on a brand-new
        // row that's not the seeded local entry.
        if (! $row->exists) {
            $row->status = 'disabled';
        }
        $row->save();

        StorageSetting::flushCache();

        AuditLogger::record(
            'storage.update',
            $row,
            "Updated storage credentials for {$filesystem}",
            // Never include raw key / secret — region + bucket only.
            [
                'filesystem' => $filesystem,
                'region' => is_array($authKeys) ? ($authKeys['region'] ?? null) : null,
                'bucket' => is_array($authKeys) ? ($authKeys['bucket'] ?? null) : null,
            ],
        );

        return response()->json([
            'ok' => true,
            'filesystem' => $filesystem,
        ]);
    }

    public function setActive(Request $request): JsonResponse
    {
        $data = $request->validate([
            'filesystem' => ['required', 'string', Rule::in(self::SUPPORTED_FILESYSTEMS)],
        ]);

        $filesystem = $data['filesystem'];
        $row = StorageSetting::query()->where('filesystem', $filesystem)->first();
        if ($row === null) {
            throw ValidationException::withMessages([
                'filesystem' => "No credentials saved for {$filesystem}. Save credentials first.",
            ]);
        }

        // Local needs no creds. Every other provider must have auth_keys
        // populated before it can be the active disk — flipping to a half-
        // configured row would 500 every upload.
        if ($filesystem !== 'local') {
            $keys = is_array($row->auth_keys) ? $row->auth_keys : [];
            $required = ['key', 'secret', 'region', 'bucket'];
            if ($filesystem === 'minio') {
                $required[] = 'endpoint';
            }
            foreach ($required as $field) {
                if (! filled($keys[$field] ?? null)) {
                    throw ValidationException::withMessages([
                        'filesystem' => "Cannot activate {$filesystem}: missing {$field}.",
                    ]);
                }
            }
        }

        DB::transaction(function () use ($row) {
            StorageSetting::query()->update(['status' => 'disabled']);
            $row->status = 'enabled';
            $row->save();
        });

        StorageSetting::flushCache();
        // Re-overlay so the just-flipped disk takes effect immediately for
        // any code path in this same request that reads filesystems config.
        AppServiceProvider::applyStorageSettings();

        AuditLogger::record(
            'storage.setActive',
            $row,
            "Activated storage filesystem {$filesystem}",
            ['filesystem' => $filesystem],
        );

        return response()->json([
            'ok' => true,
            'active' => $filesystem,
        ]);
    }

    public function testUpload(Request $request): JsonResponse
    {
        // Re-apply in case the admin just hit /update or /active without a
        // request restart; AppServiceProvider has been called in boot but the
        // config it set was based on the row at boot time.
        AppServiceProvider::applyStorageSettings();

        // Target the `public` disk specifically — it's the disk media uploads
        // (MediaController, ProjectController, etc.) hit, and the disk
        // applyStorageSettings overlays. config('filesystems.default') is
        // unrelated to the user-facing media path.
        $disk = Storage::disk('public');
        $name = 'storage-test-'.Str::random(12).'.txt';

        try {
            $ok = $disk->put($name, 'ok');
            if ($ok === false) {
                return response()->json([
                    'ok' => false,
                    'message' => 'Disk refused write — check credentials and bucket permissions.',
                ], 422);
            }
            // Server-side round-trip: read back what we wrote, assert byte
            // equivalence. No URL surface — handing the admin a signed URL
            // they can click only to 403 (because we delete next) is worse
            // than no URL.
            $readBack = $disk->get($name);
            if ($readBack !== 'ok') {
                return response()->json([
                    'ok' => false,
                    'message' => 'Round-trip mismatch — bytes written did not match bytes read.',
                ], 422);
            }
        } catch (\Throwable $e) {
            // Pass the SDK error through verbatim — admins debugging a misconfig
            // need the actual "InvalidAccessKeyId" / "NoSuchBucket" message.
            return response()->json([
                'ok' => false,
                'message' => $e->getMessage(),
            ], 422);
        }

        // Read-back + delete to confirm the round-trip and keep the bucket
        // clean. Failures here are surfaced but the upload itself counts as
        // success — most credentials catch errors at the put() boundary.
        try {
            $disk->delete($name);
        } catch (\Throwable) {
            // Ignore — tracer cleanup is best-effort.
        }

        AuditLogger::record(
            'storage.testUpload',
            null,
            'Tested upload on active storage disk',
            ['filesystem' => optional(StorageSetting::active())->filesystem ?? 'local'],
        );

        return response()->json([
            'ok' => true,
            'filesystem' => optional(StorageSetting::active())->filesystem ?? 'local',
        ]);
    }

    /**
     * Move every `media_files` row currently on `disk='local'` over to the
     * active disk via MediaStorage::moveToActiveDisk. Time-budgeted at 50s
     * so a slow batch doesn't blow past PHP max_execution_time on shared
     * hosting — the response carries `remaining` so the UI can re-poll.
     *
     * Audit batching: one summary row per migrate call (not N) via
     * MediaStorage::beginBatch / endBatch.
     */
    public function migrate(Request $request): JsonResponse
    {
        // Keep the active-disk overlay fresh in case the admin just hit
        // /update or /active in this same session — moveToActiveDisk reads
        // through Storage::disk('public') which is the overlay.
        AppServiceProvider::applyStorageSettings();

        $active = StorageSetting::active();
        $activeFs = optional($active)->filesystem ?? 'local';

        // 1. Active disk must have credentials when it's S3-compatible.
        // Without this fail-fast, every moveToActiveDisk would hit a
        // provider auth error and we'd write N failure rows. Local needs
        // no credential check.
        if ($active !== null && $active->filesystem !== 'local') {
            $keys = is_array($active->auth_keys) ? $active->auth_keys : [];
            $required = ['key', 'secret', 'region', 'bucket'];
            if ($active->filesystem === 'minio') {
                $required[] = 'endpoint';
            }
            foreach ($required as $field) {
                if (! filled($keys[$field] ?? null)) {
                    throw ValidationException::withMessages([
                        'filesystem' => "Cannot migrate: active disk {$active->filesystem} is missing {$field}.",
                    ]);
                }
            }
        }

        // 2. At least one row not on the active disk exists (otherwise the
        // call is a no-op — surface a clear 422 so the UI can hide the
        // affordance instead).
        $totalBefore = MediaFile::query()->where('disk', '!=', $activeFs)->count();
        if ($totalBefore === 0) {
            throw ValidationException::withMessages([
                'filesystem' => 'No files need migrating — all rows are on the active disk.',
            ]);
        }

        // Lift the per-request time limit when the host allows it. Many
        // shared hosts default to 30s, which is too tight for the chunked
        // copy loop. set_time_limit() is a no-op when disabled (e.g.
        // safe_mode-locked legacy installs), in which case the budget
        // below falls back to whatever ini reports.
        @set_time_limit(60);

        // Cap each hop at 25s so the progress bar gets a fresh reading
        // every ~25-30s no matter how relaxed the host's max_execution_time
        // is. On a host with max_exec=300 we'd otherwise sit silent for
        // ~5 minutes per response — bad UX even when the work is healthy.
        // Trade-off: more round-trips on long backlogs, but each hop is
        // bounded so the user sees the bar tick predictably.
        $hopCeiling = 25.0;

        // Budget against PHP's max_execution_time so a tighter limit (e.g.
        // shared-hosting 30s default) doesn't get killed mid-sweep. Leave
        // a 5s safety buffer for the response serialization + connection
        // teardown. Pick the SMALLER of the ini ceiling and our hop cap
        // — we never want a single hop to exceed either.
        $maxExec = (int) ini_get('max_execution_time');
        $iniBudget = $maxExec > 0 ? max(5.0, $maxExec - 5.0) : $hopCeiling;
        $deadline = microtime(true) + min($iniBudget, $hopCeiling);
        $moved = 0;
        $failed = 0;
        /** @var array<int, array{path: string, message: string}> $errors */
        $errors = [];

        MediaStorage::beginBatch('media.migrate');

        try {
            // Cursor + chunked iteration — registry could be tens of
            // thousands of rows on a long-running self-host install. We
            // requery in chunks of 25 by id so a partial pass that flips
            // some rows from local→s3 doesn't re-read them in the same
            // sweep (cursor on the same query would, since rows still
            // satisfy the where after we update them inside the loop).
            $lastId = 0;
            while (microtime(true) < $deadline) {
                $batch = MediaFile::query()
                    ->where('disk', '!=', $activeFs)
                    ->where('id', '>', $lastId)
                    ->orderBy('id')
                    ->limit(25)
                    ->get();

                if ($batch->isEmpty()) {
                    break;
                }

                foreach ($batch as $row) {
                    if (microtime(true) >= $deadline) {
                        break 2;
                    }

                    $lastId = max($lastId, (int) $row->id);

                    try {
                        $ok = MediaStorage::moveToActiveDisk($row);
                        if ($ok) {
                            $moved++;
                        } else {
                            $failed++;
                            $errors[] = [
                                'path' => (string) $row->path,
                                'message' => 'Migration returned false (source likely missing on disk).',
                            ];
                        }
                    } catch (\Throwable $e) {
                        $failed++;
                        $errors[] = [
                            'path' => (string) $row->path,
                            'message' => $e->getMessage(),
                        ];
                    }
                }
            }
        } finally {
            MediaStorage::endBatch(sprintf(
                'Migrated %d file(s) to %s, %d failed',
                $moved,
                $activeFs,
                $failed,
            ));
        }

        $remaining = (int) MediaFile::query()->where('disk', '!=', $activeFs)->count();

        return response()->json([
            'ok' => true,
            'moved' => $moved,
            'failed' => $failed,
            'remaining' => $remaining,
            // Cap surfaced errors at 50 so a wholesale provider outage
            // (every row failing) doesn't explode the response payload.
            'errors' => array_slice($errors, 0, 50),
        ]);
    }
}
