<?php

/**
 * Activity API logs (JSON Lines). One file per calendar day; rotates when a file
 * exceeds QDB_API_LOG_MAX_BYTES (default 10 MiB) to api-YYYY-MM-DD-002.log, etc.
 *
 * Recorded activity (not routine website page loads):
 *   - app_sync:     GET /app_questionnaires (mobile sync)
 *   - app_change:   POST/PUT/PATCH/DELETE from the Android app
 *   - web_change:   POST/PUT/PATCH/DELETE from the management website (incl. translation labels)
 *   - web_export:   Website downloads (CSV/ZIP/JSON exports, translation bundle)
 *
 * Set QDB_API_LOG=0 to disable. Set QDB_API_LOG_DIR for a custom path.
 * Default directory is uploads/logs/api (writable by PHP alongside the database).
 */

/** @var list<string> */
const QDB_API_LOG_ACTIVITIES = ['app_sync', 'app_change', 'web_change', 'web_export', 'api_error'];

function qdb_api_log_enabled(): bool
{
    $v = qdb_env_get('QDB_API_LOG');
    if ($v === null || $v === '') {
        return true;
    }
    $v = strtolower(trim($v));
    return !in_array($v, ['0', 'false', 'no', 'off'], true);
}

function qdb_api_log_dir(): string
{
    static $resolved = null;
    if ($resolved !== null) {
        return $resolved;
    }

    $custom = qdb_env_get('QDB_API_LOG_DIR');
    if ($custom !== null && $custom !== '') {
        $resolved = rtrim($custom, '/\\');
        return $resolved;
    }

    $root = dirname(__DIR__);
    foreach ([$root . '/uploads/logs/api', $root . '/logs/api'] as $candidate) {
        if (qdb_api_log_dir_is_usable($candidate)) {
            $resolved = $candidate;
            return $resolved;
        }
    }

    $resolved = $root . '/uploads/logs/api';
    return $resolved;
}

function qdb_api_log_dir_is_usable(string $dir): bool
{
    if (is_dir($dir)) {
        return is_writable($dir);
    }
    $parent = dirname($dir);
    if (is_dir($parent) && is_writable($parent)) {
        return true;
    }
    if (@mkdir($dir, 0775, true)) {
        return is_writable($dir);
    }
    return is_dir($dir) && is_writable($dir);
}

/**
 * Diagnostics for admin UI (permissions, path, enabled flag).
 *
 * @return array<string, mixed>
 */
function qdb_api_log_status(): array
{
    $dir     = qdb_api_log_dir();
    $enabled = qdb_api_log_enabled();
    $exists  = is_dir($dir);
    $writable = false;
    $probeError = null;

    if ($enabled) {
        if (!$exists) {
            if (!@mkdir($dir, 0775, true)) {
                $probeError = 'Cannot create directory: ' . $dir;
            }
            $exists = is_dir($dir);
        }
        if ($exists && !is_writable($dir)) {
            $probeError = 'Directory exists but is not writable by PHP: ' . $dir;
        } elseif ($exists) {
            $probe = $dir . '/.write_probe';
            if (@file_put_contents($probe, (string)time() . "\n", LOCK_EX) === false) {
                $probeError = 'Cannot write files in: ' . $dir;
            } else {
                $writable = true;
                @unlink($probe);
            }
        }
    }

    $phpUser = null;
    if (function_exists('posix_geteuid') && function_exists('posix_getpwuid')) {
        $info = posix_getpwuid(posix_geteuid());
        if (is_array($info) && !empty($info['name'])) {
            $phpUser = (string)$info['name'];
        }
    }

    return [
        'enabled'    => $enabled,
        'dir'        => $dir,
        'exists'     => $exists,
        'writable'   => $writable,
        'error'      => $probeError,
        'php_user'   => $phpUser,
        'fix_hint'   => $writable || !$enabled
            ? null
            : 'On the server run: mkdir -p ' . $dir . ' && chown -R www-data:www-data '
                . dirname($dir) . ' && chmod -R 775 ' . dirname($dir)
                . ' (adjust www-data to your PHP user). Or set QDB_API_LOG_DIR in .env to a writable path.',
    ];
}

