<?php

if (!defined('BASE_PATH')) {
    define('BASE_PATH', dirname(__DIR__));
}

// Self-heal web-access protection for private directories (config, storage,
// projects, bin, bootstrap). Critical on hosts where the document root cannot be
// moved to /public — there the project root is web-accessible, and storage/ and
// projects/ (which hold the licence signing key and per-project encryption keys)
// are excluded from bundles, so their deny-all guards must be (re)created here.
// Cheap, idempotent, never throws. See core/PrivatePathGuard.php.
require_once BASE_PATH . '/core/PrivatePathGuard.php';
\PpPhp\Core\PrivatePathGuard::ensure(BASE_PATH);

$appConfigFile = BASE_PATH . '/config/app.php';
$appConfig = is_file($appConfigFile) ? require $appConfigFile : [];

// Timezone — align PHP with the database clock (default Asia/Kathmandu, +05:45)
// so PHP date() and MySQL NOW()/CURRENT_TIMESTAMP read on ONE clock. `PP_TIMEZONE`
// makes core/DB.php apply the same zone to every MySQL session.
$appTimezone = (string) ($appConfig['timezone'] ?? 'Asia/Kathmandu');
if ($appTimezone !== '') {
    @date_default_timezone_set($appTimezone);
    if (!defined('PP_TIMEZONE')) {
        define('PP_TIMEZONE', $appTimezone);
    }
}

$appEnv = (string)($appConfig['env'] ?? getenv('APP_ENV') ?: 'local');
$appDebug = (bool)($appConfig['debug'] ?? filter_var(getenv('APP_DEBUG') ?: false, FILTER_VALIDATE_BOOLEAN));

error_reporting(E_ALL);

if ($appDebug) {
    ini_set('display_errors', '1');
    ini_set('display_startup_errors', '1');
} else {
    ini_set('display_errors', '0');
    ini_set('display_startup_errors', '0');
    ini_set('log_errors', '1');
}

/* declare(strict_types=1); */

