<?php

namespace App\Filament\Central\Resources\TenantResource\Pages;

use App\Exceptions\StorageQuotaExceededException;
use App\Filament\Central\Resources\TenantResource;
use App\Filament\Forms\Components\PresetPicker;
use App\Filament\Support\PermissionMatrix;
use App\Models\Central\Tenant;
use App\Models\Central\TenantSetting;
use App\Models\Reference\Currency;
use App\Models\Reference\Locale;
use App\Services\CurrentContext;
use App\Services\Storage\TenantStorage;
use App\Services\Tenancy\TenantCatalogResetService;
use App\Services\Tenancy\TestInstanceService;
use App\Support\Invoice\InvoiceBusiness;
use App\Support\Invoice\SampleInvoice;
use App\Support\Locale\TimezoneOptions;
use App\Support\Theme\TenantTheme;
use App\Support\Theme\ThemeFonts;
use App\Support\WhatsApp;
use Closure;
use Filament\Actions\Action;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
use Filament\Schemas\Components\Actions;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\View;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;

/**
 * Ported from TenantController::update(). The tenant's settings live in
 * `tenant_settings` as flat columns; the source validates them in the nested
 * {branding, invoice, locale, theme, pos} shape and calls
 * TenantSetting::flatten() before persisting — reproduced exactly here.
 *
 * The Theme tab + live preview (docs/migration-plan.md Decisions Log #63) was
 * originally dropped on the reasoning that "Filament's own theming replaces
 * it" — reversed by explicit product decision (see the Decisions Log entry
 * appended after #68): Filament's own theming was left on a static colour, so
 * no tenant ever actually saw the 30-preset engine. TenantPanelProvider now
 * applies it to the real app; this page is where a landlord picks it and sees
 * it before saving.
 */
class EditTenant extends EditRecord
{
    protected static string $resource = TenantResource::class;

