<?php

declare(strict_types=1);

/**
 * DentalCare v0.9.0.3 privileged multi-factor authentication.
 *
 * TOTP secrets are encrypted with the installed application key. Recovery
 * codes are one-way keyed hashes and are displayed only once at enrollment.
 */

function dental_mfa_methods_table(): string
{
    return table('user_mfa_methods');
}

function dental_mfa_recovery_table(): string
{
    return table('user_mfa_recovery_codes');
}

function dental_mfa_schema_ready(): bool
{
    try {
        $methods = dental_mfa_methods_table();
        $codes = dental_mfa_recovery_table();
        if (!schema_table_exists($methods) || !schema_table_exists($codes)) return false;
        foreach (['user_id','method_type','secret_encrypted','enabled_at','revoked_at'] as $column) {
            if (!schema_column_exists($methods, $column)) return false;
        }
        foreach (['user_id','code_hash','used_at','created_at'] as $column) {
            if (!schema_column_exists($codes, $column)) return false;
        }
        return true;
    } catch (Throwable) {
        return false;
    }
}

function dental_mfa_application_key(): string
{
    $key = trim((string)app_config('app.key', ''));
    if ($key === '' || $key === 'generated-during-installation') {
        throw new RuntimeException('The application encryption key is not configured. Restore a valid config/generated.php before enabling MFA.');
    }
    return hash('sha256', $key, true);
}

function dental_mfa_encrypt_secret(string $plain): string
{
    if (!extension_loaded('openssl')) {
        throw new RuntimeException('The PHP OpenSSL extension is required for MFA secret protection.');
    }
    $iv = random_bytes(12);
    $tag = '';
    $cipher = openssl_encrypt($plain, 'aes-256-gcm', dental_mfa_application_key(), OPENSSL_RAW_DATA, $iv, $tag, 'DentalCare-MFA-v1', 16);
    if ($cipher === false || strlen($tag) !== 16) {
        throw new RuntimeException('Unable to protect the MFA secret.');
    }
    return base64_encode("DCM1" . $iv . $tag . $cipher);
}

function dental_mfa_decrypt_secret(string $payload): string
{
    if (!extension_loaded('openssl')) return '';
    $raw = base64_decode($payload, true);
    if ($raw === false || strlen($raw) < 33 || substr($raw, 0, 4) !== 'DCM1') return '';
    $iv = substr($raw, 4, 12);
    $tag = substr($raw, 16, 16);
    $cipher = substr($raw, 32);
    $plain = openssl_decrypt($cipher, 'aes-256-gcm', dental_mfa_application_key(), OPENSSL_RAW_DATA, $iv, $tag, 'DentalCare-MFA-v1');
    return is_string($plain) ? $plain : '';
}

function dental_mfa_base32_encode(string $binary): string
{
    $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
    $bits = '';
    foreach (str_split($binary) as $char) $bits .= str_pad(decbin(ord($char)), 8, '0', STR_PAD_LEFT);
    $result = '';
    foreach (str_split($bits, 5) as $chunk) {
        $chunk = str_pad($chunk, 5, '0', STR_PAD_RIGHT);
        $result .= $alphabet[bindec($chunk)];
    }
    return $result;
}

function dental_mfa_base32_decode(string $encoded): string
{
    $encoded = strtoupper(preg_replace('/[^A-Z2-7]/', '', $encoded) ?? '');
    if ($encoded === '') return '';
    $alphabet = array_flip(str_split('ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'));
    $bits = '';
    foreach (str_split($encoded) as $char) {
        if (!isset($alphabet[$char])) return '';
        $bits .= str_pad(decbin((int)$alphabet[$char]), 5, '0', STR_PAD_LEFT);
    }
    $result = '';
    foreach (str_split($bits, 8) as $chunk) {
        if (strlen($chunk) < 8) break;
        $result .= chr(bindec($chunk));
    }
    return $result;
}

function dental_mfa_generate_secret(): string
{
    return dental_mfa_base32_encode(random_bytes(20));
}

