<?php

namespace App\Filament\Resources;

use App\Filament\Exports\ProductExporter;
use App\Filament\Resources\Concerns\HasModulePermissions;
use App\Filament\Resources\Concerns\HasStandardTableFeatures;
use App\Filament\Resources\ProductResource\Pages\CreateProduct;
use App\Filament\Resources\ProductResource\Pages\EditProduct;
use App\Filament\Resources\ProductResource\Pages\ListProducts;
use App\Filament\Support\Chip;
use App\Filament\Support\RelationSelect;
use App\Models\BranchStock;
use App\Models\Catalog\MainCategory;
use App\Models\Catalog\Product;
use App\Models\Catalog\SubCategory;
use App\Models\Inventory\StockBatch;
use App\Services\CategorySkuService;
use App\Services\CurrentContext;
use App\Services\Storage\TenantStorage;
use App\Services\VatSettings;
use App\Support\Quantity;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Infolists\Components\TextEntry;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Components\View;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\ViewColumn;
use Filament\Tables\Enums\FiltersLayout;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use RuntimeException;

/**
 * Ported from App\Http\Controllers\Isms\ProductController
 * (docs/migration-plan.md Phase 2). Key preserved behaviors:
 *  - SKU generated server-side via CategorySkuService::next() (never
 *    user-editable), scoped to the category tree exactly as the source app.
 *  - sub_category_id is required only when the chosen main category actually
 *    has sub-categories, and must belong to that main category.
 *  - image_url goes through TenantStorage (quota-enforced, per-tenant
 *    storage source), not a static Laravel disk.
 *  - attributes are free-form key/value rows, synced wholesale on save
 *    (App\Support\ProductAttributeSync, same delete+reinsert as the source).
 *
 * Excludes the "Packaging & Add-ons" system category end to end (list,
 * create, edit, view, and the category picker here never offers it) — those
 * products are ordinary Product rows too, but are managed on their own,
 * simplified page. See {@see PackagingProductResource}.
 */
class ProductResource extends Resource
{
    use HasModulePermissions;
    use HasStandardTableFeatures;

    protected static ?string $model = Product::class;

    protected static string $permissionModuleKey = 'isms_products';

    protected static string $exporter = ProductExporter::class;

    protected static string|\BackedEnum|null $navigationIcon = Heroicon::OutlinedCube;

    protected static string|\UnitEnum|null $navigationGroup = 'Master Data';

    protected static ?string $navigationLabel = 'Products';

    protected static ?int $navigationSort = 0;

