<?php

use App\Http\Controllers\AccountController;
use App\Http\Controllers\Account\ProfileController as AccountProfileController;
use App\Http\Controllers\Account\AiUsageController as AccountAiUsageController;
use App\Http\Controllers\Account\StorageUsageController as AccountStorageUsageController;
use App\Http\Controllers\Admin\AppearanceController as AdminAppearanceController;
use App\Http\Controllers\Admin\AuditLogController as AdminAuditLogController;
use App\Http\Controllers\Admin\BillingController as AdminBillingController;
use App\Http\Controllers\Admin\CurrenciesController as AdminCurrenciesController;
use App\Http\Controllers\Admin\DashboardController as AdminDashboardController;
use App\Http\Controllers\Admin\InvoicesController as AdminInvoicesController;
use App\Http\Controllers\Admin\KitsController as AdminKitsController;
use App\Http\Controllers\Admin\MaintenanceController as AdminMaintenanceController;
use App\Http\Controllers\Admin\MarketingController as AdminMarketingController;
use App\Http\Controllers\Admin\SettingsController as AdminSettingsController;
use App\Http\Controllers\Admin\StorageSettingsController as AdminStorageSettingsController;
use App\Http\Controllers\Admin\SubscriptionsController as AdminSubscriptionsController;
use App\Http\Controllers\Admin\TenantsController as AdminTenantsController;
use App\Http\Controllers\Admin\UpdateController as AdminUpdateController;
use App\Http\Controllers\Admin\UsersController as AdminUsersController;
use App\Http\Controllers\Admin\WebhookEventsController as AdminWebhookEventsController;
use App\Http\Controllers\AiController;
use App\Http\Controllers\Auth\AcceptInviteController;
use App\Http\Controllers\Auth\LoginController;
use App\Http\Controllers\Auth\PasswordResetController;
use App\Http\Controllers\Auth\RegisterController;
use App\Http\Controllers\Auth\VerificationController;
use App\Http\Controllers\BillingWebhookController;
use App\Http\Controllers\CheckoutController;
use App\Http\Controllers\ExportController;
use App\Http\Controllers\InstallerController;
use App\Http\Controllers\LicenseController;
use App\Http\Controllers\MediaController;
use App\Http\Controllers\NotificationController;
use App\Http\Controllers\PricingController;
use App\Http\Controllers\ProjectController;
use App\Http\Controllers\ProjectMemberController;
use App\Http\Controllers\ProjectEditLockController;
use App\Http\Controllers\ScreenController;
use App\Http\Controllers\WorkspaceController;
use App\Http\Controllers\WorkspaceInvitationController;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;

// Browser-reachable recovery endpoints (= /update-database,
// /clear-cache, /refresh-cache). NOT behind auth/admin, so they work after a
// buyer FTP-uploads new code whose pending migration would 500 every
// authenticated page — the in-app Settings buttons are unreachable then.
// Registered before the /{slug} catch-all so they aren't shadowed; they only
// call Artisan, never query models.
Route::get('/update-database', [\App\Http\Controllers\SystemMigrateController::class, 'migrate'])->name('system.migrate');
Route::get('/clear-cache', [\App\Http\Controllers\SystemMigrateController::class, 'clearCache'])->name('system.clear-cache');
Route::get('/refresh-cache', [\App\Http\Controllers\SystemMigrateController::class, 'refreshCache'])->name('system.refresh-cache');

Route::middleware(\App\Http\Middleware\GuardInstaller::class)
    ->prefix('install')
    ->name('installer.')
    ->group(function () {
        Route::get('/', [InstallerController::class, 'requirements'])->name('requirements');
        Route::post('/proceed', [InstallerController::class, 'proceed'])->name('proceed');
        Route::get('/mode', [InstallerController::class, 'mode'])->name('mode');
        Route::post('/mode', [InstallerController::class, 'storeMode'])->name('mode.store');
        Route::get('/database', [InstallerController::class, 'database'])->name('database');
        Route::post('/database', [InstallerController::class, 'storeDatabase'])->name('database.store');
        Route::get('/admin', [InstallerController::class, 'admin'])->name('admin');
        Route::post('/admin', [InstallerController::class, 'storeAdmin'])->name('admin.store');
        Route::get('/complete', [InstallerController::class, 'complete'])->name('complete');
        Route::post('/finalize', [InstallerController::class, 'finalize'])->name('finalize');
    });

