<?php

namespace App\Filament\Resources;

use App\Filament\Exports\GrnExporter;
use App\Filament\Resources\Concerns\AssertsBranchAccess;
use App\Filament\Resources\Concerns\HasModulePermissions;
use App\Filament\Resources\Concerns\HasStandardTableFeatures;
use App\Filament\Resources\GrnResource\Pages\CreateGrn;
use App\Filament\Resources\GrnResource\Pages\ListGrns;
use App\Filament\Resources\GrnResource\Pages\ViewGrn;
use App\Filament\Support\Chip;
use App\Filament\Support\Perm;
use App\Filament\Support\ProductSelect;
use App\Filament\Support\RelationSelect;
use App\Filament\Support\StatusLookup;
use App\Models\Catalog\MainCategory;
use App\Models\Catalog\Product;
use App\Models\Catalog\SubCategory;
use App\Models\Inventory\Grn;
use App\Models\Inventory\StockBatch;
use App\Models\Lookups\Status;
use App\Models\Purchasing\PurchaseOrderItem;
use App\Services\GrnService;
use App\Support\Quantity;
use Filament\Actions\Action;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
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\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Enums\FiltersLayout;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Validation\ValidationException;

/**
 * Ported from App\Http\Controllers\Isms\GrnController + GrnService
 * (docs/migration-plan.md Phase 3). Draft→confirm→(optionally deleted)
 * lifecycle, matching the source exactly:
 *  - create() only ever produces a DRAFT GRN with its line items — no stock
 *    effect yet.
 *  - confirm() (GrnService::confirm) is the only thing that posts stock
 *    movements, creates batches/serials, syncs sale price, debits the
 *    supplier ledger, and applies PO receipt — all already ported verbatim.
 *  - delete() is a STATUS change (grn_status_id → DELETED via saveQuietly()),
 *    not an Eloquent delete/soft-delete — Grn has no SoftDeletes trait. If
 *    the GRN was confirmed, deleting it reverses every stock movement and
 *    credits the supplier back. No EditRecord page exists, matching the
 *    source (no update route for GRNs at all).
 *
 * The source's "pick an existing product OR fill in new_product.* to create
 * one inline" per-line toggle is expressed here as a plain product picker —
 * no inline create option. New products are created exclusively through the
 * Product resource, keeping the create permission gate in one place and
 * avoiding per-GRN product creation under partial permission grants.
 */
class GrnResource extends Resource
{
    use AssertsBranchAccess;
    use HasModulePermissions;
    use HasStandardTableFeatures;

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

    protected static string $permissionModuleKey = 'isms_grn';

    protected static string $exporter = GrnExporter::class;

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

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

    protected static ?string $navigationLabel = 'GRN';

    protected static ?int $navigationSort = 0;

