<?php

declare(strict_types=1);

/**
 * Minimal test runner.
 *
 * No PHPUnit yet — we will add it in a later phase if the test suite grows
 * complex. For now, simple assert-driven scripts are enough to prove the
 * foundation works.
 *
 * Run with: php tests/run.php
 *
 * Each test is a function that takes nothing, returns nothing, and throws
 * AssertionError on failure. The runner catches those, reports, exits non-zero
 * if any failed.
 */

require_once __DIR__ . '/../vendor/autoload.php';

// Tests need a usable environment. We DON'T require .env to exist here —
// individual tests can skip themselves if they need DB access and DB isn't configured.

class TestRunner
{
    private int $passed = 0;
    private int $failed = 0;
    /** @var list<string> */
    private array $failures = [];

    public function run(string $name, callable $test): void
    {
        try {
            $test();
            $this->passed++;
            echo "  \033[32m✓\033[0m {$name}\n";
        } catch (\Throwable $e) {
            $this->failed++;
            $this->failures[] = "{$name}\n    " . $e->getMessage()
                . "\n    at " . basename($e->getFile()) . ':' . $e->getLine();
            echo "  \033[31m✗\033[0m {$name}\n      \033[31m{$e->getMessage()}\033[0m\n";
        }
    }

    public function summary(): int
    {
        $total = $this->passed + $this->failed;
        echo "\n";
        echo "─────────────────────────────────────\n";
        echo "Tests: {$total} | ";
        echo "\033[32mPassed: {$this->passed}\033[0m";
        if ($this->failed > 0) {
            echo " | \033[31mFailed: {$this->failed}\033[0m\n";
            return 1;
        }
        echo "\n";
        return 0;
    }
}

function br_assert(bool $cond, string $message): void
{
    if (!$cond) {
        throw new AssertionError($message);
    }
}

function br_assert_equals(mixed $expected, mixed $actual, string $message = ''): void
{
    if ($expected !== $actual) {
        $msg = $message ?: 'expected and actual differ';
        $exp = is_scalar($expected) ? var_export($expected, true) : gettype($expected);
        $act = is_scalar($actual) ? var_export($actual, true) : gettype($actual);
        throw new AssertionError("{$msg}\n    expected: {$exp}\n    actual:   {$act}");
    }
}

$runner = new TestRunner();

// -----------------------------------------------------------------------------
// Bootstrap-level tests (no .env, no DB required)
// -----------------------------------------------------------------------------
echo "\n  Bootstrap\n";

$runner->run('Container resolves registered service', function () {
    $c = new \BlackRed\Bootstrap\Container();
    $c->set('foo', fn() => new \stdClass());
    br_assert($c->has('foo'), 'has() should return true for registered service');
    $obj = $c->get('foo');
    br_assert($obj instanceof \stdClass, 'should resolve to stdClass');
    br_assert($c->get('foo') === $obj, 'should return cached instance');
});

$runner->run('Container detects circular dependencies', function () {
    $c = new \BlackRed\Bootstrap\Container();
    $c->set('a', fn(\BlackRed\Bootstrap\Container $c) => $c->get('b'));
    $c->set('b', fn(\BlackRed\Bootstrap\Container $c) => $c->get('a'));
    $caught = false;
    try {
        $c->get('a');
    } catch (\RuntimeException $e) {
        $caught = str_contains($e->getMessage(), 'Circular dependency');
    }
    br_assert($caught, 'should throw on circular resolution');
});

$runner->run('Container throws on unknown service', function () {
    $c = new \BlackRed\Bootstrap\Container();
    $caught = false;
    try {
        $c->get('does_not_exist');
    } catch (\RuntimeException $e) {
        $caught = true;
    }
    br_assert($caught, 'should throw RuntimeException');
});

// -----------------------------------------------------------------------------
// Router tests
// -----------------------------------------------------------------------------
echo "\n  Router\n";

$runner->run('Router matches static path', function () {
    $r = new \BlackRed\Http\Router();
    $r->get('/api/health', ['class' => 'X', 'method' => 'y']);
    $m = $r->match('GET', '/api/health');
    br_assert($m !== null, 'should match');
    br_assert_equals('X', $m['handler']['class']);
    br_assert_equals([], $m['params']);
});

$runner->run('Router matches parametrised path', function () {
    $r = new \BlackRed\Http\Router();
    $r->get('/api/players/:id', ['class' => 'X', 'method' => 'y']);
    $m = $r->match('GET', '/api/players/42');
    br_assert($m !== null, 'should match');
    br_assert_equals('42', $m['params']['id']);
});