function dental_mfa_totp_code(string $secret, ?int $timestamp = null): string
{
    $key = dental_mfa_base32_decode($secret);
    if ($key === '') return '';
    $counter = intdiv($timestamp ?? time(), 30);
    $binaryCounter = pack('N2', 0, $counter);
    $hash = hash_hmac('sha1', $binaryCounter, $key, true);
    $offset = ord($hash[19]) & 0x0f;
    $value = ((ord($hash[$offset]) & 0x7f) << 24)
        | ((ord($hash[$offset + 1]) & 0xff) << 16)
        | ((ord($hash[$offset + 2]) & 0xff) << 8)
        | (ord($hash[$offset + 3]) & 0xff);
    return str_pad((string)($value % 1000000), 6, '0', STR_PAD_LEFT);
}

function dental_mfa_verify_totp(string $secret, string $code, int $window = 1): bool
{
    $code = preg_replace('/\D/', '', $code) ?? '';
    if (strlen($code) !== 6) return false;
    $now = time();
    for ($step = -$window; $step <= $window; $step++) {
        $candidate = dental_mfa_totp_code($secret, $now + ($step * 30));
        if ($candidate !== '' && hash_equals($candidate, $code)) return true;
    }
    return false;
}

function dental_mfa_active_method(int $userId): ?array
{
    if ($userId < 1 || !dental_mfa_schema_ready()) return null;
    try {
        $stmt = db()->prepare('SELECT * FROM `' . dental_mfa_methods_table() . '` WHERE user_id=? AND method_type="totp" AND enabled_at IS NOT NULL AND revoked_at IS NULL ORDER BY id DESC LIMIT 1');
        $stmt->execute([$userId]);
        $row = $stmt->fetch();
        return is_array($row) ? $row : null;
    } catch (Throwable) {
        return null;
    }
}

function dental_mfa_user_enabled(int $userId): bool
{
    return dental_mfa_active_method($userId) !== null;
}

/**
 * Return whether MFA can be enrolled and verified safely on this installation.
 *
 * The enrollment gate must never lock the only Superuser behind a feature that
 * cannot work because the migration tables, OpenSSL extension, or application
 * encryption key are unavailable.
 */
function dental_mfa_prerequisites_status(): array
{
    $schemaReady = dental_mfa_schema_ready();
    $opensslReady = extension_loaded('openssl');
    $keyReady = false;
    $reason = '';

    if (!$schemaReady) {
        $reason = 'schema_unavailable';
    } elseif (!$opensslReady) {
        $reason = 'openssl_unavailable';
    } else {
        try {
            dental_mfa_application_key();
            $keyReady = true;
        } catch (Throwable) {
            $reason = 'application_key_unavailable';
        }
    }

    return [
        'ready' => $schemaReady && $opensslReady && $keyReady,
        'schema_ready' => $schemaReady,
        'openssl_ready' => $opensslReady,
        'application_key_ready' => $keyReady,
        'reason' => $reason,
    ];
}

function dental_mfa_active_method_count(): int
{
    if (!dental_mfa_schema_ready()) return 0;
    try {
        return (int) db()->query(
            'SELECT COUNT(*) FROM `' . dental_mfa_methods_table()
            . '` WHERE method_type="totp" AND enabled_at IS NOT NULL AND revoked_at IS NULL'
        )->fetchColumn();
    } catch (Throwable) {
        return 0;
    }
}

function dental_mfa_user_has_role(array $user, string $role): bool
{
    $roles = [];
    if (!empty($user['role'])) $roles[] = (string) $user['role'];
    if (!empty($user['role_codes']) && is_array($user['role_codes'])) {
        foreach ($user['role_codes'] as $assignedRole) $roles[] = (string) $assignedRole;
    }
    return in_array($role, array_values(array_unique($roles)), true);
}

/**
 * Bootstrap escape for the signed-in Superuser only.
 *
 * This is intentionally narrow: it applies when the account still needs MFA
 * but either MFA is not operational or no MFA method exists anywhere yet.
 * It prevents a fresh rollout from locking the owner out before the first
 * authenticator can be enrolled. Once any working method exists, normal
 * mandatory enrollment resumes.
 */
function dental_mfa_bootstrap_access_allowed(array $user): bool
{
    $userId = (int) ($user['id'] ?? 0);
    if ($userId < 1 || !dental_mfa_user_has_role($user, 'super_admin')) return false;
    if (!dental_mfa_policy_required_for_user($user) || dental_mfa_user_enabled($userId)) return false;

    $prerequisites = dental_mfa_prerequisites_status();
    if (empty($prerequisites['ready'])) return true;

    return dental_mfa_active_method_count() === 0;
}

