<?php

namespace Database\Seeders\Demo;

use App\Exceptions\InsufficientStockException;
use App\Exceptions\StockReferenceException;
use App\Models\Branch;
use App\Models\Catalog\Product;
use App\Models\Catalog\ProductBundle;
use App\Models\Customers\Customer;
use App\Models\Inventory\ProductSerial;
use App\Models\Lookups\PaymentMethod;
use App\Models\Lookups\Status;
use App\Models\Loyalty\CustomerLoyaltyBalance;
use App\Models\Loyalty\LoyaltyProgram;
use App\Models\Sales\CashMovement;
use App\Models\Sales\PosSession;
use App\Models\Sales\Till;
use App\Models\Vouchers\Voucher;
use App\Services\BatchService;
use App\Services\InvoiceService;
use App\Services\PosSessionService;
use Carbon\Carbon;
use Database\Seeders\Demo\Concerns\GeneratesDemoData;
use Illuminate\Database\Seeder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use RuntimeException;

class SalesSeeder extends Seeder
{
    use GeneratesDemoData;

    private const POS_SESSION_COUNT_PER_BRANCH = 13;

    private const INVOICE_COUNT = 600;

    private const TILL_CHANNEL_RATIO = 0.3;

    private const BUNDLE_INVOICE_COUNT = 30;

    private const VOUCHER_INVOICE_COUNT = 40;

    private InvoiceService $invoices;

    private array $skipped = ['stock' => 0, 'serial' => 0, 'other' => 0];

    public function run(): void
    {
        $this->invoices = app(InvoiceService::class);

        $branches = Branch::orderBy('id')->get();
        $customers = Customer::orderBy('id')->get();
        $products = Product::where('is_active', true)->get();
        $bundles = ProductBundle::all();
        $adminId = (int) Auth::id();

        $sessionsByBranch = $this->seedPosSessions($branches, $adminId);

        $regularCount = self::INVOICE_COUNT - self::BUNDLE_INVOICE_COUNT;
        for ($i = 1; $i <= $regularCount; $i++) {
            $this->createRandomInvoice($branches, $customers, $products, $sessionsByBranch, useVoucher: $i <= self::VOUCHER_INVOICE_COUNT);
        }

        for ($i = 1; $i <= self::BUNDLE_INVOICE_COUNT; $i++) {
            $this->createBundleInvoice($branches, $customers, $bundles, $sessionsByBranch);
        }

        $this->command?->info(sprintf(
            'SalesSeeder: skipped %d (stock), %d (serial), %d (other) out of ~%d attempted invoices.',
            $this->skipped['stock'], $this->skipped['serial'], $this->skipped['other'], self::INVOICE_COUNT,
        ));
    }

    /** @return array<int,Collection<int,PosSession>> branch_id => sessions */
    private function seedPosSessions(Collection $branches, int $adminId): array
    {
        $sessionService = app(PosSessionService::class);
        $byBranch = [];

        foreach ($branches as $branch) {
            // BranchSeeder creates the fixed counter till before the mobile
            // one for each branch — the lowest id is the counter till.
            $till = Till::where('branch_id', $branch->id)->orderBy('id')->first();
            $sessions = collect();

            for ($i = 1; $i <= self::POS_SESSION_COUNT_PER_BRANCH; $i++) {
                $date = $this->recentDate(6)->setTime(8, random_int(0, 30));
                // Every session but the first carries the previous session's
                // closing balance forward, same as PosSessionService::open()
                // now requires in real use.
                $opening = $i === 1
                    ? (float) fake()->randomElement([5000, 10000, 15000, 20000])
                    : (float) $sessions->last()->closing_balance;

                $session = $sessionService->open($branch->id, $till->id, $opening);
                $session->forceFill(['opened_at' => $date, 'created_at' => $date, 'updated_at' => $date])->saveQuietly();

                foreach (range(1, random_int(1, 3)) as $__) {
                    CashMovement::create([
                        'pos_session_id' => $session->id,
                        'type' => fake()->randomElement(['in', 'out']),
                        'amount' => fake()->randomFloat(2, 500, 8000),
                        'note' => fake()->randomElement(['Change float top-up', 'Bank deposit run', 'Petty cash withdrawal', 'Supplier cash payment']),
                        'created_by' => $adminId,
                        'created_at' => $date->copy()->addHours(random_int(1, 6)),
                    ]);
                }

                // Leave only the most recent session per branch open (still trading) —
                // a till can have at most one open session at a time.
                if ($i < self::POS_SESSION_COUNT_PER_BRANCH) {
                    $closeDate = $date->copy()->addHours(random_int(8, 11));
                    $closed = $sessionService->close($session->fresh(), $opening + fake()->randomFloat(2, 3000, 60000));
                    $closed->forceFill(['opened_at' => $date, 'closed_at' => $closeDate, 'updated_at' => $closeDate])->saveQuietly();
                    $session = $closed;
                }

                $sessions->push($session->fresh());
            }

            $byBranch[$branch->id] = $sessions;
        }

        return $byBranch;
    }

