<?php

namespace Tests\Feature\Payroll;

use App\Domain\Accounting\Models\LedgerAccount;
use App\Domain\HR\Models\Employee;
use App\Domain\Identity\Models\User;
use App\Domain\Organization\Models\Branch;
use App\Domain\Organization\Models\Company;
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 Database\Seeders\ChartOfAccountsSeeder;
use Database\Seeders\PermissionSeeder;
use Database\Seeders\RoleSeeder;
use Database\Seeders\SalaryComponentSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class PayrollTest extends TestCase
{
    use RefreshDatabase;

    private Company $company;

    private Branch $branch;

    protected function setUp(): void
    {
        parent::setUp();
        $this->seed([PermissionSeeder::class, RoleSeeder::class]);

        $this->company = Company::factory()->create();
        $this->branch = Branch::factory()->for($this->company)->create();

        $this->seed([ChartOfAccountsSeeder::class, SalaryComponentSeeder::class]);
    }

    private function user(string $role): User
    {
        return tap(User::factory()->create([
            'company_id' => $this->company->id,
            'branch_id' => $this->branch->id,
        ]))->assignRole($role);
    }

    private function employee(array $attributes = []): Employee
    {
        return Employee::factory()->for($this->company)->create(array_merge([
            'branch_id' => $this->branch->id,
            'status' => 'active',
        ], $attributes));
    }

    private function balanceOf(string $code): int
    {
        return LedgerAccount::where('company_id', $this->company->id)
            ->where('code', $code)
            ->firstOrFail()
            ->balance();
    }

    /**
     * Gives an employee a 500.00 basic with a 40% house allowance and a 10%
     * provident fund deduction.
     *
     * @param  array<int, array<string, mixed>>  $lines
     */
    private function structure(Employee $employee, int $basic = 50000, ?array $lines = null): void
    {
        $this->actingAs($this->user('hr-manager'))
            ->postJson("/api/v1/employees/{$employee->uuid}/salary-structure", [
                'basic_salary' => $basic,
                'currency' => 'BDT',
                'effective_from' => now()->startOfYear()->toDateString(),
                'lines' => $lines ?? [
                    ['component_code' => 'house_allowance'],
                    ['component_code' => 'provident_fund'],
                ],
            ])
            ->assertCreated();
    }

    private function openRun(): string
    {
        return $this->actingAs($this->user('payroll-officer'))
            ->postJson('/api/v1/payroll/runs', [
                'period_start' => now()->startOfMonth()->toDateString(),
                'period_end' => now()->endOfMonth()->toDateString(),
            ])
            ->assertCreated()
            ->json('data.id');
    }

    public function test_a_payslip_is_built_from_the_salary_structure(): void
    {
        $employee = $this->employee();
        $this->structure($employee);

        $run = $this->openRun();

        $this->actingAs($this->user('payroll-officer'))
            ->postJson("/api/v1/payroll/runs/{$run}/calculate")
            ->assertOk()
            // 500.00 basic + 200.00 house allowance.
            ->assertJsonPath('data.gross_earnings', 70000)
            // 10% provident fund on the basic.
            ->assertJsonPath('data.total_deductions', 5000)
            ->assertJsonPath('data.net_payable', 65000)
            ->assertJsonPath('data.employee_count', 1);
    }

    public function test_a_percentage_of_gross_component_waits_for_the_gross(): void
    {
        $employee = $this->employee();

        // Income tax is 5% of gross, and gross includes the house allowance.
        $this->structure($employee, 100000, [
            ['component_code' => 'house_allowance'],
            ['component_code' => 'income_tax'],
        ]);

        $run = $this->openRun();

        $this->actingAs($this->user('payroll-officer'))
            ->postJson("/api/v1/payroll/runs/{$run}/calculate")
            ->assertOk()
            ->assertJsonPath('data.gross_earnings', 140000)
            // 5% of 1,400.00, not 5% of the 1,000.00 basic.
            ->assertJsonPath('data.total_deductions', 7000);
    }

    public function test_absence_is_deducted_per_day(): void
    {
        $employee = $this->employee();

        $this->structure($employee, 60000, [
            ['component_code' => 'absence_deduction', 'amount' => 2000],
        ]);

        $run = $this->openRun();

        $this->actingAs($this->user('payroll-officer'))
            ->postJson("/api/v1/payroll/runs/{$run}/calculate", [
                'attendance' => [$employee->id => ['days_absent' => 3]],
            ])
            ->assertOk()
            ->assertJsonPath('data.total_deductions', 6000)
            ->assertJsonPath('data.net_payable', 54000);
    }

    public function test_overtime_is_paid_per_hour(): void
    {
        $employee = $this->employee();

        $this->structure($employee, 60000, [
            ['component_code' => 'overtime', 'amount' => 500],
        ]);

        $run = $this->openRun();

        $this->actingAs($this->user('payroll-officer'))
            ->postJson("/api/v1/payroll/runs/{$run}/calculate", [
                'attendance' => [$employee->id => ['overtime_hours' => 12]],
            ])
            ->assertOk()
            ->assertJsonPath('data.gross_earnings', 66000);
    }

    public function test_an_employee_without_a_structure_is_skipped_not_paid_zero(): void
    {
        $paid = $this->employee();
        $this->structure($paid);

        // Nobody has said what this person earns.
        $this->employee();

        $run = $this->openRun();

        $this->actingAs($this->user('payroll-officer'))
            ->postJson("/api/v1/payroll/runs/{$run}/calculate")
            ->assertOk()
            ->assertJsonPath('data.employee_count', 1);

        $this->assertSame(1, Payslip::count());
    }

    public function test_recalculating_replaces_the_previous_figures(): void
    {
        $employee = $this->employee();
        $this->structure($employee);
        $officer = $this->user('payroll-officer');

        $run = $this->openRun();

        $this->actingAs($officer)->postJson("/api/v1/payroll/runs/{$run}/calculate")->assertOk();
        $this->actingAs($officer)->postJson("/api/v1/payroll/runs/{$run}/calculate")->assertOk();

        // Not doubled: a recalculation starts from scratch.
        $this->assertSame(1, Payslip::count());
        $this->assertSame(65000, (int) PayrollRun::firstOrFail()->net_payable);
    }

    public function test_a_superseded_structure_does_not_change_an_old_payslip(): void
    {
        $employee = $this->employee();
        $this->structure($employee, 50000);
        $officer = $this->user('payroll-officer');

        $run = $this->openRun();
        $this->actingAs($officer)->postJson("/api/v1/payroll/runs/{$run}/calculate")->assertOk();

        // A rise from next month.
        $this->actingAs($this->user('hr-manager'))
            ->postJson("/api/v1/employees/{$employee->uuid}/salary-structure", [
                'basic_salary' => 90000,
                'currency' => 'BDT',
                'effective_from' => now()->addMonth()->startOfMonth()->toDateString(),
                'lines' => [['component_code' => 'house_allowance']],
            ])
            ->assertCreated();

        $this->assertSame(50000, (int) Payslip::firstOrFail()->basic_salary);
    }

    public function test_payroll_cannot_be_approved_by_whoever_calculated_it(): void
    {
        $employee = $this->employee();
        $this->structure($employee);

        $officer = $this->user('payroll-officer');
        $officer->givePermissionTo('payroll.approve');

        $run = $this->openRun();

        $this->actingAs($officer)->postJson("/api/v1/payroll/runs/{$run}/calculate")->assertOk();

        $this->actingAs($officer)
            ->postJson("/api/v1/payroll/runs/{$run}/approve")
            ->assertStatus(422);
    }

    public function test_posting_payroll_writes_a_balanced_journal(): void
    {
        $employee = $this->employee();
        $this->structure($employee);

        $officer = $this->user('payroll-officer');
        $manager = $this->user('hr-manager');

        $run = $this->openRun();
        $this->actingAs($officer)->postJson("/api/v1/payroll/runs/{$run}/calculate")->assertOk();
        $this->actingAs($manager)->postJson("/api/v1/payroll/runs/{$run}/approve")->assertOk();

        $this->actingAs($this->user('accounts-manager'))
            ->postJson("/api/v1/payroll/runs/{$run}/post")
            ->assertOk()
            ->assertJsonPath('data.status', PayrollStatus::Posted->value);

        // Dr Salaries 700.00; Cr Salary Payable 650.00 + PF payable 50.00.
        $this->assertSame(70000, $this->balanceOf('6100'));
        $this->assertSame(65000, $this->balanceOf('2300'));
        $this->assertSame(5000, $this->balanceOf('2200'));

        // Salary is accrued at period end, which is later this month, so the
        // report window has to reach it.
        $trial = $this->actingAs($this->user('accounts-manager'))
            ->getJson('/api/v1/ledger/trial-balance?from='.now()->startOfYear()->toDateString()
                .'&to='.now()->endOfMonth()->toDateString())
            ->assertOk()
            ->json('data');

        $this->assertTrue($trial['is_balanced']);
        $this->assertGreaterThan(0, $trial['total_debit']);
    }

    public function test_paying_payroll_clears_the_salary_payable(): void
    {
        $employee = $this->employee();
        $this->structure($employee);

        $run = $this->openRun();
        $this->actingAs($this->user('payroll-officer'))
            ->postJson("/api/v1/payroll/runs/{$run}/calculate")->assertOk();
        $this->actingAs($this->user('hr-manager'))
            ->postJson("/api/v1/payroll/runs/{$run}/approve")->assertOk();

        $accounts = $this->user('accounts-manager');
        $this->actingAs($accounts)->postJson("/api/v1/payroll/runs/{$run}/post")->assertOk();

        $this->actingAs($accounts)
            ->postJson("/api/v1/payroll/runs/{$run}/pay", [
                'paid_from_code' => '1200',
                'payment_method' => 'bank_transfer',
            ])
            ->assertOk()
            ->assertJsonPath('data.status', PayrollStatus::Paid->value);

        $this->assertSame(0, $this->balanceOf('2300'), 'Nothing left owing to staff.');
        $this->assertSame(-65000, $this->balanceOf('1200'));
    }

    public function test_a_loan_is_recovered_only_when_payroll_is_actually_paid(): void
    {
        $employee = $this->employee();
        $this->structure($employee);

        $loan = EmployeeLoan::create([
            'loan_no' => 'LOAN-1',
            'company_id' => $this->company->id,
            'employee_id' => $employee->id,
            'status' => LoanStatus::Disbursed->value,
            'currency' => 'BDT',
            'principal_amount' => 30000,
            'installment_amount' => 10000,
            'installment_count' => 3,
            'requested_on' => now()->subMonth()->toDateString(),
            'disbursed_on' => now()->subMonth()->toDateString(),
        ]);

        $officer = $this->user('payroll-officer');
        $run = $this->openRun();

        // Calculating twice must not recover two installments.
        $this->actingAs($officer)->postJson("/api/v1/payroll/runs/{$run}/calculate")->assertOk();
        $this->actingAs($officer)->postJson("/api/v1/payroll/runs/{$run}/calculate")->assertOk();

        $this->assertSame(0, (int) $loan->fresh()->recovered_amount);

        $this->actingAs($this->user('hr-manager'))
            ->postJson("/api/v1/payroll/runs/{$run}/approve")->assertOk();

        $accounts = $this->user('accounts-manager');
        $this->actingAs($accounts)->postJson("/api/v1/payroll/runs/{$run}/post")->assertOk();
        $this->actingAs($accounts)->postJson("/api/v1/payroll/runs/{$run}/pay", [
            'paid_from_code' => '1200',
        ])->assertOk();

        $this->assertSame(10000, (int) $loan->fresh()->recovered_amount);
        // 650.00 net less the 100.00 installment.
        $this->assertSame(55000, (int) Payslip::firstOrFail()->net_pay);
    }

    public function test_a_loan_recovery_never_takes_more_than_is_left(): void
    {
        $employee = $this->employee();
        $this->structure($employee);

        $loan = EmployeeLoan::create([
            'loan_no' => 'LOAN-2',
            'company_id' => $this->company->id,
            'employee_id' => $employee->id,
            'status' => LoanStatus::Disbursed->value,
            'currency' => 'BDT',
            'principal_amount' => 12000,
            'installment_amount' => 10000,
            'recovered_amount' => 8000,
            'installment_count' => 2,
            'requested_on' => now()->subMonth()->toDateString(),
        ]);

        $run = $this->openRun();
        $this->actingAs($this->user('payroll-officer'))
            ->postJson("/api/v1/payroll/runs/{$run}/calculate")->assertOk();
        $this->actingAs($this->user('hr-manager'))
            ->postJson("/api/v1/payroll/runs/{$run}/approve")->assertOk();

        $accounts = $this->user('accounts-manager');
        $this->actingAs($accounts)->postJson("/api/v1/payroll/runs/{$run}/post")->assertOk();
        $this->actingAs($accounts)->postJson("/api/v1/payroll/runs/{$run}/pay", [
            'paid_from_code' => '1200',
        ])->assertOk();

        // Only the remaining 40.00, and the loan closes.
        $loan->refresh();
        $this->assertSame(12000, (int) $loan->recovered_amount);
        $this->assertSame(LoanStatus::Settled, $loan->status);
    }

    public function test_the_same_period_cannot_be_run_twice(): void
    {
        $this->openRun();

        $this->actingAs($this->user('payroll-officer'))
            ->postJson('/api/v1/payroll/runs', [
                'period_start' => now()->startOfMonth()->toDateString(),
                'period_end' => now()->endOfMonth()->toDateString(),
            ])
            ->assertStatus(422);
    }

    public function test_an_approved_payroll_cannot_be_recalculated(): void
    {
        $employee = $this->employee();
        $this->structure($employee);

        $officer = $this->user('payroll-officer');
        $run = $this->openRun();

        $this->actingAs($officer)->postJson("/api/v1/payroll/runs/{$run}/calculate")->assertOk();
        $this->actingAs($this->user('hr-manager'))
            ->postJson("/api/v1/payroll/runs/{$run}/approve")->assertOk();

        $this->actingAs($officer)
            ->postJson("/api/v1/payroll/runs/{$run}/calculate")
            ->assertStatus(422);
    }

    public function test_an_employee_reads_their_own_payslips(): void
    {
        $account = tap(User::factory()->create([
            'company_id' => $this->company->id,
            'branch_id' => $this->branch->id,
        ]))->assignRole('data-entry-officer');

        $employee = $this->employee(['user_id' => $account->id]);
        $this->structure($employee);

        $run = $this->openRun();
        $this->actingAs($this->user('payroll-officer'))
            ->postJson("/api/v1/payroll/runs/{$run}/calculate")->assertOk();

        $payslips = $this->actingAs($account)
            ->getJson('/api/v1/my/payslips')
            ->assertOk()
            ->json('data');

        $this->assertCount(1, $payslips);
        $this->assertSame(65000, $payslips[0]['net_pay']);
    }

    public function test_payroll_needs_the_permission(): void
    {
        $this->actingAs($this->user('data-entry-officer'))
            ->postJson('/api/v1/payroll/runs', [
                'period_start' => now()->startOfMonth()->toDateString(),
                'period_end' => now()->endOfMonth()->toDateString(),
            ])
            ->assertForbidden();
    }
}
