includes/core.php

<?php
declare(strict_types=1);

/*
|--------------------------------------------------------------------------
| Installation check
|--------------------------------------------------------------------------
*/

require_once __DIR__ . '/settings.php';

if (!defined('BRIVACIA_BLOCKED')) {
    define('BRIVACIA_BLOCKED', '__blocked__');
}

if (!defined('BRIVACIA_UNKNOWN')) {
    define('BRIVACIA_UNKNOWN', '__unknown__');
}

require_once __DIR__ . '/rules.php';

function brivaciaNeedsInstall(): bool {
    return !brivacia_is_installed();
}

/*
|--------------------------------------------------------------------------
| HTTP security headers
|--------------------------------------------------------------------------
|
| Sent on every page that isn't meant to be embedded from another site.
| $allowFraming is set to true only for api/privacy.php's iframe widget,
| which is deliberately embedded on the *tracked* website itself.
|
*/

/*
|--------------------------------------------------------------------------
| CSP nonce
|--------------------------------------------------------------------------
|
| One random value per request, used both in the Content-Security-Policy
| header (script-src) and as the nonce="" attribute on index.php's few
| legitimate inline <script> blocks. static $nonce makes sure the header
| and the HTML always agree, however many times this is called.
|
*/

function brivaciaCspNonce(): string
{
    static $nonce = null;

    if ($nonce === null) {
        $nonce = base64_encode(random_bytes(16));
    }

    return $nonce;
}

function brivaciaSecurityHeaders(bool $allowFraming = false): void
{
    if (headers_sent()) {
        return;
    }

    header('X-Content-Type-Options: nosniff');
    header('Referrer-Policy: strict-origin-when-cross-origin');
    header('Permissions-Policy: geolocation=(), microphone=(), camera=(), payment=()');

    if (!$allowFraming) {
        header('X-Frame-Options: SAMEORIGIN');

        // Every inline <script> that stays in index.php carries a matching
        // nonce="" attribute (see brivaciaCspNonce()), so script-src doesn't
        // need 'unsafe-inline'. The onchange="" attributes that used to
        // require it have been replaced by a data-auto-submit hook wired
        // up from one of those nonce'd scripts instead.
        header(
            "Content-Security-Policy: default-src 'self'; " .
            "script-src 'self' 'nonce-" . brivaciaCspNonce() . "'; " .
            "style-src 'self'; " .
            "img-src 'self' data:; " .
            "font-src 'self'; " .
            "connect-src 'self'; " .
            "object-src 'none'; " .
            "base-uri 'self'; " .
            "form-action 'self'; " .
            "frame-ancestors 'self'"
        );
    }
}


/*
|--------------------------------------------------------------------------
| Storage
|--------------------------------------------------------------------------
*/

function ensureDir(string $dir): string {
    if (!is_dir($dir)) {
        mkdir($dir, 0755, true);
    }
    return $dir;
}

function protectDir(string $dir): void {
    $file = rtrim($dir, '/') . '/.htaccess';
    if (is_file($file)) return;

    @file_put_contents($file, "Require all denied\nDeny from all\n", LOCK_EX);
}

function storagePath(string $folder): string {
    return __DIR__ . '/../' . $folder;
}

function storageDir(string $dir): string {
    $dir = ensureDir($dir);
    protectDir($dir);
    return $dir;
}

function archiveDir(): string { return storageDir(storagePath('archives')); }
function backupDir(): string { return storageDir(storagePath('backup')); }
function corruptDir(): string { return storageDir(storagePath('corrupt')); }
function dataDir(): string { return storageDir(storagePath('data')); }
function liveDbFile(): string { return dataDir() . '/brivacia.sqlite'; }
function logDir(): string {
    $dir = storageDir(storagePath('logs'));
    foreach (['archive','backup','import','pages','pixel','providers','referrers', 'update'] as $subdir) {
        storageDir($dir . '/' . $subdir);
    }
    return $dir;
}
function updateDir(): string { return storageDir(storagePath('update')); }

function initStorageDirs(): void {
    archiveDir(); backupDir(); corruptDir(); dataDir(); logDir(); updateDir();

    $blocklist = dataDir() . '/referrers_blocklist.json';
    if (!is_file($blocklist)) {
        file_put_contents($blocklist, json_encode(['hosts' => [], 'contains' => []], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), LOCK_EX);
    }
    protectDir(__DIR__);
}

if (!brivaciaNeedsInstall()) {
    initStorageDirs();
}

/*
|--------------------------------------------------------------------------
| Logging
|--------------------------------------------------------------------------
*/

function trimLogFile(string $file, int $days = 30): void {
    if (!is_file($file)) return;
    // ... (garde ta fonction existante)
    $cutoff = time() - ($days * 86400);
    $lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
    $keep = [];
    foreach ($lines as $line) {
        $timestamp = strtotime(substr($line, 0, 25));
        if ($timestamp !== false && $timestamp >= $cutoff) $keep[] = $line;
    }
    file_put_contents($file, implode(PHP_EOL, $keep) . (count($keep) ? PHP_EOL : ''), LOCK_EX);
}

function brivaciaSanitizeLogMessage(string $message): string {
    $message = str_replace(["\r", "\n"], ' ', $message);
    $message = preg_replace('~\s+~u', ' ', $message) ?? $message;

    // Never keep raw IP addresses in logs.
    $message = preg_replace(
        '~(?<![A-Za-z0-9])(?:\d{1,3}\.){3}\d{1,3}(?![A-Za-z0-9])~',
        '[redacted-ipv4]',
        $message
    ) ?? $message;

    // Best-effort IPv6 redaction for diagnostics logs.
    $message = preg_replace(
        '~(?<![A-Za-z0-9])(?:[A-Fa-f0-9]{1,4}:){2,7}[A-Fa-f0-9]{0,4}(?![A-Za-z0-9])~',
        '[redacted-ipv6]',
        $message
    ) ?? $message;

    return trim(mb_substr($message, 0, 2000, 'UTF-8'));
}

function brivaciaLog(string $file, string $message): void {
    $path = logDir() . '/' . $file;
    $dir = dirname($path);
    if (!is_dir($dir)) mkdir($dir, 0755, true);
    trimLogFile($path);
    file_put_contents($path, date('c') . ' ' . brivaciaSanitizeLogMessage($message) . PHP_EOL, FILE_APPEND | LOCK_EX);
}

require_once __DIR__ . '/backup.php';

/*
|--------------------------------------------------------------------------
| Database
|--------------------------------------------------------------------------
|
| Opens the live SQLite database, restores it from backup if needed, and
| ensures the schema exists.
|
*/

function brivaciaDb(): PDO {
    restoreBrivaciaDbIfBroken();

    $db = new PDO('sqlite:' . liveDbFile());
    $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
    $db->exec('PRAGMA journal_mode = WAL');
    $db->exec('PRAGMA busy_timeout = 3000');

    initBrivaciaDb($db);

    return $db;
}