    private function createRandomInvoice(Collection $branches, Collection $customers, Collection $products, array $sessionsByBranch, bool $useVoucher): void
    {
        $useTill = random_int(1, 100) <= (self::TILL_CHANNEL_RATIO * 100);
        [$branch, $session, $date] = $this->pickChannelContext($branches, $sessionsByBranch, $useTill);

        $customer = fake()->boolean(90) ? $customers->random() : null;
        $lineCount = random_int(1, 5);
        $lineProducts = $products->random(min($lineCount, $products->count()));

        $lines = [];
        foreach ($lineProducts as $product) {
            $line = $this->buildInvoiceLine($product, $branch->id);
            if ($line !== null) {
                $lines[] = $line;
            }
        }

        if (empty($lines)) {
            $this->skipped['other']++;

            return;
        }

        $subtotalEstimate = array_sum(array_map(fn ($l) => (float) $l['line_total'] + (float) ($l['vat_amount'] ?? 0), $lines));

        $header = [
            'channel' => $useTill ? 'till' : 'sales',
            'branch_id' => $branch->id,
            'customer_id' => $customer?->id,
        ];

        if ($useTill && $session) {
            $header['till_id'] = $session->till_id;
            $header['pos_session_id'] = $session->id;
        }

        if (! $useTill && $customer?->assigned_rep_id) {
            $header['sales_rep_id'] = $customer->assigned_rep_id;
        }

        $hasDiscount = false;
        if ($useVoucher && $customer) {
            $voucherCode = $this->pickVoucherCode($customer, $subtotalEstimate);
            if ($voucherCode) {
                $header['voucher_codes'] = [$voucherCode];
                $hasDiscount = true;
            }
        } elseif ($customer) {
            $redeemPoints = $this->pickLoyaltyRedemption($customer, $subtotalEstimate);
            if ($redeemPoints > 0) {
                $header['redeem_points'] = $redeemPoints;
                $hasDiscount = true;
            }
        }

        // A voucher/loyalty discount changes the actual net total in ways this
        // seeder can't cheaply predict up front — leave those unpaid rather than
        // risk building splits that overshoot the real (discounted) balance.
        [, $splits] = $hasDiscount ? ['full', []] : $this->pickPaymentPlan($subtotalEstimate);

        try {
            $invoice = $this->invoices->create(header: $header, lines: $lines, splits: $splits, confirmShortfall: true);
            $this->backdate($invoice, $date, cascadeStockMovements: true);
        } catch (InsufficientStockException $e) {
            $this->skipped['stock']++;
        } catch (StockReferenceException $e) {
            $this->skipped['serial']++;
        } catch (RuntimeException $e) {
            $this->skipped['other']++;
        }
    }

