<?php

namespace App\Filament\Resources\SalesOrderResource\Pages;

use App\Filament\Pages\Pos;
use App\Filament\Resources\Concerns\InteractsWithSalesOrderCatalog;
use App\Filament\Resources\SalesOrderResource;
use App\Filament\Support\RelationSelect;
use App\Filament\Support\StatusLookup;
use App\Mail\SalesOrderCreated;
use App\Models\Customers\Customer;
use App\Models\Geo\City;
use App\Models\Inventory\BarcodeLabel;
use App\Models\Lookups\PaymentMethod;
use App\Models\Lookups\Status;
use App\Models\People\SalesRep;
use App\Models\Returns\CreditNote;
use App\Models\Sales\SalesOrder;
use App\Services\AppSettings;
use App\Services\CurrentContext;
use App\Services\PosSettings;
use App\Services\SalesOrderCatalogService;
use App\Services\SalesOrderService;
use App\Services\VatSettings;
use Filament\Actions\Action;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\Page;
use Filament\Support\Enums\Width;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Renderless;
use RuntimeException;
use Throwable;

/**
 * Taking a sales order on the full-screen terminal.
 *
 * A custom resource page — deliberately NOT a CreateRecord. The order desk is
 * the same shape of screen as the POS terminal
 * ({@see Pos}): catalog on the left, the order on the
 * right, and every cart mutation in Alpine on the client. Nothing about
 * picking products or nudging a quantity may round-trip to Livewire; the
 * server is consulted only at the one real boundary — createOrder().
 *
 * Sales order requirements, carved out of the POS terminal's feature set:
 *
 *  - No till, open-session, held sales, cash movements or amend — an order is
 *    a promise, and the money/stock story all happens at conversion.
 *  - A customer is mandatory (no walk-in mode); branch is mandatory.
 *    Advances are optional and never settle anything — they are recorded
 *    against the order and banked by the "Invoice" action.
 *  - Catalog lines quote from their lot; out-of-stock products still quote,
 *    via the virtual quotation lot (SalesOrderCatalogService).
 *  - Made-to-order (custom) lines carry no product at all — described in
 *    words, priced as quoted, never touching stock.
 *  - Order-level and line-level discounts are gated by the
 *    `isms_sales_orders.discount` permission — enforced here by
 *    SalesOrderService::assertDiscountAllowed() on the exact same payload a
 *    full save submits.
 *  - Credit notes can be taken as an advance payment (method CREDIT), but
 *    only the selected customer's own issued notes — the same rule the
 *    order-desk form's credit-note select applies.
 *
 * Everything the client submits crosses the same
 * SalesOrderService::create() boundary the form used, with the same header /
 * lines / payments split; the service remains the one source of truth.
 */
class CreateSalesOrder extends Page
{
    use InteractsWithSalesOrderCatalog;

    protected static string $resource = SalesOrderResource::class;

    /**
     * Session key holding the branch this order desk is quoting from — the
     * same "device remembers its branch" pattern as Pos::BRANCH_SESSION_KEY.
     */
    public const BRANCH_SESSION_KEY = 'sales_order_branch_id';

    /** The terminal is a full-width two-panel screen, not an article column. */
    protected Width|string|null $maxContentWidth = Width::Full;

    protected string $view = 'filament.pages.create-sales-order';

    public function mount(): void
    {
        // The create gate, exactly as CreateRecord applied it — a user without
        // `isms_sales_orders.create` gets a 403, not the screen.
        abort_unless(SalesOrderResource::canCreate(), 403);

        // Resolve and persist the branch this device will quote from, so a
        // reload or a new visit does not re-prompt. See resolveBranchId().
        $branchId = $this->resolveBranchId();

        if ($branchId !== null && session()->get(self::BRANCH_SESSION_KEY) !== $branchId) {
            session([self::BRANCH_SESSION_KEY => $branchId]);
        }
    }

    // ─────────────────────────────────────────────────────────────────────
    // Branch resolution
    // ─────────────────────────────────────────────────────────────────────

