<?php
declare(strict_types=1);

if (!function_exists('app_join_path')) {
    function app_join_path(string $root, string ...$segments): string
    {
        $path = rtrim($root, "\\/");

        foreach ($segments as $segment) {
            $segment = trim($segment, "\\/");
            if ($segment === '') {
                continue;
            }

            $path .= DIRECTORY_SEPARATOR . $segment;
        }

        return $path;
    }
}

if (!function_exists('app_root_path')) {
    function app_root_path(string ...$segments): string
    {
        return app_join_path(dirname(__DIR__), ...$segments);
    }
}

if (!function_exists('app_path')) {
    function app_path(string ...$segments): string
    {
        return app_root_path('app', ...$segments);
    }
}

if (!function_exists('app_config_path')) {
    function app_config_path(string ...$segments): string
    {
        return app_root_path('config', ...$segments);
    }
}

if (!function_exists('app_public_path')) {
    function app_public_path(string ...$segments): string
    {
        return app_root_path('public', ...$segments);
    }
}

if (!function_exists('app_version')) {
    /**
     * Return the application version from the single release source.
     *
     * Keeping this in a plain root VERSION file makes the value easy to
     * inspect and safe to consume from shared-host PHP entry points.
     */
    function app_version(): string
    {
        static $version = null;

        if ($version !== null) {
            return $version;
        }

        $file = app_root_path('VERSION');
        $raw = is_file($file) ? trim((string) file_get_contents($file)) : '';

        if (preg_match('/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/', $raw) !== 1) {
            throw new RuntimeException('VERSION must contain a valid semantic version.');
        }

        return $version = $raw;
    }
}

if (!function_exists('quaeriva_asset_version')) {
    /**
     * Cache key for a versioned static asset.
     *
     * Assets are served with `immutable, max-age=1 year`, so the query string
     * is the only thing that can invalidate them. Keying on the release
     * version alone means any change shipped without a VERSION bump reaches
     * returning visitors as stale CSS/JS, so the file's own modification time
     * is mixed in. Lives in the bootstrap because both the public layout and
     * the admin panel need it, and the admin panel does not load the view
     * helpers.
     */
    function quaeriva_asset_version(string $relativePath): string
    {
        static $cache = [];

        if (!array_key_exists($relativePath, $cache)) {
            $file = app_public_path(...explode('/', trim($relativePath, '/')));
            $stamp = is_file($file) ? (int) @filemtime($file) : 0;
            $cache[$relativePath] = $stamp > 0
                ? app_version() . '.' . $stamp
                : app_version();
        }

        return $cache[$relativePath];
    }
}

if (!function_exists('app_load_env_file')) {
    /**
     * Read the optional root .env file into $_ENV/$_SERVER.
     *
     * Shared hosting rarely allows setting real process environment
     * variables, so the documented .env.example is only useful if the
     * application reads the file itself. Existing environment values always
     * win, so a host that does provide real variables is never overridden.
     */
    function app_load_env_file(?string $file = null): void
    {
        static $loaded = false;

        if ($loaded) {
            return;
        }

        $loaded = true;
        $file ??= app_root_path('.env');

        if (!is_file($file) || !is_readable($file)) {
            return;
        }

        $values = @parse_ini_file($file, false, INI_SCANNER_RAW);

        if (!is_array($values)) {
            return;
        }

        foreach ($values as $key => $value) {
            if (!is_string($key) || !is_scalar($value)) {
                continue;
            }

            if (array_key_exists($key, $_ENV) || array_key_exists($key, $_SERVER) || getenv($key) !== false) {
                continue;
            }

            $_ENV[$key] = (string) $value;
            $_SERVER[$key] = (string) $value;
        }
    }
}

if (!function_exists('app_env')) {
    function app_env(string $key, mixed $default = null): mixed
    {
        app_load_env_file();

        $value = $_ENV[$key] ?? $_SERVER[$key] ?? getenv($key);

        if ($value === false || $value === null || $value === '') {
            return $default;
        }

        return $value;
    }
}

