<?php
// =============================================================================
// Admin auth helpers
// =============================================================================

function auth_user() {
    if (empty($_SESSION['admin_id'])) {
        return null;
    }
    static $cached = null;
    if ($cached !== null && (int)$cached['id'] === (int)$_SESSION['admin_id']) {
        return $cached;
    }
    $cached = db_one('SELECT id, username, role, last_login_at, totp_enabled FROM admin_users WHERE id = ?',
                     [(int)$_SESSION['admin_id']]);
    return $cached;
}

function auth_check_password($username, $password) {
    $row = db_one('SELECT id, username, password_hash, totp_secret, totp_enabled FROM admin_users WHERE username = ?', [$username]);
    // Always run password_verify (against a dummy hash if the user doesn't
    // exist) so the response timing for a wrong username matches a wrong
    // password — no username enumeration via login timing.
    // Valid cost-10 bcrypt hash that no real password can match. Used purely
    // to soak up the same CPU time as the real-user branch so wrong-username
    // and wrong-password requests take roughly the same wall clock.
    static $dummy_hash = '$2y$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy';
    if (!$row) {
        password_verify($password, $dummy_hash);
        return false;
    }
    if (!password_verify($password, $row['password_hash'])) {
        return false;
    }
    return $row;
}

function auth_login($admin_row) {
    session_regenerate_id(true);
    $_SESSION['admin_id'] = (int)$admin_row['id'];
    $_SESSION['admin_username'] = $admin_row['username'];
    db_exec('UPDATE admin_users SET last_login_at = ? WHERE id = ?',
            [unix_now(), (int)$admin_row['id']]);
}

function auth_logout() {
    $_SESSION = [];
    if (ini_get('session.use_cookies')) {
        $params = session_get_cookie_params();
        setcookie(session_name(), '', time() - 42000,
            $params['path'], $params['domain'], $params['secure'], $params['httponly']);
    }
    session_destroy();
}

// File-based per-IP login throttle. Tracks failed attempts in a JSON file
// under data/ so brute-force callers don't get unlimited tries. After 5
// failures inside 10 min, lock that IP for 15 min. Resets on successful login.
function login_throttle_path() {
    return __DIR__ . '/../data/.login_throttle.json';
}

function login_throttle_read() {
    $path = login_throttle_path();
    if (!is_file($path)) return [];
    $raw = @file_get_contents($path);
    if ($raw === false) return [];
    $j = json_decode($raw, true);
    return is_array($j) ? $j : [];
}

function login_throttle_write(array $state) {
    $path = login_throttle_path();
    $dir = dirname($path);
    if (!is_dir($dir)) { @mkdir($dir, 0755, true); }
    @file_put_contents($path, json_encode($state), LOCK_EX);
}

// Combined throttle + auth step that holds an exclusive flock across the whole
// read → check → auth → record → write cycle. Solves the original TOCTOU race
// where N parallel failed logins could each read state {failed:0,…} before any
// of them wrote, so all N would bypass the lockout. Concurrent attempts now
// serialise on the lock; under attack each attempt costs one bcrypt verify and
// the lockout engages after exactly 5 failures regardless of parallelism.
//
// $auth_callback must return truthy on success, falsey on failure.
// Returns ['locked' => bool, 'locked_msg' => string|null, 'auth_result' => mixed]
function login_throttle_step($ip, callable $auth_callback) {
    if ($ip === '') {
        return ['locked' => false, 'locked_msg' => null, 'auth_result' => $auth_callback()];
    }
    $path = login_throttle_path();
    $dir = dirname($path);
    if (!is_dir($dir)) { @mkdir($dir, 0755, true); }
    $fp = @fopen($path, 'c+');
    if (!$fp || !flock($fp, LOCK_EX)) {
        if ($fp) fclose($fp);
        // Lock unavailable — fall back to unsafe but functional (don't take the
        // panel down because of a broken filesystem).
        return ['locked' => false, 'locked_msg' => null, 'auth_result' => $auth_callback()];
    }

    $raw   = stream_get_contents($fp);
    $state = ($raw !== false && ($j = json_decode((string)$raw, true)) && is_array($j)) ? $j : [];
    $now   = time();

    // Sweep stale entries (anything inactive for over an hour).
    foreach ($state as $k => $e) {
        if (!is_array($e)) { unset($state[$k]); continue; }
        $u = (int)($e['until'] ?? 0); $f = (int)($e['first'] ?? 0);
        if ($u !== 0 && $u < $now - 3600) unset($state[$k]);
        elseif ($u === 0 && $f < $now - 3600) unset($state[$k]);
    }

    $entry = $state[$ip] ?? null;
    if (is_array($entry) && (int)($entry['until'] ?? 0) > $now) {
        $secs = (int)$entry['until'] - $now;
        $mins = max(1, (int)ceil($secs / 60));
        $msg  = 'Too many failed attempts. Try again in ' . $mins . ' minute' . ($mins === 1 ? '' : 's') . '.';
        flock($fp, LOCK_UN); fclose($fp);
        return ['locked' => true, 'locked_msg' => $msg, 'auth_result' => null];
    }

    // Not locked — run auth inside the critical section.
    $auth_result = $auth_callback();

    if ($auth_result) {
        unset($state[$ip]);
    } else {
        $e = is_array($entry) ? $entry : ['failed' => 0, 'first' => $now, 'until' => 0];
        if ((int)($e['first'] ?? 0) < $now - 600) {
            $e = ['failed' => 0, 'first' => $now, 'until' => 0];
        }
        $e['failed'] = (int)($e['failed'] ?? 0) + 1;
        if ($e['failed'] >= 5) {
            $e['until'] = $now + 900;
        }
        $state[$ip] = $e;
    }

    ftruncate($fp, 0);
    rewind($fp);
    fwrite($fp, json_encode($state));
    fflush($fp);
    flock($fp, LOCK_UN);
    fclose($fp);

    return ['locked' => false, 'locked_msg' => null, 'auth_result' => $auth_result];
}

