<?php

namespace App\Domain\Payroll\Services;

use App\Domain\Accounting\Services\LedgerPoster;
use App\Domain\Audit\Enums\ActivityCategory;
use App\Domain\Audit\Services\ActivityLogger;
use App\Domain\HR\Models\Employee;
use App\Domain\Identity\Models\User;
use App\Domain\Payroll\Enums\ComponentType;
use App\Domain\Payroll\Enums\LoanStatus;
use App\Domain\Payroll\Enums\PayrollStatus;
use App\Domain\Payroll\Models\EmployeeLoan;
use App\Domain\Payroll\Models\PayrollRun;
use App\Domain\Payroll\Models\Payslip;
use App\Domain\Payroll\Models\PayslipLine;
use App\Domain\Payroll\Models\SalaryComponent;
use App\Support\Money\Money;
use App\Support\Sequences\SequenceGenerator;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;

/**
 * Running payroll (§51, §53).
 *
 * A run is recalculable until it is approved, and posting it is a single
 * balanced journal. Loan recovery is only written to the loan when the run is
 * actually paid, so a recalculated draft cannot recover the same money twice.
 */
class PayrollService
{
    public function __construct(
        private readonly SequenceGenerator $sequences,
        private readonly ActivityLogger $activity,
        private readonly PayrollCalculator $calculator,
        private readonly LedgerPoster $ledger,
    ) {}

    /** @throws ValidationException */
    public function open(
        int $companyId,
        ?int $branchId,
        Carbon $periodStart,
        Carbon $periodEnd,
        User $actor,
    ): PayrollRun {
        if ($periodEnd->isBefore($periodStart)) {
            throw ValidationException::withMessages([
                'period_end' => ['The period ends before it starts.'],
            ]);
        }

        $existing = PayrollRun::where('company_id', $companyId)
            ->where('branch_id', $branchId)
            ->whereDate('period_start', $periodStart->toDateString())
            ->whereDate('period_end', $periodEnd->toDateString())
            ->whereNot('status', PayrollStatus::Cancelled->value)
            ->exists();

        if ($existing) {
            throw ValidationException::withMessages([
                'period_start' => ['A payroll already exists for that period.'],
            ]);
        }

        return PayrollRun::create([
            'run_no' => $this->nextRunNumber($companyId),
            'company_id' => $companyId,
            'branch_id' => $branchId,
            'period_label' => $periodStart->format('F Y'),
            'period_start' => $periodStart->toDateString(),
            'period_end' => $periodEnd->toDateString(),
            'status' => PayrollStatus::Draft->value,
            'currency' => config('mams.money.base_currency'),
            'created_by' => $actor->getKey(),
        ]);
    }

    /**
     * Builds a payslip for every eligible employee, replacing whatever the
     * previous calculation produced.
     *
     * @param  array<int, array<string, mixed>>  $attendance  keyed by employee id
     *
     * @throws ValidationException
     */
    public function calculate(PayrollRun $run, User $actor, array $attendance = []): PayrollRun
    {
        if (! $run->status->isRecalculable()) {
            throw ValidationException::withMessages([
                'run' => ["A {$run->status->label()} payroll cannot be recalculated."],
            ]);
        }

        $employees = Employee::where('company_id', $run->company_id)
            ->when($run->branch_id !== null, fn ($q) => $q->where('branch_id', $run->branch_id))
            ->where('status', 'active')
            ->get();

        if ($employees->isEmpty()) {
            throw ValidationException::withMessages([
                'run' => ['There are no active employees to pay.'],
            ]);
        }

        return DB::transaction(function () use ($run, $actor, $attendance, $employees) {
            // A recalculation starts from scratch rather than patching, so the
            // figures always match the current structures.
            $run->payslips()->each(fn (Payslip $payslip) => $payslip->delete());

            $gross = 0;
            $deductions = 0;
            $contributions = 0;
            $net = 0;
            $count = 0;

            foreach ($employees as $employee) {
                $result = $this->calculator->forEmployee(
                    $employee,
                    $run->period_end->toDateString(),
                    $attendance[$employee->getKey()] ?? [],
                );

                if ($result === null) {
                    continue;
                }

                $payslip = Payslip::create([
                    'payslip_no' => $this->nextPayslipNumber($run->company_id),
                    'company_id' => $run->company_id,
                    'payroll_run_id' => $run->getKey(),
                    'employee_id' => $employee->getKey(),
                    'currency' => $result['currency'],
                    'basic_salary' => $result['basic'],
                    'gross_earnings' => $result['gross'],
                    'total_deductions' => $result['deductions'],
                    'employer_contributions' => $result['contributions'],
                    'net_pay' => $result['net'],
                    'days_absent' => $result['days_absent'],
                    'overtime_hours' => $result['overtime_hours'],
                ]);

                foreach ($result['lines'] as $line) {
                    PayslipLine::create(array_merge($line, [
                        'payslip_id' => $payslip->getKey(),
                        'currency' => $result['currency'],
                    ]));
                }

                $gross += $result['gross'];
                $deductions += $result['deductions'];
                $contributions += $result['contributions'];
                $net += $result['net'];
                $count++;
            }

            $run->update([
                'status' => PayrollStatus::Calculated->value,
                'gross_earnings' => $gross,
                'total_deductions' => $deductions,
                'employer_contributions' => $contributions,
                'net_payable' => $net,
                'employee_count' => $count,
                'calculated_by' => $actor->getKey(),
                'calculated_at' => now(),
            ]);

            $this->activity->log(ActivityCategory::Payment, 'payroll.calculated', [
                'subject' => $run,
                'actor' => $actor,
                'title' => 'Payroll calculated',
                'description' => "{$run->period_label} · {$count} employees · ".
                    Money::of($net, $run->currency)->format(),
                'visibility' => 'internal',
            ]);

            return $run->fresh();
        });
    }

