<?php

namespace App\Domain\POS\Services;

use App\Domain\Financial\Models\CashSession;
use App\Domain\Financial\Models\Installment;
use App\Domain\Financial\Models\PaymentPlan;
use App\Domain\Financial\Services\CashSessionService;
use App\Domain\Financial\Services\InvoiceService;
use App\Domain\Financial\Services\PaymentService;
use App\Domain\Financial\Services\WalletService;
use App\Domain\Inventory\Enums\MovementType;
use App\Domain\Inventory\Models\Kit;
use App\Domain\Inventory\Models\Product;
use App\Domain\Inventory\Models\ProductInstallmentPlan;
use App\Domain\Inventory\Models\Warehouse;
use App\Domain\Inventory\Services\InventoryService;
use App\Domain\Shared\Services\PlatformFeeService;
use App\Domain\Shared\Services\SettingsService;
use App\Domain\Participant\Models\Participant;
use App\Domain\POS\Enums\POSItemType;
use App\Domain\POS\Enums\POSPaymentMethod;
use App\Domain\POS\Enums\POSPaymentStatus;
use App\Domain\POS\Events\POSTransactionCompleted;
use App\Domain\POS\Models\POSTransaction;
use App\Domain\Pricing\Services\PriceResult;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Training\Models\Enrollment;
use App\Domain\Training\Models\TrainingGroup;
use App\Domain\Training\Models\TrainingProgram;
use App\Models\User;
use Illuminate\Support\Facades\DB;

class POSService
{
    public function __construct(
        private InvoiceService $invoiceService,
        private PaymentService $paymentService,
        private WalletService $walletService,
        private CashSessionService $cashSessionService,
        private PlatformFeeService $platformFeeService,
        private InventoryService $inventoryService,
        private SettingsService $settings,
    ) {}