// Public marketing surfaces — gated by the admin-flippable landing mode
// (Enabled / Redirect / Login). Two variants of `landing.mode`:
//
//   :strict  — authed users redirected to /projects in non-enabled mode
//              (used on pure marketing surfaces: /, /about, /contact)
//   default  — authed users always pass through
//              (used on utility surfaces: /templates kit browsing,
//               /pricing change-plan flow — see saas-only group below)
//
// Auth routes (/login, /register, etc.) stay outside the group — they
// must always render regardless of mode.
Route::middleware('landing.mode:strict')->group(function () {
    Route::get('/', function (\App\Services\KitInstallerService $kitService) {
        return Inertia::render('Welcome', [
            // Real kit data drives the landing's "Browse templates" teaser.
            // Capped at 3 so the section stays a teaser; the full grid lives
            // at /templates. Featured kits surface first.
            'kits' => $kitService->featuredForLanding(3),
        ]);
    });
    // About + Contact each carry a `marketing.{page}.enabled` flag —
    // admin can publish/unpublish from /admin/marketing → {page} tab.
    // Disabled = 404 (page genuinely doesn't exist for visitors). Nav +
    // footer links to these routes are admin-managed separately via the
    // Header + Footer editors.
    Route::get('/contact', function () {
        $payload = app(\App\Http\Controllers\Admin\MarketingController::class)->contactPayload();
        if (($payload['enabled'] ?? true) === false) {
            abort(404);
        }
        return Inertia::render('Contact');
    })->name('contact');
    Route::get('/about', function () {
        $payload = app(\App\Http\Controllers\Admin\MarketingController::class)->aboutPayload();
        if (($payload['enabled'] ?? true) === false) {
            abort(404);
        }
        return Inertia::render('About');
    })->name('about');

    // Legal pages — default to enabled=false in defaults, so 404 until
    // admin explicitly publishes via /admin/marketing → {page} tab.
    Route::get('/privacy', function () {
        $payload = app(\App\Http\Controllers\Admin\MarketingController::class)->privacyPayload();
        if (($payload['enabled'] ?? false) === false) {
            abort(404);
        }
        return Inertia::render('Privacy');
    })->name('privacy');
    Route::get('/terms', function () {
        $payload = app(\App\Http\Controllers\Admin\MarketingController::class)->termsPayload();
        if (($payload['enabled'] ?? false) === false) {
            abort(404);
        }
        return Inertia::render('Terms');
    })->name('terms');
    Route::get('/refund', function () {
        $payload = app(\App\Http\Controllers\Admin\MarketingController::class)->refundPayload();
        if (($payload['enabled'] ?? false) === false) {
            abort(404);
        }
        return Inertia::render('Refund');
    })->name('refund');
});

// Utility surfaces — same toggle gate but lax variant (authed pass through).
Route::middleware('landing.mode')->group(function () {
    Route::get('/templates', [\App\Http\Controllers\TemplatesController::class, 'index'])->name('templates');
});

// Resized media variants. Public (matches `/storage/{path}` direct URLs —
// once a path is leaked it's already accessible). Path must exist in the
// `media_files` registry; the controller validates that before reading
// bytes, so this isn't a generic file-read primitive.
Route::get('/media/thumb', [\App\Http\Controllers\MediaThumbController::class, 'show'])
    ->name('media.thumb');

// License activation gate. Behind RequireInstall (must be installed first)
// but reachable while unlicensed — EnforceLicense self-allowlists license.*
// so these routes never redirect-loop onto themselves.
Route::get('/license/verify', [LicenseController::class, 'verify'])->name('license.verify');
Route::post('/license/activate', [LicenseController::class, 'activate'])->middleware('throttle:license')->name('license.activate');

Route::middleware('guest')->group(function () {
    Route::get('/login', [LoginController::class, 'create'])->name('login');
    Route::post('/login', [LoginController::class, 'store'])->middleware('throttle:login');

    Route::get('/register', [RegisterController::class, 'create'])->name('register');
    Route::post('/register', [RegisterController::class, 'store'])->middleware('throttle:register');

    Route::get('/forgot-password', [PasswordResetController::class, 'request'])->name('password.request');
    Route::post('/forgot-password', [PasswordResetController::class, 'email'])
        ->middleware('throttle:password-email')
        ->name('password.email');
    Route::get('/reset-password/{token}', [PasswordResetController::class, 'reset'])->name('password.reset');
    Route::post('/reset-password', [PasswordResetController::class, 'update'])
        ->middleware('throttle:password-email')
        ->name('password.update');

    // Invite acceptance — set-password landing for an admin-invited (Δ20) or
    // owner-invited (future SaaS) member. The accept link is the invitee's
    // email verification (Δ16). Public/guest: they have no password yet.
    Route::get('/invite/accept/{token}', [AcceptInviteController::class, 'show'])->name('invite.accept');
    Route::post('/invite/accept', [AcceptInviteController::class, 'accept'])
        ->middleware('throttle:password-email')
        ->name('invite.accept.store');

    Route::get('/auth/google', [\App\Http\Controllers\Auth\SocialAuthController::class, 'googleRedirect'])
        ->middleware('throttle:login')
        ->name('auth.google');
    Route::get('/auth/google/callback', [\App\Http\Controllers\Auth\SocialAuthController::class, 'googleCallback'])
        ->middleware('throttle:login');
    Route::get('/auth/github', [\App\Http\Controllers\Auth\SocialAuthController::class, 'githubRedirect'])
        ->middleware('throttle:login')
        ->name('auth.github');
    Route::get('/auth/github/callback', [\App\Http\Controllers\Auth\SocialAuthController::class, 'githubCallback'])
        ->middleware('throttle:login');
});

Route::middleware('auth')->group(function () {
    // Kept out of the verified group — unverified users still need to sign out.
    Route::post('/logout', [LoginController::class, 'destroy'])->name('logout');

    Route::get('/email/verify', [VerificationController::class, 'notice'])->name('verification.notice');
    Route::get('/email/verify/{id}/{hash}', [VerificationController::class, 'verify'])
        ->middleware(['signed', 'throttle:verification-send'])
        ->name('verification.verify');
    Route::post('/email/verification-notification', [VerificationController::class, 'send'])
        ->middleware('throttle:verification-send')
        ->name('verification.send');
});