function dental_mfa_user_has_any_role(array $user, array $roles): bool
{
    $assigned = [];
    if (!empty($user['role'])) $assigned[] = (string)$user['role'];
    if (!empty($user['role_codes']) && is_array($user['role_codes'])) {
        foreach ($user['role_codes'] as $role) $assigned[] = (string)$role;
    }
    $assigned = array_values(array_unique(array_filter($assigned)));
    foreach ($roles as $role) {
        if (in_array((string)$role, $assigned, true)) return true;
    }
    return false;
}

/**
 * MFA is an optional account-security feature by default. It must never change
 * a Dentist's clinical permissions or redirect an account into a profile loop.
 * A future clinic-wide mandatory policy can be enabled only through the
 * explicit `mfa_enforcement_mode=required` setting.
 */
function dental_mfa_dentist_limited_access_allowed(array $user): bool
{
    return false;
}

function dental_mfa_limited_access_active_for_current_user(): bool
{
    return false;
}

function dental_mfa_enforcement_enabled(): bool
{
    try {
        return strtolower(trim((string)setting_get('security', 'mfa_enforcement_mode', 'optional'))) === 'required';
    } catch (Throwable) {
        return false;
    }
}

function dental_mfa_enrollment_gate_required(array $user): bool
{
    if (!dental_mfa_user_requires_enrollment($user)) return false;

    // Never trap an account when MFA cannot operate on the current host.
    $prerequisites = dental_mfa_prerequisites_status();
    if (empty($prerequisites['ready'])) return false;

    return !dental_mfa_bootstrap_access_allowed($user);
}

function dental_mfa_policy_required_for_role(string $role): bool
{
    if (!dental_mfa_enforcement_enabled()) return false;
    if (!function_exists('dental_security_privileged_role') || !dental_security_privileged_role($role)) return false;
    try {
        return (string)setting_get('security', 'require_mfa_privileged', '0') === '1';
    } catch (Throwable) {
        return false;
    }
}

function dental_mfa_policy_required_for_user(array $user): bool
{
    $roles = [];
    if (!empty($user['role'])) $roles[] = (string)$user['role'];
    if (!empty($user['role_codes']) && is_array($user['role_codes'])) {
        foreach ($user['role_codes'] as $role) $roles[] = (string)$role;
    }
    $roles = array_values(array_unique(array_filter($roles)));
    foreach ($roles as $role) {
        if (dental_mfa_policy_required_for_role($role)) return true;
    }
    return false;
}

function dental_mfa_user_requires_enrollment(array $user): bool
{
    $userId = (int)($user['id'] ?? 0);
    return $userId > 0 && dental_mfa_policy_required_for_user($user) && !dental_mfa_user_enabled($userId);
}

function dental_mfa_recovery_hash(string $code): string
{
    $normalized = strtoupper(preg_replace('/[^A-Z0-9]/', '', $code) ?? '');
    return hash_hmac('sha256', $normalized, dental_mfa_application_key());
}

function dental_mfa_generate_recovery_codes(int $count = 10): array
{
    $alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
    $codes = [];
    for ($i = 0; $i < $count; $i++) {
        $raw = '';
        for ($n = 0; $n < 12; $n++) $raw .= $alphabet[random_int(0, strlen($alphabet) - 1)];
        $codes[] = substr($raw, 0, 4) . '-' . substr($raw, 4, 4) . '-' . substr($raw, 8, 4);
    }
    return $codes;
}

function dental_mfa_verify_recovery_code(int $userId, string $code): bool
{
    if ($userId < 1 || !dental_mfa_schema_ready()) return false;
    $hash = dental_mfa_recovery_hash($code);
    $pdo = db();
    try {
        $pdo->beginTransaction();
        $stmt = $pdo->prepare('SELECT id,code_hash FROM `' . dental_mfa_recovery_table() . '` WHERE user_id=? AND used_at IS NULL FOR UPDATE');
        $stmt->execute([$userId]);
        foreach ($stmt->fetchAll() as $row) {
            if (!hash_equals((string)$row['code_hash'], $hash)) continue;
            $pdo->prepare('UPDATE `' . dental_mfa_recovery_table() . '` SET used_at=NOW(),used_ip=? WHERE id=? AND used_at IS NULL')
                ->execute([function_exists('dental_security_client_ip') ? dental_security_client_ip() : null, (int)$row['id']]);
            $pdo->commit();
            return true;
        }
        $pdo->rollBack();
    } catch (Throwable) {
        if ($pdo->inTransaction()) $pdo->rollBack();
    }
    return false;
}

