<?php

namespace App\Providers\Filament;

use App\Filament\Auth\MultiFactor\EmailOtpAuthentication;
use App\Filament\Pages\Auth\EditProfile;
use App\Filament\Pages\Auth\Login;
use App\Filament\Pages\Dashboard;
use App\Http\Middleware\CheckActiveUser;
use App\Http\Middleware\IdentifyTenant;
use App\Http\Middleware\RedirectToAccessibleHome;
use App\Http\Middleware\ResolveBranchContext;
use App\Models\Central\Tenant;
use App\Services\CurrentContext;
use App\Services\Storage\TenantStorage;
use App\Support\Theme\TenantTheme;
use Closure;
use Filament\Auth\MultiFactor\App\AppAuthentication;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession;
use Filament\Http\Middleware\DisableBladeIconComponents;
use Filament\Http\Middleware\DispatchServingFilamentEvent;
use Filament\Panel;
use Filament\PanelProvider;
use Filament\Support\Facades\FilamentColor;
use Filament\View\PanelsRenderHook;
use Filament\Widgets\AccountWidget;
use Illuminate\Contracts\View\View;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\HtmlString;
use Illuminate\View\Middleware\ShareErrorsFromSession;

/**
 * The tenant application (ISMS: POS, inventory, sales, purchasing, etc.).
 * Reachable on any host NOT in config('tenancy.central_domains') — no
 * ->domain() constraint, matching the source app's routes/web.php (see
 * docs/migration-plan.md §1). Registered AFTER CentralPanelProvider in
 * bootstrap/providers.php so the central, domain-bound panel takes priority
 * on its own host — mirrors the source app's "central routes registered
 * first" rule in bootstrap/app.php.
 *
 * Guard: web (App\Models\User, tenant-scoped). IdentifyTenant +
 * ResolveBranchContext bind CurrentContext before any resource query runs.
 */
