<?php
/*
 * package examples/crash_reporter/backend/src/bootstrap.php
 * bootstrap.php
 * Created at: Wed 20 May 2026 14:31:55 +0200
 * Updated at: Wed 20 May 2026 15:17:13 +0200 by Anton Afanasyeu <a.afanasieff@gmail.com>
 * Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8
 * Contributors:
 *    - Anton Afanasyeu <a.afanasieff@gmail.com> (2 commits, 45 lines)
 *    - Cursor Agent (project assistant)
 * Digest: SHA256	13f47a5da844f6ab0e4de9a934ec4e3a3892401c5b8b43123aa1539131b1ab48
 */
declare(strict_types=1);

require_once dirname(__DIR__, 3) . '/platform/shared_session.php';
require_once dirname(__DIR__, 3) . '/platform/footer.php';

$configPath = __DIR__ . '/../config/config.php';
if (!is_file($configPath)) {
    $configPath = __DIR__ . '/../config/config.example.php';
}
$config = require $configPath;

$sessionName = $config['session_name'] ?? 'ac_crash_sess';
$sessionPath = $config['session_cookie_path'] ?? '/app/androidcast_project';

if (!function_exists('session_start')) {
    if (PHP_SAPI !== 'cli') {
        http_response_code(500);
        header('Content-Type: text/plain; charset=utf-8');
        $phpV = PHP_VERSION;
        echo "PHP session extension is not loaded (SAPI=" . PHP_SAPI . ", PHP {$phpV}).\n"
            . "CLI check:  php -m | grep -i session\n"
            . "            php -v   # match package major (php81-* vs php82-*)\n"
            . "Alpine: apk add php81-session php81-pdo  # or php82-session for PHP 8.2\n"
            . "        rc-service php-fpm81 restart\n"
            . "Debian: apt install php-session\n";
        exit(1);
    }
    // CLI cron (purge_remote_access.php, verify): PDO only; no web login cookie.
} elseif (PHP_SAPI !== 'cli') {
    platform_start_session($sessionName, $sessionPath);
}

require_once __DIR__ . '/Database.php';
require_once __DIR__ . '/Rbac.php';
require_once __DIR__ . '/DeviceRepository.php';
require_once __DIR__ . '/Auth.php';
require_once __DIR__ . '/AuthEmailSchema.php';
require_once __DIR__ . '/AuthMailer.php';
require_once __DIR__ . '/AuthRegistration.php';
require_once __DIR__ . '/AuthCrypto.php';
require_once __DIR__ . '/AuthTotp.php';
require_once __DIR__ . '/AuthFactors.php';
require_once __DIR__ . '/AuthAttempts.php';
require_once __DIR__ . '/ReportRepository.php';
require_once __DIR__ . '/TagCatalog.php';
require_once __DIR__ . '/TicketTagCatalog.php';
require_once __DIR__ . '/TicketWorkflow.php';
require_once __DIR__ . '/TicketAttachmentProvider.php';
require_once __DIR__ . '/TicketAttachmentRepository.php';
require_once __DIR__ . '/TicketCommentRepository.php';
require_once __DIR__ . '/UserRepository.php';
require_once __DIR__ . '/TicketRepository.php';
require_once __DIR__ . '/GraphRepository.php';
require_once __DIR__ . '/RemoteAccessRepository.php';
require_once __DIR__ . '/UrlShortenerDatabase.php';
require_once __DIR__ . '/ShortLinksRepository.php';
require_once __DIR__ . '/WireGuardPeerProvisioner.php';
require_once __DIR__ . '/WireGuardAddressPool.php';
require_once __DIR__ . '/RsshSessionProvisioner.php';
require_once __DIR__ . '/AnalyticsHead.php';

function cfg(string $key, $default = null) {
    global $config;
    $parts = explode('.', $key);
    $v = $config;
    foreach ($parts as $p) {
        if (!is_array($v) || !array_key_exists($p, $v)) {
            return $default;
        }
        $v = $v[$p];
    }
    return $v;
}