    /**
     * The branch the order desk quotes from. Resolution order:
     *
     *   1. Exactly one accessible branch — single-branch deployments select it
     *      automatically, no prompt (same as the old form's auto-filled and
     *      locked branch select);
     *   2. An explicit pick — the `branch_id` query param (the switch reloads
     *      with it) or the remembered `sales_order_branch_id` session var;
     *   3. null — several branches and none chosen yet: the terminal blocks
     *      behind the branch picker until one is picked.
     *
     * The same rule Pos::resolveBranchId() applies: for a multi-branch user
     * the choice is explicit, never guessed from the first accessible branch.
     */
    public function resolveBranchId(): ?int
    {
        $context = app(CurrentContext::class);
        $branches = $context->branches();

        if ($branches->count() === 1) {
            return (int) $branches->first()->id;
        }

        $requested = (int) request()->integer('branch_id') ?: session()->get(self::BRANCH_SESSION_KEY);

        if ($requested !== null && $context->canAccessBranch((int) $requested)) {
            return (int) $requested;
        }

        return null;
    }

    // ─────────────────────────────────────────────────────────────────────
    // View data (the page payload the Alpine terminal starts from)
    // ─────────────────────────────────────────────────────────────────────

    /**
     * @return array<string, mixed>
     */
    protected function getViewData(): array
    {
        $context = app(CurrentContext::class);
        $branchId = $this->resolveBranchId();

        return [
            'branches' => $context->branches()->map(fn ($b) => ['id' => $b->id, 'name' => $b->name])->values()->all(),
            'selectedBranchId' => $branchId,
            'branchRequired' => $branchId === null,
            'products' => $branchId ? app(SalesOrderCatalogService::class)->forBranch($branchId) : [],
            'customers' => Customer::where('is_active', true)
                ->orderBy('business_name')
                ->get(['id', 'business_name', 'phone', 'nic'])
                ->all(),
            'salesReps' => SalesRep::where('is_active', true)
                ->orderBy('full_name')
                ->get(['id', 'full_name'])
                ->all(),
            // Everything except `invoiced`, which is reached only by converting
            // the order — mirroring SalesOrderResource::assignableStatusOptions().
            'statuses' => StatusLookup::forContext(Status::CTX_SALES_ORDER)
                ->reject(fn ($status) => $status->code === Status::SO_INVOICED)
                ->map(fn ($status) => ['code' => $status->code, 'name' => $status->name])
                ->values()
                ->all(),
            'defaultStatus' => Status::SO_CONFIRMED,
            'paymentMethods' => $this->paymentMethodsPayload(),
            'canDiscount' => Auth::user()?->hasPermissionTo('isms_sales_orders.discount') ?? false,
            // Gates picking a lot other than the next one to sell — the same
            // rule SalesOrderService::quoteLot() enforces server-side (it
            // throws when a submitted batch_id is not the front lot and the
            // acting user lacks this). Without it here, the lot picker would
            // let a rep choose freely and only find out at save time that the
            // choice is refused.
            'canOverrideBatch' => Auth::user()?->hasPermissionTo('inventory_batches.override') ?? false,
            'vatEnabled' => app(VatSettings::class)->enabled(),
            'businessName' => app(PosSettings::class)->businessName(),
            'autoInvoice' => app(AppSettings::class)->salesOrderAutoInvoice(),
            'today' => now()->toDateString(),
            // The namespace shop-printed labels live in — the desk tells one
            // from a product SKU by it, so an ordinary scan never costs a
            // round-trip. See resolveScan().
            'labelPrefix' => BarcodeLabel::CODE_PREFIX,
        ];
    }

    /**
     * The tenders an order advance can be taken in — the user-selectable
     * checkout methods plus CREDIT (a credit note), matching
     * SalesOrderResource::paymentMethodOptions().
     *
     * @return array<int, array<string, mixed>>
     */
    protected function paymentMethodsPayload(): array
    {
        $methods = PaymentMethod::where('is_active', true)
            ->whereNotIn('code', PaymentMethod::SYSTEM)
            ->orderBy('sort_order')
            ->get(['code', 'name', 'requires_reference', 'requires_cheque_fields', 'fields', 'is_active'])
            ->map(fn (PaymentMethod $m) => [
                'code' => $m->code,
                'name' => $m->name,
                'requires_reference' => $m->requires_reference,
                'requires_cheque_fields' => $m->requires_cheque_fields,
                'is_custom' => $m->isCustom(),
                'fields' => $m->fields,
            ])
            ->values()
            ->all();

        $methods[] = [
            'code' => PaymentMethod::CREDIT,
            'name' => 'Credit note',
            'requires_reference' => false,
            'requires_cheque_fields' => false,
            'is_custom' => false,
            'fields' => [],
        ];

        return $methods;
    }