$isHttps = (
    (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
    || (($_SERVER['SERVER_PORT'] ?? null) === '443')
    || (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https')
);

/* declare(strict_types=1); */

// Set session cookie to expire on browser close
session_set_cookie_params([
    'lifetime' => 0,
    'path'     => '/',
    'domain'   => '',
    'secure'   => (bool)($appConfig['session']['secure'] ?? $isHttps),
    'httponly' => true,
    'samesite' => (string)($appConfig['session']['samesite'] ?? 'Lax'),
]);
session_name('PPPHPSESSID');

if (session_status() === PHP_SESSION_NONE) {
    session_start();
}


// Check if framework is installed (config file exists). Redirect to the installer
// that sits BESIDE this front controller (public/install.php) — reachable whether the
// web-server document root is the bundle root or its public/ folder. (Redirecting to
// ../install.php instead breaks when the doc-root IS public/: that file is then above
// the doc-root and the rewrite bounces the request straight back here — an endless
// redirect loop, with no error shown.)
if (!file_exists(BASE_PATH . '/config/database.php')) {
    header('Location: install.php');
    exit;
}

// 1. Autoloader
require_once BASE_PATH . '/core/Autoloader.php';

$autoloader = new \PpPhp\Core\Autoloader();
$autoloader->addNamespace('PpPhp\\Core\\', BASE_PATH . '/core/');
$autoloader->addNamespace('PpPhp\\Plugins\\', BASE_PATH . '/plugins/');
$autoloader->register();


// 2. Container
$container = new \PpPhp\Core\Container();

// Load database configuration
$dbConfig = require BASE_PATH . '/config/database.php';

// *** CALCULATE BASE URL (ABSOLUTE, NO TRAILING SLASH) ***
// The app is always addressed under /public (SCRIPT_NAME = ".../public/index.php")
// so generated links never collide with the private sibling dirs at the project
// root (projects/, config/, storage/…). On hosts that cannot repoint the document
// root, the project-root .htaccess lands the BARE DOMAIN on public/index.php; the
// routing step below normalises that so the home page resolves.
$scriptDir = dirname($_SERVER['SCRIPT_NAME']);
$basePath = '/' . trim($scriptDir, '/');
if ($basePath === '/') {
    $basePath = '';
}

// Define a constant so helpers can use it
define('BASE_URL', $basePath);

// Module JS/CSS (/module-asset/…) is answered here, before the container,
// module discovery and the database are touched: a versioned URL plus a
// one-year immutable cache means the browser fetches each file once instead
// of receiving it inlined in the HTML on every page load.
if (\PpPhp\Core\ModuleAssets::serve($basePath)) {
    return;
}

// Load global helpers (after BASE_URL is defined)
require_once BASE_PATH . '/core/Helpers.php';

// 3. Configure the Container with all shared settings
$container->set(\PpPhp\Core\Config::class, new \PpPhp\Core\Config([
    'base_url'   => $basePath,
    'assets_url' => $basePath . '/assets',
    'db_config'  => new \PpPhp\Core\Config($dbConfig),
]));

// 4. Module Discovery
$moduleManager = new \PpPhp\Core\ModuleManager();
$moduleManager->discoverModules(BASE_PATH . '/modules');

foreach ($moduleManager->getModules() as $module) {
    $autoloader->addNamespace(
        $module->manifest['namespace'] . '\\',
        $module->directory . '/'
    );
}

// 5. Event Dispatcher
$eventDispatcher = new \PpPhp\Core\EventDispatcher();
foreach ($moduleManager->collectEventListeners() as $event => $handlers) {
    foreach ($handlers as $handler) {
        $eventDispatcher->addListener($event, $handler);
    }
}

$container->set(\PpPhp\Core\ModuleManager::class, $moduleManager);
$container->set(\PpPhp\Core\EventDispatcher::class, $eventDispatcher);

// 6. Router
$router = new \PpPhp\Core\Router($container);
foreach ($moduleManager->collectRoutes() as $route) {
    $router->addRoute(
        $route['method'],
        $route['uri'],
        $route['handler'],
        $route['name'] ?? null
    );
}

// 7. Dispatch request
$requestUri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);

if ($basePath !== '' && str_starts_with($requestUri, $basePath)) {
    // Normal case: the request is addressed under the /public base.
    $requestUri = substr($requestUri, strlen($basePath));
} elseif (str_ends_with($basePath, '/public')) {
    // Bare-domain funnel: on hosts that cannot repoint the doc root, the
    // project-root .htaccess lands the visitor's bare URL on public/index.php,
    // so REQUEST_URI has no "/public" segment and the strip above misses. Remove
    // the project's own sub-path prefix (everything before "/public") so the
    // route resolves — the bare domain "/" (or "/subdir/") becomes the home
    // route. Only the ROUTE is normalised; BASE_URL keeps the /public prefix, so
    // generated links stay under /public and never collide with a private
    // sibling directory (projects/, config/, storage/…).
    $funnelParent = substr($basePath, 0, -7); // basePath without the trailing "/public"
    if ($funnelParent === '' || $requestUri === $funnelParent || str_starts_with($requestUri, $funnelParent . '/')) {
        $requestUri = substr($requestUri, strlen($funnelParent));
    }
}

if (empty($requestUri) || $requestUri[0] !== '/') {
    $requestUri = '/' . $requestUri;
}

$requestMethod = $_SERVER['REQUEST_METHOD'];

// Migration cut-over gate. After the owner flips this server to a new home, show a
// standalone "this site has moved" notice to the public and stop the request. The
// owner and any logged-in staff bypass it (so the console stays reachable to manage
// or revert the flip), as does anyone who chose to continue to the old site. Fully
// reversible — deleting storage/migrated.flag brings the server back.
if (class_exists(\PpPhp\Core\Migrate\MigrationFlip::class)
    && \PpPhp\Core\Migrate\MigrationFlip::shouldBlock()) {
    \PpPhp\Core\Migrate\MigrationFlip::renderNotice(); // exits
}

// A BACKUP licence server pulls a fresh snapshot from the main every 15 minutes.
// Piggybacked on a normal request (rate-limited, silent), so no cron is needed;
// a no-op on a main server or any non-backup install. class_exists guards client
// bundles, which don't ship the replication code at all.
if (class_exists(\PpPhp\Core\Licensing\LicenseMirror::class)) {
    \PpPhp\Core\Licensing\LicenseMirror::tickIfDue();
}

// SELF-DRIVING data/file clone — a due backup fires ONE non-blocking self-request
// to its own web-cron endpoint (scheduled after this response is flushed, so the
// visitor is never delayed) which then pulls the databases, virtual-database files
// and project trees in a detached worker. No external/host cron to configure — the
// server drives itself. A cheap due-check + cooldown on a normal request.
if (class_exists(\PpPhp\Core\Databases\CloneSchedule::class)) {
    \PpPhp\Core\Databases\CloneSchedule::selfDriveIfDue();
}

// Drain a few due retry-queue emails (messages that failed on every SMTP server).
// Rate-limited to once every couple of minutes and a no-op when the queue is empty,
// so it costs a stat() on a normal request. A cron (bin/mail-queue.php) drains bigger
// backlogs faster where cron is available.
if (class_exists(\PpPhp\Core\MailQueue::class)) {
    \PpPhp\Core\MailQueue::tickIfDue();
}

// Keep the offline IP-location database fresh. At most once a day, and the actual
// multi-MB download runs AFTER the response is delivered (or is left to cron), so
// no visitor ever waits for it. No-op until a newer build exists.
if (class_exists(\PpPhp\Core\Analytics\GeoIp::class)) {
    \PpPhp\Core\Analytics\GeoIp::tickIfDue();
}

// Quietly notice when a newer platform release is published and drop an "update
// available" note in the admin's notification bell — never applies anything. At
// most once every few hours, and the feed fetch runs AFTER the response, so no
// visitor waits. No-op on an unlicensed copy or with no update host configured.
if (class_exists(\PpPhp\Core\Update\UpdateNotifier::class)) {
    \PpPhp\Core\Update\UpdateNotifier::tickIfDue();
}

// Record "last seen" for the signed-in platform user (throttled to once every
// couple of minutes via the session), so Manage Users shows real activity.
if (class_exists(\PpPhp\Core\Auth\UserActivity::class) && \PpPhp\Core\Security::IsLoggedIn()) {
    \PpPhp\Core\Auth\UserActivity::touch((int) (\PpPhp\Core\Security::CurrentUserData()['id'] ?? 0));
}

// Maintenance gate. While a platform update is applying, PlatformUpdater raises a
// flag; hold other requests behind a short 503 so none of them execute against a
// half-written core/ (autoloading a partially-rewritten class fatals). The request
// that RAISED the flag has already passed this point, so it is never blocked, and
// the flag is self-clearing — one older than the window is ignored, so a crashed
// apply cannot wedge the site (the licence gate then auto-recovers it).
$__maintFlag = BASE_PATH . '/storage/maintenance.flag';
if (is_file($__maintFlag)) {
    $__since = (int) @file_get_contents($__maintFlag);
    if ($__since > 0 && (time() - $__since) < 1200) {
        http_response_code(503);
        header('Retry-After: 20');
        header('Content-Type: text/html; charset=utf-8');
        header('Cache-Control: no-store, must-revalidate');
        echo '<!doctype html><meta charset="utf-8"><meta http-equiv="refresh" content="15">'
            . '<title>Updating…</title>'
            . '<style>body{font:16px/1.6 system-ui,-apple-system,Segoe UI,sans-serif;max-width:32rem;margin:15vh auto;padding:0 1.5rem;color:#334}h1{font-size:1.4rem;margin:0 0 .5rem}</style>'
            . '<h1>Update in progress</h1><p>This site is applying a platform update and will be back in a moment. This page refreshes automatically.</p>';
        return;
    }
}

// Licence gate. Only bites on a vendor-stamped build (see LicenseTrust); working
// from source it is a no-op. When a licence has lapsed it locks THIS console and
// nothing else — generated apps carry no licensing code and keep serving.
if (\PpPhp\Core\Licensing\LicenseGate::guard($requestUri, $basePath)) {
    return;
}

// Visitor analytics. The JS beacon posts device/engagement detail to /pp/visit;
// handle it here (before the pageview record, so the beacon is not itself counted
// as a page hit) and return an empty 204. Everything is silent and non-fatal.
if (class_exists(\PpPhp\Core\Analytics\VisitorLog::class)) {
    if ($requestMethod === 'POST' && $requestUri === '/pp/visit') {
        try {
            $raw = file_get_contents('php://input') ?: '';
            $payload = strlen($raw) <= 4096 ? json_decode($raw, true) : null;
            if (is_array($payload)) \PpPhp\Core\Analytics\VisitorLog::enrich($payload);
        } catch (\Throwable $e) { /* never fatal */ }
        http_response_code(204);
        return;
    }

    // Classify the surface. Browser traffic (console) is recorded as a pageview;
    // server-to-server API calls (licence / update host) are logged as client
    // queries via a shutdown hook so we capture the FINAL status even when the
    // controller exits. The collector endpoint logs itself, so it is skipped here.
    $ppChannel = \PpPhp\Core\Analytics\VisitorLog::classifyChannel($requestUri);
    if ($ppChannel === 'console') {
        \PpPhp\Core\Analytics\VisitorLog::sendClientHintHeaders();
        // Record the pageview AFTER the response is flushed — geo/device resolution
        // (and, if the owner enabled the online fallback, a network lookup) must
        // never add to the page's TTFB. Shutdown callbacks still run on exit(), so
        // this is as reliable as the old inline call. Mirrors the API path below.
        register_shutdown_function(static function () use ($requestUri) {
            if (function_exists('fastcgi_finish_request')) { @fastcgi_finish_request(); }
            elseif (function_exists('litespeed_finish_request')) { @litespeed_finish_request(); }
            try { \PpPhp\Core\Analytics\VisitorLog::record($requestUri); }
            catch (\Throwable $e) { /* never break the page */ }
        });
    } elseif ($ppChannel === 'license' || $ppChannel === 'update' || $ppChannel === 'api') {
        $ppAction = \PpPhp\Core\Analytics\VisitorLog::apiAction($requestUri);
        register_shutdown_function(static function () use ($ppChannel, $ppAction) {
            try {
                \PpPhp\Core\Analytics\VisitorLog::recordApiQuery($ppChannel, $ppAction, http_response_code() ?: 200);
            } catch (\Throwable $e) { /* never break the API response */ }
        });
    }
}

$router->dispatch($requestMethod, $requestUri);