class TenantPanelProvider extends PanelProvider
{
    public function panel(Panel $panel): Panel
    {
        return $panel
            ->default()
            ->id('app')
            // Path tenancy: the {workspace} route parameter carries the tenant
            // slug on EVERY panel route (resources, pages, auth, password
            // reset). Filament's route prefix accepts a parameter as a plain
            // string; `URL::defaults(['workspace' => ...])` (IdentifyTenant)
            // back-fills it for URL generation (docs/path-based-tenancy-plan.md
            // Levers 1 + 2).
            ->path(config('tenancy.routing') === 'path' ? '{workspace}/app' : 'app')
            ->authGuard('web')
            ->login(Login::class)
            ->profile(EditProfile::class, isSimple: false)
            ->passwordReset()
            ->multiFactorAuthentication([
                AppAuthentication::make()->recoverable(),
                EmailOtpAuthentication::make(),
            ])
            ->bootUsing(static::registerThemeColors())
            // Resolved lazily at render time, so IdentifyTenant has already bound
            // CurrentContext by the time this closure runs. The invoice "Business
            // name" overrides the system default whenever the landlord set it —
            // the app name (explicit branding) still wins over it, then the
            // platform default is the last resort.
            ->brandName(fn (): string => static::brandName())
            // The uploaded logo was previously stored and previewed but never
            // rendered: with no brandLogo, Filament falls through to printing the
            // brand NAME as text in the sidebar header, topbar and above the
            // sign-in card. A tenant who uploaded a mark never saw it in their own
            // app. Null keeps the text fallback for tenants who have no logo. The
            // invoice business logo is the fallback mark when only that was
            // uploaded, so the tenant's own logo shows wherever branding renders.
            ->brandLogo(fn (): ?string => static::brandLogo())
            ->brandLogoHeight('1.75rem')
            ->favicon(fn (): string => asset('images/vellix-point-logo.png'))
            ->sidebarCollapsibleOnDesktop()
            ->sidebarFullyCollapsibleOnDesktop()
            // The topbar record-search box searched across resources, didn't
            // work reliably, and wasn't needed here — replaced below by a
            // menu-label search in the sidebar itself.
            ->globalSearch(false)
            // Sidebar group order, most-used first. Must stay in lockstep with
            // Database\Seeders\Menu\MenuDefinition section order — the same
            // hierarchy the permission matrix renders from.
            ->navigationGroups([
                'Point of Sale',
                'Sales',
                'Master Data',
                'Inventory',
                'Purchasing',
                'Reports',
                'Promotions & Loyalty',
                'Administration',
            ])
            ->viteTheme('resources/css/filament/app/theme.css')
            ->renderHook(PanelsRenderHook::HEAD_END, fn (): HtmlString => static::themeHeadHtml())
            ->renderHook(PanelsRenderHook::TOPBAR_LOGO_BEFORE, fn (): View => view('filament.partials.sidebar-collapse-toggle'))
            ->renderHook(PanelsRenderHook::SIDEBAR_NAV_START, fn (): View => view('filament.partials.sidebar-nav-search'))
            ->renderHook(PanelsRenderHook::TOPBAR_BEFORE, fn (): ?View => static::renderImpersonationBanner())
            // Test-instance environments get a persistent amber banner so
            // nobody mistakes a testing copy for the live tenant. Shows how
            // stale the environment is ("Last refreshed from Live").
            ->renderHook(PanelsRenderHook::TOPBAR_BEFORE, fn (): ?View => static::renderTestEnvironmentBanner())
            // Rendered inside .fi-topbar-end just before the user menu, so the
            // right side reads: clock, profile.
            ->renderHook(PanelsRenderHook::GLOBAL_SEARCH_AFTER, fn (): View => view('filament.partials.clock'))
            ->renderHook(PanelsRenderHook::TOPBAR_END, fn (): ?View => static::renderBranchSwitcher())
            // Test instances only: "Sync from Live" in the topbar. The strict
            // isTestInstance() check below means a LIVE tenant never renders
            // this button — and the controller re-checks the same rule on POST,
            // so a forged request cannot reach the sync either.
            ->renderHook(PanelsRenderHook::TOPBAR_END, fn (): ?View => static::renderSyncFromLiveButton())
            ->renderHook(PanelsRenderHook::FOOTER, fn (): View => view('filament.partials.copyright-footer', [
                'company' => 'Vellix Global',
                'year' => now()->format('Y'),
            ]))
            ->renderHook(PanelsRenderHook::BODY_END, fn (): ?View => static::renderDeviceTokenScript())
            // Prevents the browser's native number-input behavior where
            // scrolling the mouse wheel over a focused <input type="number">
            // silently increments/decrements its value. Blurs the input on
            // wheel (capture phase, before the browser applies the delta) and
            // leaves the event uncancelled so the page still scrolls normally.
            ->renderHook(PanelsRenderHook::BODY_END, fn (): HtmlString => static::numberInputWheelGuardScript())
            // Page-wide USB barcode-scanner capture — a scan reaches
            // window.VellixScan's registered handlers no matter what has
            // focus. Rendered on every tenant page so a scanner works the
            // instant a surface registers with it; inert until one does.
            // Not on the central panel — Master Control has no scanning
            // surface.
            ->renderHook(PanelsRenderHook::BODY_END, fn (): View => view('filament.partials.scan-capture-script'))
            ->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
            ->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
            ->pages([
                Dashboard::class,
            ])
            ->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\Filament\Widgets')
            ->widgets([
                AccountWidget::class,
            ])
            ->middleware([
                EncryptCookies::class,
                AddQueuedCookiesToResponse::class,
                StartSession::class,
                AuthenticateSession::class,
                ShareErrorsFromSession::class,
                VerifyCsrfToken::class,
                SubstituteBindings::class,
                DisableBladeIconComponents::class,
                DispatchServingFilamentEvent::class,
                IdentifyTenant::class,
                ResolveBranchContext::class,
                RedirectToAccessibleHome::class,
                CheckActiveUser::class,
            ])
            ->authMiddleware([
                Authenticate::class,
            ]);
    }

    /**
     * Hand Filament the tenant's brand and neutral shade ramps.
     *
     * The timing here is the whole point, and it is why ->colors() cannot be used
     * for this. Panel::boot() calls FilamentColor::register($this->getColors()),
     * and Panel::getColors() evaluates its closures EAGERLY — but boot() is driven
     * by the SetUpPanel middleware, which HasMiddleware places first in the stack,
     * ahead of this panel's own IdentifyTenant. So anything read through ->colors()
     * sees a null tenant and silently themes every tenant identically. That is one
     * of the two reasons saving a preset appeared to do nothing.
     *
     * Registering a Closure instead defers evaluation to ColorManager::getColors(),
     * which nothing calls until render — component colour maps and
     * AssetManager::renderStyles() — by which point the tenant is bound.
     *
     * Two ordering details this relies on:
     *
     *  - bootUsing() callbacks run at the END of Panel::boot(), after the panel's
     *    own colour registration, and ColorManager overwrites per name. So these
     *    ramps win even though nothing else registers `primary` or `gray` now.
     *  - boot() runs only for the panel handling the request, so Master Control
     *    keeps its own palette rather than inheriting a tenant's.
     */
    protected static function registerThemeColors(): Closure
    {
        return static function (): void {
            FilamentColor::register(fn (): array => static::currentTheme()->filamentColors());
        };
    }

