<?php

namespace App\Domain\Payroll\Services;

use App\Domain\HR\Models\Employee;
use App\Domain\Payroll\Enums\CalculationMode;
use App\Domain\Payroll\Enums\ComponentType;
use App\Domain\Payroll\Models\EmployeeLoan;
use App\Domain\Payroll\Models\EmployeeSalaryLine;
use App\Domain\Payroll\Models\EmployeeSalaryStructure;
use App\Support\Money\Money;

/**
 * Works out one employee's pay for one period (§51, §52).
 *
 * Everything is integer arithmetic on minor units, and percentages are basis
 * points, so no rounding drift can creep in (§112). Percentage-of-gross runs
 * in a second pass because it cannot be known until the earnings are settled.
 */
class PayrollCalculator
{
    /**
     * @param  array<string, mixed>  $attendance  days_absent, overtime_hours
     * @return array{
     *     basic: int, gross: int, deductions: int, contributions: int, net: int,
     *     lines: array<int, array<string, mixed>>, currency: string
     * }
     */
    public function forEmployee(Employee $employee, string $periodEnd, array $attendance = []): ?array
    {
        $structure = EmployeeSalaryStructure::where('employee_id', $employee->getKey())
            ->effectiveOn($periodEnd)
            ->with('lines.component')
            ->orderByDesc('effective_from')
            ->first();

        // No structure means nobody has said what this person is paid, which is
        // a gap to be filled rather than a zero payslip to be issued.
        if ($structure === null) {
            return null;
        }

        $currency = $structure->currency;
        $basic = (int) $structure->basic_salary;
        $daysAbsent = max(0, (int) ($attendance['days_absent'] ?? 0));
        $overtimeHours = max(0, (int) ($attendance['overtime_hours'] ?? 0));

        $lines = [];
        $earnings = $basic;
        $deductions = 0;
        $contributions = 0;

        // The basic itself is always the first line of the payslip.
        $lines[] = [
            'component_code' => 'basic',
            'component_name' => 'Basic Salary',
            'component_type' => ComponentType::Earning->value,
            'amount' => $basic,
            'basis' => null,
            'sort_order' => 0,
            'salary_component_id' => null,
        ];

        $deferred = [];

        foreach ($structure->lines as $line) {
            $component = $line->component;

            if ($component === null || ! $component->is_active) {
                continue;
            }

            // Held back until the gross is known.
            if ($component->calculation_mode->dependsOnGross()) {
                $deferred[] = $line;

                continue;
            }

            $amount = $this->amountFor($line, $basic, 0, $daysAbsent, $overtimeHours);

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

            $lines[] = $this->line($line, $amount, $basic, $daysAbsent, $overtimeHours);

            match ($component->component_type) {
                ComponentType::Earning => $earnings += $amount,
                ComponentType::Deduction => $deductions += $amount,
                ComponentType::EmployerContribution => $contributions += $amount,
                ComponentType::Informational => null,
            };
        }

        // Second pass: percentage-of-gross now has a gross to work from.
        foreach ($deferred as $line) {
            $amount = $this->amountFor($line, $basic, $earnings, $daysAbsent, $overtimeHours);

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

            $lines[] = $this->line($line, $amount, $basic, $daysAbsent, $overtimeHours);

            match ($line->component->component_type) {
                ComponentType::Earning => $earnings += $amount,
                ComponentType::Deduction => $deductions += $amount,
                ComponentType::EmployerContribution => $contributions += $amount,
                ComponentType::Informational => null,
            };
        }

        // Loan recovery comes off last, and never takes more than is left owing.
        foreach ($this->loanDeductions($employee, $currency) as $loan) {
            $lines[] = $loan['line'];
            $deductions += $loan['amount'];
        }

        return [
            'basic' => $basic,
            'gross' => $earnings,
            'deductions' => $deductions,
            'contributions' => $contributions,
            // Net can never go below zero: a month's deductions cannot leave an
            // employee owing the agency money out of that month's pay.
            'net' => max(0, $earnings - $deductions),
            'lines' => $lines,
            'currency' => $currency,
            'days_absent' => $daysAbsent,
            'overtime_hours' => $overtimeHours,
        ];
    }

    /**
     * The recovery lines for every loan currently being repaid.
     *
     * @return array<int, array{line: array<string, mixed>, amount: int}>
     */
    private function loanDeductions(Employee $employee, string $currency): array
    {
        return EmployeeLoan::where('employee_id', $employee->getKey())
            ->where('currency', $currency)
            ->recovering()
            ->get()
            ->map(function (EmployeeLoan $loan) {
                $amount = $loan->nextInstallment()->minorUnits;

                return [
                    'amount' => $amount,
                    'loan_id' => $loan->getKey(),
                    'line' => [
                        'component_code' => 'loan_recovery',
                        'component_name' => "Loan recovery ({$loan->loan_no})",
                        'component_type' => ComponentType::Deduction->value,
                        'amount' => $amount,
                        'basis' => $loan->outstanding()->format().' outstanding',
                        'sort_order' => 900,
                        'salary_component_id' => null,
                    ],
                ];
            })
            ->filter(fn (array $loan) => $loan['amount'] > 0)
            ->values()
            ->all();
    }

    /** Integer arithmetic throughout; basis points keep percentages exact. */
    private function amountFor(
        EmployeeSalaryLine $line,
        int $basic,
        int $gross,
        int $daysAbsent,
        int $overtimeHours,
    ): int {
        $component = $line->component;
        $rate = $line->rate_basis_points > 0 ? $line->rate_basis_points : $component->rate_basis_points;

        return match ($component->calculation_mode) {
            CalculationMode::Fixed => (int) $line->amount,
            CalculationMode::PercentOfBasic => intdiv($basic * $rate, 10_000),
            CalculationMode::PercentOfGross => intdiv($gross * $rate, 10_000),
            CalculationMode::Attendance => (int) $line->amount * $daysAbsent,
            CalculationMode::Overtime => (int) $line->amount * $overtimeHours,
        };
    }

    /** @return array<string, mixed> */
    private function line(EmployeeSalaryLine $line, int $amount, int $basic, int $daysAbsent, int $overtimeHours): array
    {
        $component = $line->component;

        $basis = match ($component->calculation_mode) {
            CalculationMode::PercentOfBasic, CalculationMode::PercentOfGross => $component->rateLabel(),
            CalculationMode::Attendance => "{$daysAbsent} day".($daysAbsent === 1 ? '' : 's'),
            CalculationMode::Overtime => "{$overtimeHours} hour".($overtimeHours === 1 ? '' : 's'),
            CalculationMode::Fixed => null,
        };

        return [
            'component_code' => $component->code,
            'component_name' => $component->name,
            'component_type' => $component->component_type->value,
            'amount' => $amount,
            'basis' => $basis,
            'sort_order' => (int) $component->sort_order + 1,
            'salary_component_id' => $component->getKey(),
        ];
    }

    /** Exposed so the run can record recoveries once a payslip is committed. */
    public function loansFor(Employee $employee, string $currency): array
    {
        return $this->loanDeductions($employee, $currency);
    }
}
