<?php

/**
 * ReconciliationHelperTest.php
 *
 * Unit and property-based tests for portal/helpers/ReconciliationHelper.php.
 *
 * Feature: daily-transaction-reconciliation
 * Validates: Requirements 3.4, 4.2, 5.2, 6.2, 7.2, 8.2, 9.1, 9.2, 9.3, 9.4,
 *            12.2, 12.3, 13.2, 13.3, 13.5, 13.6, 13.7
 */

declare(strict_types=1);

namespace Tests;

use PHPUnit\Framework\TestCase;

require_once __DIR__ . '/../helpers/ReconciliationHelper.php';

class ReconciliationHelperTest extends TestCase
{

    // =========================================================================
    // Task 2.1 — Unit tests for all six helper functions
    // =========================================================================

    // --- validateReconciliationDate ---

    public function testValidateDateEmptyStringReturnsToday(): void
    {
        $this->assertSame(date('Y-m-d'), validateReconciliationDate(''));
    }

    public function testValidateDateNonDateStringReturnsToday(): void
    {
        $this->assertSame(date('Y-m-d'), validateReconciliationDate('not-a-date'));
    }

    public function testValidateDateInvalidMonthReturnsToday(): void
    {
        // Month 13 does not exist
        $this->assertSame(date('Y-m-d'), validateReconciliationDate('2025-13-01'));
    }

    public function testValidateDateSqlInjectionReturnsToday(): void
    {
        $this->assertSame(date('Y-m-d'), validateReconciliationDate("' OR '1'='1"));
    }

    public function testValidateDateValidDatePassesThrough(): void
    {
        $this->assertSame('2025-05-25', validateReconciliationDate('2025-05-25'));
    }

    public function testValidateDateAnotherValidDatePassesThrough(): void
    {
        $this->assertSame('2024-01-01', validateReconciliationDate('2024-01-01'));
    }

    public function testValidateDatePartialDateReturnsToday(): void
    {
        $this->assertSame(date('Y-m-d'), validateReconciliationDate('2025-05'));
    }

    public function testValidateDateWrongFormatReturnsToday(): void
    {
        $this->assertSame(date('Y-m-d'), validateReconciliationDate('25-05-2025'));
    }

    // --- formatNaira ---

    public function testFormatNairaZero(): void
    {
        $this->assertSame('₦0.00', formatNaira(0));
    }

    public function testFormatNairaWholeNumber(): void
    {
        $this->assertSame('₦125,000.00', formatNaira(125000));
    }

    public function testFormatNairaDecimal(): void
    {
        $this->assertSame('₦1,500.50', formatNaira(1500.50));
    }

    public function testFormatNairaSmallAmount(): void
    {
        $this->assertSame('₦0.01', formatNaira(0.01));
    }

    public function testFormatNairaStartsWithSymbol(): void
    {
        $result = formatNaira(5000);
        $this->assertStringStartsWith('₦', $result);
    }

    public function testFormatNairaAlwaysTwoDecimalPlaces(): void
    {
        $result = formatNaira(100);
        $this->assertMatchesRegularExpression('/^₦[\d,]+\.\d{2}$/', $result);
    }

    // --- computeVariance ---

    public function testVarianceWhenEqual(): void
    {
        $this->assertEqualsWithDelta(0.0, computeVariance(100, 100), 0.001);
    }

    public function testVariancePositive(): void
    {
        $this->assertEqualsWithDelta(20.0, computeVariance(100, 80), 0.001);
    }

    public function testVarianceNegative(): void
    {
        $this->assertEqualsWithDelta(-20.0, computeVariance(80, 100), 0.001);
    }

    public function testVarianceZeroInputs(): void
    {
        $this->assertEqualsWithDelta(0.0, computeVariance(0, 0), 0.001);
    }

    public function testVarianceLargeAmounts(): void
    {
        $this->assertEqualsWithDelta(50000.0, computeVariance(200000, 150000), 0.001);
    }