function qdb_api_log_max_bytes(): int
{
    $v = qdb_env_get('QDB_API_LOG_MAX_BYTES');
    if ($v !== null && $v !== '' && ctype_digit($v)) {
        return max(1024 * 1024, (int)$v);
    }
    return 10 * 1024 * 1024;
}

function qdb_api_log_body_max_chars(): int
{
    $v = qdb_env_get('QDB_API_LOG_BODY_MAX');
    if ($v !== null && $v !== '' && ctype_digit($v)) {
        return max(256, (int)$v);
    }
    return 4096;
}

/**
 * Whether this request should be written to the activity log.
 */
function qdb_api_log_classify_activity(string $method, string $client, string $route): ?string
{
    $method = strtoupper($method);
    $client = strtolower(trim($client));
    $route  = strtolower(trim($route));

    if ($method === 'OPTIONS' || in_array($route, ['activity-log', 'session'], true)) {
        return null;
    }

    if (in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true)) {
        return $client === 'web' ? 'web_change' : 'app_change';
    }

    if ($method === 'GET') {
        if ($route === 'app_questionnaires' && $client !== 'web') {
            return 'app_sync';
        }
        if (qdb_api_log_is_website_download($route)) {
            return 'web_export';
        }
    }

    return null;
}

/** Website GET that downloads or exports data (not routine list/load). */
function qdb_api_log_is_website_download(string $route): bool
{
    if (in_array($route, ['export', 'backup'], true)) {
        return true;
    }
    if ($route === 'translations' && !empty($_GET['exportBundle'])) {
        return true;
    }
    return false;
}

/** @param array<string, mixed> $context */
function qdb_api_log_begin(string $method, string $route, array $context = []): void
{
    if (!qdb_api_log_enabled()) {
        return;
    }

    $client   = trim((string)($_SERVER['HTTP_X_QDB_CLIENT'] ?? ''));
    $activity = qdb_api_log_classify_activity($method, $client, $route);

    $GLOBALS['qdb_api_log_ctx'] = array_merge([
        'started_at' => microtime(true),
        'method'     => strtoupper($method),
        'route'      => $route,
        'uri'        => (string)($_SERVER['REQUEST_URI'] ?? ''),
        'ip'         => qdb_api_log_client_ip(),
        'client'     => $client,
        'user_agent' => trim((string)($_SERVER['HTTP_USER_AGENT'] ?? '')),
    ], $context);

    if ($activity !== null) {
        $GLOBALS['qdb_api_log_ctx']['activity'] = $activity;
    } else {
        $GLOBALS['qdb_api_log_ctx']['skip_success'] = true;
    }
}

function qdb_api_log_note_exception(Throwable $e): void
{
    if (empty($GLOBALS['qdb_api_log_ctx'])) {
        return;
    }
    $GLOBALS['qdb_api_log_ctx']['exception'] = $e->getMessage();
    $GLOBALS['qdb_api_log_ctx']['exception_class'] = $e::class;
}

function qdb_api_log_note_api_error(string $code, string $message, int $status): void
{
    if (empty($GLOBALS['qdb_api_log_ctx'])) {
        return;
    }
    $GLOBALS['qdb_api_log_ctx']['api_error_code']    = $code;
    $GLOBALS['qdb_api_log_ctx']['api_error_message'] = $message;
    $GLOBALS['qdb_api_log_ctx']['api_error_status']  = $status;
}