    public function form(Schema $schema): Schema
    {
        return $schema
            ->components([
                Section::make('Tenant')
                    ->description(sprintf('The tenant\'s identity on the platform. %s changes are lower-cased on save.', TenantResource::slugLabel()))
                    ->schema([
                        TextInput::make('name')->label('Company Name')->required()->maxLength(255),
                        TextInput::make('slug')
                            ->label(TenantResource::slugLabel())
                            ->required()->maxLength(63)->alphaDash()
                            ->rules([
                                fn (?Tenant $record) => Rule::unique('tenants', 'slug')->ignore($record?->id),
                                Rule::notIn(config('tenancy.reserved_slugs', [])),
                            ])
                            ->suffix(TenantResource::slugSuffix())
                            ->dehydrateStateUsing(fn (?string $state): string => Str::lower((string) $state)),
                        // The login/panel logo sits next to the company name, not
                        // buried in a settings tab: it is the first thing a tenant
                        // (and their cashiers) sees, so it belongs where onboarding
                        // and profile updates naturally land.
                        View::make('filament.central.tenant.logo-preview')
                            ->viewData(fn (Get $get): array => ['url' => $this->resolveStored($get('settings.branding.logo_url'))])
                            ->columnSpanFull(),
                        FileUpload::make('branding_logo_upload')
                            ->label('Logo')
                            ->image()
                            ->maxSize(5120)
                            ->acceptedFileTypes(['image/jpeg', 'image/png', 'image/gif', 'image/webp'])
                            ->storeFiles(false)
                            ->helperText('JPEG, PNG, GIF or WEBP, up to 5MB. Replaces the current logo shown above. Shown on the tenant\'s login page, panel and POS terminal.')
                            ->columnSpanFull(),
                    ])->columns(2),

                Tabs::make('Settings')->tabs([
                    Tab::make('Invoice')
                        ->schema([
                            // Branding used to be its own tab, but "App name"
                            // was the only field in it that did anything (the
                            // login heading/subtitle/panel colour next to it
                            // never rendered anywhere and have been removed) —
                            // not enough to justify a tab of its own alongside
                            // the business identity below, which is the same
                            // kind of thing: what a tenant's install is called
                            // and what it looks like.
                            Section::make('App branding')
                                ->description('Shown in the app\'s sidebar, topbar and login page. Separate from the business identity below, which is what prints on an invoice — a shop might trade under one name in the app and invoice under its registered company name.')
                                ->schema([
                                    TextInput::make('settings.branding.app_name')->label('App name')->maxLength(255),
                                ]),

                            Section::make('Business identity')
                                ->description('The only part of an invoice a landlord controls. Everything else on the print — numbers, dates, parties, lines, tax breakdown, totals, tenders — comes from the sale itself and is fixed by what a valid tax invoice has to say.')
                                ->schema([
                                    TextInput::make('settings.invoice.business_name')
                                        ->label('Business name')
                                        ->maxLength(255)
                                        ->helperText('Heads the invoice. Use the registered trading name.'),

                                    TextInput::make('settings.invoice.business_description')
                                        ->label('Business description')
                                        ->maxLength(160)
                                        ->helperText('One short line under the name, e.g. "Automotive parts & workshop services".'),

                                    FileUpload::make('invoice_logo_upload')
                                        ->label('Business logo')
                                        ->image()
                                        ->maxSize(5120)
                                        ->acceptedFileTypes(['image/jpeg', 'image/png', 'image/gif', 'image/webp'])
                                        ->storeFiles(false)
                                        ->live()
                                        ->helperText('JPEG, PNG, GIF or WEBP up to 5MB. Prints monochrome on the 80mm roll, so a high-contrast mark reads best. Leave empty to fall back to the branding logo.')
                                        ->columnSpanFull(),

                                    Textarea::make('settings.invoice.business_address')
                                        ->label('Business address')
                                        ->rows(2)
                                        ->maxLength(500)
                                        ->helperText('The head-office address the invoice is issued from.')
                                        ->columnSpanFull(),

                                    TextInput::make('settings.invoice.business_phone')
                                        ->label('Business phone')
                                        ->maxLength(100),

                                    TextInput::make('settings.invoice.business_email')
                                        ->label('Business email')
                                        ->email()
                                        ->maxLength(255),

                                    TextInput::make('settings.invoice.business_website')
                                        ->label('Business website')
                                        ->maxLength(255),

                                    TextInput::make('settings.invoice.business_registration_no')
                                        ->label('Business registration no.')
                                        ->maxLength(100),

                                    Toggle::make('settings.invoice.vat_enabled')
                                        ->label('VAT registered')
                                        ->live()
                                        ->default(false)
                                        ->helperText('Off by default. Switching this off hides VAT everywhere for this tenant — no VAT rate field on products or invoices, no VAT charged at checkout, no VAT figure or "Tax Invoice" heading on any print. Switch it on only for a tenant that is actually VAT-registered.'),

                                    TextInput::make('settings.invoice.tax_registration_no')
                                        ->label('Tax registration no. (VAT/TIN)')
                                        ->maxLength(100)
                                        ->visible(fn (Get $get): bool => (bool) $get('settings.invoice.vat_enabled'))
                                        // A VAT-registered seller must head the
                                        // document "Tax Invoice"; leaving this
                                        // blank prints a plain "Invoice".
                                        ->helperText('Filling this in changes the document title to "Tax Invoice".'),
                                ])->columns(2),

                            View::make('filament.central.tenant.invoice-preview')
                                ->viewData(fn (Get $get): array => $this->invoicePreviewData($get))
                                ->columnSpanFull(),

                            $this->brandingAndInvoiceSaveActions(),
                        ])->columns(1),

                    Tab::make('Theme')->schema([
                        PresetPicker::make('settings.theme.preset')
                            ->label('Colour preset')
                            ->columnSpanFull()
                            ->helperText('Every card is painted from that preset\'s real tokens — sidebar, page surface, primary button and login panel — so what you pick here is exactly what the tenant\'s app and login page will look like.'),

                        Select::make('settings.theme.font_body')
                            ->label('Body font')
                            ->native(false)
                            ->options(function (): array {
                                $groups = [];
                                foreach (ThemeFonts::all() as $key => $font) {
                                    $groups[$font['category']][$key] = $font['label'];
                                }

                                return $groups;
                            })
                            ->extraAlpineAttributes(static::previewSelectAttributes('theme.font_body')),

                        Select::make('settings.theme.font_heading')
                            ->label('Heading font')
                            ->native(false)
                            ->helperText('Leave blank to match the body font.')
                            ->options(function (): array {
                                $groups = [];
                                foreach (ThemeFonts::all() as $key => $font) {
                                    $groups[$font['category']][$key] = $font['label'];
                                }

                                return $groups;
                            })
                            ->extraAlpineAttributes(static::previewSelectAttributes('theme.font_heading')),

                        Select::make('settings.theme.radius')
                            ->label('Corner radius')
                            ->native(false)
                            ->options(collect(ThemeFonts::RADII)->map(fn (array $r): string => $r['label'])->all())
                            ->extraAlpineAttributes(static::previewSelectAttributes('theme.radius')),

                        Select::make('settings.theme.scale')
                            ->label('Type scale')
                            ->native(false)
                            ->options(collect(ThemeFonts::SCALES)->map(fn (array $s): string => $s['label'])->all())
                            ->extraAlpineAttributes(static::previewSelectAttributes('theme.scale')),
                        $this->sectionSaveActions('theme'),

                        View::make('filament.central.tenant.theme-preview')
                            ->viewData(fn (Get $get): array => $this->themePreviewData($get))
                            ->columnSpanFull(),
                    ])->columns(2),

                    Tab::make('Locale')
                        ->schema([
                            Select::make('settings.locale.currency')
                                ->label('Currency')
                                ->native(false)
                                ->searchable()
                                ->options(fn (): array => Currency::selectOptions())
                                ->helperText('Drives the symbol on invoices and every money figure in the app — picked, never typed, so the two can never disagree.')
                                ->extraAlpineAttributes(static::previewSelectAttributes('locale.currency')),

                            Select::make('settings.locale.locale')
                                ->label('Locale')
                                ->native(false)
                                ->searchable()
                                ->options(fn (): array => Locale::selectOptions())
                                ->helperText('Number and date formatting throughout the tenant\'s app.'),

                            Select::make('settings.locale.timezone')
                                ->label('Timezone')
                                ->native(false)
                                ->searchable()
                                ->options(fn (): array => TimezoneOptions::grouped())
                                ->helperText('Drives the header clock and every date/time shown to this tenant.'),

                            $this->sectionSaveActions('locale'),
                        ])->columns(2),

                    Tab::make('POS')->schema([
                        Section::make('Master control')
                            ->description('Global switches for the promotional feature set. Switching a feature off hides every trace of it from the POS terminal and refuses it at the service layer — cashiers cannot see or apply vouchers or loyalty points while the switch is off.')
                            ->schema([
                                Toggle::make('settings.pos.loyalty_enabled')
                                    ->label('Loyalty programs')
                                    ->default(TenantSetting::POS_DEFAULTS['loyalty_enabled'])
                                    ->helperText('Points earning, redemption and balances across the app. Off hides all loyalty UI at the POS and stops the loyalty engine entirely.'),
                                Toggle::make('settings.pos.vouchers_enabled')
                                    ->label('Vouchers & promotions')
                                    ->default(TenantSetting::POS_DEFAULTS['vouchers_enabled'])
                                    ->helperText('Voucher codes and promotional discounts. Off removes the voucher field from the POS checkout and rejects every voucher code.'),
                            ])->columns(2),
                        Toggle::make('settings.pos.walkin_only')
                            ->label('Walk-in only')
                            ->helperText('No customers are registered; credit notes are disabled system-wide.'),
                        Toggle::make('settings.pos.allow_amend')->label('Allow sale amendment'),
                        TextInput::make('settings.pos.amend_window_minutes')
                            ->label('Amend window (minutes)')
                            ->numeric()->minValue(0)->maxValue(1440),
                        $this->sectionSaveActions('pos'),
                    ])->columns(2),

                    // Moved here from a "Sales Order Settings" page inside the
                    // tenant panel: these are set up with the rest of the
                    // tenant's configuration now, not left to the shop floor.
                    Tab::make('Sales Orders')->schema([
                        Section::make('Delivery deadlines')
                            ->description('How the sales order listing flags work that is coming due.')
                            ->schema([
                                TextInput::make('settings.sales_order.deadline_warning_days')
                                    ->label('Highlight deadlines within')
                                    ->helperText('An order whose delivery deadline falls inside this many days is shown in red. Overdue orders are always red. Set to 0 to turn the warning off.')
                                    ->numeric()
                                    ->integer()
                                    ->default(TenantSetting::SALES_ORDER_DEFAULTS['deadline_warning_days'])
                                    ->minValue(0)
                                    ->maxValue(365)
                                    ->suffix('days'),
                            ]),

                        Section::make('Invoicing')
                            ->description('When a sales order becomes a real invoice.')
                            ->schema([
                                Toggle::make('settings.sales_order.auto_invoice')
                                    ->label('Invoice orders as they are created')
                                    ->default(TenantSetting::SALES_ORDER_DEFAULTS['auto_invoice'])
                                    ->helperText('Every new sales order is converted into an invoice in the same write — stock is deducted and any advance is banked immediately. The order stays editable afterwards; its invoice is kept in step automatically.'),
                            ]),

                        Section::make('WhatsApp')
                            ->description('Where the "WhatsApp" button on a sales order sends the order to.')
                            ->schema([
                                TextInput::make('settings.sales_order.whatsapp_target')
                                    ->label('WhatsApp destination')
                                    ->placeholder('0771234567  or  https://chat.whatsapp.com/AbCdEf…')
                                    ->maxLength(255)
                                    // The two shapes behave differently and the
                                    // difference is WhatsApp's, not ours: only a
                                    // one-to-one chat link accepts a pre-filled
                                    // message. Say so here rather than let a
                                    // landlord discover it at the workshop group.
                                    ->helperText('A phone number (or wa.me link) opens a one-to-one chat with the whole order pre-filled, ready to send. A group invite link (chat.whatsapp.com/…) opens the group and copies the order to the clipboard to paste — WhatsApp does not allow pre-filling a group message. Leave blank to hide the button.')
                                    // Wrapped in a closure that RETURNS the rule:
                                    // handing ->rule() a bare Closure makes
                                    // Filament try to evaluate it as one of its
                                    // own injected closures rather than pass it
                                    // through as a Laravel closure rule.
                                    ->rule(fn (): Closure => static::whatsappTargetRule()),
                            ]),

                        $this->sectionSaveActions('sales_order'),
                    ])->columns(1),
                ])->columnSpanFull(),

                // The previews are repainted client-side by this script, so the
                // theme and invoice fields above need no ->live() round-trips.
                View::make('filament.central.tenant.partials.preview-bridge')
                    // The stored currency is the bridge's baseline until the
                    // Locale-tab select boots; without it an early repaint
                    // would reformat every amount with the default symbol.
                    ->viewData(fn (Get $get): array => ['currency' => $get('settings.locale.currency')])
                    ->columnSpanFull(),
            ]);
    }