$runner->run('Router does not match wrong method', function () {
    $r = new \BlackRed\Http\Router();
    $r->get('/api/health', ['class' => 'X', 'method' => 'y']);
    br_assert($r->match('POST', '/api/health') === null, 'should not match POST');
    br_assert($r->pathExists('/api/health'), 'pathExists should still return true');
});

$runner->run('Router rejects invalid HTTP method', function () {
    $r = new \BlackRed\Http\Router();
    $caught = false;
    try {
        $r->add('FOO', '/x', ['class' => 'X', 'method' => 'y']);
    } catch (\RuntimeException $e) {
        $caught = true;
    }
    br_assert($caught, 'should reject FOO');
});

$runner->run('Router rejects pattern without leading slash', function () {
    $r = new \BlackRed\Http\Router();
    $caught = false;
    try {
        $r->get('api/x', ['class' => 'X', 'method' => 'y']);
    } catch (\RuntimeException $e) {
        $caught = true;
    }
    br_assert($caught, 'should reject pattern without leading /');
});

// -----------------------------------------------------------------------------
// Response tests
// -----------------------------------------------------------------------------
echo "\n  Response\n";

$runner->run('Response::json sets correct headers and status', function () {
    $r = \BlackRed\Http\Response::json(['ok' => true]);
    br_assert_equals(200, $r->status);
    br_assert($r->headers['Content-Type'] === 'application/json; charset=utf-8', 'content type');
});

$runner->run('Response::error has standard shape', function () {
    $r = \BlackRed\Http\Response::error('bad_thing', 'Something is wrong', 400);
    br_assert_equals(400, $r->status);
    br_assert_equals('bad_thing', $r->body['error']);
    br_assert_equals('Something is wrong', $r->body['message']);
});

$runner->run('Response::withHeader returns new instance (immutable)', function () {
    $r1 = \BlackRed\Http\Response::json(['x' => 1]);
    $r2 = $r1->withHeader('X-Custom', 'value');
    br_assert($r1 !== $r2, 'should be a new instance');
    br_assert(!isset($r1->headers['X-Custom']), 'original unchanged');
    br_assert_equals('value', $r2->headers['X-Custom']);
});

// -----------------------------------------------------------------------------
// Pipeline tests
// -----------------------------------------------------------------------------
echo "\n  Pipeline\n";

$runner->run('Pipeline runs middleware in order, response in reverse', function () {
    $log = [];
    $mw1 = new class($log) implements \BlackRed\Http\Middleware {
        public function __construct(private array &$log) {}
        public function process(\BlackRed\Http\Request $req, callable $next): \BlackRed\Http\Response {
            $this->log[] = 'mw1_in';
            $r = $next($req);
            $this->log[] = 'mw1_out';
            return $r;
        }
    };
    $mw2 = new class($log) implements \BlackRed\Http\Middleware {
        public function __construct(private array &$log) {}
        public function process(\BlackRed\Http\Request $req, callable $next): \BlackRed\Http\Response {
            $this->log[] = 'mw2_in';
            $r = $next($req);
            $this->log[] = 'mw2_out';
            return $r;
        }
    };
    $finalH = function (\BlackRed\Http\Request $req) use (&$log) {
        $log[] = 'handler';
        return \BlackRed\Http\Response::json(['ok' => true]);
    };

    // Build a fake request without using fromGlobals
    $reflRequest = new ReflectionClass(\BlackRed\Http\Request::class);
    $req = $reflRequest->newInstanceWithoutConstructor();
    foreach (['method' => 'GET', 'path' => '/x', 'rawBody' => '', 'headers' => [], 'query' => [], 'clientIp' => '1.1.1.1', 'userAgent' => '', 'requestId' => 'test'] as $k => $v) {
        $rp = $reflRequest->getProperty($k);
        $rp->setValue($req, $v);
    }

    $p = new \BlackRed\Http\Pipeline([$mw1, $mw2], $finalH);
    $resp = $p->handle($req);
    br_assert_equals(200, $resp->status);
    br_assert_equals(['mw1_in', 'mw2_in', 'handler', 'mw2_out', 'mw1_out'], $log);
});

// -----------------------------------------------------------------------------
// Config tests (no .env required — we test failure modes)
// -----------------------------------------------------------------------------
echo "\n  Config\n";

