<?php

declare(strict_types=1);

namespace App\Tests\Service\Rtt;

use App\Dto\Rtt\WeekRecoveryDetail;
use App\Service\Rtt\RttClosingBalanceService;
use PHPUnit\Framework\TestCase;
use ReflectionClass;

/**
 * The service constructor takes final-class collaborators (repositories,
 * RttRecoveryComputationService) that PHPUnit cannot double. The fold logic is
 * pure (no $this dependency), so it is exercised via newInstanceWithoutConstructor.
 *
 * Invariant under test: the bucket sum of the closing balance ALWAYS equals
 *   opening_report + net_earned - paid
 * which is exactly the "disponible" the RTT tab shows for that exercise — so the
 * report carried to the next exercise matches the displayed balance.
 *
 * @internal
 */
final class RttClosingBalanceServiceTest extends TestCase
{
    public function testOpeningReportIsCarriedForwardOnTopOfEarned(): void
    {
        // Regression for the reported bug: the previous exercise's opening report
        // (e.g. go-live import or unused carry) must be included, not dropped.
        $opening = new WeekRecoveryDetail(base25Minutes: 600, totalMinutes: 600); // 10h report
        $week    = new WeekRecoveryDetail(base25Minutes: 240, bonus25Minutes: 60, totalMinutes: 300); // +5h earned

        $closing = $this->service()->fold($opening, [$week], $this->payments());

        // 10h report + 5h earned = 15h carried (NOT 5h).
        self::assertSame(900, $closing->totalMinutes);
        self::assertSame(600 + 240, $closing->base25Minutes);
        self::assertSame(60, $closing->bonus25Minutes);
    }

    public function testPaymentsAreDeductedFromClosing(): void
    {
        $opening = new WeekRecoveryDetail(base25Minutes: 600, totalMinutes: 600);
        $week    = new WeekRecoveryDetail(base25Minutes: 240, bonus25Minutes: 60, totalMinutes: 300);

        // 7h paid out of the 25% base bucket.
        $closing = $this->service()->fold($opening, [$week], $this->payments(b25: 420));

        self::assertSame(900 - 420, $closing->totalMinutes);
        self::assertSame(600 + 240 - 420, $closing->base25Minutes);
    }

    public function testDeficitWeekConsumesFiftyTierBeforeTwentyFiveTier(): void
    {
        // Opening: 60min in 50%-base, 120min in 25%-base.
        $opening = new WeekRecoveryDetail(base25Minutes: 120, base50Minutes: 60, totalMinutes: 180);
        // Deficit week of 100min (worked less than reference): buckets 0, negative total.
        $deficit = new WeekRecoveryDetail(totalMinutes: -100);

        $closing = $this->service()->fold($opening, [$deficit], $this->payments());

        // 50%-base absorbs 60 first, the remaining 40 hits the 25%-base.
        self::assertSame(0, $closing->base50Minutes);
        self::assertSame(80, $closing->base25Minutes);
        self::assertSame(80, $closing->totalMinutes);
    }

    public function testCustomRecoveryWithoutBucketsStillCountsInTotal(): void
    {
        // CUSTOM contract: positive total recovery (1h=1h) but every 25/50 bucket is 0.
        $custom = new WeekRecoveryDetail(totalMinutes: 180); // 3h plain recovery

        $closing = $this->service()->fold(new WeekRecoveryDetail(), [$custom], $this->payments());

        // The 3h must survive into the carried report (sum of buckets == total).
        self::assertSame(180, $closing->totalMinutes);
        self::assertSame(
            180,
            $closing->base25Minutes + $closing->bonus25Minutes + $closing->base50Minutes + $closing->bonus50Minutes,
        );
    }

    public function testCustomDeficitWeekReducesClosingBalance(): void
    {
        // CUSTOM (4h) : une semaine de récup +3h puis une semaine déficitaire -1h
        // (toutes deux sans tranches 25/50). Le déficit doit réduire la clôture.
        $recovery = new WeekRecoveryDetail(totalMinutes: 180, isFlatRecovery: true);   // +3h
        $deficit  = new WeekRecoveryDetail(totalMinutes: -60, isFlatRecovery: true);   // -1h

        $closing = $this->service()->fold(new WeekRecoveryDetail(), [$recovery, $deficit], $this->payments());

        // 3h - 1h = 2h reportées, et la somme des buckets égale toujours le total.
        self::assertSame(120, $closing->totalMinutes);
        self::assertSame(
            120,
            $closing->base25Minutes + $closing->bonus25Minutes + $closing->base50Minutes + $closing->bonus50Minutes,
        );
    }

    public function testBucketSumAlwaysEqualsTotalInvariant(): void
    {
        $opening = new WeekRecoveryDetail(base25Minutes: 200, bonus25Minutes: 50, base50Minutes: 100, bonus50Minutes: 50, totalMinutes: 400);
        $weeks   = [
            new WeekRecoveryDetail(base25Minutes: 240, bonus25Minutes: 60, totalMinutes: 300),
            new WeekRecoveryDetail(totalMinutes: -500),  // deeper deficit than tiers hold
            new WeekRecoveryDetail(totalMinutes: 90),    // custom-style recovery
        ];

        $closing = $this->service()->fold($opening, $weeks, $this->payments(b25: 120, b50: 30));

        $bucketSum = $closing->base25Minutes + $closing->bonus25Minutes + $closing->base50Minutes + $closing->bonus50Minutes;
        self::assertSame($closing->totalMinutes, $bucketSum);
        // opening 400 + earned (300 - 500 + 90 = -110) - paid 150 = 140
        self::assertSame(140, $closing->totalMinutes);
    }

    private function service(): RttClosingBalanceService
    {
        return new ReflectionClass(RttClosingBalanceService::class)->newInstanceWithoutConstructor();
    }

    private function payments(int $b25 = 0, int $bo25 = 0, int $b50 = 0, int $bo50 = 0): WeekRecoveryDetail
    {
        return new WeekRecoveryDetail(
            base25Minutes: $b25,
            bonus25Minutes: $bo25,
            base50Minutes: $b50,
            bonus50Minutes: $bo50,
            totalMinutes: $b25 + $bo25 + $b50 + $bo50,
        );
    }
}