    /**
     * Process a full POS transaction (10-step flow, single DB::transaction).
     *
     * @param array $cartItems Each item: ['item_type', 'item_id', 'item_name_ar', 'quantity', 'unit_price', 'discount_amount', 'line_total', 'pricing_snapshot', 'metadata'].
     *                         A product line may also carry 'installment_plan_id' and 'pay_installments'; both are
     *                         re-resolved from the database here, and a plan that does not belong to the line's
     *                         product or to this buyer's membership tier is refused, not ignored.
     * @param User $cashier The user processing the transaction
     * @param int $branchId The branch where the sale occurs
     * @param string $paymentMethod One of: cash, card, wallet, split
     * @param Participant|null $participant Optional participant (required for wallet/enrollment)
     * @param string|null $couponCode Applied coupon code
     * @param array|null $splitPayments For split: [['method' => 'cash', 'amount' => 5000, 'reference' => null], ...]
     * @param string|null $notes Transaction notes
     * @return POSTransaction
     */
    public function processTransaction(
        array $cartItems,
        User $cashier,
        int $branchId,
        string $paymentMethod,
        ?Participant $participant = null,
        ?string $couponCode = null,
        ?array $splitPayments = null,
        ?string $notes = null,
        ?int $depositAmount = null,
    ): POSTransaction {
        // Guard: POS session must be open — in THIS branch. The transaction is
        // stamped with $branchId while the cash_session_id and academy_id come
        // from the drawer, so an unfiltered drawer meant the sale counted in one
        // branch's revenue and the cash in another branch's reconciliation. A
        // till cannot serve two branches.
        $cashSession = $this->cashSessionService->getOpenSession($cashier, $branchId);
        if (!$cashSession) {
            throw new DomainException('يجب فتح وردية أولاً');
        }

        if ((int) $cashSession->branch_id !== $branchId) {
            throw new DomainException('الوردية المفتوحة تخص فرعاً آخر');
        }

        // Guard: cart must not be empty
        if (empty($cartItems)) {
            throw new DomainException('السلة فارغة');
        }

        // Guard: participant suspended or blacklisted
        if ($participant) {
            $status = $participant->status instanceof \BackedEnum
                ? $participant->status->value
                : (string) $participant->status;

            if (in_array($status, ['suspended', 'blacklisted'])) {
                throw new DomainException('المشترك معلق — لا يمكن إجراء عمليات شراء جديدة');
            }
        }

        return DB::transaction(function () use ($cartItems, $cashier, $branchId, $paymentMethod, $participant, $couponCode, $splitPayments, $notes, $cashSession, $depositAmount) {
            // Steps 2-3: Items already priced by caller (pricing engine runs in Livewire/controller layer)
            $subtotal = 0;
            $totalDiscount = 0;

            foreach ($cartItems as $item) {
                $subtotal += (int) $item['line_total'];
                $totalDiscount += (int) ($item['discount_amount'] ?? 0);
            }

            // Step 5: Calculate totals
            $taxAmount = 0; // Tax configuration per academy — to be implemented
            $customerPays = $this->platformFeeService->customerPays();
            $serviceFeeAmount = $customerPays ? $this->platformFeeService->calculate($subtotal) : 0;
            $totalAmount = $subtotal + $taxAmount + $serviceFeeAmount;

            // An instalment plan says what is collected today, and it says it
            // from the database — never from the amount the browser sent. Every
            // plan named on a cart line is re-resolved against that line's
            // product and this buyer's membership tier before a piaster of it
            // is believed.
            $plannedLines = $this->resolveInstallmentPlans($cartItems, $participant);
            $planDueNow = $this->planDueNow($cartItems, $plannedLines, $serviceFeeAmount);

            // Step 6-7: Validate payment
            $paymentMethodEnum = POSPaymentMethod::from($paymentMethod);

            if ($planDueNow !== null) {
                // A plan and a deposit are two answers to the same question.
                $depositAmount = null;
            }

            $isDeposit = $depositAmount !== null && $depositAmount > 0 && $depositAmount < $totalAmount;
            $amountToPayNow = match (true) {
                $planDueNow !== null => min($planDueNow, $totalAmount),
                $isDeposit => $depositAmount,
                default => $totalAmount,
            };
            $isPartial = $amountToPayNow < $totalAmount;
            $this->validatePayment($paymentMethodEnum, $amountToPayNow, $participant, $splitPayments);

            // Step 8: Commit — create POS transaction
            $receiptNumber = $this->generateReceiptNumber($branchId);
            $paymentStatus = $isPartial ? POSPaymentStatus::PartiallyPaid : POSPaymentStatus::Completed;

            $posTransaction = POSTransaction::create([
                'academy_id' => $cashSession->academy_id,
                'branch_id' => $branchId,
                'cash_session_id' => $cashSession->id,
                'participant_id' => $participant?->id,
                'receipt_number' => $receiptNumber,
                'subtotal' => $subtotal,
                'discount_amount' => $totalDiscount,
                'tax_amount' => $taxAmount,
                'service_fee_amount' => $serviceFeeAmount,
                'total_amount' => $totalAmount,
                'payment_method' => $paymentMethodEnum->value,
                'payment_status' => $paymentStatus->value,
                'coupon_code' => $couponCode,
                'notes' => match (true) {
                    $planDueNow !== null => ($notes ? $notes . ' | ' : '') . 'بيع بالتقسيط',
                    $isDeposit => ($notes ? $notes . ' | ' : '') . 'دفع مقدم (عربون)',
                    default => $notes,
                },
                'processed_by' => $cashier->id,
                'processed_at' => now(),
                'metadata' => $isPartial
                    ? [
                        'amount_due_now' => $amountToPayNow,
                        'remaining' => $totalAmount - $amountToPayNow,
                        'deposit_amount' => $isDeposit ? $depositAmount : null,
                        'installment_plans' => array_map(
                            fn ($line) => [
                                'product_id' => $line['product_id'],
                                'plan_id' => $line['plan']->id,
                                'label' => $line['plan']->label_ar,
                                'installments' => $line['plan']->installments,
                                'paying_now' => $line['pay'],
                            ],
                            $plannedLines
                        ),
                    ]
                    : [],
            ]);

            // Create POS transaction items
            foreach ($cartItems as $item) {
                $posTransaction->items()->create([
                    'item_type' => $item['item_type'],
                    'item_id' => $item['item_id'] ?? null,
                    'item_name_ar' => $item['item_name_ar'],
                    'quantity' => $item['quantity'] ?? 1,
                    'unit_price' => (int) $item['unit_price'],
                    'discount_amount' => (int) ($item['discount_amount'] ?? 0),
                    'line_total' => (int) $item['line_total'],
                    'pricing_snapshot' => $item['pricing_snapshot'] ?? [],
                    'metadata' => $item['metadata'] ?? [],
                ]);
            }

            // Create split payment records (if split method)
            if ($paymentMethodEnum === POSPaymentMethod::Split && $splitPayments) {
                foreach ($splitPayments as $sp) {
                    $posTransaction->splitPayments()->create([
                        'payment_method' => $sp['method'],
                        'amount' => (int) $sp['amount'],
                        'reference' => $sp['reference'] ?? null,
                    ]);
                }
            }

            // Create invoice + items
            $invoice = $this->invoiceService->create([
                'academy_id' => $cashSession->academy_id,
                'branch_id' => $branchId,
                'billable_type' => $participant ? $participant->getMorphClass() : null,
                'billable_id' => $participant?->id,
                'number' => $this->invoiceService->generateNumber($cashSession->academy_id),
                'total_amount' => $totalAmount,
                'subtotal_amount' => $subtotal,
                'discount_amount' => $totalDiscount,
                'tax_amount' => $taxAmount,
                'service_fee_amount' => $serviceFeeAmount,
                'contact_name' => $participant?->person?->name_ar ?? 'عميل عابر',
            ], $this->buildInvoiceItems($cartItems), $cashier);

            // Link invoice to POS transaction
            $posTransaction->update(['invoice_id' => $invoice->id]);

            // The schedule the customer agreed to, on the invoice that carries it.
            $this->createPaymentPlans($invoice, $cartItems, $plannedLines, $cashSession->academy_id);

            // Record payment(s) on the invoice (deposit pays only the deposit amount)
            if ($paymentMethodEnum === POSPaymentMethod::Split && $splitPayments) {
                foreach ($splitPayments as $sp) {
                    $this->recordSinglePayment($invoice, $sp['method'], (int) $sp['amount'], $cashier, $participant, $branchId);
                }
            } else {
                $this->recordSinglePayment($invoice, $paymentMethod, $amountToPayNow, $cashier, $participant, $branchId);
            }

            // Handle enrollment for program items + inventory deduction for tracked products
            foreach ($cartItems as $item) {
                $itemType = $item['item_type'] instanceof POSItemType
                    ? $item['item_type']
                    : POSItemType::from($item['item_type']);

                if ($itemType === POSItemType::Program && $participant && isset($item['metadata']['program_id'])) {
                    $this->createEnrollmentIfNeeded(
                        $participant,
                        (int) $item['metadata']['program_id'],
                        isset($item['metadata']['group_id']) ? (int) $item['metadata']['group_id'] : null,
                        $branchId
                    );
                }

                // Inventory movement for tracked products
                if ($itemType === POSItemType::Product && isset($item['item_id'])) {
                    $this->deductInventoryIfTracked(
                        (int) $item['item_id'],
                        (int) ($item['quantity'] ?? 1),
                        $branchId,
                        $posTransaction,
                        $cashier
                    );
                }
            }

            // One sale is one transaction on the drawer, whatever it was paid
            // with. `total_cash_in` is deliberately NOT touched here: the
            // UpdateCashSessionTotals listener already increments it from the
            // PaymentReceived event, so incrementing it here too inflated the
            // expected drawer by exactly double on every POS cash sale.
            $cashSession->increment('transactions_count');

            // Step 10: Dispatch events (fires after commit via ShouldDispatchAfterCommit)
            POSTransactionCompleted::dispatch($posTransaction, $cashier);

            return $posTransaction->fresh(['items', 'splitPayments']);
        });
    }