function qdb_api_log_finish(): void
{
    $ctx = $GLOBALS['qdb_api_log_ctx'] ?? null;
    unset($GLOBALS['qdb_api_log_ctx']);
    if (!is_array($ctx) || !qdb_api_log_enabled()) {
        return;
    }

    $started = (float)($ctx['started_at'] ?? microtime(true));
    $status  = http_response_code();
    if ($status === false || $status === 0) {
        $status = 200;
    }

    $isError = $status >= 400
        || !empty($ctx['exception'])
        || !empty($ctx['api_error_code']);

    if (!empty($ctx['skip_success']) && !$isError) {
        return;
    }

    $token = get_bearer_token();
    $auth  = ['role' => null, 'userID' => null, 'token_hint' => null];
    if ($token !== null && $token !== '') {
        $auth['token_hint'] = qdb_api_log_token_hint($token);
        try {
            $rec = token_get_record($token, false);
            if (is_array($rec)) {
                $auth['role']   = $rec['role'] ?? null;
                $auth['userID'] = $rec['userID'] ?? null;
                if (!empty($rec['temp'])) {
                    $auth['temp_token'] = true;
                }
            }
        } catch (Throwable) {
            $auth['token_lookup'] = 'failed';
        }
    }

    $rawBody  = function_exists('qdb_raw_request_body') ? qdb_raw_request_body() : '';
    $bodyLog  = qdb_api_log_format_body($rawBody);
    $queryLog = qdb_api_log_redact_query_string((string)($_SERVER['QUERY_STRING'] ?? ''));

    $actorUsername  = qdb_api_log_lookup_username($auth['userID'] ?? null);
    $targetUsername = qdb_api_log_extract_target_username($bodyLog, $queryLog);

    $activity = $ctx['activity'] ?? null;
    if ($isError) {
        $activity = 'api_error';
    }

    $entry = [
        'ts'              => gmdate('c'),
        'activity'        => $activity,
        'method'          => $ctx['method'] ?? '',
        'route'           => $ctx['route'] ?? '',
        'status'          => $status,
        'duration_ms'     => (int)round((microtime(true) - $started) * 1000),
        'ip'              => $ctx['ip'] ?? '',
        'client'          => $ctx['client'] ?? '',
        'uri'             => $ctx['uri'] ?? '',
        'query'           => $queryLog,
        'role'            => $auth['role'],
        'userID'          => $auth['userID'],
        'actor_username'  => $actorUsername,
        'target_username' => $targetUsername,
        'token_hint'      => $auth['token_hint'],
        'temp_token'      => $auth['temp_token'] ?? null,
        'changes'         => qdb_api_log_build_changes(
            (string)($ctx['method'] ?? ''),
            (string)($ctx['route'] ?? ''),
            $bodyLog,
            $queryLog
        ),
    ];

    if (!empty($ctx['api_error_code'])) {
        $entry['error_code']    = (string)$ctx['api_error_code'];
        $entry['error_message'] = (string)($ctx['api_error_message'] ?? '');
        $entry['error']         = $entry['error_code'] . ': ' . $entry['error_message'];
    }
    if (!empty($ctx['exception'])) {
        $entry['error_internal'] = (string)$ctx['exception'];
        if (!empty($ctx['exception_class'])) {
            $entry['error_class'] = (string)$ctx['exception_class'];
        }
        if (empty($entry['error'])) {
            $entry['error'] = 'SERVER_ERROR: ' . $entry['error_internal'];
        }
    }

    if (($ctx['user_agent'] ?? '') !== '') {
        $entry['user_agent'] = $ctx['user_agent'];
    }

    try {
        $line = json_encode($entry, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        if ($line === false) {
            return;
        }
        qdb_api_log_append_line($line . "\n");
    } catch (Throwable $e) {
        error_log('api_log write failed: ' . $e->getMessage());
    }
}

function qdb_api_log_client_ip(): string
{
    foreach ([
        'HTTP_CF_CONNECTING_IP',
        'HTTP_X_FORWARDED_FOR',
        'HTTP_X_REAL_IP',
        'REMOTE_ADDR',
    ] as $key) {
        $v = trim((string)($_SERVER[$key] ?? ''));
        if ($v === '') {
            continue;
        }
        if ($key === 'HTTP_X_FORWARDED_FOR') {
            $v = trim(explode(',', $v)[0]);
        }
        if (filter_var($v, FILTER_VALIDATE_IP)) {
            return $v;
        }
    }
    return '';
}

function qdb_api_log_token_hint(string $token): string
{
    $len = strlen($token);
    if ($len <= 8) {
        return '[token]';
    }
    return substr($token, 0, 4) . '…' . substr($token, -4) . " ($len)";
}

function qdb_api_log_redact_query_string(string $qs): ?string
{
    if ($qs === '') {
        return null;
    }
    parse_str($qs, $params);
    if (!is_array($params) || $params === []) {
        return $qs;
    }
    foreach ($params as $k => $_) {
        if (in_array(strtolower((string)$k), ['token', 'password', 'authorization'], true)) {
            $params[$k] = '[REDACTED]';
        }
    }
    return http_build_query($params);
}

/**
 * @return array<string, mixed>|string|null
 */
function qdb_api_log_format_body(string $raw): array|string|null
{
    $raw = trim($raw);
    if ($raw === '') {
        return null;
    }

    $max = qdb_api_log_body_max_chars();
    $decoded = json_decode($raw, true);
    if (is_array($decoded)) {
        $redacted = qdb_api_log_redact_value($decoded);
        $json     = json_encode($redacted, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        if ($json === false) {
            return '[unencodable body]';
        }
        if (strlen($json) > $max) {
            return substr($json, 0, $max) . '…[truncated]';
        }
        return $redacted;
    }

    if (strlen($raw) > $max) {
        return substr($raw, 0, $max) . '…[truncated]';
    }
    return $raw;
}

/** @param mixed $value @return mixed */
function qdb_api_log_redact_value(mixed $value): mixed
{
    if (!is_array($value)) {
        return $value;
    }

    $isList = array_keys($value) === range(0, count($value) - 1);
    $out    = [];
    foreach ($value as $k => $v) {
        $lk = strtolower((string)$k);
        if (in_array($lk, [
            'password', 'old_password', 'new_password', 'passwordhash',
            'token', 'authorization', 'secret',
        ], true)) {
            $out[$k] = '[REDACTED]';
        } elseif ($lk === 'encrypted' && is_string($v)) {
            $out[$k] = '[ENCRYPTED:' . strlen($v) . ' chars]';
        } elseif (is_array($v)) {
            $out[$k] = qdb_api_log_redact_value($v);
        } else {
            $out[$k] = $v;
        }
    }
    return $isList ? array_values($out) : $out;
}

/**
 * Field-level change list for the activity log UI (passwords stay redacted).
 *
 * @param  mixed       $body  Redacted decoded body or null
 * @return list<array{field: string, value: string|int|float|bool|null}>
 */
function qdb_api_log_build_changes(string $method, string $route, mixed $body, ?string $queryString): array
{
    $changes = [];
    $max     = 100;

    if ($queryString !== null && $queryString !== '') {
        parse_str($queryString, $params);
        if (is_array($params)) {
            foreach ($params as $k => $v) {
                if (count($changes) >= $max) {
                    break;
                }
                if (!is_scalar($v)) {
                    continue;
                }
                $changes[] = [
                    'field' => 'query.' . (string)$k,
                    'value' => qdb_api_log_change_scalar($v),
                ];
            }
        }
    }

    if (is_array($body)) {
        qdb_api_log_flatten_for_changes($body, '', $changes, 0, 4, $max);
    } elseif (is_string($body) && $body !== '') {
        $changes[] = ['field' => 'body', 'value' => qdb_api_log_change_scalar($body)];
    }

    if ($changes === [] && strtoupper($method) === 'DELETE') {
        $changes[] = ['field' => '_action', 'value' => 'delete'];
    }

    return $changes;
}

/**
 * @param list<array{field: string, value: string|int|float|bool|null}> $out
 */
function qdb_api_log_flatten_for_changes(
    array $data,
    string $prefix,
    array &$out,
    int $depth,
    int $maxDepth,
    int $maxFields
): void {
    if ($depth >= $maxDepth || count($out) >= $maxFields) {
        return;
    }

    foreach ($data as $k => $v) {
        if (count($out) >= $maxFields) {
            return;
        }
        $key   = (string)$k;
        $field = $prefix === '' ? $key : $prefix . '.' . $key;

        if (is_array($v)) {
            $childList = array_keys($v) === range(0, count($v) - 1);
            $n         = count($v);
            if ($n === 0) {
                $out[] = ['field' => $field, 'value' => '[]'];
                continue;
            }
            if ($childList && $n > 8) {
                $out[] = ['field' => $field, 'value' => '[list: ' . $n . ' items]'];
                continue;
            }
            if (!$childList && $depth + 1 >= $maxDepth) {
                $out[] = ['field' => $field, 'value' => '[object: ' . $n . ' keys]'];
                continue;
            }
            qdb_api_log_flatten_for_changes($v, $field, $out, $depth + 1, $maxDepth, $maxFields);
        } else {
            $out[] = ['field' => $field, 'value' => qdb_api_log_change_scalar($v)];
        }
    }
}

/** @return string|int|float|bool|null */
function qdb_api_log_change_scalar(mixed $v): string|int|float|bool|null
{
    if ($v === null) {
        return null;
    }
    if (is_bool($v) || is_int($v) || is_float($v)) {
        return $v;
    }
    $s = is_string($v) ? $v : json_encode($v, JSON_UNESCAPED_UNICODE);
    if ($s === false) {
        return '';
    }
    if (strlen($s) > 800) {
        return substr($s, 0, 800) . '…';
    }
    return $s;
}

function qdb_api_log_append_line(string $line): void
{
    $dir = qdb_api_log_dir();
    if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
        $err = error_get_last();
        throw new RuntimeException(
            'Cannot create log directory: ' . $dir
            . ($err ? ' (' . ($err['message'] ?? '') . ')' : '')
        );
    }
    if (!is_writable($dir)) {
        throw new RuntimeException('Log directory not writable: ' . $dir);
    }

    $path = qdb_api_log_resolve_write_path($dir);
    if (@file_put_contents($path, $line, FILE_APPEND | LOCK_EX) === false) {
        $err = error_get_last();
        throw new RuntimeException(
            'Cannot write API log: ' . $path
            . ($err ? ' (' . ($err['message'] ?? '') . ')' : '')
        );
    }
}

function qdb_api_log_resolve_write_path(string $dir): string
{
    $date = date('Y-m-d');
    $base = $dir . '/api-' . $date;
    $max  = qdb_api_log_max_bytes();

    $primary = $base . '.log';
    if (!is_file($primary) || filesize($primary) < $max) {
        return $primary;
    }

    for ($part = 2; $part <= 999; $part++) {
        $path = sprintf('%s-%03d.log', $base, $part);
        if (!is_file($path) || filesize($path) < $max) {
            return $path;
        }
    }

    return sprintf('%s-%03d.log', $base, 999);
}

/**
 * Read activity log entries for admin UI (newest first).
 *
 * @return array{date: string, activity: string, entries: list<array<string, mixed>>, files: list<string>, truncated: bool}
 */
function qdb_api_log_read_entries(string $date, string $activityFilter = '', int $limit = 200, bool $errorsOnly = false): array
{
    if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
        $date = date('Y-m-d');
    }
    $limit = max(1, min(1000, $limit));
    $activityFilter = strtolower(trim($activityFilter));
    if ($activityFilter === 'errors') {
        $errorsOnly     = true;
        $activityFilter = '';
    }
    if ($activityFilter !== '' && !in_array($activityFilter, QDB_API_LOG_ACTIVITIES, true)) {
        $activityFilter = '';
    }

    $dir   = qdb_api_log_dir();
    $files = qdb_api_log_files_for_date($dir, $date);
    $entries = [];

    foreach ($files as $file) {
        $lines = @file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
        if ($lines === false) {
            continue;
        }
        foreach ($lines as $line) {
            $row = json_decode($line, true);
            if (!is_array($row)) {
                continue;
            }
            if ($activityFilter !== '' && ($row['activity'] ?? '') !== $activityFilter) {
                continue;
            }
            if ($errorsOnly) {
                $rowActivity = $row['activity'] ?? '';
                $rowStatus   = (int)($row['status'] ?? 0);
                if ($rowActivity !== 'api_error' && $rowStatus < 400 && empty($row['error'])) {
                    continue;
                }
            }
            $entries[] = qdb_api_log_entry_for_ui($row);
        }
    }

    usort($entries, static function (array $a, array $b): int {
        return strcmp($b['ts'] ?? '', $a['ts'] ?? '');
    });

    $truncated = count($entries) > $limit;
    if ($truncated) {
        $entries = array_slice($entries, 0, $limit);
    }

    $entries = qdb_api_log_enrich_entries_usernames($entries);

    return [
        'date'       => $date,
        'activity'   => $activityFilter,
        'errorsOnly' => $errorsOnly,
        'entries'    => $entries,
        'files'      => array_map('basename', $files),
        'truncated'  => $truncated,
        'kinds'      => QDB_API_LOG_ACTIVITIES,
        'logStatus'  => qdb_api_log_status(),
    ];
}

