<?php
// Session login, remember-me, brute-force lockout, and the require-login
// guard every protected page (and later, every API endpoint) starts with.

require_once __DIR__ . '/helpers.php';
require_once __DIR__ . '/csrf.php';

// A fixed, valid-format bcrypt hash that no real password will ever match.
// Used to keep attempt_login()'s timing the same whether or not the
// username exists, so responses don't reveal which usernames are real.
const DUMMY_PASSWORD_HASH = '$2y$10$RcInn.6pImkIskssIWoQ8./FztwGkJraP8NrI7mYDt6nuhxrUxxAW';

function start_app_session(): void
{
    if (session_status() === PHP_SESSION_ACTIVE) {
        return;
    }

    // Some shared-hosting PHP configs (this app has hit both a cPanel
    // account and a local XAMPP install) point session.save_path at a
    // directory the running PHP process can't actually write to, which
    // makes session_start() fail outright — logins silently don't persist.
    // Rather than depend on server-level session config that varies (and
    // can be broken) across hosts, store sessions in our own directory
    // next to logs/ and backups/, web-blocked by .htaccess the same way.
    //
    // Permissions are deliberately permissive (0777, not owner-only):
    // on shared hosting PHP typically runs as the account owner, so 0700
    // would suffice there — but on local XAMPP, Apache/PHP commonly runs
    // as a different system user (e.g. 'daemon') than whoever extracted
    // the project files, and 0700 blocks exactly that user. This folder
    // holds only ephemeral session data and is never web-accessible, so
    // the trade-off is fine for a small household app either way.
    $sessionPath = __DIR__ . '/../sessions';
    if (!is_dir($sessionPath)) {
        @mkdir($sessionPath, 0777, true);
    }
    if (is_dir($sessionPath) && !is_writable($sessionPath)) {
        @chmod($sessionPath, 0777); // self-heal if it shipped with stale permissions
    }
    if (is_dir($sessionPath) && is_writable($sessionPath)) {
        session_save_path($sessionPath);
    }
    // If even that fails, deliberately leave session.save_path alone
    // rather than forcing sys_get_temp_dir(): on this exact XAMPP/macOS
    // setup the system temp dir is a per-user folder Apache can't reach
    // either, so forcing it would just trade one broken path for another.
    // Leaving PHP's own configured default in place is more likely to work.

    ini_set('session.use_strict_mode', '1');
    session_set_cookie_params([
        'lifetime' => 0,
        'path'     => '/',
        'secure'   => app_is_https(),
        'httponly' => true,
        'samesite' => 'Lax',
    ]);
    session_start();
}

start_app_session();

/** The logged-in user (from the session), or null. */
function current_user(): ?array
{
    if (empty($_SESSION['user_id'])) {
        return null;
    }

    return [
        'id'           => $_SESSION['user_id'],
        'username'     => $_SESSION['username'],
        'display_name' => $_SESSION['display_name'],
        'is_admin'     => (bool) ($_SESSION['is_admin'] ?? false),
    ];
}

/**
 * True for the admin account (Motaher), false for restricted accounts
 * (Sumona). Admin has unrestricted access everywhere; a non-admin can see
 * everything but only add expenses and edit/delete her own, can't touch
 * Income at all, and can view (not change) Budgets/Manage/admin Settings.
 */
function current_user_is_admin(): bool
{
    return (bool) (current_user()['is_admin'] ?? false);
}

/** Starts an authenticated session for a user row. Regenerates the session ID. */
function login_session(array $user): void
{
    session_regenerate_id(true);
    $_SESSION['user_id'] = (int) $user['id'];
    $_SESSION['username'] = $user['username'];
    $_SESSION['display_name'] = $user['display_name'];
    $_SESSION['is_admin'] = (bool) $user['is_admin'];
}

/**
 * Verifies username/password with per-user brute-force lockout.
 * Returns ['ok' => true, 'user' => [...]] or ['ok' => false, 'error' => string].
 */
function attempt_login(string $username, string $password): array
{
    $username = strtolower(trim($username));
    $db = get_db();

    $stmt = $db->prepare('SELECT * FROM users WHERE username = :username');
    $stmt->execute(['username' => $username]);
    $user = $stmt->fetch();

    if ($user && $user['locked_until'] !== null && strtotime($user['locked_until']) > time()) {
        return ['ok' => false, 'error' => 'Too many attempts. Please try again in a few minutes.'];
    }

    $hashToCheck = $user['password_hash'] ?? DUMMY_PASSWORD_HASH;
    $passwordOk = password_verify($password, $hashToCheck);

    if (!$user || !$passwordOk) {
        if ($user) {
            record_failed_login((int) $user['id'], (int) $user['failed_login_attempts']);
        }
        return ['ok' => false, 'error' => 'Wrong username or password.'];
    }

    $stmt = $db->prepare(
        'UPDATE users SET failed_login_attempts = 0, locked_until = NULL, last_login_at = NOW() WHERE id = :id'
    );
    $stmt->execute(['id' => $user['id']]);

    return ['ok' => true, 'user' => $user];
}