    public static function form(Schema $schema): Schema
    {
        return $schema->components([
            Section::make('Details')->schema([
                TextInput::make('name')->required()->maxLength(200)->columnSpanFull(),
                TextInput::make('barcode')
                    ->maxLength(100)
                    ->helperText('Leave blank to auto-generate from the SKU.'),
                RelationSelect::make(
                    name: 'main_category_id',
                    relationship: 'mainCategory',
                    titleAttribute: 'name',
                    createForm: [
                        TextInput::make('name')->required()->maxLength(100)->unique('main_categories', 'name'),
                        Textarea::make('description')->maxLength(500),
                    ],
                    createPermission: 'isms_main_categories.create',
                    // is_system: false — "Packaging & Add-ons" is managed on its
                    // own page (PackagingProductResource), never picked here.
                    modifyQueryUsing: fn ($query, $record) => $query
                        ->where('is_active', true)
                        ->where('is_system', false)
                        ->when($record?->main_category_id, fn ($q, $id) => $q->orWhere('id', $id)),
                )
                    ->label('Main Category')
                    ->required()
                    ->live()
                    ->afterStateUpdated(fn (Get $get, $set) => $set('sub_category_id', null)),
                RelationSelect::make(
                    name: 'sub_category_id',
                    relationship: 'subCategory',
                    titleAttribute: 'name',
                    createPermission: 'isms_sub_categories.create',
                    modifyQueryUsing: fn ($query, $record, Get $get) => $query
                        ->where('main_category_id', $get('main_category_id'))
                        ->where(fn ($q) => $q->where('is_active', true)->when($record?->sub_category_id, fn ($qq, $id) => $qq->orWhere('id', $id))),
                )
                    ->label('Sub Category')
                    ->required(fn (Get $get): bool => $get('main_category_id')
                        && MainCategory::whereKey($get('main_category_id'))->whereHas('subCategories')->exists())
                    ->disabled(fn (Get $get): bool => blank($get('main_category_id')))
                    ->live()
                    ->afterStateUpdated(function (Get $get, Set $set, $state) {
                        if ($state) {
                            $sub = SubCategory::find($state);
                            if ($sub && $sub->default_base_unit_id) {
                                $set('base_unit_id', $sub->default_base_unit_id);
                            }
                        }
                    })
                    ->rule(function (Get $get) {
                        return function (string $attribute, $value, \Closure $fail) use ($get): void {
                            if (blank($value)) {
                                return;
                            }
                            $sub = SubCategory::find($value);
                            if (! $sub || (int) $sub->main_category_id !== (int) $get('main_category_id')) {
                                $fail('The selected subcategory does not belong to the chosen main category.');
                            }
                        };
                    }),
                RelationSelect::make(
                    name: 'base_unit_id',
                    relationship: 'baseUnit',
                    titleAttribute: 'name',
                    modifyQueryUsing: fn ($query) => $query->where('is_active', true),
                )
                    ->label('Base Unit')
                    ->required(),
                Toggle::make('is_active')->default(true),
                Toggle::make('is_batch_tracked')
                    ->label('Batch-tracked')
                    ->default(true)
                    ->helperText('On: stock is tracked per batch/lot with individual batch numbers, manufacture/expiry dates, and batch-specific pricing. Off: stock is a single unified pool and the active price is overwritten on each GRN.')
                    ->live()
                    ->afterStateUpdated(function ($state, Set $set): void {
                        if (! $state) {
                            $set('is_expiry_tracked', false);
                        }
                    }),
                Toggle::make('is_serial_tracked')
                    ->label('Serial-tracked')
                    ->default(false)
                    ->helperText('On: every unit received on a GRN must carry its own serial number before the receipt can be confirmed.'),
                Toggle::make('is_expiry_tracked')
                    ->label('Expiry-tracked')
                    ->default(false)
                    ->helperText('On: every GRN line must carry an expiry date, and barcode-driven sales take stock FEFO (soonest expiry first). Off: no expiry field on GRNs, stock sells FIFO (oldest receipt first).')
                    ->visible(fn (Get $get): bool => (bool) $get('is_batch_tracked'))
                    ->afterStateHydrated(function (Get $get, Set $set, $state): void {
                        if (! $get('is_batch_tracked') && $state) {
                            $set('is_expiry_tracked', false);
                        }
                    })
                    ->afterStateUpdated(function ($state, Set $set, Get $get): void {
                        if (! $get('is_batch_tracked') && $state) {
                            $set('is_expiry_tracked', false);
                        }
                    }),
                TextEntry::make('active_stock')
                    ->label('Active Stock')
                    ->state(function (?Product $record): string {
                        if (! $record) {
                            return '—';
                        }

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

                        $quantity = (float) BranchStock::query()
                            ->where('product_id', $record->id)
                            ->sum('quantity');

                        $where = $branchId
                            ? ($context->branches()->firstWhere('id', $branchId)?->name ?? "Branch #{$branchId}")
                            : 'All accessible branches';

                        return Quantity::format($quantity, $record->baseUnit).' — '.$where;
                    })
                    ->visible(fn (?Product $record): bool => $record !== null)
                    ->columnSpanFull(),
            ])->columns(2),

            // Price is a property of the receipt lot, not the catalog entry:
            // it is set per consignment on the GRN (stock_batches.sale_price)
            // and never copied onto the product. The lot price and remaining
            // quantity are shown read-only here; a product's price is the
            // price of the lots that still have sellable stock.
            Section::make('Pricing & lots')->schema([
                TextEntry::make('active_lots')
                    ->label('Active lots')
                    ->html()
                    ->state(function (?Product $record): string {
                        if (! $record) {
                            return '—';
                        }

                        $lots = $record->lots()
                            ->active()
                            ->with('grn:id,grn_number')
                            ->orderByRaw('CASE WHEN expiry_date IS NULL THEN 1 ELSE 0 END')
                            ->orderBy('expiry_date')
                            ->orderBy('created_at')
                            ->get();

                        if ($lots->isEmpty()) {
                            return '<p class="text-sm text-gray-500 dark:text-gray-400">No sellable lots yet — receive stock through a GRN to give this product a price.</p>';
                        }

                        $rows = $lots->map(function (StockBatch $lot) use ($record): string {
                            $price = (float) $lot->sale_price;
                            $cost = $lot->batch_cost !== null ? (float) $lot->batch_cost : null;
                            $expiry = $lot->expiry_date?->toDateString() ?? '—';
                            $qty = Quantity::format((float) $lot->available_quantity, $record->baseUnit);
                            $grn = $lot->grn?->grn_number ?? 'Opening balance';

                            return '<tr>'
                                .'<td class="px-2 py-1">'.e($grn).'</td>'
                                .'<td class="px-2 py-1">'.e($lot->internal_batch_number).'</td>'
                                .'<td class="px-2 py-1 text-right tabular-nums">'.number_format($price, 2).'</td>'
                                .'<td class="px-2 py-1 text-right tabular-nums">'.($cost !== null ? number_format($cost, 2) : '—').'</td>'
                                .'<td class="px-2 py-1">'.e($expiry).'</td>'
                                .'<td class="px-2 py-1 text-right tabular-nums">'.e($qty).'</td>'
                                .'</tr>';
                        })->implode('');

                        return '<table class="w-full text-sm">'
                            .'<thead><tr class="text-left text-gray-500 dark:text-gray-400">'
                            .'<th class="px-2 py-1">GRN</th><th class="px-2 py-1">Batch</th>'
                            .'<th class="px-2 py-1 text-right">Price</th><th class="px-2 py-1 text-right">Cost</th>'
                            .'<th class="px-2 py-1">Expiry</th><th class="px-2 py-1 text-right">Qty</th>'
                            .'</tr></thead><tbody>'.$rows.'</tbody></table>';
                    })
                    ->visible(fn (?Product $record): bool => $record !== null)
                    ->columnSpanFull(),
                // Only meaningful — and only shown — once the landlord has
                // switched VAT on for this tenant (Master Control → Invoice).
                // Hidden entirely otherwise, and forced back to 0 on save even
                // if a crafted request submits one (see CreateProduct/EditProduct).
                TextInput::make('vat_rate')
                    ->label('VAT Rate')
                    ->numeric()
                    ->minValue(0)
                    ->maxValue(100)
                    ->default(0)
                    ->suffix('%')
                    ->visible(fn (): bool => app(VatSettings::class)->enabled())
                    ->helperText('Applied on the discounted line total at checkout and on invoices.'),
            ])->columns(2),

            // The stored file lives on the platform R2 bucket — a DIFFERENT
            // ORIGIN from the app — and that one fact decides the shape of this
            // whole section.
            //
            // A FileUpload bound straight to `image_url` has to hydrate itself
            // from that stored path, and Filament hands FilePond no file
            // metadata for a previewable image (see getFiles() in
            // forms/resources/js/components/file-upload.js): it passes only the
            // URL as a `type: 'local'` source, which makes FilePond FETCH the
            // file to build it. The browser will not do a cross-origin read of
            // the bucket, so the item never finished loading — a spinner that
            // spun forever, on an item that therefore never became removable or
            // replaceable.
            //
            // So the current image is shown as a plain <img> (images are not
            // subject to CORS, which is why the POS grid and every listing
            // render it fine) and the upload control below is a pure input that
            // always starts empty. That is the same shape the tenant logo
            // fields already use in Master Control, and it also means the field
            // no longer has any way to express "the image was dropped" by
            // accident — removal is now its own explicit toggle.
            Section::make('Image & description')->schema([
                View::make('filament.products.image-preview')
                    ->viewData(fn (?Model $record, Get $get): array => [
                        'url' => Product::resolveStoredImage($record?->image_url),
                        'removing' => (bool) $get('remove_image'),
                    ])
                    ->visible(fn (?Model $record): bool => filled($record?->image_url))
                    ->columnSpanFull(),

                FileUpload::make('image_upload')
                    ->label(fn (?Model $record): string => filled($record?->image_url) ? 'Replace image' : 'Image')
                    ->image()
                    ->maxSize(10240)
                    ->acceptedFileTypes(['image/jpeg', 'image/png', 'image/gif', 'image/webp'])
                    // Left as a TemporaryUploadedFile in form state: the file
                    // has to go through TenantStorage (the bucket is not one of
                    // Filament's configured disks), which the page does on save
                    // via ProductResource::applyImageInput().
                    ->storeFiles(false)
                    ->helperText('JPEG, PNG, GIF or WEBP, up to 10MB.')
                    ->columnSpanFull(),

                Toggle::make('remove_image')
                    ->label('Remove the current image')
                    ->live()
                    ->visible(fn (?Model $record): bool => filled($record?->image_url))
                    ->helperText('The product will show the no-image placeholder until a new one is uploaded.')
                    ->columnSpanFull(),

                Textarea::make('description')->columnSpanFull(),
            ]),

            Section::make('Attributes')->schema([
                // Plain array-backed field, NOT ->relationship() — the source
                // app's syncAttributes() does an explicit delete+reinsert on
                // every save (not a per-row diff), replicated identically in
                // CreateProduct/EditProduct via App\Support\ProductAttributeSync,
                // rather than relying on Filament's own relationship-repeater
                // diffing (a different set of DB operations for the same
                // end-state — the source behavior is reproduced exactly instead).
                Repeater::make('attributes')
                    ->schema([
                        TextInput::make('key')->required()->maxLength(100),
                        TextInput::make('value')->maxLength(255),
                    ])
                    ->columns(2)
                    ->defaultItems(0)
                    ->addActionLabel('Add attribute')
                    ->columnSpanFull(),
            ]),
        ]);
    }