if (!function_exists('app_env_write')) {
    /**
     * Set or remove keys in .env, preserving every other line and comment.
     *
     * A null value removes the key. Returns false if the file could not be
     * written, which on shared hosting usually means the project root is
     * read-only -- the caller must report that rather than pretend it saved.
     *
     * @param array<string, string|null> $values
     */
    function app_env_write(string $path, array $values): bool
    {
        $existing = is_file($path) ? (string) @file_get_contents($path) : '';
        $lines = $existing === '' ? [] : (preg_split('/\R/', $existing) ?: []);
        $remaining = $values;
        $output = [];

        foreach ($lines as $line) {
            $matched = false;

            foreach ($values as $key => $value) {
                if (preg_match('/^\s*' . preg_quote($key, '/') . '\s*=/', (string) $line) === 1) {
                    $matched = true;
                    if ($value !== null && $value !== '') {
                        $output[] = $key . '=' . $value;
                    }
                    unset($remaining[$key]);
                    break;
                }
            }

            if (!$matched) {
                $output[] = (string) $line;
            }
        }

        foreach ($remaining as $key => $value) {
            if ($value !== null && $value !== '') {
                $output[] = $key . '=' . $value;
            }
        }

        $body = rtrim(implode("\n", $output));

        if ($body === '' && !is_file($path)) {
            return true;
        }

        return @file_put_contents($path, $body === '' ? '' : $body . "\n", LOCK_EX) !== false;
    }
}

if (!function_exists('app_resolve_base_path')) {
    /**
     * Resolve the deployment folder and retain enough safe evidence for the
     * admin diagnostics page. No hostnames, query values or credentials are
     * returned.
     *
     * @param array<string, mixed>|null $server
     * @return array{path:string,source:string,candidates:array<string,string>,ambiguous:bool}
     */
    function app_resolve_base_path(?string $configured = null, ?array $server = null): array
    {
        $normalize = static function (string $path): string {
            $path = trim(str_replace('\\', '/', $path));
            if ($path === '' || $path === '/') {
                return '';
            }
            return '/' . trim($path, '/');
        };
        $frontControllerPath = static function (string $path) use ($normalize): ?string {
            $path = str_replace('\\', '/', trim($path));
            if ($path === '' || !str_ends_with(strtolower($path), '/index.php')) {
                return null;
            }
            $base = preg_replace('#/(?:public/)?index\.php$#i', '', $path);
            return $normalize((string) $base);
        };

        $configured = trim((string) $configured);
        if ($configured !== '') {
            return ['path' => $normalize($configured), 'source' => 'explicit', 'candidates' => ['explicit' => $normalize($configured)], 'ambiguous' => false];
        }

        $server ??= $_SERVER;
        $candidates = [];
        $scriptPath = $frontControllerPath((string) ($server['SCRIPT_NAME'] ?? ''));
        if ($scriptPath !== null) {
            $candidates['script_name'] = $scriptPath;
        }

        $scriptFilename = str_replace('\\', '/', (string) ($server['SCRIPT_FILENAME'] ?? ''));
        $documentRoot = rtrim(str_replace('\\', '/', (string) ($server['DOCUMENT_ROOT'] ?? '')), '/');
        if ($scriptFilename !== '' && $documentRoot !== '' && str_starts_with(strtolower($scriptFilename), strtolower($documentRoot . '/'))) {
            $relativeScript = substr($scriptFilename, strlen($documentRoot));
            $filesystemPath = $frontControllerPath($relativeScript);
            if ($filesystemPath !== null) {
                $candidates['filesystem'] = $filesystemPath;
            }
        }

        $requestPath = parse_url((string) ($server['REQUEST_URI'] ?? ''), PHP_URL_PATH);
        $requestPath = is_string($requestPath) ? str_replace('\\', '/', $requestPath) : '';
        $adminSegment = function_exists('quaeriva_admin_path') ? preg_quote(quaeriva_admin_path(), '#') : 'admin';
        if ($requestPath !== '' && preg_match('#^(.*?)/(?:' . $adminSegment . '|admin|install)(?:/|$)#i', $requestPath, $matches) === 1) {
            $candidates['request_uri'] = $normalize((string) ($matches[1] ?? ''));
        }

        $priority = ['script_name', 'filesystem', 'request_uri'];
        $source = 'default';
        $path = '';
        foreach ($priority as $candidateSource) {
            if (array_key_exists($candidateSource, $candidates)) {
                $source = $candidateSource;
                $path = $candidates[$candidateSource];
                break;
            }
        }
        $ambiguous = count(array_unique(array_values($candidates), SORT_STRING)) > 1;

        return ['path' => $path, 'source' => $source, 'candidates' => $candidates, 'ambiguous' => $ambiguous];
    }
}