    // --- convertKoboToNaira ---

    public function testKoboConversionTypical(): void
    {
        $this->assertEqualsWithDelta(5000.0, convertKoboToNaira(500000), 0.001);
    }

    public function testKoboConversionZero(): void
    {
        $this->assertEqualsWithDelta(0.0, convertKoboToNaira(0), 0.001);
    }

    public function testKoboConversionSmall(): void
    {
        $this->assertEqualsWithDelta(0.01, convertKoboToNaira(1), 0.001);
    }

    public function testKoboConversionLarge(): void
    {
        $this->assertEqualsWithDelta(1000000.0, convertKoboToNaira(100000000), 0.001);
    }

    // --- classifyReferences ---

    public function testClassifyReferencesTypicalCase(): void
    {
        $result = classifyReferences(['ref1', 'ref2'], ['ref1', 'ref3']);

        $this->assertContains('ref1', $result['matched']);
        $this->assertContains('ref3', $result['missing_locally']);
        $this->assertContains('ref2', $result['not_in_paystack']);
    }

    public function testClassifyReferencesAllMatched(): void
    {
        $result = classifyReferences(['ref1', 'ref2'], ['ref1', 'ref2']);

        $this->assertCount(2, $result['matched']);
        $this->assertCount(0, $result['missing_locally']);
        $this->assertCount(0, $result['not_in_paystack']);
    }

    public function testClassifyReferencesNoneMatched(): void
    {
        $result = classifyReferences(['ref1'], ['ref2']);

        $this->assertCount(0, $result['matched']);
        $this->assertContains('ref2', $result['missing_locally']);
        $this->assertContains('ref1', $result['not_in_paystack']);
    }

    public function testClassifyReferencesEmptyBoth(): void
    {
        $result = classifyReferences([], []);

        $this->assertCount(0, $result['matched']);
        $this->assertCount(0, $result['missing_locally']);
        $this->assertCount(0, $result['not_in_paystack']);
    }

    public function testClassifyReferencesLocalOnlyEmpty(): void
    {
        $result = classifyReferences([], ['ref1', 'ref2']);

        $this->assertCount(0, $result['matched']);
        $this->assertCount(2, $result['missing_locally']);
        $this->assertCount(0, $result['not_in_paystack']);
    }

    public function testClassifyReferencesApiOnlyEmpty(): void
    {
        $result = classifyReferences(['ref1', 'ref2'], []);

        $this->assertCount(0, $result['matched']);
        $this->assertCount(0, $result['missing_locally']);
        $this->assertCount(2, $result['not_in_paystack']);
    }

    // --- computeGrandTotal ---

    public function testGrandTotalEmptyArray(): void
    {
        $this->assertEqualsWithDelta(0.0, computeGrandTotal([]), 0.001);
    }

    public function testGrandTotalMultipleAmounts(): void
    {
        $this->assertEqualsWithDelta(350.0, computeGrandTotal([100.0, 200.0, 50.0]), 0.001);
    }

    public function testGrandTotalSingleAmount(): void
    {
        $this->assertEqualsWithDelta(75.5, computeGrandTotal([75.5]), 0.001);
    }

    public function testGrandTotalLargeAmounts(): void
    {
        $this->assertEqualsWithDelta(300000.0, computeGrandTotal([100000.0, 200000.0]), 0.001);
    }

    // =========================================================================
    // Task 2.2 — Property 1: Date validation always falls back to today
    //            for invalid inputs
    // Validates: Requirements 3.4, 12.2, 12.3
    // =========================================================================

    /**
     * @dataProvider invalidDateProvider
     */
    public function testDateValidationFallbackForInvalidInputs(string $input): void
    {
        // Feature: daily-transaction-reconciliation, Property 1: Date validation always falls back to today for invalid inputs
        $this->assertSame(
            date('Y-m-d'),
            validateReconciliationDate($input),
            "Expected today's date for invalid input: '{$input}'"
        );
    }