Route::middleware([
    'auth',
    \App\Http\Middleware\EnsureEmailVerified::class,
    \App\Http\Middleware\EnsureTenant::class,
])->group(function () {

    Route::get('/projects', [ProjectController::class, 'index'])->name('projects.index');
    // Project-details modal (JSON) — read-only, no can-edit gate (viewers
    // can see details of projects shared with them).
    Route::get('/projects/{project}/details', [ProjectController::class, 'details'])
        ->whereNumber('project')
        ->name('projects.details');
    Route::post('/projects', [ProjectController::class, 'store'])
        ->middleware('can-edit')
        ->name('projects.store');
    Route::patch('/projects/{project}', [ProjectController::class, 'update'])
        ->whereNumber('project')
        ->middleware('can-edit')
        ->name('projects.update');
    Route::post('/projects/{project}/duplicate', [ProjectController::class, 'duplicate'])
        ->whereNumber('project')
        ->middleware('can-edit')
        ->name('projects.duplicate');
    Route::delete('/projects/{project}', [ProjectController::class, 'destroy'])
        ->whereNumber('project')
        ->middleware('can-edit')
        ->name('projects.destroy');
    Route::get('/projects/{project}/template-preflight', [ProjectController::class, 'exportTemplatePreflight'])
        ->whereNumber('project')
        ->name('projects.exportTemplate.preflight');
    Route::post('/projects/{project}/export-template', [ProjectController::class, 'exportTemplate'])
        ->whereNumber('project')
        ->name('projects.exportTemplate');

    Route::get('/editor/{project}', [ProjectController::class, 'show'])
        ->whereNumber('project')
        ->name('editor.show');

    Route::post('/projects/{project}/media', [MediaController::class, 'store'])
        ->whereNumber('project')
        ->middleware(['throttle:media-uploads', 'can-edit'])
        ->name('projects.media.store');

    Route::post('/projects/{project}/screens', [ScreenController::class, 'store'])
        ->whereNumber('project')
        ->middleware('can-edit')
        ->name('projects.screens.store');

    Route::post('/projects/{project}/screens/reorder', [ScreenController::class, 'reorder'])
        ->whereNumber('project')
        ->middleware('can-edit')
        ->name('projects.screens.reorder');

    Route::patch('/screens/{screen}', [ScreenController::class, 'update'])
        ->whereNumber('screen')
        ->middleware('can-edit')
        ->name('screens.update');

    Route::post('/screens/{screen}/duplicate', [ScreenController::class, 'duplicate'])
        ->whereNumber('screen')
        ->middleware('can-edit')
        ->name('screens.duplicate');

    Route::delete('/screens/{screen}', [ScreenController::class, 'destroy'])
        ->whereNumber('screen')
        ->middleware('can-edit')
        ->name('screens.destroy');

    Route::patch('/screens/{screen}/master/frame-screen-locale-path', [ScreenController::class, 'updateMasterFrameScreenLocalePath'])
        ->whereNumber('screen')
        ->middleware('can-edit')
        ->name('screens.master.frameScreenLocalePath');

    // Pessimistic project-edit lock (Team Workspaces P4 / Δ21). Both modes —
    // a solo self-hosted user always holds it. NOT saas-only. Viewers can't
    // acquire/take over the lock (P3c / Δ22) — gated by can-edit.
    Route::post('/projects/{project}/edit-lock/ping', [ProjectEditLockController::class, 'ping'])
        ->whereNumber('project')
        ->middleware('can-edit')
        ->name('projects.editLock.ping');

    Route::post('/projects/{project}/edit-lock/takeover', [ProjectEditLockController::class, 'takeover'])
        ->whereNumber('project')
        ->middleware('can-edit')
        ->name('projects.editLock.takeover');

    Route::delete('/projects/{project}/edit-lock', [ProjectEditLockController::class, 'release'])
        ->whereNumber('project')
        ->name('projects.editLock.release');

    // AI generation — same auth+tenant stack as project routes; mutating endpoints carry can-edit.
    // Pre-stage one screenshot/logo file at a time so a big multi-device deck
    // dodges PHP's `max_file_uploads` cap on the single generate POST.
    Route::post('/ai/stage-asset', [AiController::class, 'stageAsset'])
        ->middleware('can-edit')
        ->name('ai.stage-asset');
    Route::post('/ai/generate', [AiController::class, 'generate'])
        ->middleware('can-edit')
        ->name('ai.generate');
    Route::post('/ai/chat', [AiController::class, 'chat'])
        ->middleware('can-edit')
        ->name('ai.chat');
    Route::post('/ai/undo', [AiController::class, 'undo'])
        ->middleware('can-edit')
        ->name('ai.undo');
    Route::post('/ai/shuffle/{project}', [AiController::class, 'shuffle'])
        ->whereNumber('project')
        ->middleware('can-edit')
        ->name('ai.shuffle');

    Route::get('/account/profile', [AccountProfileController::class, 'edit'])->name('account.profile.edit');
    Route::patch('/account/profile', [AccountProfileController::class, 'update'])->name('account.profile.update');
    Route::patch('/account/password', [AccountProfileController::class, 'updatePassword'])->name('account.password.update');
    // GDPR self-service: data export (Art. 15/20) + account deletion (Art. 17).
    // Both modes, auth-only — the data subject acts on their own account.
    Route::get('/account/export', [AccountProfileController::class, 'exportData'])->name('account.export');
    Route::delete('/account', [AccountProfileController::class, 'destroy'])->name('account.destroy');

    Route::get('/account/storage-usage', [AccountStorageUsageController::class, 'show'])
        ->name('account.storageUsage');
    Route::get('/account/ai-usage', [AccountAiUsageController::class, 'show'])
        ->name('account.aiUsage');

    // Stateless preflight: validates plan + project + screens + devices,
    // resolves layouts, returns the tile spec. The client renders + ZIPs
    // locally with JSZip and downloads directly — the legacy server-side
    // ZIP pipeline (storeTile / finalize / show / download / ZipExportJob)
    // retired with the client-zip pivot in May 2026.
    Route::post('/projects/{project}/exports', [ExportController::class, 'store'])
        ->whereNumber('project')
        ->name('exports.store');
});