    /**
     * Every cart line that is genuinely being sold on an instalment plan.
     *
     * The cart reaches this service from a Livewire public property, so the
     * plan id on a line is a number the browser chose. Three things have to be
     * true before it buys anyone a schedule: the plan exists and belongs to the
     * product on that line, the product is actually an annual one, and the plan
     * is open to this buyer's membership tier — a members-only plan sold to a
     * walk-in is the whole point of having tiers.
     *
     * @param array<int, array> $cartItems
     * @return array<int, array{plan: ProductInstallmentPlan, pay: int, product_id: int}>
     */
    private function resolveInstallmentPlans(array $cartItems, ?Participant $participant): array
    {
        $tier = $this->membershipTier($participant);
        $resolved = [];

        foreach ($cartItems as $index => $item) {
            $planId = (int) ($item['installment_plan_id'] ?? 0);
            $productId = (int) ($item['item_id'] ?? 0);

            if ($planId <= 0 || $productId <= 0) {
                continue;
            }

            $itemType = $item['item_type'] instanceof POSItemType
                ? $item['item_type']
                : POSItemType::tryFrom((string) $item['item_type']);

            if ($itemType !== POSItemType::Product) {
                continue;
            }

            $product = Product::find($productId);
            if (! $product || ! $product->isAnnual()) {
                throw new DomainException('هذا المنتج لا يُباع بالتقسيط');
            }

            $plan = $product->installmentPlans()
                ->active()
                ->forTier($tier)
                ->whereKey($planId)
                ->first();

            if (! $plan) {
                throw new DomainException('خطة التقسيط غير متاحة لهذا المنتج أو لفئة هذا المشترك');
            }

            $resolved[$index] = [
                'plan' => $plan,
                'pay' => max(1, min((int) ($item['pay_installments'] ?? 1), $plan->installments)),
                'product_id' => $productId,
            ];
        }

        return $resolved;
    }