if (!function_exists('app_detect_base_path')) {
    /**
     * Recover the URL folder the site is served from, when nothing declared it.
     *
     * SCRIPT_NAME is the front controller's own public path, so it already
     * carries the answer in both supported layouts:
     *   docroot = public/      -> /ask/index.php        -> /ask
     *   docroot = project root -> /ask/public/index.php -> /ask
     */
    function app_detect_base_path(?string $scriptName = null): string
    {
        $server = $scriptName === null ? $_SERVER : ['SCRIPT_NAME' => $scriptName];
        return app_resolve_base_path('', $server)['path'];
    }
}

if (!function_exists('app_base_path')) {
    function app_base_path(?string $value = null): string
    {
        if ($value !== null) {
            return app_resolve_base_path($value, [])['path'];
        }

        return app_resolve_base_path((string) app_env('APP_BASE_URL', ''), $_SERVER)['path'];
    }
}

if (!function_exists('quaeriva_admin_path')) {
    /**
     * The URL segment the admin panel answers on. Renaming it removes the
     * panel from the one path every scanner tries first; it is obscurity, not
     * a substitute for the password and the role check, both of which still
     * apply. Set APP_ADMIN_PATH in .env, e.g. APP_ADMIN_PATH=studio.
     */
    function quaeriva_admin_path(): string
    {
        static $path = null;

        if (isset($GLOBALS['quaeriva_admin_path_override']) && is_string($GLOBALS['quaeriva_admin_path_override']) && $GLOBALS['quaeriva_admin_path_override'] !== '') {
            $candidate = $GLOBALS['quaeriva_admin_path_override'];
            $candidate = strtolower(trim($candidate, "/ \t\n\r\0\x0B"));
            return preg_match('/^[a-z0-9][a-z0-9_-]{0,31}$/', $candidate) === 1 ? $candidate : 'admin';
        }

        if ($path === null) {
            $candidate = '';
            try {
                $siteConfig = function_exists('app_config') ? app_config('site') : [];
                $candidate = (string) ($siteConfig['admin_path'] ?? '');
            } catch (Throwable) {
                $candidate = '';
            }
            if ($candidate === '') {
                $candidate = (string) app_env('APP_ADMIN_PATH', 'admin');
            }
            $candidate = strtolower(trim($candidate, "/ \t\n\r\0\x0B"));
            // A path with a slash or an exotic character would silently never
            // match the dispatcher and lock the owner out of their own panel.
            $path = preg_match('/^[a-z0-9][a-z0-9_-]{0,31}$/', $candidate) === 1 ? $candidate : 'admin';
        }

        return $path;
    }
}

if (!function_exists('app_config')) {
    function app_config(string $name): array
    {
        static $cache = [];

        if (!array_key_exists($name, $cache)) {
            $file = app_config_path($name . '.php');

            if (!is_file($file)) {
                throw new RuntimeException(sprintf('Missing configuration file: %s', $file));
            }

            $config = require $file;

            if (!is_array($config)) {
                throw new RuntimeException(sprintf('Configuration file must return array: %s', $file));
            }

            $cache[$name] = $config;
        }

        return $cache[$name];
    }
}