Route::middleware('saas-only')->group(function () {
    Route::get('/pricing', [PricingController::class, 'show'])->middleware('landing.mode')->name('pricing');
    // JSON twin of /pricing — drives the new currency-first SPA pricing
    // page. Public; auth users get `currentSubscription` populated.
    Route::get('/pricing.json', [PricingController::class, 'showJson'])->name('pricing.json');

    // Webhook endpoint — providers don't carry session/CSRF cookies.
    // CSRF exclusion lives in `bootstrap/app.php` via validateCsrfTokens().
    // Throttled per-IP to brake log/worker exhaustion floods; legitimate
    // providers retry on 429 so real events still land after backoff.
    Route::post('/webhooks/billing', [BillingWebhookController::class, 'handle'])
        ->middleware('throttle:billing-webhooks')
        ->name('webhooks.billing');

    // Team Workspaces (P3, SaaS-only). The invite accept LANDING is reachable
    // by guests (it redirects them to register-with-invite); the action
    // endpoints below are auth-gated. JSON-returning actions are desktop-
    // portable (CLAUDE.md §Core patterns).
    Route::get('/workspace/invite/{token}', [WorkspaceInvitationController::class, 'showAccept'])
        ->name('workspace.invite.show');

    Route::middleware('auth')->group(function () {
        Route::post('/checkout', [CheckoutController::class, 'store'])->name('checkout.store');
        Route::get('/checkout/success', [CheckoutController::class, 'success'])->name('checkout.success');
        Route::get('/checkout/cancel', [CheckoutController::class, 'cancel'])->name('checkout.cancel');
        Route::get('/checkout/razorpay/{subscription}', [CheckoutController::class, 'razorpay'])
            ->where('subscription', 'sub_[A-Za-z0-9]+')
            ->name('checkout.razorpay.modal');
        Route::post('/checkout/razorpay/verify', [CheckoutController::class, 'razorpayVerify'])
            ->name('checkout.razorpay.verify');

        Route::get('/account/subscription/status', [AccountController::class, 'subscriptionStatus'])
            ->name('account.subscription.status');

        Route::get('/account/subscription', [AccountController::class, 'subscription'])
            ->name('account.subscription');
        Route::post('/account/subscription/cancel', [AccountController::class, 'cancel'])
            ->name('account.subscription.cancel');
        Route::post('/account/subscription/resume', [AccountController::class, 'resume'])
            ->name('account.subscription.resume');
        Route::post('/account/subscription/change-plan', [AccountController::class, 'changePlan'])
            ->name('account.subscription.changePlan');

        // -- Team Workspaces (P3) ------------------------------------------
        // Membership management + switcher. All require an active workspace
        // (the EnsureTenant equivalent is implicit — `activeTenant()` 404s a
        // tenantless user). Authorization splits via WorkspacePolicy.

        Route::get('/workspace/members', [WorkspaceController::class, 'members'])
            ->name('workspace.members');

        // Per-workspace activity log (owner/admin only — tenant-scoped audit
        // trail; reads the same audit_logs rows filtered by context tenant_id).
        Route::get('/workspace/activity', [WorkspaceController::class, 'activity'])
            ->name('workspace.activity');

        Route::post('/workspace/invitations', [WorkspaceInvitationController::class, 'store'])
            ->name('workspace.invitations.store');
        Route::delete('/workspace/invitations/{invitation}', [WorkspaceInvitationController::class, 'destroy'])
            ->whereNumber('invitation')
            ->name('workspace.invitations.destroy');

        // In-app pending-invitations surface (the bell/switcher twin of the
        // email accept link). List + accept/decline by id for the addressed
        // user — no raw token in the client.
        Route::get('/workspace/invitations', [WorkspaceInvitationController::class, 'invitations'])
            ->name('workspace.invitations.index');
        Route::post('/workspace/invitations/{invitation}/accept', [WorkspaceInvitationController::class, 'acceptById'])
            ->whereNumber('invitation')
            ->name('workspace.invitations.accept');
        Route::post('/workspace/invitations/{invitation}/decline', [WorkspaceInvitationController::class, 'declineById'])
            ->whereNumber('invitation')
            ->name('workspace.invitations.decline');

        Route::post('/workspace/invite/{token}/accept', [WorkspaceInvitationController::class, 'accept'])
            ->name('workspace.invite.accept');
        Route::post('/workspace/invite/{token}/decline', [WorkspaceInvitationController::class, 'decline'])
            ->name('workspace.invite.decline');

        Route::patch('/workspace', [WorkspaceController::class, 'rename'])
            ->name('workspace.rename');

        Route::patch('/workspace/members/{user}', [WorkspaceController::class, 'changeRole'])
            ->whereNumber('user')
            ->name('workspace.members.changeRole');
        Route::patch('/workspace/members/{user}/ai-allocation', [WorkspaceController::class, 'setAiAllocation'])
            ->whereNumber('user')
            ->name('workspace.members.aiAllocation');
        Route::delete('/workspace/members/{user}', [WorkspaceController::class, 'removeMember'])
            ->whereNumber('user')
            ->name('workspace.members.remove');

        Route::post('/workspace/leave', [WorkspaceController::class, 'leave'])
            ->name('workspace.leave');
        Route::post('/workspace/switch', [WorkspaceController::class, 'switch'])
            ->name('workspace.switch');

        // -- Per-project visibility sharing (P3d / Δ23/Δ24/Δ25) ------------
        // Owner-or-admin grant/revoke of a project to existing workspace
        // members. SaaS-only (per-project sharing is a SaaS feature — Δ23);
        // owner-or-admin gated inside the controller via WorkspacePolicy.
        Route::get('/projects/{project}/members', [ProjectMemberController::class, 'index'])
            ->whereNumber('project')
            ->name('projects.members.index');
        Route::post('/projects/{project}/members', [ProjectMemberController::class, 'store'])
            ->whereNumber('project')
            ->name('projects.members.store');
        Route::delete('/projects/{project}/members/{user}', [ProjectMemberController::class, 'destroy'])
            ->whereNumber('project')
            ->whereNumber('user')
            ->name('projects.members.destroy');

        // -- In-app notifications (P3d / Δ25) ------------------------------
        // The AppNav bell rides the `notifications` shared prop (latest 10);
        // `index` is the full-history "View all" page; the POSTs mark read.
        Route::get('/notifications', [NotificationController::class, 'index'])
            ->name('notifications.index');
        Route::post('/notifications/{id}/read', [NotificationController::class, 'read'])
            ->name('notifications.read');
        Route::post('/notifications/read-all', [NotificationController::class, 'readAll'])
            ->name('notifications.readAll');
    });
});