    // ─────────────────────────────────────────────────────────────────────
    // Customer-dependent lookups (deferred: fetched only when a customer is
    // actually selected — mirrors Pos::customerContext())
    // ─────────────────────────────────────────────────────────────────────

    /**
     * @return array<string, mixed>
     */
    #[Renderless]
    public function customerContext(int $customerId): array
    {
        abort_unless(SalesOrderResource::canCreate(), 403);

        return [
            // An advance can only ever be claimed against its own customer's
            // notes (SalesOrderResource::issuedCreditNoteOptions()).
            'credit_notes' => CreditNote::where('customer_id', $customerId)
                ->whereHas('status', fn ($q) => $q->where('code', Status::ISSUED))
                ->get(['id', 'cn_number', 'amount'])
                ->toArray(),
        ];
    }

    /**
     * Large Filament customer creation modal used directly from the
     * custom Sales Order terminal.
     */
    protected function getHeaderActions(): array
    {
        return [
            Action::make('newCustomer')
                ->label('New customer')
                ->modalHeading('New customer')
                ->modalSubmitActionLabel('Create')
                ->modalWidth('2xl')

                ->schema([
                    TextInput::make('business_name')
                        ->label('Customer name')
                        ->required()
                        ->maxLength(200),

                    TextInput::make('phone')
                        ->required()
                        ->maxLength(30)
                        ->tel(),

                    TextInput::make('nic')
                        ->label('NIC')
                        ->maxLength(20),

                    TextInput::make('email')
                        ->email()
                        ->maxLength(150),

                    RelationSelect::standalone(
                        name: 'city_id',
                        modelClass: City::class,
                        titleAttribute: 'name',
                    )
                        ->label('City'),
                ])

                ->action(function (array $data): void {
                    $customer = Customer::create(
                        $data + ['is_active' => true]
                    );

                    $this->dispatch(
                        'sales-order-customer-created',
                        customer: [
                            'id' => $customer->id,
                            'business_name' => $customer->business_name,
                            'phone' => $customer->phone,
                            'nic' => $customer->nic,
                        ],
                    );

                    Notification::make()
                        ->title('Customer created successfully')
                        ->success()
                        ->send();
                })
                ->extraAttributes([
                    'class' => 'hidden',
                ]),
        ];
    }

    // ─────────────────────────────────────────────────────────────────────
    // The one server boundary: create the order
    // ─────────────────────────────────────────────────────────────────────

    /**
     * Pins the desk to a branch (persisted in session) — the client reloads so
     * the whole catalog payload is rebuilt scoped to it.
     *
     * @return array<string, mixed>
     */
    public function selectBranch(int $branchId): array
    {
        $context = app(CurrentContext::class);

        if (! $context->canAccessBranch($branchId)) {
            return ['ok' => false, 'errors' => ['You do not have access to that branch.']];
        }

        session([self::BRANCH_SESSION_KEY => $branchId]);

        return ['ok' => true];
    }