function h(mixed $s): string {
    if ($s === null || $s === false) {
        return '';
    }
    return htmlspecialchars((string) $s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

/** json_encode that never returns false (substitutes bad UTF-8). */
function safe_json_encode(mixed $data, int $flags = 0): string {
    $flags |= JSON_INVALID_UTF8_SUBSTITUTE;
    $json = json_encode($data, $flags);
    return is_string($json) ? $json : 'null';
}

function json_out(array $data, int $code = 200): void {
    http_response_code($code);
    header('Content-Type: application/json; charset=utf-8');
    echo json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
    exit;
}

/** Public URL prefix for the active console view (graphs vs crashes). */
function console_base_path(?string $view = null): string {
    $view = $view ?? (string) ($_GET['view'] ?? '');
    if ($view === 'graphs') {
        $graphs = trim((string) cfg('graphs_base_path', ''));
        if ($graphs !== '') {
            return rtrim($graphs, '/');
        }
        $crashes = rtrim((string) cfg('base_path', ''), '/');
        if (str_ends_with($crashes, '/crashes')) {
            return substr($crashes, 0, -strlen('/crashes')) . '/graphs';
        }
        return '/app/androidcast_project/graphs';
    }
    return rtrim((string) cfg('base_path', ''), '/');
}

/**
 * Raw POST body for crash ingest. Supports plain JSON or Content-Encoding: gzip
 * (when nginx gunzip is off or the client talks to PHP directly).
 *
 * @return string|null decoded JSON text, null if empty, false if gzip decode failed
 */
function read_crash_upload_body(): string|false|null {
    $raw = file_get_contents('php://input');
    if ($raw === false || $raw === '') {
        return null;
    }
    $encoding = $_SERVER['HTTP_CONTENT_ENCODING'] ?? '';
    if ($encoding !== '' && stripos($encoding, 'gzip') !== false) {
        $decoded = @gzdecode($raw);
        return $decoded === false ? false : $decoded;
    }
    return $raw;
}

/** Short lines for expandable list preview (not full report detail). */
function report_brief_lines(array $row, bool $grouped): array {
    if ($grouped) {
        return array_filter([
            sprintf('%d reports · %s', (int) ($row['cnt'] ?? 0), $row['crash_type'] ?? ''),
            'Fingerprint: ' . ($row['fingerprint'] ?? ''),
            trim(sprintf(
                '%s · app %s',
                $row['device_model'] ?? '',
                $row['app_version'] ?? ''
            ), ' ·'),
        ]);
    }
    $p = json_decode($row['payload_json'] ?? '', true);
    if (!is_array($p)) {
        $p = [];
    }
    $lines = [
        sprintf(
            '%s · %s · %s',
            $row['crash_type'] ?? ($p['crash_type'] ?? ''),
            $p['scenario'] ?? '—',
            substr($row['fingerprint'] ?? ($p['fingerprint'] ?? ''), 0, 16)
        ),
        trim(sprintf(
            '%s %s · %s v%s',
            $p['device']['manufacturer'] ?? '',
            $row['device_model'] ?? ($p['device']['model'] ?? ''),
            $p['app']['package'] ?? 'com.foxx.androidcast',
            $row['app_version'] ?? ($p['app']['version_name'] ?? '')
        )),
    ];
    if (!empty($p['java'])) {
        $j = $p['java'];
        $exc = trim(($j['exception'] ?? '') . (!empty($j['message']) ? ': ' . $j['message'] : ''));
        $lines[] = $exc !== '' ? $exc : 'Java crash';
        $frames = $j['stack_frames'] ?? [];
        if (!empty($frames[0])) {
            $lines[] = (string) $frames[0];
        }
    } elseif (!empty($p['native'])) {
        $lines[] = 'Native: ' . ($p['native']['signal'] ?? 'crash');
        $bt = $p['native']['backtrace'] ?? [];
        if (!empty($bt[0])) {
            $lines[] = (string) $bt[0];
        }
    }
    if (!empty($p['runtime_context']['last_activity'])) {
        $lines[] = 'Activity: ' . $p['runtime_context']['last_activity'];
    }
    return array_values(array_filter($lines, static fn ($l) => trim($l) !== '' && trim($l) !== '·'));
}

/** @return array{label:string,bg:string} */
function crash_kind_tag(string $crashType): array {
    $t = strtolower($crashType);
    return match ($t) {
        'native' => ['label' => 'NDK', 'bg' => '#b45309'],
        'java' => ['label' => 'Java', 'bg' => '#6d28d9'],
        'android' => ['label' => 'Android', 'bg' => '#047857'],
        default => ['label' => ucfirst($t ?: '?'), 'bg' => '#5c6b82'],
    };
}

/** Human-readable ABI list from device payload. */
function format_device_abis(mixed $abis): string {
    if (!is_array($abis)) {
        return is_scalar($abis) ? trim((string) $abis) : '';
    }
    $parts = [];
    foreach ($abis as $abi) {
        if (is_scalar($abi)) {
            $s = trim((string) $abi);
            if ($s !== '') {
                $parts[] = $s;
            }
        }
    }
    return implode(', ', $parts);
}

function device_display_name(array $device): string {
    return trim(((string) ($device['manufacturer'] ?? '')) . ' ' . ((string) ($device['model'] ?? '')));
}

/** Shorter family name for “similar device” (drops last token when 3+ words). */
function device_model_family(string $manufacturer, string $model): string {
    $full = trim($manufacturer . ' ' . $model);
    if ($full === '') {
        return '';
    }
    $parts = preg_split('/\s+/', $full) ?: [];
    if (count($parts) <= 2) {
        return $full;
    }
    return implode(' ', array_slice($parts, 0, -1));
}

/** Keys used to rank similar crashes (from payload). */
function report_similarity_keys(array $payload): array {
    $java = is_array($payload['java'] ?? null) ? $payload['java'] : [];
    $native = is_array($payload['native'] ?? null) ? $payload['native'] : [];
    $device = is_array($payload['device'] ?? null) ? $payload['device'] : [];
    $exc = (string) ($java['exception'] ?? '');
    $msg = (string) ($java['message'] ?? '');
    $topic = '';
    if ($msg !== '' && preg_match('/\b([A-Za-z][A-Za-z0-9_]*(?:Exception|Error))\b/', $msg, $m)) {
        $topic = $m[1];
    }
    return [
        'fingerprint' => (string) ($payload['fingerprint'] ?? ''),
        'crash_type' => strtolower((string) ($payload['crash_type'] ?? '')),
        'exception' => $exc,
        'exception_topic' => $topic,
        'signal' => (string) ($native['signal'] ?? ''),
        'device_family' => device_model_family(
            (string) ($device['manufacturer'] ?? ''),
            (string) ($device['model'] ?? '')
        ),
        'device_model' => (string) ($device['model'] ?? ''),
    ];
}

/** Higher = more similar. */
function report_similarity_score(array $anchorKeys, array $row, array $rowPayload): int {
    $score = 0;
    $fp = (string) ($row['fingerprint'] ?? $rowPayload['fingerprint'] ?? '');
    $ct = strtolower((string) ($row['crash_type'] ?? $rowPayload['crash_type'] ?? ''));
    if ($anchorKeys['fingerprint'] !== '' && $fp === $anchorKeys['fingerprint']) {
        $score += 1000;
    }
    if ($anchorKeys['crash_type'] !== '' && $ct === $anchorKeys['crash_type']) {
        $score += 80;
    }
    $java = is_array($rowPayload['java'] ?? null) ? $rowPayload['java'] : [];
    $native = is_array($rowPayload['native'] ?? null) ? $rowPayload['native'] : [];
    $exc = (string) ($java['exception'] ?? '');
    if ($anchorKeys['exception'] !== '' && $exc !== '' && $exc === $anchorKeys['exception']) {
        $score += 120;
    }
    if ($anchorKeys['exception_topic'] !== '') {
        $msg = (string) ($java['message'] ?? '');
        if ($msg !== '' && stripos($msg, $anchorKeys['exception_topic']) !== false) {
            $score += 60;
        }
    }
    $sig = (string) ($native['signal'] ?? '');
    if ($anchorKeys['signal'] !== '' && $sig !== '' && $sig === $anchorKeys['signal']) {
        $score += 100;
    }
    $device = is_array($rowPayload['device'] ?? null) ? $rowPayload['device'] : [];
    $family = device_model_family(
        (string) ($device['manufacturer'] ?? ''),
        (string) ($device['model'] ?? '')
    );
    if ($anchorKeys['device_family'] !== '' && $family !== '') {
        if ($family === $anchorKeys['device_family']) {
            $score += 50;
        } elseif (str_starts_with($family, $anchorKeys['device_family'])
            || str_starts_with($anchorKeys['device_family'], $family)) {
            $score += 25;
        }
    }
    $dm = (string) ($row['device_model'] ?? $device['model'] ?? '');
    if ($anchorKeys['device_model'] !== '' && $dm !== '' && $dm === $anchorKeys['device_model']) {
        $score += 30;
    }
    return $score;
}

/** OS product name (Android, Linux, …) — not device manufacturer. */
function device_os_name(array $device): string {
    if (!empty($device['os_name'])) {
        return trim((string) $device['os_name']);
    }
    if (!empty($device['os'])) {
        return trim((string) $device['os']);
    }
    $sdk = (int) ($device['sdk_int'] ?? 0);
    $blob = strtolower(implode(' ', array_filter([
        (string) ($device['product'] ?? ''),
        (string) ($device['device'] ?? ''),
        (string) ($device['model'] ?? ''),
    ])));
    if ($sdk > 0 || str_contains($blob, 'android')) {
        return 'Android';
    }
    if (str_contains($blob, 'linux')) {
        return 'Linux';
    }
    if (($device['release'] ?? '') !== '') {
        return 'Android';
    }
    return 'Android';
}

/** Enrich list row with OS fields and rating inputs (keeps payload_json until unset). */
function report_enrich_list_row(array &$row, int $sinceMs = 0): void {
    $p = json_decode($row['payload_json'] ?? '', true);
    if (!is_array($p)) {
        $p = [];
    }
    $d = $p['device'] ?? [];
    $row['os_name'] = device_os_name($d);
    $rel = (string) ($d['release'] ?? '');
    $sdk = (int) ($d['sdk_int'] ?? 0);
    if ($rel !== '' && $sdk > 0) {
        $row['os_version'] = $rel . ' (API ' . $sdk . ')';
    } elseif ($rel !== '') {
        $row['os_version'] = $rel;
    } elseif ($sdk > 0) {
        $row['os_version'] = 'API ' . $sdk;
    } else {
        $row['os_version'] = '';
    }
    $row['scenario'] = (string) ($p['scenario'] ?? '');
    $total = (int) ($row['fingerprint_cnt'] ?? 1);
    $recent = (int) ($row['fingerprint_recent_cnt'] ?? 0);
    if ($sinceMs > 0 && $recent === 0 && !empty($row['received_at_ms'])) {
        $recent = (int) $row['received_at_ms'] > $sinceMs ? 1 : 0;
    }
    $row['rating'] = report_rating_score($total, $recent, (int) ($row['cnt'] ?? 0));
}

function report_rating_score(int $fingerprintTotal, int $fingerprintRecent, int $groupCnt = 0): int {
    $t = $groupCnt > 0 ? $groupCnt : max(1, $fingerprintTotal);
    $r = $fingerprintRecent;
    $score = 0;
    if ($t >= 40) {
        $score = 5;
    } elseif ($t >= 20) {
        $score = 4;
    } elseif ($t >= 10) {
        $score = 3;
    } elseif ($t >= 4) {
        $score = 2;
    } elseif ($t >= 2) {
        $score = 1;
    }
    if ($r >= 8) {
        $score = min(5, $score + 2);
    } elseif ($r >= 3) {
        $score = min(5, $score + 1);
    }
    return max(0, min(5, $score));
}

/** @return list<string> */
function search_query_tokens(string $q): array {
    $q = mb_strtolower(trim($q));
    if ($q === '') {
        return [];
    }
    $parts = preg_split('/\s+/u', $q) ?: [];
    $out = [];
    foreach ($parts as $part) {
        $t = preg_replace('/[^a-z0-9_.-]+/i', '', $part) ?? '';
        if (strlen($t) >= 2) {
            $out[] = $t;
        }
    }
    return array_values(array_unique($out));
}

/** Lowercase blob for keyword matching. */
function report_search_blob(array $row, array $payload): string {
    $java = is_array($payload['java'] ?? null) ? $payload['java'] : [];
    $native = is_array($payload['native'] ?? null) ? $payload['native'] : [];
    $device = is_array($payload['device'] ?? null) ? $payload['device'] : [];
    $app = is_array($payload['app'] ?? null) ? $payload['app'] : [];
    $build = is_array($payload['build'] ?? null) ? $payload['build'] : [];
    $parts = [
        $row['crash_type'] ?? '',
        $row['fingerprint'] ?? '',
        $row['device_model'] ?? '',
        $row['app_version'] ?? '',
        $payload['scenario'] ?? '',
        $java['exception'] ?? '',
        $java['message'] ?? '',
        $java['thread'] ?? '',
        $native['signal'] ?? '',
        $device['manufacturer'] ?? '',
        $device['brand'] ?? '',
        $device['model'] ?? '',
        $device['product'] ?? '',
        $app['package'] ?? '',
        $app['version_name'] ?? '',
        $build['git_commit'] ?? '',
        $build['git'] ?? '',
    ];
    if (!empty($java['stack_frames']) && is_array($java['stack_frames'])) {
        $parts[] = implode(' ', array_map('strval', array_slice($java['stack_frames'], 0, 3)));
    }
    return mb_strtolower(implode(' ', array_filter(array_map('strval', $parts))));
}

/**
 * @param list<string> $tokens
 */
function report_search_score(array $tokens, array $row, array $payload): int {
    if ($tokens === []) {
        return 0;
    }
    $blob = report_search_blob($row, $payload);
    $java = is_array($payload['java'] ?? null) ? $payload['java'] : [];
    $exc = mb_strtolower((string) ($java['exception'] ?? ''));
    $msg = mb_strtolower((string) ($java['message'] ?? ''));
    $device = is_array($payload['device'] ?? null) ? $payload['device'] : [];
    $mfr = mb_strtolower((string) ($device['manufacturer'] ?? ''));
    $brand = mb_strtolower((string) ($device['brand'] ?? ''));
    $model = mb_strtolower((string) ($device['model'] ?? ''));
    $score = 0;
    foreach ($tokens as $token) {
        if ($token === '') {
            continue;
        }
        if ($exc !== '' && str_contains($exc, $token)) {
            $score += 220;
        } elseif ($msg !== '' && str_contains($msg, $token)) {
            $score += 140;
        } elseif ($mfr !== '' && str_contains($mfr, $token)) {
            $score += 70;
        } elseif ($brand !== '' && str_contains($brand, $token)) {
            $score += 65;
        } elseif ($model !== '' && str_contains($model, $token)) {
            $score += 60;
        } elseif (str_contains($blob, $token)) {
            $score += 45;
        }
    }
    $rating = (int) ($row['rating'] ?? 0);
    if ($rating > 0 && $score > 0) {
        $score += $rating * 12;
    }
    $fpCnt = (int) ($row['fingerprint_cnt'] ?? 1);
    if ($fpCnt > 1 && $score > 0) {
        $score += min(40, (int) log($fpCnt + 1) * 10);
    }
    return $score;
}

/** System tags — not stored in tags_json (computed in UI). */
function report_reserved_tag_ids(): array {
    return ['new', 'java', 'ndk', 'android'];
}

function normalize_tag_color(string $bg): string {
    $bg = trim($bg);
    if (preg_match('/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/', $bg) !== 1) {
        return '#5c6b82';
    }
    if (strlen($bg) === 4) {
        return '#' . $bg[1] . $bg[1] . $bg[2] . $bg[2] . $bg[3] . $bg[3];
    }
    return strtolower($bg);
}

/**
 * @param list<mixed> $tags
 * @return list<array{id:string,label:string,bg:string}>
 */
function normalize_report_tags(array $tags): array {
    $reserved = array_flip(report_reserved_tag_ids());
    $out = [];
    $seen = [];
    foreach ($tags as $tag) {
        if (!is_array($tag)) {
            continue;
        }
        $label = trim((string) ($tag['label'] ?? $tag['id'] ?? ''));
        if ($label === '' || strlen($label) > 40) {
            continue;
        }
        $id = trim((string) ($tag['id'] ?? $label));
        $id = strtolower(preg_replace('/[^a-z0-9_-]+/i', '-', $id) ?? $id);
        $id = trim($id, '-');
        if ($id === '' || isset($reserved[$id])) {
            continue;
        }
        if (isset($seen[$id])) {
            continue;
        }
        $seen[$id] = true;
        $out[] = [
            'id' => $id,
            'label' => $label,
            'bg' => normalize_tag_color((string) ($tag['bg'] ?? '#5c6b82')),
        ];
        if (count($out) >= 24) {
            break;
        }
    }
    return $out;
}

/** @return list<array{id:string,label:string,bg:string}> */
/**
 * @return list<string>
 */
function request_tag_filter_ids(): array {
    $raw = [];
    if (isset($_GET['tag']) && is_array($_GET['tag'])) {
        $raw = $_GET['tag'];
    } elseif (isset($_GET['tag']) && is_string($_GET['tag']) && $_GET['tag'] !== '') {
        $raw = [$_GET['tag']];
    }
    return TagCatalog::normalizeFilterIds($raw);
}

function request_tag_filter_mode(): string {
    $mode = strtolower(trim((string) ($_GET['tag_mode'] ?? 'and')));
    return $mode === 'or' ? 'or' : 'and';
}

/** @return list<array{id:string,label:string,bg:string}> */
function parse_report_tags(?string $json): array {
    if ($json === null || $json === '') {
        return [];
    }
    $raw = json_decode($json, true);
    if (!is_array($raw)) {
        return [];
    }
    $out = [];
    foreach ($raw as $tag) {
        if (!is_array($tag)) {
            continue;
        }
        $id = (string) ($tag['id'] ?? $tag['label'] ?? '');
        if ($id === '') {
            continue;
        }
        $out[] = [
            'id' => $id,
            'label' => (string) ($tag['label'] ?? $id),
            'bg' => (string) ($tag['bg'] ?? '#5c6b82'),
        ];
    }
    return $out;
}

function log_crash_event(string $channel, string $message): void {
    $dir = dirname(__DIR__) . '/storage';
    if (!is_dir($dir)) {
        @mkdir($dir, 0775, true);
    }
    $line = date('c') . " [$channel] $message\n";
    @file_put_contents($dir . '/crash.log', $line, FILE_APPEND | LOCK_EX);
}