    /**
     * Attributes for the theme/currency Selects that drive a live preview.
     *
     * Their state is entangled and deferred, so picking an option sends no
     * request on its own; the wrapper merely announces that the value may have
     * changed (mouse and keyboard picks both bubble to it) and the preview
     * bridge repaints both previews from the current state. The bind key also
     * lets the bridge locate the select and read its entangled value.
     *
     * @return array<string, string>
     */
    protected static function previewSelectAttributes(string $bind): array
    {
        return [
            'data-preview-bind' => $bind,
            'data-preview-value' => 'select.state',
            'x-on:click' => '$nextTick(() => window.dispatchEvent(new CustomEvent(\'preview:change\')))',
            'x-on:keyup' => '$nextTick(() => window.dispatchEvent(new CustomEvent(\'preview:change\')))',
        ];
    }

    /**
     * The preview draws the tenant's real chrome, so it needs the real
     * variables — tokens, both Filament ramps and typography — not just the
     * shadcn token set. {@see TenantTheme::previewVariables()}
     *
     * @return array<string, mixed>
     */
    protected function themePreviewData(Get $get): array
    {
        $theme = TenantTheme::fromSettings([
            'theme' => $get('settings.theme') ?? [],
            'branding' => $get('settings.branding') ?? [],
        ]);

        return [
            'vars' => $theme->previewVariables(),
            'appName' => $get('settings.branding.app_name') ?: $get('name'),
            // An unsaved upload previews through Livewire's temporary URL, so a
            // landlord sees their mark in the shell before committing to it.
            'logoUrl' => static::previewImageUrl($get('branding_logo_upload'))
                ?? $this->resolveStored($get('settings.branding.logo_url')),
            // Blank falls through to what Filament itself would print, so the
            // preview shows the real default rather than inventing copy.
            'loginHeading' => $get('settings.branding.login_heading')
                ?: __('filament-panels::auth/pages/login.heading'),
            'loginSubtitle' => $get('settings.branding.login_subtitle') ?: null,
        ];
    }

