<?php

namespace App\Domain\Expenses\Services;

use App\Domain\Accounting\Models\LedgerAccount;
use App\Domain\Accounting\Services\LedgerPoster;
use App\Domain\Audit\Enums\ActivityCategory;
use App\Domain\Audit\Services\ActivityLogger;
use App\Domain\Expenses\Enums\BillStatus;
use App\Domain\Expenses\Models\Vendor;
use App\Domain\Expenses\Models\VendorBill;
use App\Domain\Expenses\Models\VendorBillLine;
use App\Domain\Expenses\Models\VendorPayment;
use App\Domain\Identity\Models\User;
use App\Support\Money\Money;
use App\Support\Sequences\SequenceGenerator;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;

/**
 * Vendor bills and what the agency owes on them (§44).
 *
 * Totals are computed from the lines, approval posts the payable, and paying
 * clears it. Overpaying a bill is refused.
 */
class PayableService
{
    public function __construct(
        private readonly SequenceGenerator $sequences,
        private readonly ActivityLogger $activity,
        private readonly LedgerPoster $ledger,
    ) {}

    /**
     * @param  array<int, array<string, mixed>>  $lines
     * @param  array<string, mixed>  $attributes
     *
     * @throws ValidationException
     */
    public function createBill(Vendor $vendor, array $lines, User $actor, array $attributes = []): VendorBill
    {
        if ($lines === []) {
            throw ValidationException::withMessages(['lines' => ['A bill needs at least one line.']]);
        }

        $currency = $attributes['currency'] ?? $vendor->currency;
        $invoiceNo = $attributes['vendor_invoice_no'] ?? null;

        // The vendor's own reference identifies one real bill; entering it
        // twice is how an agency pays the same invoice twice.
        if (filled($invoiceNo) && VendorBill::where('vendor_id', $vendor->getKey())
            ->where('vendor_invoice_no', $invoiceNo)
            ->exists()) {
            throw ValidationException::withMessages([
                'vendor_invoice_no' => ['This bill has already been entered for this vendor.'],
            ]);
        }

        return DB::transaction(function () use ($vendor, $lines, $actor, $attributes, $currency, $invoiceNo) {
            $billDate = $attributes['bill_date'] ?? now()->toDateString();

            $bill = VendorBill::create([
                'bill_no' => $this->nextBillNumber($vendor->company_id),
                'company_id' => $vendor->company_id,
                'branch_id' => $attributes['branch_id'] ?? $actor->branch_id,
                'vendor_id' => $vendor->getKey(),
                'applicant_id' => $attributes['applicant_id'] ?? null,
                'job_demand_id' => $attributes['job_demand_id'] ?? null,
                'vendor_invoice_no' => $invoiceNo,
                'status' => BillStatus::Draft->value,
                'currency' => $currency,
                'bill_date' => $billDate,
                // Payment terms turn into a due date so the aging report works.
                'due_date' => $attributes['due_date']
                    ?? Carbon::parse($billDate)
                        ->addDays($vendor->payment_terms_days)
                        ->toDateString(),
                'notes' => $attributes['notes'] ?? null,
                'created_by' => $actor->getKey(),
            ]);

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

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

    /**
     * @param  array<int, array<string, mixed>>  $lines
     *
     * @throws ValidationException
     */
    public function replaceLines(VendorBill $bill, array $lines): VendorBill
    {
        if (! $bill->status->isEditable()) {
            throw ValidationException::withMessages([
                'bill' => ["A {$bill->status->label()} bill cannot be edited."],
            ]);
        }

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

            $subtotal = 0;
            $tax = 0;

            foreach (array_values($lines) as $index => $line) {
                $quantity = max(1, (int) ($line['quantity'] ?? 1));
                $unit = (int) $line['unit_amount'];
                $lineTax = (int) ($line['tax_amount'] ?? 0);
                $total = $quantity * $unit + $lineTax;

                $account = LedgerAccount::where('company_id', $bill->company_id)
                    ->where('code', $line['account_code'])
                    ->postable()
                    ->first();

                if ($account === null) {
                    throw ValidationException::withMessages([
                        'lines' => ["No postable account with code [{$line['account_code']}]."],
                    ]);
                }

                VendorBillLine::create([
                    'vendor_bill_id' => $bill->getKey(),
                    'ledger_account_id' => $account->getKey(),
                    'applicant_id' => $line['applicant_id'] ?? $bill->applicant_id,
                    'description' => $line['description'],
                    'quantity' => $quantity,
                    'unit_amount' => $unit,
                    'tax_amount' => $lineTax,
                    'line_total' => $total,
                    'currency' => $bill->currency,
                    'sort_order' => $index,
                ]);

                $subtotal += $quantity * $unit;
                $tax += $lineTax;
            }

            // Totals come from the lines, never from the request.
            $bill->subtotal_amount = $subtotal;
            $bill->tax_amount = $tax;
            $bill->total_amount = $subtotal + $tax;
            $bill->save();

            return $bill;
        });
    }

    /**
     * Approving a bill recognises the cost and the payable:
     *
     *   Dr Cost / Expense accounts (per line)
     *       Cr Accounts Payable
     *
     * @throws ValidationException
     */
    public function approve(VendorBill $bill, User $actor): VendorBill
    {
        if (! $bill->status->isEditable()) {
            throw ValidationException::withMessages([
                'bill' => ["A {$bill->status->label()} bill cannot be approved."],
            ]);
        }

        if ($bill->total_amount <= 0) {
            throw ValidationException::withMessages([
                'bill' => ['A bill with no charges cannot be approved.'],
            ]);
        }

        return DB::transaction(function () use ($bill, $actor) {
            $bill->status = BillStatus::Approved;
            $bill->approved_by = $actor->getKey();
            $bill->approved_at = now();
            $bill->save();

            $lines = $bill->lines()->with('account')->get()
                ->map(fn (VendorBillLine $line) => [
                    'account' => $line->account,
                    'debit' => (int) $line->line_total,
                    'applicant_id' => $line->applicant_id,
                    'memo' => $line->description,
                ])
                ->all();

            $lines[] = [
                'account' => '2100',
                'credit' => (int) $bill->total_amount,
                'memo' => $bill->bill_no,
            ];

            $this->ledger->postForEvent(
                $bill->company_id,
                'bill_approved',
                $bill,
                "Bill {$bill->bill_no} from {$bill->vendor->name}",
                $lines,
                $actor,
                [
                    'currency' => $bill->currency,
                    'branch_id' => $bill->branch_id,
                    'entry_date' => $bill->bill_date->toDateString(),
                ],
            );

            $this->activity->log(ActivityCategory::Payment, 'bill.approved', [
                'subject' => $bill->vendor,
                'actor' => $actor,
                'title' => 'Vendor bill approved',
                'description' => $bill->bill_no.' · '.Money::of((int) $bill->total_amount, $bill->currency)->format(),
                'visibility' => 'internal',
            ]);

            return $bill;
        });
    }

    /**
     * Paying a vendor:
     *
     *   Dr Accounts Payable
     *       Cr Cash/Bank
     *
     * @param  array<string, mixed>  $attributes
     *
     * @throws ValidationException
     */
    public function payBill(VendorBill $bill, Money $amount, array $attributes, User $actor): VendorPayment
    {
        if (! $bill->status->acceptsPayment()) {
            throw ValidationException::withMessages([
                'bill' => ["A {$bill->status->label()} bill cannot be paid."],
            ]);
        }

        if ($amount->currency !== $bill->currency) {
            throw ValidationException::withMessages([
                'currency' => ["This bill is in {$bill->currency}, not {$amount->currency}."],
            ]);
        }

        if ($amount->isGreaterThan($bill->outstanding())) {
            throw ValidationException::withMessages([
                'amount' => ["That is more than the {$bill->outstanding()->format()} outstanding."],
            ]);
        }

        $reference = $attributes['transaction_reference'] ?? null;

        if (filled($reference) && VendorPayment::where('company_id', $bill->company_id)
            ->where('transaction_reference', $reference)
            ->exists()) {
            throw ValidationException::withMessages([
                'transaction_reference' => ['This reference has already been recorded.'],
            ]);
        }

        $source = LedgerAccount::where('company_id', $bill->company_id)
            ->where('code', $attributes['paid_from_code'] ?? '1200')
            ->postable()
            ->cash()
            ->first();

        if ($source === null) {
            throw ValidationException::withMessages([
                'paid_from_code' => ['Choose the cash or bank account the money came out of.'],
            ]);
        }

        return DB::transaction(function () use ($bill, $amount, $attributes, $actor, $source, $reference) {
            $payment = VendorPayment::create([
                'payment_no' => $this->nextPaymentNumber($bill->company_id),
                'company_id' => $bill->company_id,
                'branch_id' => $bill->branch_id,
                'vendor_id' => $bill->vendor_id,
                'vendor_bill_id' => $bill->getKey(),
                'paid_from_account_id' => $source->getKey(),
                'amount' => $amount->minorUnits,
                'currency' => $amount->currency,
                'method' => $attributes['method'] ?? 'bank_transfer',
                'transaction_reference' => $reference,
                'paid_on' => $attributes['paid_on'] ?? now()->toDateString(),
                'paid_by' => $actor->getKey(),
                'notes' => $attributes['notes'] ?? null,
            ]);

            $locked = VendorBill::whereKey($bill->getKey())->lockForUpdate()->first();
            $locked->paid_amount = $locked->paid_amount + $amount->minorUnits;
            $locked->status = $locked->paid_amount >= $locked->total_amount
                ? BillStatus::Paid
                : BillStatus::PartiallyPaid;
            $locked->save();

            $this->ledger->postForEvent(
                $bill->company_id,
                'vendor_payment',
                $payment,
                "Payment {$payment->payment_no} to {$bill->vendor->name}",
                [
                    [
                        'account' => '2100',
                        'debit' => $amount->minorUnits,
                        'memo' => $bill->bill_no,
                    ],
                    [
                        'account' => $source,
                        'credit' => $amount->minorUnits,
                        'memo' => $payment->payment_no,
                    ],
                ],
                $actor,
                [
                    'currency' => $amount->currency,
                    'branch_id' => $bill->branch_id,
                    'entry_date' => $payment->paid_on->toDateString(),
                ],
            );

            $this->activity->log(ActivityCategory::Payment, 'bill.paid', [
                'subject' => $bill->vendor,
                'actor' => $actor,
                'title' => 'Vendor paid',
                'description' => $amount->format()." · {$bill->bill_no}",
                'visibility' => 'internal',
            ]);

            return $payment;
        });
    }

    /**
     * Payables aging (§47.11): what is owed, bucketed by how late it is.
     *
     * @return array<string, mixed>
     */
    public function aging(int $companyId): array
    {
        $buckets = ['current' => 0, '1_30' => 0, '31_60' => 0, '61_90' => 0, 'over_90' => 0];

        $bills = VendorBill::where('company_id', $companyId)
            ->outstanding()
            ->with('vendor:id,uuid,name,vendor_code')
            ->get();

        $rows = $bills->map(function (VendorBill $bill) use (&$buckets) {
            $due = $bill->outstanding()->minorUnits;
            $days = $bill->daysOverdue();

            $bucket = match (true) {
                $days === 0 => 'current',
                $days <= 30 => '1_30',
                $days <= 60 => '31_60',
                $days <= 90 => '61_90',
                default => 'over_90',
            };

            $buckets[$bucket] += $due;

            return [
                'bill_no' => $bill->bill_no,
                'vendor' => $bill->vendor?->name,
                'due_date' => $bill->due_date?->toDateString(),
                'days_overdue' => $days,
                'bucket' => $bucket,
                'outstanding' => $due,
                'currency' => $bill->currency,
            ];
        });

        return [
            'buckets' => $buckets,
            'total' => array_sum($buckets),
            'bills' => $rows->sortByDesc('days_overdue')->values(),
        ];
    }

    private function nextBillNumber(int $companyId): string
    {
        return $this->sequences->nextUnique(
            'vendor_bill',
            'BIL',
            [now()->year],
            6,
            fn (string $no) => VendorBill::where('company_id', $companyId)->where('bill_no', $no)->exists(),
        );
    }

    private function nextPaymentNumber(int $companyId): string
    {
        return $this->sequences->nextUnique(
            'vendor_payment',
            'VPY',
            [now()->year],
            6,
            fn (string $no) => VendorPayment::where('company_id', $companyId)
                ->where('payment_no', $no)
                ->exists(),
        );
    }
}