    private function createBundleInvoice(Collection $branches, Collection $customers, Collection $bundles, array $sessionsByBranch): void
    {
        if ($bundles->isEmpty()) {
            return;
        }

        $useTill = random_int(1, 100) <= 50;
        [$branch, $session, $date] = $this->pickChannelContext($branches, $sessionsByBranch, $useTill);
        $customer = fake()->boolean(85) ? $customers->random() : null;
        $bundle = $bundles->random();

        $header = [
            'channel' => $useTill ? 'till' : 'sales',
            'branch_id' => $branch->id,
            'customer_id' => $customer?->id,
        ];
        if ($useTill && $session) {
            $header['till_id'] = $session->till_id;
            $header['pos_session_id'] = $session->id;
        }

        $qty = random_int(1, 2);
        $lines = [[
            'product_bundle_id' => $bundle->id,
            'quantity' => $qty,
            'unit_price' => (float) $bundle->sale_price,
        ]];

        [, $splits] = $this->pickPaymentPlan((float) $bundle->sale_price * $qty);

        try {
            $invoice = $this->invoices->create(header: $header, lines: $lines, splits: $splits, confirmShortfall: true);
            $this->backdate($invoice, $date, cascadeStockMovements: true);
        } catch (InsufficientStockException $e) {
            $this->skipped['stock']++;
        } catch (RuntimeException $e) {
            $this->skipped['other']++;
        }
    }

    /** @return array{0:Branch,1:?PosSession,2:Carbon} */
    private function pickChannelContext(Collection $branches, array $sessionsByBranch, bool $useTill): array
    {
        if ($useTill) {
            $branch = $branches->random();
            $sessions = $sessionsByBranch[$branch->id] ?? collect();
            $session = $sessions->isNotEmpty() ? $sessions->random() : null;
            $date = $session ? $session->opened_at->copy()->addMinutes(random_int(10, 500)) : $this->recentDate(6);

            return [$branch, $session, $date];
        }

        return [$branches->random(), null, $this->recentDate(6)];
    }

    /**
     * @return array{product_id:int,product_name:string,product_sku:string,quantity:float,unit_price:float,batch_id:int,vat_rate:float,vat_amount:float,line_total:float,serial_ids?:array<int,int>}|null
     */
    private function buildInvoiceLine(Product $product, int $branchId): ?array
    {
        $quantity = 1.0;
        $extra = [];

        // The lot is the price owner — sell from the FEFO-front sellable lot,
        // and price the estimate from it (InvoiceService reprices from the
        // same lot anyway).
        $lot = app(BatchService::class)->sellableFor($product->id, $branchId)->first();
        if (! $lot) {
            return null;
        }

        $unitPrice = (float) $lot->sale_price;

        // Data-driven, not a mode flag: a product sells by serial only when
        // serials are actually on file in that lot.
        $availableSerials = ProductSerial::where('product_id', $product->id)
            ->where('stock_batch_id', $lot->id)
            ->where('status_id', Status::idOf(Status::CTX_PRODUCT_SERIAL, Status::SERIAL_AVAILABLE));

        if ($availableSerials->exists()) {
            $desired = random_int(1, min(2, $availableSerials->count()));
            $serials = $availableSerials->inRandomOrder()->limit($desired)->get();

            if ($serials->isEmpty()) {
                return null;
            }

            $quantity = (float) $serials->count();
            $extra['serial_ids'] = $serials->pluck('id')->all();
        } else {
            $cap = (int) floor((float) $lot->available_quantity);
            if ($cap < 1) {
                return null;
            }

            $perishable = in_array($product->id, CatalogSeeder::$perishableIds, true);
            $max = $perishable ? min(5, $cap) : min(4, $cap);
            $quantity = (float) random_int(1, $max);
        }

        $lineTotal = round($quantity * $unitPrice, 2);
        $vatAmount = round($lineTotal * (float) $product->vat_rate / 100, 2);

        return array_merge([
            'product_id' => $product->id,
            'product_name' => $product->name,
            'product_sku' => $product->sku,
            'quantity' => $quantity,
            'unit_price' => $unitPrice,
            'batch_id' => $lot->id,
            'vat_rate' => (float) $product->vat_rate,
            'vat_amount' => $vatAmount,
            'line_total' => $lineTotal,
        ], $extra);
    }

