<?php

namespace App\Domain\Financial\Services;

use App\Domain\Financial\Enums\InvoiceStatus;
use App\Domain\Financial\Events\InvoiceCancelled;
use App\Domain\Financial\Events\InvoiceCreated;
use App\Domain\Financial\Events\InvoicePaid;
use App\Domain\Financial\Models\Invoice;
use App\Domain\Financial\Models\InvoiceItem;
use App\Domain\Participant\Models\Participant;
use App\Models\User;
use Illuminate\Support\Facades\DB;

class InvoiceService
{
    public function create(array $data, array $items, User $creator): Invoice
    {
        return DB::transaction(function () use ($data, $items, $creator) {
            $academyId = $data['academy_id'] ?? app('current_academy')->id;

            $invoice = Invoice::create([
                ...$data,
                'academy_id' => $academyId,
                'branch_id' => $data['branch_id'] ?? $this->resolveBranchId($data),
                'number' => $data['number'] ?? $this->generateNumber($academyId),
                'issue_date' => $data['issue_date'] ?? now()->toDateString(),
                'due_date' => $data['due_date'] ?? $data['issue_date'] ?? now()->toDateString(),
                'status' => InvoiceStatus::Draft,
                'created_by' => $creator->id,
                'due_amount' => $data['total_amount'] ?? 0,
            ]);

            foreach ($items as $item) {
                $item['total_amount'] = ($item['quantity'] * $item['unit_price'])
                    - ($item['discount_amount'] ?? 0)
                    + ($item['tax_amount'] ?? 0);

                // Lines are a tenant table now; stamp the academy so a portal
                // query can scope them directly instead of joining upwards.
                $item['academy_id'] = $academyId;

                $invoice->items()->create($item);
            }

            $invoice->recalculateTotals();

            InvoiceCreated::dispatch($invoice, $creator);

            return $invoice->fresh(['items']);
        });
    }

    public function addItem(Invoice $invoice, array $itemData): InvoiceItem
    {
        return DB::transaction(function () use ($invoice, $itemData) {
            $itemData['total_amount'] = ($itemData['quantity'] * $itemData['unit_price'])
                - ($itemData['discount_amount'] ?? 0)
                + ($itemData['tax_amount'] ?? 0);

            $itemData['academy_id'] = $invoice->academy_id;

            $item = $invoice->items()->create($itemData);
            $invoice->recalculateTotals();

            return $item;
        });
    }

    public function cancel(Invoice $invoice, User $user): Invoice
    {
        return DB::transaction(function () use ($invoice, $user) {
            if ($invoice->paid_amount > 0) {
                throw new \App\Domain\Shared\Exceptions\DomainException('لا يمكن إلغاء فاتورة بها مدفوعات');
            }

            if (!in_array($invoice->status, [InvoiceStatus::Draft, InvoiceStatus::Sent, InvoiceStatus::Overdue])) {
                throw new \App\Domain\Shared\Exceptions\DomainException('لا يمكن إلغاء فاتورة بحالة: ' . $invoice->status->label());
            }

            $invoice->update([
                'status' => InvoiceStatus::Cancelled,
                'cancelled_at' => now(),
            ]);

            InvoiceCancelled::dispatch($invoice, $user);

            return $invoice;
        });
    }

    public function markAsPaid(Invoice $invoice, User $actor): void
    {
        $invoice->update([
            'status' => InvoiceStatus::Paid,
            'paid_at' => now(),
            'due_amount' => 0,
        ]);

        InvoicePaid::dispatch($invoice, $actor);
    }