$runner->run('Config fails loudly when .env missing', function () {
    $tmpDir = sys_get_temp_dir() . '/br_test_' . bin2hex(random_bytes(4));
    mkdir($tmpDir);
    try {
        $cfg = new \BlackRed\Bootstrap\Config($tmpDir);
        $caught = false;
        try {
            $cfg->load();
        } catch (\RuntimeException $e) {
            $caught = str_contains($e->getMessage(), '.env');
        }
        br_assert($caught, 'should complain about missing .env');
    } finally {
        @rmdir($tmpDir);
    }
});

$runner->run('Config fails on missing required keys', function () {
    $tmpDir = sys_get_temp_dir() . '/br_test_' . bin2hex(random_bytes(4));
    mkdir($tmpDir);
    file_put_contents($tmpDir . '/.env', "APP_ENV=test\n");
    try {
        $cfg = new \BlackRed\Bootstrap\Config($tmpDir);
        $caught = false;
        try {
            $cfg->load();
        } catch (\RuntimeException $e) {
            $caught = str_contains($e->getMessage(), 'Missing required');
        }
        br_assert($caught, 'should complain about missing required keys');
    } finally {
        @unlink($tmpDir . '/.env');
        @rmdir($tmpDir);
    }
});

$runner->run('Config fails on too-short APP_SECRET', function () {
    $tmpDir = sys_get_temp_dir() . '/br_test_' . bin2hex(random_bytes(4));
    mkdir($tmpDir);
    file_put_contents($tmpDir . '/.env',
        "APP_ENV=test\nAPP_SECRET=tooshort\nDB_HOST=x\nDB_NAME=x\nDB_USER=x\n"
    );
    try {
        $cfg = new \BlackRed\Bootstrap\Config($tmpDir);
        $caught = false;
        try {
            $cfg->load();
        } catch (\RuntimeException $e) {
            $caught = str_contains($e->getMessage(), 'APP_SECRET');
        }
        br_assert($caught, 'should complain about short APP_SECRET');
    } finally {
        @unlink($tmpDir . '/.env');
        @rmdir($tmpDir);
    }
});

// -----------------------------------------------------------------------------
// Request base path stripping
// -----------------------------------------------------------------------------
echo "\n  Request\n";

$runner->run('Request strips configured base path', function () {
    $_SERVER['REQUEST_METHOD'] = 'GET';
    $_SERVER['REQUEST_URI'] = '/blackred/public/api/health';
    $_SERVER['REMOTE_ADDR'] = '127.0.0.1';
    $req = \BlackRed\Http\Request::fromGlobals('/blackred/public');
    br_assert_equals('/api/health', $req->path);
});

$runner->run('Request leaves path alone when no base path configured', function () {
    $_SERVER['REQUEST_METHOD'] = 'GET';
    $_SERVER['REQUEST_URI'] = '/api/health';
    $_SERVER['REMOTE_ADDR'] = '127.0.0.1';
    $req = \BlackRed\Http\Request::fromGlobals('');
    br_assert_equals('/api/health', $req->path);
});

$runner->run('Request leaves path alone when base path does not match', function () {
    $_SERVER['REQUEST_METHOD'] = 'GET';
    $_SERVER['REQUEST_URI'] = '/something/else';
    $_SERVER['REMOTE_ADDR'] = '127.0.0.1';
    $req = \BlackRed\Http\Request::fromGlobals('/blackred/public');
    br_assert_equals('/something/else', $req->path);
});

// =============================================================================
// PHASE 2 TESTS
// =============================================================================

// -----------------------------------------------------------------------------
// Ghana phone normalisation
// -----------------------------------------------------------------------------
echo "\n  GhanaPhone\n";

$runner->run('Normalises 0244000001 to 233244000001', function () {
    br_assert_equals('233244000001', \BlackRed\Support\GhanaPhone::normalise('0244000001'));
});

$runner->run('Normalises +233244000001', function () {
    br_assert_equals('233244000001', \BlackRed\Support\GhanaPhone::normalise('+233244000001'));
});

$runner->run('Normalises 244000001 (no leading zero)', function () {
    br_assert_equals('233244000001', \BlackRed\Support\GhanaPhone::normalise('244000001'));
});

$runner->run('Normalises with spaces and dashes', function () {
    br_assert_equals('233244000001', \BlackRed\Support\GhanaPhone::normalise('024 4000 001'));
    br_assert_equals('233244000001', \BlackRed\Support\GhanaPhone::normalise('024-400-0001'));
});