    /**
     * What a cart carrying instalment plans collects today, or null for a cart
     * carrying none.
     *
     * A line on a plan owes the instalments being paid now; every other line
     * owes its total in full. The service fee is charged once, on this visit,
     * rather than spread across a schedule the platform is not party to.
     */
    private function planDueNow(array $cartItems, array $plannedLines, int $serviceFeeAmount): ?int
    {
        if ($plannedLines === []) {
            return null;
        }

        $due = $serviceFeeAmount;

        foreach ($cartItems as $index => $item) {
            $lineTotal = (int) $item['line_total'];

            if (! isset($plannedLines[$index])) {
                $due += $lineTotal;
                continue;
            }

            $schedule = $plannedLines[$index]['plan']->buildSchedule($lineTotal);
            $due += array_sum(array_slice($schedule, 0, $plannedLines[$index]['pay']));
        }

        return $due;
    }

    /**
     * Write the agreed schedule against the invoice that carries it.
     *
     * One plan per product line, so a sale of a registration card and a kit on
     * two different schedules stays two schedules. Instalments the customer is
     * paying on this visit are marked paid immediately — the money is being
     * taken in the same transaction — and next_due_date points at the first one
     * that is not.
     */
    private function createPaymentPlans($invoice, array $cartItems, array $plannedLines, int $academyId): void
    {
        foreach ($plannedLines as $index => $line) {
            $plan = $line['plan'];
            $schedule = $plan->buildSchedule((int) $cartItems[$index]['line_total']);
            $regular = count($schedule) > 1 ? ($schedule[1] ?? $schedule[0]) : $schedule[0];

            $paymentPlan = PaymentPlan::create([
                'academy_id' => $academyId,
                'invoice_id' => $invoice->id,
                'status' => $line['pay'] >= $plan->installments ? 'completed' : 'active',
                'total_installments' => $plan->installments,
                'paid_installments' => $line['pay'],
                'installment_amount' => $regular,
                'frequency' => $plan->frequency,
                'start_date' => now()->toDateString(),
                'next_due_date' => null,
                'notes' => $plan->label_ar . ' — ' . ($cartItems[$index]['item_name_ar'] ?? ''),
            ]);

            $dueDate = now();
            $nextDue = null;

            foreach ($schedule as $slot => $amount) {
                $sequence = $slot + 1;
                $paidNow = $sequence <= $line['pay'];

                Installment::create([
                    'payment_plan_id' => $paymentPlan->id,
                    'sequence' => $sequence,
                    'amount' => $amount,
                    'due_date' => $dueDate->toDateString(),
                    'status' => $paidNow ? 'paid' : 'pending',
                    'paid_at' => $paidNow ? now() : null,
                ]);

                if (! $paidNow && $nextDue === null) {
                    $nextDue = $dueDate->toDateString();
                }

                $dueDate = match ($plan->frequency) {
                    'weekly' => $dueDate->copy()->addWeek(),
                    'biweekly' => $dueDate->copy()->addWeeks(2),
                    'quarterly' => $dueDate->copy()->addMonths(3),
                    default => $dueDate->copy()->addMonth(),
                };
            }

            $paymentPlan->update(['next_due_date' => $nextDue]);
        }
    }