    /**
     * The invoice preview renders the real print layouts against a worst-case
     * sample sale, so a landlord approves the actual document rather than a
     * mock-up of one.
     *
     * @return array<string, mixed>
     */
    protected function invoicePreviewData(Get $get): array
    {
        $invoice = (array) ($get('settings.invoice') ?? []);

        // A logo the landlord has just picked but not yet saved still previews,
        // via Livewire's temporary URL — otherwise they'd have to save to find
        // out whether their mark survives a 22mm box.
        $invoice['business_logo'] = static::previewImageUrl($get('invoice_logo_upload'))
            ?? $this->resolveStored($invoice['business_logo'] ?? null);

        $branding = (array) ($get('settings.branding') ?? []);
        $branding['logo_url'] = $this->resolveStored($branding['logo_url'] ?? null);

        $business = InvoiceBusiness::fromSettings([
            'invoice' => $invoice,
            'branding' => $branding,
        ]);

        // Live off the currency Select on the Locale tab (not a stored
        // `currency_symbol`, which is now derived server-side on save, never
        // typed) — so picking a currency there is reflected here immediately,
        // even before saving.
        $symbol = static::currencySymbolFor($get('settings.locale.currency'));

        return [
            'doc' => SampleInvoice::build($business, $symbol ?? 'Rs.'),
        ];
    }