$runner->run('Rejects invalid lengths', function () {
    br_assert(\BlackRed\Support\GhanaPhone::normalise('024') === null, 'too short');
    br_assert(\BlackRed\Support\GhanaPhone::normalise('024400000123456') === null, 'too long');
});

$runner->run('Rejects non-mobile prefix (only 02X/05X allowed)', function () {
    // 23310... isn't a Ghana mobile prefix (would be a landline range)
    br_assert(\BlackRed\Support\GhanaPhone::normalise('233100000001') === null, '233 1XX should be null');
    br_assert(\BlackRed\Support\GhanaPhone::normalise('233300000001') === null, '233 3XX should be null');
    br_assert(\BlackRed\Support\GhanaPhone::normalise('233900000001') === null, '233 9XX should be null');
});

$runner->run('Accepts any 02X or 05X prefix loosely', function () {
    // Even unknown sub-prefixes within 02X/05X get through — ANM is the
    // authoritative source for whether the number actually exists.
    br_assert(\BlackRed\Support\GhanaPhone::normalise('0299000001') !== null, '0299 (unknown 02X) should pass');
    br_assert(\BlackRed\Support\GhanaPhone::normalise('0588000001') !== null, '0588 (unknown 05X) should pass');
});

$runner->run('Detects MTN provider', function () {
    br_assert_equals('MTN', \BlackRed\Support\GhanaPhone::provider('233244000001'));
    br_assert_equals('MTN', \BlackRed\Support\GhanaPhone::provider('233544000001'));
});

$runner->run('Detects ATL (AirtelTigo) provider', function () {
    br_assert_equals('ATL', \BlackRed\Support\GhanaPhone::provider('233264000001'));
    br_assert_equals('ATL', \BlackRed\Support\GhanaPhone::provider('233564000001'));
});

$runner->run('Detects TEL (Telecel) provider', function () {
    br_assert_equals('TEL', \BlackRed\Support\GhanaPhone::provider('233204000001'));
    br_assert_equals('TEL', \BlackRed\Support\GhanaPhone::provider('233504000001'));
});

$runner->run('Formats phone for display', function () {
    br_assert_equals('+233 24 400 0001', \BlackRed\Support\GhanaPhone::formatForDisplay('233244000001'));
});

// -----------------------------------------------------------------------------
// Validator
// -----------------------------------------------------------------------------
echo "\n  Validator\n";

$runner->run('requireString returns trimmed value', function () {
    $v = new \BlackRed\Validation\Validator(['name' => '  Hello  ']);
    br_assert_equals('Hello', $v->requireString('name'));
});

$runner->run('requireString rejects missing field', function () {
    $v = new \BlackRed\Validation\Validator([]);
    $caught = false;
    try { $v->requireString('name'); }
    catch (\BlackRed\Http\Middleware\HttpException $e) { $caught = $e->status === 400; }
    br_assert($caught, 'should throw 400');
});

$runner->run('requirePassword enforces uppercase, digit, symbol', function () {
    // Missing uppercase
    $v = new \BlackRed\Validation\Validator(['p' => 'pass1234!']);
    $caught = false;
    try { $v->requirePassword('p'); }
    catch (\BlackRed\Http\Middleware\HttpException $e) {
        $caught = str_contains($e->getMessage(), 'uppercase');
    }
    br_assert($caught, 'no-uppercase password should be rejected');

    // Missing digit
    $v2 = new \BlackRed\Validation\Validator(['p' => 'PassPass!']);
    $caught2 = false;
    try { $v2->requirePassword('p'); }
    catch (\BlackRed\Http\Middleware\HttpException $e) {
        $caught2 = str_contains($e->getMessage(), 'number');
    }
    br_assert($caught2, 'no-digit password should be rejected');

    // Missing symbol
    $v3 = new \BlackRed\Validation\Validator(['p' => 'Password123']);
    $caught3 = false;
    try { $v3->requirePassword('p'); }
    catch (\BlackRed\Http\Middleware\HttpException $e) {
        $caught3 = str_contains($e->getMessage(), 'symbol');
    }
    br_assert($caught3, 'no-symbol password should be rejected');

    // Valid: uppercase + digit + symbol + 8 chars
    $v4 = new \BlackRed\Validation\Validator(['p' => 'Password1!']);
    br_assert_equals('Password1!', $v4->requirePassword('p'));
});