    /**
     * The current request's tenant, resolved through the theme engine
     * (App\Support\Theme\ThemePresets/ThemePalette/ThemeFonts) built for the
     * Central "Theme" tab live preview — this is the single place that
     * applies it to the real, rendered tenant app instead of leaving it
     * dormant. Falls back to the default preset for central/CLI/no-tenant
     * contexts (no different from any other tenant reading a stale/unset key).
     */
    protected static function currentTheme(): TenantTheme
    {
        return TenantTheme::fromSettings(static::currentTenantSettings());
    }

    /**
     * Nested {branding, invoice, locale, theme, pos} settings for the
     * current tenant, cached under the same key EditTenant already invalidates
     * via Tenant::flushEntitlementCache() on every settings save.
     *
     * On a universal (non-tenant) host — e.g. the platform's own bare domain —
     * the tenant is resolved from the `slug` / `tenant` query parameter before
     * authentication, so the login page can brand itself with the workspace the
     * visitor is signing into instead of the platform default.
     *
     * @return array<string, mixed>
     */
    protected static function currentTenantSettings(): array
    {
        $tenant = static::resolveBrandingTenant();

        if ($tenant === null) {
            return [];
        }

        return Cache::remember(
            Tenant::settingsCacheKey($tenant->id),
            now()->addHour(),
            fn (): array => $tenant->settingsRow?->toNestedArray() ?? [],
        );
    }

    /**
     * The tenant whose branding this request should show: the subdomain-bound
     * tenant (normal case, set by IdentifyTenant), or — on a universal host
     * where no tenant is bound yet — the tenant named by the `slug` (or
     * `tenant`) query parameter, e.g. `/app/login?slug=acme`.
     */
    protected static function resolveBrandingTenant(): ?Tenant
    {
        return app(CurrentContext::class)->brandingTenant();
    }

    /**
     * The display name for the tenant panel chrome: branding "App name",
     * falling back to the invoice "Business name", then the platform default.
     */
    protected static function brandName(): string
    {
        $branding = static::currentBranding();

        return trim((string) ($branding['app_name'] ?? '')) !== ''
            ? $branding['app_name']
            : (trim((string) ($branding['business_name'] ?? '')) !== ''
                ? $branding['business_name']
                : (string) config('app.name'));
    }

    /**
     * The logo URL for the tenant panel chrome, or null for the text fallback.
     */
    protected static function brandLogo(): ?string
    {
        $branding = static::currentBranding();

        return trim((string) ($branding['logo_url'] ?? '')) !== ''
            ? $branding['logo_url']
            : (trim((string) ($branding['business_logo'] ?? '')) !== ''
                ? $branding['business_logo']
                : null);
    }

    /**
     * The current tenant's {app_name, business_name, logo_url, business_logo} —
     * set on the Master Control "Branding" / "Invoice" tabs (EditTenant) and
     * uploaded via the same TenantStorage pipeline as every other tenant file.
     * The invoice "Business name" is the branding override when no explicit app
     * name was set, and the invoice business logo is the fallback mark when no
     * branding logo was uploaded — so a tenant that only ever filled in its
     * business identity still sees its own name/logo, never the system default.
     * Falls back to the platform default logo/name (via ?: in the panel config)
     * when the tenant has set neither.
     *
     * @return array{app_name: ?string, business_name: ?string, logo_url: ?string, business_logo: ?string}
     */
    protected static function currentBranding(): array
    {
        $settings = static::currentTenantSettings();
        $branding = $settings['branding'] ?? [];
        $invoice = $settings['invoice'] ?? [];

        return [
            'app_name' => $branding['app_name'] ?? null,
            'business_name' => $invoice['business_name'] ?? null,
            // Stored as a path on the tenant's storage source (older rows hold an
            // already-resolved URL, which resolveUrl() passes through), so the
            // mark follows the tenant when it is moved between sources.
            'logo_url' => app(TenantStorage::class)->resolveUrl($branding['logo_url'] ?? null),
            'business_logo' => app(TenantStorage::class)->resolveUrl($invoice['business_logo'] ?? null),
        ];
    }

    /**
     * The <style> block (typography + both colour modes) plus a webfont
     * <link>, injected at panels::head.end — included by both the
     * authenticated app shell and the login page's layout, so a preset
     * applies on first paint even on a hard refresh of /login.
     */
    protected static function themeHeadHtml(): HtmlString
    {
        $theme = static::currentTheme();
        $fontLink = $theme->fontUrl();

        return new HtmlString(
            ($fontLink !== null ? '<link rel="stylesheet" href="'.e($fontLink).'">' : '')
            .'<style>'.$theme->css().'</style>'
        );
    }

    /**
     * Red banner + "End impersonation" button, rendered while a central admin
     * session is impersonating a tenant user (see ImpersonationController).
     * The `impersonator` session key is set by consume() and cleared by stop().
     */
    protected static function renderImpersonationBanner(): ?View
    {
        if (! auth('web')->check() || ! session()->has('impersonator')) {
            return null;
        }

        return view('filament.partials.impersonation-banner');
    }