    /**
     * A saved logo is a storage path on the tenant's source, so it has to be
     * resolved before a preview can show it. The central domain binds no tenant
     * to the request, hence the explicit record.
     */
    protected function resolveStored(mixed $value): ?string
    {
        return app(TenantStorage::class)->resolveUrl(
            is_string($value) ? $value : null,
            $this->record instanceof Tenant ? $this->record : null,
        );
    }

    /**
     * A pending FileUpload holds a TemporaryUploadedFile (the component is set
     * to `storeFiles(false)`, so nothing is written until the section is saved).
     * Livewire can serve it a preview URL; anything unexpected degrades to null
     * rather than breaking the whole preview pane.
     */
    protected static function previewImageUrl(mixed $state): ?string
    {
        if (is_array($state)) {
            $state = reset($state);
        }

        if (! $state instanceof TemporaryUploadedFile) {
            return null;
        }

        try {
            return $state->temporaryUrl();
        } catch (\Throwable) {
            return null;
        }
    }

    protected function getHeaderActions(): array
    {
        return [
            Action::make('backToTenants')
                ->label('Back to Tenants')
                ->icon(Heroicon::OutlinedArrowLeft)
                ->color('gray')
                ->url(TenantResource::getUrl('index')),

            Action::make('impersonate')
                ->label('Impersonate')
                ->icon(Heroicon::OutlinedUserCircle)
                ->requiresConfirmation()
                ->modalDescription('Opens a support session as this tenant\'s administrator. The hand-off is written to their audit trail.')
                ->action(fn (Tenant $record) => TenantResource::startImpersonation($record)),

            Action::make('entitlements')
                ->label('Entitlements')
                ->icon(Heroicon::OutlinedKey)
                ->modalWidth('5xl')
                ->fillForm(fn (): array => ['permissions' => $this->record->entitledPermissionNames()])
                ->schema([
                    PermissionMatrix::checkboxList('permissions')
                        ->label('Permissions this tenant may use')
                        ->helperText('The outer ceiling: a tenant\'s own roles can never grant beyond this set.'),
                ])
                ->action(fn (array $data) => TenantResource::updateEntitlements($this->record, $data['permissions'] ?? [])),

            Action::make('resetAdminPassword')
                ->label('Reset admin password')
                ->color('warning')
                ->requiresConfirmation()
                ->modalDescription('Resets the tenant administrator to the default password and forces a change at next login.')
                ->action(fn () => TenantResource::resetAdminPassword($this->record)),

            Action::make('resetCatalog')
                ->label('Reset product catalog')
                ->icon(Heroicon::OutlinedTrash)
                ->color('danger')
                ->requiresConfirmation()
                ->modalHeading('Reset product catalog?')
                ->modalIcon(Heroicon::OutlinedExclamationTriangle)
                ->modalIconColor('danger')
                ->modalDescription('Permanently deletes every product, category, sub-category and bundle for this tenant, along with their stock batches, serials and purchase-order lines. "Packaging & Add-ons" is kept, but emptied — every packaging product goes too. Roles, permissions, users, branches and tills are never touched, and neither is any invoice, sale, held sale, customer, loyalty or purchasing record. This cannot be undone.')
                ->modalContent(fn () => view('filament.central.tenant.partials.reset-catalog-countdown'))
                ->modalSubmitActionLabel('Confirm reset')
                ->modalSubmitAction(fn (Action $action) => $action->extraAttributes([
                    'x-data' => '{ seconds: 3 }',
                    'x-init' => 'let t = setInterval(() => { if (seconds > 0) seconds--; if (seconds <= 0) clearInterval(t); }, 1000)',
                    'x-bind:disabled' => 'seconds > 0',
                    'x-text' => "seconds > 0 ? `Confirm reset (\${seconds})` : 'Confirm reset'",
                ]))
                ->action(function (): void {
                    /** @var Tenant $tenant */
                    $tenant = $this->record;

                    $result = app(TenantCatalogResetService::class)->reset($tenant);

                    TenantResource::audit($tenant, 'tenant.catalog_reset', $result);

                    Notification::make()
                        ->title('Product catalog reset.')
                        ->body(sprintf(
                            'Removed %d product(s), %d categor(y/ies), %d bundle(s), %d stock batch(es), %d serial(s). "Packaging & Add-ons" was kept, emptied.',
                            $result['products'],
                            $result['categories'],
                            $result['bundles'],
                            $result['stock_batches'],
                            $result['product_serials'],
                        ))
                        ->success()
                        ->persistent()
                        ->send();
                }),

            Action::make('openTestInstance')
                ->label('Open Test Instance')
                ->icon(Heroicon::OutlinedBeaker)
                ->visible(fn (Tenant $tenant): bool => $tenant->testInstance !== null)
                ->url(fn (Tenant $tenant): string => TenantResource::tenantUrl($tenant->testInstance))
                ->openUrlInNewTab(),

            Action::make('createTestInstance')
                ->label('Create Test Instance')
                ->icon(Heroicon::OutlinedBeaker)
                ->color('warning')
                ->visible(fn (Tenant $tenant): bool => $tenant->testInstance === null)
                ->requiresConfirmation()
                ->modalHeading('Create a test instance?')
                ->modalIcon(Heroicon::OutlinedBeaker)
                ->modalIconColor('warning')
                ->modalDescription('An isolated copy of this tenant will be provisioned, seeded with the current live data. Changes inside it never affect the live tenant.')
                ->modalSubmitActionLabel('Create test instance')
                ->action(function (Tenant $tenant): void {
                    try {
                        $test = app(TestInstanceService::class)->create(
                            $tenant,
                            Auth::guard('central')->id(),
                        );
                    } catch (\Throwable $e) {
                        Notification::make()
                            ->title('Could not create test instance')
                            ->body($e->getMessage())
                            ->danger()
                            ->persistent()
                            ->send();

                        return;
                    }

                    TenantResource::audit($tenant, 'tenant.test_instance_provisioned', [
                        'test_tenant_id' => $test->id,
                        'test_slug' => $test->slug,
                    ]);

                    Notification::make()
                        ->title('Test instance created')
                        ->body(sprintf('Isolated copy provisioned at "%s".', $test->url()))
                        ->success()
                        ->persistent()
                        ->send();
                }),
        ];
    }