function dental_mfa_verify_user_code(int $userId, string $code, bool $allowRecovery = true): bool
{
    $method = dental_mfa_active_method($userId);
    if (!$method) return false;
    $secret = dental_mfa_decrypt_secret((string)($method['secret_encrypted'] ?? ''));
    if ($secret !== '' && dental_mfa_verify_totp($secret, $code)) {
        try {
            db()->prepare('UPDATE `' . dental_mfa_methods_table() . '` SET last_used_at=NOW(),updated_at=NOW() WHERE id=?')->execute([(int)$method['id']]);
        } catch (Throwable) {
        }
        return true;
    }
    return $allowRecovery && dental_mfa_verify_recovery_code($userId, $code);
}

function dental_mfa_begin_pending_login(array $user, string $destination): void
{
    session_regenerate_id(true);
    unset($_SESSION['user']);
    $safeUser = [
        'id' => (int)($user['id'] ?? 0),
        'branch_id' => isset($user['branch_id']) && $user['branch_id'] !== null ? (int)$user['branch_id'] : null,
        'name' => (string)($user['name'] ?? ''),
        'email' => (string)($user['email'] ?? ''),
        'role' => (string)($user['role'] ?? ''),
    ];
    $_SESSION['mfa_pending_login'] = [
        'user' => $safeUser,
        'destination' => $destination,
        'created_at' => time(),
        'attempts' => 0,
    ];
}

function dental_mfa_pending_login(): ?array
{
    $pending = $_SESSION['mfa_pending_login'] ?? null;
    if (!is_array($pending)) return null;
    if ((int)($pending['created_at'] ?? 0) < time() - 300 || (int)($pending['attempts'] ?? 0) >= 8) {
        unset($_SESSION['mfa_pending_login']);
        return null;
    }
    return $pending;
}

function dental_mfa_clear_pending_login(): void
{
    unset($_SESSION['mfa_pending_login']);
}

function dental_mfa_initialize_staff_session(array $user, bool $mfaVerified): void
{
    $now = time();
    $_SESSION['staff_session_started_at'] = $now;
    $_SESSION['staff_session_last_rotated_at'] = $now;
    $_SESSION['staff_session_user_agent_hash'] = hash('sha256', function_exists('dental_security_user_agent') ? dental_security_user_agent() : (string)($_SERVER['HTTP_USER_AGENT'] ?? ''));
    $_SESSION['last_activity'] = $now;
    $_SESSION['mfa_verified_at'] = $mfaVerified ? $now : 0;
    $_SESSION['mfa_enrollment_required'] = dental_mfa_enrollment_gate_required($user);
}

function dental_mfa_staff_session_valid(array $user): bool
{
    $started = (int)($_SESSION['staff_session_started_at'] ?? 0);
    $agentHash = (string)($_SESSION['staff_session_user_agent_hash'] ?? '');
    if ($started < 1 || $agentHash === '') {
        // Backward-compatible initialization for sessions created before v0.9.0.3.
        dental_mfa_initialize_staff_session($user, false);
        return true;
    }
    $absoluteHours = 12;
    try { $absoluteHours = max(1, min(24, (int)setting_get('security', 'staff_absolute_session_hours', '12'))); } catch (Throwable) {}
    $currentAgentHash = hash('sha256', function_exists('dental_security_user_agent') ? dental_security_user_agent() : (string)($_SERVER['HTTP_USER_AGENT'] ?? ''));
    $reason = '';
    if (time() - $started > $absoluteHours * 3600) $reason = 'absolute_timeout';
    elseif (!hash_equals($agentHash, $currentAgentHash)) $reason = 'client_fingerprint_changed';
    if ($reason === '') return true;
    if (function_exists('dental_security_record_event')) {
        dental_security_record_event('staff_session_ended', 'warning', ['reason'=>$reason], (int)($user['id'] ?? 0));
    }
    if (function_exists('dental_security_end_current_session')) dental_security_end_current_session($reason);
    unset($_SESSION['user'], $_SESSION['last_activity'], $_SESSION['mfa_verified_at'], $_SESSION['mfa_enrollment_required'], $_SESSION['staff_session_started_at'], $_SESSION['staff_session_last_rotated_at'], $_SESSION['staff_session_user_agent_hash']);
    return false;
}