    public static function invalidDateProvider(): array
    {
        $cases = [];
        $invalids = [
            '', 'not-a-date', '2025-13-01', '2025-00-15', '25-05-2025',
            '2025/05/25', 'hello', '0000-00-00', '9999-99-99',
            "' OR '1'='1", '2025-05', '05-2025', 'null', '0',
            '2025-02-30', // Feb 30 doesn't exist
        ];
        foreach ($invalids as $v) {
            $cases[$v] = [$v];
        }
        // Add 100 random non-date strings
        for ($i = 0; $i < 100; $i++) {
            $rand = substr(str_shuffle('abcdefghijklmnopqrstuvwxyz0123456789!@#$%'), 0, random_int(1, 15));
            $dt   = \DateTime::createFromFormat('Y-m-d', $rand);
            if ($dt === false || $dt->format('Y-m-d') !== $rand) {
                $cases["rand_{$i}"] = [$rand];
            }
        }
        return $cases;
    }

    // =========================================================================
    // Task 2.3 — Property 2: Amount formatting always produces two decimal
    //            places with currency symbol
    // Validates: Requirements 4.2, 5.2, 6.2, 7.2, 8.2
    // =========================================================================

    /**
     * @dataProvider nonNegativeAmountProvider
     */
    public function testFormatNairaAlwaysProducesTwoDecimalPlacesWithSymbol(float $amount): void
    {
        // Feature: daily-transaction-reconciliation, Property 2: Amount formatting always produces two decimal places with currency symbol
        $result = formatNaira($amount);
        $this->assertMatchesRegularExpression(
            '/^₦[\d,]+\.\d{2}$/',
            $result,
            "formatNaira({$amount}) = '{$result}' does not match expected pattern"
        );
    }

    public static function nonNegativeAmountProvider(): array
    {
        $cases = [[0.0], [0.01], [1.0], [100.0], [1500.50], [125000.0], [9999999.99]];
        for ($i = 0; $i < 100; $i++) {
            $cases[] = [(float)(random_int(0, 999999999)) / 100];
        }
        return $cases;
    }

    // =========================================================================
    // Task 2.4 — Property 3: Variance calculation is always Paystack inflows
    //            minus Paystack-method approved allocations
    // Validates: Requirements 9.2, 9.3, 9.4, 13.3
    // =========================================================================

    /**
     * @dataProvider variancePairProvider
     */
    public function testVarianceIsAlwaysPaystackMinusApproved(float $paystackTotal, float $approvedPaystack): void
    {
        // Feature: daily-transaction-reconciliation, Property 3: Variance calculation is always Paystack inflows minus Paystack-method approved allocations
        $variance = computeVariance($paystackTotal, $approvedPaystack);
        $this->assertEqualsWithDelta(
            $paystackTotal - $approvedPaystack,
            $variance,
            0.001,
            "computeVariance({$paystackTotal}, {$approvedPaystack}) should equal their difference"
        );
    }

    public static function variancePairProvider(): array
    {
        $cases = [[0.0, 0.0], [100.0, 100.0], [100.0, 80.0], [80.0, 100.0]];
        for ($i = 0; $i < 100; $i++) {
            $a = (float)(random_int(0, 99999900)) / 100;
            $b = (float)(random_int(0, 99999900)) / 100;
            $cases[] = [$a, $b];
        }
        return $cases;
    }

    // =========================================================================
    // Task 2.5 — Property 4: Kobo-to-Naira conversion is always division by 100
    // Validates: Requirements 13.2
    // =========================================================================

    /**
     * @dataProvider koboProvider
     */
    public function testKoboToNairaIsAlwaysDivisionBy100(int $kobo): void
    {
        // Feature: daily-transaction-reconciliation, Property 4: Kobo-to-Naira conversion is always division by 100
        $this->assertEqualsWithDelta(
            $kobo / 100,
            convertKoboToNaira($kobo),
            0.001,
            "convertKoboToNaira({$kobo}) should equal {$kobo}/100"
        );
    }