    public static function form(Schema $schema): Schema
    {
        return $schema->components([
            Section::make('Details')->schema([
                RelationSelect::make(
                    name: 'branch_id',
                    relationship: 'branch',
                    titleAttribute: 'name',
                    modifyQueryUsing: fn ($query) => $query->where('is_active', true),
                )->label('Branch')->required(),
                RelationSelect::make(
                    name: 'supplier_id',
                    relationship: 'supplier',
                    titleAttribute: 'name',
                    createPermission: 'isms_suppliers.create',
                )
                    ->label('Supplier')
                    // Only offer a supplier picker (and the PO flow below) when
                    // the tenant/user actually has the Purchasing modules —
                    // a tenant whose landlord removed Suppliers/POs sees a plain
                    // GRN with no supplier or purchase order fields at all.
                    ->visible(fn (): bool => Perm::can('isms_suppliers', 'access'))
                    // Re-query the PO options (and drop any stale selection)
                    // the moment the supplier changes.
                    ->live()
                    ->afterStateUpdated(fn (Set $set): mixed => $set('purchase_order_id', null)),
                RelationSelect::make(
                    name: 'purchase_order_id',
                    relationship: 'purchaseOrder',
                    titleAttribute: 'po_number',
                    // Only ACTIVE purchase orders of the picked supplier are
                    // receivable — a cancelled PO is dead and must never be
                    // offered. Draft/sent/partial (and even received, which
                    // simply pre-fills no outstanding lines) stay selectable.
                    modifyQueryUsing: fn ($query, Get $get) => $query
                        ->where('supplier_id', $get('supplier_id'))
                        ->whereHas('status', fn ($q) => $q->where('code', '!=', Status::PO_CANCELLED)),
                )
                    ->label('Purchase Order')
                    ->visible(fn (): bool => Perm::can('isms_purchase_orders', 'access'))
                    // A PO belongs to a supplier: the selector stays locked
                    // until one is chosen, so the option list can never be
                    // read against a null supplier.
                    ->disabled(fn (Get $get): bool => blank($get('supplier_id')))
                    ->live()
                    ->reactive()
                    // Selecting a PO drops its outstanding lines into the items
                    // repeater (product, unit, quantity, cost). Neither the rows
                    // nor their values are locked — rates, quantities and even
                    // the products themselves stay editable, so a GRN can always
                    // diverge from what was ordered. The selling price isn't
                    // part of a PO, so it is seeded from the product's most
                    // recent lot: an untouched line then re-confirms the price
                    // that is already in force rather than moving it.
                    ->afterStateUpdated(function (Set $set, Get $get, $state): void {
                        if (! $state) {
                            return;
                        }

                        $set('items', PurchaseOrderItem::where('purchase_order_id', (int) $state)
                            ->get()
                            ->filter(fn (PurchaseOrderItem $line): bool => $line->remainingQuantity() > 0)
                            ->map(function (PurchaseOrderItem $line) use ($get): array {
                                $pricing = self::latestLotPricing((int) $line->product_id, $get('branch_id'));

                                return [
                                    'product_id' => $line->product_id,
                                    'unit_id' => $line->unit_id,
                                    'quantity' => (float) $line->remainingQuantity(),
                                    'unit_cost' => $line->unit_cost !== null ? (float) $line->unit_cost : $pricing['unit_cost'],
                                    'sale_price' => $pricing['sale_price'],
                                ];
                            })
                            ->values()
                            ->all());
                    }),
                // Not stored on the GRN itself (not in Grn::$fillable) — a
                // convenience filter only, matching the source's create form:
                // when set, every line's product must belong to it (validated in
                // CreateGrn, see assertItemsMatchCategory()). standalone(), not
                // make(), because Grn has no mainCategory() relationship to
                // resolve against — this field is purely UI-side.
                RelationSelect::standalone(
                    name: 'main_category_id',
                    modelClass: MainCategory::class,
                    titleAttribute: 'name',
                    modifyQueryUsing: fn ($query) => $query->where('is_active', true),
                )
                    ->label('Main Category (filter)')
                    ->live(),
                // Second level of the same convenience filter — pick a sub
                // category first, then only its products are offered in the
                // items repeater below.
                RelationSelect::standalone(
                    name: 'sub_category_id',
                    modelClass: SubCategory::class,
                    titleAttribute: 'name',
                    modifyQueryUsing: fn ($query, Get $get) => $query
                        ->where('is_active', true)
                        ->when($get('main_category_id'), fn ($q, $id) => $q->where('main_category_id', (int) $id)),
                )
                    ->label('Sub Category (filter)')
                    ->live(),
                TextInput::make('reference')
                    ->maxLength(100)
                    ->helperText('Auto-generated when left blank.'),
                Textarea::make('note')->maxLength(500)->columnSpanFull(),
            ])->columns(2),

            Section::make('Items')->schema([
                Repeater::make('items')
                    ->schema([
                        ProductSelect::standalone(
                            modifyQueryUsing: fn ($query, Get $get) => $query
                                ->where('is_active', true)
                                ->when($get('../../main_category_id'), fn ($q, $id) => $q->where('main_category_id', (int) $id))
                                ->when($get('../../sub_category_id'), fn ($q, $id) => $q->where('sub_category_id', (int) $id)),
                        )
                            ->label('Product')
                            ->required()
                            ->columnSpanFull()
                            ->live()
                            // Seed cost and price from the product's most recent
                            // lot, so the operator edits a real number rather
                            // than guessing, and an untouched line re-confirms
                            // the price that is already in force instead of
                            // silently leaving it out. Blank when the product
                            // has never been received.
                            ->afterStateUpdated(function (Set $set, Get $get, $state): void {
                                if (! $state) {
                                    return;
                                }

                                $pricing = self::latestLotPricing((int) $state, $get('../../branch_id'));
                                $set('unit_cost', $pricing['unit_cost']);
                                $set('sale_price', $pricing['sale_price']);
                            }),
                        // No unit picker — the product's own base unit applies
                        // automatically (HasDefaultUnitFromProduct); a cake
                        // configured in kilograms can only ever be received in
                        // kilograms. The suffix just echoes that unit back so
                        // whoever is receiving goods can see what they're
                        // confirming.
                        TextInput::make('quantity')
                            ->numeric()->required()->minValue(0.01)
                            // Live so the serials repeater's min/max items can
                            // track it: a serial-tracked line must name exactly
                            // as many serials as units.
                            ->live()
                            ->suffix(fn (Get $get): ?string => Quantity::unitLabel(Product::find($get('product_id'))?->baseUnit)),
                        // On confirm these two become the lot's own numbers
                        // (GrnService::confirm) — cost becomes the lot's
                        // batch_cost, selling price becomes its sale_price.
                        // Leave one blank to keep that side as-is.
                        TextInput::make('unit_cost')
                            ->label('Purchase Price')
                            ->numeric()
                            ->minValue(0)
                            ->prefix('Rs.')
                            ->helperText('Copied to the lot on confirm.'),
                        TextInput::make('sale_price')
                            ->label('Selling Price')
                            ->numeric()
                            ->required()
                            ->minValue(0.01)
                            ->prefix('Rs.')
                            ->helperText('Copied to the lot on confirm. A lot with no selling price is unsellable at the POS.'),
                        // The expiry switch on the product decides whether this
                        // field exists at all: expiry-tracked products must
                        // strictly carry an expiry date (FEFO needs one to
                        // pick from), products without the flag have no expiry
                        // field here — their lots sell FIFO instead.
                        DatePicker::make('expiry_date')
                            ->visible(fn (Get $get): bool => (bool) (($p = Product::find($get('product_id'))) && $p->is_batch_tracked && $p->is_expiry_tracked))
                            ->required(fn (Get $get): bool => (bool) (($p = Product::find($get('product_id'))) && $p->is_batch_tracked && $p->is_expiry_tracked)),
                        DatePicker::make('manufacture_date')
                            ->visible(fn (Get $get): bool => (bool) Product::find($get('product_id'))?->is_batch_tracked),
                        TextInput::make('batch_cost')
                            ->numeric()->minValue(0)->prefix('Rs.')
                            ->visible(fn (Get $get): bool => (bool) Product::find($get('product_id'))?->is_batch_tracked),
                        // One row per serial number, only for products flagged
                        // serial-tracked: the line must name exactly as many
                        // serials as it has units (the same count GrnService
                        // enforces on confirm). Rows are plain values, not
                        // keyed items, so the service reads them as-is.
                        Repeater::make('serial_numbers')
                            ->label('Serial numbers')
                            ->simple(TextInput::make('serial')->required())
                            ->helperText('One serial number per unit received (serial-tracked products only).')
                            ->visible(fn (Get $get): bool => (bool) Product::find($get('product_id'))?->is_serial_tracked)
                            ->minItems(fn (Get $get): int => (int) $get('quantity'))
                            ->maxItems(fn (Get $get): int => (int) $get('quantity'))
                            ->columnSpanFull(),
                    ])
                    // 2, not 4: this repeater lives inside the "Items" Section,
                    // one of two side-by-side panels on the page (see "Details"
                    // right next to it, also ->columns(2)) — roughly half the
                    // viewport, not all of it. Filament's ->columns(N) picks the
                    // Tailwind lg: breakpoint (1024px viewport) for a bare int,
                    // so at 4 it was cramming 4 fields into ~500-600px of real
                    // width the moment the page was wide enough to show both
                    // panels side by side at all.
                    ->columns(2)
                    ->minItems(1)
                    ->addActionLabel('Add item')
                    ->columnSpanFull(),
            ]),
        ]);
    }