function dental_mfa_touch_staff_session(array $user): void
{
    $lastRotated = (int)($_SESSION['staff_session_last_rotated_at'] ?? 0);
    $rotationMinutes = 15;
    try { $rotationMinutes = max(5, min(60, (int)setting_get('security', 'staff_session_rotation_minutes', '15'))); } catch (Throwable) {}
    if ($lastRotated > 0 && time() - $lastRotated < $rotationMinutes * 60) return;
    if (headers_sent()) return;
    if (function_exists('dental_security_end_current_session')) dental_security_end_current_session('session_rotation');
    session_regenerate_id(true);
    $_SESSION['staff_session_last_rotated_at'] = time();
    if (function_exists('dental_security_track_current_session')) dental_security_track_current_session($user);
}

function dental_mfa_enrollment_required_for_current_user(): bool
{
    $user = $_SESSION['user'] ?? null;
    if (!is_array($user)) return false;

    $required = dental_mfa_enrollment_gate_required($user);
    $_SESSION['mfa_enrollment_required'] = $required;

    if (!$required
        && dental_mfa_user_requires_enrollment($user)
        && dental_mfa_bootstrap_access_allowed($user)) {
        $_SESSION['mfa_bootstrap_access_active'] = true;
        if (empty($_SESSION['mfa_bootstrap_access_logged'])) {
            $_SESSION['mfa_bootstrap_access_logged'] = time();
            if (function_exists('dental_security_record_event')) {
                $status = dental_mfa_prerequisites_status();
                dental_security_record_event(
                    'mfa_bootstrap_access',
                    'warning',
                    [
                        'reason' => !empty($status['ready']) ? 'no_enrolled_method' : (string) ($status['reason'] ?? 'unavailable'),
                        'scope' => 'superuser_recovery_only',
                    ],
                    (int) ($user['id'] ?? 0),
                    'user',
                    (int) ($user['id'] ?? 0)
                );
            }
        }
    } else {
        unset($_SESSION['mfa_bootstrap_access_active']);
    }

    return $required;
}

function dental_mfa_begin_enrollment(int $userId, string $email): array
{
    $prerequisites = dental_mfa_prerequisites_status();
    if ($userId < 1 || empty($prerequisites['ready'])) {
        $message = match ((string) ($prerequisites['reason'] ?? '')) {
            'schema_unavailable' => 'Apply or repair the v0.9.0.3 database update before enabling MFA.',
            'openssl_unavailable' => 'Enable the PHP OpenSSL extension before enabling MFA.',
            'application_key_unavailable' => 'Restore a valid application encryption key before enabling MFA.',
            default => 'MFA enrollment is not available on this installation.',
        };
        throw new RuntimeException($message);
    }
    $secret = dental_mfa_generate_secret();
    $_SESSION['mfa_enrollment'] = ['user_id'=>$userId,'secret'=>$secret,'created_at'=>time()];
    $issuer = preg_replace('/[^A-Za-z0-9 ._-]/', '', (string)app_config('app.name', 'DentalCare')) ?: 'DentalCare';
    $label = rawurlencode($issuer . ':' . strtolower($email));
    $uri = 'otpauth://totp/' . $label . '?secret=' . rawurlencode($secret) . '&issuer=' . rawurlencode($issuer) . '&algorithm=SHA1&digits=6&period=30';
    return ['secret'=>$secret,'uri'=>$uri];
}

function dental_mfa_pending_enrollment(int $userId): ?array
{
    $pending = $_SESSION['mfa_enrollment'] ?? null;
    if (!is_array($pending) || (int)($pending['user_id'] ?? 0) !== $userId || (int)($pending['created_at'] ?? 0) < time() - 600) {
        unset($_SESSION['mfa_enrollment']);
        return null;
    }
    return $pending;
}