/** Bumps the failed-attempt counter; locks the account once it hits the limit. */
function record_failed_login(int $userId, int $currentAttempts): void
{
    $attempts = $currentAttempts + 1;
    $db = get_db();

    if ($attempts >= LOGIN_MAX_ATTEMPTS) {
        $stmt = $db->prepare(
            'UPDATE users SET failed_login_attempts = 0,
                locked_until = DATE_ADD(NOW(), INTERVAL :minutes MINUTE) WHERE id = :id'
        );
        $stmt->execute(['minutes' => LOGIN_LOCKOUT_MINUTES, 'id' => $userId]);
        return;
    }

    $stmt = $db->prepare('UPDATE users SET failed_login_attempts = :attempts WHERE id = :id');
    $stmt->execute(['attempts' => $attempts, 'id' => $userId]);
}

/** Issues a fresh remember-me token for the user and sets the cookie. */
function create_remember_me(int $userId): void
{
    $selector = bin2hex(random_bytes(6));   // 12 hex chars
    $validator = bin2hex(random_bytes(32)); // 64 hex chars
    $hash = hash('sha256', $validator);
    $expiresAt = date('Y-m-d H:i:s', time() + REMEMBER_COOKIE_DAYS * 86400);

    $stmt = get_db()->prepare(
        'INSERT INTO auth_tokens (user_id, selector, token_hash, expires_at)
         VALUES (:user_id, :selector, :hash, :expires_at)'
    );
    $stmt->execute(['user_id' => $userId, 'selector' => $selector, 'hash' => $hash, 'expires_at' => $expiresAt]);

    set_remember_cookie($selector, $validator);
}

function set_remember_cookie(string $selector, string $validator): void
{
    setcookie(REMEMBER_COOKIE_NAME, $selector . ':' . $validator, [
        'expires'  => time() + REMEMBER_COOKIE_DAYS * 86400,
        'path'     => '/',
        'secure'   => app_is_https(),
        'httponly' => true,
        'samesite' => 'Lax',
    ]);
}

function clear_remember_cookie(): void
{
    setcookie(REMEMBER_COOKIE_NAME, '', [
        'expires'  => time() - 3600,
        'path'     => '/',
        'secure'   => app_is_https(),
        'httponly' => true,
        'samesite' => 'Lax',
    ]);
    unset($_COOKIE[REMEMBER_COOKIE_NAME]);
}

/** Deletes expired remember-me tokens. Called on every login. */
function purge_expired_tokens(): void
{
    get_db()->exec('DELETE FROM auth_tokens WHERE expires_at < NOW()');
}

/** Tries to log the visitor in silently via the remember-me cookie. Rotates the token on success. */
function try_remember_me_login(): bool
{
    if (empty($_COOKIE[REMEMBER_COOKIE_NAME])) {
        return false;
    }

    $parts = explode(':', $_COOKIE[REMEMBER_COOKIE_NAME], 2);
    if (count($parts) !== 2) {
        clear_remember_cookie();
        return false;
    }

    [$selector, $validator] = $parts;
    $db = get_db();

    $stmt = $db->prepare('SELECT * FROM auth_tokens WHERE selector = :selector');
    $stmt->execute(['selector' => $selector]);
    $token = $stmt->fetch();

    if (!$token || strtotime($token['expires_at']) < time()
        || !hash_equals($token['token_hash'], hash('sha256', $validator))) {
        clear_remember_cookie();
        return false;
    }

    $stmt = $db->prepare('SELECT * FROM users WHERE id = :id');
    $stmt->execute(['id' => $token['user_id']]);
    $user = $stmt->fetch();

    if (!$user) {
        clear_remember_cookie();
        return false;
    }

    $db->prepare('DELETE FROM auth_tokens WHERE id = :id')->execute(['id' => $token['id']]);

    login_session($user);
    create_remember_me($user['id']);
    purge_expired_tokens();
    $db->prepare('UPDATE users SET last_login_at = NOW() WHERE id = :id')->execute(['id' => $user['id']]);

    return true;
}

/** Ends the session and clears the remember-me token (DB row + cookie). */
function logout(): void
{
    if (!empty($_COOKIE[REMEMBER_COOKIE_NAME])) {
        $parts = explode(':', $_COOKIE[REMEMBER_COOKIE_NAME], 2);
        if (count($parts) === 2) {
            get_db()->prepare('DELETE FROM auth_tokens WHERE selector = :selector')
                ->execute(['selector' => $parts[0]]);
        }
        clear_remember_cookie();
    }

    $_SESSION = [];
    session_destroy();
}

/** Wipes all of a user's remember-me tokens (used on password change, Phase 7). */
function wipe_remember_tokens(int $userId): void
{
    get_db()->prepare('DELETE FROM auth_tokens WHERE user_id = :id')->execute(['id' => $userId]);
}

/** Every protected page (and later API endpoint) calls this first. */
function require_login(): void
{
    if (!empty($_SESSION['user_id'])) {
        return;
    }

    if (try_remember_me_login()) {
        return;
    }

    header('Location: login.php');
    exit;
}

/**
 * For pages/endpoints fully restricted to the admin (Income). Call after
 * require_login(). Non-admins are quietly sent back to the dashboard —
 * the nav link is already hidden for them, so this only fires on a direct
 * URL visit, and doesn't need to announce why.
 */
function require_admin(): void
{
    if (current_user_is_admin()) {
        return;
    }

    header('Location: index.php');
    exit;
}