if (!function_exists('quaeriva_utf8_length')) {
    function quaeriva_utf8_length(string $value): int
    {
        if (function_exists('mb_strlen')) {
            return mb_strlen($value, 'UTF-8');
        }
        $count = preg_match_all('/./us', $value, $matches);
        return $count === false ? strlen($value) : $count;
    }
}

if (!function_exists('quaeriva_utf8_substr')) {
    function quaeriva_utf8_substr(string $value, int $start, ?int $length = null): string
    {
        if (function_exists('mb_substr')) {
            return $length === null
                ? mb_substr($value, $start, null, 'UTF-8')
                : mb_substr($value, $start, $length, 'UTF-8');
        }

        $characters = preg_split('//u', $value, -1, PREG_SPLIT_NO_EMPTY);
        if (!is_array($characters)) {
            return $length === null ? substr($value, $start) : substr($value, $start, $length);
        }

        return implode('', $length === null
            ? array_slice($characters, $start)
            : array_slice($characters, $start, $length));
    }
}

if (!function_exists('quaeriva_user_has_role')) {
    /**
     * Case-insensitive role check for a user.
     */
    function quaeriva_user_has_role(?array $user, string ...$roles): bool
    {
        if ($user === null) {
            return false;
        }

        $userRole = strtolower(trim((string) ($user['role'] ?? '')));
        if ($userRole === '') {
            return false;
        }

        foreach ($roles as $role) {
            if ($userRole === strtolower(trim($role))) {
                return true;
            }
        }

        return false;
    }
}

if (!function_exists('quaeriva_plugins_directory')) {
    function quaeriva_plugins_directory(): string
    {
        return app_root_path('plugins');
    }
}

if (!function_exists('quaeriva_is_safe_mode')) {
    function quaeriva_is_safe_mode(): bool
    {
        if (defined('QUAERIVA_SAFE_MODE') && QUAERIVA_SAFE_MODE === true) {
            return true;
        }
        $env = app_env('QUAERIVA_SAFE_MODE');
        if ($env === '1' || $env === 'true' || $env === true) {
            return true;
        }
        return false;
    }
}