    public static function koboProvider(): array
    {
        $cases = [[0], [1], [100], [500000], [100000000]];
        for ($i = 0; $i < 100; $i++) {
            $cases[] = [random_int(0, 100000000)];
        }
        return $cases;
    }

    // =========================================================================
    // Task 2.6 — Property 5: Reference matching correctly classifies all records
    // Validates: Requirements 13.5, 13.6, 13.7
    // =========================================================================

    /**
     * @dataProvider referenceSetsProvider
     */
    public function testReferenceMatchingClassifiesAllRecordsCorrectly(array $localRefs, array $apiRefs): void
    {
        // Feature: daily-transaction-reconciliation, Property 5: Reference matching correctly classifies all records
        $result = classifyReferences($localRefs, $apiRefs);

        // For empty inputs, just assert the structure is correct
        if (empty($localRefs) && empty($apiRefs)) {
            $this->assertCount(0, $result['matched']);
            $this->assertCount(0, $result['missing_locally']);
            $this->assertCount(0, $result['not_in_paystack']);
            return;
        }

        foreach ($result['matched'] as $ref) {
            $this->assertContains($ref, $localRefs, "matched ref '{$ref}' should be in localRefs");
            $this->assertContains($ref, $apiRefs,   "matched ref '{$ref}' should be in apiRefs");
        }

        foreach ($result['missing_locally'] as $ref) {
            $this->assertNotContains($ref, $localRefs, "missing_locally ref '{$ref}' should NOT be in localRefs");
            $this->assertContains($ref, $apiRefs,      "missing_locally ref '{$ref}' should be in apiRefs");
        }

        foreach ($result['not_in_paystack'] as $ref) {
            $this->assertContains($ref, $localRefs,    "not_in_paystack ref '{$ref}' should be in localRefs");
            $this->assertNotContains($ref, $apiRefs,   "not_in_paystack ref '{$ref}' should NOT be in apiRefs");
        }
    }

    public static function referenceSetsProvider(): array
    {
        $cases = [
            [[], []],
            [['ref1'], []],
            [[], ['ref1']],
            [['ref1', 'ref2'], ['ref1', 'ref3']],
            [['a', 'b', 'c'], ['b', 'c', 'd']],
        ];
        for ($i = 0; $i < 50; $i++) {
            $pool  = array_map(fn($n) => 'ref_' . $n, range(1, random_int(1, 20)));
            $local = array_values(array_unique(array_slice($pool, 0, random_int(0, count($pool)))));
            $api   = array_values(array_unique(array_slice($pool, random_int(0, count($pool) - 1))));
            $cases[] = [$local, $api];
        }
        return $cases;
    }

    // =========================================================================
    // Task 2.7 — Property 6: Grand total equals sum of all approved payment amounts
    // Validates: Requirements 9.1
    // =========================================================================

    /**
     * @dataProvider amountArrayProvider
     */
    public function testGrandTotalEqualsArraySum(array $amounts): void
    {
        // Feature: daily-transaction-reconciliation, Property 6: Grand total equals sum of all approved payment amounts
        $this->assertEqualsWithDelta(
            array_sum($amounts),
            computeGrandTotal($amounts),
            0.001,
            'computeGrandTotal should equal array_sum for any input array'
        );
    }

    public static function amountArrayProvider(): array
    {
        $cases = [[[]], [[0.0]], [[100.0, 200.0, 50.0]], [[75.5]], [[100000.0, 200000.0]]];
        for ($i = 0; $i < 100; $i++) {
            $len    = random_int(0, 20);
            $amounts = [];
            for ($j = 0; $j < $len; $j++) {
                $amounts[] = (float)(random_int(0, 9999900)) / 100;
            }
            $cases[] = [$amounts];
        }
        return $cases;
    }
}
