<?php

namespace App\Http\Controllers\Admin;

use App\Http\Controllers\Controller;
use App\Models\AuditLog;
use App\Models\MediaFile;
use App\Models\Project;
use App\Models\Subscription;
use App\Models\Tenant;
use App\Models\WebhookEvent;
use App\Services\Settings;
use App\Support\AppMode;
use Illuminate\Notifications\DatabaseNotification;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;

/**
 * Manual-trigger surface for every scheduled cleanup. Mirrors the cron
 * schedule (routes/console.php) — each task has a Preview (dry-run) and a
 * Purge button. Operators don't have to wait for the next cron tick or SSH
 * in to run sweeps; this also serves as the install-day cleanup tool
 * (admin tests + signs up users, then clears the test churn before going
 * live).
 *
 * Each task's eligible count is computed via a direct query that mirrors
 * what the artisan command would see — separate from the command itself to
 * keep the index page fast. Triggering Preview re-runs the command in
 * --dry-run mode; Purge runs it for real. Both capture artisan output and
 * return it inline so the admin can see exactly what was deleted.
 */
class MaintenanceController extends Controller
{
    /**
     * Whitelist + per-task metadata: human label, description, the artisan
     * command, the option keys the form accepts, an inline closure that
     * returns the current "eligible" count (so the index page can show
     * pending work without dispatching a dry-run on every render), and an
     * optional default for the `days` knob (falls back to whatever the
     * command itself reads when unset).
     *
     * @return array<string, array{
     *   label:string, description:string, command:string,
     *   accepts_days:bool, default_days:?int,
     *   eligible: \Closure(): int,
     * }>
     */
    private function tasks(): array
    {
        return [
            'auditlog' => [
                'label' => 'Audit log retention',
                'description' => 'Hard-delete admin-action audit rows older than the retention window. The window defaults to whatever you set on Settings → App settings → Audit log retention (730 days by default).',
                'command' => 'auditlog:purge',
                'accepts_days' => true,
                'default_days' => (int) (Settings::get('audit.retention_days') ?? 730),
                'eligible' => function (?int $days): int {
                    $days = $days ?? (int) (Settings::get('audit.retention_days') ?? 730);

                    return (int) AuditLog::query()
                        ->where('created_at', '<', now()->subDays(max(1, $days)))
                        ->count();
                },
            ],
            'media-orphans' => [
                'label' => 'Orphan media',
                'description' => 'Sweep media_files rows whose path is no longer referenced by any layer AND whose owning project either has none of those references or no longer exists. 7-day safety window protects abandoned uploads + the editor undo stack.',
                'command' => 'media:gc-orphans',
                'accepts_days' => true,
                'default_days' => 7,
                'eligible' => function (?int $days): int {
                    $days = $days ?? 7;
                    $cutoff = now()->subDays(max(0, $days));
                    $existingProjectIds = Project::query()->pluck('id')->all();
                    // Projectless rows (owner_id no longer points at a real
                    // project) — caught by stage 2 of the GC sweep.
                    return (int) MediaFile::query()
                        ->where('owner_type', 'project')
                        ->whereNotIn('owner_id', $existingProjectIds ?: [0])
                        ->where(function ($q) use ($cutoff) {
                            $q->where('created_at', '<', $cutoff)
                                ->orWhereNull('created_at');
                        })
                        ->count();
                },
            ],
            'tenant-orphans' => [
                'label' => 'Orphan tenants',
                'description' => 'Delete tenants with zero users left, plus their projects + media + exports. Catches state left behind by paths that bypassed UsersController::purgeUser (test runs, raw User::delete in tinker).',
                'command' => 'tenants:purge-orphans',
                'saas_only' => true,
                'accepts_days' => true,
                'default_days' => 7,
                'eligible' => function (?int $days): int {
                    $days = $days ?? 7;

                    return (int) Tenant::query()
                        ->whereDoesntHave('users')
                        ->where(function ($q) use ($days) {
                            $q->where('created_at', '<', now()->subDays(max(0, $days)))
                                ->orWhereNull('created_at');
                        })
                        ->count();
                },
            ],
            'webhook-events' => [
                'label' => 'Webhook events retention',
                'description' => 'Hard-delete dispatched-OK webhook event rows older than the retention window. The provider dashboard (Stripe / Razorpay) is the authoritative record; older local rows are diagnostic-only.',
                'command' => 'webhookevents:purge',
                'saas_only' => true,
                'accepts_days' => true,
                'default_days' => 90,
                'eligible' => function (?int $days): int {
                    $days = $days ?? 90;

                    return (int) WebhookEvent::query()
                        ->where('processed_at', '<', now()->subDays(max(1, $days)))
                        ->count();
                },
            ],
            'notifications' => [
                'label' => 'In-app notifications retention',
                'description' => 'Hard-delete READ in-app notifications (the bell + /notifications history) older than the retention window. Unread notifications are always kept — they\'re still an unacted signal. The activity audit feed has its own retention.',
                'command' => 'notifications:purge',
                // In-app notifications only fire on SaaS workspace events
                // (share / invite / role change); the bell + table are always
                // empty in self-hosted, so this sweep is SaaS-only too.
                'saas_only' => true,
                'accepts_days' => true,
                'default_days' => 90,
                'eligible' => function (?int $days): int {
                    $days = $days ?? 90;

                    return (int) DatabaseNotification::query()
                        ->whereNotNull('read_at')
                        ->where('created_at', '<', now()->subDays(max(1, $days)))
                        ->count();
                },
            ],
            'razorpay-stale-pending' => [
                'label' => 'Stale pending Razorpay subs',
                'description' => 'Cancel + clean up Razorpay subscriptions stuck in `pending` (abandoned checkouts from a domestic-card plan-change fallback, etc.). No-op when Razorpay isn\'t configured.',
                'command' => 'razorpay:cleanup-stale-pending-subs',
                'saas_only' => true,
                'accepts_days' => true,
                'default_days' => 7,
                'eligible' => function (?int $days): int {
                    $days = $days ?? 7;

                    return (int) Subscription::query()
                        ->where('provider_key', 'razorpay')
                        ->where('status', 'pending')
                        ->where('created_at', '<', now()->subDays(max(1, $days)))
                        ->count();
                },
            ],
            'theme-versions' => [
                'label' => 'Theme versions retention',
                'description' => 'Each Save / Import on /admin/appearance creates a new theme_versions row. The active version is always kept; this sweep deletes the oldest inactive rows past the keep-count so the Versions list stays usable.',
                'command' => 'themes:purge-versions',
                'option_flag' => '--keep',
                'knob_label' => 'Keep newest (versions)',
                'accepts_days' => true,
                'default_days' => 50,
                'eligible' => function (?int $days): int {
                    $keep = max(1, $days ?? 50);

                    return max(
                        0,
                        (int) \App\Models\ThemeVersion::query()
                            ->where('is_active', false)
                            ->count() - $keep,
                    );
                },
            ],
            'stale-subscriptions' => [
                'label' => 'Stale subscriptions (cross-provider)',
                'description' => 'Sync local rows the provider has already retired. Targets any provider\'s subs in `incomplete` / `past_due` past their useful period — catches the rare case where the `.deleted` webhook was dropped. Stripe normally auto-expires `incomplete` subs after 23h, so a 30+ day default is a wide safety margin.',
                'command' => 'subscriptions:cleanup-stale',
                'saas_only' => true,
                'accepts_days' => true,
                'default_days' => 30,
                'eligible' => function (?int $days): int {
                    $days = $days ?? 30;

                    return (int) Subscription::query()
                        ->whereIn('status', ['incomplete', 'past_due'])
                        ->where('current_period_ends_at', '<', now()->subDays(max(1, $days)))
                        ->whereNull('canceled_at')
                        ->count();
                },
            ],
        ];
    }