/** @return list<string> */
function qdb_api_log_files_for_date(string $dir, string $date): array
{
    if (!is_dir($dir)) {
        return [];
    }
    $pattern = $dir . '/api-' . $date . '*.log';
    $files   = glob($pattern) ?: [];
    sort($files);
    return $files;
}

/** @param array<string, mixed> $row @return array<string, mixed> */
function qdb_api_log_entry_for_ui(array $row): array
{
    $changes = $row['changes'] ?? null;
    if (!is_array($changes) || $changes === []) {
        $changes = qdb_api_log_build_changes(
            (string)($row['method'] ?? ''),
            (string)($row['route'] ?? ''),
            $row['body'] ?? null,
            isset($row['query']) ? (string)$row['query'] : null
        );
    }
    $changes = qdb_api_log_sort_changes_for_display($changes);

    $actorUsername  = $row['actor_username'] ?? null;
    $targetUsername = $row['target_username'] ?? null;

    return [
        'ts'              => $row['ts'] ?? '',
        'activity'        => $row['activity'] ?? '',
        'method'          => $row['method'] ?? '',
        'route'           => $row['route'] ?? '',
        'status'          => $row['status'] ?? null,
        'duration_ms'     => $row['duration_ms'] ?? null,
        'role'            => $row['role'] ?? null,
        'userID'          => $row['userID'] ?? null,
        'actor_username'  => is_string($actorUsername) ? $actorUsername : null,
        'target_username' => is_string($targetUsername) ? $targetUsername : null,
        'client'          => $row['client'] ?? '',
        'ip'              => $row['ip'] ?? '',
        'query'           => $row['query'] ?? null,
        'changes'         => $changes,
        'summary'         => qdb_api_log_summarize_changes($changes, $targetUsername),
        'error'           => $row['error'] ?? null,
        'error_code'      => $row['error_code'] ?? null,
        'error_message'   => $row['error_message'] ?? null,
    ];
}