$runner->run('requirePhone normalises to canonical form', function () {
    $v = new \BlackRed\Validation\Validator(['phone' => '0244000001']);
    br_assert_equals('233244000001', $v->requirePhone('phone'));
});

$runner->run('requireDigits enforces exact length', function () {
    $v = new \BlackRed\Validation\Validator(['code' => '123456']);
    br_assert_equals('123456', $v->requireDigits('code', 6));

    $v2 = new \BlackRed\Validation\Validator(['code' => '12345']);
    $caught = false;
    try { $v2->requireDigits('code', 6); }
    catch (\BlackRed\Http\Middleware\HttpException $e) { $caught = true; }
    br_assert($caught, 'wrong-length should be rejected');
});

$runner->run('optionalEmail accepts valid email', function () {
    $v = new \BlackRed\Validation\Validator(['e' => 'Test@Example.COM']);
    br_assert_equals('test@example.com', $v->optionalEmail('e'));
});

$runner->run('optionalEmail rejects malformed email', function () {
    $v = new \BlackRed\Validation\Validator(['e' => 'not-an-email']);
    $caught = false;
    try { $v->optionalEmail('e'); }
    catch (\BlackRed\Http\Middleware\HttpException $e) { $caught = true; }
    br_assert($caught, 'malformed email should be rejected');
});

$runner->run('optionalEmail returns null when missing', function () {
    $v = new \BlackRed\Validation\Validator([]);
    br_assert($v->optionalEmail('e') === null, 'should return null');
});

// -----------------------------------------------------------------------------
// Password hashing
// -----------------------------------------------------------------------------
echo "\n  PasswordHasher\n";

// Helper: build a real loaded Config with a temp .env
function br_test_config(): \BlackRed\Bootstrap\Config {
    $tmpDir = sys_get_temp_dir() . '/br_test_' . bin2hex(random_bytes(4));
    mkdir($tmpDir);
    file_put_contents($tmpDir . '/.env',
        "APP_ENV=test\n" .
        "APP_SECRET=" . str_repeat('a', 64) . "\n" .
        "DB_HOST=localhost\nDB_NAME=x\nDB_USER=x\n"
    );
    $cfg = new \BlackRed\Bootstrap\Config($tmpDir);
    $cfg->load();
    register_shutdown_function(function () use ($tmpDir) {
        @unlink($tmpDir . '/.env');
        @rmdir($tmpDir);
    });
    return $cfg;
}

$runner->run('PasswordHasher hashes and verifies', function () {
    $h = new \BlackRed\Auth\PasswordHasher(br_test_config());
    $hash = $h->hash('correct horse battery staple');
    br_assert(strlen($hash) > 30, 'hash should be substantial');
    br_assert($h->verify('correct horse battery staple', $hash), 'correct password verifies');
    br_assert(!$h->verify('wrong password', $hash), 'wrong password rejected');
});

$runner->run('PasswordHasher rejects empty password', function () {
    $h = new \BlackRed\Auth\PasswordHasher(br_test_config());
    $caught = false;
    try { $h->hash(''); }
    catch (\RuntimeException $e) { $caught = true; }
    br_assert($caught, 'empty password should throw');
});

$runner->run('PasswordHasher constant-time on verify', function () {
    $h = new \BlackRed\Auth\PasswordHasher(br_test_config());
    br_assert(!$h->verify('', 'some-hash'), 'empty plaintext returns false');
    br_assert(!$h->verify('pass', ''), 'empty hash returns false');
});

// -----------------------------------------------------------------------------
// Auth middleware cookie parsing
// -----------------------------------------------------------------------------
echo "\n  AuthMiddleware\n";

$runner->run('AuthMiddleware parses cookie from header', function () {
    // Build a minimal Request with a Cookie header
    $r = (new ReflectionClass(\BlackRed\Http\Request::class))->newInstanceWithoutConstructor();
    foreach ([
        'method' => 'GET', 'path' => '/api/me', 'rawBody' => '',
        'headers' => ['cookie' => 'OTHER=x; BR_SESSION=' . str_repeat('a', 64) . '; YET_ANOTHER=z'],
        'query' => [], 'clientIp' => '127.0.0.1', 'userAgent' => '', 'requestId' => 'r1',
    ] as $k => $v) {
        $p = (new ReflectionClass(\BlackRed\Http\Request::class))->getProperty($k);
        $p->setValue($r, $v);
    }

    // Call extractCookie via reflection (private method).
    // We use newInstanceWithoutConstructor so we don't have to build the constructor args.
    $authClass = new ReflectionClass(\BlackRed\Http\Middleware\AuthMiddleware::class);
    $method = $authClass->getMethod('extractCookie');
    $method->setAccessible(true);
    $instance = $authClass->newInstanceWithoutConstructor();
    $token = $method->invoke($instance, $r, 'BR_SESSION');
    br_assert_equals(str_repeat('a', 64), $token);
});