    /**
     * Validate and persist the order. Everything the desk submits — header,
     * lines, advances — lands here exactly as the old CreateRecord's
     * handleRecordCreation() handed it to SalesOrderService::create(); the
     * service re-validates, reprices from the lots, and is the sole source of
     * truth (lot FEFO/front rules, discount permission gate, advance cap,
     * auto-invoice conversion).
     *
     * Renderless: the client gets a small success payload, no catalog
     * re-render — the terminal ages the screen state itself.
     *
     * @param  array<string, mixed>  $payload
     * @return array<string, mixed>
     */
    #[Renderless]
    public function createOrder(array $payload): array
    {
        abort_unless(SalesOrderResource::canCreate(), 403);

        // Precondition errors (branch) must surface before field validation so
        // the desk shows the actionable message, not a pile of noise.
        $branchId = (int) ($payload['branch_id'] ?? 0);

        if (! $branchId || ! app(CurrentContext::class)->canAccessBranch($branchId)) {
            return ['ok' => false, 'errors' => ['Select a branch to take the order in first.']];
        }

        $validate = Validator::make($payload, self::rules(), self::messages());

        if ($validate->fails()) {
            return ['ok' => false, 'errors' => $validate->errors()->all()];
        }

        // Per-tender field rules, same set the order-desk form and
        // SalesOrderService::recordPayment() apply: required reference /
        // cheque details, required custom-method fields, and the credit-note
        // rules (own customer's note, issued, claimed in full — its amount is
        // the note's face value).
        $paymentErrors = $this->paymentErrors($payload);

        if ($paymentErrors !== []) {
            return ['ok' => false, 'errors' => $paymentErrors];
        }

        try {
            $order = app(SalesOrderService::class)->create(
                header: [
                    'branch_id' => $branchId,
                    'customer_id' => (int) $payload['customer_id'],
                    'sales_rep_id' => $payload['sales_rep_id'] ?? null,
                    'order_date' => $payload['order_date'] ?? null,
                    'deadline_date' => $payload['deadline_date'] ?? null,
                    'discount_type' => $payload['discount_type'] ?? null,
                    'discount_value' => (float) ($payload['discount_value'] ?? 0),
                    'note' => $payload['note'] ?? null,
                    'status_code' => $payload['status_code'] ?? null,
                ],
                lines: $payload['items'] ?? [],
                payments: $payload['payments'] ?? [],
            );
        } catch (RuntimeException $e) {
            return ['ok' => false, 'errors' => [$e->getMessage()]];
        }

        $this->notifyOrderTaken($order);

        return [
            'ok' => true,
            'order_id' => $order->id,
            'order_number' => $order->order_number,
            'url' => ViewSalesOrder::getUrl(['record' => $order]),
            'auto_invoiced' => $order->isConverted(),
        ];
    }

    /**
     * @return array<string, mixed>
     */
    protected static function rules(): array
    {
        $statusCodes = StatusLookup::forContext(Status::CTX_SALES_ORDER)
            ->reject(fn ($status) => $status->code === Status::SO_INVOICED)
            ->keys()
            ->implode(',');

        $payMethodCodes = PaymentMethod::query()
            ->where('is_active', true)
            ->whereNotIn('code', PaymentMethod::SYSTEM)
            ->pluck('code')
            ->push(PaymentMethod::CREDIT)
            ->unique()
            ->values()
            ->all();

        return [
            'branch_id' => 'required|integer',
            'customer_id' => 'required|integer|exists:customers,id',
            'sales_rep_id' => 'nullable|integer|exists:sales_reps,id',
            'order_date' => 'nullable|date',
            'deadline_date' => 'nullable|date|after_or_equal:order_date',
            'status_code' => 'nullable|string|in:'.$statusCodes,
            'discount_type' => 'nullable|in:fixed,percentage',
            'discount_value' => 'nullable|numeric|min:0|max:99999999.99',
            'note' => 'nullable|string|max:500',
            'items' => 'required|array|min:1',
            'items.*.is_custom' => 'nullable|boolean',
            'items.*.product_id' => 'required_without:items.*.is_custom|nullable|integer|exists:products,id',
            'items.*.product_name' => 'required_if:items.*.is_custom,true|nullable|string|max:255',
            'items.*.custom_description' => 'nullable|string|max:1000',
            'items.*.custom_specification' => 'nullable|string|max:1000',
            'items.*.quantity' => 'required|numeric|min:0.01|max:1000000',
            'items.*.unit_price' => 'nullable|numeric|min:0|max:99999999.99',
            'items.*.vat_rate' => 'nullable|numeric|min:0|max:100',
            'items.*.batch_id' => 'nullable|integer|exists:stock_batches,id',
            'items.*.discount_type' => 'nullable|in:fixed,percentage',
            'items.*.discount_value' => 'nullable|numeric|min:0|max:99999999.99',
            'payments' => 'nullable|array',
            'payments.*.method' => ['required', Rule::in($payMethodCodes)],
            'payments.*.amount' => 'required|numeric|min:0.01|max:99999999.99',
            'payments.*.reference' => 'nullable|string|max:100',
            'payments.*.cheque_number' => 'nullable|string|max:50',
            'payments.*.cheque_bank' => 'nullable|string|max:100',
            'payments.*.cheque_branch' => 'nullable|string|max:100',
            'payments.*.cheque_date' => 'nullable|date',
            'payments.*.credit_note_id' => 'nullable|integer|exists:credit_notes,id',
            'payments.*.received_at' => 'nullable|date',
            'payments.*.custom_fields' => 'nullable|array',
        ];
    }