    /**
     * @param  array<string, mixed>  $data
     * @return array<string, mixed>
     */
    protected function mutateFormDataBeforeFill(array $data): array
    {
        /** @var Tenant $tenant */
        $tenant = $this->record;

        $data['settings'] = $tenant->settingsRow?->toNestedArray() ?? [];

        return $data;
    }

    /**
     * The per-tab "Save this section" button. Each settings tab persists ONLY
     * its own group of columns (so a landlord can, say, switch the theme preset
     * and apply it without touching Invoice/Locale/etc. values still in
     * draft), then flushes the same settings cache key the panel reads.
     */
    protected function sectionSaveActions(string $section): Actions
    {
        $label = [
            'invoice' => 'Save invoice',
            'theme' => 'Save theme',
            'locale' => 'Save locale',
            'pos' => 'Save POS',
            'sales_order' => 'Save sales order settings',
        ][$section] ?? 'Save';

        return Actions::make([
            Action::make("save_{$section}_section")
                ->label($label)
                ->color('primary')
                ->action(fn () => $this->saveSection($section)),
        ]);
    }

    /**
     * The Invoice tab's single save button, covering both sections shown on
     * it — app branding and business identity — since they are edited
     * together on one tab now (see the docblock on that Section above).
     */
    protected function brandingAndInvoiceSaveActions(): Actions
    {
        return Actions::make([
            Action::make('save_branding_and_invoice_sections')
                ->label('Save')
                ->color('primary')
                ->action(function (): void {
                    $this->persistSection('branding');
                    $this->persistSection('invoice');

                    Notification::make()->title('Branding & invoice settings saved.')->success()->send();
                }),
        ]);
    }