$runner->run('AuthMiddleware rejects malformed cookie value', function () {
    $r = (new ReflectionClass(\BlackRed\Http\Request::class))->newInstanceWithoutConstructor();
    foreach ([
        'method' => 'GET', 'path' => '/x', 'rawBody' => '',
        'headers' => ['cookie' => 'BR_SESSION=not-hex-value-and-too-short'],
        'query' => [], 'clientIp' => '1.1.1.1', 'userAgent' => '', 'requestId' => 'r2',
    ] as $k => $v) {
        $p = (new ReflectionClass(\BlackRed\Http\Request::class))->getProperty($k);
        $p->setValue($r, $v);
    }
    $authClass = new ReflectionClass(\BlackRed\Http\Middleware\AuthMiddleware::class);
    $method = $authClass->getMethod('extractCookie');
    $method->setAccessible(true);
    $instance = $authClass->newInstanceWithoutConstructor();
    $token = $method->invoke($instance, $r, 'BR_SESSION');
    br_assert($token === null, 'malformed cookie value should be rejected');
});

$runner->run('AuthMiddleware returns null when no cookie header', function () {
    $r = (new ReflectionClass(\BlackRed\Http\Request::class))->newInstanceWithoutConstructor();
    foreach ([
        'method' => 'GET', 'path' => '/x', 'rawBody' => '',
        'headers' => [],
        'query' => [], 'clientIp' => '1.1.1.1', 'userAgent' => '', 'requestId' => 'r3',
    ] as $k => $v) {
        $p = (new ReflectionClass(\BlackRed\Http\Request::class))->getProperty($k);
        $p->setValue($r, $v);
    }
    $authClass = new ReflectionClass(\BlackRed\Http\Middleware\AuthMiddleware::class);
    $method = $authClass->getMethod('extractCookie');
    $method->setAccessible(true);
    $instance = $authClass->newInstanceWithoutConstructor();
    $token = $method->invoke($instance, $r, 'BR_SESSION');
    br_assert($token === null, 'should return null when no cookie header');
});

// -----------------------------------------------------------------------------
// Money formatter
// -----------------------------------------------------------------------------
echo "\n  Money\n";

$runner->run('Money::pesewasToString formats correctly', function () {
    br_assert_equals('0.00', \BlackRed\Support\Money::pesewasToString(0));
    br_assert_equals('0.01', \BlackRed\Support\Money::pesewasToString(1));
    br_assert_equals('0.50', \BlackRed\Support\Money::pesewasToString(50));
    br_assert_equals('1.00', \BlackRed\Support\Money::pesewasToString(100));
    br_assert_equals('123.45', \BlackRed\Support\Money::pesewasToString(12345));
    br_assert_equals('-2.50', \BlackRed\Support\Money::pesewasToString(-250));
    br_assert_equals('1000000.00', \BlackRed\Support\Money::pesewasToString(100000000));
});

$runner->run('Money::pesewasToGhs prefixes with currency', function () {
    br_assert_equals('GHS 0.00', \BlackRed\Support\Money::pesewasToGhs(0));
    br_assert_equals('GHS 12.50', \BlackRed\Support\Money::pesewasToGhs(1250));
});

$runner->run('Money::parseGhsToPesewas parses valid amounts', function () {
    br_assert_equals(0, \BlackRed\Support\Money::parseGhsToPesewas('0'));
    br_assert_equals(0, \BlackRed\Support\Money::parseGhsToPesewas('0.00'));
    br_assert_equals(100, \BlackRed\Support\Money::parseGhsToPesewas('1'));
    br_assert_equals(100, \BlackRed\Support\Money::parseGhsToPesewas('1.00'));
    br_assert_equals(150, \BlackRed\Support\Money::parseGhsToPesewas('1.5'));
    br_assert_equals(150, \BlackRed\Support\Money::parseGhsToPesewas('1.50'));
    br_assert_equals(12345, \BlackRed\Support\Money::parseGhsToPesewas('123.45'));
});