    public static function table(Table $table): Table
    {
        return static::standardTableDefaults($table)
            ->recordUrl(null)
            ->emptyStateHeading('No products yet')
            ->emptyStateDescription('Products added at the till or in the catalog will appear here.')

            ->columns([
                ViewColumn::make('product_card')
                    ->label('')
                    ->view('filament.tables.products.product-card')
                    ->searchable([
                        'name',
                        'sku',
                        'barcode',
                    ])
                    ->extraAttributes([
                        'class' => 'p-0 align-top',
                    ]),
            ])

            ->filters([
                Filter::make('sku')
                    ->schema([
                        TextInput::make('value')
                            ->label('SKU'),
                    ])
                    ->query(fn (Builder $query, array $data): Builder => $query->when(
                        $data['value'] ?? null,
                        fn (Builder $q, $value) => $q->where(
                            'sku',
                            'like',
                            "%{$value}%"
                        ),
                    )),

                Filter::make('name')
                    ->schema([
                        TextInput::make('value')
                            ->label('Name'),
                    ])
                    ->query(fn (Builder $query, array $data): Builder => $query->when(
                        $data['value'] ?? null,
                        fn (Builder $q, $value) => $q->where(
                            'name',
                            'like',
                            "%{$value}%"
                        ),
                    )),

                SelectFilter::make('main_category_id')
                    ->label('Main Category')
                    ->relationship(
                        'mainCategory',
                        'name',
                        fn ($query) => $query->where('is_system', false)
                    ),

                TernaryFilter::make('is_active')
                    ->label('Active'),
            ])

            ->filtersLayout(FiltersLayout::Dropdown)
            ->filtersFormColumns(2)

            // Keep the existing branch-aware stock calculation.
            ->modifyQueryUsing(fn (Builder $query) => $query
                ->withStockFor(null)
                ->with([
                    'baseUnit',
                    'mainCategory',
                    'subCategory',
                ])
            )

            ->headerActions(static::standardExportActions())
            ->toolbarActions([])

            ->defaultSort('is_active', 'desc');
    }