    /**
     * Persist one settings section (from the form's current raw state) into the
     * tenant's settings row, leaving the other sections untouched. Branding and
     * Invoice each carry a pending logo upload, flushed through the same
     * quota-checked TenantStorage pipeline as the main save.
     */
    public function saveSection(string $section): void
    {
        $this->persistSection($section);

        $name = ucfirst(str_replace('_', ' ', $section));

        Notification::make()->title($name.' settings saved.')->success()->send();
    }

    /**
     * A WhatsApp destination must be something {@see WhatsApp::linkForTarget()}
     * can actually turn into a URL, or the button it drives would open a broken
     * tab. Blank is fine — that is how the button is switched off.
     */
    protected static function whatsappTargetRule(): Closure
    {
        return static function (string $attribute, mixed $value, Closure $fail): void {
            if (! is_string($value) || trim($value) === '') {
                return;
            }

            if (WhatsApp::linkForTarget($value, 'test') === null) {
                $fail('Enter a phone number, a wa.me link, or a WhatsApp group invite link (https://chat.whatsapp.com/…).');
            }
        };
    }

    /**
     * The persistence {@see saveSection()} wraps with its own notification —
     * split out so {@see brandingAndInvoiceSaveActions()} can persist both of
     * its sections under a single combined notification instead of stacking
     * two.
     */
    protected function persistSection(string $section): void
    {
        /** @var Tenant $tenant */
        $tenant = $this->record;

        $raw = $this->form->getRawState();

        $sectionData = data_get($raw, "settings.{$section}", []);

        // [form field => settings key within the section]
        $logoField = [
            'branding' => ['branding_logo_upload', 'logo_url'],
            'invoice' => ['invoice_logo_upload', 'business_logo'],
        ][$section] ?? null;

        if ($logoField !== null) {
            $url = $this->storeLogoUpload($tenant, $raw[$logoField[0]] ?? null);

            if ($url !== null) {
                $sectionData[$logoField[1]] = $url;
            }
        }

        if ($section === 'locale') {
            $sectionData['currency_symbol'] = static::currencySymbolFor($sectionData['currency'] ?? null);
        }

        $this->persistSectionSettings($tenant, $section, $sectionData);
    }

