<?php

declare(strict_types=1);

namespace PpPhp\Core\Auth;

use PpPhp\Core\DB;
use PpPhp\Core\Security;
use PpPhp\Core\Email;
use PpPhp\Core\SMS;

/**
 * Two-Factor Authentication for a generated app.
 *
 * The admin (Security module) decides which methods are AVAILABLE — authenticator
 * (TOTP), email or SMS. Each user then turns on exactly ONE of them from their own
 * account: the `user_2fa` row holds their single chosen `method`, the TOTP secret
 * (authenticator only) and a set of single-use backup codes.
 *
 * Which user-table column holds the email / phone is configured by the admin and
 * shipped as PP_2FA_EMAIL_FIELD / PP_2FA_PHONE_FIELD.
 *
 * Secrets and backup codes are stored raw (TOTP secret the user provisioned) or
 * hashed (backup / email / SMS codes) and never returned in plaintext once saved,
 * except backup codes shown once at creation.
 */
final class TwoFactor
{
    private const BACKUP_COUNT = 10;
    private const OTP_TTL       = 600;   // seconds an email/SMS code stays valid

    public const METHODS = ['totp', 'email', 'sms'];

    // ── store ───────────────────────────────────────────────────────────
    public static function ensureTable(): void
    {
        try {
            DB::Exec(
                'CREATE TABLE IF NOT EXISTS `user_2fa` ('
                . 'user_id INT NOT NULL PRIMARY KEY, '
                . 'method VARCHAR(10) DEFAULT NULL, '
                . 'secret VARCHAR(255) DEFAULT NULL, '
                . 'enabled TINYINT(1) NOT NULL DEFAULT 0, '
                . 'backup_codes TEXT DEFAULT NULL, '
                . 'created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP'
                . ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
            );
            foreach (['method' => "VARCHAR(10) DEFAULT NULL", 'backup_codes' => 'TEXT DEFAULT NULL'] as $col => $ddl) {
                $has = DB::FetchAll("SHOW COLUMNS FROM `user_2fa` LIKE '" . $col . "'");
                if (!is_array($has) || $has === []) {
                    DB::Exec('ALTER TABLE `user_2fa` ADD COLUMN ' . $col . ' ' . $ddl);
                }
            }
        } catch (\Throwable $e) {
            // best effort
        }
    }

    /** @return array<string,mixed>|null */
    public static function record(int $userId): ?array
    {
        if ($userId <= 0) return null;
        self::ensureTable();
        try {
            $rows = DB::FetchAll('SELECT user_id, method, secret, backup_codes FROM `user_2fa` WHERE user_id = ?', [$userId]);
            return is_array($rows) && $rows ? $rows[0] : null;
        } catch (\Throwable $e) {
            return null;
        }
    }

    /** The user's single active method, or '' if none. */
    public static function activeMethod(int $userId): string
    {
        $m = (string) (self::record($userId)['method'] ?? '');
        return in_array($m, self::METHODS, true) ? $m : '';
    }

    public static function hasAny(int $userId): bool
    {
        return self::activeMethod($userId) !== '';
    }

    /** Back-compat alias used by the login flow / runtime gate. */
    public static function isEnabled(int $userId): bool
    {
        return self::hasAny($userId);
    }

    public static function secretFor(int $userId): string
    {
        return (string) (self::record($userId)['secret'] ?? '');
    }

    public static function backupCount(int $userId): int
    {
        $codes = json_decode((string) (self::record($userId)['backup_codes'] ?? '[]'), true);
        return is_array($codes) ? count($codes) : 0;
    }

    // ── enrolment (one method at a time) ────────────────────────────────
    public static function newSecret(): string
    {
        return Security::TOTP()->GenerateSecret();
    }

    public static function otpauthUri(string $username, string $secret, string $issuer): string
    {
        return Security::TOTP()->GetQRCodeUrl($username, $secret, $issuer !== '' ? $issuer : 'App');
    }

    public static function verifySecret(string $secret, string $code): bool
    {
        $code = preg_replace('/\D/', '', $code);
        return $secret !== '' && $code !== '' && Security::TOTP()->Verify($secret, $code);
    }

    public static function verifyTotp(int $userId, string $code): bool
    {
        return self::verifySecret(self::secretFor($userId), $code);
    }

    /** Switch the user's active method to authenticator. @return list<string> fresh backup codes (once) */
    public static function enableTotp(int $userId, string $secret): array
    {
        return self::setMethod($userId, 'totp', $secret);
    }

    /** Switch to email codes. @return list<string> fresh backup codes (once) */
    public static function enableEmail(int $userId): array
    {
        return self::setMethod($userId, 'email', '');
    }

    /** Switch to SMS codes. @return list<string> fresh backup codes (once) */
    public static function enableSms(int $userId): array
    {
        return self::setMethod($userId, 'sms', '');
    }

    /** Turn 2FA off entirely for the user. */
    public static function disable(int $userId): void
    {
        try { DB::Delete('user_2fa', ['user_id' => $userId]); } catch (\Throwable $e) {}
    }

    private static function setMethod(int $userId, string $method, string $secret): array
    {
        self::ensureTable();
        $data = ['method' => $method, 'secret' => $secret, 'enabled' => 1];
        try {
            if (self::record($userId) !== null) {
                DB::Update('user_2fa', $data, ['user_id' => $userId]);
            } else {
                DB::Insert('user_2fa', array_merge(['user_id' => $userId], $data));
            }
        } catch (\Throwable $e) { return []; }
        if (self::activeMethod($userId) !== $method) return [];
        return self::ensureBackupCodes($userId);
    }