    public static function getPages(): array
    {
        return [
            'index' => ListProducts::route('/'),
            'create' => CreateProduct::route('/create'),
            'edit' => EditProduct::route('/{record}/edit'),
        ];
    }

    public static function getRelations(): array
    {
        return [
        ];
    }

    /**
     * Every page (list, create, edit, view) is built from this query, so
     * excluding packaging products here — not just from the table — also
     * 404s a direct link to a packaging product's edit page instead of
     * silently rendering it through the wrong (image/barcode-bearing) form.
     */
    public static function getEloquentQuery(): Builder
    {
        $packaging = MainCategory::packaging();

        return parent::getEloquentQuery()->when(
            $packaging,
            fn (Builder $query) => $query->where('main_category_id', '!=', $packaging->id),
        );
    }

    public static function generateSku(?SubCategory $sub, ?MainCategory $main): string
    {
        return app(CategorySkuService::class)->next($sub, $main);
    }

    // ─────────────────────────────────────────────────────────────────────
    // Product image
    //
    // The form's two image controls (`image_upload`, `remove_image`) are not
    // the `image_url` column, so exactly one place turns them into it — shared
    // by create and edit so the two can never disagree about what "no upload"
    // means.
    // ─────────────────────────────────────────────────────────────────────

    /**
     * Resolves the image controls into the `image_url` column.
     *
     * Absence is not emptiness: with nothing uploaded and nothing removed the
     * key is dropped from the payload entirely, so a save that never touched
     * the image cannot blank it. That failure — editing a product for an
     * unrelated reason and silently losing its photo — is the one this form has
     * hit before, and dropping the key makes it structurally impossible rather
     * than a behaviour some other setting has to keep switched on.
     *
     * @param  array<string, mixed>  $data
     * @return array<string, mixed>
     */
    public static function applyImageInput(array $data, ?Product $record = null): array
    {
        $upload = $data['image_upload'] ?? null;
        $remove = (bool) ($data['remove_image'] ?? false);

        unset($data['image_upload'], $data['remove_image']);

        // An upload outranks the remove toggle: someone who picked a new file
        // after ticking "remove" plainly wants the new file, not a blank.
        $path = static::storeUploadedImage($upload);

        if ($path !== null) {
            $data['image_url'] = $path;

            return $data;
        }

        if ($remove) {
            $data['image_url'] = null;

            return $data;
        }

        unset($data['image_url']);

        return $data;
    }