    private function membershipTier(?Participant $participant): string
    {
        $type = $participant?->membership_type;

        if ($type instanceof \BackedEnum) {
            $type = $type->value;
        }

        return $type === 'member' ? 'member' : 'non_member';
    }

    /**
     * Validate payment method-specific constraints.
     */
    private function validatePayment(POSPaymentMethod $method, int $total, ?Participant $participant, ?array $splitPayments): void
    {
        if ($method === POSPaymentMethod::Wallet) {
            if (!$participant) {
                throw new DomainException('الدفع بالمحفظة يتطلب تحديد المشترك');
            }
            $wallet = $this->walletService->getOrCreateWallet($participant, $participant->academy_id);
            $availableBalance = $wallet->balance;
            if ($availableBalance < $total) {
                throw new DomainException('رصيد المحفظة غير كافٍ');
            }
        }

        if ($method === POSPaymentMethod::Split) {
            if (!$splitPayments || empty($splitPayments)) {
                throw new DomainException('يجب تحديد طرق الدفع المقسمة');
            }
            $splitTotal = (int) collect($splitPayments)->sum('amount');
            if ($splitTotal < $total) {
                throw new DomainException('المبلغ المدفوع أقل من الإجمالي');
            }
        }
    }

    /**
     * Record a single payment against the invoice (handles wallet deduction).
     */
    private function recordSinglePayment($invoice, string $method, int $amount, User $cashier, ?Participant $participant, int $branchId): void
    {
        // A split may legitimately be tendered above the total — the cashier
        // types the cash the customer handed over and gives change back. Only
        // what settles the invoice is a payment; the change is not revenue.
        // Without this cap the last split now hits recordPayment()'s
        // "amount > due" guard, and before the guard existed it silently
        // produced an overpaid invoice with a negative due_amount.
        $remaining = (int) $invoice->fresh()->due_amount;
        $amount = min($amount, $remaining);

        if ($amount <= 0) {
            return;
        }

        // Wallet deduction
        if ($method === 'wallet' && $participant) {
            $wallet = $this->walletService->getOrCreateWallet($participant, $participant->academy_id);
            $this->walletService->withdraw($wallet, $amount, 'دفع فاتورة: ' . $invoice->number, $invoice, $cashier);
        }

        $this->paymentService->recordPayment([
            'academy_id' => $invoice->academy_id,
            // The branch of the sale. Invoices carry no branch_id, so reading
            // it off the invoice always produced null and left POS payments
            // unattributed to any branch.
            'branch_id' => $branchId,
            'invoice_id' => $invoice->id,
            'amount' => $amount,
            'method' => $method,
            'direction' => 'inbound',
            'currency' => 'EGP',
            'payment_date' => now()->toDateString(),
            // Provenance, so the cash-session listener does not count a POS
            // sale a second time.
            'metadata' => ['source' => 'pos'],
        ], $cashier);
    }

    /**
     * Build invoice items array from cart items.
     */
    private function buildInvoiceItems(array $cartItems): array
    {
        return array_map(function (array $item) {
            $line = [
                'description' => $item['item_name_ar'],
                'quantity' => $item['quantity'] ?? 1,
                'unit_price' => (int) $item['unit_price'],
                'discount_amount' => (int) ($item['discount_amount'] ?? 0),
                'tax_amount' => 0,
            ];

            // Point the line at what was actually sold. Without this every POS
            // sale lands with a null itemable_type, which reporting reads as a
            // programme subscription — so product sales inflated subscription
            // revenue, were missing from product revenue, and never counted
            // towards "has this participant bought the required product".
            $type = $item['item_type'] ?? null;
            $type = $type instanceof POSItemType ? $type : POSItemType::tryFrom((string) $type);
            $id = $item['item_id'] ?? null;

            if ($id && $type === POSItemType::Product) {
                $line['itemable_type'] = Product::class;
                $line['itemable_id'] = (int) $id;
            } elseif ($id && $type === POSItemType::Kit) {
                $line['itemable_type'] = Kit::class;
                $line['itemable_id'] = (int) $id;
            }

            return $line;
        }, $cartItems);
    }