// Admin namespace — mode-aware. Settings, appearance, audit log, and Users
// are available in BOTH modes (self_hosted buyers running multi-user
// installs need to manage their teammates: promote/disable/delete).
// Billing-specific surfaces (dashboard tiles, tenants, subscriptions,
// invoices, webhook events, plans) and impersonation stay saas-only —
// they'd render empty / be meaningless in a single-tenant install.
Route::middleware(['auth', 'admin'])->prefix('admin')->name('admin.')->group(function () {
    // Users — both modes. CRUD + bulk ops; impersonation is saas-only.
    Route::get('/users', [AdminUsersController::class, 'index'])->name('users.index');
    // Invite a teammate into the shared pool (self-hosted only — Δ20).
    Route::post('/users/invite', [AdminUsersController::class, 'invite'])->name('users.invite');
    // Operator-provisioned account (SaaS only) — registration-equivalent:
    // user + personal workspace + owner pivot + a set-password email.
    Route::post('/users', [AdminUsersController::class, 'store'])->name('users.store');
    Route::post('/users/bulk', [AdminUsersController::class, 'bulk'])->name('users.bulk');
    Route::get('/users/{user}', [AdminUsersController::class, 'show'])
        ->whereNumber('user')
        ->name('users.show');
    Route::patch('/users/{user}', [AdminUsersController::class, 'update'])
        ->whereNumber('user')
        ->name('users.update');
    Route::delete('/users/{user}', [AdminUsersController::class, 'destroy'])
        ->whereNumber('user')
        ->name('users.destroy');

    // Saas-only admin surfaces — Tenants, billing tables, dashboard tiles,
    // impersonation. self_hosted is single-tenant with no billing.
    Route::middleware('saas-only')->group(function () {
        Route::get('/', [AdminDashboardController::class, 'index'])->name('dashboard');

        Route::post('/users/{user}/impersonate', [AdminUsersController::class, 'impersonate'])
            ->whereNumber('user')
            ->name('users.impersonate');

        // Tenants
        Route::get('/tenants', [AdminTenantsController::class, 'index'])->name('tenants.index');
        Route::get('/tenants/{tenant}', [AdminTenantsController::class, 'show'])
            ->whereNumber('tenant')
            ->name('tenants.show');

        // Subscriptions
        Route::get('/subscriptions', [AdminSubscriptionsController::class, 'index'])->name('subscriptions.index');
        // Bulk-delete must come BEFORE the {subscription} catch-all so the
        // 'clear-test' literal isn't matched as an int subscription id.
        Route::post('/subscriptions/clear-test', [AdminSubscriptionsController::class, 'clearTestData'])
            ->name('subscriptions.clearTestData');
        Route::get('/subscriptions/{subscription}', [AdminSubscriptionsController::class, 'show'])
            ->whereNumber('subscription')
            ->name('subscriptions.show');
        Route::post('/subscriptions/{subscription}/cancel', [AdminSubscriptionsController::class, 'cancel'])
            ->whereNumber('subscription')
            ->name('subscriptions.cancel');
        Route::post('/subscriptions/{subscription}/resume', [AdminSubscriptionsController::class, 'resume'])
            ->whereNumber('subscription')
            ->name('subscriptions.resume');
        Route::post('/subscriptions/{subscription}/change-plan', [AdminSubscriptionsController::class, 'changePlan'])
            ->whereNumber('subscription')
            ->name('subscriptions.changePlan');

        // Invoices
        Route::get('/invoices', [AdminInvoicesController::class, 'index'])->name('invoices.index');
        Route::get('/invoices/{invoice}', [AdminInvoicesController::class, 'show'])
            ->whereNumber('invoice')
            ->name('invoices.show');
        Route::post('/invoices/{invoice}/refund', [AdminInvoicesController::class, 'refund'])
            ->whereNumber('invoice')
            ->name('invoices.refund');

        // Webhook events
        Route::get('/webhook-events', [AdminWebhookEventsController::class, 'index'])->name('webhookEvents.index');
        Route::get('/webhook-events/{event}', [AdminWebhookEventsController::class, 'show'])
            ->whereNumber('event')
            ->name('webhookEvents.show');
        Route::post('/webhook-events/{event}/replay', [AdminWebhookEventsController::class, 'replay'])
            ->whereNumber('event')
            ->name('webhookEvents.replay');

        // Plans + providers (existing)
        Route::get('/billing', [AdminBillingController::class, 'index'])->name('billing.index');
        Route::patch('/billing/providers/{key}', [AdminBillingController::class, 'updateProvider'])
            ->where('key', '[a-z_]+')
            ->name('billing.providers.update');
        Route::post('/billing/plans', [AdminBillingController::class, 'storePlan'])
            ->name('billing.plans.store');
        Route::patch('/billing/plans/{planKey}', [AdminBillingController::class, 'updatePlan'])
            ->where('planKey', '[a-z0-9-]+')
            ->name('billing.plans.update');
        Route::delete('/billing/plans/{planKey}', [AdminBillingController::class, 'destroyPlan'])
            ->where('planKey', '[a-z0-9-]+')
            ->name('billing.plans.destroy');

        // Plan prices — currency-first (plan, currency, gateway, period)
        // tuple management. Replaces the older bulk PUT
        // /billing/plans/{key}/provider-links endpoint.
        Route::post('/billing/plans/{planKey}/prices', [AdminBillingController::class, 'storePrice'])
            ->where('planKey', '[a-z0-9-]+')
            ->name('billing.plans.prices.store');
        Route::patch('/billing/prices/{price}', [AdminBillingController::class, 'updatePrice'])
            ->whereNumber('price')
            ->name('billing.prices.update');
        Route::delete('/billing/prices/{price}', [AdminBillingController::class, 'destroyPrice'])
            ->whereNumber('price')
            ->name('billing.prices.destroy');

        // Payment Gateways tab on /admin/settings — Settings is its own
        // Inertia page, so the gateways tab fetches the providers payload
        // standalone via this JSON endpoint. Same provider mapping as
        // /admin/billing's index render (BillingController::buildProviderPayload).
        Route::get('/settings/gateways', [AdminBillingController::class, 'providersJson'])
            ->name('gateways.index');
    });

    // Available in BOTH modes — self_hosted buyers need these too.

    // Appearance — theme management (knobs, presets, version history,
    // live preview via PreviewTheme middleware reading session draft).
    Route::get('/appearance', [AdminAppearanceController::class, 'index'])->name('appearance.index');
    Route::post('/appearance/draft', [AdminAppearanceController::class, 'storeDraft'])->name('appearance.draft.store');
    Route::delete('/appearance/draft', [AdminAppearanceController::class, 'clearDraft'])->name('appearance.draft.clear');
    Route::post('/appearance', [AdminAppearanceController::class, 'store'])->name('appearance.store');
    Route::patch('/appearance/{version}/apply', [AdminAppearanceController::class, 'apply'])
        ->whereNumber('version')->name('appearance.apply');
    Route::delete('/appearance/{version}', [AdminAppearanceController::class, 'destroy'])
        ->whereNumber('version')->name('appearance.destroy');
    Route::get('/appearance/preset/{key}', [AdminAppearanceController::class, 'preset'])
        ->where('key', '[a-z][a-z0-9-]*')->name('appearance.preset');
    Route::get('/appearance/{version}/export', [AdminAppearanceController::class, 'exportJson'])
        ->whereNumber('version')->name('appearance.export');
    Route::post('/appearance/import', [AdminAppearanceController::class, 'importJson'])->name('appearance.import');
    Route::post('/appearance/{version}/assets', [AdminAppearanceController::class, 'uploadAsset'])
        ->whereNumber('version')->name('appearance.assets.upload');
    Route::delete('/appearance/{version}/assets/{slot}', [AdminAppearanceController::class, 'deleteAsset'])
        ->whereNumber('version')->where('slot', '[a-z_]+')->name('appearance.assets.delete');
    Route::post('/audit-log/{auditLog}/revert-theme', [AdminAppearanceController::class, 'revertFromAudit'])
        ->whereNumber('auditLog')->name('audit.revert.theme');

    // Marketing CMS — header nav + footer columns. Both modes: self-hosted
    // operators want to retitle/disable links too (e.g. drop Pricing on a
    // single-tenant install). Storage flows through Settings::set/get with
    // JSON casting; defaults live in MarketingDefaults so a fresh install
    // ships identical copy on both PHP and TS sides.
    Route::get('/marketing', [AdminMarketingController::class, 'index'])->name('marketing.index');
    Route::patch('/marketing/header', [AdminMarketingController::class, 'updateHeader'])->name('marketing.header.update');
    Route::patch('/marketing/footer', [AdminMarketingController::class, 'updateFooter'])->name('marketing.footer.update');
    Route::patch('/marketing/welcome', [AdminMarketingController::class, 'updateWelcome'])->name('marketing.welcome.update');
    Route::patch('/marketing/pricing', [AdminMarketingController::class, 'updatePricing'])->name('marketing.pricing.update');
    Route::patch('/marketing/templates', [AdminMarketingController::class, 'updateTemplates'])->name('marketing.templates.update');
    Route::patch('/marketing/about', [AdminMarketingController::class, 'updateAbout'])->name('marketing.about.update');
    Route::patch('/marketing/contact', [AdminMarketingController::class, 'updateContact'])->name('marketing.contact.update');
    Route::patch('/marketing/privacy', [AdminMarketingController::class, 'updatePrivacy'])->name('marketing.privacy.update');
    Route::patch('/marketing/terms', [AdminMarketingController::class, 'updateTerms'])->name('marketing.terms.update');
    Route::patch('/marketing/refund', [AdminMarketingController::class, 'updateRefund'])->name('marketing.refund.update');
    Route::post('/marketing/custom-pages', [AdminMarketingController::class, 'createCustomPage'])->name('marketing.custom_pages.create');
    Route::patch('/marketing/custom-pages/{slug}', [AdminMarketingController::class, 'updateCustomPage'])
        ->where('slug', '[a-z0-9-]+')
        ->name('marketing.custom_pages.update');
    Route::delete('/marketing/custom-pages/{slug}', [AdminMarketingController::class, 'deleteCustomPage'])
        ->where('slug', '[a-z0-9-]+')
        ->name('marketing.custom_pages.delete');
    Route::patch('/marketing/mode', [AdminMarketingController::class, 'updateMode'])->name('marketing.mode.update');
    Route::patch('/marketing/seo', [AdminMarketingController::class, 'updateSeo'])->name('marketing.seo.update');
    Route::patch('/marketing/scripts', [AdminMarketingController::class, 'updateScripts'])->name('marketing.scripts.update');
    Route::patch('/marketing/cookie-consent', [AdminMarketingController::class, 'updateCookieConsent'])->name('marketing.cookie_consent.update');
    Route::post('/marketing/hero-image', [AdminMarketingController::class, 'uploadHeroImage'])->name('marketing.hero_image.upload');
    Route::post('/marketing/testimonial-avatar', [AdminMarketingController::class, 'uploadTestimonialAvatar'])->name('marketing.testimonial_avatar.upload');

    // Settings + Audit log — the buyer's main self_hosted touchpoint
    // (mail config, branding, signup policy, etc.).
    Route::get('/settings', [AdminSettingsController::class, 'index'])->name('settings.index');
    Route::patch('/settings', [AdminSettingsController::class, 'update'])->name('settings.update');
    Route::patch('/settings/notifications', [AdminSettingsController::class, 'updateNotifications'])->name('settings.notifications.update');
    Route::post('/settings/notifications/preview', [AdminSettingsController::class, 'previewNotification'])->name('settings.notifications.preview');
    Route::post('/settings/notifications/test-send', [AdminSettingsController::class, 'sendTestNotification'])->name('settings.notifications.test_send');
    Route::post('/settings/notifications/email-logo', [AdminSettingsController::class, 'uploadEmailLogo'])->name('settings.notifications.email_logo_upload');
    Route::delete('/settings/notifications/email-logo', [AdminSettingsController::class, 'deleteEmailLogo'])->name('settings.notifications.email_logo_delete');
    Route::post('/settings/mail/test', [AdminSettingsController::class, 'testMail'])->name('settings.mail.test');
    // Realtime collab transport (Team Workspaces P6 / Tier 2 — Reverb opt-in).
    Route::patch('/settings/realtime', [AdminSettingsController::class, 'updateRealtime'])->name('settings.realtime.update');
    Route::post('/settings/realtime/test', [AdminSettingsController::class, 'testReverb'])->name('settings.realtime.test');
    // Social login credentials (Google OAuth).
    Route::patch('/settings/social-auth', [AdminSettingsController::class, 'updateSocialAuth'])->name('settings.social_auth.update');
    // AI generation settings.
    Route::patch('/settings/ai', [AdminSettingsController::class, 'updateAiSettings'])->name('settings.ai.update');
    Route::post('/settings/ai/test', [AdminSettingsController::class, 'testAiConnection'])->name('settings.ai.test')->middleware('throttle:10,1');

    // One-click updates (Updates tab) — JSON-only, consumed via fetch. Both
    // modes. The support-window gate + purchase-code requirement are enforced
    // server-side in UpdateController/Updater, not just hidden in the UI.
    Route::get('/settings/update/check', [AdminUpdateController::class, 'check'])->name('update.check');
    Route::get('/settings/update/progress', [AdminUpdateController::class, 'progress'])->name('update.progress');
    Route::post('/settings/update/download', [AdminUpdateController::class, 'download'])->name('update.download');
    Route::post('/settings/update/install', [AdminUpdateController::class, 'install'])->name('update.install');
    Route::post('/settings/update/finalize', [AdminUpdateController::class, 'finalize'])->name('update.finalize');
    // Recovery actions (not support-gated): re-run migrations, clear caches —
    // for finishing a manual FTP update. (No in-app ZIP upload — manual update
    // is FTP overwrite + /update-database.)
    Route::post('/settings/update/migrate', [AdminUpdateController::class, 'migrate'])->name('update.migrate');
    Route::post('/settings/update/clear-cache', [AdminUpdateController::class, 'clearCache'])->name('update.clearCache');

    Route::get('/audit-log', [AdminAuditLogController::class, 'index'])->name('audit.index');

    // Maintenance — manual-trigger surface for the scheduled cleanup
    // commands (mirrors routes/console.php). Same task whitelist drives
    // both endpoints; the controller validates `task` against it so
    // arbitrary artisan calls aren't possible from the UI.
    Route::get('/maintenance', [AdminMaintenanceController::class, 'index'])->name('maintenance.index');
    Route::post('/maintenance/preview', [AdminMaintenanceController::class, 'preview'])->name('maintenance.preview');
    Route::post('/maintenance/purge', [AdminMaintenanceController::class, 'purge'])->name('maintenance.purge');

    // Kits — admin-managed project-template kits. Bulk must come BEFORE the
    // {key} catch-all so the 'bulk' literal isn't matched as a kit key.
    // download + apply are similarly literal-suffixed and registered before
    // the bare /kits/{key} GET so the regex doesn't swallow them.
    Route::get('/kits', [AdminKitsController::class, 'index'])->name('kits.index');
    Route::post('/kits', [AdminKitsController::class, 'store'])->name('kits.store');
    Route::post('/kits/bulk', [AdminKitsController::class, 'bulkAction'])->name('kits.bulk');
    Route::get('/kits/{key}/download', [AdminKitsController::class, 'download'])
        ->where('key', '[a-z0-9-]+')
        ->name('kits.download');
    Route::post('/kits/{key}/apply', [AdminKitsController::class, 'apply'])
        ->where('key', '[a-z0-9-]+')
        ->name('kits.apply');
    Route::get('/kits/{key}', [AdminKitsController::class, 'show'])
        ->where('key', '[a-z0-9-]+')
        ->name('kits.show');
    Route::patch('/kits/{key}', [AdminKitsController::class, 'update'])
        ->where('key', '[a-z0-9-]+')
        ->name('kits.update');
    Route::delete('/kits/{key}', [AdminKitsController::class, 'destroy'])
        ->where('key', '[a-z0-9-]+')
        ->name('kits.destroy');

    // Currencies — JSON-only endpoints consumed by the Settings page's
    // Currencies tab via fetch. Display-format only (symbol + position +
    // separators); we don't track exchange rates because Stripe + Razorpay
    // charge fixed-currency price objects, so admin sets explicit
    // plan_prices rows per (plan, currency) pair instead of converting.
    Route::get('/settings/currencies', [AdminCurrenciesController::class, 'index'])->name('currencies.index');
    Route::post('/settings/currencies', [AdminCurrenciesController::class, 'store'])->name('currencies.store');
    Route::patch('/settings/currencies/{currency}', [AdminCurrenciesController::class, 'update'])
        ->whereNumber('currency')
        ->name('currencies.update');
    Route::delete('/settings/currencies/{currency}', [AdminCurrenciesController::class, 'destroy'])
        ->whereNumber('currency')
        ->name('currencies.destroy');
    Route::post('/settings/currencies/{currency}/default', [AdminCurrenciesController::class, 'setDefault'])
        ->whereNumber('currency')
        ->name('currencies.setDefault');

    // Storage settings — multi-cloud disk picker (local / S3 / DO Spaces /
    // Wasabi / Minio). JSON-only endpoints consumed by the Settings page's
    // Storage tab. Available in BOTH modes — self-hosted buyers ship to
    // shared hosting where local disk eventually fills, so the cloud-disk
    // flip is a self-hosted operator concern too.
    Route::get('/settings/storage', [AdminStorageSettingsController::class, 'index'])->name('storage.index');
    Route::patch('/settings/storage', [AdminStorageSettingsController::class, 'update'])->name('storage.update');
    Route::post('/settings/storage/active', [AdminStorageSettingsController::class, 'setActive'])->name('storage.setActive');
    Route::post('/settings/storage/test', [AdminStorageSettingsController::class, 'testUpload'])->name('storage.testUpload');
    Route::post('/settings/storage/migrate', [AdminStorageSettingsController::class, 'migrate'])->name('storage.migrate');
});