    /**
     * Persistent amber banner on every page of a TEST environment — the
     * clearest possible signal that this is an isolated copy, not production.
     * Rendered whenever the resolved tenant is a test instance
     * (`environment = test`, see TestInstanceService / TestInstanceResource).
     */
    protected static function renderTestEnvironmentBanner(): ?View
    {
        if (! auth('web')->check()) {
            return null;
        }

        $tenant = app(CurrentContext::class)->tenant();

        if (! $tenant instanceof Tenant || ! $tenant->isTestInstance()) {
            return null;
        }

        return view('filament.partials.test-environment-banner', [
            'tenant' => $tenant,
            'parentName' => $tenant->parentTenant?->name,
            'lastSyncedAt' => $tenant->last_synced_at?->timezone(app(CurrentContext::class)->timezone()),
        ]);
    }

    /**
     * The top-bar operational branch selector, mirroring the source's
     * BranchSwitcher component: rendered only when the current user has access
     * to more than one active branch (single branch → used implicitly, no UI).
     * POSTs to /branch/select, which ResolveBranchContext reads on every
     * subsequent request.
     */
    protected static function renderBranchSwitcher(): ?View
    {
        if (! auth('web')->check()) {
            return null;
        }

        $context = app(CurrentContext::class);
        $branches = $context->branches();

        if ($branches->count() <= 1) {
            return null;
        }

        $selectedId = session('selected_branch_id');
        $currentName = $branches->firstWhere('id', $selectedId)?->name;

        return view('filament.partials.branch-switcher', [
            'branches' => $branches->map(fn ($b) => ['id' => $b->id, 'name' => $b->name])->values()->all(),
            'selectedId' => $selectedId !== null ? (int) $selectedId : null,
            'currentName' => $currentName,
        ]);
    }

    /**
     * The topbar "Sync from Live" button — rendered ONLY on test environments.
     * The check is strict: the button exists exclusively when the bound tenant
     * isTestInstance(), so a live tenant never sees (and can never trigger) a
     * live→test refresh from inside its own app. Hidden during central-admin
     * impersonation, since a sync wipes the impersonated user's row mid-session.
     * The receiving endpoint enforces the same test-only rule a second time.
     */
    protected static function renderSyncFromLiveButton(): ?View
    {
        if (! auth('web')->check() || session()->has('impersonator')) {
            return null;
        }

        $tenant = app(CurrentContext::class)->tenant();

        if (! $tenant instanceof Tenant || ! $tenant->isTestInstance()) {
            return null;
        }

        return view('filament.partials.sync-from-live-button', [
            'parentName' => $tenant->parentTenant?->name,
        ]);
    }

    /**
     * Mints/rebinds the Quick PIN "trusted device" token, mirroring
     * leolanka-inertia's useDeviceToken() hook. Server-side skip on
     * impersonation (ImpersonationController::consume() sets the
     * `impersonator` session key) instead of the source's client-side
     * check — same rule, enforced earlier.
     */
    protected static function renderDeviceTokenScript(): ?View
    {
        if (! auth('web')->check() || session()->has('impersonator')) {
            return null;
        }

        return view('filament.partials.device-token-script', [
            'userId' => auth('web')->id(),
            'hasPin' => auth('web')->user()?->canUseQuickLogin() ?? false,
        ]);
    }

    /**
     * Guards every <input type="number"> in the panel against the browser's
     * native "scroll wheel over a focused number input silently changes its
     * value" behavior. Listens on `wheel` in the CAPTURE phase (before the
     * browser applies the increment/decrement) and blurs the input instead of
     * calling preventDefault(), so the page keeps scrolling normally — only
     * the accidental value change is suppressed. A module-level guard flag
     * keeps the listener idempotent if this hook is ever rendered more than
     * once on the same page.
     */
    protected static function numberInputWheelGuardScript(): HtmlString
    {
        return new HtmlString(<<<'HTML'
            <script>
                (() => {
                    if (window.__vellixNumberInputWheelGuard) {
                        return;
                    }

                    window.__vellixNumberInputWheelGuard = true;

                    document.addEventListener(
                        'wheel',
                        (event) => {
                            const input = event.target;

                            if (
                                input instanceof HTMLInputElement &&
                                input.type === 'number' &&
                                document.activeElement === input
                            ) {
                                /*
                                 * Remove focus before the browser performs the
                                 * number-input wheel increment/decrement.
                                 *
                                 * The wheel event is NOT cancelled, so the page
                                 * continues scrolling normally.
                                 */
                                input.blur();
                            }
                        },
                        {
                            capture: true,
                            passive: true,
                        }
                    );
                })();
            </script>
        HTML);
    }
}