<?php

namespace App\Domain\Billing\Services;

use App\Domain\Accounting\Services\BusinessEventPoster;
use App\Domain\Applicants\Models\Applicant;
use App\Domain\Audit\Enums\ActivityCategory;
use App\Domain\Audit\Services\ActivityLogger;
use App\Domain\Billing\Enums\InstallmentStatus;
use App\Domain\Billing\Enums\InvoiceStatus;
use App\Domain\Billing\Models\Installment;
use App\Domain\Billing\Models\Invoice;
use App\Domain\Billing\Models\InvoiceLine;
use App\Domain\Identity\Models\User;
use App\Domain\Organization\Models\Branch;
use App\Support\Money\Money;
use App\Support\Sequences\SequenceGenerator;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;

class InvoiceService
{
    public function __construct(
        private readonly SequenceGenerator $sequences,
        private readonly ActivityLogger $activity,
        private readonly BusinessEventPoster $books,
    ) {}

    /**
     * @param  array<int, array<string, mixed>>  $lines
     *
     * @throws ValidationException
     */
    public function create(Applicant $applicant, string $currency, array $lines, User $actor, array $attributes = []): Invoice
    {
        if ($lines === []) {
            throw ValidationException::withMessages([
                'lines' => ['An invoice needs at least one charge.'],
            ]);
        }

        return DB::transaction(function () use ($applicant, $currency, $lines, $actor, $attributes) {
            $invoice = Invoice::create([
                'invoice_no' => $this->nextNumber($applicant->company_id, $applicant->branch_id),
                'company_id' => $applicant->company_id,
                'branch_id' => $applicant->branch_id,
                'applicant_id' => $applicant->getKey(),
                'job_demand_id' => $applicant->job_demand_id,
                'status' => InvoiceStatus::Draft->value,
                'currency' => strtoupper($currency),
                'due_on' => $attributes['due_on'] ?? null,
                'notes' => $attributes['notes'] ?? null,
                'created_by' => $actor->getKey(),
            ]);

            $this->replaceLines($invoice, $lines);

            $this->activity->log(ActivityCategory::Invoice, 'invoice.created', [
                'subject' => $applicant,
                'actor' => $actor,
                'title' => 'Invoice drafted',
                'description' => $invoice->invoice_no.' · '.$invoice->total()->format(),
            ]);

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

    /**
     * Replaces the charges and recomputes the totals from them. Totals are
     * never accepted from the client (§70 rule 7).
     *
     * @param  array<int, array<string, mixed>>  $lines
     *
     * @throws ValidationException
     */
    public function replaceLines(Invoice $invoice, array $lines): Invoice
    {
        if (! $invoice->status->isEditable()) {
            throw ValidationException::withMessages([
                'invoice' => ["A {$invoice->status->label()} invoice cannot be edited. Cancel it and raise a new one."],
            ]);
        }

        return DB::transaction(function () use ($invoice, $lines) {
            $invoice->lines()->delete();

            $subtotal = Money::zero($invoice->currency);
            $discount = Money::zero($invoice->currency);
            $tax = Money::zero($invoice->currency);
            $total = Money::zero($invoice->currency);

            foreach (array_values($lines) as $index => $line) {
                $record = new InvoiceLine([
                    'component' => $line['component'] ?? 'service',
                    'description' => $line['description'],
                    'quantity' => max(1, (int) ($line['quantity'] ?? 1)),
                    'unit_amount' => (int) $line['unit_amount'],
                    'discount_amount' => (int) ($line['discount_amount'] ?? 0),
                    'tax_amount' => (int) ($line['tax_amount'] ?? 0),
                    'external_cost_amount' => (int) ($line['external_cost_amount'] ?? 0),
                    'is_waived' => (bool) ($line['is_waived'] ?? false),
                    'waiver_reason' => $line['waiver_reason'] ?? null,
                    'sort_order' => $index,
                ]);

                $lineTotal = $record->computeTotal($invoice->currency);
                $record->line_total = $lineTotal->minorUnits;

                $gross = Money::of($record->unit_amount, $invoice->currency)->times($record->quantity);

                $subtotal = $subtotal->plus($gross);
                $discount = $discount->plus(Money::of($record->discount_amount, $invoice->currency));
                $tax = $tax->plus(Money::of($record->tax_amount, $invoice->currency));
                $total = $total->plus($lineTotal);

                $invoice->lines()->save($record);
            }

            $invoice->update([
                'subtotal_amount' => $subtotal->minorUnits,
                'discount_amount' => $discount->minorUnits,
                'tax_amount' => $tax->minorUnits,
                'total_amount' => $total->minorUnits,
            ]);

            return $invoice;
        });
    }

    /**
     * Sets the installment plan (§28). The plan must add up to the invoice
     * total — an applicant asked to pay a schedule that does not reconcile is
     * how disputes start.
     *
     * @param  array<int, array<string, mixed>>  $installments
     *
     * @throws ValidationException
     */
    public function setInstallments(Invoice $invoice, array $installments, User $actor): Invoice
    {
        if (! $invoice->status->isEditable()) {
            throw ValidationException::withMessages([
                'invoice' => ['Installments can only be set while the invoice is a draft.'],
            ]);
        }

        $planned = array_reduce(
            $installments,
            fn (Money $carry, array $item) => $carry->plus(Money::of((int) $item['amount'], $invoice->currency)),
            Money::zero($invoice->currency),
        );

        if (! $planned->equals($invoice->total())) {
            throw ValidationException::withMessages([
                'installments' => [
                    "The plan totals {$planned->format()} but the invoice is {$invoice->total()->format()}.",
                ],
            ]);
        }

        return DB::transaction(function () use ($invoice, $installments, $actor) {
            $invoice->installments()->delete();

            foreach (array_values($installments) as $index => $item) {
                $invoice->installments()->create([
                    'sequence' => $index + 1,
                    'label' => $item['label'],
                    'due_trigger' => $item['due_trigger'] ?? null,
                    'due_on' => $item['due_on'] ?? null,
                    'amount' => (int) $item['amount'],
                    'status' => InstallmentStatus::Upcoming->value,
                    'blocking_stage_id' => $item['blocking_stage_id'] ?? null,
                ]);
            }

            $this->activity->log(ActivityCategory::Invoice, 'invoice.plan_set', [
                'subject' => $invoice->applicant,
                'actor' => $actor,
                'title' => 'Payment plan set',
                'description' => count($installments).' installments · '.$invoice->total()->format(),
                'visibility' => 'shared',
            ]);

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

    /** @throws ValidationException */
    public function issue(Invoice $invoice, User $actor): Invoice
    {
        if ($invoice->status !== InvoiceStatus::Draft) {
            throw ValidationException::withMessages([
                'invoice' => ["This invoice is already {$invoice->status->label()}."],
            ]);
        }

        if ($invoice->total()->isZero()) {
            throw ValidationException::withMessages([
                'invoice' => ['An invoice with no charges cannot be issued.'],
            ]);
        }

        return DB::transaction(function () use ($invoice, $actor) {
            $invoice->update([
                'status' => InvoiceStatus::Issued->value,
                'issued_on' => now()->toDateString(),
                'issued_at' => now(),
                'issued_by' => $actor->getKey(),
            ]);

            // The first installment becomes due the moment the invoice is issued.
            $invoice->installments()->where('sequence', 1)->update([
                'status' => InstallmentStatus::Due->value,
            ]);

            $this->activity->log(ActivityCategory::Invoice, 'invoice.issued', [
                'subject' => $invoice->applicant,
                'actor' => $actor,
                'title' => 'Invoice issued',
                'description' => $invoice->invoice_no.' · '.$invoice->total()->format(),
                'visibility' => 'shared',
            ]);

            // Issuing is what recognises the revenue and the receivable (§35).
            $this->books->invoiceIssued($invoice->fresh(), $actor);

            return $invoice;
        });
    }

    /** @throws ValidationException */
    public function cancel(Invoice $invoice, string $reason, User $actor): Invoice
    {
        if (blank($reason)) {
            throw ValidationException::withMessages([
                'reason' => ['Say why the invoice is being cancelled.'],
            ]);
        }

        // Money already taken has to be refunded, not wished away.
        if (! $invoice->paid()->isZero()) {
            throw ValidationException::withMessages([
                'invoice' => ['This invoice has payments against it; refund them before cancelling.'],
            ]);
        }

        return DB::transaction(function () use ($invoice, $reason, $actor) {
            $invoice->update([
                'status' => InvoiceStatus::Cancelled->value,
                'status_reason' => $reason,
            ]);

            $this->activity->log(ActivityCategory::Invoice, 'invoice.cancelled', [
                'subject' => $invoice->applicant,
                'actor' => $actor,
                'title' => 'Invoice cancelled',
                'description' => $invoice->invoice_no,
                'metadata' => ['reason' => $reason],
            ]);

            return $invoice;
        });
    }

    private function nextNumber(int $companyId, ?int $branchId): string
    {
        $branchCode = $branchId !== null
            ? (Branch::whereKey($branchId)->value('code') ?? 'HO')
            : 'HO';

        return $this->sequences->nextUnique(
            'invoice',
            'INV',
            [$branchCode, now()->year],
            6,
            fn (string $no) => Invoice::where('company_id', $companyId)->where('invoice_no', $no)->exists(),
        );
    }
}
