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();
}


/*
|--------------------------------------------------------------------------
| 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 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') . ' ' . $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 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,
            PRIMARY KEY (site, day, visitor_hash)
        );
    ");

    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, '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 ''");

    $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(): bool {
    $file = archiveDir() . '/check-year.txt';
    $year = date('Y');

    return !is_file($file) || trim((string)file_get_contents($file)) !== $year;
}

function brivaciaMarkYearArchiveChecked(): void {
    file_put_contents(archiveDir() . '/check-year.txt', date('Y'), LOCK_EX);
}


/*
|--------------------------------------------------------------------------
| 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;
}

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


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

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 ipPrefix(string $ip): string {
    if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
        $parts = explode('.', $ip);

        return ($parts[0] ?? '') . '.' . ($parts[1] ?? '');
    }

    return substr($ip, 0, 12);
}

function visitorHash(string $day, string $ua, string $ip): string {
    return hash(
        'sha256',
        $day . '|' .
        ipPrefix($ip) . '|' .
        $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]);
}

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;

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

    return $isNewSession;
}

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 {
    $isFallback = brivaciaShouldLogPageFallback($key, $title, $url);

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

    $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();
}


/*
|--------------------------------------------------------------------------
| Internal HTTP client
|--------------------------------------------------------------------------
*/

function brivaciaHttpContext(int $timeout = 1) {
    return stream_context_create([
        'http' => [
            'timeout' => $timeout,
            'user_agent' => 'Brivacia privacy-focused analytics favicon fetcher',
        ],
    ]);
}


/*
|--------------------------------------------------------------------------
| 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=' . ($url !== '' ? $url : '[empty]') .
            ' failures=' . $failures
        );

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

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

        $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=' . $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=' . ($url !== '' ? $url : '[empty]') .
            ' 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=' . $resolved['url']
        );

        $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');
    }

    if (class_exists('Locale')) {
        return \Locale::getDisplayRegion('-' . $code, currentLang()) ?: $code;
    }

    return $code;
}

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

    foreach (['svg', 'png', 'webp', 'jpg', 'jpeg', 'ico'] as $ext) {
        $file = '/assets/images/flags/' . $country . '.' . $ext;

        if (is_file(dirname(__DIR__) . $file)) {
            return $file;
        }
    }

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

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


/*
|--------------------------------------------------------------------------
| 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' => '',
        'country' => 'XX',
        '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((string)($data['country_code'] ?? BRIVACIA_UNKNOWN)),
        'vpn' => array_key_exists('is_vpn', $data) ? (bool)$data['is_vpn'] : null,
        'prefix' => $prefix,
    ];
}

function blurLocIpPrefix(string $ip): string {
    if ($ip === '') {
        brivaciaLog('providers/blurloc.log', 'error=empty_ip');
        return '';
    }

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

        return '';
    }

    $parts = explode('.', $ip);

    $octets = max(
        1,
        min(3, brivacia_setting('privacy.ip_prefix_octets', 2))
    );

    if (count($parts) < $octets) {
        brivaciaLog('providers/blurloc.log', 'error=too_few_octets');
        return '';
    }

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

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 '';
    }

    $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 {
    $totalStart = microtime(true);

    $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")) {
            brivaciaLog(
                'referrers/favicon.log',
                'CACHE host=' . $source .
                ' time=' . round((microtime(true) - $totalStart) * 1000, 1) . 'ms'
            );

            return "$name.$ext";
        }
    }

    if (!brivacia_setting('referrers.auto_referrer_icons', true)) {
        return '';
    }

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

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

    if (is_file($failFile) && filemtime($failFile) > time() - 86400) {
        brivaciaLog(
            'referrers/favicon.log',
            'SKIP cooldown host=' . $source .
            ' time=' . round((microtime(true) - $totalStart) * 1000, 1) . 'ms'
        );

        return '';
    }

    $fetchStart = microtime(true);

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

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

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

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

    return '';
}

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;
    }

    $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[] = [70, "https://$host/favicon.svg"];
    $candidates[] = [65, "https://$host/favicon.png"];
    $candidates[] = [60, "https://$host/apple-touch-icon.png"];
    $candidates[] = [55, "https://$host/apple-touch-icon-precomposed.png"];
    $candidates[] = [0, "https://$host/favicon.ico"];
    rsort($candidates);

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

    return false;
}

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

    if (str_contains($tagLower, 'apple-touch-icon')) $score += 100;
    if (str_contains($tagLower, 'mask-icon')) $score += 20;
    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, '/');
}

function downloadIcon(string $url, string $targetBase): bool {
    $data = @file_get_contents($url, false, brivaciaHttpContext());

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

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

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

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

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

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;
}

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

    $labels = referrerLabels();

    if (isset($labels[$host])) return [$host, $labels[$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, $rule];
            }
        }
    }

    $canonical = rootHost($host);
    if (isset($labels[$canonical])) return [$canonical, $labels[$canonical]];

    return [$canonical, []];
}

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;

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

    $labels = referrerLabels();
    $changed = false;

    if (!isset($labels[$canonical])) {
        $labels[$canonical] = [
            'auto'      => brivacia_setting('referrers.auto_referrers', true),
            'category'  => 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);
    $changed = false;
    $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) continue;

        $label = fetchSiteLabel($canonical);
        $labels[$canonical]['last_try'] = date('c');
        $changed = true;

        if ($label === '') {
            $labels[$canonical]['failures'] = $failures + 1;
        } else {
            $labels[$canonical]['label'] = $label;
            $labels[$canonical]['resolved'] = true;
            $labels[$canonical]['failures'] = 0;
            $labels[$canonical]['updated'] = date('Y-m-d');
        }
        $done++;
    }

    if ($changed) saveReferrerLabels($labels);

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

/* Label discovery */
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));
        if ($html) {
            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;
            }
        }
    }
    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;
    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();
    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 '/assets/images/favicon.ico'; }
function brivaciaLogoPlaceholder(): string { return '/assets/images/logo.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 {
    foreach (['png', 'jpg', 'jpeg', 'webp', 'svg', 'gif', 'ico'] as $ext) {
        $url = "/static/images/{$prefix}-{$siteCode}.{$ext}";

        if ($siteCode !== '' && is_file(dirname(__DIR__) . $url)) {
            return $url;
        }
    }

    return $fallback;
}


/*
|--------------------------------------------------------------------------
| Branding footer
|--------------------------------------------------------------------------
|
| Displays the Brivacia attribution footer.
|
| The free version keeps this footer visible.
| Supporter builds may choose to hide it.
|
*/

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