    /**
     * Create enrollment if not already enrolled in the program.
     */
    private function createEnrollmentIfNeeded(Participant $participant, int $programId, ?int $groupId, int $branchId): void
    {
        // The programme and group ids ride in on the cart array from the
        // browser, and a till in one branch enrolling a player into another
        // branch's programme puts them on a roster, an attendance sheet and a
        // session list they will never attend. Stated explicitly rather than
        // relying on the request scope, so a queued or console sale is refused
        // the same way.
        $program = TrainingProgram::where('id', $programId)
            ->where(fn ($q) => $q->where('branch_id', $branchId))
            ->first();

        if (! $program) {
            throw new DomainException('هذا البرنامج غير متاح في هذا الفرع');
        }

        $group = null;

        if ($groupId) {
            $group = TrainingGroup::where('id', $groupId)
                ->where('branch_id', $branchId)
                ->first();

            if (! $group) {
                throw new DomainException('المجموعة المحددة تخص فرعاً آخر');
            }
        }

        $exists = Enrollment::where('participant_id', $participant->id)
            ->where('training_program_id', $programId)
            ->whereIn('status', ['active', 'pending'])
            ->exists();

        if ($exists) {
            throw new DomainException('مسجل بالفعل في هذا البرنامج');
        }

        Enrollment::create([
            'academy_id' => $participant->academy_id,
            'branch_id' => $group?->branch_id ?? $branchId,
            'participant_id' => $participant->id,
            'training_program_id' => $programId,
            'training_group_id' => $groupId,
            'status' => 'active',
            'enrollment_date' => now()->toDateString(),
        ]);
    }

    /**
     * Deduct inventory for a tracked product after POS sale.
     * Finds the branch warehouse and creates a 'sale' movement.
     */
    private function deductInventoryIfTracked(int $productId, int $quantity, int $branchId, POSTransaction $posTransaction, User $cashier): void
    {
        if (!(bool) $this->settings->get('auto_inventory_deduction_on_sale', true)) {
            return;
        }

        // A branch sells its own products. One belonging to another branch is
        // not sellable here — its stock sits in that branch's warehouse — so
        // refuse rather than sell it and deduct from the wrong shelf.
        $product = Product::where('id', $productId)
            ->where(fn ($q) => $q->where('branch_id', $branchId))
            ->first();

        if (! $product) {
            if (Product::withoutBranchScope()->whereKey($productId)->exists()) {
                throw new DomainException('هذا المنتج غير متاح في هذا الفرع');
            }

            return;
        }

        if (! $product->track_inventory) {
            return;
        }

        // Resolve the warehouse the stock leaves from: this branch's own if it
        // has one, otherwise the academy-wide warehouse (branch_id NULL means
        // every branch uses it — skipping those left shared stock never
        // deducted).
        $warehouse = Warehouse::where('is_active', true)
            ->where(fn ($q) => $q->where('branch_id', $branchId))
            ->orderByRaw('CASE WHEN branch_id = ? THEN 0 ELSE 1 END', [$branchId])
            ->first();

        if (!$warehouse) {
            // No warehouse configured for this branch — skip silently
            // (admin should configure warehouses for inventory-tracked branches)
            return;
        }

        $this->inventoryService->createMovement(
            product: $product,
            warehouse: $warehouse,
            type: MovementType::Sale,
            quantity: $quantity,
            actor: $cashier,
            unitCost: $product->cost_price,
            reference: $posTransaction,
            reason: "بيع نقطة بيع: {$posTransaction->receipt_number}",
        );
    }

    /**
     * Generate unique receipt number: RCP-{BRANCH}-{YYYYMMDD}-{SEQ}
     */
    private function generateReceiptNumber(int $branchId): string
    {
        $branchCode = str_pad($branchId, 3, '0', STR_PAD_LEFT);
        $dateCode = now()->format('Ymd');

        $seq = POSTransaction::withoutGlobalScopes()
            ->where('branch_id', $branchId)
            ->whereDate('processed_at', now()->toDateString())
            ->count() + 1;

        return "RCP-{$branchCode}-{$dateCode}-" . str_pad($seq, 4, '0', STR_PAD_LEFT);
    }
}