function qdb_api_log_lookup_username(?string $userID): ?string
{
    if ($userID === null || $userID === '') {
        return null;
    }
    try {
        require_once __DIR__ . '/../db_init.php';
        [$pdo, $tmpDb, $lockFp] = qdb_open(false);
        $stmt = $pdo->prepare('SELECT username FROM users WHERE userID = :uid');
        $stmt->execute([':uid' => $userID]);
        $name = $stmt->fetchColumn();
        qdb_discard($tmpDb, $lockFp);
        return $name !== false ? (string)$name : null;
    } catch (Throwable) {
        return null;
    }
}

function qdb_api_log_extract_target_username(mixed $body, ?string $queryString): ?string
{
    if (is_array($body) && !empty($body['username']) && is_scalar($body['username'])) {
        return trim((string)$body['username']);
    }
    if ($queryString !== null && $queryString !== '') {
        parse_str($queryString, $params);
        if (!empty($params['username']) && is_scalar($params['username'])) {
            return trim((string)$params['username']);
        }
    }
    return null;
}

/**
 * Resolve userID / supervisorID in log rows to usernames for retraceability.
 *
 * @param  list<array<string, mixed>> $entries
 * @return list<array<string, mixed>>
 */
function qdb_api_log_enrich_entries_usernames(array $entries): array
{
    if ($entries === []) {
        return $entries;
    }

    $userIds       = [];
    $supervisorIds = [];

    foreach ($entries as $entry) {
        if (!empty($entry['userID']) && is_string($entry['userID'])) {
            $userIds[$entry['userID']] = true;
        }
        if (empty($entry['actor_username']) && !empty($entry['userID'])) {
            $userIds[$entry['userID']] = true;
        }
        foreach ($entry['changes'] ?? [] as $change) {
            if (!is_array($change)) {
                continue;
            }
            $field = strtolower((string)($change['field'] ?? ''));
            $value = $change['value'] ?? null;
            if (!is_string($value) || $value === '' || str_starts_with($value, '[')) {
                continue;
            }
            if ($field === 'userid' || str_ends_with($field, '.userid')) {
                $userIds[$value] = true;
            }
            if ($field === 'supervisorid' || str_ends_with($field, '.supervisorid')) {
                $supervisorIds[$value] = true;
            }
        }
    }

    $userMap = qdb_api_log_load_user_map(array_keys($userIds));
    $svMap   = qdb_api_log_load_supervisor_map(array_keys($supervisorIds));

    foreach ($entries as $i => $entry) {
        if (empty($entry['actor_username']) && !empty($entry['userID'])) {
            $entry['actor_username'] = $userMap[$entry['userID']] ?? null;
        }
        if (empty($entry['target_username'])) {
            $entry['target_username'] = qdb_api_log_target_from_changes($entry['changes'] ?? []);
        }

        $enriched = [];
        foreach ($entry['changes'] ?? [] as $change) {
            if (!is_array($change)) {
                continue;
            }
            $change['display'] = qdb_api_log_change_display_label($change, $userMap, $svMap);
            $enriched[]        = $change;
        }
        $entry['changes'] = qdb_api_log_sort_changes_for_display($enriched);
        $entry['summary'] = qdb_api_log_summarize_changes(
            $entry['changes'],
            $entry['target_username'] ?? null
        );
        $entries[$i] = $entry;
    }

    return $entries;
}

