<?php

// Feature: student-search-officer-wallet-access, Property 15: OTP validation rejects expired or mismatched codes
// Feature: student-search-officer-wallet-access, Property 16: Transfer rollback preserves balances on DB failure
// Feature: student-search-officer-wallet-access, Property 17: Successful transfer produces complete and correct log entries
// Feature: student-search-officer-wallet-access, Property 18: OTP is cleared after successful transfer

declare(strict_types=1);

namespace Tests;

use PHPUnit\Framework\TestCase;

/**
 * Tasks 10.1–10.4 — Property tests for small_complete_transfer.php logic.
 * Validates: Requirements 10.2, 10.5, 10.6, 10.7, 10.8
 */
class TransferCompletionPropertyTest extends TestCase
{
    private \mysqli $con;
    private int $senderId    = 0;
    private int $recipientId = 0;

    protected function setUp(): void
    {
        $host = defined('DB_SERVER') ? DB_SERVER : 'localhost';
        $user = defined('DB_USER')   ? DB_USER   : 'root';
        $pass = defined('DB_PASS')   ? DB_PASS   : '';
        $name = defined('DB_NAME')   ? DB_NAME   : 'sms';

        $this->con = new \mysqli($host, $user, $pass, $name);
        if ($this->con->connect_error) {
            $this->markTestSkipped('Cannot connect to DB: ' . $this->con->connect_error);
        }

        $this->con->query("INSERT INTO students (studentName, studentno, class, section, stream, email, status)
            VALUES ('Sender OTP Test','SOTP001','JSS 1','A','Day','sotp@test.com',1)");
        $this->senderId = (int)$this->con->insert_id;

        $this->con->query("INSERT INTO students (studentName, studentno, class, section, stream, email, status)
            VALUES ('Recipient OTP Test','ROTP001','JSS 1','A','Day','rotp@test.com',1)");
        $this->recipientId = (int)$this->con->insert_id;

        $this->con->query("INSERT INTO wallets (student_id, balance) VALUES ({$this->senderId}, 10000.00)");
        $this->con->query("INSERT INTO wallets (student_id, balance) VALUES ({$this->recipientId}, 500.00)");
    }

    protected function tearDown(): void
    {
        $this->con->query("DELETE FROM wallet_transactions WHERE student_id IN ({$this->senderId},{$this->recipientId})");
        $this->con->query("DELETE FROM wallets WHERE student_id IN ({$this->senderId},{$this->recipientId})");
        $this->con->query("DELETE FROM students WHERE id IN ({$this->senderId},{$this->recipientId})");
        $this->con->close();
    }

    // -----------------------------------------------------------------------
    // Helpers
    // -----------------------------------------------------------------------

    private function setOtp(string $otp, string $expiry): void
    {
        $stmt = $this->con->prepare("UPDATE wallets SET otp_code=?, otp_expiry=? WHERE student_id=?");
        $stmt->bind_param("ssi", $otp, $expiry, $this->senderId);
        $stmt->execute();
        $stmt->close();
    }

    private function getBalance(int $studentId): float
    {
        $res = $this->con->query("SELECT balance FROM wallets WHERE student_id={$studentId}");
        return (float)($res->fetch_assoc()['balance'] ?? 0);
    }

    private function getStoredOtp(): ?string
    {
        $res = $this->con->query("SELECT otp_code FROM wallets WHERE student_id={$this->senderId}");
        return $res->fetch_assoc()['otp_code'] ?? null;
    }

    private function getStoredOtpExpiry(): ?string
    {
        $res = $this->con->query("SELECT otp_expiry FROM wallets WHERE student_id={$this->senderId}");
        return $res->fetch_assoc()['otp_expiry'] ?? null;
    }

    /**
     * Simulate the OTP validation logic from small_complete_transfer.php
     */
    private function validateOtp(string $inputOtp, string $storedOtp, string $expiry): bool
    {
        return $inputOtp === $storedOtp && strtotime($expiry) >= time();
    }

    /**
     * Execute the atomic transfer and return success/failure.
     */
    private function executeAtomicTransfer(float $amount, string $baseRef): bool
    {
        $refOut  = $baseRef . '-DR';
        $refIn   = $baseRef . '-CR';
        $noteOut = 'Officer transfer to recipient';
        $noteIn  = 'Officer transfer from sender';
        $typeOut = 'Transfer Out';
        $typeIn  = 'Transfer In';

        $this->con->begin_transaction();
        try {
            $s1 = $this->con->prepare("UPDATE wallets SET balance = balance - ? WHERE student_id = ? AND balance >= ?");
            $s1->bind_param("did", $amount, $this->senderId, $amount);
            $s1->execute();
            if ($s1->affected_rows !== 1) throw new \Exception("Deduction failed");
            $s1->close();

            $s2 = $this->con->prepare("UPDATE wallets SET balance = balance + ? WHERE student_id = ?");
            $s2->bind_param("di", $amount, $this->recipientId);
            $s2->execute();
            $s2->close();

            $s3 = $this->con->prepare(
                "INSERT INTO wallet_transactions (student_id, amount, type, status, reference, notes, created_at)
                 VALUES (?, ?, ?, 'success', ?, ?, NOW())"
            );
            $s3->bind_param("idsss", $this->senderId, $amount, $typeOut, $refOut, $noteOut);
            $s3->execute();
            $s3->close();

            $s4 = $this->con->prepare(
                "INSERT INTO wallet_transactions (student_id, amount, type, status, reference, notes, created_at)
                 VALUES (?, ?, ?, 'success', ?, ?, NOW())"
            );
            $s4->bind_param("idsss", $this->recipientId, $amount, $typeIn, $refIn, $noteIn);
            $s4->execute();
            $s4->close();

            $s5 = $this->con->prepare("UPDATE wallets SET otp_code = NULL, otp_expiry = NULL WHERE student_id = ?");
            $s5->bind_param("i", $this->senderId);
            $s5->execute();
            $s5->close();

            $this->con->commit();
            return true;
        } catch (\Exception $e) {
            $this->con->rollback();
            return false;
        }
    }

    // -----------------------------------------------------------------------
    // Property 15: OTP validation rejects expired or mismatched codes
    // -----------------------------------------------------------------------

    /**
     * @dataProvider invalidOtpProvider
     */
    public function testOtpValidationRejectsInvalidCodes(string $inputOtp, string $storedOtp, string $expiry, bool $shouldPass): void
    {
        // Feature: student-search-officer-wallet-access, Property 15: OTP validation rejects expired or mismatched codes
        $senderBalanceBefore    = $this->getBalance($this->senderId);
        $recipientBalanceBefore = $this->getBalance($this->recipientId);

        $valid = $this->validateOtp($inputOtp, $storedOtp, $expiry);

        if (!$shouldPass) {
            $this->assertFalse($valid, "OTP validation must fail for input='{$inputOtp}', stored='{$storedOtp}', expiry='{$expiry}'");
            // Balances must be unchanged when OTP is invalid
            $this->assertSame($senderBalanceBefore,    $this->getBalance($this->senderId),    "Sender balance must not change on invalid OTP");
            $this->assertSame($recipientBalanceBefore, $this->getBalance($this->recipientId), "Recipient balance must not change on invalid OTP");
        } else {
            $this->assertTrue($valid, "OTP validation must pass for valid input");
        }
    }

    // -----------------------------------------------------------------------
    // Property 16: Transfer rollback preserves balances on DB failure
    // -----------------------------------------------------------------------

    public function testTransferRollbackPreservesBalances(): void
    {
        // Feature: student-search-officer-wallet-access, Property 16: Transfer rollback preserves balances on DB failure
        for ($i = 0; $i < 100; $i++) {
            $amount = round(mt_rand(100, 500000) / 100, 2);

            // Reset balances
            $this->con->query("UPDATE wallets SET balance=10000.00 WHERE student_id={$this->senderId}");
            $this->con->query("UPDATE wallets SET balance=500.00 WHERE student_id={$this->recipientId}");

            $senderBefore    = $this->getBalance($this->senderId);
            $recipientBefore = $this->getBalance($this->recipientId);

            // Simulate rollback by using an amount that exceeds balance
            $excessAmount = $senderBefore + 1.0;
            $this->con->begin_transaction();
            try {
                $s1 = $this->con->prepare("UPDATE wallets SET balance = balance - ? WHERE student_id = ? AND balance >= ?");
                $s1->bind_param("did", $excessAmount, $this->senderId, $excessAmount);
                $s1->execute();
                if ($s1->affected_rows !== 1) throw new \Exception("Forced rollback");
                $s1->close();
                $this->con->commit();
            } catch (\Exception $e) {
                $this->con->rollback();
            }

            $this->assertSame($senderBefore,    $this->getBalance($this->senderId),    "Sender balance must be unchanged after rollback (iter {$i})");
            $this->assertSame($recipientBefore, $this->getBalance($this->recipientId), "Recipient balance must be unchanged after rollback (iter {$i})");
        }
    }

    // -----------------------------------------------------------------------
    // Property 17: Successful transfer produces complete and correct log entries
    // -----------------------------------------------------------------------

    public function testSuccessfulTransferProducesCorrectLogEntries(): void
    {
        // Feature: student-search-officer-wallet-access, Property 17: Successful transfer produces complete and correct log entries
        for ($i = 0; $i < 100; $i++) {
            $amount  = round(mt_rand(100, 500000) / 100, 2);
            $baseRef = 'TRF-OFF-' . strtoupper(bin2hex(random_bytes(4)));

            // Reset balances
            $this->con->query("UPDATE wallets SET balance=10000.00 WHERE student_id={$this->senderId}");
            $this->con->query("UPDATE wallets SET balance=500.00 WHERE student_id={$this->recipientId}");

            // Delete any prior logs for these students
            $this->con->query("DELETE FROM wallet_transactions WHERE student_id IN ({$this->senderId},{$this->recipientId})");

            $success = $this->executeAtomicTransfer($amount, $baseRef);
            $this->assertTrue($success, "Transfer must succeed (iter {$i})");

            // Verify debit log
            $debitRes = $this->con->query(
                "SELECT * FROM wallet_transactions WHERE student_id={$this->senderId} AND reference='{$baseRef}-DR'"
            );
            $debit = $debitRes->fetch_assoc();
            $this->assertNotNull($debit, "Debit log entry must exist (iter {$i})");
            $this->assertSame('Transfer Out', $debit['type'],    "Debit type must be 'Transfer Out' (iter {$i})");
            $this->assertSame('success',      $debit['status'],  "Debit status must be 'success' (iter {$i})");
            $this->assertStringStartsWith('TRF-OFF-', $debit['reference'], "Debit reference must start with TRF-OFF- (iter {$i})");
            $this->assertStringEndsWith('-DR', $debit['reference'], "Debit reference must end with -DR (iter {$i})");

            // Verify credit log
            $creditRes = $this->con->query(
                "SELECT * FROM wallet_transactions WHERE student_id={$this->recipientId} AND reference='{$baseRef}-CR'"
            );
            $credit = $creditRes->fetch_assoc();
            $this->assertNotNull($credit, "Credit log entry must exist (iter {$i})");
            $this->assertSame('Transfer In', $credit['type'],   "Credit type must be 'Transfer In' (iter {$i})");
            $this->assertSame('success',     $credit['status'], "Credit status must be 'success' (iter {$i})");
            $this->assertStringStartsWith('TRF-OFF-', $credit['reference'], "Credit reference must start with TRF-OFF- (iter {$i})");
            $this->assertStringEndsWith('-CR', $credit['reference'], "Credit reference must end with -CR (iter {$i})");

            // Both share the same base ref
            $this->assertSame(
                substr($debit['reference'], 0, -3),
                substr($credit['reference'], 0, -3),
                "Debit and credit must share the same base reference (iter {$i})"
            );
        }
    }

    // -----------------------------------------------------------------------
    // Property 18: OTP is cleared after successful transfer
    // -----------------------------------------------------------------------

    public function testOtpClearedAfterSuccessfulTransfer(): void
    {
        // Feature: student-search-officer-wallet-access, Property 18: OTP is cleared after successful transfer
        for ($i = 0; $i < 100; $i++) {
            $amount  = round(mt_rand(100, 500000) / 100, 2);
            $baseRef = 'TRF-OFF-' . strtoupper(bin2hex(random_bytes(4)));

            // Reset balance and set an OTP
            $this->con->query("UPDATE wallets SET balance=10000.00 WHERE student_id={$this->senderId}");
            $this->con->query("UPDATE wallets SET balance=500.00 WHERE student_id={$this->recipientId}");
            $this->setOtp((string)rand(100000, 999999), date("Y-m-d H:i:s", strtotime("+10 minutes")));

            $this->assertNotNull($this->getStoredOtp(), "OTP must be set before transfer (iter {$i})");

            $this->con->query("DELETE FROM wallet_transactions WHERE student_id IN ({$this->senderId},{$this->recipientId})");
            $success = $this->executeAtomicTransfer($amount, $baseRef);
            $this->assertTrue($success, "Transfer must succeed (iter {$i})");

            $this->assertNull($this->getStoredOtp(),    "otp_code must be NULL after successful transfer (iter {$i})");
            $this->assertNull($this->getStoredOtpExpiry(), "otp_expiry must be NULL after successful transfer (iter {$i})");
        }
    }

    // -----------------------------------------------------------------------
    // Data Providers
    // -----------------------------------------------------------------------

    public static function invalidOtpProvider(): array
    {
        $cases = [];
        $validOtp    = '482910';
        $validExpiry = date("Y-m-d H:i:s", strtotime("+10 minutes"));
        $pastExpiry  = date("Y-m-d H:i:s", strtotime("-1 minute"));

        // Valid case
        $cases['valid'] = [$validOtp, $validOtp, $validExpiry, true];

        // Mismatched OTP (valid expiry)
        for ($i = 0; $i < 49; $i++) {
            $wrong = str_pad((string)rand(100000, 999999), 6, '0', STR_PAD_LEFT);
            if ($wrong === $validOtp) $wrong = '000001';
            $cases["mismatch_{$i}"] = [$wrong, $validOtp, $validExpiry, false];
        }

        // Correct OTP but expired
        for ($i = 0; $i < 49; $i++) {
            $otp = str_pad((string)rand(100000, 999999), 6, '0', STR_PAD_LEFT);
            $cases["expired_{$i}"] = [$otp, $otp, $pastExpiry, false];
        }

        return $cases;
    }
}