    /** @throws ValidationException */
    public function approve(PayrollRun $run, User $actor): PayrollRun
    {
        if (! in_array($run->status, [PayrollStatus::Calculated, PayrollStatus::Reviewed], true)) {
            throw ValidationException::withMessages([
                'run' => ["A {$run->status->label()} payroll cannot be approved."],
            ]);
        }

        // Whoever ran the numbers should not be the only person who saw them.
        if ($run->calculated_by === $actor->getKey() && ! $actor->can('payroll.pay')) {
            throw ValidationException::withMessages([
                'run' => ['Payroll should be approved by someone other than whoever calculated it.'],
            ]);
        }

        $run->update([
            'status' => PayrollStatus::Approved->value,
            'approved_by' => $actor->getKey(),
            'approved_at' => now(),
        ]);

        $this->activity->log(ActivityCategory::Payment, 'payroll.approved', [
            'subject' => $run,
            'actor' => $actor,
            'title' => 'Payroll approved',
            'description' => $run->period_label.' · '.$run->net()->format(),
            'visibility' => 'internal',
        ]);

        return $run;
    }

    /**
     * Posts the payroll (§53):
     *
     *   Dr Salary Expense, Dr Contribution Expense
     *       Cr Salary Payable, Cr Tax Payable, Cr other payables
     *
     * @throws ValidationException
     */
    public function post(PayrollRun $run, User $actor): PayrollRun
    {
        if ($run->status !== PayrollStatus::Approved) {
            throw ValidationException::withMessages([
                'run' => ['Only an approved payroll can be posted.'],
            ]);
        }

        if ($run->net_payable <= 0) {
            throw ValidationException::withMessages([
                'run' => ['There is nothing to post.'],
            ]);
        }

        return DB::transaction(function () use ($run, $actor) {
            $lines = $this->journalLines($run);

            $this->ledger->postForEvent(
                $run->company_id,
                'payroll_posted',
                $run,
                "Payroll {$run->run_no} for {$run->period_label}",
                $lines,
                $actor,
                [
                    'currency' => $run->currency,
                    'branch_id' => $run->branch_id,
                    'entry_date' => $run->period_end->toDateString(),
                ],
            );

            $run->update([
                'status' => PayrollStatus::Posted->value,
                'posted_by' => $actor->getKey(),
                'posted_at' => now(),
            ]);

            return $run;
        });
    }

    /**
     * Pays the net and clears the payable, then records loan recovery.
     *
     * @param  array<string, mixed>  $attributes
     *
     * @throws ValidationException
     */
    public function pay(PayrollRun $run, array $attributes, User $actor): PayrollRun
    {
        if ($run->status !== PayrollStatus::Posted) {
            throw ValidationException::withMessages([
                'run' => ['Payroll must be posted to accounts before it is paid.'],
            ]);
        }

        $sourceCode = $attributes['paid_from_code'] ?? '1200';

        return DB::transaction(function () use ($run, $attributes, $actor, $sourceCode) {
            $this->ledger->postForEvent(
                $run->company_id,
                'payroll_paid',
                $run,
                "Salaries paid for {$run->period_label}",
                [
                    [
                        'account' => '2300',
                        'debit' => (int) $run->net_payable,
                        'memo' => $run->run_no,
                    ],
                    [
                        'account' => $sourceCode,
                        'credit' => (int) $run->net_payable,
                        'memo' => $run->run_no,
                    ],
                ],
                $actor,
                [
                    'currency' => $run->currency,
                    'branch_id' => $run->branch_id,
                    'entry_date' => $attributes['pay_date'] ?? now()->toDateString(),
                ],
            );

            // Recovery is written now, not at calculation: a draft that was
            // recalculated three times must not recover three installments.
            $this->recordLoanRecovery($run);

            $run->payslips()->update([
                'payment_method' => $attributes['payment_method'] ?? null,
                'payment_reference' => $attributes['payment_reference'] ?? null,
                'paid_on' => $attributes['pay_date'] ?? now()->toDateString(),
            ]);

            $run->update([
                'status' => PayrollStatus::Paid->value,
                'pay_date' => $attributes['pay_date'] ?? now()->toDateString(),
                'paid_by' => $actor->getKey(),
                'paid_at' => now(),
            ]);

            $this->activity->log(ActivityCategory::Payment, 'payroll.paid', [
                'subject' => $run,
                'actor' => $actor,
                'title' => 'Salaries paid',
                'description' => $run->period_label.' · '.$run->net()->format(),
                'visibility' => 'internal',
            ]);

            return $run->fresh();
        });
    }