/** @param list<string> $userIds @return array<string, string> */
function qdb_api_log_load_user_map(array $userIds): array
{
    $userIds = array_values(array_filter($userIds, static fn($id) => $id !== ''));
    if ($userIds === []) {
        return [];
    }
    try {
        require_once __DIR__ . '/../db_init.php';
        [$pdo, $tmpDb, $lockFp] = qdb_open(false);
        $placeholders = implode(',', array_fill(0, count($userIds), '?'));
        $stmt         = $pdo->prepare(
            "SELECT userID, username FROM users WHERE userID IN ($placeholders)"
        );
        $stmt->execute($userIds);
        $map = [];
        while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
            $map[(string)$row['userID']] = (string)$row['username'];
        }
        qdb_discard($tmpDb, $lockFp);
        return $map;
    } catch (Throwable) {
        return [];
    }
}

/** @param list<string> $supervisorIds @return array<string, string> */
function qdb_api_log_load_supervisor_map(array $supervisorIds): array
{
    $supervisorIds = array_values(array_filter($supervisorIds, static fn($id) => $id !== ''));
    if ($supervisorIds === []) {
        return [];
    }
    try {
        require_once __DIR__ . '/../db_init.php';
        [$pdo, $tmpDb, $lockFp] = qdb_open(false);
        $placeholders = implode(',', array_fill(0, count($supervisorIds), '?'));
        $stmt         = $pdo->prepare(
            "SELECT supervisorID, username FROM supervisor WHERE supervisorID IN ($placeholders)"
        );
        $stmt->execute($supervisorIds);
        $map = [];
        while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
            $map[(string)$row['supervisorID']] = (string)$row['username'];
        }
        qdb_discard($tmpDb, $lockFp);
        return $map;
    } catch (Throwable) {
        return [];
    }
}

