<?php

use Models\Character;

/**
 * Load the environment variables from the .env file.
 */
function env_load(string $filePath): void
{
    if (!file_exists($filePath)) throw new Exception("The .env file does not exist. (el)");

    $lines = file($filePath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
    foreach ($lines as $line) {
        $line = trim($line);

        // Skip lines that are empty after trimming or are comments
        if ($line === '' || str_starts_with($line, '#')) continue;

		// Skip lines without an '=' character
        if (strpos($line, '=') === false) continue;

        [$name, $value] = explode('=', $line, 2);

        $name = trim($name);
        $value = trim($value, " \t\n\r\0\x0B\"'"); // Trim whitespace and quotes

        if (!array_key_exists($name, $_SERVER) && !array_key_exists($name, $_ENV)) {
            putenv("$name=$value");
            $_ENV[$name] = $value;
            $_SERVER[$name] = $value;
        }
    }
}


/**
 * Retrieve an environment variable.
 */
function env(string $key, mixed $default = null): mixed
{
	$v = $_ENV[$key] ?? $_SERVER[$key] ?? (getenv($key) ?: $default);
	return match(true) {
		$v === 'true'  => true,
		$v === 'false' => false,
		is_numeric($v) => (int) $v,
		is_float($v)   => (float) $v,
		default        => $v
	};
}

/**
 * Return the path to a view file.
 */
function template(string $name): string
{
	return SRC . "/../templates/$name.php";
}

/**
 * Render a view with the given data. Looks for `$view` through `template()`.
 */
function render(string $path_to_base_view, array $data = []): string|false
{
	ob_start();
	extract($data);
	require template($path_to_base_view);
	return ob_get_clean();
}

/**
 * Get the auth database connection from GLOBALS['databases'], or create it if it doesn't exist.
 */
function auth_db(): Database
{
	return $GLOBALS['databases']['auth'] ??= new Database(DATABASE_PATH . '/auth.db');
}

/**
 * Get the live database connection from GLOBALS['databases'], or create it if it doesn't exist.
 */
function live_db(): Database
{
	return $GLOBALS['databases']['live'] ??= new Database(DATABASE_PATH . '/live.db');
}

/**
 * Generate a pretty dope token.
 */
function token($length = 32): string
{
	return bin2hex(random_bytes($length));
}

/**
 * Redirect to a new location.
 */
function redirect($location): void
{
	header("Location: $location");
	exit;
}

/**
 * Flash data to the session, or retrieve an existing flash value. Returns false if the key does not exist.
 */
function flash($key, $value = '')
{
	if ($value === '') return $_SESSION["flash_$key"] ?? false;
	$_SESSION["flash_$key"] = $value;
	return $value;
}

/**
 * Clear a specific flash message.
 */
function unflash($key)
{
	unset($_SESSION["flash_$key"]);
}

/**
 * Clear all flash messages.
 */
function clear_flashes()
{
	foreach ($_SESSION as $key => $_) {
		if (str_starts_with($key, 'flash_')) unset($_SESSION[$key]);
	}
}

/**
 * Create a CSRF token.
 */
function csrf()
{
	if (empty($_SESSION['csrf'])) $_SESSION['csrf'] = token();
	return $_SESSION['csrf'];
}

/**
 * Create a hidden input field for CSRF tokens.
 */
function csrf_field()
{
	return '<input type="hidden" name="csrf" value="' . csrf() . '">';
}

/**
 * Kill the current request with a 418 error, if $_POST['csrf'] is invalid.
 */
function csrf_ensure()
{
	$csrf = $_POST['csrf'] ?? $_SERVER['HTTP_X_CSRF'] ?? '';
	if (!hash_equals($_SESSION['csrf'] ?? '', $csrf)) error_response(418);
}

/**
 * Set a cookie with secure and HTTP-only flags.
 */
function set_cookie($name, $value, $expires)
{
	setcookie($name, $value, [
		'expires' => $expires,
		'path' => '/',
		'domain' => '', // Defaults to the current domain
		'secure' => true, // Ensure the cookie is only sent over HTTPS
		'httponly' => true, // Prevent access to cookie via JavaScript
		'samesite' => 'Strict' // Enforce SameSite=Strict
	]);
}

/**
 * Get the current user's object from SESSION if it exists.
 */
function user(): Models\User|false
{
	if (empty($_SESSION['user'])) return false;
	return $GLOBALS['state']['user'] ??= unserialize($_SESSION['user']);
}

/**
 * Modify a field in the user session object. Returns true on success and false on failure.
 */
function modify_user_session(string $field, mixed $value): bool
{
	$user = user();
	if ($user === false || !property_exists($user, $field)) return false;
	$user->{$field} = $value;
	$_SESSION['user'] = serialize($user);
	return true;
}

/**
 * If the current user has a selected char and the data is in the session, retrieve the character object. If there
 * is no character data, populate it.
 */
function char(): Character|false
{
	if (empty($_SESSION['user'])) return false;
	return $GLOBALS['state']['char'] ??= Character::find(user()->char_id);
}

/**
 * Update the user's selected character. Returns true on success, false on failure. Database
 * is updated if the character ID is different from the current session.
 */
function change_user_character(int $char_id): bool
{
	// If the character does not exist, return false
	if (($char = Character::find($char_id)) === false) return false;
	$GLOBALS['char'] = $char;

	// If the character ID is different, update the session and database
	if (user()->char_id !== $char_id) {
		modify_user_session('char_id', $char_id);
		auth_db()->query("UPDATE users SET char_id = :c WHERE id = :u", [':c' => $char_id, ':u' => user()->id]);
	}

	return true;
}

/**
 * Get a percent between two ints, rounded to the nearest whole number or return 0.
 */
function percent(float $num, float $denom, int $precision = 4): float
{
	if ($denom === 0) return 0;
	$p = ($num / $denom) * 100;
	return $p < 0 ? 0 : round($p, $precision);
}

/**
 * Access the account wallet. On first execution it will populate $GLOBALS['wallet'] with the wallet data. This way
 * the data is up to date with every request without having to query the database every use within, for example, a
 * template. Will return false if the user or wallet does not exist.
 */
function wallet(): Models\Wallet|false
{
	if (user() === false) return false;
	return $GLOBALS['state']['wallet'] = Models\Wallet::find(user()->id);
}

/**
 * Access the character location. On first execution it will populate $GLOBALS['location'] with the location data. This
 * way the data is up to date with every request without having to query the database every use within, for example, a
 * template. Will return false if the field does not exist, or the entire location array if no field is specified.
 */
function location($field = '')
{
	if (empty($GLOBALS['location'])) {
		$GLOBALS['location'] = live_db()->query(
			"SELECT * FROM char_locations WHERE char_id = :c",
			[':c' => user()->char_id]
		)->fetchArray(SQLITE3_ASSOC);
	}

	if ($field === '') return $GLOBALS['location'];
	return $GLOBALS['location'][$field] ?? false;
}

/**
 * Format an array of strings to a ul element.
 */
function array_to_ul($array)
{
	$html = '';
	foreach ($array as $item) $html .= "<li>$item</li>";
	return "<ul>$html</ul>";
}

/**
 * Conditional Echo; if the condition is true, echo the value. If the condition is false, echo the $or value.
 */
function ce(bool $condition, mixed $value, mixed $or = ''): void
{
	echo $condition ? $value : $or;
}

/**
 * Get whether the request is an HTMX request.
 */
function is_htmx(): bool
{
	return isset($_SERVER['HTTP_HX_REQUEST']);
}

/**
 * Return a JSON response with the given data.
 */
function json_response(mixed $data): void
{
	header('Content-Type: application/json; charset=utf-8');
	echo json_encode($data);
	exit;
}

/**
 * Handle an error by setting the response code and echoing an error message
 */
function error_response(int $code): void
{
	http_response_code($code);
	echo match ($code) {
		403 => 'Forbidden',
		404 => 'Not Found',
		405 => 'Method Not Allowed',
		418 => 'I\'m a teapot',
		999 => 'Cheating attempt detected',
		default => 'Unknown Error',
	};
	exit;
}

/**
 * Return a title's [name, lore].
 */
function title(int $title_id): array|false
{
	return live_db()->query('SELECT * FROM titles WHERE id = ?', [$title_id])->fetchArray();
}

/**
 * Abbreviate a number in text notation.
 */
function abb_num(int $num): string
{
	$d = $num % 100 === 0 ? 0 : 1;
	return match(true) {
		$num >= 1000000000 => number_format($num / 1000000000, $d) . 'B',
		$num >= 1000000 => number_format($num / 1000000, $d) . 'M',
		default => number_format($num, 0)
	};
}

/**
 * Check if all keys of an array are numeric.
 */
function all_keys_numeric(array $a): bool
{
    foreach (array_keys($a) as $k) if (!is_int($k)) return false;
    return true;
}

/**
 * Parse simple BBCode to HTML, and keep count of characters.
 */
function parse_bbcode(string $text): array
{
	$html = $text;

	// Store the original text without tags and whitespace for counting
	$textForCounting = preg_replace('/\[.*?\]/', '', $text); // Remove BBCode tags
	$textForCounting = preg_replace('/\s+/', '', $textForCounting); // Remove whitespace
	$charCount = strlen($textForCounting);

	// Basic BBCode conversions
	$replacements = [
		'/\[b\](.*?)\[\/b\]/is' => '<strong>$1</strong>',
		'/\[i\](.*?)\[\/i\]/is' => '<em>$1</em>',
		'/\[u\](.*?)\[\/u\]/is' => '<u>$1</u>',
		'/\[url=(.*?)\](.*?)\[\/url\]/is' => '<a href="$1">$2</a>',
		'/\[url\](.*?)\[\/url\]/is' => '<a href="$1">$1</a>',
		//'/\[img\](.*?)\[\/img\]/is' => '<img src="$1" alt="" />',
		'/\[size=([1-7])\](.*?)\[\/size\]/is' => '<font size="$1">$2</font>',
		'/\[color=(#[A-F0-9]{6}|[a-z]+)\](.*?)\[\/color\]/is' => '<span style="color:$1">$2</span>',
		'/\[quote\](.*?)\[\/quote\]/is' => '<blockquote>$1</blockquote>',
		'/\[code\](.*?)\[\/code\]/is' => '<pre><code>$1</code></pre>',
		'/\[list\](.*?)\[\/list\]/is' => '<ul>$1</ul>',
		'/\[list=1\](.*?)\[\/list\]/is' => '<ol>$1</ol>',
		'/\[\*\](.*?)(?=\[\*\]|\[\/list\])/is' => '<li>$1</li>'
	];

	foreach ($replacements as $pattern => $replacement) $html = preg_replace($pattern, $replacement, $html);

	// Convert newlines to <br> tags
	$html = nl2br($html);

	return [
		'html' => $html,
		'char_count' => $charCount
	];
}

/**
 * Shorthand to call fetchArray() on a SQLite3Result. Defaults to SQLITE3_ASSOC but can pass any constant to $mode.
 */
function db_fetch_array(SQLite3Result $result, int $mode = SQLITE3_ASSOC): array|false
{
	return $result->fetchArray($mode);
}

/**
 * Returns whether there is a valid remember cookie, and whether that cookie has a valid session token.
 */
function validate_session(): bool
{
	if (!isset($_COOKIE['remember_me'])) return false;
	if (($session = Models\Session::find($_COOKIE['remember_me'])) && $session->validate()) return true;
	return false;
}

/**
 * Ensure this is an AJAX-only request.
 */
function ajax_only(): void
{
	$header = $_SERVER['HTTP_X_REQUESTED_WITH'] ?? '';
	if (!strtolower($header) === 'xmlhttprequest') error_response(418);
}

/**
 * Mark a request as guest only.
 */
function guest_only(): void
{
	if ($GLOBALS['state']['logged_in']) redirect('/');
}

/**
 * Mark a request as auth'd only.
 */
function auth_only(): void
{
	if (!$GLOBALS['state']['logged_in']) redirect('/auth/login');
}

/**
 * Mark a request as needing to have a character selected. Automatically checks auth_only as well.
 */
function must_have_character(): void
{
	auth_only();
	$user = user();

	// If there is a character selected, make sure the session is up to date.
	if ($user->char_id !== 0) {
		char();
		return;
	}

	// if no characters, redirect to create first
	if ($user->char_count() === 0) redirect('/character/create-first');

	// if no character selected, select the first one
	if ($user->char_id === 0) {
		$char = live_db()->query(
			'SELECT * FROM characters WHERE user_id = :u ORDER BY id ASC LIMIT 1',
			[':u' => $user->id]
		)->fetchArray(SQLITE3_ASSOC);
		change_user_character($char['id']);
	}
}