    /**
     * Tasks visible in the current mode. Self-hosted has no tenants /
     * subscriptions / webhook events, so those sweeps are dropped entirely —
     * not just hidden in the UI, so a hand-crafted preview/purge can't run a
     * SaaS-only command in self-hosted either.
     *
     * @return array<string, array<string, mixed>>
     */
    private function availableTasks(): array
    {
        $tasks = $this->tasks();
        if (AppMode::isSaas()) {
            return $tasks;
        }

        return array_filter($tasks, fn (array $meta) => ! ($meta['saas_only'] ?? false));
    }

    public function index(): Response
    {
        $taskList = [];
        foreach ($this->availableTasks() as $key => $meta) {
            $defaultDays = $meta['default_days'];
            $taskList[] = [
                'key' => $key,
                'label' => $meta['label'],
                'description' => $meta['description'],
                'accepts_days' => $meta['accepts_days'],
                'default_days' => $defaultDays,
                // Default knob label is "Retention (days)" for the standard
                // age-based sweeps. Tasks whose scalar isn't a day count
                // (theme-versions uses --keep, a count) override here so
                // the UI input isn't misleading.
                'knob_label' => $meta['knob_label'] ?? 'Retention (days)',
                'eligible' => $meta['eligible']($defaultDays),
            ];
        }

        return Inertia::render('Admin/Maintenance', [
            'tasks' => $taskList,
        ]);
    }

    public function preview(Request $request): JsonResponse
    {
        return $this->dispatchTask($request, dryRun: true);
    }

    public function purge(Request $request): JsonResponse
    {
        return $this->dispatchTask($request, dryRun: false);
    }

    private function dispatchTask(Request $request, bool $dryRun): JsonResponse
    {
        $tasks = $this->availableTasks();

        $data = $request->validate([
            'task' => ['required', 'string', Rule::in(array_keys($tasks))],
            'days' => ['nullable', 'integer', 'min:0', 'max:3650'],
        ]);

        $meta = $tasks[$data['task']];

        // Validate that days is positive when the task requires it.
        // 0-day overrides are allowed on purpose — they're how the admin
        // forces an immediate sweep after backfilling legacy data.
        // Tasks with a different scalar knob (e.g. themes:purge-versions
        // takes --keep) declare it via the `option_flag` field; default is
        // --days for the standard retention sweeps.
        $options = [];
        $optionFlag = $meta['option_flag'] ?? '--days';
        if ($meta['accepts_days'] && isset($data['days'])) {
            $options[$optionFlag] = (int) $data['days'];
        }
        if ($dryRun) {
            $options['--dry-run'] = true;
        }

        try {
            $exitCode = Artisan::call($meta['command'], $options);
        } catch (\Throwable $e) {
            return response()->json([
                'ok' => false,
                'message' => $e->getMessage(),
            ], 500);
        }

        return response()->json([
            'ok' => $exitCode === 0,
            'task' => $data['task'],
            'dry_run' => $dryRun,
            'days' => $options[$optionFlag] ?? null,
            'exit_code' => $exitCode,
            'output' => Artisan::output(),
            // Recompute eligible after the run so the UI can refresh
            // counters without a full page reload.
            'eligible_after' => $meta['eligible']($options[$optionFlag] ?? null),
        ]);
    }
}