/** @param list<array{field?: string, value?: mixed}> $changes */
function qdb_api_log_target_from_changes(array $changes): ?string
{
    foreach ($changes as $c) {
        $field = strtolower((string)($c['field'] ?? ''));
        if ($field === 'username' && isset($c['value']) && is_scalar($c['value'])) {
            return trim((string)$c['value']);
        }
    }
    return null;
}

/**
 * @param  array<string, string> $userMap
 * @param  array<string, string> $svMap
 */
function qdb_api_log_change_display_label(array $change, array $userMap, array $svMap): string
{
    $field = strtolower((string)($change['field'] ?? ''));
    $value = $change['value'] ?? null;
    if ($value === null) {
        return 'null';
    }
    if (!is_scalar($value)) {
        return '';
    }
    $str = (string)$value;

    if ($field === 'username') {
        return $str;
    }
    if ($field === 'userid' || str_ends_with($field, '.userid')) {
        return $userMap[$str] ?? $str;
    }
    if ($field === 'supervisorid' || str_ends_with($field, '.supervisorid')) {
        return $svMap[$str] ?? $str;
    }
    if ($field === 'clientcode' || str_ends_with($field, 'clientcode')) {
        return $str;
    }
    if ($field === 'mustchangepassword') {
        return ((int)$value === 1) ? 'yes (temporary password)' : 'no (permanent password)';
    }
    if ($field === 'password' || str_contains($field, 'password')) {
        return (string)$value;
    }

    return $str;
}