    /**
     * Moves a just-uploaded image onto the platform bucket and returns the path
     * to persist, or null when nothing was uploaded.
     *
     * A failed write must never block the product itself from being saved — the
     * real reason (quota, credentials, misconfigured storage) is surfaced as a
     * notification and only the file is dropped.
     */
    public static function storeUploadedImage(mixed $upload): ?string
    {
        if (is_array($upload)) {
            $upload = reset($upload);
        }

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

        try {
            $path = app(TenantStorage::class)->putFile('products', $upload);

            if (! is_string($path) || $path === '') {
                throw new RuntimeException('The storage layer did not return a file path.');
            }

            return $path;
        } catch (\Throwable $e) {
            report($e);

            Notification::make()
                ->title('Product image could not be saved')
                ->body('The product was still saved without the image. '.$e->getMessage())
                ->danger()
                ->send();

            return null;
        }
    }

    /**
     * Drops a file the product no longer references.
     *
     * Called only AFTER the row committing the new value has been written — an
     * image deleted before a save that then fails would be gone with the record
     * still pointing at it.
     */
    public static function deleteStoredImage(?string $path): void
    {
        if (blank($path)) {
            return;
        }

        try {
            app(TenantStorage::class)->delete($path);
        } catch (\Throwable $e) {
            // An orphaned file costs quota; a failed delete must not cost the
            // operator their save.
            report($e);
        }
    }
}