    // ── email / SMS one-time codes ──────────────────────────────────────
    /** Send a 6-digit code by the user's active channel. */
    public static function sendOtp(int $userId, string $method, string $contact, string $appName, string $template = ''): bool
    {
        if ($userId <= 0 || $contact === '') return false;
        $code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
        $_SESSION['pp_2fa_otp'] = ['uid' => $userId, 'method' => $method, 'hash' => password_hash($code, PASSWORD_DEFAULT), 'exp' => time() + self::OTP_TTL];
        $app = $appName !== '' ? $appName : 'Your account';
        try {
            if ($method === 'sms') {
                if (!class_exists('\\PpPhp\\Core\\SMS')) return false;
                $msg = $template !== '' ? str_replace('{code}', $code, $template) : ($app . ' code: ' . $code);
                $res = SMS::Send($contact, $msg);
                return !empty($res['sent']) || !empty($res['success']) || !empty($res['queued']);
            }
            if (!filter_var($contact, FILTER_VALIDATE_EMAIL)) return false;
            $line = $template !== '' ? htmlspecialchars(str_replace('{code}', $code, $template)) : ('Your ' . htmlspecialchars($app) . ' verification code is:');
            $body = '<p>' . $line . '</p><p style="font-size:26px;font-weight:800;letter-spacing:4px">' . $code . '</p>'
                . '<p style="color:#64748b;font-size:13px">It expires in 10 minutes. If you did not try to sign in, you can ignore this.</p>';
            $res = Email::send(['to' => $contact, 'subject' => $app . ' verification code', 'htmlbody' => $body]);
            return !empty($res['mailed']);
        } catch (\Throwable $e) {
            return false;
        }
    }

    public static function verifyOtp(int $userId, string $code): bool
    {
        $s = $_SESSION['pp_2fa_otp'] ?? null;
        if (!is_array($s) || (int) ($s['uid'] ?? 0) !== $userId) return false;
        if ((int) ($s['exp'] ?? 0) < time()) { unset($_SESSION['pp_2fa_otp']); return false; }
        $code = preg_replace('/\D/', '', $code);
        if ($code === '' || !password_verify($code, (string) ($s['hash'] ?? ''))) return false;
        unset($_SESSION['pp_2fa_otp']);
        return true;
    }

    /** Read a user's contact value from a configured column (email/phone). */
    public static function contact(int $userId, string $field): string
    {
        $field = preg_replace('/[^A-Za-z0-9_]/', '', $field);
        if ($userId <= 0 || $field === '') return '';
        try {
            $rows = DB::FetchAll('SELECT `' . $field . '` AS v FROM `users` WHERE id = ? LIMIT 1', [$userId]);
            return is_array($rows) && $rows ? (string) ($rows[0]['v'] ?? '') : '';
        } catch (\Throwable $e) {
            return '';
        }
    }

    // ── backup codes ────────────────────────────────────────────────────
    public static function regenerateBackupCodes(int $userId): array
    {
        if (!self::hasAny($userId)) return [];
        [$plain, $hashed] = self::makeBackupCodes();
        try { DB::Update('user_2fa', ['backup_codes' => json_encode($hashed)], ['user_id' => $userId]); }
        catch (\Throwable $e) { return []; }
        return $plain;
    }

    public static function consumeBackupCode(int $userId, string $code): bool
    {
        $r = self::record($userId);
        if ($r === null) return false;
        $codes = json_decode((string) ($r['backup_codes'] ?? '[]'), true);
        if (!is_array($codes) || $codes === []) return false;
        $needle = self::normalize($code);
        if ($needle === '') return false;
        foreach ($codes as $i => $hash) {
            if (is_string($hash) && password_verify($needle, $hash)) {
                unset($codes[$i]);
                try { DB::Update('user_2fa', ['backup_codes' => json_encode(array_values($codes))], ['user_id' => $userId]); } catch (\Throwable $e) {}
                return true;
            }
        }
        return false;
    }

    private static function ensureBackupCodes(int $userId): array
    {
        $codes = json_decode((string) (self::record($userId)['backup_codes'] ?? '[]'), true);
        if (is_array($codes) && $codes !== []) return [];
        [$plain, $hashed] = self::makeBackupCodes();
        try { DB::Update('user_2fa', ['backup_codes' => json_encode($hashed)], ['user_id' => $userId]); }
        catch (\Throwable $e) { return []; }
        return $plain;
    }

    /** @return array{0:list<string>,1:list<string>} */
    private static function makeBackupCodes(): array
    {
        $plain = []; $hashed = [];
        for ($i = 0; $i < self::BACKUP_COUNT; $i++) {
            $raw = strtoupper(bin2hex(random_bytes(4)));
            $code = substr($raw, 0, 4) . '-' . substr($raw, 4, 4);
            $plain[] = $code;
            $hashed[] = password_hash(self::normalize($code), PASSWORD_DEFAULT);
        }
        return [$plain, $hashed];
    }

    private static function normalize(string $code): string
    {
        return strtoupper(preg_replace('/[^A-Za-z0-9]/', '', $code));
    }

    // ── login-pending gate ──────────────────────────────────────────────
    public static function setPending(string $username, bool $remember, int $uid = 0, string $email = ''): void
    {
        $_SESSION['pp_2fa_pending'] = ['username' => $username, 'remember' => $remember, 'uid' => $uid, 'email' => $email, 'ts' => time()];
    }

    /** @return array<string,mixed>|null */
    public static function pending(): ?array
    {
        $p = $_SESSION['pp_2fa_pending'] ?? null;
        return is_array($p) ? $p : null;
    }

    public static function clearPending(): void
    {
        unset($_SESSION['pp_2fa_pending'], $_SESSION['pp_2fa_otp']);
    }
}