function login_throttle_check($ip) {
    if ($ip === '') return null;
    $now = time();
    $state = login_throttle_read();
    $entry = $state[$ip] ?? null;
    if (!is_array($entry)) return null;
    $until = (int)($entry['until'] ?? 0);
    if ($until > $now) {
        $secs = $until - $now;
        $mins = max(1, (int)ceil($secs / 60));
        return 'Too many failed attempts. Try again in ' . $mins . ' minute' . ($mins === 1 ? '' : 's') . '.';
    }
    return null;
}

function login_throttle_record_failure($ip) {
    if ($ip === '') return;
    $now = time();
    $state = login_throttle_read();
    // Sweep stale entries (any lockout that ended > 1 h ago).
    foreach ($state as $k => $e) {
        if (!is_array($e)) { unset($state[$k]); continue; }
        $u = (int)($e['until'] ?? 0); $f = (int)($e['first'] ?? 0);
        if ($u !== 0 && $u < $now - 3600) unset($state[$k]);
        elseif ($u === 0 && $f < $now - 3600) unset($state[$k]);
    }
    $entry = $state[$ip] ?? ['failed' => 0, 'first' => $now, 'until' => 0];
    if ((int)$entry['first'] < $now - 600) {
        $entry = ['failed' => 0, 'first' => $now, 'until' => 0];
    }
    $entry['failed']++;
    if ($entry['failed'] >= 5) {
        $entry['until'] = $now + 900;  // 15 min lockout
    }
    $state[$ip] = $entry;
    login_throttle_write($state);
}

function login_throttle_reset($ip) {
    if ($ip === '') return;
    $state = login_throttle_read();
    if (isset($state[$ip])) {
        unset($state[$ip]);
        login_throttle_write($state);
    }
}

function auth_role() {
    $me = auth_user();
    return $me ? (string)($me['role'] ?? 'admin') : null;
}

function auth_is_admin() {
    return auth_role() === 'admin';
}

function auth_require_admin($msg = 'Admins only.') {
    if (!auth_is_admin()) {
        json_error($msg, 403);
    }
}

function auth_require_login() {
    if (!auth_user()) {
        $is_ajax = false;
        if (isset($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest') {
            $is_ajax = true;
        }
        if (!$is_ajax && isset($_SERVER['HTTP_ACCEPT']) && strpos($_SERVER['HTTP_ACCEPT'], 'application/json') !== false) {
            $is_ajax = true;
        }
        if ($is_ajax) {
            json_error('unauthorized', 401);
        }
        // Use the panel-base-rooted login URL so nested pages redirect
        // correctly instead of resolving relative to the current path.
        $login = function_exists('panel_url') ? panel_url('login.php') : 'login.php';
        header('Location: ' . $login);
        exit;
    }
}
