<?php

declare(strict_types=1);

namespace BlackRed\Auth;

use BlackRed\Database\Connection;

/**
 * AuthtokenVerifier — verifies an OTP entered in the app against codes
 * issued by the live USSD endpoint (*920*995#).
 *
 * The authtoken table is owned by the USSD endpoint, not by this application.
 * We treat it strictly as a read source and only DELETE rows after successful
 * verification (so the same code can't be used twice).
 *
 * Schema (managed by USSD code, do not change here):
 *   authtoken (
 *     id INT,
 *     phonenumber VARCHAR(20),    -- format: "0244XXXXXX" (10 digits, leading 0)
 *     codehash VARCHAR(255),       -- hash('sha512', $code)
 *     expirydate DATE,
 *     expirytime TIME,
 *     ip_address VARCHAR(45),
 *     device_id VARCHAR(100),      -- holds CSRF token from USSD, not actual device
 *     request_time DATETIME
 *   )
 *
 * Code format: 4 alphanumeric characters (digits + uppercase letters minus
 * the visually ambiguous ones). Comparison is case-sensitive, but we
 * normalise to uppercase first since USSD generates uppercase only.
 *
 * Security note: SHA-512 of a 4-char alphanumeric code is brute-forceable
 * if the table is dumped — only ~1M possible inputs. This matches the live
 * USSD's hash. For production hardening, both endpoints should switch to
 * Argon2id. Tracked as a known issue.
 */
final class AuthtokenVerifier
{
    public function __construct(private readonly Connection $db)
    {
    }

    /**
     * Verify a code against the most recent unexpired token for the phone.
     *
     * @param string $phoneCanonical  233XXXXXXXXX form (12 digits)
     * @param string $code            User-entered code (will be uppercased before hashing)
     * @return bool                   true on match, false otherwise
     */
    public function verify(string $phoneCanonical, string $code): bool
    {
        // Convert canonical 233-prefixed back to 0-prefixed for the lookup,
        // since the USSD endpoint stores it that way.
        $phoneLocal = $this->canonicalToLocal($phoneCanonical);
        if ($phoneLocal === null) {
            return false;
        }

        // Normalise the code: trim and uppercase. The USSD generates uppercase only.
        $code = strtoupper(trim($code));
        if ($code === '' || strlen($code) > 32) {
            return false;
        }

        $hash = hash('sha512', $code);

        // Find the latest unexpired token for this phone.
        // expiry is split across two columns in the schema: expirydate + expirytime.
        $row = $this->db->fetchOne(
            'SELECT id, codehash
             FROM authtoken
             WHERE phonenumber = :phone
               AND CONCAT(expirydate, " ", expirytime) > NOW()
             ORDER BY id DESC LIMIT 1',
            ['phone' => $phoneLocal]
        );

        if ($row === null) {
            return false;
        }

        // Constant-time comparison (defence in depth — hash output length is fixed)
        if (!hash_equals((string)$row['codehash'], $hash)) {
            return false;
        }

        // One-time use: delete this token (and any other still-valid ones for
        // this phone) so the code cannot be replayed.
        $this->db->execute(
            'DELETE FROM authtoken WHERE phonenumber = :phone',
            ['phone' => $phoneLocal]
        );

        return true;
    }

    /**
     * Convert canonical form (233XXXXXXXXX) to local form (0XXXXXXXXX).
     */
    private function canonicalToLocal(string $canonical): ?string
    {
        if (strlen($canonical) !== 12 || !str_starts_with($canonical, '233')) {
            return null;
        }
        return '0' . substr($canonical, 3);
    }
}