    /**
     * The symbol is derived from the picked currency, never typed — this is
     * the ONE place that turns a `locale.currency` code into the
     * `locale.currency_symbol` every print/money-column read actually uses,
     * so the two can never drift apart the way a free-typed symbol field
     * used to let them.
     */
    protected static function currencySymbolFor(mixed $code): ?string
    {
        if (! is_string($code) || trim($code) === '') {
            return null;
        }

        return Currency::findByCode($code)?->symbol;
    }

    /**
     * Ported from TenantController::storeLogo(): the upload goes through the
     * same TenantStorage pipeline (quota-checked) as every other tenant file,
     * just triggered from the settings form instead of a separate endpoint.
     * CurrentContext has no tenant bound on the central domain, so it is set
     * explicitly for the duration of the write.
     *
     * Returns the storage PATH, not a resolved URL — the same thing
     * ProductResource persists for a product image, and for the same reason: a
     * path still points at the right file after the tenant is moved to another
     * storage source, an absolute bucket URL does not.
     * {@see TenantStorage::resolveUrl()} turns it back into a URL on read, and
     * still passes through the absolute URLs older rows hold.
     */
    protected function storeLogoUpload(Tenant $tenant, mixed $upload): ?string
    {
        if (is_array($upload)) {
            $upload = reset($upload);
        }

        if (! $upload instanceof UploadedFile) {
            return null;
        }

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

        try {
            return app(TenantStorage::class)->putFile('logo', $upload);
        } catch (StorageQuotaExceededException $e) {
            Notification::make()->title($e->getMessage())->danger()->send();

            return null;
        }
    }

    /**
     * @param  array<string, mixed>  $sectionData
     */
    protected function persistSectionSettings(Tenant $tenant, string $section, array $sectionData): void
    {
        $nested = $tenant->settingsRow?->toNestedArray() ?? [];
        $nested[$section] = $sectionData;

        $tenant->settingsRow()->updateOrCreate([], TenantSetting::flatten($nested));

        $tenant->flushEntitlementCache();

        TenantResource::audit($tenant, 'tenant.settings_updated', [
            'fields' => array_keys($sectionData),
        ]);
    }

    protected function handleRecordUpdate(Model $record, array $data): Model
    {
        /** @var Tenant $tenant */
        $tenant = $record;

        $tenant->update([
            'name' => $data['name'],
            'slug' => Str::lower($data['slug']),
        ]);

        foreach ([
            'branding_logo_upload' => ['branding', 'logo_url'],
            'invoice_logo_upload' => ['invoice', 'business_logo'],
        ] as $field => [$section, $key]) {
            $upload = $data[$field] ?? null;
            unset($data[$field]);

            $url = $this->storeLogoUpload($tenant, $upload);

            if ($url !== null) {
                $data['settings'][$section][$key] = $url;
            }
        }

        if (isset($data['settings'])) {
            if (isset($data['settings']['locale'])) {
                // Same rule as persistSection(): the symbol always follows the
                // picked currency, never a free-typed value.
                $data['settings']['locale']['currency_symbol'] = static::currencySymbolFor(
                    $data['settings']['locale']['currency'] ?? null,
                );
            }

            // tenant_settings is flat columns; flatten() maps the nested shape
            // onto them, exactly as the source controller does.
            $tenant->settingsRow()->updateOrCreate([], TenantSetting::flatten($data['settings']));
        }

        $tenant->flushEntitlementCache();

        TenantResource::audit($tenant, 'tenant.settings_updated', [
            'fields' => array_keys($data['settings'] ?? []),
        ]);

        return $tenant->refresh();
    }

    protected function getRedirectUrl(): string
    {
        return TenantResource::getUrl('index');
    }
}