function initBrivaciaDb(PDO $db): void {
    $db->exec("
        CREATE TABLE IF NOT EXISTS hits_daily (
            site TEXT NOT NULL DEFAULT '',
            day TEXT NOT NULL,
            unique_visitors INTEGER NOT NULL DEFAULT 0,
            visits INTEGER NOT NULL DEFAULT 0,
            pageviews INTEGER NOT NULL DEFAULT 0,
            bots INTEGER NOT NULL DEFAULT 0,
            PRIMARY KEY (site, day)
        );

        CREATE TABLE IF NOT EXISTS hits_hourly (
            site TEXT NOT NULL DEFAULT '',
            day TEXT NOT NULL,
            hour INTEGER NOT NULL,
            unique_visitors INTEGER NOT NULL DEFAULT 0,
            visits INTEGER NOT NULL DEFAULT 0,
            pageviews INTEGER NOT NULL DEFAULT 0,
            bots INTEGER NOT NULL DEFAULT 0,
            PRIMARY KEY (site, day, hour)
        );

        CREATE TABLE IF NOT EXISTS pages_daily (
            site TEXT NOT NULL,
            day TEXT NOT NULL,
            page_key TEXT NOT NULL,
            title TEXT NOT NULL DEFAULT '',
            url TEXT NOT NULL DEFAULT '',
            views INTEGER NOT NULL DEFAULT 0,
            PRIMARY KEY (day, site, page_key)
        );

        CREATE TABLE IF NOT EXISTS countries_daily (
            site TEXT NOT NULL DEFAULT '',
            day TEXT NOT NULL,
            country TEXT NOT NULL,
            views INTEGER NOT NULL DEFAULT 0,
            PRIMARY KEY (site, day, country)
        );

        CREATE TABLE IF NOT EXISTS referrers_daily (
            site TEXT NOT NULL DEFAULT '',
            day TEXT NOT NULL,
            referrer TEXT NOT NULL,
            views INTEGER NOT NULL DEFAULT 0,
            PRIMARY KEY (site, day, referrer)
        );

        CREATE TABLE IF NOT EXISTS seen_daily (
            site TEXT NOT NULL DEFAULT '',
            day TEXT NOT NULL,
            visitor_hash TEXT NOT NULL,
            PRIMARY KEY (site, day, visitor_hash)
        );

        CREATE TABLE IF NOT EXISTS visitor_sessions (
            site TEXT NOT NULL DEFAULT '',
            day TEXT NOT NULL,
            visitor_hash TEXT NOT NULL,
            last_seen INTEGER NOT NULL,
            returned_today INTEGER NOT NULL DEFAULT 0,
            PRIMARY KEY (site, day, visitor_hash)
        );

        CREATE TABLE IF NOT EXISTS login_attempts (
            ip_prefix TEXT NOT NULL,
            failed_count INTEGER NOT NULL DEFAULT 0,
            last_failed_at INTEGER NOT NULL DEFAULT 0,
            locked_until INTEGER NOT NULL DEFAULT 0,
            PRIMARY KEY (ip_prefix)
        );

        CREATE TABLE IF NOT EXISTS pixel_rate_limit (
            ip_prefix TEXT NOT NULL,
            window_start INTEGER NOT NULL DEFAULT 0,
            hits INTEGER NOT NULL DEFAULT 0,
            PRIMARY KEY (ip_prefix)
        );
    ");

    ensureColumn($db, 'hits_daily', 'site', "TEXT NOT NULL DEFAULT ''");
    ensureColumn($db, 'countries_daily', 'site', "TEXT NOT NULL DEFAULT ''");
    ensureColumn($db, 'pages_daily', 'url', "TEXT NOT NULL DEFAULT ''");
    ensureColumn($db, 'pages_daily', 'page_last_try', "TEXT DEFAULT NULL");
    ensureColumn($db, 'pages_daily', 'page_failures', "INTEGER NOT NULL DEFAULT 0");
    ensureColumn($db, 'pages_daily', 'page_resolved', "INTEGER NOT NULL DEFAULT 0");
    ensureColumn($db, 'pages_daily', 'page_lang_last_try', "TEXT DEFAULT NULL");
    ensureColumn($db, 'pages_daily', 'page_lang_failures', "INTEGER NOT NULL DEFAULT 0");
    ensureColumn($db, 'referrers_daily', 'site', "TEXT NOT NULL DEFAULT ''");
    ensureColumn($db, 'seen_daily', 'site', "TEXT NOT NULL DEFAULT ''");
    ensureColumn($db, 'visitor_sessions', 'site', "TEXT NOT NULL DEFAULT ''");
    ensureColumn($db, 'visitor_sessions', 'returned_today', "INTEGER NOT NULL DEFAULT 0");
    ensureColumn($db, 'visitor_sessions', 'referrer', "TEXT NOT NULL DEFAULT ''");

    $db->exec("
        CREATE INDEX IF NOT EXISTS idx_hits_daily_day
            ON hits_daily(day);

        CREATE INDEX IF NOT EXISTS idx_countries_daily_day_country
            ON countries_daily(day, country);

        CREATE INDEX IF NOT EXISTS idx_referrers_daily_day_referrer
            ON referrers_daily(day, referrer);

        CREATE INDEX IF NOT EXISTS idx_pages_daily_day_site_page
            ON pages_daily(day, site, page_key);

        CREATE INDEX IF NOT EXISTS idx_pages_daily_site_day
            ON pages_daily(site, day);

        CREATE INDEX IF NOT EXISTS idx_pages_daily_resolve
            ON pages_daily(page_resolved, day DESC, views DESC);
    ");
}

function ensureColumn(PDO $db, string $table, string $column, string $definition): void {
    $cols = fetchAll($db, 'PRAGMA table_info(' . $table . ')');

    foreach ($cols as $col) {
        if (($col['name'] ?? '') === $column) {
            return;
        }
    }

    $db->exec('ALTER TABLE ' . $table . ' ADD COLUMN ' . $column . ' ' . $definition);
}


/*
|--------------------------------------------------------------------------
| Yearly archive check
|--------------------------------------------------------------------------
|
| Closed years only need to be archived once per calendar year.
| Store the last processed year to avoid scanning archives on every request.
|
*/

function brivaciaShouldCheckYearArchive(PDO $db): bool {
    $cutoff = date('Y') . '-01-01';

    $stmt = $db->prepare('
        SELECT EXISTS(
            SELECT 1
            FROM hits_daily
            WHERE day < ?
            AND site != ""
            LIMIT 1
        )
    ');

    $stmt->execute([$cutoff]);

    return (int)$stmt->fetchColumn() === 1;
}


/*
|--------------------------------------------------------------------------
| Network helpers
|--------------------------------------------------------------------------
|
| Shared helpers for outbound HTTP requests.
| Prevents SSRF by refusing private, loopback and reserved addresses.
|
*/

function brivaciaHttpContext(int $timeout = 1) {
    return stream_context_create([
        'http' => [
            'timeout' => $timeout,
            'user_agent' => 'Brivacia privacy-focused analytics favicon fetcher',
            // Without this, PHP's http:// wrapper returns false on any
            // non-2xx response (e.g. a 403 from a bot-protection layer)
            // instead of the response body — even when that body still
            // has a usable <title>. We want the body regardless of the
            // status code; callers decide what to do with it.
            'ignore_errors' => true,
        ],
    ]);
}

function brivaciaIsPublicHost(string $host): bool {
    $host = strtolower(trim($host));

    if ($host === '') {
        return false;
    }

    $records = dns_get_record($host, DNS_A + DNS_AAAA);

    if (!is_array($records) || $records === []) {
        return false;
    }

    foreach ($records as $record) {
        $ip = (string)($record['ip'] ?? $record['ipv6'] ?? '');

        if ($ip === '') {
            continue;
        }

        if (!filter_var(
            $ip,
            FILTER_VALIDATE_IP,
            FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
        )) {
            return false;
        }
    }

    return true;
}


/*
|--------------------------------------------------------------------------
| Pixel response and request helpers
|--------------------------------------------------------------------------
*/

function sendPixel(): never {
    header('Content-Type: image/png');
    header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
    echo base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X8xQAAAAASUVORK5CYII=');
    exit;
}

/*
| Sends the pixel bytes to the browser right now, WITHOUT ending the
| script. Any slow work that happens after this call (country lookups,
| page-label/language auto-detection over HTTP, DB writes, ...) no
| longer delays the response the visitor's browser is waiting on.
|
| On PHP-FPM, fastcgi_finish_request() actually closes the connection
| to the browser while PHP keeps running server-side. When that's not
| available (e.g. plain mod_php), we fall back to a best-effort flush:
| the browser still gets the bytes as soon as possible, even if the
| connection itself stays open a bit longer.
*/
function sendPixelNow(): void {
    // Whichever path below is used, the visitor's browser may move on
    // (close the tab, navigate away) the instant it has its pixel bytes.
    // Without this, a plain mod_php setup would abort the script right
    // there, before the "slow" work after this function even runs.
    ignore_user_abort(true);

    $png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X8xQAAAAASUVORK5CYII=');

    header('Content-Type: image/png');
    header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
    header('Content-Length: ' . strlen($png));
    echo $png;

    if (function_exists('fastcgi_finish_request')) {
        // PHP-FPM (LiteSpeed, nginx, most modern hosts): this is the clean
        // way to close the connection to the browser while PHP keeps
        // running server-side for the rest of this file.
        fastcgi_finish_request();
        return;
    }

    // Plain mod_php fallback (older/shared Apache hosts without FPM):
    // there's no equivalent of fastcgi_finish_request(), but flushing
    // every buffering layer still pushes the bytes out to the browser
    // as soon as possible. Since Content-Length matches exactly what we
    // sent, the browser considers the request done right away and won't
    // wait around for the connection to close — even though, on this
    // fallback path, the PHP process itself keeps occupying a worker
    // slot on the server until the script finishes.
    while (ob_get_level() > 0) {
        ob_end_flush();
    }
    flush();
}

function param(string $key, string $default = ''): string {
    return trim((string)($_GET[$key] ?? $default));
}


/*
|--------------------------------------------------------------------------
| Visitor identity and classification
|--------------------------------------------------------------------------
*/

function brivaciaClientIp(): string {
    return (string)($_SERVER['REMOTE_ADDR'] ?? '');
}

function brivaciaNormalizeUserAgent(string $ua): string {
    $ua = strtolower(trim($ua));

    if ($ua === '') {
        return 'other|other';
    }

    if (detectVisitorKind($ua) === 'bot') {
        return 'bot|other';
    }

    // Order matters: Edge and Opera also expose Chrome/Chromium tokens.
    $browser = match (true) {
        preg_match('~\b(?:edg|edge)/\d+~', $ua) === 1 => 'edge',
        preg_match('~\b(?:opr|opera)/\d+~', $ua) === 1 => 'opera',
        preg_match('~\b(?:firefox|fxios)/\d+~', $ua) === 1 => 'firefox',
        preg_match('~\b(?:chrome|crios|chromium)/\d+~', $ua) === 1 => 'chromium',
        preg_match('~\bversion/\d+.*\bsafari/\d+~', $ua) === 1 => 'safari',
        default => 'other',
    };

    // Keep only the platform family, never the exact OS version, architecture or device model.
    $platform = match (true) {
        str_contains($ua, 'android') => 'android',
        str_contains($ua, 'iphone') || str_contains($ua, 'ipad') || str_contains($ua, 'ipod') => 'ios',
        str_contains($ua, 'windows') => 'windows',
        str_contains($ua, 'mac os') || str_contains($ua, 'macintosh') => 'macos',
        str_contains($ua, 'linux') || str_contains($ua, 'x11') || str_contains($ua, 'cros') => 'linux',
        default => 'other',
    };

    return $browser . '|' . $platform;
}

function brivaciaSafeLogPath(string $value): string {
    $value = trim($value);

    if ($value === '') {
        return '[empty]';
    }

    if (preg_match('~^https?://~i', $value)) {
        $path = parse_url($value, PHP_URL_PATH);
        return is_string($path) && $path !== '' ? '[path]' : '[empty]';
    }

    if (str_starts_with($value, '/')) {
        return '[path]';
    }

    return '[value]';
}

function brivaciaSafeLogRef(string $ref): string {
    $host = normalizeHost((string)(parse_url($ref, PHP_URL_HOST) ?? ''));

    if ($host !== '') {
        return $host;
    }

    $ref = trim($ref);

    if ($ref === '') {
        return '[empty]';
    }

    return '[ref]';
}

function brivaciaSafeLogUrl(string $url): string {
    $url = trim($url);

    if ($url === '') {
        return '[empty]';
    }

    $host = parse_url($url, PHP_URL_HOST);
    $path = parse_url($url, PHP_URL_PATH);

    if (is_string($host) && $host !== '') {
        return strtolower($host) . (is_string($path) && $path !== '' ? $path : '/');
    }

    if (str_starts_with($url, '/')) {
        return (string)(parse_url($url, PHP_URL_PATH) ?: '/');
    }

    return mb_substr($url, 0, 120, 'UTF-8');
}

function detectVisitorKind(string $ua): string {
    static $botPatterns = [
        // IA
        'anthropic',
        'chatgpt',
        'claudebot',
        'cohere-ai',
        'gptbot',
        'google-extended',
        'omgili',
        'omgilibot',
        'perplexity',
        'perplexitybot',
        'qwen',
        'youbot',
        'you.com',

        // Google
        'googlebot',
        'googleother',
        'google-inspectiontool',
        'storebot-google',

        // Bing / Microsoft
        'bingbot',
        'bingpreview',
        'adidxbot',

        // Apple
        'applebot',
        'applebot-extended',

        // Meta
        'facebookexternalhit',
        'meta-externalagent',
        'meta-externalfetcher',

        // ByteDance
        'bytespider',

        // SEO
        'ahrefs',
        'ahrefsbot',
        'semrush',
        'semrushbot',
        'mj12bot',
        'dotbot',
        'seokicks',
        'petalbot',

        // Archive
        'archive.org_bot',
        'ia_archiver',

        // Divers
        'slurp',
        'duckassistbot',
        'imagesiftbot',
        'amazonbot',
        'coccocbot',
        'dataprovider',
        'uptimerobot',
        'pingdom',
        'whatsapp',
    ];

    $ua = strtolower($ua);

    if (preg_match('~(?:bot|crawl|crawler|spider|scraper|fetcher|scanner)\b~i', $ua)) {
        return 'bot';
    }

    foreach ($botPatterns as $pattern) {
        if (str_contains($ua, $pattern)) {
            return 'bot';
        }
    }

    return 'human';
}

function brivaciaIpPrefix(string $ip): string {
    $ip = trim($ip);

    $precision = max(
        2,
        min(3, (int) brivacia_setting('privacy.ip_prefix_octets', 3))
    );

    if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
        $parts = explode('.', $ip);

        return implode('.', array_slice($parts, 0, $precision));
    }

    if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
        $packed = @inet_pton($ip);

        if ($packed === false || strlen($packed) !== 16) {
            return '';
        }

        $hextets = $precision === 2 ? 3 : 4;
        $hex = bin2hex(substr($packed, 0, $hextets * 2));

        return implode(':', str_split($hex, 4));
    }

    return '';
}

/*
|--------------------------------------------------------------------------
| Pixel flood protection
|--------------------------------------------------------------------------
|
| A crude flood/anti-abuse guard for the public tracking endpoint, reusing
| the same truncated IP prefix as everywhere else (never the full IP).
| Counts hits in fixed 10-second windows per prefix; once a window goes
| over the threshold, further hits in that same window are dropped from
| storage (the pixel itself is still returned, so nothing looks broken to
| a script hammering the endpoint — it just stops being recorded).
| A real visitor loading pages normally never gets close to this.
*/

function brivaciaPixelRateLimitKey(string $ip): string {
    $prefix = brivaciaIpPrefix($ip);
    return $prefix !== '' ? $prefix : 'unknown';
}

function brivaciaPixelRateLimited(PDO $db, string $key, int $windowSeconds = 10): bool {
    $maxPerWindow = max(10, (int) brivacia_setting('privacy.pixel_rate_limit', 60));
    $windowStart = intdiv(time(), $windowSeconds) * $windowSeconds;

    $stmt = $db->prepare('SELECT window_start, hits FROM pixel_rate_limit WHERE ip_prefix = ?');
    $stmt->execute([$key]);
    $row = $stmt->fetch() ?: ['window_start' => 0, 'hits' => 0];

    $hits = ((int)$row['window_start'] === $windowStart) ? (int)$row['hits'] + 1 : 1;

    $db->prepare('
        INSERT INTO pixel_rate_limit (ip_prefix, window_start, hits)
        VALUES (?, ?, ?)
        ON CONFLICT(ip_prefix) DO UPDATE SET
            window_start = excluded.window_start,
            hits = excluded.hits
    ')->execute([$key, $windowStart, $hits]);

    return $hits > $maxPerWindow;
}

function visitorHash(string $day, string $ua, string $ip): string {
    return hash(
        'sha256',
        $day . '|' .
        brivaciaIpPrefix($ip) . '|' .
        brivaciaNormalizeUserAgent($ua) . '|' .
        brivacia_secret_key()
    );
}


/*
|--------------------------------------------------------------------------
| Counters
|--------------------------------------------------------------------------
*/

function ensureDay(PDO $db, string $day, string $site = ''): void {
    $site = $site !== '' ? $site : array_key_first(brivacia_sites());

    $db->prepare('INSERT OR IGNORE INTO hits_daily(site, day) VALUES(?, ?)')
        ->execute([$site, $day]);
}

/*
|--------------------------------------------------------------------------
| Hourly counters
|--------------------------------------------------------------------------
|
| Same idea as hits_daily/inc(), just with an extra hour dimension (0-23).
| This exists so the dashboard can compare "today so far" with the same
| number of hours yesterday, instead of a partial day vs a full day.
|
| Only ~9 days are kept (maybePurgeHourlyHits) since this is only useful
| for short, recent comparisons, not long-term history.
|
*/

function ensureHour(PDO $db, string $day, int $hour, string $site = ''): void {
    $site = $site !== '' ? $site : array_key_first(brivacia_sites());

    $db->prepare('INSERT OR IGNORE INTO hits_hourly(site, day, hour) VALUES(?, ?, ?)')
        ->execute([$site, $day, $hour]);
}

function incHourly(PDO $db, string $col, string $day, int $hour, string $site = ''): void {
    $allowed = ['unique_visitors', 'visits', 'pageviews', 'bots'];

    if (!in_array($col, $allowed, true)) {
        return;
    }

    $site = $site !== '' ? $site : array_key_first(brivacia_sites());

    $db->prepare("UPDATE hits_hourly SET $col = $col + 1 WHERE site = ? AND day = ? AND hour = ?")
        ->execute([$site, $day, $hour]);
}

function maybePurgeHourlyHits(PDO $db, string $day, int $hour): void {
    // Only worth checking once per day, right when a new day starts, so
    // this doesn't re-run (and rescan the table) on every single hit.
    if ($hour !== 0) {
        return;
    }

    $cutoff = date('Y-m-d', strtotime($day . ' -8 days'));

    $db->prepare('DELETE FROM hits_hourly WHERE day < ?')->execute([$cutoff]);
}

function markSeen(PDO $db, string $day, string $hash, string $site = ''): bool {
    $site = $site !== '' ? $site : array_key_first(brivacia_sites());

    $stmt = $db->prepare('
        INSERT OR IGNORE INTO seen_daily(site, day, visitor_hash)
        VALUES(?, ?, ?)
    ');

    $stmt->execute([$site, $day, $hash]);

    return $stmt->rowCount() > 0;
}

function markSession(PDO $db, string $day, string $hash, string $site = '', int $timeout = 1800): bool {
    $site = $site !== '' ? $site : array_key_first(brivacia_sites());
    $now = time();

    $stmt = $db->prepare('
        SELECT last_seen
        FROM visitor_sessions
        WHERE site = ? AND day = ? AND visitor_hash = ?
        LIMIT 1
    ');

    $stmt->execute([$site, $day, $hash]);
    $row = $stmt->fetch(PDO::FETCH_ASSOC);

    if (!is_array($row)) {
        $db->prepare('
            INSERT INTO visitor_sessions(site, day, visitor_hash, last_seen)
            VALUES(?, ?, ?, ?)
        ')->execute([$site, $day, $hash, $now]);

        return true;
    }

    $isNewSession = (int)($row['last_seen'] ?? 0) <= $now - $timeout;

    if ($isNewSession) {
        $db->prepare('
            UPDATE visitor_sessions
            SET last_seen = ?, returned_today = 1
            WHERE site = ? AND day = ? AND visitor_hash = ?
        ')->execute([$now, $site, $day, $hash]);
    } else {
        $db->prepare('
            UPDATE visitor_sessions
            SET last_seen = ?
            WHERE site = ? AND day = ? AND visitor_hash = ?
        ')->execute([$now, $site, $day, $hash]);
    }

    return $isNewSession;
}

/*
|--------------------------------------------------------------------------
| Referrer upgrade within an existing session
|--------------------------------------------------------------------------
|
| A session/visit is only counted once per 30-minute window (see
| markSession()), and its referrer is only recorded on that first hit. If
| that first hit had no usable referrer (direct visit, or the browser
| withheld it) but a later hit in the SAME session carries a real one —
| e.g. the visitor opened the site directly, then a bit later clicked
| through from an external link to another page while still within the
| session window — this corrects the attribution: the visit moves from
| "Unknown" to the real source, without touching visit/pageview counts.
|
| Deliberately one-directional: once a session has a real, identified
| referrer, a *different* later referrer in the same session never
| overwrites it (e.g. internal navigation, or clicking a second external
| link) — the first real source found is kept as the visit's origin.
*/
function maybeUpgradeSessionReferrer(PDO $db, string $day, string $hash, string $site, string $referrer): void {
    if ($referrer === '' || $referrer === BRIVACIA_UNKNOWN || $referrer === BRIVACIA_BLOCKED) {
        return;
    }

    $stmt = $db->prepare('
        SELECT referrer FROM visitor_sessions
        WHERE site = ? AND day = ? AND visitor_hash = ?
        LIMIT 1
    ');
    $stmt->execute([$site, $day, $hash]);
    $stored = $stmt->fetchColumn();

    if ($stored === false) {
        return;
    }

    $stored = (string)$stored;

    if ($stored !== '' && $stored !== BRIVACIA_UNKNOWN) {
        return;
    }

    $oldReferrer = $stored === '' ? BRIVACIA_UNKNOWN : $stored;

    $db->prepare('
        UPDATE referrers_daily SET views = MAX(0, views - 1)
        WHERE site = ? AND day = ? AND referrer = ?
    ')->execute([$site, $day, canonicalReferrerSource($oldReferrer)]);

    incReferrer($db, $day, $referrer, $site);

    $db->prepare('
        UPDATE visitor_sessions SET referrer = ?
        WHERE site = ? AND day = ? AND visitor_hash = ?
    ')->execute([$referrer, $site, $day, $hash]);
}

function inc(PDO $db, string $col, string $day, string $site = ''): void {
    $allowed = ['unique_visitors','visits','pageviews','bots'];

    if (!in_array($col, $allowed, true)) {
        return;
    }

    $site = $site !== '' ? $site : array_key_first(brivacia_sites());

    $db->prepare("UPDATE hits_daily SET $col = $col + 1 WHERE site = ? AND day = ?")
        ->execute([$site, $day]);
}

function incPage(PDO $db, string $day, string $site, string $key, string $title, string $url = ''): void {
    $url = brivaciaCleanStoredPageUrl($url);
    $isFallback = brivaciaShouldLogPageFallback($key, $title, $url);

    if ($isFallback) {
        brivaciaLog(
            'pixel/page-fallback.log',
            'site=' . $site .
            ' key=' . $key .
            ' title=' . ($title !== '' ? $title : '[empty]') .
            ' url=' . brivaciaSafeLogUrl($url)
        );
    }

    $db->prepare(
        'INSERT OR IGNORE INTO pages_daily(day, site, page_key, title, url, views, page_resolved) VALUES(?, ?, ?, ?, ?, 0, ?)'
    )->execute([$day, $site, $key, $title, $url, $isFallback ? 0 : 1]);

    // Do not let a bad hit overwrite a previously resolved title/URL.
    $db->prepare(
        'UPDATE pages_daily
         SET views = views + 1,
             title = CASE WHEN ? = 0 AND ? != "" THEN ? ELSE title END,
             url = CASE WHEN ? = 0 AND ? != "" THEN ? ELSE url END,
             page_resolved = CASE WHEN ? = 0 THEN 1 ELSE page_resolved END
         WHERE day = ? AND site = ? AND page_key = ?'
    )->execute([
        $isFallback ? 1 : 0, $title, $title,
        $isFallback ? 1 : 0, $url, $url,
        $isFallback ? 1 : 0,
        $day, $site, $key
    ]);
}


/*
|--------------------------------------------------------------------------
| Sites and page helpers
|--------------------------------------------------------------------------
*/

function cleanPageTitle(string $title, string $site, string $pageKey = '', string $url = ''): string {
    $default = brivaciaCleanPageTitle($title, $site);

    $result = brivaciaRunCustomRules($site, [
        'title' => $default,
        'pageKey' => $pageKey,
        'pageId' => brivaciaRawPageKey($pageKey),
        'url' => $url,
        'pageUrl' => $url,
    ]);

    return (string)($result['title'] ?? $default);
}

function siteLabel(string $site): string {
    $site = trim(strtolower($site));

    if (isset(brivacia_sites()[$site])) {
        return brivacia_sites()[$site];
    }

    if (str_contains($site, '.')) {
        return $site;
    }

    return $site;
}

function mainSiteHost(): string {
    $sites = array_values(brivacia_sites());

    return strtolower(
        trim($sites[0] ?? '')
    );
}

/*
|--------------------------------------------------------------------------
| Custom rules runtime
|--------------------------------------------------------------------------
|
| Runs rules_custom.php with simple variables.
|
| This is only used to make page titles nicer and page URLS with the real
| one in the dashboard.
|
| Available in rules_custom.php:
| - $isSite1, $isSite2, ...
| - $title
| - $pageKey
| - $pageId
| - $trackedWebsiteLang
| - $url
| - $pageUrl
|
*/

function brivaciaRunCustomRules(string $site, array $data): array {
    $file = __DIR__ . '/rules_custom.php';

    if (!is_file($file)) {
        return $data;
    }

    foreach (array_keys(brivacia_sites()) as $index => $siteId) {
        $number = $index + 1;

        ${'site' . $number} = (string)$siteId;
        ${'isSite' . $number} = ($site === (string)$siteId);
    }

    $title = (string)($data['title'] ?? '');
    $pageKey = (string)($data['pageKey'] ?? '');
    $pageId = (string)($data['pageId'] ?? brivaciaRawPageKey($pageKey));
    $trackedWebsiteLang = (string)($data['trackedWebsiteLang'] ?? '');
    $url = (string)($data['url'] ?? '');
    $pageUrl = (string)($data['pageUrl'] ?? '');
    $resolved = (array)($data['resolved'] ?? []);

    include $file;

    $data['title'] = $title;
    $data['pageKey'] = $pageKey;
    $data['pageId'] = $pageId;
    $data['trackedWebsiteLang'] = $trackedWebsiteLang;
    $data['url'] = $url;
    $data['pageUrl'] = $pageUrl;
    $data['resolved'] = $resolved;

    return $data;
}

/*
|--------------------------------------------------------------------------
| Cookies
|--------------------------------------------------------------------------
|
| Returns the cookie domain used by ignore.php.
|
*/

function ignoreCookieDomain(): string {
    return '.' . mainSiteHost();
}


/*
|--------------------------------------------------------------------------
| Page metadata
|--------------------------------------------------------------------------
|
| Bad shared hosting can make the tracked site return only a page id.
| Brivacia keeps the hit immediately, then retries later to resolve title/URL.
|
*/

function brivaciaResolveMetadata(string $site, string $pageKey, string $url = ''): array {
    $resolved = brivaciaResolvePageMetadata($site, $pageKey, $url);

    $resolvedUrl = (string)($resolved['url'] ?? $url);

    $result = brivaciaRunCustomRules($site, [
        'pageKey' => $pageKey,
        'url' => $resolvedUrl,
        'title' => (string)($resolved['title'] ?? ''),
        'pageUrl' => $resolvedUrl,
        'resolved' => $resolved,
    ]);

    $resolved['title'] = (string)($result['title'] ?? $resolved['title'] ?? '');
    $resolved['url'] = (string)($result['pageUrl'] ?? $resolvedUrl);

    return $resolved;
}

function refreshPageLabels(PDO $db, int $limit = 3): void {
    if ($limit <= 0) {
        return;
    }

    $lockFile = dataDir() . '/pages-refresh.lock';
    $lockHandle = fopen($lockFile, 'c');

    if (!$lockHandle) return;

    if (!flock($lockHandle, LOCK_EX | LOCK_NB)) {
        fclose($lockHandle);
        return;
    }

    $currentYearStart = date('Y') . '-01-01';
    $scanLimit = max(1, min(20, $limit * 3));

    $rows = fetchAll($db, '
        SELECT
            site,
            page_key,
            MAX(url) AS url,
            MAX(page_failures) AS failures,
            MAX(page_last_try) AS last_try
        FROM pages_daily
        WHERE page_resolved = 0
        AND day >= ?
        GROUP BY site, page_key
        ORDER BY MAX(day) DESC, SUM(views) DESC
        LIMIT ?
    ', [$currentYearStart, $scanLimit]);

    $done = 0;

    foreach ($rows as $row) {
        if ($done >= $limit) break;

        $failures = (int)($row['failures'] ?? 0);
        $cooldown = match (true) {
            $failures >= 5 => 86400,
            $failures >= 3 => 3600,
            $failures >= 1 => 600,
            default => 0,
        };

        $lastTry = $row['last_try'] ?? null;

        if ($lastTry !== null && strtotime((string)$lastTry) > time() - $cooldown) {
            continue;
        }

        $site = (string)$row['site'];
        $pageKey = (string)$row['page_key'];
        $url = (string)($row['url'] ?? '');

        brivaciaLog(
            'pages/recheck.log',
            'TRY site=' . $site .
            ' key=' . $pageKey .
            ' url=' . brivaciaSafeLogUrl($url) .
            ' failures=' . $failures
        );

        $resolved = brivaciaResolveMetadata($site, $pageKey, $url);

        brivaciaLog(
            'pages/recheck.log',
            'RESULT site=' . $site .
            ' key=' . $pageKey .
            ' title=' . (($resolved['title'] ?? '') !== '' ? $resolved['title'] : '[empty]') .
            ' url=' . brivaciaSafeLogUrl((string)($resolved['url'] ?? ''))
        );

        $now = date('c');

        if (($resolved['title'] ?? '') === '' || ($resolved['url'] ?? '') === '') {
            $db->prepare('
                UPDATE pages_daily
                SET page_last_try = ?, page_failures = page_failures + 1
                WHERE site = ? AND page_key = ?
            ')->execute([$now, $site, $pageKey]);

            $done++;
            continue;
        }

        $db->prepare('
            UPDATE pages_daily
            SET title = ?, url = ?, page_resolved = 1, page_failures = 0, page_last_try = ?
            WHERE site = ? AND page_key = ?
        ')->execute([(string)$resolved['title'], (string)$resolved['url'], $now, $site, $pageKey]);

        brivaciaLog('pixel/page-resolved.log', 'site=' . $site . ' key=' . $pageKey . ' url=' . brivaciaSafeLogUrl((string)$resolved['url']));
        $done++;
    }

    flock($lockHandle, LOCK_UN);
    fclose($lockHandle);
}

function refreshPageLabelsForRange(PDO $db, string $rangeSql, array $rangeParams, int $limit = 5): void {
    if ($limit <= 0) {
        return;
    }

    $lockFile = dataDir() . '/pages-refresh.lock';
    $lockHandle = fopen($lockFile, 'c');

    if (!$lockHandle) return;

    if (!flock($lockHandle, LOCK_EX | LOCK_NB)) {
        fclose($lockHandle);
        return;
    }

    $scanLimit = max(1, min(30, $limit * 3));

    $rows = fetchAll($db, '
        SELECT
            site,
            page_key,
            MAX(url) AS url,
            MAX(page_failures) AS failures,
            MAX(page_last_try) AS last_try
        FROM pages_daily
        WHERE page_resolved = 0
        AND ' . $rangeSql . '
        GROUP BY site, page_key
        ORDER BY SUM(views) DESC, MAX(day) DESC
        LIMIT ?
    ', array_merge($rangeParams, [$scanLimit]));

    $done = 0;

    foreach ($rows as $row) {
        if ($done >= $limit) break;

        $failures = (int)($row['failures'] ?? 0);

        $cooldown = match (true) {
            $failures >= 5 => 86400,
            $failures >= 3 => 3600,
            $failures >= 1 => 600,
            default => 0,
        };

        $lastTry = $row['last_try'] ?? null;

        if ($lastTry !== null && strtotime((string)$lastTry) > time() - $cooldown) {
            continue;
        }

        $site = (string)$row['site'];
        $pageKey = (string)$row['page_key'];
        $url = (string)($row['url'] ?? '');

        brivaciaLog(
            'pages/recheck.log',
            'RANGE TRY site=' . $site .
            ' key=' . $pageKey .
            ' url=' . brivaciaSafeLogUrl($url) .
            ' failures=' . $failures
        );

        $resolved = brivaciaResolveMetadata($site, $pageKey, $url);
        $now = date('c');

        if (($resolved['title'] ?? '') === '' || ($resolved['url'] ?? '') === '') {
            $db->prepare('
                UPDATE pages_daily
                SET page_last_try = ?, page_failures = page_failures + 1
                WHERE site = ? AND page_key = ?
            ')->execute([$now, $site, $pageKey]);

            $done++;
            continue;
        }

        $db->prepare('
            UPDATE pages_daily
            SET title = ?, url = ?, page_resolved = 1, page_failures = 0, page_last_try = ?
            WHERE site = ? AND page_key = ?
        ')->execute([
            (string)$resolved['title'],
            (string)$resolved['url'],
            $now,
            $site,
            $pageKey
        ]);

        brivaciaLog(
            'pixel/page-resolved.log',
            'range site=' . $site . ' key=' . $pageKey . ' url=' . brivaciaSafeLogUrl((string)$resolved['url'])
        );

        $done++;
    }

    flock($lockHandle, LOCK_UN);
    fclose($lockHandle);
}

/*
|--------------------------------------------------------------------------
| Language refresh
|--------------------------------------------------------------------------
|
| Mirrors refreshPageLabels(): periodically retries pages that couldn't get
| a real language at pixel time (stuck on the 'xx:' alert marker, or on a
| bare pre-fix legacy key with no language segment at all). Never touches a
| page_key that already carries a real language prefix.
|
| Re-fetches the page's HTML (already needed for title/URL resolution) and
| reads <html lang="...">. If found, the page_key is renamed from
| 'xx:/path' (or '/path') to 'lang:/path'. If a row for that day already
| exists under the resolved key (e.g. both '/path' and 'en:/path' got hits
| on the same day), the views are merged into the existing row and the
| unresolved one is dropped — never two rows for the same real page.
|
*/

function brivaciaPageKeyNeedsLangRefresh(string $pageKey): bool {
    if (str_starts_with($pageKey, BRIVACIA_UNKNOWN_LANG . ':')) {
        return true;
    }

    // Anything that isn't already "xx:..." and doesn't start with a real
    // 2-letter language prefix either (a pre-fix legacy key, e.g. "/about").
    return !preg_match('/^[a-z]{2}:/i', $pageKey);
}

function unresolvedLanguagesCount(PDO $db): int {
    $row = fetchAll($db, "
        SELECT COUNT(*) AS total
        FROM (
            SELECT site, page_key
            FROM pages_daily
            WHERE (page_key GLOB 'xx:*' OR page_key NOT GLOB '[a-z][a-z]:*')
            AND day >= ?
            GROUP BY site, page_key
        )
    ", [date('Y') . '-01-01'])[0] ?? [];

    return (int)($row['total'] ?? 0);
}

function brivaciaMaintenanceLangLimit(PDO $db): int {
    $remaining = unresolvedLanguagesCount($db);

    return match (true) {
        $remaining > 10000 => 25,
        $remaining > 5000  => 15,
        $remaining > 1000  => 8,
        $remaining > 100   => 5,
        $remaining > 0     => 2,
        default            => 0,
    };
}

// Renames site+oldKey to site+newKey across every day it appears. When a
// day already has a row under newKey (both keys got hits that day), the
// views are merged into that existing row and the oldKey row is dropped,
// so the same real page never ends up counted under two different keys.
function brivaciaMergePageKey(PDO $db, string $site, string $oldKey, string $newKey, string $now): void {
    if ($oldKey === $newKey) {
        return;
    }

    $rows = fetchAll($db, '
        SELECT day, views
        FROM pages_daily
        WHERE site = ? AND page_key = ?
    ', [$site, $oldKey]);

    foreach ($rows as $row) {
        $day = (string)$row['day'];
        $views = (int)($row['views'] ?? 0);

        $exists = fetchAll($db, '
            SELECT 1 FROM pages_daily WHERE day = ? AND site = ? AND page_key = ?
        ', [$day, $site, $newKey]);

        if ($exists) {
            $db->prepare('
                UPDATE pages_daily SET views = views + ?
                WHERE day = ? AND site = ? AND page_key = ?
            ')->execute([$views, $day, $site, $newKey]);

            $db->prepare('
                DELETE FROM pages_daily WHERE day = ? AND site = ? AND page_key = ?
            ')->execute([$day, $site, $oldKey]);
        } else {
            $db->prepare('
                UPDATE pages_daily
                SET page_key = ?, page_lang_last_try = ?, page_lang_failures = 0
                WHERE day = ? AND site = ? AND page_key = ?
            ')->execute([$newKey, $now, $day, $site, $oldKey]);
        }
    }
}

function refreshPageLanguages(PDO $db, int $limit = 3): void {
    if ($limit <= 0) {
        return;
    }

    $lockFile = dataDir() . '/pages-lang-refresh.lock';
    $lockHandle = fopen($lockFile, 'c');

    if (!$lockHandle) return;

    if (!flock($lockHandle, LOCK_EX | LOCK_NB)) {
        fclose($lockHandle);
        return;
    }

    $currentYearStart = date('Y') . '-01-01';
    $scanLimit = max(1, min(20, $limit * 3));

    $rows = fetchAll($db, "
        SELECT
            site,
            page_key,
            MAX(url) AS url,
            MAX(page_lang_failures) AS failures,
            MAX(page_lang_last_try) AS last_try
        FROM pages_daily
        WHERE (page_key GLOB 'xx:*' OR page_key NOT GLOB '[a-z][a-z]:*')
        AND day >= ?
        GROUP BY site, page_key
        ORDER BY MAX(day) DESC, SUM(views) DESC
        LIMIT ?
    ", [$currentYearStart, $scanLimit]);

    $done = 0;

    foreach ($rows as $row) {
        if ($done >= $limit) break;

        $site = (string)$row['site'];
        $oldKey = (string)$row['page_key'];

        if (!brivaciaPageKeyNeedsLangRefresh($oldKey)) {
            continue;
        }

        $failures = (int)($row['failures'] ?? 0);
        $cooldown = match (true) {
            $failures >= 5 => 86400,
            $failures >= 3 => 3600,
            $failures >= 1 => 600,
            default => 0,
        };

        $lastTry = $row['last_try'] ?? null;

        if ($lastTry !== null && strtotime((string)$lastTry) > time() - $cooldown) {
            continue;
        }

        $url = (string)($row['url'] ?? '');
        $now = date('c');

        brivaciaLog(
            'pages/lang-recheck.log',
            'TRY site=' . $site .
            ' key=' . brivaciaSafeLogPath($oldKey) .
            ' url=' . brivaciaSafeLogUrl($url) .
            ' failures=' . $failures
        );

        $resolved = brivaciaResolvePageMetadata($site, $oldKey, $url);
        $lang = brivaciaNormalizeLangCode((string)($resolved['lang'] ?? ''));

        if ($lang === '') {
            $db->prepare('
                UPDATE pages_daily
                SET page_lang_last_try = ?, page_lang_failures = page_lang_failures + 1
                WHERE site = ? AND page_key = ?
            ')->execute([$now, $site, $oldKey]);

            $done++;
            continue;
        }

        $newKey = $lang . ':' . brivaciaRawPageKey($oldKey);

        brivaciaMergePageKey($db, $site, $oldKey, $newKey, $now);

        brivaciaLog(
            'pages/lang-resolved.log',
            'site=' . $site . ' from=' . brivaciaSafeLogPath($oldKey) . ' to_lang=' . $lang
        );

        $done++;
    }

    flock($lockHandle, LOCK_UN);
    fclose($lockHandle);
}

// Same idea as refreshPageLanguages(), but scoped to an arbitrary SQL
// range instead of "the current year, a few per pixel hit" — meant to be
// called synchronously right before a year is archived+purged (see
// createYearArchive() in archive.php), so a page stuck on 'xx:' or a bare
// key doesn't get frozen into the static archive JSON forever just
// because the regular per-hit trickle hadn't caught up to it yet.
function refreshPageLanguagesForRange(PDO $db, string $rangeSql, array $rangeParams, int $limit = 100): void {
    if ($limit <= 0) {
        return;
    }

    $lockFile = dataDir() . '/pages-lang-refresh.lock';
    $lockHandle = fopen($lockFile, 'c');

    if (!$lockHandle) return;

    if (!flock($lockHandle, LOCK_EX | LOCK_NB)) {
        fclose($lockHandle);
        return;
    }

    $scanLimit = max(1, min(500, $limit * 3));

    $rows = fetchAll($db, "
        SELECT
            site,
            page_key,
            MAX(url) AS url,
            MAX(page_lang_failures) AS failures,
            MAX(page_lang_last_try) AS last_try
        FROM pages_daily
        WHERE (page_key GLOB 'xx:*' OR page_key NOT GLOB '[a-z][a-z]:*')
        AND " . $rangeSql . "
        GROUP BY site, page_key
        ORDER BY SUM(views) DESC, MAX(day) DESC
        LIMIT ?
    ", array_merge($rangeParams, [$scanLimit]));

    $done = 0;

    foreach ($rows as $row) {
        if ($done >= $limit) break;

        $site = (string)$row['site'];
        $oldKey = (string)$row['page_key'];

        if (!brivaciaPageKeyNeedsLangRefresh($oldKey)) {
            continue;
        }

        // Unlike the regular trickle job, this is a one-shot pre-archive
        // pass — it still records failures for logging, but doesn't wait
        // out the usual backoff cooldown, since there won't be another
        // chance to retry after this year is purged.
        $failures = (int)($row['failures'] ?? 0);
        $url = (string)($row['url'] ?? '');
        $now = date('c');

        brivaciaLog(
            'pages/lang-recheck.log',
            'RANGE TRY site=' . $site .
            ' key=' . brivaciaSafeLogPath($oldKey) .
            ' url=' . brivaciaSafeLogUrl($url) .
            ' failures=' . $failures
        );

        $resolved = brivaciaResolvePageMetadata($site, $oldKey, $url);
        $lang = brivaciaNormalizeLangCode((string)($resolved['lang'] ?? ''));

        if ($lang === '') {
            $db->prepare('
                UPDATE pages_daily
                SET page_lang_last_try = ?, page_lang_failures = page_lang_failures + 1
                WHERE site = ? AND page_key = ?
            ')->execute([$now, $site, $oldKey]);

            $done++;
            continue;
        }

        $newKey = $lang . ':' . brivaciaRawPageKey($oldKey);

        brivaciaMergePageKey($db, $site, $oldKey, $newKey, $now);

        brivaciaLog(
            'pages/lang-resolved.log',
            'range site=' . $site . ' from=' . brivaciaSafeLogPath($oldKey) . ' to_lang=' . $lang
        );

        $done++;
    }

    flock($lockHandle, LOCK_UN);
    fclose($lockHandle);
}

function pageRulesSignature(): string {
    $files = [
        __DIR__ . '/rules.php',
        __DIR__ . '/rules_custom.php',
    ];

    $hash = '';

    foreach ($files as $file) {
        $hash .= is_file($file)
            ? md5_file($file)
            : 'missing';
    }

    return md5($hash);
}

function normalizeStoredPages(PDO $db): void {
    $db->exec("
        UPDATE pages_daily
        SET
            page_resolved = 0,
            page_failures = 0,
            page_last_try = NULL
    ");
}

function maybeNormalizeStoredPages(PDO $db): void {
    $stamp = dataDir() . '/pages-rules.stamp';
    $current = pageRulesSignature();

    $last = is_file($stamp)
        ? trim((string)file_get_contents($stamp))
        : '';

    if ($current === $last) {
        return;
    }

    normalizeStoredPages($db);

    file_put_contents($stamp, $current, LOCK_EX);
}

function unresolvedPagesCount(PDO $db): int {
    $row = fetchAll($db, '
        SELECT COUNT(*) AS total
        FROM (
            SELECT site, page_key
            FROM pages_daily
            WHERE page_resolved = 0
            AND day >= ?
            GROUP BY site, page_key
        )
    ', [date('Y') . '-01-01'])[0] ?? [];

    return (int)($row['total'] ?? 0);
}

function brivaciaMaintenancePageLimit(PDO $db): int {
    $remaining = unresolvedPagesCount($db);

    return match (true) {
        $remaining > 10000 => 25,
        $remaining > 5000  => 15,
        $remaining > 1000  => 8,
        $remaining > 100   => 5,
        $remaining > 0     => 2,
        default            => 0,
    };
}


/*
|--------------------------------------------------------------------------
| Countries
|--------------------------------------------------------------------------
*/

function normalizeCountryCode(string $country): string {
    $country = trim($country);

    if ($country === '' || $country === BRIVACIA_UNKNOWN) {
        return 'XX';
    }

    $country = strtoupper(substr($country, 0, 2));

    return preg_match('/^[A-Z]{2}$/', $country)
        ? $country
        : 'XX';
}

function incCountry(PDO $db, string $day, string $country, string $site = ''): void {
    $site = $site !== '' ? $site : array_key_first(brivacia_sites());
    $country = normalizeCountryCode($country);

    $db->prepare(
        'INSERT OR IGNORE INTO countries_daily(site, day, country, views) VALUES(?, ?, ?, 0)'
    )->execute([$site, $day, $country]);

    $db->prepare(
        'UPDATE countries_daily SET views = views + 1 WHERE site = ? AND day = ? AND country = ?'
    )->execute([$site, $day, $country]);
}

function countryName(string $code): string {
    $code = normalizeCountryCode($code);

    if ($code === 'XX') {
        return t('ui.unknown');
    }

    $lang = currentLang();

    // Cached per-request: ResourceBundle::create() reads from disk, so
    // this only pays that cost once no matter how many countries are
    // rendered on a given page (bar/pie/map graphs, the countries card,
    // the countries modal all call countryName() repeatedly).
    static $shortTables = [];

    if (!array_key_exists($lang, $shortTables)) {
        $shortTables[$lang] = class_exists('ResourceBundle')
            ? \ResourceBundle::create($lang, 'ICUDATA-region')?->get('Countries%short')
            : null;
    }

    $short = $shortTables[$lang]?->get($code);

    // Generic sanity check, not a country list: ICU4C's "Countries%short"
    // table is supposed to be for genuine disambiguation (Hong Kong vs.
    // "Hong Kong SAR China", RD Congo vs. Congo), but in practice it's
    // inconsistent across languages -- e.g. the English table contains
    // plain abbreviations like "US" and "UK", not just disambiguation
    // names. A real place name has a mix of upper/lowercase letters
    // ("Hong Kong", "Macao"); a pure abbreviation, once punctuation and
    // spaces are stripped, is entirely uppercase ("US", "UK", "É.-U." ->
    // "ÉU"). Rejecting anything that looks like the latter and falling
    // through to the full name is safer than trusting the table blindly.
    if (is_string($short) && $short !== '') {
        $lettersOnly = preg_replace('/[^\p{L}]/u', '', $short) ?? '';
        $looksLikeAbbreviation = $lettersOnly !== '' && $lettersOnly === mb_strtoupper($lettersOnly, 'UTF-8');

        if (!$looksLikeAbbreviation) {
            return $short;
        }
    }

    if (class_exists('Locale')) {
        $long = \Locale::getDisplayRegion('-' . $code, $lang) ?: $code;

        // One-off exception, not the start of a list: the Vatican is the
        // only case in this app's two languages where the ICU long name
        // has a chunky, droppable prefix/suffix around the actual name
        // ("État de la Cité du Vatican" / "Vatican City") rather than
        // being a genuinely different (and unshortenable) official name.
        if ($code === 'VA') {
            $long = str_replace(['État de la Cité du ', ' City'], '', $long);
        }

        return $long;
    }

    return $code;
}

function countryFlagUrl(string $country): string
{
    $country = strtolower(normalizeCountryCode($country));
    $base = dirname(__DIR__);

    foreach (['svg', 'png', 'webp', 'jpg', 'jpeg', 'ico'] as $ext) {
        // /static/images/... survives updates (see update_blocked_dirs());
        // /assets/images/... is Brivacia's own built-in asset and gets
        // overwritten on update, so a custom flag placed there would be
        // silently lost on the next update.
        $customFile = '/static/images/flags/' . $country . '.' . $ext;

        if (is_file($base . $customFile)) {
            return $customFile;
        }

        $builtInFile = '/assets/images/flags/' . $country . '.' . $ext;

        if (is_file($base . $builtInFile)) {
            return $builtInFile;
        }
    }

    return '/assets/images/flags/xx.png';
}

function countryFlag(string $country): string
{
    return '<img class="flag" src="' . h(countryFlagUrl($country)) . '" alt="">';
}


/*
|--------------------------------------------------------------------------
| Language to country mapping
|--------------------------------------------------------------------------
|
| Some ISO 639 language codes differ from ISO 3166 country codes.
| Only the exceptions are listed here.
|
*/

function pageLanguageCode(string $pageKey): string
{
    if (!preg_match('/^([a-z]{2})(?:-[a-z]{2})?:/i', trim($pageKey), $m)) {
        return '';
    }

    return strtolower($m[1]);
}

function pageLanguageCountry(string $pageKey): string
{
    $lang = pageLanguageCode($pageKey);

    if ($lang === '') {
        return '';
    }

    return match ($lang) {
        'cs' => 'cz', // Čeština
        'da' => 'dk', // Dansk
        'el' => 'gr', // Ελληνικά
        'en' => 'us', // English
        'ja' => 'jp', // 日本語
        'ko' => 'kr', // 한국어
        'uk' => 'ua', // Українська
        'zh' => 'cn', // 中文
        default => normalizeCountryCode($lang),
    };
}


/*
|--------------------------------------------------------------------------
| Geo provider
|--------------------------------------------------------------------------
|
| Resolves visitor geo data using the configured provider.
|
| BlurLoc receives only a truncated IPv4 prefix, never the full IP.
| Cloudflare is supported for convenience, but is not recommended for
| privacy-first deployments.
|
*/

function geoLookup(string $ip): array {
    return match (brivacia_setting('privacy.country_provider', 'none')) {
        'blurloc'    => geoLookupBlurLoc($ip),
        'cloudflare' => geoLookupCloudflare(),
        default      => geoDisabled(),
    };
}

function geoDisabled(): array {
    return [
        'provider' => 'none',
        'country' => BRIVACIA_UNKNOWN,
        'vpn' => null,
        'prefix' => '',
    ];
}

function countryFromIp(string $ip): string {
    return (string)geoLookup($ip)['country'];
}

/*
| Cloudflare uses the CF-IPCountry header when it is available.
| This only works when the request reaches Brivacia through Cloudflare
| or through a proxy/CDN that provides a compatible header.
| It does not send the visitor IP to Cloudflare from Brivacia.
| However, if Cloudflare is used in front of the site, Cloudflare already
| receives the full visitor IP before Brivacia runs.
*/

function geoLookupCloudflare(): array {
    $country = normalizeCountryCode(
        $_SERVER['HTTP_CF_IPCOUNTRY'] ?? BRIVACIA_UNKNOWN
    );

    return [
        'provider' => 'cloudflare',
        'country' => $country,
        'vpn' => null,
        'prefix' => '',
    ];
}

/*
|--------------------------------------------------------------------------
| BlurLoc
|--------------------------------------------------------------------------
|
| Privacy-first geolocation provider.
|
| Only a truncated IPv4 prefix is sent to BlurLoc, never the full
| address. Depending on the configured prefix length, country accuracy
| may be reduced compared to traditional IP geolocation services.
|
| This trade-off is intentional: protecting visitor privacy is preferred
| over obtaining perfectly accurate geolocation data.
|
| The provider can also return VPN detection information when available.
|
*/

function geoLookupBlurLoc(string $ip): array {
    $prefix = blurLocIpPrefix($ip);

    if ($prefix === '') {
        return geoDisabled();
    }

    $url = 'https://blurloc.com/lookup/' . rawurlencode($prefix);

    $json = @file_get_contents($url, false, brivaciaHttpContext(2));

    if (!$json) {
        brivaciaLog('providers/blurloc.log', 'error=request_failed prefix=' . $prefix);
        return geoDisabled();
    }

    $data = json_decode($json, true);

    if (!is_array($data)) {
        brivaciaLog('providers/blurloc.log', 'error=invalid_json prefix=' . $prefix);
        return geoDisabled();
    }

    return [
        'provider' => 'blurloc',
        'country' => normalizeCountryCode($data['country_code'] ?? BRIVACIA_UNKNOWN),
        'vpn' => array_key_exists('is_vpn', $data) ? (bool)$data['is_vpn'] : null,
        'prefix' => $prefix,
    ];
}

function blurLocIpPrefix(string $ip): string {
    $prefix = brivaciaIpPrefix($ip);

    if ($prefix === '') {
        brivaciaLog(
            'providers/blurloc.log',
            'error=invalid_ip family=' .
            (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? 'ipv6' : 'invalid')
        );

        return '';
    }

    return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)
        ? $prefix . '.'
        : $prefix;
}

function geoVpnLabel(?bool $vpn): string {
    if ($vpn === null) {
        return t('ui.unknown');
    }

    return $vpn ? t('ui.yes') : t('ui.no');
}


/*
|--------------------------------------------------------------------------
| Referrer icons
|--------------------------------------------------------------------------
|
| Favicon files are cached locally under /static/images/referrers.
|
*/

function safeIconName(string $source): string {
    $source = strtolower(trim($source));
    $source = preg_replace('/[^a-z0-9.-]+/', '-', $source) ?? '';

    return trim($source, '-') ?: 'unknown';
}

function referrerIconUrl(string $source): string {
    if ($source === BRIVACIA_BLOCKED) {
        return '/assets/images/icons/blocked.svg';
    }

    $source = referrerCanonical($source);
    $icon = referrerIcon($source);

    if ($icon === '') {
        return '/assets/images/flags/xx.png';
    }

    return '/static/images/referrers/' . $icon;
}

function referrerIconHtml(string $source): string {
    if ($source === BRIVACIA_BLOCKED) {
        return icon('blocked');
    }

    return '<img alt="" class="referrer" src="' . h(referrerIconUrl($source)) . '">';
}

function referrerIcon(string $source): string {
    $source = referrerCanonical(trim($source));

    if ($source === BRIVACIA_UNKNOWN) {
        return '';
    }

    if ($source === mainSiteHost()) {
        return safeIconName(mainSiteHost()) . '.png';
    }

    $dir = __DIR__ . '/../static/images/referrers';

    if (!is_dir($dir)) {
        mkdir($dir, 0755, true);
    }

    $name = safeIconName($source);

    foreach (['svg', 'png', 'webp', 'jpg', 'ico'] as $ext) {
        if (is_file("$dir/$name.$ext")) {
            return "$name.$ext";
        }
    }

    // No cached icon: never fetch it live from here. This function runs
    // during page rendering (dashboard cards, the "since the start" modal,
    // etc.), so it must never block a response on outbound network I/O —
    // that's exactly what made the dashboard slow before. Fetching missing
    // icons is refreshReferrerIcons()'s job, called from the same
    // background-ish maintenance step as label resolution (see
    // maybeNormalizeStoredReferrers() in pixel.php).
    return '';
}

function refreshReferrerIcons(int $limit = 5): void {
    if (!brivacia_setting('referrers.auto_referrer_icons', true)) {
        return;
    }

    $dir = __DIR__ . '/../static/images/referrers';

    if (!is_dir($dir)) {
        mkdir($dir, 0755, true);
    }

    $done = 0;

    foreach (referrerLabels() as $canonical => $rule) {
        if ($done >= $limit) break;

        $canonical = rootHost((string)$canonical);
        if ($canonical === '' || !preg_match('/^[a-z0-9.-]+\.[a-z]{2,}$/i', $canonical)) continue;

        $name = safeIconName($canonical);
        $hasIcon = false;

        foreach (['svg', 'png', 'webp', 'jpg', 'ico'] as $ext) {
            if (is_file("$dir/$name.$ext")) {
                $hasIcon = true;
                break;
            }
        }

        if ($hasIcon) continue;

        $failFile = "$dir/$name.fail";

        if (is_file($failFile) && filemtime($failFile) > time() - 86400) continue;

        $fetchStart = microtime(true);

        if (fetchFavicon($canonical, "$dir/$name")) {
            @unlink($failFile);

            brivaciaLog(
                'referrers/favicon.log',
                'OK host=' . $canonical .
                ' fetch=' . round((microtime(true) - $fetchStart) * 1000, 1) . 'ms'
            );
        } else {
            @touch($failFile);

            brivaciaLog(
                'referrers/favicon.log',
                'FAIL host=' . $canonical .
                ' fetch=' . round((microtime(true) - $fetchStart) * 1000, 1) . 'ms'
            );
        }

        $done++;
    }
}

function fetchFavicon(string $host, string $targetBase): bool {
    $host = strtolower(trim($host));

    if ($host === '' || $host === BRIVACIA_UNKNOWN || !preg_match('/^[a-z0-9.-]+\.[a-z]{2,}$/i', $host)) {
        return false;
    }

    if (!brivaciaIsPublicHost($host)) {
        brivaciaLog('referrers/favicon.log', 'BLOCK private_or_reserved host=' . $host);
        return false;
    }

    $home = "https://$host/";
    $html = @file_get_contents($home, false, brivaciaHttpContext());
    $candidates = [];

    if ($html) {
        if (preg_match_all('~<link[^>]+>~i', $html, $links)) {
            foreach ($links[0] as $tag) {
                if (!preg_match('~rel=["\']([^"\']+)["\']~i', $tag, $rel)) continue;
                if (!str_contains(strtolower($rel[1]), 'icon')) continue;
                if (!preg_match('~href=["\']([^"\']+)["\']~i', $tag, $href)) continue;

                $hrefValue = trim(html_entity_decode($href[1], ENT_QUOTES, 'UTF-8'));
                if ($hrefValue === '') continue;

                $candidates[] = [iconScore($tag), resolveIconUrl($home, $hrefValue)];
            }
        }
    }

    $candidates[] = [75, "https://$host/apple-touch-icon.png"];
    $candidates[] = [70, "https://$host/favicon.svg"];
    $candidates[] = [65, "https://$host/favicon.png"];
    $candidates[] = [60, "https://$host/apple-touch-icon-precomposed.png"];
    $candidates[] = [0, "https://$host/favicon.ico"];
    rsort($candidates);

    $fallback = null;

    foreach ($candidates as [, $url]) {
        if ($url === '' || !preg_match('~^https?://~i', $url)) continue;

        $prepared = brivaciaPrepareIconData($url);
        if ($prepared === null) continue;

        // SVGs are vector and essentially always meant to be transparent
        // wherever they don't draw anything, so they're accepted as-is
        // without a pixel-level check. Raster formats are checked for a
        // real alpha channel — apple-touch-icon in particular is very
        // often a fully opaque PNG/JPG with a plain background, since
        // Apple applies its own rounded mask at display time rather than
        // expecting the icon itself to carry transparency.
        $isTransparent = $prepared['ext'] === 'svg'
            || brivaciaIconHasTransparency($prepared['data']);

        if ($isTransparent) {
            file_put_contents($targetBase . '.' . $prepared['ext'], $prepared['data'], LOCK_EX);
            return true;
        }

        // Keep the first opaque result as a last resort — still better
        // than falling back to the generic unknown placeholder — but
        // keep looking in case a later, genuinely transparent candidate
        // turns up.
        $fallback ??= $prepared;
    }

    if ($fallback !== null) {
        file_put_contents($targetBase . '.' . $fallback['ext'], $fallback['data'], LOCK_EX);
        return true;
    }

    return false;
}

// Fetches and prepares one icon candidate's bytes (download, extract the
// best frame if it's an .ico, resize if oversized) without writing
// anything to disk yet — lets fetchFavicon() evaluate several candidates
// (e.g. for transparency) before committing to one.
function brivaciaPrepareIconData(string $url): ?array {
    $host = strtolower(trim((string)parse_url($url, PHP_URL_HOST)));

    if (!is_string($host) || $host === '' || !brivaciaIsPublicHost($host)) {
        brivaciaLog('referrers/favicon.log', 'BLOCK private_or_reserved_url');
        return null;
    }

    $data = @file_get_contents($url, false, brivaciaHttpContext());

    if (!$data || strlen($data) < 50 || strlen($data) > brivacia_setting('referrers.max_icon_bytes', 102400)) {
        return null;
    }

    $ext = iconExtFromUrlOrData($url, $data);

    // GD cannot decode .ico files at all (imagecreatefromstring() always
    // fails on them), so without this step the resize below silently
    // no-ops and whatever the server returned — often just a 16x16 or
    // 32x32 legacy frame — would be kept completely unscaled. Modern
    // .ico files embed their larger frames as plain PNG data inside the
    // ICO container, so the largest frame can usually be pulled out and
    // treated as a normal PNG from here on.
    if ($ext === 'ico') {
        $frame = brivaciaExtractBestIcoFrame($data);

        if ($frame !== null) {
            $data = $frame;
            $ext = 'png';
        }
    }

    if ($ext !== 'svg') {
        $resized = resizeIcon($data);

        if ($resized !== $data) {
            $data = $resized;
            $ext = 'png';
        }
    }

    return ['data' => $data, 'ext' => $ext];
}

function downloadIcon(string $url, string $targetBase): bool {
    $prepared = brivaciaPrepareIconData($url);

    if ($prepared === null) {
        return false;
    }

    file_put_contents($targetBase . '.' . $prepared['ext'], $prepared['data'], LOCK_EX);
    return true;
}

// Samples a grid of pixels looking for any real alpha transparency.
// Returns false (treated as "not confirmed transparent", so callers fall
// back rather than trust it blindly) when GD can't decode the image at all.
function brivaciaIconHasTransparency(string $data): bool {
    $img = @imagecreatefromstring($data);

    if (!$img) {
        return false;
    }

    $width = imagesx($img);
    $height = imagesy($img);
    $grid = 12;

    for ($i = 0; $i < $grid; $i++) {
        for ($j = 0; $j < $grid; $j++) {
            $x = (int)(($i + 0.5) / $grid * $width);
            $y = (int)(($j + 0.5) / $grid * $height);
            $rgba = imagecolorat($img, $x, $y);
            $alpha = ($rgba >> 24) & 0x7F; // GD: 0 = opaque, 127 = fully transparent

            if ($alpha > 4) {
                imagedestroy($img);
                return true;
            }
        }
    }

    imagedestroy($img);
    return false;
}


function iconScore(string $tag): int {
    $tagLower = strtolower($tag);
    $score = 0;

    // <link rel="mask-icon"> is explicitly a single-color icon by spec —
    // browsers recolor it themselves (Safari pinned tabs, etc.), it was
    // never meant to represent the brand visually on its own. It must
    // never be able to outscore a real apple-touch-icon or rel="icon",
    // so this is checked first and returns early rather than stacking
    // with the generic "svg" bonus below.
    if (str_contains($tagLower, 'mask-icon')) {
        return 5;
    }

    if (str_contains($tagLower, 'apple-touch-icon')) $score += 150;
    if (str_contains($tagLower, 'svg')) $score += 80;
    if (str_contains($tagLower, 'png')) $score += 60;
    if (str_contains($tagLower, 'shortcut icon')) $score += 10;

    if (preg_match('~sizes=["\'](\d+)x(\d+)["\']~i', $tag, $m)) {
        $size = (int)$m[1];

        if ($size === brivacia_setting('referrers.max_icon_size', 96)) {
            $score += 10000; // best possible match
        } elseif ($size > brivacia_setting('referrers.max_icon_size', 96)) {
            $score += 5000 - min($size - brivacia_setting('referrers.max_icon_size', 96), 4000);
        } else {
            $score += 1000 + $size;
        }
    }

    return $score;
}

function resolveIconUrl(string $base, string $href): string {
    if (preg_match('~^https?://~i', $href)) return $href;

    $u = parse_url($base);
    $origin = $u['scheme'] . '://' . $u['host'];

    if (str_starts_with($href, '//')) return $u['scheme'] . ':' . $href;
    if (str_starts_with($href, '/')) return $origin . $href;

    return rtrim($origin, '/') . '/' . ltrim($href, '/');
}

// Parses an ICO file's directory to find its largest embedded frame and
// return it, but only if that frame is itself a PNG byte stream (the
// modern convention for anything above ~32x32) — GD can decode that part
// fine even though it can't parse the ICO container itself. Older-style
// frames stored as a raw BMP DIB are left alone (returns null) rather
// than risk producing corrupt output.
function brivaciaExtractBestIcoFrame(string $ico): ?string {
    if (strlen($ico) < 6) {
        return null;
    }

    $header = unpack('vreserved/vtype/vcount', substr($ico, 0, 6));

    if (!$header || $header['type'] !== 1 || $header['count'] < 1) {
        return null;
    }

    $best = null;
    $bestArea = -1;

    for ($i = 0; $i < $header['count']; $i++) {
        $offset = 6 + ($i * 16);

        if (strlen($ico) < $offset + 16) {
            break;
        }

        $entry = unpack(
            'Cwidth/Cheight/CcolorCount/Creserved/vplanes/vbpp/Vsize/Voffset',
            substr($ico, $offset, 16)
        );

        if (!$entry) {
            continue;
        }

        $width = $entry['width'] === 0 ? 256 : $entry['width'];
        $height = $entry['height'] === 0 ? 256 : $entry['height'];
        $area = $width * $height;

        if ($area > $bestArea) {
            $bestArea = $area;
            $best = $entry;
        }
    }

    if ($best === null || strlen($ico) < $best['offset'] + $best['size']) {
        return null;
    }

    $frame = substr($ico, $best['offset'], $best['size']);

    return str_starts_with($frame, "\x89PNG\r\n\x1a\n") ? $frame : null;
}

function resizeIcon(string $data): string {
    $img = @imagecreatefromstring($data);

    if (!$img) {
        return $data;
    }

    $width  = imagesx($img);
    $height = imagesy($img);

    if (
        $width <= brivacia_setting('referrers.max_icon_size', 96) &&
        $height <= brivacia_setting('referrers.max_icon_size', 96)
    ) {
        imagedestroy($img);
        return $data;
    }

    $ratio = min(
        brivacia_setting('referrers.max_icon_size', 96) / $width,
        brivacia_setting('referrers.max_icon_size', 96) / $height
    );

    $newWidth  = max(1, (int)round($width * $ratio));
    $newHeight = max(1, (int)round($height * $ratio));

    $dst = imagecreatetruecolor($newWidth, $newHeight);

    imagealphablending($dst, false);
    imagesavealpha($dst, true);

    imagecopyresampled(
        $dst,
        $img,
        0, 0, 0, 0,
        $newWidth,
        $newHeight,
        $width,
        $height
    );

    ob_start();
    imagepng($dst);
    $result = ob_get_clean();

    imagedestroy($img);
    imagedestroy($dst);

    return $result ?: $data;
}

function iconExtFromUrlOrData(string $url, string $data): string {
    $path = strtolower(parse_url($url, PHP_URL_PATH) ?? '');

    if (str_ends_with($path, '.png')) return 'png';
    if (str_ends_with($path, '.svg')) return 'svg';
    if (str_ends_with($path, '.jpg') || str_ends_with($path, '.jpeg')) return 'jpg';
    if (str_ends_with($path, '.webp')) return 'webp';
    if (str_ends_with($path, '.ico')) return 'ico';

    if (str_starts_with($data, '<svg')) return 'svg';
    if (str_starts_with($data, "\x89PNG")) return 'png';
    if (str_starts_with($data, "\xFF\xD8")) return 'jpg';
    if (str_starts_with($data, 'RIFF')) return 'webp';

    return 'ico';
}


/*
|--------------------------------------------------------------------------
| Referrer categories
|--------------------------------------------------------------------------
*/

if (!defined('BRIVACIA_CATEGORY_BLOCKED')) {
    define('BRIVACIA_CATEGORY_BLOCKED', 'blocked');
}

if (!defined('BRIVACIA_CATEGORY_REFERRER')) {
    define('BRIVACIA_CATEGORY_REFERRER', 'referrer');
}

if (!defined('BRIVACIA_CATEGORY_SEARCH')) {
    define('BRIVACIA_CATEGORY_SEARCH', 'search');
}


/*
|--------------------------------------------------------------------------
| Referrer labels management
|--------------------------------------------------------------------------
|
| referrers.json stores metadata for external referrers.
| New referrers are discovered immediately from the pixel.
|
*/

function referrersFile(): string {
    return dataDir() . '/referrers.json';
}

function referrerLabels(bool $fresh = false): array {
    static $cache = null;
    if ($fresh) {$cache = null;}
    if ($cache !== null) return $cache;

    $file = referrersFile();
    if (!is_file($file)) {
        file_put_contents($file, "{}", LOCK_EX);
        return $cache = [];
    }

    $json = json_decode((string)file_get_contents($file), true);
    return $cache = is_array($json) ? normalizeReferrerLabels($json) : [];
}

function saveReferrerLabels(array $labels): void {
    $labels = normalizeReferrerLabels($labels);
    file_put_contents(
        referrersFile(),
        json_encode($labels, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
        LOCK_EX
    );
}

/* Helpers */
function normalizeHost(string $host): string {
    $host = strtolower(trim($host));
    if (str_starts_with($host, 'www.')) $host = substr($host, 4);
    return $host;
}

function rootHost(string $host): string {
    $parts = explode('.', normalizeHost($host));
    return count($parts) <= 2 ? normalizeHost($host) : implode('.', array_slice($parts, -2));
}

function fallbackReferrerLabel(string $host): string {
    return rootHost($host);
}

function normalizeReferrerLabels(array $labels): array {
    $normalized = [];
    foreach ($labels as $key => $rule) {
        if (!is_array($rule)) continue;
        $host = normalizeHost((string)$key);
        if ($host === '' || $host === BRIVACIA_UNKNOWN) continue;

        $canonical = rootHost((string)($rule['canonical'] ?? $host));

        if (!isset($normalized[$canonical])) {
            $normalized[$canonical] = [
                'auto'      => (bool)($rule['auto'] ?? brivacia_setting('referrers.auto_referrers', true)),
                'category'  => (string)($rule['category'] ?? BRIVACIA_CATEGORY_REFERRER),
                'failures'  => (int)($rule['failures'] ?? 0),
                'label'     => (string)($rule['label'] ?? fallbackReferrerLabel($canonical)),
                'last_try'  => $rule['last_try'] ?? null,
                'resolved'  => (bool)($rule['resolved'] ?? false),
                'updated'   => (string)($rule['updated'] ?? date('Y-m-d')),
                'urls'      => [],
            ];
        }

        $urls = array_map('normalizeHost', (array)($rule['urls'] ?? []));
        $urls[] = $host;
        $urls[] = $canonical;

        foreach ($urls as $u) {
            if ($u !== '' && $u !== BRIVACIA_UNKNOWN) $normalized[$canonical]['urls'][] = $u;
        }
    }

    foreach ($normalized as $canonical => &$rule) {
        $rule['urls'] = array_values(array_unique(array_filter($rule['urls'])));
        sort($rule['urls'], SORT_NATURAL | SORT_FLAG_CASE);

        $rule = [
            'auto'      => (bool)($rule['auto'] ?? false),
            'category'  => in_array($rule['category'] ?? BRIVACIA_CATEGORY_REFERRER, [BRIVACIA_CATEGORY_REFERRER, BRIVACIA_CATEGORY_SEARCH, BRIVACIA_CATEGORY_BLOCKED], true)
                ? $rule['category'] : BRIVACIA_CATEGORY_REFERRER,
            'failures'  => (int)($rule['failures'] ?? 0),
            'label'     => (string)($rule['label'] ?? $canonical),
            'last_try'  => $rule['last_try'] ?? null,
            'resolved'  => (bool)($rule['resolved'] ?? false),
            'updated'   => (string)($rule['updated'] ?? date('Y-m-d')),
            'urls'      => $rule['urls'],
        ];
    }
    unset($rule);

    ksort($normalized, SORT_NATURAL | SORT_FLAG_CASE);
    return $normalized;
}

// Finds the canonical entry a host already belongs to — as its own
// top-level key, listed inside another entry's `urls` (manually grouped
// domains that don't share a root, e.g. regional TLDs or a rebrand onto a
// different domain), or via the root-domain fallback. Returns null if the
// host isn't known under any existing entry yet.
function findKnownReferrerCanonical(string $host, array $labels): ?string {
    if (isset($labels[$host])) {
        return $host;
    }

    foreach ($labels as $canonical => $rule) {
        foreach (array_map('normalizeHost', (array)($rule['urls'] ?? [])) as $url) {
            if ($host === $url || str_ends_with($host, '.' . $url)) {
                return $canonical;
            }
        }
    }

    $root = rootHost($host);

    return isset($labels[$root]) ? $root : null;
}

function referrerRuleForHost(string $host): array {
    $host = normalizeHost($host);
    if ($host === '' || $host === BRIVACIA_UNKNOWN) return [BRIVACIA_UNKNOWN, []];

    $labels = referrerLabels();
    $canonical = findKnownReferrerCanonical($host, $labels);

    return $canonical !== null
        ? [$canonical, $labels[$canonical]]
        : [rootHost($host), []];
}

function referrerCanonical(string $host): string {
    [$c] = referrerRuleForHost($host);
    return $c;
}

function referrerLabel(string $host): string {
    [$c, $r] = referrerRuleForHost($host);
    if ($c === BRIVACIA_UNKNOWN) return t('ui.unknown');
    $label = (string)($r['label'] ?? $c);
    return $label === BRIVACIA_UNKNOWN ? t('ui.unknown') : $label;
}

function referrerCategory(string $host): string {
    [$c, $r] = referrerRuleForHost($host);
    $cat = (string)($r['category'] ?? BRIVACIA_CATEGORY_REFERRER);
    return in_array($cat, [BRIVACIA_CATEGORY_REFERRER, BRIVACIA_CATEGORY_SEARCH, BRIVACIA_CATEGORY_BLOCKED], true) ? $cat : BRIVACIA_CATEGORY_REFERRER;
}

function isSearchReferrer(string $host): bool {
    return referrerCategory($host) === BRIVACIA_CATEGORY_SEARCH;
}

function isOwnHost(string $host): bool {
    $main = mainSiteHost();
    return $host === $main || str_ends_with($host, '.' . $main);
}

/* Discovery */
function discoverReferrer(string $host): void {
    $host = normalizeHost($host);
    if ($host === '' || $host === BRIVACIA_UNKNOWN || isOwnHost($host)) return;

    $labels = referrerLabels();

    // Check whether this host is already known — as its own entry, or
    // grouped inside another entry's `urls` (e.g. a regional TLD or a
    // rebrand domain manually merged into a canonical entry that doesn't
    // share its root). Without this, a host that was already grouped
    // elsewhere would get re-created here as its own separate entry the
    // moment it shows up in traffic again, silently splitting it back out
    // of the grouping — findKnownReferrerCanonical() is the same lookup
    // referrerRuleForHost() uses for that reason.
    $canonical = findKnownReferrerCanonical($host, $labels);
    $changed = false;

    if ($canonical === null) {
        $canonical = rootHost($host);
        if ($canonical === '') return;

        $labels[$canonical] = [
            'auto'      => brivacia_setting('referrers.auto_referrers', true),
            // A host that's already on the blocklist at discovery time is
            // created as 'blocked' straight away instead of 'referrer' —
            // saves a manual edit, and stops it from being picked up by
            // the automatic label-fetch pass (refreshReferrerLabels())
            // for a domain that's never going to be shown anyway.
            'category'  => referrerBlocked($host) ? BRIVACIA_CATEGORY_BLOCKED : BRIVACIA_CATEGORY_REFERRER,
            'urls'      => [$canonical, $host],
            'failures'  => 0,
            'label'     => fallbackReferrerLabel($canonical),
            'last_try'  => null,
            'resolved'  => false,
            'updated'   => date('Y-m-d'),
        ];
        $changed = true;
    } else if (!in_array($host, array_map('normalizeHost', (array)($labels[$canonical]['urls'] ?? [])), true)) {
        $labels[$canonical]['urls'][] = $host;
        $changed = true;
    }

    if ($changed) saveReferrerLabels($labels);
}

function refreshReferrerLabels(int $limit = 5): void {
    $lockFile = dataDir() . '/referrers-refresh.lock';
    $lockHandle = fopen($lockFile, 'c');
    if (!$lockHandle || !flock($lockHandle, LOCK_EX | LOCK_NB)) {
        fclose($lockHandle ?? null);
        return;
    }

    $labels = referrerLabels(true);
    $done = 0;

    foreach ($labels as $canonical => $rule) {
        if ($done >= $limit) break;
        if (!($rule['auto'] ?? false) || ($rule['resolved'] ?? false)) continue;

        $canonical = rootHost((string)$canonical);
        if ($canonical === '' || !preg_match('/^[a-z0-9.-]+\.[a-z]{2,}$/i', $canonical)) continue;

        $failures = (int)($rule['failures'] ?? 0);
        $cooldown = match(true) {
            $failures >= 5 => 7*86400,
            $failures >= 3 => 86400,
            $failures >= 1 => 3600,
            default => 0
        };

        $lastTry = $rule['last_try'] ?? null;
        if ($lastTry !== null && strtotime((string)$lastTry) > time() - $cooldown) {
            brivaciaLog(
                'referrers/label.log',
                'SKIP cooldown host=' . $canonical .
                ' failures=' . $failures .
                ' last_try=' . $lastTry .
                ' cooldown_until=' . date('c', strtotime((string)$lastTry) + $cooldown)
            );
            continue;
        }

        // fetchSiteLabel() does real network I/O (up to a few seconds per
        // host, with a timeout). Save right after this single attempt
        // instead of waiting until the whole batch of $limit referrers is
        // done: if the request gets killed by a PHP/webserver execution
        // time limit partway through, whatever was already resolved here
        // — and the last_try/failures bookkeeping that drives the retry
        // cooldown — is not lost. Without this, a batch that never
        // finishes in time never saves anything at all, so the same
        // unresolved referrers get retried (and time out) forever.
        $fetchStart = microtime(true);
        $label = fetchSiteLabel($canonical);
        $fetchMs = round((microtime(true) - $fetchStart) * 1000, 1);

        $labels[$canonical]['last_try'] = date('c');

        if ($label === '') {
            $labels[$canonical]['failures'] = $failures + 1;

            brivaciaLog(
                'referrers/label.log',
                'FAIL host=' . $canonical .
                ' fetch=' . $fetchMs . 'ms' .
                ' failures=' . ($failures + 1)
            );
        } else {
            $labels[$canonical]['label'] = $label;
            $labels[$canonical]['resolved'] = true;
            $labels[$canonical]['failures'] = 0;
            $labels[$canonical]['updated'] = date('Y-m-d');

            brivaciaLog(
                'referrers/label.log',
                'OK host=' . $canonical .
                ' label=' . $label .
                ' fetch=' . $fetchMs . 'ms'
            );
        }

        saveReferrerLabels($labels);
        $done++;
    }

    flock($lockHandle, LOCK_UN);
    fclose($lockHandle);
}

/* Label discovery */
function looksLikeBotChallengePage(string $html): bool {
    if (!preg_match('~<title[^>]*>(.*?)</title>~is', $html, $m)) {
        return false;
    }

    $title = trim(html_entity_decode(strip_tags($m[1]), ENT_QUOTES, 'UTF-8'));

    // Real site titles don't start with these — they're specific enough
    // to Cloudflare/generic bot-challenge interstitials that checking
    // just the title (instead of scanning the whole body for script
    // markers like cdn-cgi/challenge-platform, which plenty of normal,
    // unblocked pages also load) avoids false-positiving on real sites.
    return (bool)preg_match(
        '~^(just a moment|attention required|checking your browser|please wait|access denied|one moment|verifying you are human)~i',
        $title
    );
}

function fetchSiteLabel(string $host): string {
    $hostsToTry = [$host];
    if (!str_starts_with($host, 'www.')) $hostsToTry[] = 'www.' . $host;

    foreach ($hostsToTry as $tryHost) {
        $url = 'https://' . $tryHost . '/';
        $html = @file_get_contents($url, false, brivaciaHttpContext(3));
        $status = $http_response_header[0] ?? 'no_status_line';

        if (!$html) {
            brivaciaLog(
                'referrers/label.log',
                'DETAIL host=' . $host . ' try=' . $tryHost . ' result=no_response status="' . $status . '"'
            );
            continue;
        }

        brivaciaLog(
            'referrers/label.log',
            'DETAIL host=' . $host . ' try=' . $tryHost . ' result=response status="' . $status . '" bytes=' . strlen($html)
        );

        // Sites behind bot-protection (Cloudflare and similar) serve an
        // interstitial "checking your browser" page instead of the real
        // homepage to a plain fetch like this one. That page still has a
        // <title> (often literally "Just a moment..."), which would
        // otherwise get saved as if it were the site's actual name. Treat
        // it as a failed attempt instead of a resolved label.
        if (looksLikeBotChallengePage($html)) {
            brivaciaLog(
                'referrers/label.log',
                'DETAIL host=' . $host . ' try=' . $tryHost . ' result=bot_challenge_page'
            );
            continue;
        }

        if (preg_match('~<link[^>]+rel=["\']manifest["\'][^>]*href=["\']([^"\']+)["\']~i', $html, $m)) {
            $manifestUrl = resolveIconUrl($url, $m[1]);
            $label = fetchManifestLabelFromUrl($host, $manifestUrl);
            if ($label !== '') return $label;
        }
        if (preg_match('~<meta[^>]+(?:property|name)=["\'](?:og:site_name|application-name)["\'][^>]*content=["\']([^"\']+)["\']~i', $html, $m)) {
            $label = cleanFetchedSiteLabel($m[1], $host);
            if ($label !== '') return $label;
        }
        if (preg_match('~<title[^>]*>(.*?)</title>~is', $html, $m)) {
            $label = cleanFetchedSiteLabel($m[1], $host);
            if ($label !== '') return $label;
        }

        brivaciaLog(
            'referrers/label.log',
            'DETAIL host=' . $host . ' try=' . $tryHost . ' result=no_usable_manifest_meta_or_title'
        );
    }
    return '';
}

function fetchManifestLabelFromUrl(string $host, string $manifestUrl): string {
    $json = @file_get_contents($manifestUrl, false, brivaciaHttpContext(3));
    if (!$json) return '';

    $data = json_decode($json, true);
    if (!is_array($data)) return '';

    $name = trim((string)($data['short_name'] ?? $data['name'] ?? ''));
    return $name !== '' ? cleanFetchedSiteLabel($name, $host) : '';
}

function cleanFetchedSiteLabel(string $label, string $host): string {
    $label = html_entity_decode(strip_tags(trim($label)), ENT_QUOTES, 'UTF-8');
    $label = preg_replace('~\s+~u', ' ', $label) ?? $label;
    $label = trim($label);

    // Titles are frequently "Brand | Tagline", "Brand - Tagline", or
    // "Brand.com. Tagline. More tagline." — keep only the brand part
    // instead of storing the whole marketing tagline as the referrer's
    // display name.
    $parts = preg_split('~\s+(?:\||—|–|::|»|:)\s+|\s+-\s+~u', $label);

    if ($parts !== false && count($parts) > 1 && trim($parts[0]) !== '') {
        $label = trim($parts[0]);
    } else {
        // No pipe/dash-style separator found — try a sentence-style split
        // (e.g. "Amazon.com. Spend less. Smile more.") and keep the first
        // clause if that leaves something non-empty.
        $sentenceParts = preg_split('~\.\s+~u', $label);

        if ($sentenceParts !== false && count($sentenceParts) > 1 && trim($sentenceParts[0]) !== '') {
            $label = trim($sentenceParts[0]);
        }
    }

    // "Amazon.com", "Booking.com"-style titles just repeat the domain with
    // its TLD; keep only the brand part before it.
    if (preg_match('~^([\p{L}\p{N} ]+)\.(?:com|net|org|io|co|app|dev|shop|store)$~ui', $label, $tldMatch)) {
        $label = trim($tldMatch[1]);
    }

    return mb_substr(trim($label), 0, 80, 'UTF-8');
}

/*
|--------------------------------------------------------------------------
| Pixel helpers
|--------------------------------------------------------------------------
*/

function refSourceFast(string $ref): string {
    $host = normalizeHost(parse_url($ref, PHP_URL_HOST) ?? '');
    if ($host === '') return BRIVACIA_UNKNOWN;
    if (isOwnHost($host)) return mainSiteHost();
    return substr($host, 0, 120);
}

function canonicalReferrerSource(string $source): string {
    $source = trim($source);
    $lower = mb_strtolower($source, 'UTF-8');

    if ($source === '' || $lower === BRIVACIA_UNKNOWN || $lower === 'inconnu') return BRIVACIA_UNKNOWN;
    if (in_array($lower, ['newtab', 'new tab', 'about:newtab']) || str_contains($lower, 'android-app://com.android.chrome')) return mainSiteHost();
    if (str_contains($lower, 'googlequicksearchbox')) return 'google.com';

    $host = normalizeHost((string)(parse_url($source, PHP_URL_HOST) ?? ''));
    if ($host !== '') return isOwnHost($host) ? mainSiteHost() : referrerCanonical($host);

    $host = normalizeHost($source);
    return isOwnHost($host) ? mainSiteHost() : referrerCanonical($host);
}

function maybeNormalizeStoredReferrers(PDO $db): void {
    // The database is intentionally unused.
    unset($db);

    cleanupBlockedReferrerIcons();
    refreshReferrerIcons(6);
    refreshReferrerLabels(6);
}


/*
|--------------------------------------------------------------------------
| Referrer blocklist
|--------------------------------------------------------------------------
|
| User-editable file:
| /data/referrers_blocklist.json
|
| Blocked referrers are NOT deleted and are NOT ignored at write time.
| They stay in the database so historical totals remain correct.
| Dashboard code groups them under the virtual BRIVACIA_BLOCKED row.
|
*/

function referrersBlocklistFile(): string {
    return dataDir() . '/referrers_blocklist.json';
}

function referrersBlocklist(): array {
    static $cache = null;

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

    $file = referrersBlocklistFile();

    $json = json_decode((string)file_get_contents($file), true);

    if (!is_array($json)) {
        $json = [];
    }

    return $cache = [
        'hosts' => array_values(array_filter(array_map('normalizeHost', (array)($json['hosts'] ?? [])))),
        'contains' => array_values(array_filter(array_map('strval', (array)($json['contains'] ?? [])))),
    ];
}

function referrerBlocked(string $host): bool {
    $host = normalizeHost($host);

    if ($host === '' || $host === BRIVACIA_UNKNOWN) {
        return false;
    }

    $canonical = rootHost($host);
    $blocklist = referrersBlocklist();

    foreach ($blocklist['hosts'] as $blocked) {
        $blocked = rootHost($blocked);

        if ($host === $blocked || $canonical === $blocked || str_ends_with($host, '.' . $blocked)) {
            return true;
        }
    }

    foreach ($blocklist['contains'] as $needle) {
        $needle = strtolower(trim($needle));

        if ($needle !== '' && str_contains($host, $needle)) {
            return true;
        }
    }

    return false;
}

/*
|--------------------------------------------------------------------------
| Cleanup blocked referrer icons
|--------------------------------------------------------------------------
|
| Blocked referrers should never keep downloaded favicons or failed lookup
| markers. Remove them so the cache always matches the current blocklist.
|
*/

function cleanupBlockedReferrerIcons(): void {
    $dir = __DIR__ . '/../static/images/referrers';

    if (!is_dir($dir)) {
        brivaciaLog('referrers/cleanup.log', 'SKIP missing_dir=' . $dir);
        return;
    }

    $files = glob($dir . '/*') ?: [];
    $deleted = 0;

    foreach ($files as $file) {
        if (!is_file($file)) {
            continue;
        }

        $base = basename($file);

        if (!preg_match('~^(.+)\.(svg|png|webp|jpg|jpeg|ico|fail)$~i', $base, $m)) {
            continue;
        }

        $host = (string)$m[1];

        if (!referrerBlocked($host)) {
            continue;
        }

        if (@unlink($file)) {
            $deleted++;
            brivaciaLog('referrers/cleanup.log', 'DELETE file=' . $base . ' host=' . $host);
        } else {
            brivaciaLog('referrers/cleanup.log', 'FAIL_DELETE file=' . $base . ' host=' . $host);
        }
    }

    brivaciaLog('referrers/cleanup.log', 'DONE files=' . count($files) . ' deleted=' . $deleted);
}


/*
|--------------------------------------------------------------------------
| Referrer statistics and page URLs
|--------------------------------------------------------------------------
|
| Handles referrer counters and page URL reconstruction.
|
| Page URLs can be generated from stored URLs, page identifiers or
| optional page mapping files.
|
*/

function incReferrer(PDO $db, string $day, string $source, string $site = ''): void {
    $site = $site !== '' ? $site : array_key_first(brivacia_sites());
    $source = canonicalReferrerSource($source);
    $source = substr($source, 0, 120);

    $db->prepare(
        'INSERT OR IGNORE INTO referrers_daily(site, day, referrer, views) VALUES(?, ?, ?, 0)'
    )->execute([$site, $day, $source]);

    $db->prepare(
        'UPDATE referrers_daily SET views = views + 1 WHERE site = ? AND day = ? AND referrer = ?'
    )->execute([$site, $day, $source]);
}

function pageUrl(string $site, string $pageKey, string $url = ''): string {
    $default = brivaciaPageUrl($site, $pageKey, $url);

    $result = brivaciaRunCustomRules($site, [
        'pageKey' => $pageKey,
        'url' => $url,
        'pageUrl' => $default,
    ]);

    return (string)($result['pageUrl'] ?? $default);
}


/*
|--------------------------------------------------------------------------
| Generic SQL helper
|--------------------------------------------------------------------------
*/

function fetchAll(PDO $db, string $sql, array $params = []): array {
    $stmt = $db->prepare($sql);
    $stmt->execute($params);
    return $stmt->fetchAll(PDO::FETCH_ASSOC);
}


/*
|--------------------------------------------------------------------------
| Output helpers
|--------------------------------------------------------------------------
|
| Small helpers used by templates.
|
*/

function h(string|int|float|bool|null $value): string {
    return htmlspecialchars(
        (string)$value,
        ENT_QUOTES,
        'UTF-8'
    );
}


/*
|--------------------------------------------------------------------------
| Internationalization
|--------------------------------------------------------------------------
|
| English is always loaded as the base language.
|
| When a matching translation exists in /assets/i18n, it is merged over
| English using the visitor's Accept-Language header.
|
| Missing translations automatically fall back to English.
|
| Translations support optional placeholders:
|
| t('privacy.title', [
|     'instance' => brivacia_setting('dashboard.instance_name', 'Brivacia'),
| ]);
|
| Translation:
|
| "What does {site} know about me?"
|
*/

$GLOBALS['brivacia_lang'] = [];

function currentLang(): string {
    $lang = strtolower(substr(
        $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? 'en',
        0,
        2
    ));

    return $lang !== '' ? $lang : 'en';
}

function currentDir(): string {
    return in_array(currentLang(), ['ar', 'fa', 'he', 'ur'], true)
        ? 'rtl'
        : 'ltr';
}

function loadTranslations(): void {
    $lang = currentLang();

    $enFile = __DIR__ . '/../assets/i18n/en.json';
    $langFile = __DIR__ . '/../assets/i18n/' . $lang . '.json';

    $translations = [];

    if (is_file($enFile)) {
        $translations = json_decode(
            (string) file_get_contents($enFile),
            true
        ) ?: [];
    }

    if (
        $lang !== 'en' &&
        is_file($langFile)
    ) {
        $translations = array_replace(
            $translations,
            json_decode(
                (string) file_get_contents($langFile),
                true
            ) ?: []
        );
    }

    $GLOBALS['brivacia_lang'] = $translations;
}

function t(string $key, array $replace = []): string {
    $text = (string)(
        $GLOBALS['brivacia_lang'][$key]
        ?? '[[' . $key . ']]'
    );

    foreach ($replace as $name => $value) {
        $text = str_replace(
            '{' . $name . '}',
            (string)$value,
            $text
        );
    }

    return $text;
}


/*
|--------------------------------------------------------------------------
| Icons
|--------------------------------------------------------------------------
*/

function icon(string $name): string {
    static $icons = [];

    if (!isset($icons[$name])) {
        $icons[$name] = file_get_contents(
            __DIR__ . '/../assets/images/icons/' . $name . '.svg'
        );
    }

    return $icons[$name];
}


/*
|--------------------------------------------------------------------------
| External links
|--------------------------------------------------------------------------
|
| Generates a link that opens in a new tab.
|
| The external icon is optional and can be disabled per call.
|
*/

function externalLink( string $url, string $label, bool $showIcon = true, ?string $tooltip = null, string $tooltipPlacement = 'bottom'): string {
    return '<a aria-label="' . h($label . ' - ' . t('ui.external')) . '"'
        . ($tooltip !== null ? ' data-tooltip="' . h($tooltip) . '"' : '')
        . ($tooltip !== null ? ' data-tooltip-placement="' . h($tooltipPlacement) . '"' : '')
        . ' href="' . h($url) . '"'
        . ' rel="noreferrer noopener"'
        . ' target="_blank"'
        . '>'
        . h($label)
        . ($showIcon ? icon('external') : '')
        . '</a>';
}


/*
|--------------------------------------------------------------------------
| Dashboard Branding
|--------------------------------------------------------------------------
|
| Users may override the default dashboard branding by placing assets in
| /static/images. Files are selected automatically according to the
| current site code.
|
| Examples:
| - /static/images/favicon-sitecode.EXT
| - /static/images/logo-sitecode.EXT
|
| If no matching asset is found, the built-in Brivacia branding is used.
|
*/

function brivaciaFaviconPlaceholder(): string
{
    return !empty(brivacia_setting('dashboard.light_theme'))
        ? '/assets/images/logo-light.png'
        : '/assets/images/logo-dark.png';
}

function brivaciaLogoPlaceholder(): string
{
    return !empty(brivacia_setting('dashboard.light_theme'))
        ? '/assets/images/logo-light.png'
        : '/assets/images/logo-dark.png';
}

$theme = !empty(brivacia_setting('dashboard.light_theme')) ? 'light' : 'dark';
$currentSite = trim((string)($_GET['site'] ?? ''));
if ($currentSite !== '' && !isset(brivacia_sites()[$currentSite])) {
    $currentSite = '';
}
$siteTitle = $currentSite !== ''
    ? $currentSite
    : brivacia_setting('dashboard.instance_name', 'Brivacia');

function assetOrPlaceholder(string $prefix, string $fallback, string $siteCode = ''): string {
    $base = dirname(__DIR__);

    foreach (['png', 'jpg', 'jpeg', 'webp', 'svg', 'gif', 'ico'] as $ext) {
        if ($siteCode !== '') {
            $siteUrl = "/static/images/{$prefix}-{$siteCode}.{$ext}";

            if (is_file($base . $siteUrl)) {
                return $siteUrl;
            }
        }

        $globalUrl = "/static/images/{$prefix}.{$ext}";

        if (is_file($base . $globalUrl)) {
            return $globalUrl;
        }
    }

    return $fallback;
}


/*
|--------------------------------------------------------------------------
| Branding footer
|--------------------------------------------------------------------------
|
| Displays the Brivacia attribution footer.
|
*/

function brivaciaBreatfrLogoIcon(): string { return '/assets/images/icons/breat.fr.png'; }

Contribute