function dental_mfa_confirm_enrollment(int $userId, string $code): array
{
    $pending = dental_mfa_pending_enrollment($userId);
    if (!$pending) throw new RuntimeException('The MFA enrollment session expired. Start enrollment again.');
    $secret = (string)$pending['secret'];
    if (!dental_mfa_verify_totp($secret, $code)) throw new RuntimeException('The authenticator code is incorrect or expired. Enter the current six-digit code.');
    $codes = dental_mfa_generate_recovery_codes();
    $pdo = db();
    $pdo->beginTransaction();
    try {
        $pdo->prepare('UPDATE `' . dental_mfa_methods_table() . '` SET revoked_at=NOW(),updated_at=NOW() WHERE user_id=? AND revoked_at IS NULL')->execute([$userId]);
        $stmt = $pdo->prepare('INSERT INTO `' . dental_mfa_methods_table() . '` (user_id,method_type,secret_encrypted,label,enabled_at,last_used_at,created_at,updated_at) VALUES (?,"totp",?,?,NOW(),NOW(),NOW(),NOW())');
        $stmt->execute([$userId, dental_mfa_encrypt_secret($secret), 'Authenticator app']);
        $pdo->prepare('DELETE FROM `' . dental_mfa_recovery_table() . '` WHERE user_id=?')->execute([$userId]);
        $insert = $pdo->prepare('INSERT INTO `' . dental_mfa_recovery_table() . '` (user_id,code_hash,created_at) VALUES (?,?,NOW())');
        foreach ($codes as $recoveryCode) $insert->execute([$userId, dental_mfa_recovery_hash($recoveryCode)]);
        $pdo->commit();
    } catch (Throwable $exception) {
        if ($pdo->inTransaction()) $pdo->rollBack();
        throw $exception;
    }
    unset($_SESSION['mfa_enrollment']);
    $_SESSION['mfa_verified_at'] = time();
    $_SESSION['mfa_enrollment_required'] = false;
    if (function_exists('dental_security_record_event')) dental_security_record_event('mfa_enabled', 'info', ['method'=>'totp'], $userId, 'user', $userId);
    return $codes;
}

function dental_mfa_disable(int $userId, string $password, string $code): void
{
    if ($userId < 1 || !dental_mfa_user_enabled($userId)) throw new RuntimeException('MFA is not enabled for this account.');
    $stmt = db()->prepare('SELECT password_hash FROM `' . table('users') . '` WHERE id=? LIMIT 1');
    $stmt->execute([$userId]);
    $hash = (string)$stmt->fetchColumn();
    if ($hash === '' || !password_verify($password, $hash)) throw new RuntimeException('The current password is incorrect.');
    if (!dental_mfa_verify_user_code($userId, $code, true)) throw new RuntimeException('The authenticator or recovery code is incorrect.');
    $pdo = db();
    $pdo->beginTransaction();
    try {
        $pdo->prepare('UPDATE `' . dental_mfa_methods_table() . '` SET revoked_at=NOW(),updated_at=NOW() WHERE user_id=? AND revoked_at IS NULL')->execute([$userId]);
        $pdo->prepare('DELETE FROM `' . dental_mfa_recovery_table() . '` WHERE user_id=?')->execute([$userId]);
        $pdo->commit();
    } catch (Throwable $exception) {
        if ($pdo->inTransaction()) $pdo->rollBack();
        throw $exception;
    }
    $_SESSION['mfa_enrollment_required'] = isset($_SESSION['user']) && is_array($_SESSION['user'])
        ? dental_mfa_enrollment_gate_required($_SESSION['user'])
        : false;
    if (function_exists('dental_security_record_event')) dental_security_record_event('mfa_disabled', 'warning', [], $userId, 'user', $userId);
}

function dental_mfa_recovery_codes_remaining(int $userId): int
{
    if ($userId < 1 || !dental_mfa_schema_ready()) return 0;
    try {
        $stmt = db()->prepare('SELECT COUNT(*) FROM `' . dental_mfa_recovery_table() . '` WHERE user_id=? AND used_at IS NULL');
        $stmt->execute([$userId]);
        return (int)$stmt->fetchColumn();
    } catch (Throwable) {
        return 0;
    }
}