// Stop impersonating — outside the admin gate so the impersonated (non-admin)
// user can still leave the impersonation session. Saas-only since
// impersonation only exists in saas mode.
Route::middleware(['auth', 'saas-only'])->post('/admin/stop-impersonating', [AdminUsersController::class, 'stopImpersonating'])
    ->name('admin.users.stopImpersonating');

// Fallback for /user-uploads/* — the current local upload location. On a
// healthy install the web server serves these files directly (they're real
// files in the web root) and this never fires. It exists for ONE transitional
// case: an install upgraded from the old storage/app/public layout whose files
// haven't been relocated yet (manual FTP update before the relocate command
// runs). We serve the file from EITHER the new dir or the legacy dir, so a
// buyer's project media / logo / favicon keep working the instant they update
// — no "Run DB migration" click required. Once relocate runs, files move to
// public/user-uploads and serve statically; this handler stops firing for them.
// Path-traversal guarded via realpath().
Route::get('/user-uploads/{path}', function (string $path) {
    foreach ([public_path('user-uploads'), storage_path('app/public')] as $root) {
        $base = realpath($root);
        if ($base === false) {
            continue;
        }
        $full = realpath($root . '/' . $path);
        if ($full !== false && is_file($full) && str_starts_with($full, $base . DIRECTORY_SEPARATOR)) {
            return response()->file($full, [
                'Cache-Control' => 'public, max-age=31536000, immutable',
            ]);
        }
    }

    abort(404);
})->where('path', '.*');