/**
 * @param list<array{field?: string, value?: mixed}> $changes
 * @return list<array{field?: string, value?: mixed}>
 */
function qdb_api_log_sort_changes_for_display(array $changes): array
{
    $priority = [
        'username' => 0,
        'type' => 1,
        'text' => 2,
        'languagecode' => 3,
        'id' => 4,
        'role' => 5,
        'clientcode' => 6,
        'action' => 7,
        'query.id' => 10,
        'query.clients' => 11,
        'query.clientcode' => 12,
        'query.answers' => 13,
        'query.translations' => 14,
        'query.bundle' => 15,
        'query.exportall' => 16,
        'query.exportbundle' => 17,
        'supervisorid' => 18,
        'userid' => 19,
        'mustchangepassword' => 20,
        'password' => 21,
        'location' => 22,
    ];

    usort($changes, static function (array $a, array $b) use ($priority): int {
        $fa = strtolower((string)($a['field'] ?? ''));
        $fb = strtolower((string)($b['field'] ?? ''));
        $pa = $priority[$fa] ?? 50;
        $pb = $priority[$fb] ?? 50;
        if ($pa !== $pb) {
            return $pa <=> $pb;
        }
        return strcmp($fa, $fb);
    });

    return $changes;
}

/** @param list<array{field: string, value: mixed, display?: string}> $changes */
function qdb_api_log_summarize_changes(array $changes, ?string $targetUsername = null): string
{
    if ($targetUsername !== null && $targetUsername !== '') {
        return 'user=' . $targetUsername;
    }
    if ($changes === []) {
        return '';
    }
    $parts = [];
    foreach (array_slice($changes, 0, 8) as $c) {
        $field = (string)($c['field'] ?? '');
        if ($field === '') {
            continue;
        }
        $label = (string)($c['display'] ?? '');
        $value = $c['value'] ?? '';
        if ($label !== '' && $label !== (string)$value) {
            $parts[] = $field . '=' . $label;
        } elseif ($value === null) {
            $parts[] = $field . '=null';
        } elseif (is_bool($value)) {
            $parts[] = $field . '=' . ($value ? 'true' : 'false');
        } else {
            $parts[] = $field . '=' . (string)$value;
        }
    }
    $summary = implode(', ', $parts);
    if (count($changes) > 8) {
        $summary .= ', …';
    }
    if (strlen($summary) > 240) {
        $summary = substr($summary, 0, 240) . '…';
    }
    return $summary;
}