    /**
     * Move an invoice's paid/due balance by $amount (negative to reverse).
     *
     * This is a read-modify-write on money, so it re-reads the row under
     * `lockForUpdate()` inside a transaction: two settlements landing at the
     * same moment previously each read the old paid_amount and one increment
     * was silently lost. The instance the caller handed in is refreshed from
     * the locked row so it does not go on holding a stale balance.
     */
    public function updatePaidAmount(Invoice $invoice, int $amount, User $actor): void
    {
        DB::transaction(function () use ($invoice, $amount, $actor) {
            $locked = Invoice::withoutGlobalScopes()
                ->where('id', $invoice->id)
                ->lockForUpdate()
                ->firstOrFail();

            $locked->paid_amount += $amount;
            $locked->due_amount = $locked->total_amount - $locked->paid_amount;
            $locked->collected_by = $actor->id;

            if ($locked->due_amount <= 0) {
                $locked->status = $locked->due_amount < 0
                    ? InvoiceStatus::Overpaid
                    : InvoiceStatus::Paid;
                $locked->paid_at = now();
            } else {
                $locked->status = InvoiceStatus::PartiallyPaid;
            }

            $locked->save();

            $invoice->setRawAttributes($locked->getAttributes(), true);

            if ($locked->status === InvoiceStatus::Paid) {
                InvoicePaid::dispatch($locked, $actor);
            }
        });
    }

    /**
     * Allocate the next invoice number for an academy.
     *
     * The old implementation was `count() + 1` against a UNIQUE(academy_id,
     * number) index — two invoices created concurrently counted the same rows
     * and the second insert died on the constraint. The counter row is taken
     * with `lockForUpdate()` instead, so concurrent callers queue rather than
     * collide, and the sequence never revisits a number after a soft delete.
     */
    public function generateNumber(int $academyId): string
    {
        return DB::transaction(function () use ($academyId) {
            $row = DB::table('invoice_number_counters')
                ->where('academy_id', $academyId)
                ->lockForUpdate()
                ->first();

            if (! $row) {
                // First invoice for this academy on an installation that has
                // not been through the seeding migration (a brand-new tenant).
                $highest = $this->highestExistingNumber($academyId);

                DB::table('invoice_number_counters')->insert([
                    'academy_id' => $academyId,
                    'next_number' => $highest + 1,
                    'created_at' => now(),
                    'updated_at' => now(),
                ]);

                $row = DB::table('invoice_number_counters')
                    ->where('academy_id', $academyId)
                    ->lockForUpdate()
                    ->first();
            }

            $next = (int) $row->next_number;

            DB::table('invoice_number_counters')
                ->where('academy_id', $academyId)
                ->update(['next_number' => $next + 1, 'updated_at' => now()]);

            return 'INV-' . str_pad((string) $next, 6, '0', STR_PAD_LEFT);
        });
    }

    /**
     * The branch an invoice belongs to when the caller did not name one.
     *
     * `invoices` is strict-scoped, so a null branch is not "every branch" — it
     * is a row that shows on no branch's invoice list, ageing report or
     * outstanding total while still counting in the academy figures. The
     * creating hook covers the request paths; this covers the queue and console
     * ones, where there is no active branch to stamp, by reading the branch off
     * the member being billed.
     *
     * The participant is read without the branch scope on purpose: we are
     * asking which branch this member belongs to, and the answer must not
     * depend on which branch the writer is looking at.
     */
    private function resolveBranchId(array $data): ?int
    {
        $billableId = $data['billable_id'] ?? null;
        $billableType = $data['billable_type'] ?? null;

        if (! $billableId || $billableType !== Participant::class) {
            return null;
        }

        return Participant::withoutBranchScope()->find($billableId)?->branch_id;
    }

    private function highestExistingNumber(int $academyId): int
    {
        $numbers = Invoice::withoutGlobalScopes()
            ->withTrashed()
            ->where('academy_id', $academyId)
            ->where('number', 'like', 'INV-%')
            ->pluck('number');

        $highest = 0;

        foreach ($numbers as $number) {
            if (preg_match('/(\d+)$/', (string) $number, $m)) {
                $highest = max($highest, (int) $m[1]);
            }
        }

        return $highest;
    }
}