// Catch-all for admin-created custom marketing pages at /{slug}. MUST
// be the LAST route in this file — Laravel matches in registration
// order, and abort(404) doesn't fall through to subsequent routes, so
// a catch-all placed earlier would shadow /login / /pricing / etc. The
// `[a-z0-9-]+` regex bound + the in-handler reserved-slug check prevent
// shadowing app surfaces even if admin tries to create a slug that
// matches an existing app path. Single-segment only (multi-segment paths
// like /editor/123 don't match `/{slug}`). Carries the strict landing-
// mode middleware so the site-level Enabled/Redirect/Login toggle still
// gates custom pages the same way it gates About/Contact/etc.
// Public SEO endpoints — bare routes (no landing-mode gate, no admin).
// Crawlers fetch these directly even in Login/Redirect mode so admins
// who change site-level mode after the fact still have a discoverable
// sitemap if they re-enable later.
Route::get('/robots.txt', [\App\Http\Controllers\Admin\MarketingController::class, 'serveRobotsTxt'])
    ->name('marketing.robots');
Route::get('/sitemap.xml', [\App\Http\Controllers\Admin\MarketingController::class, 'serveSitemap'])
    ->name('marketing.sitemap');

Route::middleware('landing.mode:strict')->group(function () {
    Route::get('/{slug}', function (string $slug) {
        if (in_array($slug, \App\Support\MarketingDefaults::RESERVED_SLUGS, true)) {
            abort(404);
        }
        $page = app(\App\Http\Controllers\Admin\MarketingController::class)->findCustomPage($slug);
        if (! $page || ($page['enabled'] ?? false) === false) {
            abort(404);
        }
        return Inertia::render('CustomPage', [
            'page' => $page,
        ]);
    })->where('slug', '[a-z0-9-]+')->name('marketing.custom');
});