    /**
     * @return array<string, string>
     */
    protected static function messages(): array
    {
        return [
            'items.required' => 'A sales order needs at least one line.',
            'items.min' => 'A sales order needs at least one line.',
            'customer_id.required' => 'A customer is required to take an order.',
        ];
    }

    /**
     * The per-tender checks  that do not fit the generic rule shapes — the
     * same rules the resource form encodes with per-method visibility. Only
     * payments that actually record money (amount > 0) are checked, matching
     * SalesOrderService::syncPayments()'s own skip.
     *
     * @param  array<string, mixed>  $payload
     * @return array<int, string>
     */
    protected function paymentErrors(array $payload): array
    {
        $errors = [];

        foreach ($payload['payments'] ?? [] as $payment) {
            if (round((float) ($payment['amount'] ?? 0), 2) <= 0.001) {
                continue;
            }

            $code = (string) ($payment['method'] ?? '');

            if ($code === PaymentMethod::CREDIT) {
                $noteId = $payment['credit_note_id'] ?? null;

                if (! $noteId) {
                    $errors[] = 'A credit note must be selected for a credit-note advance.';

                    continue;
                }

                $note = CreditNote::find((int) $noteId);

                if (! $note || $note->customer_id !== (int) $payload['customer_id']) {
                    $errors[] = 'That credit note does not belong to this order\'s customer.';

                    continue;
                }

                if (! $note->status || $note->status->code !== Status::ISSUED) {
                    $errors[] = 'That credit note is not available to apply.';

                    continue;
                }

                // Credit notes are claimed in full (CreditNoteService::claim
                // refuses a partial) — the amount the client submitted must not
                // invent a number smaller than the note's face value.
                if (round((float) $note->amount, 2) !== round((float) ($payment['amount'] ?? 0), 2)) {
                    $errors[] = "{$note->cn_number} is claimed in full — the amount must be its face value.";
                }

                continue;
            }

            $method = PaymentMethod::findActiveByCode($code);

            if (! $method) {
                $errors[] = 'That payment method is not available.';

                continue;
            }

            if ($error = PaymentMethod::validateRequiredFields($code, $payment)) {
                $errors[] = $error;
            }

            if ($error = PaymentMethod::validateCustomFields($code, $payment['custom_fields'] ?? [])) {
                $errors[] = $error;
            }
        }

        return $errors;
    }

    /**
     * Same two notifications the old create page sent: one to the sales desk,
     * one to the customer when we hold an address for them. Committed already
     * by the time we get here, so nothing in here may throw — bad addresses
     * are logged and skipped.
     */
    protected function notifyOrderTaken(SalesOrder $order): void
    {
        $order->load(['customer', 'items.product', 'branch']);

        $recipients = [
            'sales desk' => config('mail.sales_notification_email') ?: config('mail.from.address'),
            'customer' => $order->customer?->email,
        ];

        foreach ($recipients as $role => $email) {
            if (blank($email)) {
                continue;
            }

            if (! filter_var($email, FILTER_VALIDATE_EMAIL)) {
                Log::warning('Skipped sales order notification: invalid address.', [
                    'recipient' => $role,
                    'email' => $email,
                    'sales_order_id' => $order->id,
                ]);

                continue;
            }

            try {
                Mail::to($email)->queue(new SalesOrderCreated($order));
            } catch (Throwable $e) {
                report($e);
            }
        }
    }
}