if (!function_exists('quaeriva_load_plugins')) {
    /**
     * Discover plugins and optionally register the explicitly active set.
     *
     * Discovery is side-effect free: plugin.json is read as data and plugin.php
     * is required only when the slug is explicitly active, execution is allowed,
     * and Safe Mode is off. This keeps a disabled or quarantined plugin from
     * running arbitrary top-level PHP merely because the dashboard lists it.
     *
     * @param list<string> $activeSlugs
     * @return array<string, array{slug: string, name: string, version: string, author: string, requires: string, description: string, is_requested_active: bool, is_compatible: bool, is_active: bool, path: string}>
     */
    function quaeriva_load_plugins(array $activeSlugs = [], bool $execute = true): array
    {
        $pluginsDir = quaeriva_plugins_directory();
        if (!is_dir($pluginsDir)) {
            return [];
        }

        $active = [];
        foreach ($activeSlugs as $slug) {
            $slug = strtolower(trim((string) $slug));
            if ($slug !== '' && preg_match('/^[a-z0-9][a-z0-9_-]{0,63}$/', $slug) === 1) {
                $active[$slug] = true;
            }
        }

        $safeMode = quaeriva_is_safe_mode();
        $discovered = [];
        $entries = scandir($pluginsDir) ?: [];

        foreach ($entries as $entry) {
            if ($entry === '.' || $entry === '..' || !is_dir($pluginsDir . DIRECTORY_SEPARATOR . $entry)) {
                continue;
            }

            $slug = strtolower(trim($entry));
            if (preg_match('/^[a-z0-9][a-z0-9_-]{0,63}$/', $slug) !== 1) {
                continue;
            }

            $pluginFile = $pluginsDir . DIRECTORY_SEPARATOR . $entry . DIRECTORY_SEPARATOR . 'plugin.php';
            if (!is_file($pluginFile)) {
                continue;
            }

            $manifest = [];
            $manifestFile = $pluginsDir . DIRECTORY_SEPARATOR . $entry . DIRECTORY_SEPARATOR . 'plugin.json';
            if (is_file($manifestFile)) {
                try {
                    $decoded = json_decode((string) file_get_contents($manifestFile), true, 64, JSON_THROW_ON_ERROR);
                    if (is_array($decoded)) {
                        $manifest = $decoded;
                    }
                } catch (\Throwable $throwable) {
                    try {
                        (new \App\Support\FileLogger())->error("Plugin [{$entry}] manifest is invalid: " . $throwable->getMessage(), $throwable);
                    } catch (\Throwable) {
                    }
                }
            }

            $requires = trim((string) ($manifest['requires'] ?? ''));
            $isCompatible = $requires === ''
                || (preg_match('/^\d+(?:\.\d+){1,3}(?:[-+][A-Za-z0-9.-]+)?$/', $requires) === 1
                    && version_compare(app_version(), $requires, '>='));
            $isRequestedActive = isset($active[$slug]);
            $isActive = !$safeMode && $isRequestedActive && $isCompatible;
            $runtimeStatus = !$isRequestedActive ? 'disabled' : ($safeMode ? 'safe_mode' : (!$isCompatible ? 'incompatible' : ($execute ? 'loading' : 'not_loaded')));
            $pluginData = [
                'slug' => $slug,
                'name' => trim((string) ($manifest['name'] ?? '')) ?: $entry,
                'version' => trim((string) ($manifest['version'] ?? '')) ?: '1.0.0',
                'author' => trim((string) ($manifest['author'] ?? '')),
                'requires' => $requires,
                'description' => trim((string) ($manifest['description'] ?? '')),
                'is_requested_active' => $isRequestedActive,
                'is_compatible' => $isCompatible,
                'is_active' => $isActive,
                'runtime_status' => $runtimeStatus,
                'path' => $pluginFile,
            ];

            if ($isActive && $execute) {
                try {
                    $descriptor = require $pluginFile;
                    if (!is_array($descriptor)) {
                        throw new \RuntimeException('Plugin entry file must return a descriptor array.');
                    }
                    foreach (['name', 'version', 'author', 'requires', 'description'] as $key) {
                        $value = trim((string) ($descriptor[$key] ?? ''));
                        if ($value !== '') {
                            $pluginData[$key] = $value;
                        }
                    }
                    if (isset($descriptor['register']) && is_callable($descriptor['register'])) {
                        $descriptor['register']();
                    }
                    $pluginData['runtime_status'] = 'loaded';
                } catch (\Throwable $throwable) {
                    $pluginData['is_active'] = false;
                    $pluginData['runtime_status'] = 'load_failed';
                    try {
                        (new \App\Support\FileLogger())->error("Plugin [{$entry}] failed to load: " . $throwable->getMessage(), $throwable);
                    } catch (\Throwable) {
                    }
                }
            }

            if (!$execute && isset($GLOBALS['quaeriva_plugin_runtime_status'][$slug])) {
                $pluginData['runtime_status'] = (string) $GLOBALS['quaeriva_plugin_runtime_status'][$slug];
                $pluginData['is_active'] = $pluginData['runtime_status'] === 'loaded';
            }
            if ($execute) {
                $GLOBALS['quaeriva_plugin_runtime_status'][$slug] = $pluginData['runtime_status'];
            }

            $discovered[$slug] = $pluginData;
        }

        return $discovered;
    }
}

if (!function_exists('app_bootstrap')) {
    function app_bootstrap(): array
    {
        static $bootstrapped = null;

        if ($bootstrapped !== null) {
            return $bootstrapped;
        }

        spl_autoload_register(static function (string $class): void {
            if (!str_starts_with($class, 'App\\')) {
                return;
            }

            $relative = substr($class, 4);
            $file = app_path(str_replace('\\', DIRECTORY_SEPARATOR, $relative) . '.php');

            if (is_file($file)) {
                require $file;
            }
        });

        $bootstrapped = [
            'site' => app_config('site'),
            'database' => app_config('database'),
        ];

        $GLOBALS['quaeriva_active_theme'] = 'default';

        return $bootstrapped;
    }
}

return null;