    public static function table(Table $table): Table
    {
        return static::standardTableDefaults($table)
            ->recordUrl(static::standardRecordUrl())
            ->emptyStateHeading('No GRNs received yet')
            ->emptyStateDescription('Goods received against purchase orders will appear here.')
            ->columns([
                Chip::documentNumber('grn_number', 'GRN #')->searchable()->sortable(),
                TextColumn::make('branch.name'),
                TextColumn::make('reference')->placeholder('—'),
                TextColumn::make('items_count')->label('Items')->counts('items'),
                TextColumn::make('grnStatus.name')
                    ->label('Status')
                    ->badge()
                    ->color(fn (Grn $record): string => StatusLookup::colorOfStatus($record->grnStatus)),
                TextColumn::make('created_at')->dateTime()->sortable(),
            ])
            ->defaultSort('created_at', 'desc')
            ->filters([
                Filter::make('grn_number')
                    ->schema([TextInput::make('value')->label('GRN #')])
                    ->query(fn (Builder $query, array $data): Builder => $query->when(
                        $data['value'] ?? null,
                        fn (Builder $q, $value) => $q->where('grn_number', 'like', "%{$value}%"),
                    )),
                SelectFilter::make('branch_id')->relationship('branch', 'name'),
                Filter::make('reference')
                    ->schema([TextInput::make('value')->label('Reference')])
                    ->query(fn (Builder $query, array $data): Builder => $query->when(
                        $data['value'] ?? null,
                        fn (Builder $q, $value) => $q->where('reference', 'like', "%{$value}%"),
                    )),
                SelectFilter::make('grn_status_id')->label('Status')->relationship('grnStatus', 'name'),
            ])
            ->filtersLayout(FiltersLayout::AboveContentCollapsible)
            ->filtersFormColumns(2)
            ->headerActions(static::standardExportActions())
            ->toolbarActions(static::standardExportBulkActions())
            ->recordActions(static::transitionActions());
    }