$runner->run('Money::parseGhsToPesewas rejects garbage', function () {
    br_assert(\BlackRed\Support\Money::parseGhsToPesewas('') === null, 'empty');
    br_assert(\BlackRed\Support\Money::parseGhsToPesewas('abc') === null, 'letters');
    br_assert(\BlackRed\Support\Money::parseGhsToPesewas('-10') === null, 'negative not allowed');
    br_assert(\BlackRed\Support\Money::parseGhsToPesewas('1.234') === null, 'too many decimals');
    br_assert(\BlackRed\Support\Money::parseGhsToPesewas('GHS 10') === null, 'no symbols');
});

// -----------------------------------------------------------------------------
// Validator additions
// -----------------------------------------------------------------------------
echo "\n  Validator additions\n";

$runner->run('requireOneOf accepts valid choice', function () {
    $v = new \BlackRed\Validation\Validator(['network' => 'mtn']);
    br_assert_equals('mtn', $v->requireOneOf('network', ['mtn', 'at', 'telecel']));
});

$runner->run('requireOneOf rejects invalid choice', function () {
    $v = new \BlackRed\Validation\Validator(['network' => 'glo']);
    $caught = false;
    try { $v->requireOneOf('network', ['mtn', 'at', 'telecel']); }
    catch (\BlackRed\Http\Middleware\HttpException $e) { $caught = true; }
    br_assert($caught, 'glo should be rejected');
});

$runner->run('requireOtpCode normalises to uppercase', function () {
    $v = new \BlackRed\Validation\Validator(['code' => 'a1b2']);
    br_assert_equals('A1B2', $v->requireOtpCode('code', 4));
});

$runner->run('requireOtpCode enforces exact length', function () {
    $v = new \BlackRed\Validation\Validator(['code' => 'AB1']);
    $caught = false;
    try { $v->requireOtpCode('code', 4); }
    catch (\BlackRed\Http\Middleware\HttpException $e) { $caught = true; }
    br_assert($caught, '3 chars should be rejected when 4 expected');
});

$runner->run('requireOtpCode rejects non-alphanumeric', function () {
    $v = new \BlackRed\Validation\Validator(['code' => 'A1!2']);
    $caught = false;
    try { $v->requireOtpCode('code', 4); }
    catch (\BlackRed\Http\Middleware\HttpException $e) { $caught = true; }
    br_assert($caught, 'symbol should be rejected');
});

// -----------------------------------------------------------------------------
// ANM signature builder
// -----------------------------------------------------------------------------
echo "\n  AnmSignatureBuilder\n";

$runner->run('AnmSignatureBuilder produces deterministic HMAC', function () {
    $signer = new \BlackRed\Integrations\AnmSignatureBuilder('CK', 'SK');
    $body = '{"a":1}';
    $auth1 = $signer->authHeader($body);
    $auth2 = $signer->authHeader($body);
    br_assert_equals($auth1, $auth2, 'same input → same output');
    br_assert(str_starts_with($auth1, 'CK:'), 'authHeader prefixed with client key');
    // Verify the HMAC matches manual computation
    $expected = 'CK:' . hash_hmac('sha256', $body, 'SK');
    br_assert_equals($expected, $auth1);
});

$runner->run('AnmSignatureBuilder rejects empty credentials', function () {
    $caught = false;
    try {
        new \BlackRed\Integrations\AnmSignatureBuilder('', 'sk');
    } catch (\InvalidArgumentException $e) {
        $caught = true;
    }
    br_assert($caught, 'empty client key should throw');
});

$runner->run('AnmClient::bankCodeFor maps providers correctly', function () {
    br_assert_equals('MTN', \BlackRed\Integrations\AnmClient::bankCodeFor('MTN'));
    br_assert_equals('AIR', \BlackRed\Integrations\AnmClient::bankCodeFor('ATL'));
    br_assert_equals('VOD', \BlackRed\Integrations\AnmClient::bankCodeFor('TEL'));

    $caught = false;
    try { \BlackRed\Integrations\AnmClient::bankCodeFor('GLO'); }
    catch (\InvalidArgumentException $e) { $caught = true; }
    br_assert($caught, 'unknown provider should throw');
});

// -----------------------------------------------------------------------------
// Done
// -----------------------------------------------------------------------------
exit($runner->summary());
