<?php
// =============================================================
//  include/PendingAuthToken.php
//
//  Short-lived, stateless, signed tokens for multi-step mobile-API auth
//  flows (2FA challenge, forced first-login password change, OTP password
//  reset) — the API equivalent of the $_SESSION['pending_2fa_user_id'] /
//  $_SESSION['temp_user_id'] / $_SESSION['reset_*'] keys the web flows use
//  in login.php / change_password.php / ajax/forgot_password_handler.php.
//
//  Deliberately NOT session- or DB-backed: a mobile HTTP client can't be
//  relied on to carry a session cookie between calls the way a browser
//  does, and these flows don't need a new table — the payload (which
//  account, which purpose, an OTP hash where relevant) travels inside the
//  token itself, HMAC-signed so it can't be forged or replayed past its
//  expiry. Same rotating-secret philosophy as include/CheckinToken.php,
//  namespaced onto the same CHECKIN_HMAC_SECRET so no new .env entry is
//  needed for something this small.
// =============================================================

final class PendingAuthToken
{
    private static function secret(?string $secretOverride = null): string
    {
        $s = $secretOverride ?? env('CHECKIN_HMAC_SECRET', '');
        if ($s === '') {
            throw new RuntimeException('CHECKIN_HMAC_SECRET is not configured in .env');
        }
        return $s . ':pending_auth';
    }

    /**
     * @param array $payload Small JSON-serializable data — must include 'purpose'
     *   ('2fa' | 'password_change' | 'password_reset') plus whatever that step needs.
     */
    public static function issue(array $payload, int $ttlSeconds = 300, ?string $secretOverride = null): string
    {
        $payload['exp'] = time() + $ttlSeconds;
        $b64 = rtrim(strtr(base64_encode(json_encode($payload)), '+/', '-_'), '=');
        $sig = hash_hmac('sha256', $b64, self::secret($secretOverride));
        return $b64 . '.' . $sig;
    }

    /** @return array|null Decoded payload (including 'purpose'), or null if malformed/expired/tampered. */
    public static function verify(string $token, ?string $expectedPurpose = null, ?string $secretOverride = null): ?array
    {
        $parts = explode('.', $token, 2);
        if (count($parts) !== 2) return null;
        [$b64, $sig] = $parts;

        if (!hash_equals(hash_hmac('sha256', $b64, self::secret($secretOverride)), $sig)) return null;

        $padded  = str_pad(strtr($b64, '-_', '+/'), strlen($b64) % 4 === 0 ? strlen($b64) : strlen($b64) + (4 - strlen($b64) % 4), '=');
        $payload = json_decode((string)base64_decode($padded), true);

        if (!is_array($payload) || !isset($payload['exp']) || (int)$payload['exp'] < time()) return null;
        if ($expectedPurpose !== null && ($payload['purpose'] ?? null) !== $expectedPurpose) return null;

        return $payload;
    }
}