    private function pickVoucherCode(Customer $customer, float $subtotalEstimate): ?string
    {
        $activeId = Status::idOf(Status::CTX_VOUCHER, Status::VOUCHER_ACTIVE);

        $eligible = Voucher::where('status_id', $activeId)
            ->where(function ($q) use ($customer) {
                $q->whereNull('customer_id')->orWhere('customer_id', $customer->id);
            })
            ->where(function ($q) use ($subtotalEstimate) {
                $q->whereNull('minimum_purchase')->orWhere('minimum_purchase', '<=', $subtotalEstimate);
            })
            ->get()
            ->filter(fn (Voucher $v) => ! $v->isBalanceBased() || (float) $v->remaining_balance > 0)
            ->filter(fn (Voucher $v) => $v->usage_limit === null || $v->used_count < $v->usage_limit);

        return $eligible->isEmpty() ? null : $eligible->random()->code;
    }

    private function pickLoyaltyRedemption(Customer $customer, float $subtotalEstimate): float
    {
        if (! fake()->boolean(15)) {
            return 0.0;
        }

        $program = LoyaltyProgram::where('is_active', true)->first();
        if (! $program) {
            return 0.0;
        }

        $balance = CustomerLoyaltyBalance::where('customer_id', $customer->id)
            ->where('program_id', $program->id)
            ->first();

        $minPoints = (float) $program->minimum_points_to_redeem;
        if (! $balance || (float) $balance->points_balance < $minPoints) {
            return 0.0;
        }

        $maxByCart = $subtotalEstimate * 0.3 * (float) $program->redemption_rate;
        $redeemable = min((float) $balance->points_balance, max($minPoints, $maxByCart));

        return round($redeemable, 2);
    }

    /** @return array{0:string,1:array<int,array<string,mixed>>} */
    private function pickPaymentPlan(float $netTotal): array
    {
        if ($netTotal <= 0.01) {
            return ['full', []];
        }

        $roll = random_int(1, 100);
        $methods = [PaymentMethod::CASH, PaymentMethod::CARD, PaymentMethod::BANK_TRANSFER, PaymentMethod::CHEQUE];

        if ($roll <= 15) {
            return ['none', []];
        }

        if ($roll <= 30) {
            $amount = round($netTotal * fake()->randomFloat(2, 0.25, 0.75), 2);

            return ['partial', [$this->split(fake()->randomElement([PaymentMethod::CASH, PaymentMethod::CARD]), $amount)]];
        }

        // Full payment: single method 80% of the time, two-way split otherwise.
        if (fake()->boolean(80)) {
            $method = fake()->randomElement($methods);

            return ['full', [$this->split($method, $netTotal)]];
        }

        $first = round($netTotal * fake()->randomFloat(2, 0.3, 0.6), 2);
        $second = round($netTotal - $first, 2);
        $methodA = fake()->randomElement([PaymentMethod::CASH, PaymentMethod::CARD]);
        $methodB = fake()->randomElement(array_diff($methods, [$methodA]));

        return ['full', [$this->split($methodA, $first), $this->split($methodB, $second)]];
    }

    /** @return array<string,mixed> */
    private function split(string $method, float $amount): array
    {
        $split = ['method' => $method, 'amount' => $amount];

        if ($method === PaymentMethod::CHEQUE) {
            $split['cheque_number'] = fake()->numerify('CHQ-######');
            $split['cheque_bank'] = fake()->randomElement(['Commercial Bank', 'Sampath Bank', 'HNB', "People's Bank", 'BOC']);
            $split['cheque_branch_code'] = fake()->numerify('####');
            $split['cheque_date'] = now()->addDays(random_int(1, 14))->toDateString();
        } elseif ($method === PaymentMethod::BANK_TRANSFER) {
            $split['reference'] = 'TXN'.fake()->numerify('########');
        }

        return $split;
    }
}