    /**
     * Confirm / delete transitions, shared by the table row actions and the
     * view page's header actions so both behave identically.
     *
     * @return array<int, Action>
     */
    public static function transitionActions(): array
    {
        return [
            Action::make('view')
                ->url(fn (Grn $record): string => ViewGrn::getUrl(['record' => $record]))
                ->visible(fn (Grn $record): bool => static::canView($record)),
            Action::make('confirm')
                ->visible(fn (Grn $record): bool => Perm::can('isms_grn', 'confirm') && $record->isDraft())
                ->requiresConfirmation()
                ->action(function (Grn $record): void {
                    try {
                        app(GrnService::class)->confirm($record, auth()->id());
                        Notification::make()->title('GRN confirmed. Stock and costs have been updated.')->success()->send();
                    } catch (\Throwable $e) {
                        Notification::make()->title('Failed to confirm GRN: '.$e->getMessage())->danger()->send();
                    }
                }),
            Action::make('delete')
                ->label('Delete')
                ->color('danger')
                ->visible(fn (Grn $record): bool => Perm::can('isms_grn', 'delete') && ! $record->isDeleted())
                ->requiresConfirmation()
                ->action(function (Grn $record): void {
                    try {
                        $wasConfirmed = $record->isConfirmed();
                        app(GrnService::class)->delete($record, auth()->id());
                        Notification::make()
                            ->title($wasConfirmed
                                ? "GRN {$record->grn_number} deleted. Stock quantities have been reversed."
                                : "Draft GRN {$record->grn_number} deleted.")
                            ->success()
                            ->send();
                    } catch (\Throwable $e) {
                        Notification::make()->title('Failed to delete GRN: '.$e->getMessage())->danger()->send();
                    }
                }),
        ];
    }

    public static function getPages(): array
    {
        return [
            'index' => ListGrns::route('/'),
            'create' => CreateGrn::route('/create'),
            'view' => ViewGrn::route('/{record}'),
        ];
    }

    /**
     * Ported from GrnController::store()'s main_category_id cross-check —
     * when a GRN is scoped to a main category, every line's product must
     * belong to it.
     *
     * @param  array<int, array{product_id?: int}>  $items
     */
    public static function assertItemsMatchCategory(?int $mainCategoryId, array $items): void
    {
        if (! $mainCategoryId) {
            return;
        }

        $existingIds = collect($items)->pluck('product_id')->filter()->map(fn ($id) => (int) $id)->unique();
        if ($existingIds->isEmpty()) {
            return;
        }

        $inCategoryCount = Product::whereIn('id', $existingIds)->where('main_category_id', $mainCategoryId)->count();

        if ($inCategoryCount !== $existingIds->count()) {
            throw ValidationException::withMessages([
                'items' => 'Every product must belong to the selected main category.',
            ]);
        }
    }

    /**
     * The price/cost a product most recently carried on its lots — seeds the
     * GRN line so an operator edits a real number. Scoped to the receiving
     * branch's ACTIVE lots, because a lot's price is branch-local (the POS
     * sells only what that branch holds) and a dead lot's price must not
     * re-emerge as the "in force" one. Blank when the product has never been
     * received here.
     *
     * @return array{unit_cost: float|null, sale_price: float|null}
     */
    private static function latestLotPricing(int $productId, ?int $branchId = null): array
    {
        $lot = StockBatch::query()
            ->where('product_id', $productId)
            ->when($branchId, fn ($q, $id) => $q->where('branch_id', $id))
            ->active()
            ->orderByDesc('created_at')
            ->first();

        return [
            'unit_cost' => $lot?->batch_cost !== null ? (float) $lot->batch_cost : null,
            'sale_price' => $lot?->sale_price !== null ? (float) $lot->sale_price : null,
        ];
    }
}