    /**
     * Sums the payslip lines into the accounts they belong in.
     *
     * @return array<int, array<string, mixed>>
     */
    private function journalLines(PayrollRun $run): array
    {
        $debits = [];
        $credits = [];

        $components = SalaryComponent::where('company_id', $run->company_id)
            ->with(['expenseAccount', 'payableAccount'])
            ->get()
            ->keyBy('code');

        foreach ($run->payslips()->with('lines')->get() as $payslip) {
            foreach ($payslip->lines as $line) {
                $type = ComponentType::from($line->component_type);

                if ($type === ComponentType::Informational) {
                    continue;
                }

                $component = $components->get($line->component_code);

                if ($type === ComponentType::Earning) {
                    // Earnings are a cost to the agency.
                    $account = $component?->expenseAccount?->code ?? '6100';
                    $debits[$account] = ($debits[$account] ?? 0) + $line->amount;

                    continue;
                }

                if ($type === ComponentType::EmployerContribution) {
                    $expense = $component?->expenseAccount?->code ?? '6100';
                    $payable = $component?->payableAccount?->code ?? '2200';
                    $debits[$expense] = ($debits[$expense] ?? 0) + $line->amount;
                    $credits[$payable] = ($credits[$payable] ?? 0) + $line->amount;

                    continue;
                }

                // A deduction is money withheld: it never reaches the employee,
                // so it moves from what is payable to them into its own account.
                $payable = $component?->payableAccount?->code ?? '2900';
                $credits[$payable] = ($credits[$payable] ?? 0) + $line->amount;
            }
        }

        // Whatever is left after deductions is what the agency owes staff.
        $credits['2300'] = ($credits['2300'] ?? 0) + (int) $run->net_payable;

        $lines = [];

        foreach ($debits as $code => $amount) {
            $lines[] = ['account' => (string) $code, 'debit' => $amount, 'memo' => $run->period_label];
        }

        foreach ($credits as $code => $amount) {
            $lines[] = ['account' => (string) $code, 'credit' => $amount, 'memo' => $run->period_label];
        }

        return $lines;
    }

    /** Moves each loan on by whatever the payslip actually deducted. */
    private function recordLoanRecovery(PayrollRun $run): void
    {
        foreach ($run->payslips()->with('lines')->get() as $payslip) {
            $recovered = $payslip->lines
                ->where('component_code', 'loan_recovery')
                ->sum('amount');

            if ($recovered === 0) {
                continue;
            }

            $loans = EmployeeLoan::where('employee_id', $payslip->employee_id)
                ->recovering()
                ->orderBy('id')
                ->get();

            foreach ($loans as $loan) {
                if ($recovered <= 0) {
                    break;
                }

                $take = min($recovered, $loan->outstanding()->minorUnits);
                $loan->recovered_amount += $take;

                if ($loan->recovered_amount >= $loan->principal_amount) {
                    $loan->status = LoanStatus::Settled;
                }

                $loan->save();
                $recovered -= $take;
            }
        }
    }

    private function nextRunNumber(int $companyId): string
    {
        return $this->sequences->nextUnique(
            'payroll_run',
            'PRN',
            [now()->year],
            5,
            fn (string $no) => PayrollRun::where('company_id', $companyId)->where('run_no', $no)->exists(),
        );
    }

    private function nextPayslipNumber(int $companyId): string
    {
        return $this->sequences->nextUnique(
            'payslip',
            'PSL',
            [now()->year],
            6,
            fn (string $no) => Payslip::where('company_id', $companyId)->where('payslip_no', $no)->exists(),
        );
    }
}
