// AI Assistant Monitor Worker v4.1.12 + encrypted stage backups
// Обязательные bindings/secrets:
//   DB                  — D1 database binding
//   CONFIG_KV           — тот же KV namespace, где лежат ключи
//                         legend и catalog; доступ к ним выдаётся только
//                         активированным установкам по персональному iat
//   DATA_ENCRYPTION_KEY — 32 случайных байта: 64 hex-символа или base64url
//   ADMIN_TOKEN         — только для первоначального создания admin
//   ANTHROPIC_API_KEY   — секретный ключ Anthropic; клиентским скриптам
//                         никогда не передаётся
//   GROQ_API_KEY        — секретный ключ Groq для расшифровки голосовых;
//                         аудио и текст расшифровки в D1 не сохраняются
// Временная совместимость:
//   INGEST_TOKEN        — старые скрипты могут слать только телеметрию;
//                         решения всегда требуют персональный iat-токен.
// Рекомендуемые Rate Limiting bindings:
//   API_RATE_LIMITER, AI_RATE_LIMITER, ENROLL_RATE_LIMITER,
//   LOGIN_RATE_LIMITER
// Рекомендуемый Cron Trigger: один раз в сутки, например 0 3 * * *.
const WORKER_VERSION = '4.1.12';
const ONLINE_WINDOW_MS = 5 * 60 * 1000;
// Если heartbeat продолжает приходить, но вкладка Dolphin скрыта дольше
// этого порога, CRM показывает отдельное состояние «в фоне».
const BACKGROUND_WARNING_MS = 2 * 60 * 1000;
// Физически удаляем архив значительно позже. Это защищает от потери
// телеметрии, если компьютер менеджера был выключен несколько дней.
// Карточка менеджера при этом не удаляется: она хранится в manager_stages.
const INSTALLATION_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
// Клиентский снимок приходит минимум раз в 5 минут. Если heartbeat живой,
// а строки клиентов отстали от него больше чем на 15 минут, такой снимок
// считается сломанным и не участвует в рабочих счётчиках.
const CLIENT_SNAPSHOT_STALE_GRACE_MS = 15 * 60 * 1000;
const MAX_BODY_BYTES = 512 * 1024;
const MAX_AI_BODY_BYTES = 30 * 1024 * 1024;
const MAX_OKZ_AI_BODY_BYTES = 128 * 1024;
const MAX_AUDIO_BODY_BYTES = 24 * 1024 * 1024;
const MAX_FORM_BYTES = 32 * 1024;
const MAX_CLIENTS_PER_REQUEST = 500;
const MAX_EVENTS_PER_REQUEST = 200;
// Удаление отсутствующих клиентов разрешено только полному снимку нового
// формата. Старые и частичные выгрузки работают как безопасный upsert.
const CLIENT_SNAPSHOT_SCHEMA_VERSION = 3;
const MAX_CLOCK_AHEAD_MS = 5 * 60 * 1000;
const DECISION_TTL_MS = 60 * 60 * 1000;
const DECISION_EXECUTION_TTL_MS = 60 * 60 * 1000;
const DECISION_CONTEXT_LIMIT = 8;
const DECISION_RETENTION_MS = 14 * 24 * 60 * 60 * 1000;
const DECISION_SENSITIVE_RETENTION_MS = 72 * 60 * 60 * 1000;
const DECISION_LEASE_MS = 5 * 60 * 1000;
const DECISION_MAX_EXECUTION_ATTEMPTS = 3;
const DECISION_RETRY_DELAYS_MS = [30 * 1000, 2 * 60 * 1000, 5 * 60 * 1000];
const ENROLLMENT_TTL_MS = 15 * 60 * 1000;
const INSTALLATION_TOKEN_PREFIX = 'iat';
const ACTIVATION_CODE_PREFIX = 'act';
const DATA_ENCRYPTION_KEY_VERSION = 1;
const CURRENT_SCHEMA_VERSION = 10;
const MAX_BACKUP_RECORD_BYTES = 256 * 1024;
// Keep a stage's memory cutover and credential reassignment in one D1 batch.
// Larger archives must be handled by an offline migration before stage merge.
const MAX_MEMORY_MERGE_STATEMENTS = 32;
const MAX_MEMORY_MERGE_MESSAGES = 1000;
const MAX_MEMORY_MERGE_JSON_BYTES = 1024 * 1024;
const AI_DEFAULT_MODEL = 'claude-sonnet-5';
const AI_ALLOWED_MODELS = new Set([
  'claude-sonnet-5',
]);
const AI_TASK_VALUES = new Set(['dialogue', 'dossier', 'vision']);
const AI_USAGE_RETENTION_DAYS = 90;
const AI_DEFAULT_INSTALLATION_DAILY_LIMIT_USD = 250;
const AI_DEFAULT_GLOBAL_DAILY_LIMIT_USD = 350;
// Общий лимит для одного сетевого отпечатка OKZ. Его можно переопределить
// переменной Worker OKZ_AI_DAILY_LIMIT_USD без передачи ключей в браузер.
const AI_DEFAULT_OKZ_DAILY_LIMIT_USD = 10;

const DECISION_TYPE_VALUES = new Set(['general', 'call']);
const DECISION_STATUS_VALUES = new Set([
  'pending',
  'resolved',
  'executed',
  'failed',
  'expired',
  'cancelled',
]);

const GENERAL_DECISION_ACTIONS = new Set([
  'send_recommendation',
  'ai_instruction',
  'custom_text',
  'manager_manual',
  'pause',
]);

const CALL_DECISION_ACTIONS = new Set([
  'confirm_time',
  'plus_2h',
  'custom_time',
  'decline',
  'manager_manual',
]);

const STATUS_VALUES = new Set([
  'unknown',
  'active',
  'waiting_ai',
  'waiting_client',
  'call',
  'human',
  'paused',
  'error',
]);

const LEVEL_VALUES = new Set([
  'debug',
  'info',
  'warn',
  'error',
]);

export default {
  async fetch(request, env) {
    const requestId = crypto.randomUUID();

    try {
      if (!env.DB) {
        return jsonResponse(
          { ok: false, error: 'DB_NOT_BOUND', request_id: requestId },
          503
        );
      }

      const url = new URL(request.url);
      const path = normalizePath(url.pathname);

      if (request.method === 'OPTIONS') {
        return new Response(null, {
          status: 204,
          headers: corsHeaders(),
        });
      }

      if (path === '/favicon.ico') {
        return new Response(null, { status: 204 });
      }

      await ensureServerSchema(env);

      if (request.method === 'GET' && path === '/') {
        return jsonResponse({
          ok: true,
          service: 'AI Assistant Monitor',
          version: WORKER_VERSION,
          schema_version: CURRENT_SCHEMA_VERSION,
          server_time: Date.now(),
          endpoints: [
            'POST /api/v1/heartbeat',
            'POST /api/v1/clients',
            'POST /api/v1/events',
            'POST /api/v1/snapshot',
            'POST /api/v1/max/backup/save',
            'POST /api/v1/max/backup/load',
            'POST /api/v1/telegram/backup/save',
            'POST /api/v1/telegram/backup/load',
            'POST /api/v1/whatsapp/backup/save',
            'POST /api/v1/whatsapp/backup/load',
            'POST /api/v1/decisions/request',
            'POST /api/v1/decisions/poll',
            'POST /api/v1/decisions/lease/renew',
            'POST /api/v1/decisions/prepared',
            'POST /api/v1/decisions/ack',
            'POST /api/v1/ai/messages',
            'POST /api/v1/ai/okz/messages',
            'POST /api/v1/audio/transcriptions',
            'GET /api/v1/config/legend',
            'GET /api/v1/config/catalog',
            'POST /api/v1/enroll',
            'GET /admin',
          ],
        });
      }

      if (request.method === 'GET' && path === '/health') {
        const check = await env.DB.prepare(
          'SELECT 1 AS healthy'
        ).first();

        return jsonResponse({
          ok: Boolean(check?.healthy),
          database: Boolean(check?.healthy),
          version: WORKER_VERSION,
          schema_version: CURRENT_SCHEMA_VERSION,
          server_time: Date.now(),
        });
      }

      // ---------- Легенда и каталог для активированных установок ----------
      // Клиент получает их по тому же персональному iat-токену, что и
      // heartbeat/AI Gateway. Отдельный Cloudflare ACCESS_TOKEN менеджеру
      // больше не нужен и в браузер не передаётся.
      const installationConfigRoutes = new Map([
        ['/api/v1/config/legend', 'legend'],
        ['/api/v1/config/catalog', 'catalog'],
      ]);

      if (installationConfigRoutes.has(path)) {
        if (request.method !== 'GET') {
          return jsonResponse(
            { ok: false, error: 'METHOD_NOT_ALLOWED' },
            405,
            true
          );
        }

        const installationAuth = await authenticateInstallation(
          request,
          env
        );

        if (!installationAuth) {
          return jsonResponse(
            { ok: false, error: 'UNAUTHORIZED' },
            401,
            true
          );
        }

        if (!env.CONFIG_KV || typeof env.CONFIG_KV.get !== 'function') {
          return jsonResponse(
            { ok: false, error: 'CONFIG_KV_NOT_BOUND' },
            503,
            true
          );
        }

        const configKey = installationConfigRoutes.get(path);
        const configText = await env.CONFIG_KV.get(configKey);

        if (
          typeof configText !== 'string' ||
          !configText.trim()
        ) {
          return jsonResponse(
            { ok: false, error: 'CONFIG_NOT_FOUND' },
            404,
            true
          );
        }

        return new Response(configText, {
          status: 200,
          headers: {
            'Content-Type': 'text/plain; charset=utf-8',
            'Cache-Control': 'private, no-store',
            ...corsHeaders(),
          },
        });
      }

      // Страница установки загрузчика: открывается кнопкой «Обновить»
      // в помощнике. Адрес задаёт администратор ключом loader_url_<платформа>,
      // чтобы ключ доступа не хранился в скриптах у менеджеров.
      if (request.method === 'GET' && path.startsWith('/loader/')) {
        const platform = path.slice('/loader/'.length);

        if (!VERSION_PLATFORMS.includes(platform)) {
          return new Response('Неизвестная платформа', {
            status: 404,
            headers: { 'Content-Type': 'text/plain; charset=utf-8' },
          });
        }

        const row = await env.DB.prepare(
          'SELECT value FROM app_settings WHERE key = ?'
        ).bind('loader_url_' + platform).first().catch(() => null);

        const target = String(row?.value || '').trim();

        if (!target) {
          return new Response(
            'Ссылка на загрузчик не настроена. Откройте значок Tampermonkey и нажмите «Проверить обновления».',
            { status: 404, headers: { 'Content-Type': 'text/plain; charset=utf-8' } }
          );
        }

        return Response.redirect(target, 302);
      }

      // ---------- Вход и сессии ----------
      if (request.method === 'POST' && path === '/admin/login') {
        return await handleUserLogin(request, env);
      }

      if (request.method === 'POST' && path === '/admin/logout') {
        return await handleUserLogout(request, env);
      }

      if (request.method === 'POST' && path === '/api/v1/enroll') {
        return await handleInstallationEnrollment(request, env);
      }

      if (path.startsWith('/admin') || path.startsWith('/api/admin/')) {
        await ensureAuthSchema(env);

        const sessionUser = await getSessionUser(request, env);

        if (!sessionUser) {
          return path.startsWith('/api/admin/')
            ? jsonResponse({ ok: false, error: 'UNAUTHORIZED' }, 401)
            : htmlResponse(loginPageHtml(), 200);
        }

        // Пока временный пароль не сменён — доступна только эта страница
        if (
          sessionUser.mustChangePassword &&
          path !== '/admin/password'
        ) {
          return path.startsWith('/api/admin/')
            ? jsonResponse({ ok: false, error: 'PASSWORD_CHANGE_REQUIRED' }, 403)
            : new Response(null, {
                status: 303,
                headers: { Location: '/admin/password', ...securityHeaders() },
              });
        }

        if (path === '/admin/password') {
          if (request.method === 'POST') {
            return await handlePasswordChange(request, env);
          }

          return htmlResponse(passwordPageHtml(sessionUser), 200);
        }

        // ---------- Управление пользователями: только admin ----------
        if (
          path.startsWith('/admin/users') ||
          path === '/admin/audit' ||
          path.startsWith('/admin/installations/security')
        ) {
          if (!canManageUsers(sessionUser)) {
            return htmlResponse(
              '<p style="font-family:sans-serif;color:#fff;background:#07111f;padding:24px">' +
              'Раздел доступен только администратору. <a href="/admin">Назад</a></p>',
              403
            );
          }

          if (path === '/admin/audit') {
            return await handleAuditPage(request, env, sessionUser);
          }

          if (path.startsWith('/admin/installations/security')) {
            return await handleInstallationSecurityPage(
              request,
              env,
              sessionUser
            );
          }

          if (request.method === 'POST' && path === '/admin/users/create') {
            return await handleUserCreate(request, env, sessionUser);
          }

          if (request.method === 'POST' && path === '/admin/users/update') {
            return await handleUserUpdate(request, env, sessionUser);
          }

          return await handleUsersPage(request, env, sessionUser);
        }

        // Стоп-кран: доступен только администратору
        if (path === '/admin/global-stop' && request.method === 'POST') {
          if (!canManageUsers(sessionUser)) {
            return jsonResponse({ ok: false, error: 'FORBIDDEN' }, 403, true);
          }

          if (!isSameOriginRequest(request)) {
            return jsonResponse({ ok: false, error: 'BAD_ORIGIN' }, 403, true);
          }

          const body = await readJsonBody(request);
          const stopped = body?.stopped === true;

          await writeGlobalStop(env, stopped, sessionUser);
          await writeAudit(
            env,
            sessionUser,
            stopped ? 'global_stop_on' : 'global_stop_off',
            '',
            request
          );

          const state = await readGlobalStop(env);
          return jsonResponse({ ok: true, global_stop: state }, 200, true);
        }

        // Удаление устаревших подключений одного менеджера.
        // При переустановке загрузчика скрипт получает новый installation_id,
        // а прежняя запись остаётся в базе и дублирует платформу в карточке.
        if (path === '/admin/installations/forget' && request.method === 'POST') {
          if (!canManageUsers(sessionUser)) {
            return jsonResponse({ ok: false, error: 'FORBIDDEN' }, 403, true);
          }

          if (!isSameOriginRequest(request)) {
            return jsonResponse({ ok: false, error: 'BAD_ORIGIN' }, 403, true);
          }

          const body = await readJsonBody(request);
          const requestedIds = Array.isArray(body?.installation_ids)
            ? body.installation_ids.map((item) => cleanText(item, 160)).filter(Boolean)
            : [];
          // Один административный запрос не должен раздуваться до сотен
          // D1-операций. Для обычной карточки здесь 1-3 старых установки.
          const ids = [...new Set(requestedIds)].slice(0, 8);

          if (!ids.length) {
            return jsonResponse({ ok: false, error: 'IDS_REQUIRED' }, 400, true);
          }

          let removed = 0;
          let revoked = 0;
          const now = Date.now();

          for (const installationId of ids) {
            // Простое удаление строки installations было небезопасным:
            // персональный iat оставался active и старый скрипт мог снова
            // создать подключение следующим heartbeat. Сначала отзываем токен.
            const credentialResult = await env.DB.prepare(`
              UPDATE installation_credentials
              SET status = 'revoked', revoked_at = ?, updated_at = ?
              WHERE installation_id = ? AND status <> 'revoked'
            `).bind(now, now, installationId).run();

            revoked += Number(credentialResult?.meta?.changes || 0);

            // Незавершённое решение старой установки больше некому исполнять.
            // Отменяем его, снимаем аренду и сразу очищаем чувствительный текст.
            await env.DB.prepare(`
              UPDATE decision_requests
              SET status = 'cancelled',
                  execution_status = 'installation_forgotten',
                  execution_error = 'INSTALLATION_FORGOTTEN',
                  lease_owner = '', lease_token_hash = '', lease_expires_at = 0,
                  reason = '', manager_task = '', recommendation = '',
                  context_json = '[]', resolution_text = '',
                  sensitive_ciphertext = '', sensitive_iv = '',
                  sensitive_key_version = 0, sensitive_purged_at = ?,
                  updated_at = ?
              WHERE installation_id = ? AND status IN ('pending', 'resolved')
            `).bind(now, now, installationId).run();

            await env.DB.prepare(
              'DELETE FROM client_states WHERE installation_id = ?'
            ).bind(installationId).run();

            await env.DB.prepare(
              'DELETE FROM installation_stops WHERE installation_id = ?'
            ).bind(installationId).run().catch(() => {});

            const result = await env.DB.prepare(
              'DELETE FROM installations WHERE installation_id = ?'
            ).bind(installationId).run();

            removed += Number(result?.meta?.changes || 0);
          }

          await writeAudit(
            env,
            sessionUser,
            'installations_forgotten',
            ids.join(', ').slice(0, 200),
            request
          );

          return jsonResponse({
            ok: true,
            removed,
            revoked,
            processed: ids.length,
            skipped: Math.max(0, requestedIds.length - ids.length),
          }, 200, true);
        }

        // Индивидуальная остановка установки: только администратор
        if (path === '/admin/installation-stop' && request.method === 'POST') {
          if (!canManageUsers(sessionUser)) {
            return jsonResponse({ ok: false, error: 'FORBIDDEN' }, 403, true);
          }

          if (!isSameOriginRequest(request)) {
            return jsonResponse({ ok: false, error: 'BAD_ORIGIN' }, 403, true);
          }

          const body = await readJsonBody(request);
          const installationId = cleanText(body?.installation_id, 160);
          const stopped = body?.stopped === true;

          if (!installationId) {
            return jsonResponse({ ok: false, error: 'INSTALLATION_REQUIRED' }, 400, true);
          }

          await writeInstallationStop(env, installationId, stopped, sessionUser);
          await writeAudit(
            env,
            sessionUser,
            stopped ? 'installation_stop_on' : 'installation_stop_off',
            installationId,
            request
          );

          return jsonResponse({ ok: true, installation_id: installationId, stopped }, 200, true);
        }

        // Адрес и ключ конфиг-воркера для сборки загрузчиков
        if (path === '/admin/loader-config' && request.method === 'POST') {
          if (!canManageUsers(sessionUser)) {
            return jsonResponse({ ok: false, error: 'FORBIDDEN' }, 403, true);
          }

          if (!isSameOriginRequest(request)) {
            return jsonResponse({ ok: false, error: 'BAD_ORIGIN' }, 403, true);
          }

          const body = await readJsonBody(request);

          for (const [settingKey, value] of [
            ['config_base_url', cleanText(body?.base, 300)],
            ['config_key', cleanText(body?.key, 200)],
          ]) {
            await env.DB.prepare(`
              INSERT INTO app_settings (key, value, updated_at, updated_by)
              VALUES (?, ?, ?, ?)
              ON CONFLICT(key) DO UPDATE SET
                value = excluded.value,
                updated_at = excluded.updated_at,
                updated_by = excluded.updated_by
            `).bind(
              settingKey,
              value,
              Date.now(),
              cleanText(sessionUser?.login, 80)
            ).run();
          }

          await writeAudit(env, sessionUser, 'loader_config_updated', '', request);

          return jsonResponse({ ok: true }, 200, true);
        }

        // Страница со всеми загрузчиками: удобно подключать менеджеров
        if (path === '/admin/loaders' && request.method === 'GET') {
          if (!canManageUsers(sessionUser)) {
            return htmlResponse(
              '<p style="font-family:sans-serif;color:#fff;background:#07111f;padding:24px">' +
              'Раздел доступен только администратору. <a href="/admin">Назад</a></p>',
              403
            );
          }

          return handleLoadersPage(request, env, sessionUser);
        }

        // Напоминание о перезапуске браузера: задаёт только администратор
        if (path === '/admin/restart-reminder' && request.method === 'POST') {
          if (!canManageUsers(sessionUser)) {
            return jsonResponse({ ok: false, error: 'FORBIDDEN' }, 403, true);
          }

          if (!isSameOriginRequest(request)) {
            return jsonResponse({ ok: false, error: 'BAD_ORIGIN' }, 403, true);
          }

          const body = await readJsonBody(request);

          await writeRestartReminder(env, {
            enabled: body?.enabled === true,
            time: cleanText(body?.time, 5),
            text: cleanText(body?.text, 400),
          }, sessionUser);

          await writeAudit(
            env,
            sessionUser,
            'restart_reminder_updated',
            (body?.enabled ? 'вкл ' : 'выкл ') + cleanText(body?.time, 5),
            request
          );

          return jsonResponse(
            { ok: true, restart_reminder: await readRestartReminder(env) },
            200,
            true
          );
        }

        // Актуальные версии скриптов: задаёт только администратор
        if (path === '/admin/versions' && request.method === 'POST') {
          if (!canManageUsers(sessionUser)) {
            return jsonResponse({ ok: false, error: 'FORBIDDEN' }, 403, true);
          }

          if (!isSameOriginRequest(request)) {
            return jsonResponse({ ok: false, error: 'BAD_ORIGIN' }, 403, true);
          }

          const body = await readJsonBody(request);
          const applied = {};

          for (const platform of VERSION_PLATFORMS) {
            if (typeof body?.[platform] === 'string') {
              await writeLatestVersion(env, platform, body[platform], sessionUser);
              applied[platform] = cleanText(body[platform], 20);
            }

            const loaderKey = 'loader_' + platform;
            if (typeof body?.[loaderKey] === 'string') {
              await env.DB.prepare(`
                INSERT INTO app_settings (key, value, updated_at, updated_by)
                VALUES (?, ?, ?, ?)
                ON CONFLICT(key) DO UPDATE SET
                  value = excluded.value,
                  updated_at = excluded.updated_at,
                  updated_by = excluded.updated_by
              `).bind(
                'loader_url_' + platform,
                cleanText(body[loaderKey], 500),
                Date.now(),
                cleanText(sessionUser?.login, 80)
              ).run();
            }
          }

          await writeAudit(
            env,
            sessionUser,
            'versions_updated',
            Object.entries(applied).map(([k, v]) => k + '=' + v).join(', '),
            request
          );

          return jsonResponse(
            { ok: true, versions: await readLatestVersions(env) },
            200,
            true
          );
        }

        if (path === '/admin') {
          await ensureDecisionSchema(env);
          return htmlResponse(dashboardHtml(sessionUser), 200);
        }

        // ---------- Данные ----------
        if (path.startsWith('/api/admin/')) {
          // Решения принимаются через POST. Проверяем их до общего
          // ограничения административных API только методом GET.
          if (
            request.method === 'POST' &&
            path === '/api/admin/decisions/resolve'
          ) {
            await ensureDecisionSchema(env);
            return await handleAdminDecisionResolve(
              request,
              env,
              sessionUser
            );
          }

          if (
            request.method === 'POST' &&
            path === '/api/admin/decisions/test'
          ) {
            if (!canManageUsers(sessionUser)) {
              return jsonResponse({ ok: false, error: 'FORBIDDEN' }, 403);
            }

            await ensureDecisionSchema(env);
            return await handleAdminDecisionTest(
              request,
              env,
              sessionUser
            );
          }

          if (
            request.method === 'POST' &&
            path === '/api/admin/installations/invite'
          ) {
            if (!canManageUsers(sessionUser)) {
              return jsonResponse({ ok: false, error: 'FORBIDDEN' }, 403);
            }
            return await handleAdminInstallationInvite(
              request,
              env,
              sessionUser
            );
          }

          if (
            request.method === 'POST' &&
            path === '/api/admin/stages/merge'
          ) {
            if (!canManageUsers(sessionUser)) {
              return jsonResponse({ ok: false, error: 'FORBIDDEN' }, 403);
            }
            return await handleAdminStageMerge(
              request,
              env,
              sessionUser
            );
          }

          if (
            request.method === 'POST' &&
            path === '/api/admin/installations/rotate'
          ) {
            if (!canManageUsers(sessionUser)) {
              return jsonResponse({ ok: false, error: 'FORBIDDEN' }, 403);
            }
            return await handleAdminInstallationRotate(
              request,
              env,
              sessionUser
            );
          }

          if (
            request.method === 'POST' &&
            path === '/api/admin/installations/revoke'
          ) {
            if (!canManageUsers(sessionUser)) {
              return jsonResponse({ ok: false, error: 'FORBIDDEN' }, 403);
            }
            return await handleAdminInstallationRevoke(
              request,
              env,
              sessionUser
            );
          }

          if (request.method !== 'GET') {
            return jsonResponse(
              { ok: false, error: 'METHOD_NOT_ALLOWED' },
              405
            );
          }

          if (path === '/api/admin/summary') {
            return await handleAdminSummary(env, sessionUser);
          }

          if (path === '/api/admin/installations') {
            return await handleAdminInstallations(env, sessionUser);
          }

          if (path === '/api/admin/installations/security') {
            if (!canManageUsers(sessionUser)) {
              return jsonResponse({ ok: false, error: 'FORBIDDEN' }, 403);
            }
            return await handleAdminInstallationSecurity(env);
          }

          if (path === '/api/admin/clients') {
            return await handleAdminClients(env, url, sessionUser);
          }

          if (path === '/api/admin/decisions') {
            await ensureDecisionSchema(env);
            return await handleAdminDecisions(env, url, sessionUser);
          }

          if (path === '/api/admin/events') {
            // Подробные логи ошибок доступны только администратору
            if (!canSeeErrors(sessionUser)) {
              return jsonResponse({ ok: false, error: 'FORBIDDEN' }, 403);
            }

            return await handleAdminEvents(env, url, sessionUser);
          }

          if (path === '/api/admin/export') {
            if (!canSeeErrors(sessionUser)) {
              return jsonResponse({ ok: false, error: 'FORBIDDEN' }, 403);
            }

            await ensureDecisionSchema(env);
            await writeAudit(env, sessionUser, 'export', url.search, request);
            return await handleAdminExport(env, url, sessionUser);
          }

          return jsonResponse({ ok: false, error: 'NOT_FOUND' }, 404);
        }

        return jsonResponse({ ok: false, error: 'NOT_FOUND' }, 404);
      }

      // ---------- Упрощённый OKZ API без ключей в браузере ----------
      // Anthropic API key остаётся только в Secret Worker. Маршрут имеет
      // отдельный небольшой размер запроса, rate limit и дневной бюджет.
      if (path === '/api/v1/ai/okz/messages') {
        if (request.method !== 'POST') {
          return jsonResponse(
            { ok: false, error: 'METHOD_NOT_ALLOWED' },
            405,
            true
          );
        }

        const clientMarker = cleanText(
          request.headers.get('X-OKZ-Client'),
          80
        );
        if (!/^OKZ-AI-Assistant\/\d+\.\d+\.\d+$/.test(clientMarker)) {
          return jsonResponse(
            { ok: false, error: 'OKZ_CLIENT_REQUIRED' },
            403,
            true
          );
        }

        const okzIdentity = await createOkzPublicIdentity(request);

        await enforceRateLimit(
          env.AI_RATE_LIMITER || env.API_RATE_LIMITER,
          `okz:${okzIdentity.credential_id}`,
          'AI_RATE_LIMITED'
        );
        await enforceOkzDailyBudget(env, okzIdentity);

        const payload = await readJsonBodyWithLimit(
          request,
          MAX_OKZ_AI_BODY_BYTES
        );

        return await handleAiGatewayMessage(
          env,
          {
            ...payload,
            task: 'dossier',
            installation_id: okzIdentity.installation_id,
            max_tokens: 900,
          },
          okzIdentity,
          requestId
        );
      }

      // ---------- Централизованный Anthropic API Gateway ----------
      // Ключ Anthropic хранится только в Secret Worker. Клиентский скрипт
      // допускается сюда исключительно по персональному iat-токену.
      if (path === '/api/v1/ai/messages') {
        if (request.method !== 'POST') {
          return jsonResponse(
            { ok: false, error: 'METHOD_NOT_ALLOWED' },
            405,
            true
          );
        }

        const installationAuth = await authenticateInstallation(request, env);
        if (!installationAuth) {
          return jsonResponse(
            { ok: false, error: 'UNAUTHORIZED' },
            401,
            true
          );
        }

        await enforceRateLimit(
          env.AI_RATE_LIMITER || env.API_RATE_LIMITER,
          `ai:${installationAuth.credential_id}`,
          'AI_RATE_LIMITED'
        );

        const payload = await readJsonBodyWithLimit(
          request,
          MAX_AI_BODY_BYTES
        );
        assertPayloadInstallation(
          payload,
          installationAuth.installation_id
        );

        return await handleAiGatewayMessage(
          env,
          payload,
          installationAuth,
          requestId
        );
      }

      // ---------- Серверная расшифровка голосовых ----------
      // В браузере менеджера нет Groq-ключа. Аудио проксируется напрямую
      // в Groq по персональному iat-токену и нигде не сохраняется.
      if (path === '/api/v1/audio/transcriptions') {
        if (request.method !== 'POST') {
          return jsonResponse(
            { ok: false, error: 'METHOD_NOT_ALLOWED' },
            405,
            true
          );
        }

        const installationAuth = await authenticateInstallation(request, env);
        if (!installationAuth) {
          return jsonResponse(
            { ok: false, error: 'UNAUTHORIZED' },
            401,
            true
          );
        }

        await enforceRateLimit(
          env.AI_RATE_LIMITER || env.API_RATE_LIMITER,
          `audio:${installationAuth.credential_id}`,
          'AUDIO_RATE_LIMITED'
        );

        return await handleAudioTranscription(
          request,
          env,
          installationAuth,
          requestId
        );
      }

      const decisionRoutes = new Set([
        '/api/v1/decisions/request',
        '/api/v1/decisions/poll',
        '/api/v1/decisions/lease/renew',
        '/api/v1/decisions/prepared',
        '/api/v1/decisions/ack',
      ]);

      if (decisionRoutes.has(path)) {
        if (request.method !== 'POST') {
          return jsonResponse(
            { ok: false, error: 'METHOD_NOT_ALLOWED' },
            405,
            true
          );
        }

        const installationAuth = await authenticateInstallation(request, env);
        if (!installationAuth) {
          return jsonResponse(
            { ok: false, error: 'UNAUTHORIZED' },
            401,
            true
          );
        }

        await enforceRateLimit(
          env.API_RATE_LIMITER,
          `decision:${installationAuth.credential_id}`,
          'API_RATE_LIMITED'
        );

        const payload = await readJsonBody(request);
        assertPayloadInstallation(payload, installationAuth.installation_id);

        if (path.endsWith('/request')) {
          return await handleDecisionRequest(env, payload, installationAuth);
        }

        if (path.endsWith('/poll')) {
          return await handleDecisionPoll(env, payload, installationAuth);
        }

        if (path.endsWith('/lease/renew')) {
          return await handleDecisionLeaseRenew(env, payload, installationAuth);
        }

        if (path.endsWith('/prepared')) {
          return await handleDecisionPrepared(env, payload, installationAuth);
        }

        return await handleDecisionAck(env, payload, installationAuth);
      }

      const memoryRoute = path.match(/^\/api\/v1\/memory\/(preview|create-or-attach|get-context|append-confirmed|unlink|delete-memory)$/);
      if (memoryRoute) {
        if (request.method !== 'POST') return jsonResponse({ ok: false, error: 'METHOD_NOT_ALLOWED' }, 405, true);
        const identity = await authenticateInstallation(request, env);
        if (!identity) return jsonResponse({ ok: false, error: 'UNAUTHORIZED' }, 401, true);
        assertMemoryIdentity(identity);
        await enforceRateLimit(env.API_RATE_LIMITER, `memory:${identity.credential_id}`, 'API_RATE_LIMITED');
        const payload = await readJsonBodyWithLimit(request, MAX_BODY_BYTES);
        assertPayloadInstallation(payload, identity.installation_id);
        const handlers = {preview:handleMemoryPreview,'create-or-attach':handleMemoryCreateOrAttach,'get-context':handleMemoryGetContext,'append-confirmed':handleMemoryAppendConfirmed,'unlink':handleMemoryUnlink,'delete-memory':handleMemoryDelete};
        return await handlers[memoryRoute[1]](env, identity, payload);
      }

      const backupRoute = path.match(/^\/api\/v1\/(max|telegram|whatsapp|gemspace)\/backup\/(save|load)$/);
      if (backupRoute) {
        if (request.method !== 'POST') {
          return jsonResponse({ ok: false, error: 'METHOD_NOT_ALLOWED' }, 405, true);
        }
        const identity = await authenticateInstallation(request, env);
        if (!identity) {
          return jsonResponse({ ok: false, error: 'UNAUTHORIZED' }, 401, true);
        }
        if (String(identity.platform).toLowerCase() !== backupRoute[1] || !identity.manager_id) {
          throw new PublicError('BACKUP_STAGE_REQUIRED', 403);
        }
        await enforceRateLimit(
          env.API_RATE_LIMITER,
          `backup:${identity.credential_id}`,
          'API_RATE_LIMITED'
        );
        const payload = await readJsonBodyWithLimit(request, MAX_BODY_BYTES);
        assertPayloadInstallation(payload, identity.installation_id);
        return backupRoute[2] === 'save'
          ? handleMaxBackupSave(env, identity, payload)
          : handleMaxBackupLoad(env, identity, payload);
      }

      const ingestRoutes = new Set([
        '/heartbeat',
        '/clients',
        '/events',
        '/snapshot',
        '/api/v1/heartbeat',
        '/api/v1/clients',
        '/api/v1/events',
        '/api/v1/snapshot',
      ]);

      if (ingestRoutes.has(path)) {
        if (request.method !== 'POST') {
          return jsonResponse(
            { ok: false, error: 'METHOD_NOT_ALLOWED' },
            405,
            true
          );
        }

        const ingestAuth = await authenticateIngestRequest(request, env);
        if (!ingestAuth) {
          return jsonResponse(
            { ok: false, error: 'UNAUTHORIZED' },
            401,
            true
          );
        }


        await enforceRateLimit(
          env.API_RATE_LIMITER,
          ingestAuth.auth_mode === 'installation'
            ? `ingest:${ingestAuth.credential_id}`
            : `legacy:${request.headers.get('CF-Connecting-IP') || 'unknown'}`,
          'API_RATE_LIMITED'
        );

        const payload = await readJsonBody(request);
        let trustedPayload = bindIngestIdentity(payload, ingestAuth);

        // Старый общий INGEST_TOKEN не содержит серверной личности.
        // Если стейдж ранее объединён с правильным, применяем псевдоним
        // ещё до записи heartbeat: ошибочное имя не сможет появиться снова.
        if (ingestAuth.auth_mode === 'legacy') {
          trustedPayload = await canonicalizeLegacyIngestPayload(
            env,
            trustedPayload
          );
        }

        if (path.endsWith('/heartbeat') || path === '/heartbeat') {
          return await handleHeartbeat(env, trustedPayload);
        }

        if (path.endsWith('/clients') || path === '/clients') {
          return await handleClients(env, trustedPayload);
        }

        if (path.endsWith('/events') || path === '/events') {
          return await handleEvents(env, trustedPayload);
        }

        return await handleSnapshot(env, trustedPayload);
      }

      return jsonResponse(
        { ok: false, error: 'NOT_FOUND', request_id: requestId },
        404
      );
    } catch (error) {
      const publicError =
        error instanceof PublicError
          ? error
          : new PublicError('INTERNAL_ERROR', 500);

      // Ожидаемые 4xx (например, второй клик по уже решённой карточке)
      // возвращаются интерфейсу как JSON, но не засоряют журнал Worker ошибками.
      if (!(error instanceof PublicError) || publicError.status >= 500) {
        console.error('monitor_request_failed', {
          request_id: requestId,
          name: cleanText(error?.name || 'Error', 80),
          message: redactSensitive(error?.message || String(error), 500),
        });
      }

      return jsonResponse(
        {
          ok: false,
          error: publicError.code,
          request_id: requestId,
        },
        publicError.status,
        true
      );
    }
  },

  // Ретеншн. Требует cron-триггер в wrangler.toml, например:
  // [triggers] crons = ["0 3 * * *"]
  async scheduled(event, env, ctx) {
    ctx.waitUntil(runRetention(env));
  },
};

// События старше 14 дней, установки без сигнала 30 дней,
// протухшие сессии и старый аудит удаляются автоматически.
async function runRetention(env) {
  const now = Date.now();
  const eventsBefore = now - 14 * 24 * 60 * 60 * 1000;
  const decisionsBefore = now - DECISION_RETENTION_MS;
  const sensitiveBefore = now - DECISION_SENSITIVE_RETENTION_MS;
  const installationsBefore = now - INSTALLATION_RETENTION_MS;
  const auditBefore = now - 90 * 24 * 60 * 60 * 1000;
  const aiUsageBeforeDay = moscowDayKey(
    now - AI_USAGE_RETENTION_DAYS * 24 * 60 * 60 * 1000
  );

  try {
    await ensureServerSchema(env);

    await expireDecisionRequests(env, now);

    await env.DB.prepare(
      'DELETE FROM event_logs WHERE created_at < ?'
    ).bind(eventsBefore).run();

    await env.DB.prepare(
      'DELETE FROM ai_usage WHERE usage_day < ?'
    ).bind(aiUsageBeforeDay).run();

    const stale = await env.DB.prepare(
      'SELECT installation_id FROM installations WHERE last_seen_at < ?'
    ).bind(installationsBefore).all();

    for (const row of stale.results || []) {
      await env.DB.prepare(
        'DELETE FROM client_states WHERE installation_id = ?'
      ).bind(row.installation_id).run();

      await env.DB.prepare(
        'DELETE FROM installations WHERE installation_id = ?'
      ).bind(row.installation_id).run();
    }

    await env.DB.prepare(
      'DELETE FROM user_sessions WHERE expires_at < ?'
    ).bind(now).run();

    await env.DB.prepare(
      'DELETE FROM audit_log WHERE at < ?'
    ).bind(auditBefore).run();

    await env.DB.prepare(`
      UPDATE decision_requests
      SET reason = '', manager_task = '', recommendation = '',
          context_json = '[]', resolution_text = '',
          sensitive_ciphertext = '', sensitive_iv = '',
          sensitive_key_version = 0, sensitive_purged_at = ?
      WHERE updated_at < ?
        AND sensitive_purged_at = 0
        AND status IN ('executed', 'failed', 'expired', 'cancelled')
    `).bind(now, sensitiveBefore).run();

    await env.DB.prepare(
      'DELETE FROM decision_events WHERE at < ?'
    ).bind(decisionsBefore).run();

    await env.DB.prepare(`
      DELETE FROM decision_requests
      WHERE updated_at < ?
        AND status IN ('executed', 'failed', 'expired', 'cancelled')
    `).bind(decisionsBefore).run();

    await env.DB.prepare(`
      UPDATE enrollment_codes
      SET status = 'expired'
      WHERE status = 'active' AND expires_at < ?
    `).bind(now).run();

    await env.DB.prepare(`
      DELETE FROM enrollment_codes
      WHERE created_at < ? AND status IN ('used', 'expired')
    `).bind(now - 30 * 24 * 60 * 60 * 1000).run();
  } catch (error) {
    // Ретеншн не должен ломать приём данных, но ошибка должна остаться в логах.
    console.error('monitor_retention_failed', {
      name: cleanText(error?.name || 'Error', 80),
      message: redactSensitive(error?.message || String(error), 500),
    });
  }
}

class PublicError extends Error {
  constructor(code, status = 400) {
    super(code);
    this.code = code;
    this.status = status;
  }
}

// ================================================================
// ЦЕНТР РЕШЕНИЙ: КРАСНАЯ ЗОНА И ЗВОНКИ
// Скрипты создают запросы и забирают решения через /api/v1/decisions/*.
// Тимлидер, хед и администратор работают с ними на защищённом сайте.
// ================================================================
let serverSchemaPromise = null;

async function ensureServerSchema(env) {
  if (!serverSchemaPromise) {
    serverSchemaPromise = initializeServerSchema(env).catch((error) => {
      serverSchemaPromise = null;
      throw error;
    });
  }

  return serverSchemaPromise;
}

async function initializeServerSchema(env) {
  const coreStatements = [
    `CREATE TABLE IF NOT EXISTS schema_migrations (
      version INTEGER PRIMARY KEY,
      applied_at INTEGER NOT NULL
    )`,
    `CREATE TABLE IF NOT EXISTS installations (
      installation_id TEXT PRIMARY KEY,
      manager_id TEXT NOT NULL DEFAULT '',
      manager_name TEXT NOT NULL DEFAULT '',
      stage TEXT NOT NULL DEFAULT '',
      platform TEXT NOT NULL DEFAULT '',
      script_version TEXT NOT NULL DEFAULT '',
      session_id TEXT NOT NULL DEFAULT '',
      state TEXT NOT NULL DEFAULT 'unknown',
      paused INTEGER NOT NULL DEFAULT 0,
      busy INTEGER NOT NULL DEFAULT 0,
      online INTEGER NOT NULL DEFAULT 0,
      page_visible INTEGER NOT NULL DEFAULT 0,
      page_hidden_since INTEGER NOT NULL DEFAULT 0,
      clients_total INTEGER NOT NULL DEFAULT 0,
      clients_active INTEGER NOT NULL DEFAULT 0,
      started_at INTEGER NOT NULL DEFAULT 0,
      last_seen_at INTEGER NOT NULL DEFAULT 0,
      last_reason TEXT NOT NULL DEFAULT '',
      created_at INTEGER NOT NULL DEFAULT 0,
      updated_at INTEGER NOT NULL DEFAULT 0
    )`,
    `CREATE TABLE IF NOT EXISTS client_states (
      installation_id TEXT NOT NULL,
      client_key TEXT NOT NULL,
      client_label TEXT NOT NULL DEFAULT '',
      platform TEXT NOT NULL DEFAULT '',
      enabled INTEGER NOT NULL DEFAULT 1,
      status TEXT NOT NULL DEFAULT 'unknown',
      on_call INTEGER NOT NULL DEFAULT 0,
      awaiting_human INTEGER NOT NULL DEFAULT 0,
      last_in_at INTEGER NOT NULL DEFAULT 0,
      last_out_at INTEGER NOT NULL DEFAULT 0,
      last_sender TEXT NOT NULL DEFAULT '',
      unanswered_since INTEGER NOT NULL DEFAULT 0,
      next_action_at INTEGER NOT NULL DEFAULT 0,
      last_success_at INTEGER NOT NULL DEFAULT 0,
      last_error_code TEXT NOT NULL DEFAULT '',
      last_trace_id TEXT NOT NULL DEFAULT '',
      updated_at INTEGER NOT NULL DEFAULT 0,
      PRIMARY KEY (installation_id, client_key)
    )`,
    `CREATE TABLE IF NOT EXISTS event_logs (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      event_id TEXT NOT NULL UNIQUE,
      trace_id TEXT NOT NULL DEFAULT '',
      installation_id TEXT NOT NULL,
      client_key TEXT NOT NULL DEFAULT '',
      manager_id TEXT NOT NULL DEFAULT '',
      stage TEXT NOT NULL DEFAULT '',
      platform TEXT NOT NULL DEFAULT '',
      script_version TEXT NOT NULL DEFAULT '',
      event_type TEXT NOT NULL DEFAULT 'diagnostic',
      step TEXT NOT NULL DEFAULT '',
      level TEXT NOT NULL DEFAULT 'info',
      code TEXT NOT NULL DEFAULT '',
      message TEXT NOT NULL DEFAULT '',
      details_json TEXT NOT NULL DEFAULT '{}',
      created_at INTEGER NOT NULL,
      received_at INTEGER NOT NULL
    )`,
    `CREATE TABLE IF NOT EXISTS installation_credentials (
      credential_id TEXT PRIMARY KEY,
      installation_id TEXT NOT NULL UNIQUE,
      token_hash TEXT NOT NULL,
      activation_code_id TEXT NOT NULL DEFAULT '',
      status TEXT NOT NULL DEFAULT 'active',
      manager_id TEXT NOT NULL DEFAULT '',
      manager_name TEXT NOT NULL DEFAULT '',
      stage TEXT NOT NULL DEFAULT '',
      platform TEXT NOT NULL DEFAULT '',
      created_at INTEGER NOT NULL,
      updated_at INTEGER NOT NULL,
      last_used_at INTEGER NOT NULL DEFAULT 0,
      rotated_at INTEGER NOT NULL DEFAULT 0,
      revoked_at INTEGER NOT NULL DEFAULT 0,
      last_ip TEXT NOT NULL DEFAULT '',
      last_user_agent TEXT NOT NULL DEFAULT ''
    )`,
    `CREATE TABLE IF NOT EXISTS enrollment_codes (
      code_id TEXT PRIMARY KEY,
      code_hash TEXT NOT NULL,
      status TEXT NOT NULL DEFAULT 'active',
      manager_id TEXT NOT NULL DEFAULT '',
      manager_name TEXT NOT NULL DEFAULT '',
      stage TEXT NOT NULL DEFAULT '',
      platform TEXT NOT NULL DEFAULT '',
      created_by INTEGER NOT NULL DEFAULT 0,
      created_login TEXT NOT NULL DEFAULT '',
      created_at INTEGER NOT NULL,
      expires_at INTEGER NOT NULL,
      used_at INTEGER NOT NULL DEFAULT 0,
      installation_id TEXT NOT NULL DEFAULT ''
    )`,
    `CREATE TABLE IF NOT EXISTS manager_stages (
      manager_id TEXT PRIMARY KEY,
      canonical_stage TEXT NOT NULL,
      stage_norm TEXT NOT NULL UNIQUE,
      active INTEGER NOT NULL DEFAULT 1,
      created_at INTEGER NOT NULL,
      updated_at INTEGER NOT NULL
    )`,
    `CREATE TABLE IF NOT EXISTS manager_stage_aliases (
      alias_norm TEXT PRIMARY KEY,
      manager_id TEXT NOT NULL,
      canonical_stage TEXT NOT NULL,
      created_at INTEGER NOT NULL,
      updated_at INTEGER NOT NULL
    )`,
    `CREATE TABLE IF NOT EXISTS max_stage_backups (
      manager_id TEXT NOT NULL,
      platform TEXT NOT NULL,
      record_key TEXT NOT NULL,
      ciphertext TEXT NOT NULL,
      iv TEXT NOT NULL,
      key_version INTEGER NOT NULL,
      updated_at INTEGER NOT NULL,
      PRIMARY KEY (manager_id, platform, record_key)
    )`,
    `CREATE INDEX IF NOT EXISTS idx_installations_last_seen
      ON installations(last_seen_at DESC)`,
    `CREATE INDEX IF NOT EXISTS idx_installations_stage
      ON installations(stage, last_seen_at DESC)`,
    `CREATE INDEX IF NOT EXISTS idx_clients_status
      ON client_states(status, updated_at DESC)`,
    `CREATE INDEX IF NOT EXISTS idx_events_created
      ON event_logs(created_at DESC)`,
    `CREATE INDEX IF NOT EXISTS idx_events_installation
      ON event_logs(installation_id, created_at DESC)`,
    `CREATE INDEX IF NOT EXISTS idx_manager_stages_active
      ON manager_stages(active, canonical_stage)`,
  ];

  // D1 Free допускает ограниченное число запросов за один вызов Worker.
  // Сначала получаем каталог SQLite и создаём только реально отсутствующие
  // объекты, а не повторяем все CREATE IF NOT EXISTS на каждом cold start.
  await env.DB.prepare(coreStatements[0]).run();
  const objectRows = await env.DB.prepare(`
    SELECT name FROM sqlite_master WHERE type IN ('table', 'index')
  `).all();
  const existingObjects = new Set(
    (objectRows.results || []).map((row) => String(row.name).toLowerCase())
  );

  for (const sql of coreStatements.slice(1)) {
    const match = sql.match(
      /CREATE\s+(?:UNIQUE\s+)?(?:TABLE|INDEX)\s+IF\s+NOT\s+EXISTS\s+([a-z0-9_]+)/i
    );
    const objectName = match?.[1]?.toLowerCase() || '';
    if (objectName && existingObjects.has(objectName)) continue;
    await env.DB.prepare(sql).run();
    if (objectName) existingObjects.add(objectName);
  }

  await ensureAuthSchema(env);
  await ensureDecisionSchema(env);
  await applySchemaMigrations(env);
}

async function tableColumns(env, tableName) {
  const safeName = String(tableName).replace(/[^a-z0-9_]/gi, '');
  const result = await env.DB.prepare(`PRAGMA table_info(${safeName})`).all();
  return new Set((result.results || []).map((row) => String(row.name)));
}

async function addColumnsIfMissing(env, tableName, definitions) {
  let columns = await tableColumns(env, tableName);
  const safeTable = String(tableName).replace(/[^a-z0-9_]/gi, '');

  for (const [columnName, definition] of definitions) {
    if (columns.has(columnName)) continue;
    const safeColumn = String(columnName).replace(/[^a-z0-9_]/gi, '');
    try {
      await env.DB.prepare(
        `ALTER TABLE ${safeTable} ADD COLUMN ${safeColumn} ${definition}`
      ).run();
    } catch (error) {
      const refreshed = await tableColumns(env, tableName);
      if (!refreshed.has(columnName)) throw error;
      columns = refreshed;
    }
    columns = new Set([...columns, columnName]);
  }
}

async function applySchemaMigrations(env) {
  const now = Date.now();
  const migrations = [
    // Одна сводная миграция вместо нескольких мелких: так обновление старой
    // базы укладывается в лимит запросов D1 Free даже при первом POST-запросе.
    [5, async () => {
      await addColumnsIfMissing(env, 'decision_requests', [
        ['lease_owner', "TEXT NOT NULL DEFAULT ''"],
        ['lease_token_hash', "TEXT NOT NULL DEFAULT ''"],
        ['lease_expires_at', 'INTEGER NOT NULL DEFAULT 0'],
        ['execution_attempts', 'INTEGER NOT NULL DEFAULT 0'],
        ['next_attempt_at', 'INTEGER NOT NULL DEFAULT 0'],
        ['prepared_message_hash', "TEXT NOT NULL DEFAULT ''"],
        ['prepared_at', 'INTEGER NOT NULL DEFAULT 0'],
        ['sensitive_ciphertext', "TEXT NOT NULL DEFAULT ''"],
        ['sensitive_iv', "TEXT NOT NULL DEFAULT ''"],
        ['sensitive_key_version', 'INTEGER NOT NULL DEFAULT 0'],
        ['sensitive_purged_at', 'INTEGER NOT NULL DEFAULT 0'],
      ]);
      await env.DB.prepare(`
        CREATE INDEX IF NOT EXISTS idx_decisions_delivery_queue
        ON decision_requests(installation_id, status, next_attempt_at, lease_expires_at)
      `).run();
      await addColumnsIfMissing(env, 'installation_credentials', [
        ['activation_code_id', "TEXT NOT NULL DEFAULT ''"],
      ]);
      await env.DB.prepare(`
        CREATE UNIQUE INDEX IF NOT EXISTS idx_credentials_activation_code
        ON installation_credentials(activation_code_id)
        WHERE activation_code_id <> ''
      `).run();
    }],
    [6, async () => {
      // Один составной PRIMARY KEY одновременно обеспечивает уникальность
      // request_id внутри установки и быстрые суточные выборки по МСК.
      // Отдельные индексы не нужны, поэтому миграция укладывается в лимит D1.
      await env.DB.prepare(`
        CREATE TABLE IF NOT EXISTS ai_usage (
          usage_day TEXT NOT NULL,
          installation_id TEXT NOT NULL,
          request_id TEXT NOT NULL,
          credential_id TEXT NOT NULL DEFAULT '',
          manager_id TEXT NOT NULL DEFAULT '',
          stage TEXT NOT NULL DEFAULT '',
          platform TEXT NOT NULL DEFAULT '',
          task TEXT NOT NULL,
          model TEXT NOT NULL,
          status TEXT NOT NULL,
          input_tokens INTEGER NOT NULL DEFAULT 0,
          cache_creation_input_tokens INTEGER NOT NULL DEFAULT 0,
          cache_read_input_tokens INTEGER NOT NULL DEFAULT 0,
          output_tokens INTEGER NOT NULL DEFAULT 0,
          estimated_cost_microusd INTEGER NOT NULL DEFAULT 0,
          upstream_status INTEGER NOT NULL DEFAULT 0,
          error_code TEXT NOT NULL DEFAULT '',
          latency_ms INTEGER NOT NULL DEFAULT 0,
          created_at INTEGER NOT NULL,
          PRIMARY KEY (usage_day, installation_id, request_id)
        )
      `).run();
    }],
    [7, async () => {
      // Реестр превращает введённое человеком название стейджа в
      // постоянный manager_id. Повторная активация и другой мессенджер
      // больше не создают нового «менеджера» в CRM.
      await env.DB.prepare(`
        INSERT OR IGNORE INTO manager_stages (
          manager_id, canonical_stage, stage_norm,
          active, created_at, updated_at
        )
        SELECT
          manager_id,
          stage,
          stage_norm,
          1,
          ?,
          ?
        FROM (
          SELECT
            CASE
              WHEN TRIM(COALESCE(manager_id, '')) <> '' THEN manager_id
              ELSE 'mgr_' || LOWER(HEX(RANDOMBLOB(12)))
            END AS manager_id,
            TRIM(stage) AS stage,
            LOWER(TRIM(stage)) AS stage_norm,
            ROW_NUMBER() OVER (
              PARTITION BY LOWER(TRIM(stage))
              ORDER BY last_seen_at DESC, updated_at DESC
            ) AS stage_rank
          FROM installations
          WHERE TRIM(COALESCE(stage, '')) <> ''
        )
        WHERE stage_rank = 1
      `).bind(now, now).run();

      await env.DB.prepare(`
        INSERT OR IGNORE INTO manager_stage_aliases (
          alias_norm, manager_id, canonical_stage, created_at, updated_at
        )
        SELECT stage_norm, manager_id, canonical_stage, ?, ?
        FROM manager_stages
        WHERE active = 1
      `).bind(now, now).run();
    }],
    [8, async () => {
      // CRM v4 хранит момент ухода вкладки в фон и восстанавливает
      // постоянный реестр из активных персональных установок. Благодаря
      // этому менеджер не пропадает даже после очистки старой телеметрии.
      await addColumnsIfMissing(env, 'installations', [
        ['page_hidden_since', 'INTEGER NOT NULL DEFAULT 0'],
      ]);

      await env.DB.prepare(`
        INSERT OR IGNORE INTO manager_stages (
          manager_id, canonical_stage, stage_norm,
          active, created_at, updated_at
        )
        SELECT manager_id, stage, stage_norm, 1, ?, ?
        FROM (
          SELECT
            CASE
              WHEN TRIM(COALESCE(manager_id, '')) <> '' THEN manager_id
              ELSE 'mgr_' || LOWER(HEX(RANDOMBLOB(12)))
            END AS manager_id,
            TRIM(stage) AS stage,
            LOWER(TRIM(stage)) AS stage_norm,
            ROW_NUMBER() OVER (
              PARTITION BY LOWER(TRIM(stage))
              ORDER BY last_used_at DESC, updated_at DESC
            ) AS stage_rank
          FROM installation_credentials
          WHERE status = 'active'
            AND TRIM(COALESCE(stage, '')) <> ''
        )
        WHERE stage_rank = 1
      `).bind(now, now).run();

      await env.DB.prepare(`
        INSERT OR IGNORE INTO manager_stage_aliases (
          alias_norm, manager_id, canonical_stage, created_at, updated_at
        )
        SELECT stage_norm, manager_id, canonical_stage, ?, ?
        FROM manager_stages
        WHERE active = 1
      `).bind(now, now).run();

      await env.DB.prepare(`
        UPDATE installations
        SET manager_id = COALESCE((
              SELECT s.manager_id
              FROM manager_stages s
              WHERE s.active = 1
                AND s.stage_norm = LOWER(TRIM(installations.stage))
              LIMIT 1
            ), manager_id),
            manager_name = COALESCE((
              SELECT s.canonical_stage
              FROM manager_stages s
              WHERE s.active = 1
                AND s.stage_norm = LOWER(TRIM(installations.stage))
              LIMIT 1
            ), manager_name),
            stage = COALESCE((
              SELECT s.canonical_stage
              FROM manager_stages s
              WHERE s.active = 1
                AND s.stage_norm = LOWER(TRIM(installations.stage))
              LIMIT 1
            ), stage),
            updated_at = ?
        WHERE TRIM(COALESCE(stage, '')) <> ''
      `).bind(now).run();

      await env.DB.prepare(`
        UPDATE installation_credentials
        SET manager_id = COALESCE((
              SELECT s.manager_id
              FROM manager_stages s
              WHERE s.active = 1
                AND s.stage_norm = LOWER(TRIM(installation_credentials.stage))
              LIMIT 1
            ), manager_id),
            manager_name = COALESCE((
              SELECT s.canonical_stage
              FROM manager_stages s
              WHERE s.active = 1
                AND s.stage_norm = LOWER(TRIM(installation_credentials.stage))
              LIMIT 1
            ), manager_name),
            stage = COALESCE((
              SELECT s.canonical_stage
              FROM manager_stages s
              WHERE s.active = 1
                AND s.stage_norm = LOWER(TRIM(installation_credentials.stage))
              LIMIT 1
            ), stage),
            updated_at = ?
        WHERE status = 'active'
          AND TRIM(COALESCE(stage, '')) <> ''
      `).bind(now).run();

      await env.DB.prepare(`
        UPDATE enrollment_codes
        SET manager_id = COALESCE((
              SELECT s.manager_id
              FROM manager_stages s
              WHERE s.active = 1
                AND s.stage_norm = LOWER(TRIM(enrollment_codes.stage))
              LIMIT 1
            ), manager_id),
            manager_name = COALESCE((
              SELECT s.canonical_stage
              FROM manager_stages s
              WHERE s.active = 1
                AND s.stage_norm = LOWER(TRIM(enrollment_codes.stage))
              LIMIT 1
            ), manager_name),
            stage = COALESCE((
              SELECT s.canonical_stage
              FROM manager_stages s
              WHERE s.active = 1
                AND s.stage_norm = LOWER(TRIM(enrollment_codes.stage))
              LIMIT 1
            ), stage)
        WHERE status = 'active'
          AND TRIM(COALESCE(stage, '')) <> ''
      `).run();
    }],
    // Таблица резервных копий создаётся в coreStatements до миграций.
    [9, async () => {}],
    [10, async () => {
      const statements = [
        `CREATE TABLE IF NOT EXISTS memory_people (
          person_id TEXT PRIMARY KEY, manager_id TEXT NOT NULL, memory_key TEXT NOT NULL,
          dossier_ciphertext TEXT NOT NULL DEFAULT '', dossier_iv TEXT NOT NULL DEFAULT '',
          dossier_key_version INTEGER NOT NULL DEFAULT 0, revision INTEGER NOT NULL DEFAULT 0,
          incomplete INTEGER NOT NULL DEFAULT 1, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL,
          UNIQUE(manager_id, memory_key), UNIQUE(manager_id, person_id)
        )`,
        `CREATE TABLE IF NOT EXISTS memory_chat_links (
          manager_id TEXT NOT NULL, platform TEXT NOT NULL, client_key TEXT NOT NULL,
          person_id TEXT NOT NULL, linked_at INTEGER NOT NULL, source TEXT NOT NULL,
          import_complete INTEGER NOT NULL DEFAULT 0,
          PRIMARY KEY(manager_id, platform, client_key),
          FOREIGN KEY(manager_id, person_id) REFERENCES memory_people(manager_id, person_id)
        )`,
        `CREATE INDEX IF NOT EXISTS memory_links_person ON memory_chat_links(manager_id, person_id)`,
        `CREATE TABLE IF NOT EXISTS memory_messages (
          manager_id TEXT NOT NULL, person_id TEXT NOT NULL, platform TEXT NOT NULL,
          client_key TEXT NOT NULL, message_id TEXT NOT NULL, direction TEXT NOT NULL CHECK(direction IN ('in','out')),
          time INTEGER NOT NULL, type TEXT NOT NULL CHECK(type IN ('text','voice_untranscribed')),
          ciphertext TEXT NOT NULL, iv TEXT NOT NULL, key_version INTEGER NOT NULL,
          created_at INTEGER NOT NULL,
          PRIMARY KEY(manager_id, person_id, platform, client_key, message_id),
          FOREIGN KEY(manager_id, person_id) REFERENCES memory_people(manager_id, person_id)
        )`,
        `CREATE INDEX IF NOT EXISTS memory_messages_person ON memory_messages(manager_id, person_id, time)`,
        `CREATE TABLE IF NOT EXISTS memory_link_events (
          event_id TEXT PRIMARY KEY, manager_id TEXT NOT NULL, person_id TEXT NOT NULL,
          platform TEXT NOT NULL, client_key TEXT NOT NULL, action TEXT NOT NULL,
          installation_id TEXT NOT NULL, created_at INTEGER NOT NULL
        )`
      ];
      for (const sql of statements) await env.DB.prepare(sql).run();
    }],
  ];

  const appliedRows = await env.DB.prepare(
    'SELECT version FROM schema_migrations'
  ).all();
  const applied = new Set(
    (appliedRows.results || []).map((row) => Number(row.version))
  );

  for (const [version, migrate] of migrations) {
    if (applied.has(version)) continue;
    await migrate();
    await env.DB.prepare(`
      INSERT OR IGNORE INTO schema_migrations (version, applied_at)
      VALUES (?, ?)
    `).bind(version, now).run();
  }

  if (CURRENT_SCHEMA_VERSION !== migrations.at(-1)[0]) {
    throw new Error('SCHEMA_VERSION_MISMATCH');
  }
}

let decisionSchemaPromise = null;

async function ensureDecisionSchema(env) {
  if (!decisionSchemaPromise) {
    decisionSchemaPromise = initializeDecisionSchema(env).catch((error) => {
      decisionSchemaPromise = null;
      throw error;
    });
  }
  return decisionSchemaPromise;
}

async function initializeDecisionSchema(env) {
  const statements = [
    `CREATE TABLE IF NOT EXISTS decision_requests (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      request_id TEXT NOT NULL UNIQUE,
      installation_id TEXT NOT NULL,
      client_key TEXT NOT NULL,
      client_label TEXT NOT NULL DEFAULT '',
      manager_id TEXT NOT NULL DEFAULT '',
      manager_name TEXT NOT NULL DEFAULT '',
      stage TEXT NOT NULL DEFAULT '',
      platform TEXT NOT NULL DEFAULT '',
      decision_type TEXT NOT NULL,
      status TEXT NOT NULL DEFAULT 'pending',
      reason TEXT NOT NULL DEFAULT '',
      manager_task TEXT NOT NULL DEFAULT '',
      recommendation TEXT NOT NULL DEFAULT '',
      context_json TEXT NOT NULL DEFAULT '[]',
      proposed_time_at INTEGER NOT NULL DEFAULT 0,
      proposed_time_text TEXT NOT NULL DEFAULT '',
      trace_id TEXT NOT NULL DEFAULT '',
      source TEXT NOT NULL DEFAULT 'script',
      created_at INTEGER NOT NULL,
      updated_at INTEGER NOT NULL,
      expires_at INTEGER NOT NULL,
      resolved_at INTEGER NOT NULL DEFAULT 0,
      resolved_by INTEGER NOT NULL DEFAULT 0,
      resolved_login TEXT NOT NULL DEFAULT '',
      resolution_action TEXT NOT NULL DEFAULT '',
      resolution_text TEXT NOT NULL DEFAULT '',
      resolution_time_at INTEGER NOT NULL DEFAULT 0,
      delivered_at INTEGER NOT NULL DEFAULT 0,
      delivery_attempts INTEGER NOT NULL DEFAULT 0,
      executed_at INTEGER NOT NULL DEFAULT 0,
      execution_status TEXT NOT NULL DEFAULT '',
      execution_error TEXT NOT NULL DEFAULT '',
      lease_owner TEXT NOT NULL DEFAULT '',
      lease_token_hash TEXT NOT NULL DEFAULT '',
      lease_expires_at INTEGER NOT NULL DEFAULT 0,
      execution_attempts INTEGER NOT NULL DEFAULT 0,
      next_attempt_at INTEGER NOT NULL DEFAULT 0,
      prepared_message_hash TEXT NOT NULL DEFAULT '',
      prepared_at INTEGER NOT NULL DEFAULT 0,
      sensitive_ciphertext TEXT NOT NULL DEFAULT '',
      sensitive_iv TEXT NOT NULL DEFAULT '',
      sensitive_key_version INTEGER NOT NULL DEFAULT 0,
      sensitive_purged_at INTEGER NOT NULL DEFAULT 0
    )`,
    `CREATE TABLE IF NOT EXISTS decision_events (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      request_id TEXT NOT NULL,
      at INTEGER NOT NULL,
      event_type TEXT NOT NULL,
      actor_user_id INTEGER NOT NULL DEFAULT 0,
      actor_login TEXT NOT NULL DEFAULT '',
      details_json TEXT NOT NULL DEFAULT '{}'
    )`,
    `CREATE INDEX IF NOT EXISTS idx_decisions_status_created
      ON decision_requests(status, created_at DESC)`,
    `CREATE INDEX IF NOT EXISTS idx_decisions_stage_status
      ON decision_requests(stage, status, created_at DESC)`,
    `CREATE INDEX IF NOT EXISTS idx_decisions_installation_status
      ON decision_requests(installation_id, status, updated_at DESC)`,
    `CREATE UNIQUE INDEX IF NOT EXISTS idx_decisions_open_client
      ON decision_requests(installation_id, client_key, decision_type)
      WHERE status IN ('pending', 'resolved')`,
    `CREATE INDEX IF NOT EXISTS idx_decision_events_request
      ON decision_events(request_id, at DESC)`,
  ];

  for (const sql of statements) {
    await env.DB.prepare(sql).run();
  }
}

function decisionChanges(result) {
  return Number(result?.meta?.changes || result?.changes || 0);
}

function normalizeDecisionContext(rawContext) {
  if (!Array.isArray(rawContext)) return [];

  return rawContext
    .slice(-DECISION_CONTEXT_LIMIT)
    .filter((item) => item && typeof item === 'object')
    .map((item) => ({
      who: cleanText(
        item.who || item.sender || item.role || 'unknown',
        40
      ),
      text: redactSensitive(item.text || item.message, 900),
      time: cleanText(item.time, 80),
      at: safeTimestamp(item.at || item.timestamp),
    }))
    .filter((item) => item.text);
}

function parseDecisionContext(value) {
  try {
    const parsed = JSON.parse(String(value || '[]'));
    return Array.isArray(parsed) ? parsed : [];
  } catch (_) {
    return [];
  }
}

function bytesToBase64Url(bytes) {
  let binary = '';
  for (const byte of bytes) binary += String.fromCharCode(byte);
  return btoa(binary)
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/g, '');
}

function base64UrlToBytes(value) {
  const normalized = String(value || '')
    .replace(/-/g, '+')
    .replace(/_/g, '/');
  const padded = normalized + '='.repeat((4 - normalized.length % 4) % 4);
  const binary = atob(padded);
  return Uint8Array.from(binary, (char) => char.charCodeAt(0));
}

function parseEncryptionKey(value) {
  const raw = String(value || '').trim();
  let bytes;

  if (/^[a-f0-9]{64}$/i.test(raw)) {
    bytes = Uint8Array.from(
      raw.match(/.{2}/g).map((pair) => parseInt(pair, 16))
    );
  } else {
    try {
      bytes = base64UrlToBytes(raw);
    } catch (_) {
      bytes = new Uint8Array();
    }
  }

  if (bytes.length !== 32) {
    throw new PublicError('DATA_ENCRYPTION_KEY_INVALID', 503);
  }

  return bytes;
}

function isDataEncryptionKeyValid(env) {
  try {
    parseEncryptionKey(env.DATA_ENCRYPTION_KEY);
    return true;
  } catch (_) {
    return false;
  }
}

let dataEncryptionKeyPromise = null;
let dataEncryptionKeySource = '';

async function importDataEncryptionKey(env) {
  if (!env.DATA_ENCRYPTION_KEY) {
    throw new PublicError('DATA_ENCRYPTION_KEY_REQUIRED', 503);
  }

  const source = String(env.DATA_ENCRYPTION_KEY);
  if (!dataEncryptionKeyPromise || dataEncryptionKeySource !== source) {
    dataEncryptionKeySource = source;
    dataEncryptionKeyPromise = crypto.subtle.importKey(
      'raw',
      parseEncryptionKey(source),
      { name: 'AES-GCM' },
      false,
      ['encrypt', 'decrypt']
    ).catch((error) => {
      dataEncryptionKeyPromise = null;
      dataEncryptionKeySource = '';
      throw error;
    });
  }

  return dataEncryptionKeyPromise;
}

function decisionAad(requestId, keyVersion = DATA_ENCRYPTION_KEY_VERSION) {
  return new TextEncoder().encode(
    `ai-monitor:decision:${requestId}:v${keyVersion}`
  );
}

function maxBackupAad(identity, recordKey) {
  const platform = String(identity.platform || 'max').toLowerCase();
  return new TextEncoder().encode(
    `ai-monitor:max-backup:${identity.manager_id}:${platform}:${recordKey}:v${DATA_ENCRYPTION_KEY_VERSION}`
  );
}

// Shared memory identifiers never include names or profile text.
function assertMemoryIdentity(identity) {
  if (!identity || !identity.manager_id || !['max', 'telegram', 'whatsapp', 'gemspace'].includes(identity.platform)) {
    throw new PublicError('MEMORY_IDENTITY_REQUIRED', 403);
  }
}

function memoryClientKey(payload) {
  const value = payload?.client_key;
  if (typeof value !== 'string' || !value.trim() || value.length > 500) throw new PublicError('MEMORY_CLIENT_KEY_INVALID', 400);
  return value;
}

async function memoryPersonByKey(env, identity, memoryKey) {
  assertMemoryIdentity(identity);
  if (typeof memoryKey !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(memoryKey)) throw new PublicError('MEMORY_KEY_INVALID', 400);
  const person = await env.DB.prepare('SELECT * FROM memory_people WHERE manager_id = ? AND memory_key = ?')
    .bind(identity.manager_id, memoryKey).first();
  if (person) return person;
  const foreign = await env.DB.prepare('SELECT person_id FROM memory_people WHERE memory_key = ? LIMIT 1').bind(memoryKey).first();
  throw new PublicError(foreign ? 'MEMORY_FORBIDDEN' : 'MEMORY_NOT_FOUND', foreign ? 403 : 404);
}

async function memoryLinkForChat(env, identity, clientKey) {
  return env.DB.prepare(`SELECT p.* FROM memory_chat_links l JOIN memory_people p
    ON p.manager_id = l.manager_id AND p.person_id = l.person_id
    WHERE l.manager_id = ? AND l.platform = ? AND l.client_key = ?`)
    .bind(identity.manager_id, identity.platform, clientKey).first();
}

function memoryAad(managerId, personId, kind, recordId, keyVersion = DATA_ENCRYPTION_KEY_VERSION) {
  return new TextEncoder().encode(JSON.stringify(['ai-monitor:memory', managerId, personId, kind, recordId, keyVersion]));
}
function memoryRecordId(platform, clientKey, messageId) { return JSON.stringify([platform, clientKey, messageId]); }
async function memoryEncrypt(env, managerId, personId, kind, recordId, value) {
  const key = await importDataEncryptionKey(env), iv = crypto.getRandomValues(new Uint8Array(12));
  const encrypted = await crypto.subtle.encrypt({name:'AES-GCM',iv,additionalData:memoryAad(managerId,personId,kind,recordId),tagLength:128},key,new TextEncoder().encode(JSON.stringify(value)));
  return [bytesToBase64Url(new Uint8Array(encrypted)),bytesToBase64Url(iv),DATA_ENCRYPTION_KEY_VERSION];
}
async function memoryDecrypt(env, managerId, personId, kind, recordId, ciphertext, iv, version) {
  try {
    if (Number(version) !== DATA_ENCRYPTION_KEY_VERSION) throw new Error('version');
    const decode = value => Uint8Array.from(atob(value.replace(/-/g,'+').replace(/_/g,'/')), c=>c.charCodeAt(0));
    const key = await importDataEncryptionKey(env);
    const plain = await crypto.subtle.decrypt({name:'AES-GCM',iv:decode(iv),additionalData:memoryAad(managerId,personId,kind,recordId,Number(version)),tagLength:128},key,decode(ciphertext));
    return JSON.parse(new TextDecoder().decode(plain));
  } catch (_) { throw new PublicError('MEMORY_DECRYPTION_FAILED',503); }
}
async function memoryDossier(env, identity, person) {
  if (!person.dossier_ciphertext) return [];
  return memoryDecrypt(env,identity.manager_id,person.person_id,'dossier','profile',person.dossier_ciphertext,person.dossier_iv,person.dossier_key_version);
}
async function memoryLinkedPerson(env, identity, payload) {
  assertMemoryIdentity(identity);
  const person = await memoryLinkForChat(env,identity,memoryClientKey(payload));
  if (!person) throw new PublicError('MEMORY_NOT_FOUND',404);
  return person;
}
// Conservative lexical nomination only: quotations are historical data, not verified facts.
function memoryPersonalQuotation(text) {
  return /daughter|son\b|children|family|wife|husband|mother|father|live in|work|years old|hobb|enjoy|love|доч|сын|дет[ьи]|семь|жен[аы]|муж|мам|пап|жив[уёе]|работ|лет\b|возраст|хобби|люблю|увлека|город/i.test(text);
}
function memoryRetainQuotations(entries) {
  const unique=[];
  for(const entry of entries.filter(e=>e.source==='confirmed_message').sort((a,b)=>(a.time||0)-(b.time||0) || JSON.stringify([a.source_platform,a.source_client_key,a.source_message_id]).localeCompare(JSON.stringify([b.source_platform,b.source_client_key,b.source_message_id])))) {
    if(!unique.some(e=>e.source_platform===entry.source_platform && e.source_client_key===entry.source_client_key && e.source_message_id===entry.source_message_id)) unique.push(entry);
  }
  return [...entries.filter(e=>e.source==='manager_profile'),...unique.slice(0,12),...unique.slice(12).slice(-4)];
}
async function handleMemoryGetContext(env, identity, payload, attempt=0) {
  const person = await memoryLinkedPerson(env,identity,payload);
  const limit = payload?.limit === undefined ? 40 : payload.limit;
  if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw new PublicError('MEMORY_LIMIT_INVALID',400);
  const paging=payload.archive_page!==undefined;
  const page=payload.archive_page;
  if(paging && (!page || !Number.isSafeInteger(page.offset) || page.offset<0 || page.offset>10000000 || page.person_id!==person.person_id || page.revision!==person.revision)) throw new PublicError('MEMORY_REVISION_CONFLICT',409);
  const offset=paging?page.offset:0;
  const rows = await env.DB.prepare(`SELECT * FROM memory_messages WHERE manager_id=? AND person_id=? ORDER BY time DESC,platform,client_key,message_id LIMIT ? OFFSET ?`).bind(identity.manager_id,person.person_id,limit,offset).all();
  const recent=[];
  let bytes=0;
  for (const row of rows.results || []) {
    const value = await memoryDecrypt(env,identity.manager_id,person.person_id,'message',memoryRecordId(row.platform,row.client_key,row.message_id),row.ciphertext,row.iv,row.key_version);
    // The authenticated plaintext includes metadata as well as text, preventing row metadata substitution.
    if (value.direction!==row.direction || value.time!==row.time || value.type!==row.type) throw new PublicError('MEMORY_DECRYPTION_FAILED',503);
    bytes += new TextEncoder().encode(value.text).length;
    if (bytes > 96*1024) break;
    recent.push({platform:row.platform,client_key:row.client_key,message_id:row.message_id,...value});
  }
  const links = await env.DB.prepare('SELECT DISTINCT platform FROM memory_chat_links WHERE manager_id=? AND person_id=? ORDER BY platform').bind(identity.manager_id,person.person_id).all();
  const dossier=await memoryDossier(env,identity,person);
  const latest=await memoryLinkedPerson(env,identity,payload);
  if (latest.person_id!==person.person_id || latest.revision!==person.revision) {
    if (!paging && attempt<3) return handleMemoryGetContext(env,identity,payload,attempt+1);
    throw new PublicError('MEMORY_CONTEXT_CHANGED',503);
  }
  return jsonResponse({ok:true,person_id:person.person_id,revision:person.revision,dossier,recent,archive_next:recent.length ? {person_id:person.person_id,revision:person.revision,offset:offset+recent.length}:null,incomplete:Boolean(person.incomplete),platforms:(links.results||[]).map(r=>r.platform)},200,true);
}
function memoryMediaEnrichment(previous, next) {
  if (previous.direction!==next.direction || previous.time!==next.time || previous.text===next.text) return false;
  if (previous.type==='voice_untranscribed' && next.type==='text' &&
      /^\[голосовое/i.test(previous.text) && /^\[голосовое/i.test(next.text)) return true;
  return previous.type==='text' && next.type==='text' &&
    /^\[ФОТО [^\]]+\]: \[фото — ещё не проанализировано\]$/i.test(previous.text) &&
    /^\[ФОТО [^\]]+\]: (?!\[фото — ещё не проанализировано\])\S/i.test(next.text);
}
async function handleMemoryAppendConfirmed(env, identity, payload, attempt=0) {
  const person=await memoryLinkedPerson(env,identity,payload), clientKey=memoryClientKey(payload);
  if (!Array.isArray(payload.messages) || payload.messages.length>100 || typeof payload.import_complete!=='boolean') throw new PublicError('MEMORY_BATCH_INVALID',400);
  if (new TextEncoder().encode(JSON.stringify(payload)).length>256*1024) throw new PublicError('MEMORY_BATCH_TOO_LARGE',413);
  const messages=payload.messages;
  for (const m of messages) {
    if (!m || typeof m.message_id!=='string' || !m.message_id.trim() || m.message_id.length>500 || !['in','out'].includes(m.direction) || !Number.isSafeInteger(m.time) || m.time<0 || !['text','voice_untranscribed'].includes(m.type) || typeof m.text!=='string' || m.text.length>16000 || m.confirmed===false || m.draft || m.pending || (m.status && m.status!=='confirmed') || m.ai_proposal) throw new PublicError('MEMORY_MESSAGE_INVALID',400);
  }
  let dossier=await memoryDossier(env,identity,person);
  if (payload.dossier !== undefined) {
    if (!Array.isArray(payload.dossier) || payload.dossier.length>50 || payload.expected_revision!==person.revision) throw new PublicError(payload.expected_revision!==person.revision?'MEMORY_REVISION_CONFLICT':'MEMORY_DOSSIER_INVALID',payload.expected_revision!==person.revision?409:400);
    if(payload.dossier.length>4) throw new PublicError('MEMORY_DOSSIER_INVALID',400);
    dossier=dossier.filter(e=>e.source==='manager_profile' || e.source_client_key!==clientKey || e.source_platform!==identity.platform);
    for (const entry of payload.dossier) {
      if (!entry || entry.source!=='confirmed_message' || entry.source_platform!==identity.platform || typeof entry.text!=='string' || !entry.text.trim() || entry.text.length>4000 || typeof entry.source_message_id!=='string') throw new PublicError('MEMORY_DOSSIER_SOURCE_INVALID',400);
      const source=await env.DB.prepare('SELECT * FROM memory_messages WHERE manager_id=? AND person_id=? AND platform=? AND client_key=? AND message_id=?').bind(identity.manager_id,person.person_id,identity.platform,clientKey,entry.source_message_id).first();
      if (!source) throw new PublicError('MEMORY_DOSSIER_SOURCE_MISSING',400);
      const authoritative=await memoryDecrypt(env,identity.manager_id,person.person_id,'message',memoryRecordId(identity.platform,clientKey,source.message_id),source.ciphertext,source.iv,source.key_version);
      if(authoritative.direction!=='in' || authoritative.type!=='text' || entry.text!==authoritative.text.slice(0,240) || entry.text.length>240) throw new PublicError('MEMORY_DOSSIER_SOURCE_INVALID',400);
      if (!dossier.some(e=>e.text===entry.text && e.source_platform===entry.source_platform && e.source_client_key===clientKey && e.source_message_id===entry.source_message_id)) dossier.push({text:entry.text,source_platform:identity.platform,source_client_key:clientKey,source_message_id:entry.source_message_id,source:'confirmed_message',time:authoritative.time,updated_at:authoritative.time});
    }
    dossier=memoryRetainQuotations(dossier);
  }
  const inserts=[], updates=[], seen=new Set();
  for (const m of messages) {
    if(seen.has(m.message_id)) continue;
    seen.add(m.message_id);
    const exists=await env.DB.prepare('SELECT * FROM memory_messages WHERE manager_id=? AND person_id=? AND platform=? AND client_key=? AND message_id=?').bind(identity.manager_id,person.person_id,identity.platform,clientKey,m.message_id).first();
    if(exists) {
      if(identity.platform==='max' && exists.direction===m.direction && exists.time===m.time) {
        const previous=await memoryDecrypt(env,identity.manager_id,person.person_id,'message',memoryRecordId(identity.platform,clientKey,m.message_id),exists.ciphertext,exists.iv,exists.key_version);
        if(previous.direction!==exists.direction || previous.time!==exists.time || previous.type!==exists.type) throw new PublicError('MEMORY_DECRYPTION_FAILED',503);
        if(memoryMediaEnrichment(previous,m)) {
          const revised=await memoryEncrypt(env,identity.manager_id,person.person_id,'message',memoryRecordId(identity.platform,clientKey,m.message_id),{direction:m.direction,time:m.time,type:m.type,text:m.text});
          updates.push(env.DB.prepare(`UPDATE memory_messages SET type=?,ciphertext=?,iv=?,key_version=? WHERE manager_id=? AND person_id=? AND platform=? AND client_key=? AND message_id=? AND ciphertext=? AND EXISTS(SELECT 1 FROM memory_people WHERE manager_id=? AND person_id=? AND revision=?)`).bind(m.type,...revised,identity.manager_id,person.person_id,identity.platform,clientKey,m.message_id,exists.ciphertext,identity.manager_id,person.person_id,person.revision));
        }
      }
      continue;
    }
    if(m.direction==='in' && m.type==='text' && memoryPersonalQuotation(m.text)) dossier.push({text:m.text.slice(0,240),source:'confirmed_message',source_platform:identity.platform,source_client_key:clientKey,source_message_id:m.message_id,time:m.time,updated_at:m.time});
    const encrypted=await memoryEncrypt(env,identity.manager_id,person.person_id,'message',memoryRecordId(identity.platform,clientKey,m.message_id),{direction:m.direction,time:m.time,type:m.type,text:m.text});
    inserts.push(env.DB.prepare(`INSERT OR IGNORE INTO memory_messages(manager_id,person_id,platform,client_key,message_id,direction,time,type,ciphertext,iv,key_version,created_at) SELECT ?,?,?,?,?,?,?,?,?,?,?,? FROM memory_people WHERE manager_id=? AND person_id=? AND revision=?`).bind(identity.manager_id,person.person_id,identity.platform,clientKey,m.message_id,m.direction,m.time,m.type,...encrypted,Date.now(),identity.manager_id,person.person_id,person.revision));
  }
  dossier=memoryRetainQuotations(dossier);
  const link=await env.DB.prepare('SELECT import_complete FROM memory_chat_links WHERE manager_id=? AND platform=? AND client_key=?').bind(identity.manager_id,identity.platform,clientKey).first();
  const importComplete=payload.import_complete ? 1 : 0;
  const encrypted=await memoryEncrypt(env,identity.manager_id,person.person_id,'dossier','profile',dossier);
  const previous=await memoryDossier(env,identity,person);
  const changed=inserts.length>0 || updates.length>0 || Number(link.import_complete)!==importComplete || JSON.stringify(previous)!==JSON.stringify(dossier);
  if(!changed) return jsonResponse({ok:true,accepted:0,duplicates:messages.length,revision:person.revision},200,true);
  const statements=[...inserts,...updates,env.DB.prepare(`UPDATE memory_chat_links SET import_complete=? WHERE manager_id=? AND platform=? AND client_key=? AND EXISTS(SELECT 1 FROM memory_people WHERE manager_id=? AND person_id=? AND revision=?)`).bind(importComplete,identity.manager_id,identity.platform,clientKey,identity.manager_id,person.person_id,person.revision),env.DB.prepare(`UPDATE memory_people SET dossier_ciphertext=?,dossier_iv=?,dossier_key_version=?,revision=revision+1,updated_at=?,incomplete=CASE WHEN EXISTS(SELECT 1 FROM memory_chat_links WHERE manager_id=? AND person_id=? AND import_complete=0) THEN 1 ELSE 0 END WHERE manager_id=? AND person_id=? AND revision=?`).bind(...encrypted,Date.now(),identity.manager_id,person.person_id,identity.manager_id,person.person_id,person.revision)];
  const results=await env.DB.batch(statements);
  if (!results[results.length-1].meta?.changes) {
    if(payload.dossier===undefined && attempt<3) return handleMemoryAppendConfirmed(env,identity,payload,attempt+1);
    throw new PublicError('MEMORY_REVISION_CONFLICT',409);
  }
  const accepted=results.slice(0,inserts.length+updates.length).reduce((n,r)=>n+Number(r.meta?.changes||0),0);
  return jsonResponse({ok:true,accepted,duplicates:messages.length-accepted,revision:person.revision+1},200,true);
}

async function handleMemoryUnlink(env, identity, payload) {
  const person = await memoryPersonByKey(env, identity, payload?.memory_key);
  const clientKey = memoryClientKey(payload);
  const linked = await memoryLinkForChat(env, identity, clientKey);
  if (!linked || linked.person_id !== person.person_id) throw new PublicError('MEMORY_LINK_CONFLICT', 409);
  const now = Date.now();
  await env.DB.batch([
    env.DB.prepare(`INSERT INTO memory_link_events(event_id,manager_id,person_id,platform,client_key,action,installation_id,created_at)
      SELECT ?,?,?,?,?,?,?,? WHERE EXISTS(SELECT 1 FROM memory_chat_links WHERE manager_id=? AND platform=? AND client_key=? AND person_id=?)`)
      .bind(crypto.randomUUID(),identity.manager_id,person.person_id,identity.platform,clientKey,'unlink',identity.installation_id,now,identity.manager_id,identity.platform,clientKey,person.person_id),
    env.DB.prepare('DELETE FROM memory_chat_links WHERE manager_id=? AND platform=? AND client_key=? AND person_id=?')
      .bind(identity.manager_id,identity.platform,clientKey,person.person_id),
    env.DB.prepare(`UPDATE memory_people SET revision=revision+1,updated_at=?,
      incomplete=CASE WHEN EXISTS(SELECT 1 FROM memory_chat_links WHERE manager_id=? AND person_id=? AND import_complete=0) THEN 1 ELSE 0 END
      WHERE manager_id=? AND person_id=?`).bind(now,identity.manager_id,person.person_id,identity.manager_id,person.person_id)
  ]);
  // An unlink removes routing only. The encrypted archive remains until verified deletion.
  return jsonResponse({ok:true},200,true);
}

async function handleMemoryDelete(env, identity, payload) {
  const person = await memoryPersonByKey(env, identity, payload?.memory_key);
  if (payload.confirm_memory_key !== person.memory_key) throw new PublicError('MEMORY_CONFIRMATION_REQUIRED',400);
  const linked = await env.DB.prepare('SELECT person_id FROM memory_chat_links WHERE manager_id=? AND person_id=? LIMIT 1').bind(identity.manager_id,person.person_id).first();
  if (linked) throw new PublicError('MEMORY_STILL_LINKED',409);
  const guard = 'NOT EXISTS(SELECT 1 FROM memory_chat_links WHERE manager_id=? AND person_id=?)';
  const results = await env.DB.batch([
    env.DB.prepare(`INSERT INTO memory_link_events(event_id,manager_id,person_id,platform,client_key,action,installation_id,created_at)
      SELECT ?,?,?,?,?,?,?,? WHERE EXISTS(SELECT 1 FROM memory_people WHERE manager_id=? AND person_id=?) AND ${guard}`)
      .bind(crypto.randomUUID(),identity.manager_id,person.person_id,identity.platform,'','delete',identity.installation_id,Date.now(),identity.manager_id,person.person_id,identity.manager_id,person.person_id),
    env.DB.prepare(`DELETE FROM memory_messages WHERE manager_id=? AND person_id=? AND ${guard}`).bind(identity.manager_id,person.person_id,identity.manager_id,person.person_id),
    env.DB.prepare(`DELETE FROM memory_people WHERE manager_id=? AND person_id=? AND ${guard}`).bind(identity.manager_id,person.person_id,identity.manager_id,person.person_id)
  ]);
  if (!results[2]?.meta?.changes) throw new PublicError('MEMORY_DELETE_CONFLICT',409);
  return jsonResponse({ok:true},200,true);
}

async function handleMemoryPreview(env, identity, payload) {
  const person = await memoryPersonByKey(env, identity, payload?.memory_key);
  const links = await env.DB.prepare('SELECT DISTINCT platform FROM memory_chat_links WHERE manager_id = ? AND person_id = ? ORDER BY platform')
    .bind(identity.manager_id, person.person_id).all();
  return jsonResponse({ ok: true, memory_key: person.memory_key, summary: (await memoryDossier(env, identity, person)).map(entry => entry.text).join(' · ').slice(0, 600),
    platforms: (links.results || []).map(row => row.platform), incomplete: Boolean(person.incomplete) }, 200, true);
}

function memoryProfileText(profile) {
  if (!profile || typeof profile !== 'object' || Array.isArray(profile)) throw new PublicError('MEMORY_PROFILE_INVALID',400);
  const text = Object.entries(profile).filter(([k,v]) => typeof v==='string' && v.trim()).map(([k,v])=>`${k}: ${v.trim()}`).join('\n');
  if (new TextEncoder().encode(text).length>16000) throw new PublicError('MEMORY_PROFILE_TOO_LARGE',413);
  return text;
}

async function handleMemoryCreateOrAttach(env, identity, payload, attempt=0) {
  assertMemoryIdentity(identity);
  const clientKey = memoryClientKey(payload);
  // Validate a supplied key before considering an existing chat: never silently replace it.
  const supplied = payload?.memory_key;
  const target = supplied === undefined || supplied === '' || supplied === null ? null : await memoryPersonByKey(env, identity, supplied);
  const existing = await memoryLinkForChat(env, identity, clientKey);
  if (existing) {
    if (target && existing.person_id !== target.person_id) throw new PublicError('MEMORY_CHAT_ALREADY_LINKED', 409);
    if (payload.profile !== undefined) {
      const text = memoryProfileText(payload.profile);
      const previous = await memoryDossier(env, identity, existing);
      const ownProfiles = previous.filter(entry => entry.source==='manager_profile' && entry.source_platform===identity.platform);
      if (ownProfiles.length !== (text ? 1 : 0) || (text && ownProfiles[0].text !== text)) {
        const now = Date.now();
        const dossier = previous.filter(entry => entry.source!=='manager_profile' || entry.source_platform!==identity.platform);
        if (text) dossier.push({text,source_platform:identity.platform,source:'manager_profile',updated_at:now});
        const encrypted = await memoryEncrypt(env,identity.manager_id,existing.person_id,'dossier','profile',dossier);
        const result = await env.DB.prepare(`UPDATE memory_people SET dossier_ciphertext=?,dossier_iv=?,dossier_key_version=?,revision=revision+1,updated_at=? WHERE manager_id=? AND person_id=? AND revision=?`)
          .bind(...encrypted,now,identity.manager_id,existing.person_id,existing.revision).run();
        if (!result?.meta?.changes) {
          if (attempt<3) return handleMemoryCreateOrAttach(env,identity,payload,attempt+1);
          throw new PublicError('MEMORY_REVISION_CONFLICT',409);
        }
      }
    }
    return jsonResponse({ ok: true, person_id: existing.person_id, memory_key: existing.memory_key, link_state: 'linked', created: false }, 200, true);
  }
  const personId = target?.person_id || crypto.randomUUID();
  const memoryKey = target?.memory_key || bytesToBase64Url(crypto.getRandomValues(new Uint8Array(32)));
  const now = Date.now();
  const statements = [];
  let seeded = ['', '', 0];
  if (!target) {
    const text = payload.profile === undefined ? '' : memoryProfileText(payload.profile);
    seeded = await memoryEncrypt(env,identity.manager_id,personId,'dossier','profile',text ? [{text,source_platform:identity.platform,source:'manager_profile',updated_at:now}] : []);
  }
  if (!target) statements.push(env.DB.prepare(`INSERT INTO memory_people
    (person_id,manager_id,memory_key,created_at,updated_at,dossier_ciphertext,dossier_iv,dossier_key_version,revision)
    SELECT ?,?,?,?,?,?,?,?,1 WHERE NOT EXISTS (SELECT 1 FROM memory_chat_links WHERE manager_id=? AND platform=? AND client_key=?)`)
    .bind(personId,identity.manager_id,memoryKey,now,now,...seeded,identity.manager_id,identity.platform,clientKey));
  // Audit the missing-to-linked transition before inserting the link. Both
  // statements share the transaction, so retries cannot add another audit row.
  statements.push(env.DB.prepare(`INSERT INTO memory_link_events
    (event_id,manager_id,person_id,platform,client_key,action,installation_id,created_at)
    SELECT ?,?,?,?,?,?,?,? WHERE EXISTS (SELECT 1 FROM memory_people WHERE manager_id=? AND person_id=?)
    AND NOT EXISTS (SELECT 1 FROM memory_chat_links WHERE manager_id=? AND platform=? AND client_key=?)`)
    .bind(crypto.randomUUID(),identity.manager_id,personId,identity.platform,clientKey,'attach',identity.installation_id,now,
      identity.manager_id,personId,identity.manager_id,identity.platform,clientKey));
  statements.push(env.DB.prepare(`INSERT OR IGNORE INTO memory_chat_links
    (manager_id,platform,client_key,person_id,linked_at,source)
    SELECT ?,?,?,?,?,? FROM memory_people WHERE manager_id=? AND person_id=?`)
    .bind(identity.manager_id,identity.platform,clientKey,personId,now,'manager_profile',identity.manager_id,personId));
  if (target) statements.push(env.DB.prepare(`UPDATE memory_people SET incomplete=1,revision=revision+1,updated_at=? WHERE manager_id=? AND person_id=? AND EXISTS(SELECT 1 FROM memory_chat_links WHERE manager_id=? AND person_id=? AND import_complete=0)`).bind(now,identity.manager_id,personId,identity.manager_id,personId));
  // D1 batch is transactional: the existence check, insert and link cannot interleave.
  await env.DB.batch(statements);
  const linked = await memoryLinkForChat(env, identity, clientKey);
  if (!linked) throw new PublicError('MEMORY_UNAVAILABLE', 503);
  if (target && linked.person_id !== target.person_id) throw new PublicError('MEMORY_CHAT_ALREADY_LINKED', 409);
  // A newly attached chat also contributes its manager-entered profile.
  // Reuse the existing-link CAS path so retries cannot duplicate it.
  if (target && payload.profile !== undefined) {
    return handleMemoryCreateOrAttach(env, identity, payload, attempt);
  }
  return jsonResponse({ ok: true, person_id: linked.person_id, memory_key: linked.memory_key,
    link_state: 'linked', created: !target && linked.person_id === personId }, 200, true);
}

async function handleMaxBackupSave(env, identity, payload) {
  const recordKey = String(payload?.record_key || '').trim();
  if (!/^(?:__roster__|client:[^\x00-\x1f]{1,150})$/.test(recordKey) ||
      !payload?.value || typeof payload.value !== 'object' || Array.isArray(payload.value)) {
    throw new PublicError('BACKUP_RECORD_INVALID', 400);
  }
  const expectedRevision = Number(payload?.expected_revision);
  if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) {
    throw new PublicError('BACKUP_REVISION_REQUIRED', 400);
  }
  const plaintext = new TextEncoder().encode(JSON.stringify(payload.value));
  if (plaintext.length > MAX_BACKUP_RECORD_BYTES) {
    throw new PublicError('BACKUP_RECORD_TOO_LARGE', 413);
  }
  const key = await importDataEncryptionKey(env);
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const ciphertext = await crypto.subtle.encrypt(
    { name: 'AES-GCM', iv, additionalData: maxBackupAad(identity, recordKey), tagLength: 128 },
    key, plaintext
  );
  const savedAt = Math.max(Date.now(), expectedRevision + 1);
  const common = [
    bytesToBase64Url(new Uint8Array(ciphertext)),
    bytesToBase64Url(iv), DATA_ENCRYPTION_KEY_VERSION, savedAt,
  ];
  const result = expectedRevision === 0
    ? await env.DB.prepare(`
        INSERT INTO max_stage_backups
          (manager_id, platform, record_key, ciphertext, iv, key_version, updated_at)
        VALUES (?, ?, ?, ?, ?, ?, ?)
        ON CONFLICT(manager_id, platform, record_key) DO NOTHING
      `).bind(identity.manager_id, identity.platform, recordKey, ...common).run()
    : await env.DB.prepare(`
        UPDATE max_stage_backups
        SET ciphertext = ?, iv = ?, key_version = ?, updated_at = ?
        WHERE manager_id = ? AND platform = ? AND record_key = ? AND updated_at = ?
      `).bind(...common, identity.manager_id, identity.platform, recordKey, expectedRevision).run();
  if (Number(result?.meta?.changes || 0) !== 1) {
    throw new PublicError('BACKUP_REVISION_CONFLICT', 409);
  }
  return jsonResponse({ ok: true, record_key: recordKey, saved_at: savedAt }, 200, true);
}

async function handleMaxBackupLoad(env, identity, payload) {
  const cursor = String(payload?.cursor || '').trim();
  if (cursor.length > 160) throw new PublicError('BACKUP_CURSOR_INVALID', 400);
  const rows = await env.DB.prepare(`
    SELECT record_key, ciphertext, iv, key_version, updated_at
    FROM max_stage_backups
    WHERE manager_id = ? AND platform = ? AND record_key > ?
    ORDER BY record_key LIMIT 11
  `).bind(identity.manager_id, identity.platform, cursor).all();
  const page = (rows.results || []).slice(0, 10);
  const key = page.length ? await importDataEncryptionKey(env) : null;
  const records = [];
  for (const row of page) {
    try {
      if (Number(row.key_version) !== DATA_ENCRYPTION_KEY_VERSION) {
        throw new Error('UNSUPPORTED_KEY_VERSION');
      }
      const bytes = await crypto.subtle.decrypt(
        {
          name: 'AES-GCM',
          iv: base64UrlToBytes(row.iv),
          additionalData: maxBackupAad(identity, row.record_key),
          tagLength: 128,
        },
        key,
        base64UrlToBytes(row.ciphertext)
      );
      records.push({
        record_key: row.record_key,
        value: JSON.parse(new TextDecoder().decode(bytes)),
        saved_at: Number(row.updated_at || 0),
      });
    } catch (_) {
      throw new PublicError('BACKUP_DECRYPT_FAILED', 503);
    }
  }
  return jsonResponse({
    ok: true,
    records,
    next_cursor: (rows.results || []).length > 10 ? page.at(-1).record_key : '',
  }, 200, true);
}

async function copyStageBackupsForStageMerge(env, sourceManagerId, targetManagerId) {
  if (!sourceManagerId || sourceManagerId === targetManagerId) return;
  const rows = await env.DB.prepare(`
    SELECT platform, record_key, ciphertext, iv, key_version, updated_at
    FROM max_stage_backups WHERE manager_id = ?
      AND platform IN ('max', 'telegram', 'whatsapp', 'gemspace')
  `).bind(sourceManagerId).all();
  if (!(rows.results || []).length) return;
  const key = await importDataEncryptionKey(env);
  for (const row of rows.results) {
    const source = { manager_id: sourceManagerId, platform: row.platform };
    const target = { manager_id: targetManagerId, platform: row.platform };
    let plaintext;
    let savedAt = Number(row.updated_at || 0);
    try {
      if (Number(row.key_version) !== DATA_ENCRYPTION_KEY_VERSION) {
        throw new Error('UNSUPPORTED_KEY_VERSION');
      }
      plaintext = await crypto.subtle.decrypt(
        { name: 'AES-GCM', iv: base64UrlToBytes(row.iv),
          additionalData: maxBackupAad(source, row.record_key), tagLength: 128 },
        key,
        base64UrlToBytes(row.ciphertext)
      );
    } catch (_) {
      throw new PublicError('BACKUP_DECRYPT_FAILED', 503);
    }
    if (row.record_key === '__roster__') {
      const existing = await env.DB.prepare(`
        SELECT ciphertext, iv, key_version, updated_at
        FROM max_stage_backups
        WHERE manager_id = ? AND platform = ? AND record_key = '__roster__'
      `).bind(targetManagerId, row.platform).first();
      if (existing) {
        let targetRoster;
        let sourceRoster;
        try {
          if (Number(existing.key_version) !== DATA_ENCRYPTION_KEY_VERSION) {
            throw new Error('UNSUPPORTED_KEY_VERSION');
          }
          const targetPlaintext = await crypto.subtle.decrypt(
            { name: 'AES-GCM', iv: base64UrlToBytes(existing.iv),
              additionalData: maxBackupAad(target, '__roster__'), tagLength: 128 },
            key,
            base64UrlToBytes(existing.ciphertext)
          );
          targetRoster = JSON.parse(new TextDecoder().decode(targetPlaintext));
          sourceRoster = JSON.parse(new TextDecoder().decode(plaintext));
        } catch (_) {
          throw new PublicError('BACKUP_DECRYPT_FAILED', 503);
        }
        const sourceNewer = savedAt > Number(existing.updated_at || 0);
        const older = sourceNewer ? targetRoster : sourceRoster;
        const newer = sourceNewer ? sourceRoster : targetRoster;
        const clients = new Map();
        for (const client of [...(older.clients || []), ...(newer.clients || [])]) {
          if (client?.id) clients.set(String(client.id), client);
        }
        const merged = {
          schema: 1,
          clients: [...clients.values()],
          removedClients: { ...(older.removedClients || {}), ...(newer.removedClients || {}) },
          identities: { ...(older.identities || {}), ...(newer.identities || {}) },
          parkPeerId: newer.parkPeerId || older.parkPeerId || '',
          parkName: newer.parkName || older.parkName || '',
        };
        merged.clients = merged.clients.filter((client) => {
          const removed = merged.removedClients[String(client.id)];
          if (!removed) return true;
          if (Number(client.addedAt || 0) > Number(removed.removedAt || 0)) {
            delete merged.removedClients[String(client.id)];
            return true;
          }
          return false;
        });
        for (const removedId of Object.keys(merged.removedClients)) {
          delete merged.identities[removedId];
        }
        plaintext = new TextEncoder().encode(JSON.stringify(merged));
        if (plaintext.length > MAX_BACKUP_RECORD_BYTES) {
          throw new PublicError('BACKUP_RECORD_TOO_LARGE', 413);
        }
        savedAt = Math.max(Date.now(), savedAt + 1, Number(existing.updated_at || 0) + 1);
      }
    }
    const iv = crypto.getRandomValues(new Uint8Array(12));
    const ciphertext = await crypto.subtle.encrypt(
      { name: 'AES-GCM', iv,
        additionalData: maxBackupAad(target, row.record_key), tagLength: 128 },
      key,
      plaintext
    );
    await env.DB.prepare(`
      INSERT INTO max_stage_backups
        (manager_id, platform, record_key, ciphertext, iv, key_version, updated_at)
      VALUES (?, ?, ?, ?, ?, ?, ?)
      ON CONFLICT(manager_id, platform, record_key) DO UPDATE SET
        ciphertext = excluded.ciphertext,
        iv = excluded.iv,
        key_version = excluded.key_version,
        updated_at = excluded.updated_at
      WHERE excluded.updated_at > max_stage_backups.updated_at
    `).bind(
      targetManagerId,
      row.platform,
      row.record_key,
      bytesToBase64Url(new Uint8Array(ciphertext)),
      bytesToBase64Url(iv),
      DATA_ENCRYPTION_KEY_VERSION,
      savedAt
    ).run();
  }
}

async function encryptDecisionSensitive(env, requestId, payload) {
  const key = await importDataEncryptionKey(env);
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const plaintext = new TextEncoder().encode(JSON.stringify(payload));
  const ciphertext = await crypto.subtle.encrypt(
    {
      name: 'AES-GCM',
      iv,
      additionalData: decisionAad(requestId),
      tagLength: 128,
    },
    key,
    plaintext
  );

  return {
    sensitive_ciphertext: bytesToBase64Url(new Uint8Array(ciphertext)),
    sensitive_iv: bytesToBase64Url(iv),
    sensitive_key_version: DATA_ENCRYPTION_KEY_VERSION,
  };
}

async function decryptDecisionSensitive(env, row, strict = false) {
  if (!row?.sensitive_ciphertext) {
    return {
      reason: row?.reason || '',
      manager_task: row?.manager_task || '',
      recommendation: row?.recommendation || '',
      context: parseDecisionContext(row?.context_json),
      resolution_text: row?.resolution_text || '',
    };
  }

  try {
    const keyVersion = Number(row.sensitive_key_version || 0);
    if (keyVersion !== DATA_ENCRYPTION_KEY_VERSION) {
      throw new Error('UNSUPPORTED_KEY_VERSION');
    }

    const key = await importDataEncryptionKey(env);
    const plaintext = await crypto.subtle.decrypt(
      {
        name: 'AES-GCM',
        iv: base64UrlToBytes(row.sensitive_iv),
        additionalData: decisionAad(row.request_id, keyVersion),
        tagLength: 128,
      },
      key,
      base64UrlToBytes(row.sensitive_ciphertext)
    );
    const parsed = JSON.parse(new TextDecoder().decode(plaintext));

    return {
      reason: cleanDecisionText(parsed?.reason, 1200),
      manager_task: cleanDecisionText(parsed?.manager_task, 1200),
      recommendation: cleanDecisionText(parsed?.recommendation, 2200),
      context: normalizeDecisionContext(parsed?.context),
      resolution_text: cleanDecisionText(parsed?.resolution_text, 2200),
    };
  } catch (error) {
    console.error('decision_decryption_failed', {
      request_id: cleanText(row?.request_id, 160),
      name: cleanText(error?.name || 'Error', 80),
      message: redactSensitive(error?.message || String(error), 300),
    });

    if (strict) throw new PublicError('DECISION_DECRYPT_FAILED', 500);

    return {
      reason: '[зашифрованные данные недоступны]',
      manager_task: '',
      recommendation: '',
      context: [],
      resolution_text: '',
      decryption_error: true,
    };
  }
}

async function hydrateDecisionRow(env, row, strict = false) {
  if (!row) return row;
  const sensitive = await decryptDecisionSensitive(env, row, strict);
  return {
    ...row,
    reason: sensitive.reason,
    manager_task: sensitive.manager_task,
    recommendation: sensitive.recommendation,
    context_json: JSON.stringify(sensitive.context || []),
    resolution_text: sensitive.resolution_text,
    decryption_error: Boolean(sensitive.decryption_error),
  };
}

function publicDecisionRow(row, includeContext = true) {
  const output = { ...row };

  if (includeContext) {
    output.context = parseDecisionContext(output.context_json);
  }

  delete output.context_json;
  delete output.sensitive_ciphertext;
  delete output.sensitive_iv;
  delete output.lease_token_hash;
  return output;
}

async function decisionRowForUser(env, row, user, includeContext = true) {
  const hydrated = await hydrateDecisionRow(env, row);
  const output = publicDecisionRow(hydrated, includeContext);

  if (!canSeeErrors(user)) {
    delete output.trace_id;
    delete output.execution_error;
    delete output.delivery_attempts;
    delete output.delivered_at;
    delete output.source;
  }

  return output;
}

async function recordDecisionEvent(
  env,
  requestId,
  eventType,
  actor = null,
  details = {}
) {
  await env.DB.prepare(`
    INSERT INTO decision_events (
      request_id, at, event_type, actor_user_id,
      actor_login, details_json
    ) VALUES (?, ?, ?, ?, ?, ?)
  `).bind(
    cleanText(requestId, 160),
    Date.now(),
    cleanCode(eventType, 80),
    Number(actor?.id || 0),
    cleanText(actor?.login, 80),
    safeDetailsJson(details)
  ).run();
}

async function expireDecisionRequests(env, now = Date.now()) {
  await env.DB.prepare(`
    UPDATE decision_requests
    SET status = 'expired',
        updated_at = ?,
        execution_status = 'expired'
    WHERE status = 'pending' AND expires_at < ?
  `).bind(now, now).run();

  await env.DB.prepare(`
    UPDATE decision_requests
    SET status = 'failed',
        updated_at = ?,
        execution_status = 'delivery_timeout',
        execution_error = 'Скрипт не забрал решение вовремя',
        lease_owner = '',
        lease_token_hash = '',
        lease_expires_at = 0
    WHERE status = 'resolved' AND expires_at < ?
  `).bind(now, now).run();

  await env.DB.prepare(`
    UPDATE decision_requests
    SET status = 'failed',
        updated_at = ?,
        executed_at = ?,
        execution_status = 'attempts_exhausted',
        execution_error = CASE
          WHEN execution_error = '' THEN 'Лимит попыток выполнения исчерпан'
          ELSE execution_error
        END,
        lease_owner = '',
        lease_token_hash = '',
        lease_expires_at = 0
    WHERE status = 'resolved'
      AND execution_attempts >= ?
      AND lease_expires_at > 0
      AND lease_expires_at < ?
  `).bind(
    now,
    now,
    DECISION_MAX_EXECUTION_ATTEMPTS,
    now
  ).run();
}

async function createDecisionRequest(env, payload, source = 'script') {
  const now = Date.now();
  await expireDecisionRequests(env, now);

  const installationId = cleanText(
    payload?.installation_id || payload?.installationId,
    160
  );
  const clientKey = cleanText(
    payload?.client_key || payload?.clientKey,
    160
  );
  const typeCandidate = cleanText(
    payload?.decision_type || payload?.type || payload?.human_type,
    30
  ).toLowerCase();
  const decisionType = typeCandidate === 'call_decision'
    ? 'call'
    : typeCandidate;

  if (!installationId) {
    throw new PublicError('INSTALLATION_ID_REQUIRED', 400);
  }
  if (!clientKey) {
    throw new PublicError('CLIENT_KEY_REQUIRED', 400);
  }
  if (!DECISION_TYPE_VALUES.has(decisionType)) {
    throw new PublicError('DECISION_TYPE_INVALID', 400);
  }

  const [installation, client] = await Promise.all([
    env.DB.prepare(`
      SELECT manager_id, manager_name, stage, platform
      FROM installations WHERE installation_id = ?
    `).bind(installationId).first(),
    env.DB.prepare(`
      SELECT client_label, platform
      FROM client_states
      WHERE installation_id = ? AND client_key = ?
    `).bind(installationId, clientKey).first(),
  ]);

  const requestId = cleanText(payload?.request_id, 160)
    || crypto.randomUUID();
  const requestedExpiry = safeTimestamp(payload?.expires_at);
  const expiresAt = requestedExpiry > now + 5 * 60 * 1000
    && requestedExpiry <= now + 24 * 60 * 60 * 1000
      ? requestedExpiry
      : now + DECISION_TTL_MS;
  const context = normalizeDecisionContext(
    payload?.context || payload?.messages
  );
  const proposedTimeAt = safeTimestamp(
    payload?.proposed_time_at || payload?.call_time_at
  );

  const data = {
    request_id: requestId,
    installation_id: installationId,
    client_key: clientKey,
    client_label: cleanText(
      client?.client_label || payload?.client_label || payload?.client_name,
      120
    ),
    manager_id: cleanText(
      installation?.manager_id || payload?.manager_id,
      120
    ),
    manager_name: cleanText(
      installation?.manager_name || payload?.manager_name,
      120
    ),
    stage: cleanText(installation?.stage || payload?.stage, 120),
    platform: cleanText(
      installation?.platform || client?.platform || payload?.platform,
      40
    ).toLowerCase(),
    decision_type: decisionType,
    reason: redactSensitive(
      payload?.reason || payload?.manager_task || 'Требуется решение',
      1200
    ),
    manager_task: redactSensitive(payload?.manager_task, 1200),
    recommendation: redactSensitive(
      payload?.recommendation || payload?.draft || payload?.message,
      2200
    ),
    context_json: JSON.stringify(context),
    proposed_time_at: proposedTimeAt,
    proposed_time_text: cleanText(
      payload?.proposed_time_text || payload?.call_time_text,
      200
    ),
    trace_id: cleanText(payload?.trace_id, 160),
    source: cleanCode(source, 40) || 'script',
    created_at: now,
    updated_at: now,
    expires_at: expiresAt,
  };

  const encrypted = await encryptDecisionSensitive(env, requestId, {
    reason: data.reason,
    manager_task: data.manager_task,
    recommendation: data.recommendation,
    context,
    resolution_text: '',
  });

  const sameRequest = await env.DB.prepare(`
    SELECT * FROM decision_requests WHERE request_id = ?
  `).bind(requestId).first();

  if (sameRequest) {
    const hydrated = await hydrateDecisionRow(env, sameRequest);
    return {
      row: publicDecisionRow(hydrated),
      created: false,
      deduplicated: true,
    };
  }

  const openRequest = await env.DB.prepare(`
    SELECT * FROM decision_requests
    WHERE installation_id = ?
      AND client_key = ?
      AND decision_type = ?
      AND status IN ('pending', 'resolved')
    ORDER BY created_at DESC
    LIMIT 1
  `).bind(
    installationId,
    clientKey,
    decisionType
  ).first();

  if (openRequest) {
    const hydrated = await hydrateDecisionRow(env, openRequest);
    return {
      row: publicDecisionRow(hydrated),
      created: false,
      deduplicated: true,
    };
  }

  const inserted = await env.DB.prepare(`
    INSERT OR IGNORE INTO decision_requests (
      request_id, installation_id, client_key, client_label,
      manager_id, manager_name, stage, platform,
      decision_type, status, reason, manager_task,
      recommendation, context_json, proposed_time_at,
      proposed_time_text, trace_id, source,
      created_at, updated_at, expires_at,
      sensitive_ciphertext, sensitive_iv, sensitive_key_version,
      next_attempt_at
    ) VALUES (
      ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
      ?, ?, ?, ?
    )
  `).bind(
    data.request_id,
    data.installation_id,
    data.client_key,
    data.client_label,
    data.manager_id,
    data.manager_name,
    data.stage,
    data.platform,
    data.decision_type,
    '',
    '',
    '',
    '[]',
    data.proposed_time_at,
    data.proposed_time_text,
    data.trace_id,
    data.source,
    data.created_at,
    data.updated_at,
    data.expires_at,
    encrypted.sensitive_ciphertext,
    encrypted.sensitive_iv,
    encrypted.sensitive_key_version,
    now
  ).run();

  let row = await env.DB.prepare(`
    SELECT * FROM decision_requests WHERE request_id = ?
  `).bind(requestId).first();

  if (!row && !decisionChanges(inserted)) {
    row = await env.DB.prepare(`
      SELECT * FROM decision_requests
      WHERE installation_id = ?
        AND client_key = ?
        AND decision_type = ?
        AND status IN ('pending', 'resolved')
      ORDER BY created_at DESC LIMIT 1
    `).bind(installationId, clientKey, decisionType).first();
  }

  if (!row) {
    throw new PublicError('DECISION_CREATE_FAILED', 500);
  }

  const created = row.request_id === requestId;

  if (created) {
    await recordDecisionEvent(
      env,
      requestId,
      'created',
      null,
      { type: decisionType, source: data.source }
    );

    await env.DB.prepare(`
      UPDATE client_states
      SET awaiting_human = 1,
          status = 'human',
          updated_at = ?
      WHERE installation_id = ? AND client_key = ?
    `).bind(now, installationId, clientKey).run();
  }

  const hydratedRow = await hydrateDecisionRow(env, row);

  return {
    row: publicDecisionRow(hydratedRow),
    created,
    deduplicated: !created,
  };
}

async function handleDecisionRequest(env, payload, installationAuth) {
  const result = await createDecisionRequest(
    env,
    bindDecisionIdentity(payload, installationAuth),
    'script'
  );

  return jsonResponse({
    ok: true,
    accepted: 'decision_request',
    request_id: result.row.request_id,
    status: result.row.status,
    created: result.created,
    deduplicated: result.deduplicated,
    expires_at: result.row.expires_at,
    server_time: Date.now(),
  }, 200, true);
}

async function handleDecisionPoll(env, payload, installationAuth) {
  const now = Date.now();
  await expireDecisionRequests(env, now);

  const installationId = installationAuth.installation_id;
  const instanceId = cleanText(
    payload?.instance_id || payload?.tab_id || payload?.worker_id,
    160
  );
  const requestId = cleanText(payload?.request_id, 160);
  const clientKey = cleanText(payload?.client_key, 160);

  if (!instanceId) throw new PublicError('INSTANCE_ID_REQUIRED', 400);

  let sql = `
    SELECT * FROM decision_requests
    WHERE installation_id = ?
      AND status = 'resolved'
      AND execution_attempts < ?
      AND next_attempt_at <= ?
      AND (lease_expires_at = 0 OR lease_expires_at <= ?)
  `;
  const params = [
    installationId,
    DECISION_MAX_EXECUTION_ATTEMPTS,
    now,
    now,
  ];

  if (requestId) {
    sql += ' AND request_id = ?';
    params.push(requestId);
  }
  if (clientKey) {
    sql += ' AND client_key = ?';
    params.push(clientKey);
  }

  sql += ' ORDER BY resolved_at ASC LIMIT 10';

  const candidates = (await env.DB.prepare(sql).bind(...params).all()).results || [];
  const decisions = [];

  for (const candidate of candidates) {
    const leaseToken = randomOpaqueSecret(32);
    const leaseTokenHash = await sha256Hex(leaseToken);
    const leaseExpiresAt = Math.min(
      now + DECISION_LEASE_MS,
      Number(candidate.expires_at || now + DECISION_LEASE_MS)
    );

    const claimed = await env.DB.prepare(`
      UPDATE decision_requests
      SET lease_owner = ?,
          lease_token_hash = ?,
          lease_expires_at = ?,
          execution_attempts = execution_attempts + 1,
          delivered_at = CASE WHEN delivered_at = 0 THEN ? ELSE delivered_at END,
          delivery_attempts = delivery_attempts + 1,
          execution_status = 'leased',
          updated_at = ?
      WHERE request_id = ?
        AND installation_id = ?
        AND status = 'resolved'
        AND execution_attempts < ?
        AND next_attempt_at <= ?
        AND (lease_expires_at = 0 OR lease_expires_at <= ?)
    `).bind(
      instanceId,
      leaseTokenHash,
      leaseExpiresAt,
      now,
      now,
      candidate.request_id,
      installationId,
      DECISION_MAX_EXECUTION_ATTEMPTS,
      now,
      now
    ).run();

    if (!decisionChanges(claimed)) continue;

    const claimedRow = await env.DB.prepare(`
      SELECT * FROM decision_requests
      WHERE request_id = ? AND installation_id = ?
    `).bind(candidate.request_id, installationId).first();

    const hydrated = await hydrateDecisionRow(env, claimedRow, true);
    decisions.push({
      request_id: hydrated.request_id,
      client_key: hydrated.client_key,
      client_label: hydrated.client_label,
      decision_type: hydrated.decision_type,
      action: hydrated.resolution_action,
      instruction: hydrated.resolution_text,
      time_at: numberOrZero(hydrated.resolution_time_at),
      resolved_at: numberOrZero(hydrated.resolved_at),
      resolved_by: hydrated.resolved_login,
      trace_id: hydrated.trace_id,
      lease_token: leaseToken,
      lease_expires_at: leaseExpiresAt,
      execution_attempt: numberOrZero(hydrated.execution_attempts),
      max_execution_attempts: DECISION_MAX_EXECUTION_ATTEMPTS,
      prepared_message_hash: hydrated.prepared_message_hash || '',
    });

    await recordDecisionEvent(env, candidate.request_id, 'leased', null, {
      instance_id: instanceId,
      execution_attempt: numberOrZero(hydrated.execution_attempts),
      lease_expires_at: leaseExpiresAt,
    });
  }

  return jsonResponse({
    ok: true,
    decisions,
    lease_ttl_ms: DECISION_LEASE_MS,
    renew_after_ms: 30 * 1000,
    server_time: now,
  }, 200, true);
}

async function getDecisionWithValidLease(
  env,
  payload,
  installationAuth,
  requireUnexpired = true
) {
  const now = Date.now();
  const requestId = cleanText(payload?.request_id, 160);
  const instanceId = cleanText(
    payload?.instance_id || payload?.tab_id || payload?.worker_id,
    160
  );
  const leaseToken = cleanText(payload?.lease_token, 300);

  if (!requestId) throw new PublicError('REQUEST_ID_REQUIRED', 400);
  if (!instanceId) throw new PublicError('INSTANCE_ID_REQUIRED', 400);
  if (!leaseToken) throw new PublicError('LEASE_TOKEN_REQUIRED', 400);

  const row = await env.DB.prepare(`
    SELECT * FROM decision_requests
    WHERE request_id = ? AND installation_id = ?
  `).bind(requestId, installationAuth.installation_id).first();

  if (!row) throw new PublicError('DECISION_NOT_FOUND', 404);
  if (row.status !== 'resolved') {
    if (['executed', 'failed'].includes(row.status)) return row;
    throw new PublicError('DECISION_NOT_READY', 409);
  }

  if (!safeEqual(row.lease_owner, instanceId)) {
    throw new PublicError('LEASE_OWNER_MISMATCH', 409);
  }

  const leaseHash = await sha256Hex(leaseToken);
  if (!safeEqual(row.lease_token_hash, leaseHash)) {
    throw new PublicError('LEASE_TOKEN_INVALID', 403);
  }

  if (requireUnexpired && Number(row.lease_expires_at || 0) < now) {
    throw new PublicError('LEASE_EXPIRED', 409);
  }

  return row;
}

async function handleDecisionLeaseRenew(env, payload, installationAuth) {
  const now = Date.now();
  const row = await getDecisionWithValidLease(
    env,
    payload,
    installationAuth,
    true
  );

  if (row.status !== 'resolved') {
    throw new PublicError('DECISION_ALREADY_PROCESSED', 409);
  }

  const leaseExpiresAt = Math.min(
    now + DECISION_LEASE_MS,
    Number(row.expires_at || now + DECISION_LEASE_MS)
  );
  const updated = await env.DB.prepare(`
    UPDATE decision_requests
    SET lease_expires_at = ?, updated_at = ?
    WHERE request_id = ?
      AND installation_id = ?
      AND status = 'resolved'
      AND lease_owner = ?
      AND lease_token_hash = ?
      AND lease_expires_at >= ?
  `).bind(
    leaseExpiresAt,
    now,
    row.request_id,
    installationAuth.installation_id,
    row.lease_owner,
    row.lease_token_hash,
    now
  ).run();

  if (!decisionChanges(updated)) throw new PublicError('LEASE_LOST', 409);

  return jsonResponse({
    ok: true,
    request_id: row.request_id,
    lease_expires_at: leaseExpiresAt,
    renew_after_ms: 30 * 1000,
    server_time: now,
  }, 200, true);
}

async function handleDecisionPrepared(env, payload, installationAuth) {
  const now = Date.now();
  const row = await getDecisionWithValidLease(
    env,
    payload,
    installationAuth,
    true
  );
  const messageHash = cleanText(payload?.message_hash, 128).toLowerCase();

  if (!/^[a-f0-9]{64}$/.test(messageHash)) {
    throw new PublicError('MESSAGE_HASH_INVALID', 400);
  }

  const updated = await env.DB.prepare(`
    UPDATE decision_requests
    SET prepared_message_hash = ?, prepared_at = ?, updated_at = ?
    WHERE request_id = ?
      AND installation_id = ?
      AND status = 'resolved'
      AND lease_owner = ?
      AND lease_token_hash = ?
      AND lease_expires_at >= ?
  `).bind(
    messageHash,
    now,
    now,
    row.request_id,
    installationAuth.installation_id,
    row.lease_owner,
    row.lease_token_hash,
    now
  ).run();

  if (!decisionChanges(updated)) throw new PublicError('LEASE_LOST', 409);

  await recordDecisionEvent(env, row.request_id, 'message_prepared', null, {
    message_hash: messageHash,
    execution_attempt: numberOrZero(row.execution_attempts),
  });

  return jsonResponse({
    ok: true,
    request_id: row.request_id,
    prepared_at: now,
    server_time: now,
  }, 200, true);
}

async function handleDecisionAck(env, payload, installationAuth) {
  const now = Date.now();
  const requestId = cleanText(payload?.request_id, 160);
  if (!requestId) throw new PublicError('REQUEST_ID_REQUIRED', 400);

  const existing = await env.DB.prepare(`
    SELECT * FROM decision_requests
    WHERE request_id = ? AND installation_id = ?
  `).bind(requestId, installationAuth.installation_id).first();

  if (!existing) throw new PublicError('DECISION_NOT_FOUND', 404);
  if (['executed', 'failed'].includes(existing.status)) {
    return jsonResponse({
      ok: true,
      request_id: requestId,
      status: existing.status,
      idempotent: true,
      server_time: now,
    }, 200, true);
  }

  const current = await getDecisionWithValidLease(
    env,
    payload,
    installationAuth,
    true
  );
  const rawStatus = cleanText(payload?.status, 40).toLowerCase();
  const outcome = rawStatus || (payload?.success === false
    ? 'terminal_error'
    : 'executed');

  if (!['executed', 'retryable_error', 'terminal_error'].includes(outcome)) {
    throw new PublicError('ACK_STATUS_INVALID', 400);
  }

  const errorText = redactSensitive(
    payload?.error || payload?.message,
    700
  );
  const attempts = numberOrZero(current.execution_attempts);
  const canRetry = outcome === 'retryable_error'
    && attempts < DECISION_MAX_EXECUTION_ATTEMPTS;
  const paused = current.resolution_action === 'pause';
  const nextStatus = outcome === 'executed'
    ? 'executed'
    : (canRetry ? 'resolved' : 'failed');
  const retryDelay = canRetry
    ? DECISION_RETRY_DELAYS_MS[Math.min(
        Math.max(attempts - 1, 0),
        DECISION_RETRY_DELAYS_MS.length - 1
      )]
    : 0;
  const nextAttemptAt = canRetry ? now + retryDelay : 0;
  const executionStatus = outcome === 'executed'
    ? 'success'
    : (canRetry ? 'retry_scheduled' : 'terminal_error');

  const updated = await env.DB.prepare(`
    UPDATE decision_requests
    SET status = ?,
        executed_at = ?,
        execution_status = ?,
        execution_error = ?,
        next_attempt_at = ?,
        lease_owner = '',
        lease_token_hash = '',
        lease_expires_at = 0,
        updated_at = ?
    WHERE request_id = ?
      AND installation_id = ?
      AND status = 'resolved'
      AND lease_owner = ?
      AND lease_token_hash = ?
      AND lease_expires_at >= ?
  `).bind(
    nextStatus,
    outcome === 'executed' || !canRetry ? now : 0,
    executionStatus,
    outcome === 'executed' ? '' : (errorText || 'Ошибка выполнения решения'),
    nextAttemptAt,
    now,
    requestId,
    installationAuth.installation_id,
    current.lease_owner,
    current.lease_token_hash,
    now
  ).run();

  if (!decisionChanges(updated)) throw new PublicError('LEASE_LOST', 409);

  await recordDecisionEvent(
    env,
    requestId,
    outcome === 'executed'
      ? 'executed'
      : (canRetry ? 'execution_retry_scheduled' : 'execution_failed'),
    null,
    {
      error: outcome === 'executed' ? '' : errorText,
      execution_attempt: attempts,
      next_attempt_at: nextAttemptAt,
    }
  );

  await env.DB.prepare(`
    UPDATE client_states
    SET awaiting_human = ?,
        status = ?,
        last_success_at = CASE WHEN ? = 1 THEN ? ELSE last_success_at END,
        last_error_code = CASE
          WHEN ? = 1 THEN ''
          WHEN ? = 1 THEN 'DECISION_RETRY_SCHEDULED'
          ELSE 'DECISION_EXECUTION_FAILED'
        END,
        updated_at = ?
    WHERE installation_id = ? AND client_key = ?
  `).bind(
    outcome === 'executed' && !paused ? 0 : 1,
    outcome === 'executed'
      ? (paused ? 'paused' : 'waiting_client')
      : (canRetry ? 'human' : 'error'),
    outcome === 'executed' ? 1 : 0,
    now,
    outcome === 'executed' ? 1 : 0,
    canRetry ? 1 : 0,
    now,
    installationAuth.installation_id,
    current.client_key
  ).run();

  return jsonResponse({
    ok: true,
    request_id: requestId,
    status: nextStatus,
    retry_scheduled: canRetry,
    next_attempt_at: nextAttemptAt,
    attempts,
    max_attempts: DECISION_MAX_EXECUTION_ATTEMPTS,
    server_time: now,
  }, 200, true);
}

async function handleInstallationEnrollment(request, env) {
  await enforceRateLimit(
    env.ENROLL_RATE_LIMITER,
    `enroll:${request.headers.get('CF-Connecting-IP') || 'unknown'}`,
    'ENROLL_RATE_LIMITED'
  );

  const payload = await readJsonBody(request);
  const activationCode = cleanText(
    payload?.activation_code || payload?.code,
    400
  );
  const parsed = parseScopedToken(activationCode, ACTIVATION_CODE_PREFIX);
  if (!parsed) throw new PublicError('ACTIVATION_CODE_INVALID', 400);

  const now = Date.now();
  const codeRow = await env.DB.prepare(`
    SELECT * FROM enrollment_codes WHERE code_id = ?
  `).bind(parsed.id).first();

  if (!codeRow || codeRow.status !== 'active') {
    throw new PublicError('ACTIVATION_CODE_INVALID', 403);
  }
  if (Number(codeRow.expires_at || 0) < now) {
    await env.DB.prepare(`
      UPDATE enrollment_codes SET status = 'expired'
      WHERE code_id = ? AND status = 'active'
    `).bind(codeRow.code_id).run();
    throw new PublicError('ACTIVATION_CODE_EXPIRED', 410);
  }

  const providedHash = await sha256Hex(parsed.token);
  if (!safeEqual(providedHash, codeRow.code_hash)) {
    throw new PublicError('ACTIVATION_CODE_INVALID', 403);
  }

  // Новые скрипты всегда сообщают фактическую платформу. Код, созданный
  // для Telegram, нельзя случайно или намеренно активировать в другом мессенджере.
  // Отсутствие поля пока разрешено для плавного обновления старых скриптов.
  const requestedPlatform = cleanText(
    payload?.platform,
    40
  ).toLowerCase();

  if (
    requestedPlatform &&
    !['max', 'telegram', 'whatsapp', 'gemspace'].includes(requestedPlatform)
  ) {
    throw new PublicError('PLATFORM_INVALID', 400);
  }

  if (
    requestedPlatform &&
    codeRow.platform !== 'unknown' &&
    requestedPlatform !== codeRow.platform
  ) {
    throw new PublicError('ACTIVATION_PLATFORM_MISMATCH', 409);
  }

  const installationId = cleanText(
    payload?.installation_id || payload?.installationId,
    160
  ) || crypto.randomUUID();
  const existingCredential = await env.DB.prepare(`
    SELECT credential_id, status FROM installation_credentials
    WHERE installation_id = ?
  `).bind(installationId).first();
  if (existingCredential) {
    throw new PublicError('INSTALLATION_ALREADY_ENROLLED', 409);
  }

  const credentialId = randomOpaqueSecret(12);
  const token = [INSTALLATION_TOKEN_PREFIX, credentialId, randomOpaqueSecret(32)].join('.');
  const tokenHash = await sha256Hex(token);
  let activationResults;

  try {
    // D1 batch: код, credential и installation фиксируются одной транзакцией.
    // changes() ограничивает INSERT результатом непосредственно предыдущего UPDATE.
    activationResults = await env.DB.batch([
      env.DB.prepare(`
        UPDATE enrollment_codes
        SET status = 'used', used_at = ?, installation_id = ?
        WHERE code_id = ? AND status = 'active' AND expires_at >= ?
      `).bind(now, installationId, codeRow.code_id, now),
      env.DB.prepare(`
        INSERT INTO installation_credentials (
          credential_id, installation_id, token_hash, activation_code_id,
          status, manager_id, manager_name, stage, platform,
          created_at, updated_at, last_used_at, last_ip, last_user_agent
        )
        SELECT ?, ?, ?, code_id, 'active', manager_id, manager_name, stage, platform,
               ?, ?, ?, ?, ?
        FROM enrollment_codes
        WHERE code_id = ? AND status = 'used'
          AND installation_id = ? AND used_at = ? AND changes() = 1
      `).bind(
        credentialId, installationId, tokenHash, now, now, now,
        cleanText(request.headers.get('CF-Connecting-IP'), 60),
        cleanText(request.headers.get('User-Agent'), 200),
        codeRow.code_id, installationId, now
      ),
      env.DB.prepare(`
        INSERT INTO installations (
          installation_id, manager_id, manager_name, stage, platform,
          script_version, state, online, last_seen_at, created_at, updated_at
        )
        SELECT installation_id, manager_id, manager_name, stage, platform,
               ?, 'enrolled', 0, 0, ?, ?
        FROM installation_credentials
        WHERE credential_id = ? AND installation_id = ?
        ON CONFLICT(installation_id) DO UPDATE SET
          manager_id = excluded.manager_id,
          manager_name = excluded.manager_name,
          stage = excluded.stage,
          platform = excluded.platform,
          script_version = excluded.script_version,
          updated_at = excluded.updated_at
      `).bind(cleanText(payload?.script_version, 40), now, now, credentialId, installationId),
    ]);
  } catch (_) {
    throw new PublicError('ENROLLMENT_CONFLICT', 409);
  }
  if (!decisionChanges(activationResults[0])) {
    throw new PublicError('ACTIVATION_CODE_ALREADY_USED', 409);
  }
  if (!decisionChanges(activationResults[1]) || !decisionChanges(activationResults[2])) {
    throw new PublicError('ENROLLMENT_CONFLICT', 409);
  }

  return jsonResponse({
    ok: true,
    installation_id: installationId,
    installation_token: token,
    manager_id: codeRow.manager_id,
    manager_name: codeRow.manager_name,
    stage: codeRow.stage,
    platform: codeRow.platform,
    token_shown_once: true,
    server_time: now,
  }, 201, true);
}

function normalizeStageKey(value) {
  return cleanText(value, 120)
    .trim()
    .toLocaleLowerCase('ru-RU')
    .replace(/\s+/g, ' ');
}

async function findRegisteredStage(env, stage) {
  const stageNorm = normalizeStageKey(stage);
  if (!stageNorm) return null;

  const alias = await env.DB.prepare(`
    SELECT
      s.manager_id,
      s.canonical_stage,
      s.stage_norm
    FROM manager_stage_aliases a
    INNER JOIN manager_stages s
      ON s.manager_id = a.manager_id
    WHERE a.alias_norm = ? AND s.active = 1
    LIMIT 1
  `).bind(stageNorm).first();

  if (alias) return alias;

  return await env.DB.prepare(`
    SELECT manager_id, canonical_stage, stage_norm
    FROM manager_stages
    WHERE (stage_norm = ? OR canonical_stage = ?) AND active = 1
    LIMIT 1
  `).bind(stageNorm, cleanText(stage, 120).trim()).first();
}

async function resolveRegisteredStage(env, stage, createIfMissing = true) {
  const displayStage = cleanText(stage, 120).trim();
  const stageNorm = normalizeStageKey(displayStage);
  if (!stageNorm) throw new PublicError('STAGE_REQUIRED', 400);

  const existing = await findRegisteredStage(env, displayStage);
  if (existing || !createIfMissing) return existing;

  const now = Date.now();
  const managerId = `mgr_${randomOpaqueSecret(12)}`;

  await env.DB.prepare(`
    INSERT OR IGNORE INTO manager_stages (
      manager_id, canonical_stage, stage_norm,
      active, created_at, updated_at
    ) VALUES (?, ?, ?, 1, ?, ?)
  `).bind(
    managerId,
    displayStage,
    stageNorm,
    now,
    now
  ).run();

  const registered = await findRegisteredStage(env, displayStage);
  if (!registered) throw new PublicError('STAGE_REGISTRY_ERROR', 500);

  await env.DB.prepare(`
    INSERT OR IGNORE INTO manager_stage_aliases (
      alias_norm, manager_id, canonical_stage, created_at, updated_at
    ) VALUES (?, ?, ?, ?, ?)
  `).bind(
    stageNorm,
    registered.manager_id,
    registered.canonical_stage,
    now,
    now
  ).run();

  return registered;
}

async function canonicalizeLegacyIngestPayload(env, payload) {
  const rawStage = cleanText(
    payload?.installation?.stage || payload?.stage,
    120
  );
  if (!rawStage) return payload;

  const registered = await findRegisteredStage(env, rawStage);
  if (!registered) return payload;

  const canonical = {
    ...(payload || {}),
    manager_id: registered.manager_id,
    manager_name: registered.canonical_stage,
    stage: registered.canonical_stage,
  };

  if (payload?.installation && typeof payload.installation === 'object') {
    canonical.installation = {
      ...payload.installation,
      manager_id: registered.manager_id,
      manager_name: registered.canonical_stage,
      stage: registered.canonical_stage,
    };
  }

  return canonical;
}

async function handleAdminInstallationInvite(request, env, user) {
  if (!isSameOriginRequest(request)) {
    return jsonResponse({ ok: false, error: 'REQUEST_REJECTED' }, 403);
  }

  const payload = await readJsonBody(request);
  const requestedStage = cleanText(payload?.stage, 120).trim();
  const platform = cleanText(payload?.platform, 40).toLowerCase();
  const ttlMinutes = clampInt(
    payload?.ttl_minutes || ENROLLMENT_TTL_MS / 60000,
    5,
    24 * 60
  );

  if (!requestedStage) throw new PublicError('STAGE_REQUIRED', 400);
  if (!['max', 'telegram', 'whatsapp', 'gemspace'].includes(platform)) {
    throw new PublicError('PLATFORM_INVALID', 400);
  }

  const now = Date.now();
  // Код для уже известного стейджа получает тот же постоянный manager_id.
  // Поэтому переустановка и подключение другого мессенджера больше не
  // создают нового менеджера в статистике.
  const registeredStage = await resolveRegisteredStage(
    env,
    requestedStage,
    true
  );
  const managerId = registeredStage.manager_id;
  const stage = registeredStage.canonical_stage;
  const managerName = stage;
  const codeId = randomOpaqueSecret(12);
  const activationCode = [
    ACTIVATION_CODE_PREFIX,
    codeId,
    randomOpaqueSecret(24),
  ].join('.');

  await env.DB.prepare(`
    INSERT INTO enrollment_codes (
      code_id, code_hash, status, manager_id, manager_name,
      stage, platform, created_by, created_login, created_at, expires_at
    ) VALUES (?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?)
  `).bind(
    codeId,
    await sha256Hex(activationCode),
    managerId,
    managerName,
    stage,
    platform,
    Number(user.id),
    cleanText(user.login, 80),
    now,
    now + ttlMinutes * 60 * 1000
  ).run();

  await writeAudit(
    env,
    user,
    'installation_invite_created',
    `${stage} (${platform})`,
    request
  );

  return jsonResponse({
    ok: true,
    activation_code: activationCode,
    stage,
    platform,
    expires_at: now + ttlMinutes * 60 * 1000,
    shown_once: true,
    server_time: now,
  });
}

// Prepare the whole memory move before the first merge write. A source person
// needs a new global person_id: both manager_id and person_id authenticate its
// AES-GCM records. The returned statements run with credential reassignment.
async function prepareMemoryStageMerge(env, sourceManagerId, targetManagerId) {
  if (sourceManagerId === targetManagerId) return [];
  const [peopleResult, linksResult, messagesResult] = await Promise.all([
    env.DB.prepare('SELECT * FROM memory_people WHERE manager_id=? LIMIT ?').bind(sourceManagerId,MAX_MEMORY_MERGE_STATEMENTS + 1).all(),
    env.DB.prepare('SELECT * FROM memory_chat_links WHERE manager_id=? LIMIT ?').bind(sourceManagerId,MAX_MEMORY_MERGE_STATEMENTS + 1).all(),
    env.DB.prepare('SELECT * FROM memory_messages WHERE manager_id=? LIMIT ?').bind(sourceManagerId,MAX_MEMORY_MERGE_MESSAGES + 1).all()
  ]);
  const people=peopleResult.results||[], links=linksResult.results||[], messages=messagesResult.results||[];
  // Six installation/telemetry updates join this same D1 transaction below.
  if (messages.length > MAX_MEMORY_MERGE_MESSAGES ||
      11 + 2*people.length + links.length + (messages.length ? 1 : 0) > MAX_MEMORY_MERGE_STATEMENTS) {
    throw new PublicError('MEMORY_MERGE_TOO_LARGE',409);
  }
  const [keyConflict, linkConflict] = await Promise.all([
    env.DB.prepare(`SELECT 1 FROM memory_people s JOIN memory_people t ON t.manager_id=? AND t.memory_key=s.memory_key WHERE s.manager_id=? LIMIT 1`).bind(targetManagerId,sourceManagerId).first(),
    env.DB.prepare(`SELECT 1 FROM memory_chat_links s JOIN memory_chat_links t ON t.manager_id=? AND t.platform=s.platform AND t.client_key=s.client_key WHERE s.manager_id=? LIMIT 1`).bind(targetManagerId,sourceManagerId).first()
  ]);
  if (keyConflict || linkConflict) throw new PublicError('MEMORY_MERGE_CONFLICT',409);
  const mappedIds=new Map(people.map(person=>[person.person_id,crypto.randomUUID()]));
  const revisionChecks=people.map(() => 'OR NOT EXISTS(SELECT 1 FROM memory_people WHERE manager_id=? AND person_id=? AND revision=?)').join(' ');
  const messageSnapshot=JSON.stringify(messages.map(message=>({
    person_id:message.person_id, platform:message.platform, client_key:message.client_key,
    message_id:message.message_id, direction:message.direction, time:message.time,
    type:message.type, ciphertext:message.ciphertext, iv:message.iv,
    key_version:message.key_version
  })));
  if (new TextEncoder().encode(messageSnapshot).length > MAX_MEMORY_MERGE_JSON_BYTES) {
    throw new PublicError('MEMORY_MERGE_TOO_LARGE',409);
  }
  const guard=env.DB.prepare(`INSERT INTO memory_people(person_id,manager_id,memory_key,created_at,updated_at)
    SELECT NULL,NULL,NULL,0,0 WHERE
      (SELECT COUNT(*) FROM memory_people WHERE manager_id=?)<>?
      OR (SELECT COUNT(*) FROM memory_chat_links WHERE manager_id=?)<>?
      OR (SELECT COUNT(*) FROM memory_messages WHERE manager_id=?)<>?
      OR EXISTS(SELECT 1 FROM json_each(?) snapshot
        LEFT JOIN memory_messages current ON current.manager_id=?
          AND current.person_id=json_extract(snapshot.value,'$.person_id')
          AND current.platform=json_extract(snapshot.value,'$.platform')
          AND current.client_key=json_extract(snapshot.value,'$.client_key')
          AND current.message_id=json_extract(snapshot.value,'$.message_id')
        WHERE current.ciphertext IS NULL
          OR current.ciphertext<>json_extract(snapshot.value,'$.ciphertext')
          OR current.iv<>json_extract(snapshot.value,'$.iv')
          OR current.key_version<>json_extract(snapshot.value,'$.key_version')
          OR current.direction<>json_extract(snapshot.value,'$.direction')
          OR current.time<>json_extract(snapshot.value,'$.time')
          OR current.type<>json_extract(snapshot.value,'$.type'))
      ${revisionChecks}`)
    .bind(sourceManagerId,people.length,sourceManagerId,links.length,sourceManagerId,messages.length,
      messageSnapshot,sourceManagerId,
      ...people.flatMap(person=>[sourceManagerId,person.person_id,person.revision]));
  const statements=[guard];
  for (const person of people) {
    const personId=mappedIds.get(person.person_id);
    let dossier=['','',0];
    if (person.dossier_ciphertext) {
      const value=await memoryDecrypt(env,sourceManagerId,person.person_id,'dossier','profile',person.dossier_ciphertext,person.dossier_iv,person.dossier_key_version);
      dossier=await memoryEncrypt(env,targetManagerId,personId,'dossier','profile',value);
    }
    statements.push(env.DB.prepare(`INSERT INTO memory_people(person_id,manager_id,memory_key,dossier_ciphertext,dossier_iv,dossier_key_version,revision,incomplete,created_at,updated_at)
      VALUES(?,?,?,?,?,?,?,?,?,?)`).bind(personId,targetManagerId,person.memory_key,...dossier,person.revision,person.incomplete,person.created_at,person.updated_at));
  }
  for (const link of links) {
    const personId=mappedIds.get(link.person_id);
    if (!personId) throw new PublicError('MEMORY_MERGE_INCONSISTENT',409);
    statements.push(env.DB.prepare(`INSERT INTO memory_chat_links(manager_id,platform,client_key,person_id,linked_at,source,import_complete)
      VALUES(?,?,?,?,?,?,?)`).bind(targetManagerId,link.platform,link.client_key,personId,link.linked_at,link.source,link.import_complete));
  }
  const encryptedMessages=[];
  for (const message of messages) {
    const personId=mappedIds.get(message.person_id);
    if (!personId) throw new PublicError('MEMORY_MERGE_INCONSISTENT',409);
    const recordId=memoryRecordId(message.platform,message.client_key,message.message_id);
    const value=await memoryDecrypt(env,sourceManagerId,message.person_id,'message',recordId,message.ciphertext,message.iv,message.key_version);
    if (value.direction!==message.direction || value.time!==message.time || value.type!==message.type) throw new PublicError('MEMORY_DECRYPTION_FAILED',503);
    const encrypted=await memoryEncrypt(env,targetManagerId,personId,'message',recordId,value);
    encryptedMessages.push({manager_id:targetManagerId,person_id:personId,platform:message.platform,
      client_key:message.client_key,message_id:message.message_id,direction:message.direction,
      time:message.time,type:message.type,ciphertext:encrypted[0],iv:encrypted[1],
      key_version:encrypted[2],created_at:message.created_at});
  }
  if (encryptedMessages.length) {
    const json=JSON.stringify(encryptedMessages);
    if (new TextEncoder().encode(json).length > MAX_MEMORY_MERGE_JSON_BYTES) {
      throw new PublicError('MEMORY_MERGE_TOO_LARGE',409);
    }
    statements.push(env.DB.prepare(`INSERT INTO memory_messages(manager_id,person_id,platform,client_key,message_id,direction,time,type,ciphertext,iv,key_version,created_at)
      SELECT json_extract(value,'$.manager_id'),json_extract(value,'$.person_id'),
        json_extract(value,'$.platform'),json_extract(value,'$.client_key'),
        json_extract(value,'$.message_id'),json_extract(value,'$.direction'),
        json_extract(value,'$.time'),json_extract(value,'$.type'),
        json_extract(value,'$.ciphertext'),json_extract(value,'$.iv'),
        json_extract(value,'$.key_version'),json_extract(value,'$.created_at')
      FROM json_each(?)`).bind(json));
  }
  for (const person of people) statements.push(env.DB.prepare('UPDATE memory_link_events SET manager_id=?,person_id=? WHERE manager_id=? AND person_id=?')
    .bind(targetManagerId,mappedIds.get(person.person_id),sourceManagerId,person.person_id));
  // Historical events for already-deleted people have no parent to remap.
  statements.push(env.DB.prepare('UPDATE memory_link_events SET manager_id=? WHERE manager_id=?').bind(targetManagerId,sourceManagerId));
  statements.push(env.DB.prepare('DELETE FROM memory_messages WHERE manager_id=?').bind(sourceManagerId));
  statements.push(env.DB.prepare('DELETE FROM memory_chat_links WHERE manager_id=?').bind(sourceManagerId));
  statements.push(env.DB.prepare('DELETE FROM memory_people WHERE manager_id=?').bind(sourceManagerId));
  return statements;
}

function selectKeptStageMergeInstallations(rows, targetInstallationIds) {
  const preferred = [];
  const preferredIds = new Set();
  const preferredPlatforms = new Set();
  for (const row of rows) {
    const platform = String(row.platform || 'unknown').toLowerCase();
    if (Number(row.personal_token_active) !== 1 ||
        !targetInstallationIds.has(row.installation_id) ||
        preferredPlatforms.has(platform)) continue;
    preferred.push(row);
    preferredIds.add(row.installation_id);
    preferredPlatforms.add(platform);
  }
  const ordered = [...preferred, ...rows.filter(row => !preferredIds.has(row.installation_id))];
  const keptPlatforms = new Set();
  const kept = [];
  const superseded = [];
  for (const row of ordered) {
    const platform = String(row.platform || 'unknown').toLowerCase();
    if (!keptPlatforms.has(platform)) {
      keptPlatforms.add(platform);
      kept.push(row.installation_id);
    } else {
      superseded.push(row.installation_id);
    }
  }
  return {kept, superseded};
}

async function handleAdminStageMerge(request, env, user) {
  if (!isSameOriginRequest(request)) {
    return jsonResponse({ ok: false, error: 'REQUEST_REJECTED' }, 403);
  }

  const payload = await readJsonBody(request);
  const sourceInput = cleanText(payload?.source_stage, 120).trim();
  const targetInput = cleanText(payload?.target_stage, 120).trim();

  if (!sourceInput || !targetInput) {
    throw new PublicError('STAGES_REQUIRED', 400);
  }
  if (normalizeStageKey(sourceInput) === normalizeStageKey(targetInput)) {
    throw new PublicError('STAGES_MUST_DIFFER', 400);
  }

  const now = Date.now();
  const source = await resolveRegisteredStage(env, sourceInput, true);
  const target = await resolveRegisteredStage(env, targetInput, true);
  const sourceStage = source.canonical_stage;
  const targetStage = target.canonical_stage;
  const targetManagerId = target.manager_id;
  const targetRows = await env.DB.prepare(`SELECT installation_id FROM installations
    WHERE manager_id=?`).bind(targetManagerId).all();
  const targetInstallationIds = new Set((targetRows.results || []).map(row => row.installation_id));

  const memoryMove = await prepareMemoryStageMerge(env,source.manager_id,targetManagerId);

  // Перед сменой manager_id перепаковываем копии с новым AES-GCM AAD.
  // Исходные строки остаются до успешного завершения объединения.
  await copyStageBackupsForStageMerge(env, source.manager_id, targetManagerId);

  // Если псевдоним уже направлен в нужный стейдж, повторный клик безопасен.
  if (
    source.manager_id !== targetManagerId ||
    sourceStage !== targetStage
  ) {
    const identityUpdates = [
      env.DB.prepare(`
        UPDATE installations
        SET manager_id = ?, manager_name = ?, stage = ?, updated_at = ?
        WHERE manager_id = ? OR stage = ?
      `).bind(
        targetManagerId, targetStage, targetStage, now,
        source.manager_id, sourceStage
      ),
      env.DB.prepare(`
        UPDATE installation_credentials
        SET manager_id = ?, manager_name = ?, stage = ?, updated_at = ?
        WHERE manager_id = ? OR stage = ?
      `).bind(
        targetManagerId, targetStage, targetStage, now,
        source.manager_id, sourceStage
      ),
      env.DB.prepare(`
        UPDATE enrollment_codes
        SET manager_id = ?, manager_name = ?, stage = ?
        WHERE manager_id = ? OR stage = ?
      `).bind(
        targetManagerId, targetStage, targetStage,
        source.manager_id, sourceStage
      ),
      env.DB.prepare(`
        UPDATE event_logs
        SET manager_id = ?, stage = ?
        WHERE manager_id = ? OR stage = ?
      `).bind(
        targetManagerId, targetStage,
        source.manager_id, sourceStage
      ),
      env.DB.prepare(`
        UPDATE decision_requests
        SET manager_id = ?, manager_name = ?, stage = ?, updated_at = ?
        WHERE manager_id = ? OR stage = ?
      `).bind(
        targetManagerId, targetStage, targetStage, now,
        source.manager_id, sourceStage
      ),
      env.DB.prepare(`
        UPDATE ai_usage
        SET manager_id = ?, stage = ?
        WHERE manager_id = ? OR stage = ?
      `).bind(
        targetManagerId, targetStage,
        source.manager_id, sourceStage
      ),
    ];

    await env.DB.batch([...memoryMove,...identityUpdates]);
  }

  // Доступ тимлидера также переносится на правильное написание.
  if (sourceStage !== targetStage) {
    await env.DB.prepare(`
      INSERT OR IGNORE INTO user_stages (user_id, stage)
      SELECT user_id, ?
      FROM user_stages
      WHERE stage = ?
    `).bind(targetStage, sourceStage).run();
    await env.DB.prepare(
      'DELETE FROM user_stages WHERE stage = ?'
    ).bind(sourceStage).run();
  }

  await env.DB.prepare(`
    INSERT INTO manager_stage_aliases (
      alias_norm, manager_id, canonical_stage, created_at, updated_at
    ) VALUES (?, ?, ?, ?, ?)
    ON CONFLICT(alias_norm) DO UPDATE SET
      manager_id = excluded.manager_id,
      canonical_stage = excluded.canonical_stage,
      updated_at = excluded.updated_at
  `).bind(
    normalizeStageKey(sourceInput),
    targetManagerId,
    targetStage,
    now,
    now
  ).run();

  await env.DB.prepare(`
    INSERT INTO manager_stage_aliases (
      alias_norm, manager_id, canonical_stage, created_at, updated_at
    ) VALUES (?, ?, ?, ?, ?)
    ON CONFLICT(alias_norm) DO UPDATE SET
      manager_id = excluded.manager_id,
      canonical_stage = excluded.canonical_stage,
      updated_at = excluded.updated_at
  `).bind(
    normalizeStageKey(sourceStage),
    targetManagerId,
    targetStage,
    now,
    now
  ).run();

  if (source.manager_id !== targetManagerId) {
    await env.DB.prepare(`
      UPDATE manager_stages
      SET active = 0, updated_at = ?
      WHERE manager_id = ?
    `).bind(now, source.manager_id).run();
  }

  // После объединения на каждой платформе должен остаться один исполнитель.
  // Сохраняем активную установку правильного стейджа на каждой платформе.
  // Если её нет, используем активную установку с самым свежим сигналом.
  const installations = await env.DB.prepare(`
    SELECT
      i.installation_id,
      i.platform,
      i.last_seen_at,
      i.updated_at,
      CASE WHEN c.status = 'active' THEN 1 ELSE 0 END AS personal_token_active
    FROM installations i
    LEFT JOIN installation_credentials c
      ON c.installation_id = i.installation_id
    WHERE i.manager_id = ? AND i.stage = ?
    ORDER BY
      personal_token_active DESC,
      i.last_seen_at DESC,
      i.updated_at DESC,
      i.installation_id DESC
  `).bind(targetManagerId, targetStage).all();

  const {kept, superseded} = selectKeptStageMergeInstallations(
    installations.results || [], targetInstallationIds);

  for (const installationId of superseded) {
    await env.DB.prepare(`
      UPDATE installation_credentials
      SET status = 'revoked', revoked_at = ?, updated_at = ?
      WHERE installation_id = ? AND status <> 'revoked'
    `).bind(now, now, installationId).run();

    await env.DB.prepare(`
      UPDATE installations
      SET online = 0, state = 'superseded', updated_at = ?
      WHERE installation_id = ?
    `).bind(now, installationId).run();

    await env.DB.prepare(
      'DELETE FROM client_states WHERE installation_id = ?'
    ).bind(installationId).run();

    await env.DB.prepare(
      'DELETE FROM installation_stops WHERE installation_id = ?'
    ).bind(installationId).run().catch(() => {});

    await env.DB.prepare(`
      UPDATE decision_requests
      SET status = 'cancelled',
          execution_status = 'installation_superseded',
          execution_error = 'INSTALLATION_SUPERSEDED',
          lease_owner = '', lease_token_hash = '', lease_expires_at = 0,
          reason = '', manager_task = '', recommendation = '',
          context_json = '[]', resolution_text = '',
          sensitive_ciphertext = '', sensitive_iv = '',
          sensitive_key_version = 0, sensitive_purged_at = ?,
          updated_at = ?
      WHERE installation_id = ? AND status IN ('pending', 'resolved')
    `).bind(now, now, installationId).run();
  }

  await writeAudit(
    env,
    user,
    'stages_merged',
    `${sourceInput} -> ${targetStage}; revoked=${superseded.length}`,
    request
  );

  if (source.manager_id !== targetManagerId) {
    await env.DB.prepare(`
      DELETE FROM max_stage_backups WHERE manager_id = ?
    `).bind(source.manager_id).run();
  }

  return jsonResponse({
    ok: true,
    source_stage: sourceInput,
    canonical_stage: targetStage,
    manager_id: targetManagerId,
    kept_installations: kept,
    revoked_installations: superseded,
    server_time: now,
  });
}

async function handleAdminInstallationRotate(request, env, user) {
  if (!isSameOriginRequest(request)) {
    return jsonResponse({ ok: false, error: 'REQUEST_REJECTED' }, 403);
  }

  const payload = await readJsonBody(request);
  const installationId = cleanText(payload?.installation_id, 160);
  if (!installationId) {
    throw new PublicError('INSTALLATION_ID_REQUIRED', 400);
  }

  const credential = await env.DB.prepare(`
    SELECT * FROM installation_credentials WHERE installation_id = ?
  `).bind(installationId).first();
  if (!credential) throw new PublicError('INSTALLATION_NOT_ENROLLED', 404);

  const token = [
    INSTALLATION_TOKEN_PREFIX,
    credential.credential_id,
    randomOpaqueSecret(32),
  ].join('.');
  const now = Date.now();

  await env.DB.prepare(`
    UPDATE installation_credentials
    SET token_hash = ?, status = 'active', rotated_at = ?, revoked_at = 0,
        updated_at = ?
    WHERE credential_id = ?
  `).bind(
    await sha256Hex(token),
    now,
    now,
    credential.credential_id
  ).run();

  await writeAudit(
    env,
    user,
    'installation_token_rotated',
    installationId,
    request
  );

  return jsonResponse({
    ok: true,
    installation_id: installationId,
    installation_token: token,
    shown_once: true,
    server_time: now,
  });
}

async function handleAdminInstallationRevoke(request, env, user) {
  if (!isSameOriginRequest(request)) {
    return jsonResponse({ ok: false, error: 'REQUEST_REJECTED' }, 403);
  }

  const payload = await readJsonBody(request);
  const installationId = cleanText(payload?.installation_id, 160);
  if (!installationId) {
    throw new PublicError('INSTALLATION_ID_REQUIRED', 400);
  }

  const now = Date.now();
  const result = await env.DB.prepare(`
    UPDATE installation_credentials
    SET status = 'revoked', revoked_at = ?, updated_at = ?
    WHERE installation_id = ? AND status <> 'revoked'
  `).bind(now, now, installationId).run();

  if (!decisionChanges(result)) {
    throw new PublicError('INSTALLATION_NOT_ENROLLED', 404);
  }

  await env.DB.prepare(`
    UPDATE installations
    SET online = 0, state = 'revoked', updated_at = ?
    WHERE installation_id = ?
  `).bind(now, installationId).run();

  await writeAudit(
    env,
    user,
    'installation_revoked',
    installationId,
    request
  );

  return jsonResponse({
    ok: true,
    installation_id: installationId,
    status: 'revoked',
    server_time: now,
  });
}

async function handleAdminInstallationSecurity(env) {
  const now = Date.now();
  const [credentials, codes, stages] = await Promise.all([
    env.DB.prepare(`
      SELECT credential_id, installation_id, status, manager_id,
             manager_name, stage, platform, created_at, updated_at,
             last_used_at, rotated_at, revoked_at, last_ip
      FROM installation_credentials
      ORDER BY updated_at DESC
      LIMIT 1000
    `).all(),
    env.DB.prepare(`
      SELECT code_id, status, manager_id, manager_name, stage, platform,
             created_login, created_at, expires_at, used_at, installation_id
      FROM enrollment_codes
      WHERE created_at >= ?
      ORDER BY created_at DESC
      LIMIT 300
    `).bind(now - 30 * 24 * 60 * 60 * 1000).all(),
    env.DB.prepare(`
      SELECT stage
      FROM (
        SELECT canonical_stage AS stage
        FROM manager_stages
        WHERE active = 1
        UNION
        SELECT TRIM(stage) AS stage
        FROM installations
        WHERE TRIM(COALESCE(stage, '')) <> ''
      )
      ORDER BY stage COLLATE NOCASE
    `).all(),
  ]);

  return jsonResponse({
    ok: true,
    security: {
      data_encryption_key_configured: isDataEncryptionKeyValid(env),
      api_rate_limiter_configured: Boolean(env.API_RATE_LIMITER),
      ai_gateway_configured: Boolean(env.ANTHROPIC_API_KEY),
      ai_rate_limiter_configured: Boolean(
        env.AI_RATE_LIMITER || env.API_RATE_LIMITER
      ),
      ai_installation_daily_limit_usd:
        aiInstallationDailyLimitUsd(env),
      ai_global_daily_limit_usd:
        aiGlobalDailyLimitUsd(env),
      enrollment_rate_limiter_configured: Boolean(env.ENROLL_RATE_LIMITER),
      login_rate_limiter_configured: Boolean(env.LOGIN_RATE_LIMITER),
      legacy_ingest_enabled: Boolean(env.INGEST_TOKEN),
      decisions_require_personal_tokens: true,
    },
    credentials: credentials.results || [],
    enrollment_codes: codes.results || [],
    stages: (stages.results || [])
      .map((row) => cleanText(row.stage, 120))
      .filter(Boolean),
    server_time: now,
  });
}

async function handleHeartbeat(env, payload) {
  const now = Date.now();
  const data = normalizeInstallation(payload, now);

  await upsertInstallation(env, data, now);
  const supersededInstallations =
    payload?.__auth_mode === 'installation'
      ? await supersedeOtherInstallations(env, data, now)
      : [];

  const stop = await readGlobalStop(env);
  const versions = await readLatestVersions(env);
  const latestVersion = versions[data.platform] || '';
  const personalStop = await readInstallationStop(env, data.installation_id);
  const restart = await readRestartReminder(env);

  return jsonResponse(
    {
      ok: true,
      accepted: 'heartbeat',
      installation_id: data.installation_id,
      ...(payload?.__auth_mode === 'installation'
        ? { identity_confirmed: true, manager_id: data.manager_id,
            manager_name: data.manager_name, stage: data.stage, platform: data.platform }
        : { identity_confirmed: false }),
      server_time: now,
      // Актуальная версия для этой платформы (пусто = не задана)
      latest_version: latestVersion,
      // Ежедневное напоминание о перезапуске браузера
      restart_reminder: restart,
      // Стоп-кран администратора: скрипт обязан прекратить работу
      global_stop: stop.stopped,
      // Индивидуальная остановка именно этой установки
      installation_stop: personalStop,
      superseded_installations: supersededInstallations.length,
      global_stop_at: stop.at,
      global_stop_by: stop.by,
    },
    200,
    true
  );
}

// Повторная установка безопасна: старый персональный токен отключается
// только после первого успешного heartbeat новой установки. Если новая
// активация не запустилась, прежний скрипт продолжает работать.
async function supersedeOtherInstallations(env, data, now) {
  if (!data.manager_id || !data.platform || !data.installation_id) return [];

  const oldRows = await env.DB.prepare(`
    SELECT older.installation_id
    FROM installation_credentials older
    INNER JOIN installation_credentials current
      ON current.installation_id = ?
     AND current.status = 'active'
    WHERE older.manager_id = ?
      AND older.platform = ?
      AND older.installation_id <> ?
      AND older.status = 'active'
      AND (
        older.created_at < current.created_at
        OR (
          older.created_at = current.created_at
          AND older.credential_id < current.credential_id
        )
      )
    ORDER BY older.created_at DESC
    LIMIT 100
  `).bind(
    data.installation_id,
    data.manager_id,
    data.platform,
    data.installation_id
  ).all();

  const ids = (oldRows.results || [])
    .map((row) => cleanText(row.installation_id, 160))
    .filter(Boolean);
  if (!ids.length) return [];

  const placeholders = ids.map(() => '?').join(', ');
  await env.DB.batch([
    env.DB.prepare(`
      UPDATE installation_credentials
      SET status = 'revoked', revoked_at = ?, updated_at = ?
      WHERE status = 'active'
        AND installation_id IN (${placeholders})
    `).bind(now, now, ...ids),
    env.DB.prepare(`
      UPDATE installations
      SET online = 0, state = 'superseded', updated_at = ?
      WHERE installation_id IN (${placeholders})
    `).bind(now, ...ids),
    env.DB.prepare(`
      UPDATE decision_requests
      SET status = 'cancelled',
          execution_status = 'installation_superseded',
          execution_error = 'INSTALLATION_SUPERSEDED',
          lease_owner = '', lease_token_hash = '', lease_expires_at = 0,
          reason = '', manager_task = '', recommendation = '',
          context_json = '[]', resolution_text = '',
          sensitive_ciphertext = '', sensitive_iv = '',
          sensitive_key_version = 0, sensitive_purged_at = ?,
          updated_at = ?
      WHERE installation_id IN (${placeholders})
        AND status IN ('pending', 'resolved')
    `).bind(now, now, ...ids),
  ]);

  return ids;
}

async function handleClients(env, payload) {
  const now = Date.now();
  const meta = normalizeInstallationMeta(payload, now);
  const clients = normalizeClients(payload?.clients, meta, now);
  // Старые версии могли прислать пустой либо частичный массив и тем самым
  // удалить из CRM всех клиентов установки. Полная замена теперь включается
  // только явным подтверждённым снимком нового формата.
  const snapshotPolicy = clientSnapshotPolicy(payload, clients);
  const replace = snapshotPolicy.replace;

  await ensureInstallation(env, meta, now);

  if (replace) {
    await replaceClientSnapshot(
      env,
      meta.installation_id,
      clients,
      snapshotPolicy.allow_empty_replace
    );
  } else {
    await upsertClients(env, clients);
  }

  if (replace) {
    const activeClients = clients.reduce(
      (count, client) => count + (client.enabled ? 1 : 0),
      0
    );

    // Синхронизируем heartbeat-счётчики с уже принятым снимком. Теперь
    // карточка установки и подробная таблица не могут показывать разные
    // количества после успешного POST /clients.
    await env.DB.prepare(`
      UPDATE installations
      SET clients_total = ?,
          clients_active = ?,
          updated_at = ?
      WHERE installation_id = ?
    `).bind(
      clients.length,
      activeClients,
      now,
      meta.installation_id
    ).run();
  }

  return jsonResponse(
    {
      ok: true,
      accepted: 'clients',
      count: clients.length,
      replaced: replace,
      replace_requested: snapshotPolicy.replace_requested,
      snapshot_complete: snapshotPolicy.snapshot_complete,
      snapshot_schema_version: snapshotPolicy.schema_version,
      replace_skipped_reason: snapshotPolicy.skipped_reason,
      installation_id: meta.installation_id,
      server_time: now,
    },
    200,
    true
  );
}

async function handleEvents(env, payload) {
  const now = Date.now();
  const meta = normalizeInstallationMeta(payload, now);
  const rawEvents = Array.isArray(payload?.events)
    ? payload.events
    : [payload?.event || payload];
  const events = normalizeEvents(rawEvents, meta, now);

  await ensureInstallation(env, meta, now);
  await insertEvents(env, events);

  return jsonResponse(
    {
      ok: true,
      accepted: 'events',
      count: events.length,
      installation_id: meta.installation_id,
      server_time: now,
    },
    200,
    true
  );
}

async function handleSnapshot(env, payload) {
  const now = Date.now();
  const installationPayload = payload?.installation || payload;
  const installation = normalizeInstallation(installationPayload, now);
  const meta = {
    installation_id: installation.installation_id,
    manager_id: installation.manager_id,
    stage: installation.stage,
    platform: installation.platform,
    script_version: installation.script_version,
  };
  const clients = normalizeClients(payload?.clients || [], meta, now);
  const events = normalizeEvents(payload?.events || [], meta, now);
  const snapshotPolicy = clientSnapshotPolicy(payload, clients);
  const replaceClients = snapshotPolicy.replace;

  await upsertInstallation(env, installation, now);

  if (replaceClients) {
    await replaceClientSnapshot(
      env,
      meta.installation_id,
      clients,
      snapshotPolicy.allow_empty_replace
    );
  } else {
    await upsertClients(env, clients);
  }

  await insertEvents(env, events);

  return jsonResponse(
    {
      ok: true,
      accepted: 'snapshot',
      clients: clients.length,
      clients_replaced: replaceClients,
      replace_requested: snapshotPolicy.replace_requested,
      snapshot_complete: snapshotPolicy.snapshot_complete,
      snapshot_schema_version: snapshotPolicy.schema_version,
      replace_skipped_reason: snapshotPolicy.skipped_reason,
      events: events.length,
      installation_id: installation.installation_id,
      server_time: now,
    },
    200,
    true
  );
}

function clientSnapshotPolicy(payload, clients) {
  const schemaVersion = clampInt(
    payload?.client_snapshot_schema_version ||
      payload?.snapshot_schema_version ||
      payload?.schema_version ||
      0,
    0,
    100
  );
  const replaceRequested =
    payload?.replace === true || payload?.replace_clients === true;
  const snapshotComplete = payload?.snapshot_complete === true;
  const allowEmpty = payload?.allow_empty_snapshot === true;

  let skippedReason = '';
  if (!replaceRequested) skippedReason = 'replace_not_requested';
  else if (!snapshotComplete) skippedReason = 'snapshot_not_marked_complete';
  else if (schemaVersion < CLIENT_SNAPSHOT_SCHEMA_VERSION) {
    skippedReason = 'snapshot_schema_too_old';
  } else if (!clients.length && !allowEmpty) {
    skippedReason = 'empty_snapshot_requires_confirmation';
  }

  const replace = !skippedReason;
  return {
    replace,
    replace_requested: replaceRequested,
    snapshot_complete: snapshotComplete,
    schema_version: schemaVersion,
    allow_empty_replace: replace && !clients.length && allowEmpty,
    skipped_reason: skippedReason,
  };
}

function normalizeInstallation(payload, now) {
  const meta = normalizeInstallationMeta(payload, now);

  return {
    ...meta,
    manager_name: cleanText(payload?.manager_name, 120),
    session_id: cleanText(payload?.session_id, 160),
    state: cleanText(payload?.state || 'unknown', 40),
    paused: boolInt(payload?.paused),
    busy: boolInt(payload?.busy),
    online: payload?.online === false ? 0 : 1,
    // Старые скрипты без page_visible считаем видимыми. Состояние «в фоне»
    // включается только когда клиент явно передал false или 0.
    page_visible:
      payload?.page_visible === false || payload?.page_visible === 0 ? 0 : 1,
    clients_total: clampInt(payload?.clients_total, 0, 100000),
    clients_active: clampInt(payload?.clients_active, 0, 100000),
    started_at: safeTimestamp(payload?.started_at),
    last_reason: cleanText(payload?.reason, 100),
    created_at: now,
    updated_at: now,
  };
}

function normalizeInstallationMeta(payload, now) {
  const installationId = cleanText(
    payload?.installation_id || payload?.installationId,
    160
  );

  if (!installationId) {
    throw new PublicError('INSTALLATION_ID_REQUIRED', 400);
  }

  return {
    installation_id: installationId,
    manager_id: cleanText(payload?.manager_id, 120),
    stage: cleanText(payload?.stage, 120),
    platform: cleanText(payload?.platform || 'unknown', 40).toLowerCase(),
    script_version: cleanText(payload?.script_version, 40),
    sent_at: safeTimestamp(payload?.sent_at) || now,
  };
}

function normalizeClients(rawClients, meta, now) {
  if (!Array.isArray(rawClients)) {
    throw new PublicError('CLIENTS_ARRAY_REQUIRED', 400);
  }

  if (rawClients.length > MAX_CLIENTS_PER_REQUEST) {
    throw new PublicError('TOO_MANY_CLIENTS', 413);
  }

  return rawClients.map((raw) => {
    const clientKey = cleanText(
      raw?.client_key || raw?.clientKey,
      160
    );

    if (!clientKey) {
      throw new PublicError('CLIENT_KEY_REQUIRED', 400);
    }

    const enabled = raw?.enabled === false ? 0 : 1;
    const onCall = boolInt(raw?.on_call);
    const awaitingHuman = boolInt(raw?.awaiting_human);
    const lastInAt = safeObservedTimestamp(raw?.last_in_at, now);
    const lastOutAt = safeObservedTimestamp(raw?.last_out_at, now);
    const lastSender = normalizeLastSender(raw?.last_sender);
    const status = deriveClientStatus({
      requested: raw?.status,
      enabled,
      onCall,
      awaitingHuman,
      lastInAt,
      lastOutAt,
      lastSender,
    });

    return {
      installation_id: meta.installation_id,
      client_key: clientKey,
      client_label: cleanText(raw?.client_label, 120),
      platform: cleanText(meta.platform || raw?.platform, 40).toLowerCase(),
      enabled,
      status,
      on_call: onCall,
      awaiting_human: awaitingHuman,
      last_in_at: lastInAt,
      last_out_at: lastOutAt,
      last_sender: lastSender,
      unanswered_since:
        status === 'waiting_ai'
          ? safeTimestamp(raw?.unanswered_since) || lastInAt
          : 0,
      next_action_at: safeTimestamp(raw?.next_action_at),
      last_success_at: safeTimestamp(raw?.last_success_at),
      last_error_code: cleanCode(raw?.last_error_code, 100),
      last_trace_id: cleanText(raw?.last_trace_id, 160),
      updated_at: now,
    };
  });
}

function normalizeEvents(rawEvents, meta, now) {
  if (!Array.isArray(rawEvents)) {
    throw new PublicError('EVENTS_ARRAY_REQUIRED', 400);
  }

  if (rawEvents.length > MAX_EVENTS_PER_REQUEST) {
    throw new PublicError('TOO_MANY_EVENTS', 413);
  }

  return rawEvents
    .filter((item) => item && typeof item === 'object')
    .map((raw) => {
      const levelCandidate = cleanText(raw?.level || 'info', 20).toLowerCase();
      const level = LEVEL_VALUES.has(levelCandidate)
        ? levelCandidate
        : 'info';

      return {
        event_id: cleanText(raw?.event_id, 160) || crypto.randomUUID(),
        trace_id: cleanText(raw?.trace_id, 160),
        installation_id: meta.installation_id,
        client_key: cleanText(raw?.client_key, 160),
        manager_id: cleanText(meta.manager_id || raw?.manager_id, 120),
        stage: cleanText(meta.stage || raw?.stage, 120),
        platform: cleanText(meta.platform || raw?.platform, 40).toLowerCase(),
        script_version: cleanText(
          raw?.script_version || meta.script_version,
          40
        ),
        event_type: cleanCode(raw?.event_type || 'diagnostic', 80),
        step: cleanCode(raw?.step, 100),
        level,
        code: cleanCode(raw?.code, 100),
        message: redactSensitive(raw?.message, 700),
        details_json: safeDetailsJson(raw?.details),
        created_at: safeObservedTimestamp(raw?.created_at, now) || now,
        received_at: now,
      };
    });
}

function deriveClientStatus(input) {
  if (!input.enabled) return 'paused';
  if (input.onCall) return 'call';
  if (input.awaitingHuman) return 'human';

  if (
    input.lastSender === 'client' ||
    (input.lastInAt > 0 && input.lastInAt > input.lastOutAt)
  ) {
    return 'waiting_ai';
  }

  if (
    input.lastSender === 'assistant' ||
    input.lastSender === 'manager' ||
    (input.lastOutAt > 0 && input.lastOutAt >= input.lastInAt)
  ) {
    return 'waiting_client';
  }

  const requested = cleanText(input.requested, 40).toLowerCase();
  return STATUS_VALUES.has(requested) ? requested : 'active';
}

async function upsertInstallation(env, data, now) {
  await env.DB.prepare(`
    INSERT INTO installations (
      installation_id,
      manager_id,
      manager_name,
      stage,
      platform,
      script_version,
      session_id,
      state,
      paused,
      busy,
      online,
      page_visible,
      page_hidden_since,
      clients_total,
      clients_active,
      started_at,
      last_seen_at,
      last_reason,
      created_at,
      updated_at
    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
    ON CONFLICT(installation_id) DO UPDATE SET
      manager_id = excluded.manager_id,
      manager_name = excluded.manager_name,
      stage = excluded.stage,
      platform = excluded.platform,
      script_version = excluded.script_version,
      session_id = excluded.session_id,
      state = excluded.state,
      paused = excluded.paused,
      busy = excluded.busy,
      online = excluded.online,
      page_visible = excluded.page_visible,
      page_hidden_since = CASE
        WHEN excluded.page_visible = 1 THEN 0
        WHEN installations.page_visible = 1
          OR installations.page_hidden_since = 0
          THEN excluded.last_seen_at
        ELSE installations.page_hidden_since
      END,
      clients_total = excluded.clients_total,
      clients_active = excluded.clients_active,
      started_at = excluded.started_at,
      last_seen_at = excluded.last_seen_at,
      last_reason = excluded.last_reason,
      updated_at = excluded.updated_at
  `)
    .bind(
      data.installation_id,
      data.manager_id,
      data.manager_name,
      data.stage,
      data.platform,
      data.script_version,
      data.session_id,
      data.state,
      data.paused,
      data.busy,
      data.online,
      data.page_visible,
      data.page_visible ? 0 : now,
      data.clients_total,
      data.clients_active,
      data.started_at,
      now,
      data.last_reason,
      data.created_at || now,
      now
    )
    .run();
}

async function ensureInstallation(env, meta, now) {
  await env.DB.prepare(`
    INSERT OR IGNORE INTO installations (
      installation_id,
      manager_id,
      stage,
      platform,
      script_version,
      last_seen_at,
      created_at,
      updated_at
    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
  `)
    .bind(
      meta.installation_id,
      meta.manager_id,
      meta.stage,
      meta.platform,
      meta.script_version,
      now,
      now,
      now
    )
    .run();
}

async function upsertClients(env, clients) {
  if (!clients.length) return;

  const statements = clients.map((client) =>
    env.DB.prepare(`
      INSERT INTO client_states (
        installation_id,
        client_key,
        client_label,
        platform,
        enabled,
        status,
        on_call,
        awaiting_human,
        last_in_at,
        last_out_at,
        last_sender,
        unanswered_since,
        next_action_at,
        last_success_at,
        last_error_code,
        last_trace_id,
        updated_at
      ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
      ON CONFLICT(installation_id, client_key) DO UPDATE SET
        client_label = excluded.client_label,
        platform = excluded.platform,
        enabled = excluded.enabled,
        status = excluded.status,
        on_call = excluded.on_call,
        awaiting_human = excluded.awaiting_human,
        last_in_at = excluded.last_in_at,
        last_out_at = excluded.last_out_at,
        last_sender = excluded.last_sender,
        unanswered_since = excluded.unanswered_since,
        next_action_at = excluded.next_action_at,
        last_success_at = excluded.last_success_at,
        last_error_code = excluded.last_error_code,
        last_trace_id = excluded.last_trace_id,
        updated_at = excluded.updated_at
    `).bind(
      client.installation_id,
      client.client_key,
      client.client_label,
      client.platform,
      client.enabled,
      client.status,
      client.on_call,
      client.awaiting_human,
      client.last_in_at,
      client.last_out_at,
      client.last_sender,
      client.unanswered_since,
      client.next_action_at,
      client.last_success_at,
      client.last_error_code,
      client.last_trace_id,
      client.updated_at
    )
  );

  await runBatches(env.DB, statements, 50);
}

async function replaceClientSnapshot(
  env,
  installationId,
  clients,
  allowEmptyDelete = false
) {
  if (!clients.length) {
    if (!allowEmptyDelete) {
      throw new PublicError('EMPTY_SNAPSHOT_NOT_CONFIRMED', 409);
    }
    await env.DB.prepare(`
      DELETE FROM client_states
      WHERE installation_id = ?
    `).bind(installationId).run();
    return;
  }

  // Сначала сохраняем новый снимок. Если запись оборвётся, старые клиенты
  // останутся на месте, а не исчезнут из панели полностью.
  const snapshotAt = Math.min(
    ...clients.map((client) => Number(client.updated_at || Date.now()))
  );

  await upsertClients(env, clients);

  await env.DB.prepare(`
    DELETE FROM client_states
    WHERE installation_id = ? AND updated_at < ?
  `).bind(installationId, snapshotAt).run();
}

async function insertEvents(env, events) {
  if (!events.length) return;

  const statements = events.map((event) =>
    env.DB.prepare(`
      INSERT OR IGNORE INTO event_logs (
        event_id,
        trace_id,
        installation_id,
        client_key,
        manager_id,
        stage,
        platform,
        script_version,
        event_type,
        step,
        level,
        code,
        message,
        details_json,
        created_at,
        received_at
      ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
    `).bind(
      event.event_id,
      event.trace_id,
      event.installation_id,
      event.client_key,
      event.manager_id,
      event.stage,
      event.platform,
      event.script_version,
      event.event_type,
      event.step,
      event.level,
      event.code,
      event.message,
      event.details_json,
      event.created_at,
      event.received_at
    )
  );

  await runBatches(env.DB, statements, 50);
}

async function runBatches(database, statements, size) {
  for (let start = 0; start < statements.length; start += size) {
    await database.batch(statements.slice(start, start + size));
  }
}

async function handleAdminDecisions(env, url, user) {
  const now = Date.now();
  await expireDecisionRequests(env, now);

  const limit = clampInt(url.searchParams.get('limit') || 200, 1, 500);
  const historyHours = clampInt(
    url.searchParams.get('hours') || 72,
    1,
    24 * 14
  );
  const historyAfter = now - historyHours * 60 * 60 * 1000;
  const filter = stageFilter(user, 'd.stage');

  const [rows, summary] = await Promise.all([
    env.DB.prepare(`
      SELECT d.*
      FROM decision_requests d
      WHERE (
        d.status IN ('pending', 'resolved')
        OR d.updated_at >= ?
      )${filter.sql}
        AND (? = 1 OR d.source <> 'site_test')
      ORDER BY
        CASE d.status
          WHEN 'pending' THEN 0
          WHEN 'resolved' THEN 1
          WHEN 'failed' THEN 2
          WHEN 'executed' THEN 3
          WHEN 'expired' THEN 4
          ELSE 5
        END,
        CASE WHEN d.status = 'pending' THEN d.created_at ELSE d.updated_at END DESC
      LIMIT ?
    `).bind(
      historyAfter,
      ...filter.params,
      canSeeErrors(user) ? 1 : 0,
      limit
    ).all(),
    env.DB.prepare(`
      SELECT
        SUM(CASE WHEN d.status = 'pending' THEN 1 ELSE 0 END) AS pending_total,
        SUM(CASE WHEN d.status = 'pending' AND d.decision_type = 'general' THEN 1 ELSE 0 END) AS pending_general,
        SUM(CASE WHEN d.status = 'pending' AND d.decision_type = 'call' THEN 1 ELSE 0 END) AS pending_call,
        SUM(CASE WHEN d.status = 'resolved' THEN 1 ELSE 0 END) AS awaiting_execution,
        SUM(CASE WHEN d.status = 'failed' AND d.updated_at >= ? THEN 1 ELSE 0 END) AS failed_recent
      FROM decision_requests d
      WHERE 1 = 1${filter.sql}
        AND (? = 1 OR d.source <> 'site_test')
    `).bind(
      historyAfter,
      ...filter.params,
      canSeeErrors(user) ? 1 : 0
    ).first(),
  ]);

  const visibleDecisions = await Promise.all(
    (rows.results || []).map((row) =>
      decisionRowForUser(env, row, user, true)
    )
  );

  return jsonResponse({
    ok: true,
    server_time: now,
    summary: {
      pending_total: numberOrZero(summary?.pending_total),
      pending_general: numberOrZero(summary?.pending_general),
      pending_call: numberOrZero(summary?.pending_call),
      awaiting_execution: numberOrZero(summary?.awaiting_execution),
      failed_recent: numberOrZero(summary?.failed_recent),
    },
    decisions: visibleDecisions,
  });
}

function cleanDecisionText(value, maxLength = 2200) {
  return String(value ?? '')
    .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '')
    .replace(/\r\n?/g, '\n')
    .replace(/[ \t]+/g, ' ')
    .replace(/\n{3,}/g, '\n\n')
    .trim()
    .slice(0, maxLength);
}

async function handleAdminDecisionResolve(request, env, user) {
  if (!isSameOriginRequest(request)) {
    return jsonResponse({ ok: false, error: 'REQUEST_REJECTED' }, 403);
  }

  const payload = await readJsonBody(request);
  const requestId = cleanText(payload?.request_id, 160);
  const action = cleanCode(payload?.action, 80);
  const inputText = cleanDecisionText(
    payload?.text || payload?.instruction,
    2200
  );

  if (!requestId) throw new PublicError('REQUEST_ID_REQUIRED', 400);
  if (!action) throw new PublicError('DECISION_ACTION_REQUIRED', 400);

  const filter = stageFilter(user, 'd.stage');
  const storedRow = await env.DB.prepare(`
    SELECT d.* FROM decision_requests d
    WHERE d.request_id = ?${filter.sql}
    LIMIT 1
  `).bind(requestId, ...filter.params).first();

  if (!storedRow) throw new PublicError('DECISION_NOT_FOUND', 404);
  const row = await hydrateDecisionRow(env, storedRow, true);

  if (row.status !== 'pending') {
    throw new PublicError('DECISION_ALREADY_PROCESSED', 409);
  }

  const now = Date.now();
  if (Number(row.expires_at || 0) < now) {
    await expireDecisionRequests(env, now);
    throw new PublicError('DECISION_EXPIRED', 409);
  }

  const allowedActions = row.decision_type === 'call'
    ? new Set([...CALL_DECISION_ACTIONS, ...GENERAL_DECISION_ACTIONS])
    : GENERAL_DECISION_ACTIONS;

  if (!allowedActions.has(action)) {
    throw new PublicError('DECISION_ACTION_INVALID', 400);
  }

  let resolutionText = inputText;
  let resolutionTimeAt = 0;

  if (row.decision_type === 'general' || GENERAL_DECISION_ACTIONS.has(action)) {
    if (action === 'send_recommendation') {
      resolutionText = cleanDecisionText(row.recommendation, 2200);
      if (!resolutionText) {
        throw new PublicError('RECOMMENDATION_EMPTY', 400);
      }
    }

    if (
      ['ai_instruction', 'custom_text'].includes(action) &&
      !resolutionText
    ) {
      throw new PublicError('DECISION_TEXT_REQUIRED', 400);
    }

    if (action === 'manager_manual' && !resolutionText) {
      resolutionText = 'Ответьте клиенту вручную';
    }

    if (action === 'pause' && !resolutionText) {
      resolutionText = 'Поставить клиента на паузу до решения руководителя';
    }
  } else {
    const proposedTime = numberOrZero(row.proposed_time_at);

    if (action === 'confirm_time') {
      if (!proposedTime) {
        throw new PublicError('PROPOSED_TIME_REQUIRED', 400);
      }
      resolutionTimeAt = proposedTime;
      resolutionText = resolutionText || 'Подтвердить предложенное время звонка';
    }

    if (action === 'plus_2h') {
      resolutionTimeAt = Math.max(proposedTime || now, now) + 2 * 60 * 60 * 1000;
      resolutionText = resolutionText || 'Перенести звонок на два часа позже';
    }

    if (action === 'custom_time') {
      resolutionTimeAt = safeTimestamp(payload?.time_at);
      if (
        resolutionTimeAt < now + 5 * 60 * 1000 ||
        resolutionTimeAt > now + 30 * 24 * 60 * 60 * 1000
      ) {
        throw new PublicError('CUSTOM_TIME_INVALID', 400);
      }
      resolutionText = resolutionText || 'Согласовать указанное время звонка';
    }

    if (action === 'decline' && !resolutionText) {
      resolutionText = 'Вежливо отказаться от звонка и продолжить переписку';
    }

    if (action === 'manager_manual' && !resolutionText) {
      resolutionText = 'Менеджер самостоятельно согласует звонок с клиентом';
    }
  }

  const isSiteTest = row.source === 'site_test';
  const nextStatus = isSiteTest ? 'executed' : 'resolved';
  const encrypted = await encryptDecisionSensitive(env, requestId, {
    reason: row.reason,
    manager_task: row.manager_task,
    recommendation: row.recommendation,
    context: parseDecisionContext(row.context_json),
    resolution_text: resolutionText,
  });
  const updated = await env.DB.prepare(`
    UPDATE decision_requests
    SET status = ?,
        resolved_at = ?,
        resolved_by = ?,
        resolved_login = ?,
        resolution_action = ?,
        resolution_text = '',
        resolution_time_at = ?,
        expires_at = ?,
        executed_at = ?,
        execution_status = ?,
        execution_error = '',
        execution_attempts = 0,
        next_attempt_at = ?,
        lease_owner = '',
        lease_token_hash = '',
        lease_expires_at = 0,
        prepared_message_hash = '',
        prepared_at = 0,
        sensitive_ciphertext = ?,
        sensitive_iv = ?,
        sensitive_key_version = ?,
        sensitive_purged_at = 0,
        updated_at = ?
    WHERE request_id = ? AND status = 'pending'
  `).bind(
    nextStatus,
    now,
    Number(user.id),
    cleanText(user.login, 80),
    action,
    resolutionTimeAt,
    now + DECISION_EXECUTION_TTL_MS,
    isSiteTest ? now : 0,
    isSiteTest ? 'site_test_completed' : 'waiting_script',
    now,
    encrypted.sensitive_ciphertext,
    encrypted.sensitive_iv,
    encrypted.sensitive_key_version,
    now,
    requestId
  ).run();

  if (!decisionChanges(updated)) {
    throw new PublicError('DECISION_ALREADY_PROCESSED', 409);
  }

  await recordDecisionEvent(
    env,
    requestId,
    isSiteTest ? 'test_resolved' : 'resolved',
    user,
    {
      action,
      time_at: resolutionTimeAt,
      decision_type: row.decision_type,
    }
  );
  await writeAudit(
    env,
    user,
    'decision_resolved',
    `${requestId}: ${action}`,
    request
  );

  const result = await env.DB.prepare(`
    SELECT * FROM decision_requests WHERE request_id = ?
  `).bind(requestId).first();

  return jsonResponse({
    ok: true,
    server_time: now,
    decision: await decisionRowForUser(env, result, user, true),
  });
}

async function handleAdminDecisionTest(request, env, user) {
  if (!isSameOriginRequest(request)) {
    return jsonResponse({ ok: false, error: 'REQUEST_REJECTED' }, 403);
  }

  const payload = await readJsonBody(request);
  const type = cleanText(payload?.decision_type || payload?.type, 30)
    .toLowerCase();

  if (!DECISION_TYPE_VALUES.has(type)) {
    throw new PublicError('DECISION_TYPE_INVALID', 400);
  }

  const latest = await env.DB.prepare(`
    SELECT manager_id, manager_name, stage, platform
    FROM installations
    ORDER BY last_seen_at DESC
    LIMIT 1
  `).first();
  const now = Date.now();
  const testId = crypto.randomUUID();
  const callTime = now + 2 * 60 * 60 * 1000;

  const result = await createDecisionRequest(env, {
    request_id: `test-${testId}`,
    installation_id: `site-test-${user.id}`,
    client_key: `test-client-${testId}`,
    client_label: 'Тестовый клиент',
    manager_id: latest?.manager_id || 'test-manager',
    manager_name: latest?.manager_name || 'Тестовый менеджер',
    stage: latest?.stage || 'ТЕСТ',
    platform: latest?.platform || 'test',
    decision_type: type,
    reason: type === 'call'
      ? 'Клиент предложил созвон и ожидает решения по времени'
      : 'ИИ передал диалог руководителю: требуется решение по ответу',
    manager_task: type === 'call'
      ? 'Подтвердить, перенести или отклонить звонок'
      : 'Выбрать способ продолжения диалога',
    recommendation: type === 'call'
      ? 'Подтвердить предложенное время, если оно подходит'
      : 'Спасибо, что рассказал. Давай продолжим эту тему чуть позже, мне нужно уточнить один момент',
    proposed_time_at: type === 'call' ? callTime : 0,
    proposed_time_text: type === 'call' ? 'Через два часа, МСК' : '',
    context: [
      {
        who: 'Клиент',
        text: type === 'call'
          ? 'Давай сегодня созвонимся, например через два часа?'
          : 'Можешь подробнее рассказать, как это работает?',
        at: now - 2 * 60 * 1000,
      },
      {
        who: 'ИИ',
        text: 'Диалог остановлен до решения руководителя',
        at: now - 60 * 1000,
      },
    ],
  }, 'site_test');

  await writeAudit(
    env,
    user,
    'decision_test_created',
    result.row.request_id,
    request
  );

  return jsonResponse({
    ok: true,
    server_time: now,
    decision: result.row,
  });
}

// У новых и восстановленных установок manager_id постоянный и берётся из
// manager_stages. Стейдж и имя оставлены только как мост для старых записей.
// Платформа добавляется отдельно в PARTITION BY ниже.
const CRM_MANAGER_KEY_SQL = `
  CASE
    WHEN TRIM(COALESCE(i.manager_id, '')) <> ''
      THEN 'id:' || LOWER(TRIM(i.manager_id))
    WHEN TRIM(COALESCE(i.stage, '')) <> ''
      THEN 'stage:' || LOWER(TRIM(i.stage))
    WHEN TRIM(COALESCE(i.manager_name, '')) <> ''
      THEN 'name:' || LOWER(TRIM(i.manager_name))
    ELSE 'installation:' || i.installation_id
  END
`;

function currentInstallationsCte(filter) {
  return `
    WITH installation_candidates AS (
      SELECT
        i.*,
        ${CRM_MANAGER_KEY_SQL} AS crm_manager_key,
        CASE
          WHEN i.last_seen_at > 0 AND EXISTS (
            SELECT 1
            FROM installation_credentials ic
            WHERE ic.installation_id = i.installation_id
              AND ic.status = 'active'
          ) THEN 1
          ELSE 0
        END AS crm_personal_active
      FROM installations i
      WHERE 1 = 1${filter.sql}
    ),
    ranked_installations AS (
      SELECT
        candidate.*,
        ROW_NUMBER() OVER (
          PARTITION BY
            candidate.crm_manager_key,
            LOWER(TRIM(COALESCE(candidate.platform, '')))
          ORDER BY
            candidate.crm_personal_active DESC,
            candidate.last_seen_at DESC,
            candidate.updated_at DESC,
            candidate.installation_id DESC
        ) AS crm_installation_rank
      FROM installation_candidates candidate
    ),
    current_installations AS (
      SELECT *
      FROM ranked_installations
      WHERE crm_installation_rank = 1
    )
  `;
}

function currentInstallationParams(filter) {
  return [...filter.params];
}

function currentClientsCte(filter) {
  return `
    ${currentInstallationsCte(filter)},
    ranked_current_clients AS (
      SELECT
        c.*,
        i.manager_id,
        i.manager_name,
        i.stage,
        i.last_seen_at AS installation_last_seen_at,
        i.online AS installation_online,
        i.crm_manager_key,
        ROW_NUMBER() OVER (
          PARTITION BY
            i.crm_manager_key,
            LOWER(TRIM(COALESCE(c.platform, i.platform, ''))),
            c.client_key
          ORDER BY
            c.updated_at DESC,
            i.last_seen_at DESC,
            c.installation_id DESC
        ) AS crm_client_rank
      FROM client_states c
      INNER JOIN current_installations i
        ON i.installation_id = c.installation_id
       AND c.updated_at >=
           i.last_seen_at - ${CLIENT_SNAPSHOT_STALE_GRACE_MS}
    ),
    current_clients AS (
      SELECT *
      FROM ranked_current_clients
      WHERE crm_client_rank = 1
    )
  `;
}

async function handleAdminSummary(env, user) {
  const now = Date.now();
  const onlineAfter = now - ONLINE_WINDOW_MS;
  const backgroundAfter = now - BACKGROUND_WARNING_MS;
  const errorsAfter = now - 24 * 60 * 60 * 1000;
  const installationFilter = stageFilter(user, 'i.stage');
  const clientFilter = stageFilter(user, 'i.stage');
  const eventFilter = stageFilter(user, 'stage');
  const managerFilter = stageFilter(user, 's.canonical_stage');
  const installationCte = currentInstallationsCte(installationFilter);
  const clientCte = currentClientsCte(clientFilter);
  const installationParams = currentInstallationParams(installationFilter);
  const clientParams = currentInstallationParams(clientFilter);

  const [installations, clients, errors, managers] = await Promise.all([
    env.DB.prepare(`
      ${installationCte}
      SELECT
        COUNT(*) AS installations_total,
        COUNT(DISTINCT crm_manager_key) AS managers_with_installations,
        SUM(
          CASE
            WHEN online = 1 AND last_seen_at >= ? THEN 1
            ELSE 0
          END
        ) AS installations_online,
        COUNT(DISTINCT CASE
          WHEN online = 1 AND last_seen_at >= ? THEN crm_manager_key
          ELSE NULL
        END) AS managers_online,
        SUM(CASE
          WHEN online = 1
            AND last_seen_at >= ?
            AND page_visible = 0
            AND page_hidden_since > 0
            AND page_hidden_since <= ?
          THEN 1
          ELSE 0
        END) AS installations_background,
        SUM(clients_total) AS reported_clients_total,
        SUM(clients_active) AS reported_clients_active
      FROM current_installations
    `).bind(
      ...installationParams,
      onlineAfter,
      onlineAfter,
      onlineAfter,
      backgroundAfter
    ).first(),

    env.DB.prepare(`
      ${clientCte}
      SELECT
        COUNT(*) AS clients_total,
        SUM(CASE WHEN c.enabled = 1 THEN 1 ELSE 0 END) AS clients_enabled,
        SUM(
          CASE
            WHEN c.enabled = 1
              AND c.on_call = 0
              AND c.awaiting_human = 0
              AND c.installation_online = 1
              AND c.installation_last_seen_at >= ?
            THEN 1 ELSE 0
          END
        ) AS clients_active_now,
        SUM(CASE WHEN c.on_call = 1 THEN 1 ELSE 0 END) AS clients_on_call,
        SUM(CASE WHEN c.awaiting_human = 1 THEN 1 ELSE 0 END) AS clients_human,
        SUM(CASE WHEN c.status = 'waiting_ai' THEN 1 ELSE 0 END) AS waiting_ai,
        SUM(CASE WHEN c.status = 'waiting_client' THEN 1 ELSE 0 END) AS waiting_client,
        MAX(
          CASE
            WHEN c.status = 'waiting_ai' THEN c.unanswered_since
            ELSE 0
          END
        ) AS newest_unanswered_since,
        MIN(
          CASE
            WHEN c.status = 'waiting_ai' AND c.unanswered_since > 0
              THEN c.unanswered_since
            ELSE NULL
          END
        ) AS oldest_unanswered_since
      FROM current_clients c
    `).bind(...clientParams, onlineAfter).first(),

    canSeeErrors(user)
      ? env.DB.prepare(`
          SELECT COUNT(*) AS errors_24h
          FROM event_logs
          WHERE created_at >= ?
            AND (level IN ('warn', 'error') OR code <> '')${eventFilter.sql}
        `).bind(errorsAfter, ...eventFilter.params).first()
      : Promise.resolve({ errors_24h: 0 }),

    env.DB.prepare(`
      SELECT COUNT(*) AS managers_total
      FROM manager_stages s
      WHERE s.active = 1${managerFilter.sql}
    `).bind(...managerFilter.params).first(),
  ]);

  const globalStop = await readGlobalStop(env);
  const latestVersions = await readLatestVersions(env);
  const restartReminder = await readRestartReminder(env);
  const loaderUrls = await readLoaderUrls(env);

  return jsonResponse({
    ok: true,
    server_time: now,
    latest_versions: latestVersions,
    restart_reminder: restartReminder,
    loader_urls: loaderUrls,
    online_window_ms: ONLINE_WINDOW_MS,
    background_warning_ms: BACKGROUND_WARNING_MS,
    global_stop: globalStop,
    summary: {
      managers_total: numberOrZero(managers?.managers_total),
      managers_online: numberOrZero(installations?.managers_online),
      installations_background:
        numberOrZero(installations?.installations_background),
      installations_total: numberOrZero(installations?.installations_total),
      installations_online: numberOrZero(installations?.installations_online),
      reported_clients_total: numberOrZero(installations?.reported_clients_total),
      reported_clients_active: numberOrZero(installations?.reported_clients_active),
      clients_total: numberOrZero(clients?.clients_total),
      clients_enabled: numberOrZero(clients?.clients_enabled),
      clients_active_now: numberOrZero(clients?.clients_active_now),
      clients_on_call: numberOrZero(clients?.clients_on_call),
      clients_human: numberOrZero(clients?.clients_human),
      waiting_ai: numberOrZero(clients?.waiting_ai),
      waiting_client: numberOrZero(clients?.waiting_client),
      oldest_unanswered_since: numberOrZero(clients?.oldest_unanswered_since),
      errors_24h: numberOrZero(errors?.errors_24h),
    },
  });
}

async function handleAdminInstallations(env, user) {
  // В CRM отдаём только последнюю установку каждой платформы менеджера.
  // Число клиентов берём из той же авторитетной выборки, что используется
  // подробной таблицей и глобальными карточками.
  const filter = stageFilter(user, 'i.stage');
  const managerFilter = stageFilter(user, 's.canonical_stage');
  const currentCte = currentClientsCte(filter);
  const params = currentInstallationParams(filter);

  const [result, managers] = await Promise.all([
    env.DB.prepare(`
    ${currentCte},
    current_client_counts AS (
      SELECT
        c.installation_id,
        COUNT(*) AS exact_clients_total,
        SUM(CASE WHEN c.enabled = 1 THEN 1 ELSE 0 END) AS exact_clients_active
      FROM current_clients c
      GROUP BY c.installation_id
    )
    SELECT
      i.installation_id,
      i.manager_id,
      i.manager_name,
      i.stage,
      i.platform,
      i.script_version,
      i.state,
      i.paused,
      i.busy,
      i.online,
      i.page_visible,
      i.page_hidden_since,
      COALESCE(cc.exact_clients_total, 0) AS clients_total,
      COALESCE(cc.exact_clients_active, 0) AS clients_active,
      i.clients_total AS reported_clients_total,
      i.clients_active AS reported_clients_active,
      i.started_at,
      i.last_seen_at,
      i.last_reason,
      i.updated_at,
      COALESCE(s.stopped, 0) AS admin_stopped
    FROM installations i
    INNER JOIN current_installations ci
      ON ci.installation_id = i.installation_id
    LEFT JOIN current_client_counts cc
      ON cc.installation_id = i.installation_id
    LEFT JOIN installation_stops s
      ON s.installation_id = i.installation_id
    ORDER BY i.last_seen_at DESC
    LIMIT 500
    `).bind(...params).all(),
    env.DB.prepare(`
      SELECT
        s.manager_id,
        s.canonical_stage,
        s.canonical_stage AS stage,
        s.created_at,
        s.updated_at
      FROM manager_stages s
      WHERE s.active = 1${managerFilter.sql}
      ORDER BY s.canonical_stage COLLATE NOCASE
      LIMIT 1000
    `).bind(...managerFilter.params).all(),
  ]);

  return jsonResponse({
    ok: true,
    server_time: Date.now(),
    background_warning_ms: BACKGROUND_WARNING_MS,
    managers: managers.results || [],
    installations: (result.results || []).map((row) => {
      if (canSeeErrors(user)) return row;
      return { ...row, last_reason: '' };
    }),
  });
}

async function handleAdminClients(env, url, user) {
  const limit = clampInt(url.searchParams.get('limit') || 500, 1, 1000);
  const filter = stageFilter(user, 'i.stage');
  const currentCte = currentClientsCte(filter);
  const params = currentInstallationParams(filter);

  const result = await env.DB.prepare(`
    ${currentCte}
    SELECT
      c.installation_id,
      c.client_key,
      c.client_label,
      c.platform,
      c.enabled,
      c.status,
      c.on_call,
      c.awaiting_human,
      c.last_in_at,
      c.last_out_at,
      c.last_sender,
      c.unanswered_since,
      c.next_action_at,
      c.last_success_at,
      c.last_error_code,
      c.last_trace_id,
      c.updated_at,
      c.manager_id,
      c.manager_name,
      c.stage,
      c.installation_last_seen_at,
      p.memory_key,
      p.incomplete AS memory_incomplete,
      (SELECT GROUP_CONCAT(platform, ',') FROM
        (SELECT DISTINCT ml.platform FROM memory_chat_links ml
         WHERE ml.manager_id=c.manager_id AND ml.person_id=p.person_id ORDER BY ml.platform)) AS memory_platforms
    FROM current_clients c
    LEFT JOIN memory_chat_links l ON l.manager_id=c.manager_id AND l.platform=c.platform AND l.client_key=c.client_key
    LEFT JOIN memory_people p ON p.manager_id=l.manager_id AND p.person_id=l.person_id
    ORDER BY
      CASE c.status
        WHEN 'waiting_ai' THEN 0
        WHEN 'human' THEN 1
        WHEN 'error' THEN 2
        WHEN 'call' THEN 3
        ELSE 4
      END,
      CASE
        WHEN c.unanswered_since > 0 THEN c.unanswered_since
        ELSE c.updated_at
      END ASC
    LIMIT ?
  `).bind(...params, limit).all();

  return jsonResponse({
    ok: true,
    server_time: Date.now(),
    clients: (result.results || []).map((row) => {
      row = {...row, memory_key:row.memory_key || '',
        memory_platforms:row.memory_platforms ? row.memory_platforms.split(',') : [],
        memory_incomplete:row.memory_key ? Boolean(row.memory_incomplete) : null};
      if (canSeeErrors(user)) return row;
      return {
        ...row,
        last_error_code: '',
        last_trace_id: '',
      };
    }),
  });
}

async function handleAdminEvents(env, url, user) {
  const limit = clampInt(url.searchParams.get('limit') || 300, 1, 1000);
  const hours = clampInt(url.searchParams.get('hours') || 24, 1, 24 * 30);
  const after = Date.now() - hours * 60 * 60 * 1000;
  const filter = stageFilter(user, 'e.stage');

  const result = await env.DB.prepare(`
    SELECT
      e.id,
      e.event_id,
      e.trace_id,
      e.installation_id,
      e.client_key,
      e.manager_id,
      e.stage,
      e.platform,
      e.script_version,
      e.event_type,
      e.step,
      e.level,
      e.code,
      e.message,
      e.details_json,
      e.created_at,
      e.received_at,
      COALESCE(c.client_label, '') AS client_label,
      COALESCE(i.manager_name, '') AS manager_name
    FROM event_logs e
    LEFT JOIN client_states c
      ON c.installation_id = e.installation_id
     AND c.client_key = e.client_key
    LEFT JOIN installations i
      ON i.installation_id = e.installation_id
    WHERE e.created_at >= ?
      AND (e.level IN ('warn', 'error') OR e.code <> '')${filter.sql}
    ORDER BY e.received_at DESC, e.created_at DESC
    LIMIT ?
  `).bind(after, ...filter.params, limit).all();

  return jsonResponse({
    ok: true,
    server_time: Date.now(),
    events: result.results || [],
  });
}

async function handleAdminExport(env, url, user) {
  const hours = clampInt(url.searchParams.get('hours') || 24, 1, 24 * 30);
  const after = Date.now() - hours * 60 * 60 * 1000;
  const filter = stageFilter(user, 'stage');
  const clientFilter = stageFilter(user, 'i.stage');
  const eventFilter = stageFilter(user, 'e.stage');
  const decisionFilter = stageFilter(user, 'd.stage');

  const [installations, clients, events, decisions, decisionEvents] = await Promise.all([
    env.DB.prepare(`
      SELECT * FROM installations
      WHERE 1 = 1${filter.sql}
      ORDER BY last_seen_at DESC
    `).bind(...filter.params).all(),
    env.DB.prepare(`
      SELECT c.* FROM client_states c
      LEFT JOIN installations i ON i.installation_id = c.installation_id
      WHERE 1 = 1${clientFilter.sql}
      ORDER BY c.updated_at DESC
    `).bind(...clientFilter.params).all(),
    canSeeErrors(user)
      ? env.DB.prepare(`
          SELECT
            e.*,
            COALESCE(c.client_label, '') AS client_label,
            COALESCE(i.manager_name, '') AS manager_name
          FROM event_logs e
          LEFT JOIN client_states c
            ON c.installation_id = e.installation_id
           AND c.client_key = e.client_key
          LEFT JOIN installations i
            ON i.installation_id = e.installation_id
          WHERE e.created_at >= ?${eventFilter.sql}
          ORDER BY e.received_at DESC, e.created_at DESC
          LIMIT 5000
        `).bind(after, ...eventFilter.params).all()
      : Promise.resolve({ results: [] }),
    env.DB.prepare(`
      SELECT d.*
      FROM decision_requests d
      WHERE d.updated_at >= ?${decisionFilter.sql}
      ORDER BY d.updated_at DESC
      LIMIT 5000
    `).bind(after, ...decisionFilter.params).all(),
    env.DB.prepare(`
      SELECT de.*
      FROM decision_events de
      LEFT JOIN decision_requests d ON d.request_id = de.request_id
      WHERE de.at >= ?${decisionFilter.sql}
      ORDER BY de.at DESC
      LIMIT 10000
    `).bind(after, ...decisionFilter.params).all(),
  ]);

  const exportedDecisions = await Promise.all(
    (decisions.results || []).map(async (row) => {
      const hydrated = await hydrateDecisionRow(env, row);
      return publicDecisionRow(hydrated, true);
    })
  );

  const body = JSON.stringify(
    {
      format: 'ai-assistant-diagnostics-v1',
      generated_at: Date.now(),
      generated_at_msk: new Date().toLocaleString('ru-RU', {
        timeZone: 'Europe/Moscow',
      }),
      hours,
      installations: (installations.results || []).map((row) => {
        if (canSeeErrors(user)) return row;
        return { ...row, last_reason: '' };
      }),
      clients: (clients.results || []).map((row) => {
        if (canSeeErrors(user)) return row;
        return { ...row, last_error_code: '', last_trace_id: '' };
      }),
      events: events.results || [],
      decisions: exportedDecisions,
      decision_events: decisionEvents.results || [],
    },
    null,
    2
  );

  const stamp = new Date().toISOString().replace(/[:.]/g, '-');

  return new Response(body, {
    status: 200,
    headers: {
      'Content-Type': 'application/json; charset=utf-8',
      'Content-Disposition': `attachment; filename="ai-monitor-diagnostics-${stamp}.json"`,
      'Cache-Control': 'no-store',
      ...securityHeaders(),
    },
  });
}


// ================================================================
// GROQ AUDIO GATEWAY
// Полученное аудио существует только в памяти текущего запроса.
// Ни бинарные данные, ни текст расшифровки в D1 и Worker Logs не пишутся.
// ================================================================
const AUDIO_MEDIA_TYPES = new Set([
  'audio/ogg',
  'application/ogg',
  'audio/opus',
  'audio/webm',
  'video/webm',
  'audio/mpeg',
  'audio/mp3',
  'audio/mp4',
  'video/mp4',
  'audio/x-m4a',
  'audio/wav',
  'audio/x-wav',
  'application/octet-stream',
]);

async function handleAudioTranscription(
  request,
  env,
  identity,
  requestId
) {
  if (!env.GROQ_API_KEY) {
    throw new PublicError('AUDIO_NOT_CONFIGURED', 503);
  }

  const declaredLength = Number(
    request.headers.get('Content-Length') || 0
  );
  if (declaredLength > MAX_AUDIO_BODY_BYTES) {
    throw new PublicError('AUDIO_TOO_LARGE', 413);
  }

  const rawType = cleanText(
    request.headers.get('Content-Type'),
    100
  ).split(';')[0].trim().toLowerCase();
  const mediaType = AUDIO_MEDIA_TYPES.has(rawType)
    ? rawType
    : 'application/octet-stream';
  const body = await request.arrayBuffer();

  if (body.byteLength < 200) {
    throw new PublicError('AUDIO_EMPTY', 400);
  }
  if (body.byteLength > MAX_AUDIO_BODY_BYTES) {
    throw new PublicError('AUDIO_TOO_LARGE', 413);
  }

  const requestedName = cleanText(
    request.headers.get('X-Audio-Filename'),
    100
  );
  const fileName = safeAudioFileName(requestedName, mediaType);
  const startedAt = Date.now();
  let upstreamResponse;

  try {
    upstreamResponse = await fetchGroqTranscriptionWithRetry(
      env,
      body,
      mediaType,
      fileName
    );
  } catch (error) {
    console.error('audio_gateway_network_failed', {
      request_id: requestId,
      installation_id: identity.installation_id,
      bytes: body.byteLength,
      latency_ms: Date.now() - startedAt,
      name: cleanText(error?.name || 'Error', 80),
    });

    return audioGatewayErrorResponse(
      'AUDIO_UPSTREAM_UNAVAILABLE',
      503,
      requestId
    );
  }

  let upstreamData = null;
  try {
    const responseText = await upstreamResponse.text();
    upstreamData = responseText ? JSON.parse(responseText) : null;
  } catch (_) {
    upstreamData = null;
  }

  if (!upstreamResponse.ok || !upstreamData) {
    const mapped = mapGroqAudioError(upstreamResponse.status);

    console.warn('audio_gateway_upstream_error', {
      request_id: requestId,
      installation_id: identity.installation_id,
      upstream_status: Number(upstreamResponse.status || 0),
      bytes: body.byteLength,
      latency_ms: Date.now() - startedAt,
    });

    return audioGatewayErrorResponse(
      mapped.code,
      mapped.status,
      requestId
    );
  }

  const text = String(upstreamData.text || '').trim();
  if (!text) {
    return audioGatewayErrorResponse(
      'AUDIO_EMPTY_TRANSCRIPT',
      502,
      requestId
    );
  }

  return jsonResponse(
    {
      ok: true,
      text: text.slice(0, 50000),
      gateway_request_id: requestId,
    },
    200,
    true
  );
}

function safeAudioFileName(value, mediaType) {
  const cleaned = String(value || '')
    .replace(/[^a-zA-Z0-9._-]/g, '')
    .slice(0, 80);
  if (cleaned && /\.[a-zA-Z0-9]{2,5}$/.test(cleaned)) {
    return cleaned;
  }

  const type = String(mediaType || '').toLowerCase();
  if (type.includes('webm')) return 'voice.webm';
  if (type.includes('mpeg') || type.includes('mp3')) return 'voice.mp3';
  if (type.includes('mp4') || type.includes('m4a')) return 'voice.m4a';
  if (type.includes('wav')) return 'voice.wav';
  return 'voice.ogg';
}

async function fetchGroqTranscriptionWithRetry(
  env,
  body,
  mediaType,
  fileName
) {
  let lastError = null;

  for (let attempt = 1; attempt <= 2; attempt += 1) {
    const form = new FormData();
    form.append(
      'file',
      new Blob([body], { type: mediaType }),
      fileName
    );
    form.append('model', 'whisper-large-v3');
    form.append('language', 'ru');
    form.append('response_format', 'json');

    const controller = new AbortController();
    const timeoutId = setTimeout(
      () => controller.abort(),
      90000
    );

    try {
      const response = await fetch(
        'https://api.groq.com/openai/v1/audio/transcriptions',
        {
          method: 'POST',
          headers: {
            Authorization: `Bearer ${String(env.GROQ_API_KEY)}`,
          },
          body: form,
          signal: controller.signal,
        }
      );
      clearTimeout(timeoutId);

      if (
        attempt < 2 &&
        [429, 500, 502, 503, 504].includes(response.status)
      ) {
        try {
          await response.body?.cancel();
        } catch (_) {}
        await aiDelay(1500);
        continue;
      }

      return response;
    } catch (error) {
      clearTimeout(timeoutId);
      lastError = error;

      if (attempt < 2 && error?.name !== 'AbortError') {
        await aiDelay(1500);
        continue;
      }

      throw error;
    }
  }

  throw lastError || new Error('AUDIO_UPSTREAM_UNAVAILABLE');
}

function mapGroqAudioError(status) {
  if (status === 400) return { code: 'AUDIO_REJECTED', status: 400 };
  if (status === 413) return { code: 'AUDIO_TOO_LARGE', status: 413 };
  if (status === 429) return { code: 'AUDIO_PROVIDER_RATE_LIMITED', status: 429 };
  if (status === 401 || status === 403) {
    return { code: 'AUDIO_PROVIDER_AUTH_FAILED', status: 503 };
  }
  if (status >= 500) {
    return { code: 'AUDIO_UPSTREAM_UNAVAILABLE', status: 503 };
  }
  return { code: 'AUDIO_UPSTREAM_ERROR', status: 502 };
}

function audioGatewayErrorResponse(code, status, requestId) {
  return jsonResponse(
    {
      ok: false,
      error: code,
      request_id: requestId,
    },
    status,
    true
  );
}


// ================================================================
// ANTHROPIC API GATEWAY
// Содержимое system/messages только проксируется в Anthropic и никогда
// не записывается в D1. В БД сохраняются лишь токены, стоимость и коды ошибок.
// ================================================================
const AI_DIALOGUE_OUTPUT_SCHEMA = {
  type: 'object',
  additionalProperties: false,
  required: [
    'thoughts',
    'action',
    'human_type',
    'messages',
    'manager_task',
    'call',
    'photos',
  ],
  properties: {
    thoughts: { type: 'string' },
    action: {
      type: 'string',
      enum: ['reply', 'clarify', 'human', 'skip'],
    },
    human_type: {
      type: 'string',
      enum: ['general', 'call_decision'],
    },
    messages: {
      type: 'array',
      description:
        '0 messages for human/skip, 1 for clarify, 1 to 4 for reply. Keep every message compact.',
      items: {
        type: 'string',
      },
    },
    manager_task: { type: 'string' },
    call: {
      type: 'object',
      additionalProperties: false,
      required: ['initiator', 'proposed_time'],
      properties: {
        initiator: {
          type: 'string',
          enum: ['client', 'assistant'],
        },
        proposed_time: { type: 'string' },
      },
    },
    photos: {
      type: 'array',
      items: { type: 'string' },
    },
  },
};

const AI_TASK_CONFIG = {
  dialogue: {
    defaultMaxTokens: 1600,
    maxTokens: 2600,
    effort: 'medium',
    systemMaxChars: 280000,
    userMaxChars: 420000,
  },
  dossier: {
    defaultMaxTokens: 1600,
    maxTokens: 2200,
    effort: 'low',
    systemMaxChars: 60000,
    userMaxChars: 420000,
  },
  vision: {
    defaultMaxTokens: 700,
    maxTokens: 1000,
    effort: 'low',
    systemMaxChars: 20000,
    userMaxChars: 30000,
  },
};

const AI_IMAGE_MEDIA_TYPES = new Set([
  'image/jpeg',
  'image/png',
  'image/webp',
  'image/gif',
]);

async function handleAiGatewayMessage(
  env,
  payload,
  identity,
  requestId
) {
  if (!env.ANTHROPIC_API_KEY) {
    throw new PublicError('AI_NOT_CONFIGURED', 503);
  }

  const normalized = normalizeAiGatewayPayload(payload, env);
  await enforceAiDailyBudget(env, identity);

  const startedAt = Date.now();
  let upstreamResponse;

  try {
    upstreamResponse = await fetchAnthropicWithRetry(
      env,
      normalized.body
    );
  } catch (error) {
    await safeRecordAiUsage(env, {
      identity,
      requestId,
      task: normalized.task,
      model: normalized.model,
      status: 'network_error',
      upstreamStatus: 0,
      errorCode: 'AI_UPSTREAM_UNAVAILABLE',
      latencyMs: Date.now() - startedAt,
    });

    console.error('ai_gateway_network_failed', {
      request_id: requestId,
      installation_id: identity.installation_id,
      task: normalized.task,
      name: cleanText(error?.name || 'Error', 80),
    });

    return aiGatewayErrorResponse(
      'AI_UPSTREAM_UNAVAILABLE',
      503,
      requestId
    );
  }

  const upstreamStatus = Number(upstreamResponse.status || 0);
  let upstreamData = null;

  try {
    const responseText = await upstreamResponse.text();
    upstreamData = responseText ? JSON.parse(responseText) : null;
  } catch (_) {
    upstreamData = null;
  }

  if (!upstreamResponse.ok || !upstreamData) {
    const mapped = mapAnthropicGatewayError(
      upstreamStatus,
      upstreamData?.error?.type
    );

    await safeRecordAiUsage(env, {
      identity,
      requestId,
      task: normalized.task,
      model: normalized.model,
      status: 'upstream_error',
      upstreamStatus,
      errorCode: mapped.code,
      latencyMs: Date.now() - startedAt,
      usage: upstreamData?.usage,
    });

    console.warn('ai_gateway_upstream_error', {
      request_id: requestId,
      installation_id: identity.installation_id,
      task: normalized.task,
      upstream_status: upstreamStatus,
      upstream_type: cleanCode(upstreamData?.error?.type, 80),
    });

    return aiGatewayErrorResponse(
      mapped.code,
      mapped.status,
      requestId
    );
  }

  await safeRecordAiUsage(env, {
    identity,
    requestId,
    task: normalized.task,
    model: cleanText(upstreamData.model || normalized.model, 80),
    status: 'ok',
    upstreamStatus,
    errorCode: '',
    latencyMs: Date.now() - startedAt,
    usage: upstreamData.usage,
  });

  // Новые клиенты читают messages[]. Для плавного обновления профилей
  // сервер также добавляет вычисленное поле message старого формата.
  const compatibleData = addLegacyDialogueMessageField(
    upstreamData,
    normalized.task
  );

  // Формат успешного ответа сохраняем совместимым с Anthropic Messages API.
  // Клиенту добавляется только безопасный идентификатор шлюза.
  return jsonResponse(
    {
      ...compatibleData,
      gateway_request_id: requestId,
    },
    200,
    true
  );
}

function addLegacyDialogueMessageField(upstreamData, task) {
  if (task !== 'dialogue' || !Array.isArray(upstreamData?.content)) {
    return upstreamData;
  }

  const content = upstreamData.content.map((block) => {
    if (
      !block ||
      block.type !== 'text' ||
      typeof block.text !== 'string'
    ) {
      return block;
    }

    let parsed;
    try {
      parsed = JSON.parse(block.text);
    } catch (_) {
      return block;
    }

    if (
      !parsed ||
      typeof parsed !== 'object' ||
      Array.isArray(parsed) ||
      !Array.isArray(parsed.messages)
    ) {
      return block;
    }

    const messages = parsed.messages
      .filter((value) => typeof value === 'string')
      .map((value) => value.trim())
      .filter(Boolean)
      .slice(0, 4);

    return {
      ...block,
      text: JSON.stringify({
        ...parsed,
        messages,
        message: messages.join(' '),
      }),
    };
  });

  return { ...upstreamData, content };
}

function normalizeAiGatewayPayload(payload, env) {
  if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
    throw new PublicError('AI_INVALID_PAYLOAD', 400);
  }

  const task = cleanCode(payload.task, 30).toLowerCase();
  if (!AI_TASK_VALUES.has(task)) {
    throw new PublicError('AI_TASK_NOT_ALLOWED', 400);
  }

  const model = cleanText(
    env.ANTHROPIC_MODEL || AI_DEFAULT_MODEL,
    80
  );
  if (!AI_ALLOWED_MODELS.has(model)) {
    throw new PublicError('AI_MODEL_NOT_ALLOWED', 503);
  }

  const config = AI_TASK_CONFIG[task];
  const requestedMaxTokens = Number(payload.max_tokens || 0);
  const maxTokens = requestedMaxTokens > 0
    ? Math.min(
        config.maxTokens,
        Math.max(64, Math.floor(requestedMaxTokens))
      )
    : config.defaultMaxTokens;

  const system = normalizeAiSystem(payload.system, task, config);
  const messages = normalizeAiMessages(payload.messages, task, config);

  const body = {
    model,
    max_tokens: maxTokens,
    thinking: { type: 'disabled' },
    messages,
    output_config: task === 'dialogue'
      ? {
          effort: 'medium',
          format: {
            type: 'json_schema',
            schema: AI_DIALOGUE_OUTPUT_SCHEMA,
          },
        }
      : { effort: config.effort },
  };

  if (system.length) body.system = system;

  return { task, model, body };
}

function normalizeAiSystem(value, task, config) {
  if (value === undefined || value === null || value === '') {
    if (task === 'vision') return [];
    throw new PublicError('AI_SYSTEM_REQUIRED', 400);
  }

  const rawBlocks = typeof value === 'string'
    ? [{ type: 'text', text: value }]
    : value;

  if (!Array.isArray(rawBlocks) || rawBlocks.length > 4) {
    throw new PublicError('AI_INVALID_SYSTEM', 400);
  }

  let totalChars = 0;
  const blocks = [];

  for (const raw of rawBlocks) {
    if (!raw || raw.type !== 'text' || typeof raw.text !== 'string') {
      throw new PublicError('AI_INVALID_SYSTEM', 400);
    }

    const text = cleanAiPromptText(raw.text);
    if (!text.trim()) continue;

    totalChars += text.length;
    if (totalChars > config.systemMaxChars) {
      throw new PublicError('AI_SYSTEM_TOO_LARGE', 413);
    }

    blocks.push({ type: 'text', text });
  }

  if (!blocks.length && task !== 'vision') {
    throw new PublicError('AI_SYSTEM_REQUIRED', 400);
  }

  // Кэшируем только первый статичный блок: легенду, каталог и техпромпт.
  // Следующие блоки могут содержать профиль конкретного менеджера.
  if (blocks.length && task !== 'vision') {
    blocks[0].cache_control = {
      type: 'ephemeral',
      ttl: '1h',
    };
  }

  return blocks;
}

function normalizeAiMessages(value, task, config) {
  if (!Array.isArray(value) || value.length !== 1) {
    throw new PublicError('AI_ONE_USER_MESSAGE_REQUIRED', 400);
  }

  const message = value[0];
  if (!message || message.role !== 'user') {
    throw new PublicError('AI_ONE_USER_MESSAGE_REQUIRED', 400);
  }

  if (task !== 'vision') {
    if (typeof message.content !== 'string') {
      throw new PublicError('AI_TEXT_CONTENT_REQUIRED', 400);
    }

    const content = cleanAiPromptText(message.content);
    if (!content.trim()) {
      throw new PublicError('AI_TEXT_CONTENT_REQUIRED', 400);
    }
    if (content.length > config.userMaxChars) {
      throw new PublicError('AI_USER_CONTENT_TOO_LARGE', 413);
    }

    return [{ role: 'user', content }];
  }

  return [{
    role: 'user',
    content: normalizeAiVisionContent(message.content, config),
  }];
}

function normalizeAiVisionContent(value, config) {
  if (!Array.isArray(value) || !value.length || value.length > 4) {
    throw new PublicError('AI_INVALID_VISION_CONTENT', 400);
  }

  const content = [];
  let imageCount = 0;
  let imageBase64Chars = 0;
  let textChars = 0;

  for (const block of value) {
    if (block?.type === 'text') {
      if (typeof block.text !== 'string') {
        throw new PublicError('AI_INVALID_VISION_TEXT', 400);
      }

      const text = cleanAiPromptText(block.text);
      textChars += text.length;
      if (textChars > config.userMaxChars) {
        throw new PublicError('AI_VISION_TEXT_TOO_LARGE', 413);
      }
      content.push({ type: 'text', text });
      continue;
    }

    if (block?.type !== 'image') {
      throw new PublicError('AI_INVALID_VISION_CONTENT', 400);
    }

    const source = block.source;
    const mediaType = cleanText(source?.media_type, 40).toLowerCase();
    const data = source?.data;

    if (
      !source ||
      source.type !== 'base64' ||
      !AI_IMAGE_MEDIA_TYPES.has(mediaType) ||
      typeof data !== 'string' ||
      !data.length
    ) {
      throw new PublicError('AI_INVALID_IMAGE', 400);
    }

    imageCount += 1;
    imageBase64Chars += data.length;

    // Две версии одного фото: обычная и контрастная для OCR.
    if (
      imageCount > 2 ||
      data.length > 14 * 1024 * 1024 ||
      imageBase64Chars > 28 * 1024 * 1024
    ) {
      throw new PublicError('AI_IMAGE_TOO_LARGE', 413);
    }

    content.push({
      type: 'image',
      source: {
        type: 'base64',
        media_type: mediaType,
        data,
      },
    });
  }

  if (!imageCount || !textChars) {
    throw new PublicError('AI_IMAGE_AND_QUESTION_REQUIRED', 400);
  }

  return content;
}

function cleanAiPromptText(value) {
  return String(value ?? '').replace(
    /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g,
    ''
  );
}

async function fetchAnthropicWithRetry(env, body) {
  let lastError = null;

  for (let attempt = 1; attempt <= 2; attempt += 1) {
    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), 90000);

    try {
      const response = await fetch(
        'https://api.anthropic.com/v1/messages',
        {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'x-api-key': String(env.ANTHROPIC_API_KEY),
            'anthropic-version': '2023-06-01',
          },
          body: JSON.stringify(body),
          signal: controller.signal,
        }
      );

      clearTimeout(timeoutId);

      if (
        attempt < 2 &&
        [429, 500, 502, 503, 529].includes(response.status)
      ) {
        try {
          await response.body?.cancel();
        } catch (_) {
          // Ничего: тело первой неудачной попытки клиенту не отдаётся.
        }

        const retryAfter = Number(response.headers.get('Retry-After') || 0);
        const waitMs = retryAfter > 0
          ? Math.min(5000, retryAfter * 1000)
          : 1500;
        await aiDelay(waitMs);
        continue;
      }

      return response;
    } catch (error) {
      clearTimeout(timeoutId);
      lastError = error;

      if (attempt < 2 && error?.name !== 'AbortError') {
        await aiDelay(1500);
        continue;
      }

      throw error;
    }
  }

  throw lastError || new Error('AI_UPSTREAM_UNAVAILABLE');
}

function aiDelay(milliseconds) {
  return new Promise((resolve) => setTimeout(resolve, milliseconds));
}

function mapAnthropicGatewayError(status, upstreamType) {
  if (status === 400) return { code: 'AI_REQUEST_REJECTED', status: 400 };
  if (status === 413) return { code: 'AI_REQUEST_TOO_LARGE', status: 413 };
  if (status === 429) return { code: 'AI_PROVIDER_RATE_LIMITED', status: 429 };
  if (status === 529) return { code: 'AI_OVERLOADED', status: 503 };
  if (status === 401 || status === 403) {
    return { code: 'AI_PROVIDER_AUTH_FAILED', status: 503 };
  }
  if (status >= 500) return { code: 'AI_UPSTREAM_UNAVAILABLE', status: 503 };

  const safeType = cleanCode(upstreamType, 60);
  return {
    code: safeType ? `AI_${safeType.toUpperCase()}` : 'AI_UPSTREAM_ERROR',
    status: 502,
  };
}

function aiGatewayErrorResponse(code, status, requestId) {
  return jsonResponse(
    {
      type: 'error',
      error: {
        type: code,
        message: code,
      },
      request_id: requestId,
    },
    status,
    true
  );
}

function aiInstallationDailyLimitUsd(env) {
  return aiLimitNumber(
    env.AI_INSTALLATION_DAILY_LIMIT_USD,
    AI_DEFAULT_INSTALLATION_DAILY_LIMIT_USD
  );
}

function aiGlobalDailyLimitUsd(env) {
  return aiLimitNumber(
    env.AI_GLOBAL_DAILY_LIMIT_USD,
    AI_DEFAULT_GLOBAL_DAILY_LIMIT_USD
  );
}

function aiOkzDailyLimitUsd(env) {
  return aiLimitNumber(
    env.OKZ_AI_DAILY_LIMIT_USD,
    AI_DEFAULT_OKZ_DAILY_LIMIT_USD
  );
}

function aiLimitNumber(value, fallback) {
  if (value === undefined || value === null || value === '') return fallback;
  const parsed = Number(value);
  if (!Number.isFinite(parsed) || parsed < 0) return fallback;
  return Math.min(100000, parsed);
}

async function enforceAiDailyBudget(env, identity) {
  const usageDay = moscowDayKey(Date.now());
  const totals = await env.DB.prepare(`
    SELECT
      COALESCE(SUM(estimated_cost_microusd), 0) AS global_cost,
      COALESCE(SUM(
        CASE WHEN installation_id = ?
          THEN estimated_cost_microusd ELSE 0 END
      ), 0) AS installation_cost
    FROM ai_usage
    WHERE usage_day = ? AND status = 'ok'
  `).bind(identity.installation_id, usageDay).first();

  const installationLimit = aiInstallationDailyLimitUsd(env);
  const globalLimit = aiGlobalDailyLimitUsd(env);
  const installationSpent = numberOrZero(totals?.installation_cost);
  const globalSpent = numberOrZero(totals?.global_cost);

  if (
    installationLimit > 0 &&
    installationSpent >= Math.round(installationLimit * 1000000)
  ) {
    throw new PublicError('AI_INSTALLATION_DAILY_BUDGET_REACHED', 429);
  }

  if (
    globalLimit > 0 &&
    globalSpent >= Math.round(globalLimit * 1000000)
  ) {
    throw new PublicError('AI_GLOBAL_DAILY_BUDGET_REACHED', 429);
  }
}

async function createOkzPublicIdentity(request) {
  const networkSource = [
    cleanText(request.headers.get('CF-Connecting-IP'), 60) || 'unknown-ip',
    cleanText(request.headers.get('User-Agent'), 200) || 'unknown-agent',
  ].join('|');
  const fingerprint = (await sha256Hex(networkSource)).slice(0, 32);

  return {
    auth_mode: 'okz_public',
    credential_id: `okz_${fingerprint}`,
    installation_id: `okz_public_${fingerprint}`,
    manager_id: 'okz',
    manager_name: 'OKZ',
    stage: 'okz',
    platform: 'okz',
  };
}

async function enforceOkzDailyBudget(env, identity) {
  const usageDay = moscowDayKey(Date.now());
  const row = await env.DB.prepare(`
    SELECT COALESCE(SUM(estimated_cost_microusd), 0) AS cost
    FROM ai_usage
    WHERE usage_day = ?
      AND installation_id = ?
      AND status = 'ok'
  `).bind(usageDay, identity.installation_id).first();

  const limitUsd = aiOkzDailyLimitUsd(env);
  if (
    limitUsd > 0 &&
    numberOrZero(row?.cost) >= Math.round(limitUsd * 1000000)
  ) {
    throw new PublicError('OKZ_AI_DAILY_BUDGET_REACHED', 429);
  }
}

async function safeRecordAiUsage(env, data) {
  try {
    const usage = data.usage || {};
    const inputTokens = aiUsageInteger(usage.input_tokens);
    const cacheCreationTokens = aiUsageInteger(
      usage.cache_creation_input_tokens
    );
    const cacheReadTokens = aiUsageInteger(
      usage.cache_read_input_tokens
    );
    const outputTokens = aiUsageInteger(usage.output_tokens);
    const estimatedCost = estimateAiCostMicroUsd({
      inputTokens,
      cacheCreationTokens,
      cacheReadTokens,
      outputTokens,
    });
    const now = Date.now();

    await env.DB.prepare(`
      INSERT OR REPLACE INTO ai_usage (
        usage_day, installation_id, request_id, credential_id,
        manager_id, stage, platform, task, model, status,
        input_tokens, cache_creation_input_tokens,
        cache_read_input_tokens, output_tokens,
        estimated_cost_microusd, upstream_status, error_code,
        latency_ms, created_at
      ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
    `).bind(
      moscowDayKey(now),
      data.identity.installation_id,
      cleanText(data.requestId, 100),
      cleanText(data.identity.credential_id, 120),
      cleanText(data.identity.manager_id, 120),
      cleanText(data.identity.stage, 120),
      cleanText(data.identity.platform, 40),
      cleanCode(data.task, 30),
      cleanText(data.model, 80),
      cleanCode(data.status, 40),
      inputTokens,
      cacheCreationTokens,
      cacheReadTokens,
      outputTokens,
      estimatedCost,
      Math.max(0, Math.floor(Number(data.upstreamStatus || 0))),
      cleanCode(data.errorCode, 100),
      Math.max(0, Math.floor(Number(data.latencyMs || 0))),
      now
    ).run();
  } catch (error) {
    console.error('ai_usage_write_failed', {
      request_id: cleanText(data.requestId, 100),
      installation_id: cleanText(data.identity?.installation_id, 160),
      name: cleanText(error?.name || 'Error', 80),
    });
  }
}

function aiUsageInteger(value) {
  const parsed = Number(value || 0);
  if (!Number.isFinite(parsed) || parsed < 0) return 0;
  return Math.min(1000000000, Math.floor(parsed));
}

function estimateAiCostMicroUsd({
  inputTokens,
  cacheCreationTokens,
  cacheReadTokens,
  outputTokens,
}) {
  // Консервативная цена Sonnet: обычный вход $3/MTok,
  // запись 1h $6/MTok, чтение кэша $0.30/MTok, выход $15/MTok.
  return Math.max(0, Math.round(
    inputTokens * 3 +
    cacheCreationTokens * 6 +
    cacheReadTokens * 0.3 +
    outputTokens * 15
  ));
}

function moscowDayKey(timestamp) {
  // Москва круглый год UTC+3 без перехода на летнее время.
  return new Date(Number(timestamp || Date.now()) + 3 * 60 * 60 * 1000)
    .toISOString()
    .slice(0, 10);
}

async function enforceRateLimit(binding, key, errorCode = 'RATE_LIMITED') {
  if (!binding || typeof binding.limit !== 'function') return;

  const result = await binding.limit({
    key: cleanText(key, 64) || 'unknown',
  });
  if (!result?.success) {
    throw new PublicError(errorCode, 429);
  }
}

function randomOpaqueSecret(byteLength = 32) {
  return bytesToBase64Url(
    crypto.getRandomValues(new Uint8Array(byteLength))
  );
}

function parseScopedToken(token, expectedPrefix) {
  const parts = String(token || '').split('.');
  if (
    parts.length !== 3 ||
    parts[0] !== expectedPrefix ||
    !/^[a-zA-Z0-9_-]{8,120}$/.test(parts[1]) ||
    !/^[a-zA-Z0-9_-]{20,200}$/.test(parts[2])
  ) {
    return null;
  }

  return { id: parts[1], secret: parts[2], token: parts.join('.') };
}

async function authenticateInstallation(request, env) {
  const parsed = parseScopedToken(
    bearerToken(request),
    INSTALLATION_TOKEN_PREFIX
  );
  if (!parsed) return null;

  const row = await env.DB.prepare(`
    SELECT * FROM installation_credentials
    WHERE credential_id = ? AND status = 'active'
  `).bind(parsed.id).first();

  if (!row) return null;
  const providedHash = await sha256Hex(parsed.token);
  if (!safeEqual(providedHash, row.token_hash)) return null;

  const now = Date.now();
  if (now - Number(row.last_used_at || 0) >= 5 * 60 * 1000) {
    await env.DB.prepare(`
      UPDATE installation_credentials
      SET last_used_at = ?, last_ip = ?, last_user_agent = ?, updated_at = ?
      WHERE credential_id = ? AND status = 'active'
    `).bind(
      now,
      cleanText(request.headers.get('CF-Connecting-IP'), 60),
      cleanText(request.headers.get('User-Agent'), 200),
      now,
      row.credential_id
    ).run();
  }

  return {
    auth_mode: 'installation',
    credential_id: row.credential_id,
    installation_id: row.installation_id,
    manager_id: row.manager_id,
    manager_name: row.manager_name,
    stage: row.stage,
    platform: row.platform,
  };
}

async function authenticateIngestRequest(request, env) {
  const installation = await authenticateInstallation(request, env);
  if (installation) return installation;

  // Временный мост миграции: старый общий токен допускается только
  // для телеметрии. Эндпоинты решений его никогда не принимают.
  const provided = bearerToken(request);
  if (
    env.INGEST_TOKEN &&
    safeEqual(provided, String(env.INGEST_TOKEN))
  ) {
    return { auth_mode: 'legacy' };
  }

  return null;
}

function assertPayloadInstallation(payload, installationId) {
  const supplied = cleanText(
    payload?.installation_id || payload?.installationId,
    160
  );
  if (supplied && !safeEqual(supplied, installationId)) {
    throw new PublicError('INSTALLATION_ID_MISMATCH', 403);
  }
}

function bindDecisionIdentity(payload, identity) {
  return {
    ...(payload || {}),
    installation_id: identity.installation_id,
    manager_id: identity.manager_id,
    manager_name: identity.manager_name,
    stage: identity.stage,
    platform: identity.platform,
  };
}

function bindIngestIdentity(payload, identity) {
  if (identity.auth_mode === 'legacy') return { ...(payload || {}), __auth_mode: 'legacy' };
  assertPayloadInstallation(payload, identity.installation_id);

  const trusted = {
    ...(payload || {}),
    installation_id: identity.installation_id,
    manager_id: identity.manager_id,
    manager_name: identity.manager_name,
    stage: identity.stage,
    platform: identity.platform,
    __auth_mode: 'installation',
  };

  if (payload?.installation && typeof payload.installation === 'object') {
    trusted.installation = {
      ...payload.installation,
      installation_id: identity.installation_id,
      manager_id: identity.manager_id,
      manager_name: identity.manager_name,
      stage: identity.stage,
      platform: identity.platform,
      __auth_mode: 'installation',
    };
  }

  return trusted;
}





function bearerToken(request) {
  const value = request.headers.get('Authorization') || '';
  const match = value.match(/^Bearer\s+(.+)$/i);
  return match ? match[1].trim() : '';
}

function parseCookies(header) {
  const result = {};
  for (const part of String(header).split(';')) {
    const index = part.indexOf('=');
    if (index < 0) continue;
    const key = part.slice(0, index).trim();
    const value = part.slice(index + 1).trim();
    if (key) result[key] = value;
  }
  return result;
}

function safeEqual(a, b) {
  const left = String(a || '');
  const right = String(b || '');
  const length = Math.max(left.length, right.length);
  let diff = left.length ^ right.length;

  for (let index = 0; index < length; index += 1) {
    diff |=
      (left.charCodeAt(index) || 0) ^
      (right.charCodeAt(index) || 0);
  }

  return diff === 0;
}

async function readJsonBody(request) {
  return readJsonBodyWithLimit(request, MAX_BODY_BYTES);
}

async function readJsonBodyWithLimit(request, maximumBytes) {
  const safeMaximum = Math.max(
    1024,
    Math.floor(Number(maximumBytes || MAX_BODY_BYTES))
  );
  const declaredLength = Number(request.headers.get('Content-Length') || 0);
  if (declaredLength > safeMaximum) {
    throw new PublicError('BODY_TOO_LARGE', 413);
  }

  const text = await request.text();
  if (new TextEncoder().encode(text).byteLength > safeMaximum) {
    throw new PublicError('BODY_TOO_LARGE', 413);
  }

  if (!text.trim()) {
    throw new PublicError('EMPTY_BODY', 400);
  }

  try {
    return JSON.parse(text);
  } catch (_) {
    throw new PublicError('INVALID_JSON', 400);
  }
}

function normalizeLastSender(value) {
  const normalized = cleanText(value, 20).toLowerCase();
  if (['client', 'assistant', 'manager'].includes(normalized)) {
    return normalized;
  }
  return '';
}

function cleanText(value, maxLength = 120) {
  return String(value ?? '')
    .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '')
    .replace(/\s+/g, ' ')
    .trim()
    .slice(0, maxLength);
}

function cleanCode(value, maxLength = 100) {
  return cleanText(value, maxLength)
    .replace(/[^a-zA-Z0-9_.:-]/g, '_')
    .slice(0, maxLength);
}

function redactCredentialText(value) {
    return String(value ?? '')
      .replace(/\b(?:iat|act)\.[a-zA-Z0-9_-]{8,120}\.[a-zA-Z0-9_-]{20,200}\b/g, '[REDACTED_INSTALLATION_TOKEN]')
      .replace(/\bsk-ant-[a-zA-Z0-9_-]+/gi, '[REDACTED_API_KEY]')
      .replace(/\bgsk_[a-zA-Z0-9_-]+/g, '[REDACTED_AUDIO_KEY]')
      .replace(/Bearer\s+[a-zA-Z0-9._~+\/-]+=*/gi, 'Bearer [REDACTED]');
  }

function redactSensitive(value, maxLength = 700) {
  return cleanText(redactCredentialText(value), maxLength * 2)
    .replace(/sk-ant-[a-zA-Z0-9_-]+/gi, '[REDACTED_API_KEY]')
    .replace(/\b\d{6,12}:[a-zA-Z0-9_-]{20,}\b/g, '[REDACTED_BOT_TOKEN]')
    .replace(/Bearer\s+[a-zA-Z0-9._~+\/-]+=*/gi, 'Bearer [REDACTED]')
    .replace(/([?&](?:key|token|access_token)=)[^&\s]+/gi, '$1[REDACTED]')
    .replace(/\+?\d[\d\s()\-]{7,}\d/g, '[REDACTED_ID]')
    .slice(0, maxLength);
}

function safeDetailsJson(value) {
  if (value === undefined || value === null) return '{}';

  try {
    const serialized = JSON.stringify(value);
    const redacted = redactSensitive(serialized, 4000) || '{}';

    try {
      JSON.parse(redacted);
      return redacted;
    } catch (_) {
      // Если строка была обрезана посередине JSON, сохраняем валидный объект.
      return JSON.stringify({
        truncated: true,
        preview: redactSensitive(serialized, 3500),
      });
    }
  } catch (_) {
    return '{"serialization_error":true}';
  }
}

async function readFormDataLimited(request) {
  const declaredLength = Number(request.headers.get('Content-Length') || 0);
  if (declaredLength > MAX_FORM_BYTES) {
    throw new PublicError('FORM_TOO_LARGE', 413);
  }

  const body = await request.arrayBuffer();
  if (body.byteLength > MAX_FORM_BYTES) {
    throw new PublicError('FORM_TOO_LARGE', 413);
  }

  const copy = new Request(request.url, {
    method: request.method,
    headers: request.headers,
    body,
  });

  return copy.formData();
}

function isSameOriginRequest(request) {
  const origin = request.headers.get('Origin');
  const fetchSite = String(
    request.headers.get('Sec-Fetch-Site') || ''
  ).toLowerCase();

  // Sec-Fetch-Site выставляет сам браузер. Для формы, открытой на этом же
  // Worker, это надёжное подтверждение запроса с той же страницы.
  if (fetchSite === 'same-origin') return true;

  // Явно чужой сайт не должен отправлять административные формы.
  if (fetchSite === 'cross-site') return false;

  // Совместимость с браузерами и клиентами без Fetch Metadata.
  if (!origin) return true;

  try {
    const requestUrl = new URL(request.url);
    const originUrl = new URL(origin);

    return (
      originUrl.protocol === requestUrl.protocol &&
      originUrl.host === requestUrl.host
    );
  } catch (_) {
    return false;
  }
}

function safeTimestamp(value) {
  const number = Number(value || 0);
  if (!Number.isFinite(number) || number <= 0) return 0;
  return Math.trunc(Math.min(number, 8640000000000000));
}

// Часовой пояс не меняет Unix-время, но неверные часы компьютера могут
// прислать дату из будущего. Не позволяем ей ломать статусы и сортировку.
function safeObservedTimestamp(value, now = Date.now()) {
  const timestamp = safeTimestamp(value);
  if (!timestamp) return 0;
  return timestamp > now + MAX_CLOCK_AHEAD_MS ? now : timestamp;
}

function clampInt(value, minimum, maximum) {
  const parsed = Number.parseInt(String(value ?? ''), 10);
  if (!Number.isFinite(parsed)) return minimum;
  return Math.min(maximum, Math.max(minimum, parsed));
}

function boolInt(value) {
  return value === true || value === 1 || value === '1' ? 1 : 0;
}

function numberOrZero(value) {
  const number = Number(value || 0);
  return Number.isFinite(number) ? number : 0;
}

function normalizePath(pathname) {
  if (!pathname || pathname === '/') return '/';
  return pathname.replace(/\/+$/, '') || '/';
}

function jsonResponse(data, status = 200, cors = false) {
  return new Response(JSON.stringify(data), {
    status,
    headers: {
      'Content-Type': 'application/json; charset=utf-8',
      'Cache-Control': 'no-store',
      ...(cors ? corsHeaders() : securityHeaders()),
    },
  });
}

function htmlResponse(html, status = 200) {
  return new Response(html, {
    status,
    headers: {
      'Content-Type': 'text/html; charset=utf-8',
      'Cache-Control': 'no-store',
      ...securityHeaders(true),
    },
  });
}

function corsHeaders() {
  return {
    'Access-Control-Allow-Origin': '*',
    'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
    'Access-Control-Allow-Headers':
      'Authorization, Content-Type, X-Audio-Filename, X-OKZ-Client',
    'Access-Control-Max-Age': '86400',
    ...securityHeaders(),
  };
}

function securityHeaders(html = false) {
  const headers = {
    'X-Content-Type-Options': 'nosniff',
    'X-Frame-Options': 'DENY',
    'Referrer-Policy': 'no-referrer',
    'Permissions-Policy': 'camera=(), microphone=(), geolocation=()',
  };

  if (html) {
    headers['Content-Security-Policy'] = [
      "default-src 'none'",
      "script-src 'unsafe-inline'",
      "style-src 'unsafe-inline'",
      "connect-src 'self'",
      "img-src 'self' data:",
      "form-action 'self'",
      "frame-ancestors 'none'",
      "base-uri 'none'",
    ].join('; ');
  }

  return headers;
}

// ================================================================
// ПОЛЬЗОВАТЕЛИ, СЕССИИ И РОЛИ
// admin — абсолютно всё, включая ошибки, аудит и пользователей
// head  — все стейджи, менеджеры и клиенты, без подробных логов ошибок
// lead  — тимлидер: только назначенные стейджи, без подробных логов ошибок
// Фильтрация выполняется в SQL, а не в интерфейсе.
// ================================================================
const ROLE_VALUES = new Set(['admin', 'head', 'lead']);
const SESSION_TTL_MS = 12 * 60 * 60 * 1000;
const LOGIN_MAX_ATTEMPTS = 5;
const LOGIN_LOCK_MS = 15 * 60 * 1000;
// Cloudflare Workers Web Crypto допускает не более 100000 итераций PBKDF2.
const PBKDF2_ITERATIONS = 100000;
let authSchemaPromise = null;

async function ensureAuthSchema(env) {
  if (!authSchemaPromise) {
    authSchemaPromise = initializeAuthSchema(env).catch((error) => {
      authSchemaPromise = null;
      throw error;
    });
  }

  return authSchemaPromise;
}

async function initializeAuthSchema(env) {
  const statements = [
    `CREATE TABLE IF NOT EXISTS users (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      login TEXT NOT NULL UNIQUE,
      password_hash TEXT NOT NULL,
      password_salt TEXT NOT NULL,
      role TEXT NOT NULL DEFAULT 'lead',
      display_name TEXT NOT NULL DEFAULT '',
      is_active INTEGER NOT NULL DEFAULT 1,
      must_change_password INTEGER NOT NULL DEFAULT 1,
      failed_attempts INTEGER NOT NULL DEFAULT 0,
      locked_until INTEGER NOT NULL DEFAULT 0,
      created_at INTEGER NOT NULL DEFAULT 0,
      last_login_at INTEGER NOT NULL DEFAULT 0
    )`,
    `CREATE TABLE IF NOT EXISTS user_stages (
      user_id INTEGER NOT NULL,
      stage TEXT NOT NULL,
      PRIMARY KEY (user_id, stage)
    )`,
    `CREATE TABLE IF NOT EXISTS user_sessions (
      token_hash TEXT PRIMARY KEY,
      user_id INTEGER NOT NULL,
      created_at INTEGER NOT NULL,
      expires_at INTEGER NOT NULL,
      ip TEXT NOT NULL DEFAULT '',
      user_agent TEXT NOT NULL DEFAULT ''
    )`,
    `CREATE TABLE IF NOT EXISTS installation_stops (
      installation_id TEXT PRIMARY KEY,
      stopped INTEGER NOT NULL DEFAULT 0,
      updated_at INTEGER NOT NULL DEFAULT 0,
      updated_by TEXT NOT NULL DEFAULT ''
    )`,
    `CREATE TABLE IF NOT EXISTS app_settings (
      key TEXT PRIMARY KEY,
      value TEXT NOT NULL DEFAULT '',
      updated_at INTEGER NOT NULL DEFAULT 0,
      updated_by TEXT NOT NULL DEFAULT ''
    )`,
    `CREATE TABLE IF NOT EXISTS audit_log (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      at INTEGER NOT NULL,
      user_id INTEGER NOT NULL DEFAULT 0,
      login TEXT NOT NULL DEFAULT '',
      action TEXT NOT NULL,
      target TEXT NOT NULL DEFAULT '',
      ip TEXT NOT NULL DEFAULT ''
    )`,
    `CREATE INDEX IF NOT EXISTS idx_sessions_expires ON user_sessions(expires_at)`,
    `CREATE INDEX IF NOT EXISTS idx_audit_at ON audit_log(at)`,
  ];

  for (const sql of statements) {
    await env.DB.prepare(sql).run();
  }

  // Первый вход: если пользователей нет, создаём админа из ADMIN_TOKEN.
  // Логин admin, пароль равен ADMIN_TOKEN, смена обязательна при входе.
  const count = await env.DB.prepare(
    'SELECT COUNT(*) AS total FROM users'
  ).first();

  if (!Number(count?.total || 0) && env.ADMIN_TOKEN) {
    const { hash, salt } = await hashPassword(String(env.ADMIN_TOKEN));

    await env.DB.prepare(`
      INSERT INTO users (
        login, password_hash, password_salt, role, display_name,
        is_active, must_change_password, created_at
      ) VALUES (?, ?, ?, 'admin', 'Администратор', 1, 1, ?)
    `).bind('admin', hash, salt, Date.now()).run();
  }
}

// ---------- Пароли: PBKDF2-SHA256 (bcrypt в Workers недоступен) ----------
function bytesToHex(buffer) {
  return Array.from(new Uint8Array(buffer))
    .map((byte) => byte.toString(16).padStart(2, '0'))
    .join('');
}

async function hashPassword(password, saltHex = null) {
  const salt = saltHex
    ? Uint8Array.from(
        saltHex.match(/.{2}/g).map((byte) => parseInt(byte, 16))
      )
    : crypto.getRandomValues(new Uint8Array(16));

  const key = await crypto.subtle.importKey(
    'raw',
    new TextEncoder().encode(String(password)),
    'PBKDF2',
    false,
    ['deriveBits']
  );

  const bits = await crypto.subtle.deriveBits(
    {
      name: 'PBKDF2',
      salt,
      iterations: PBKDF2_ITERATIONS,
      hash: 'SHA-256',
    },
    key,
    256
  );

  return {
    hash: bytesToHex(bits),
    salt: bytesToHex(salt.buffer || salt),
  };
}

async function verifyPassword(password, storedHash, storedSalt) {
  const { hash } = await hashPassword(password, storedSalt);
  return safeEqual(hash, storedHash);
}

// ---------- Сессии ----------
async function sha256Hex(value) {
  const digest = await crypto.subtle.digest(
    'SHA-256',
    new TextEncoder().encode(String(value))
  );
  return bytesToHex(digest);
}

async function createUserSession(env, user, request) {
  const token = crypto.randomUUID() + crypto.randomUUID().slice(0, 8);
  const tokenHash = await sha256Hex(token);
  const now = Date.now();

  await env.DB.prepare(`
    INSERT INTO user_sessions (
      token_hash, user_id, created_at, expires_at, ip, user_agent
    ) VALUES (?, ?, ?, ?, ?, ?)
  `).bind(
    tokenHash,
    user.id,
    now,
    now + SESSION_TTL_MS,
    cleanText(request.headers.get('CF-Connecting-IP'), 60),
    cleanText(request.headers.get('User-Agent'), 200)
  ).run();

  return token;
}

async function getSessionUser(request, env) {
  const cookies = parseCookies(request.headers.get('Cookie') || '');
  const token = cookies.monitor_session || '';

  if (!token) return null;

  const tokenHash = await sha256Hex(token);

  const row = await env.DB.prepare(`
    SELECT
      u.id, u.login, u.role, u.display_name,
      u.is_active, u.must_change_password,
      s.expires_at
    FROM user_sessions s
    JOIN users u ON u.id = s.user_id
    WHERE s.token_hash = ?
  `).bind(tokenHash).first();

  if (!row) return null;

  if (Number(row.expires_at || 0) < Date.now() || !Number(row.is_active)) {
    await env.DB.prepare(
      'DELETE FROM user_sessions WHERE token_hash = ?'
    ).bind(tokenHash).run();
    return null;
  }

  const stages = row.role === 'lead'
    ? (await env.DB.prepare(
        'SELECT stage FROM user_stages WHERE user_id = ?'
      ).bind(row.id).all()).results?.map((item) => item.stage) || []
    : [];

  return {
    id: row.id,
    login: row.login,
    role: row.role,
    displayName: row.display_name,
    mustChangePassword: Number(row.must_change_password) === 1,
    stages,
    tokenHash,
  };
}

function sessionCookie(token) {
  return [
    `monitor_session=${token}`,
    'Path=/',
    'HttpOnly',
    'Secure',
    'SameSite=Strict',
    `Max-Age=${Math.floor(SESSION_TTL_MS / 1000)}`,
  ].join('; ');
}

function clearSessionCookie() {
  return [
    'monitor_session=',
    'Path=/',
    'HttpOnly',
    'Secure',
    'SameSite=Strict',
    'Max-Age=0',
  ].join('; ');
}

// ---------- Права ----------
// Возвращает кусок SQL и параметры для ограничения выборки по стейджам.
// Для admin и head ограничения нет. Для lead без назначенных стейджей
// подставляется заведомо ложное условие: пустой доступ вместо полного.
function stageFilter(user, column = 'stage') {
  if (!user) return { sql: ' AND 1 = 0', params: [] };
  if (user.role === 'admin' || user.role === 'head') {
    return { sql: '', params: [] };
  }

  if (!user.stages.length) {
    return { sql: ' AND 1 = 0', params: [] };
  }

  const placeholders = user.stages.map(() => '?').join(', ');
  return {
    sql: ` AND ${column} IN (${placeholders})`,
    params: [...user.stages],
  };
}

function canSeeErrors(user) {
  return user?.role === 'admin';
}

function canManageUsers(user) {
  return user?.role === 'admin';
}

// ================================================================
// ГЛОБАЛЬНАЯ ОСТАНОВКА ВСЕХ СКРИПТОВ
// Команда уходит скриптам в ответе heartbeat. Скрипт сохраняет её
// локально, поэтому перезагрузка вкладки и потеря связи с сервером
// остановленного помощника не «оживляют».
// ================================================================
async function readGlobalStop(env) {
  try {
    const row = await env.DB.prepare(
      "SELECT value, updated_at, updated_by FROM app_settings WHERE key = 'global_stop'"
    ).first();

    return {
      stopped: String(row?.value || '') === '1',
      at: numberOrZero(row?.updated_at),
      by: String(row?.updated_by || ''),
    };
  } catch (_) {
    // Настройки недоступны — не останавливаем работу без явной команды
    return { stopped: false, at: 0, by: '' };
  }
}

async function writeGlobalStop(env, stopped, user) {
  await env.DB.prepare(`
    INSERT INTO app_settings (key, value, updated_at, updated_by)
    VALUES ('global_stop', ?, ?, ?)
    ON CONFLICT(key) DO UPDATE SET
      value = excluded.value,
      updated_at = excluded.updated_at,
      updated_by = excluded.updated_by
  `).bind(
    stopped ? '1' : '0',
    Date.now(),
    cleanText(user?.login, 80)
  ).run();
}

// ================================================================
// АКТУАЛЬНЫЕ ВЕРСИИ СКРИПТОВ
// Администратор указывает версию, которую считает актуальной.
// Она уходит скриптам в ответе heartbeat: отставший показывает
// менеджеру плашку «доступно обновление», в админке такие установки
// подсвечиваются. Работать при этом никто не мешает.
// ================================================================
const VERSION_PLATFORMS = ['telegram', 'max', 'whatsapp', 'gemspace'];

// Ежедневное напоминание о перезапуске браузера.
// Время и текст задаёт администратор на сайте, чтобы менять их
// без перезаливки скриптов всем менеджерам.
const RESTART_DEFAULTS = {
  enabled: false,
  time: '18:50',
  text: 'Зайдите в Dolphin, нажмите «Стоп», затем снова запустите профиль.',
};

async function readRestartReminder(env) {
  try {
    const rows = await env.DB.prepare(
      "SELECT key, value FROM app_settings WHERE key LIKE 'restart_%'"
    ).all();

    const map = {};
    for (const row of rows.results || []) {
      map[String(row.key || '')] = String(row.value || '');
    }

    return {
      enabled: map.restart_enabled === '1',
      time: /^\d{2}:\d{2}$/.test(map.restart_time || '')
        ? map.restart_time
        : RESTART_DEFAULTS.time,
      text: map.restart_text || RESTART_DEFAULTS.text,
    };
  } catch (_) {
    return { ...RESTART_DEFAULTS };
  }
}

async function writeRestartReminder(env, settings, user) {
  const rows = [
    ['restart_enabled', settings.enabled ? '1' : '0'],
    ['restart_time', /^\d{2}:\d{2}$/.test(settings.time || '')
      ? settings.time
      : RESTART_DEFAULTS.time],
    ['restart_text', cleanText(settings.text, 400) || RESTART_DEFAULTS.text],
  ];

  for (const [key, value] of rows) {
    await env.DB.prepare(`
      INSERT INTO app_settings (key, value, updated_at, updated_by)
      VALUES (?, ?, ?, ?)
      ON CONFLICT(key) DO UPDATE SET
        value = excluded.value,
        updated_at = excluded.updated_at,
        updated_by = excluded.updated_by
    `).bind(key, value, Date.now(), cleanText(user?.login, 80)).run();
  }
}

async function readLoaderUrls(env) {
  const result = {};

  try {
    const rows = await env.DB.prepare(
      "SELECT key, value FROM app_settings WHERE key LIKE 'loader_url_%'"
    ).all();

    for (const row of rows.results || []) {
      const platform = String(row.key || '').replace('loader_url_', '');
      if (VERSION_PLATFORMS.includes(platform)) {
        result[platform] = String(row.value || '');
      }
    }
  } catch (_) {}

  return result;
}

let latestVersionsCache = {
  at: 0,
  value: {},
};

async function readLatestVersions(
  env
) {
  const now = Date.now();

  if (
    now -
      Number(
        latestVersionsCache.at ||
        0
      ) <
      30 * 1000
  ) {
    return {
      ...latestVersionsCache.value,
    };
  }

  const result = {};

  if (
    env.CONFIG_KV &&
    typeof env.CONFIG_KV.get ===
      'function'
  ) {
    try {
      const values =
        await Promise.all(
          VERSION_PLATFORMS.map(
            (platform) =>
              env.CONFIG_KV.get(
                platform +
                  '_version'
              )
          )
        );

      VERSION_PLATFORMS.forEach(
        (platform, index) => {
          const version =
            String(
              values[index] ||
              ''
            ).trim();

          if (
            /^\d+\.\d+\.\d+$/
              .test(version)
          ) {
            result[platform] =
              version;
          }
        }
      );
    } catch (_) {}
  }

  try {
    const rows =
      await env.DB.prepare(
        "SELECT key, value FROM app_settings WHERE key LIKE 'latest_version_%'"
      ).all();

    for (
      const row of
      rows.results || []
    ) {
      const platform =
        String(
          row.key || ''
        ).replace(
          'latest_version_',
          ''
        );

      if (
        VERSION_PLATFORMS
          .includes(platform) &&
        !result[platform]
      ) {
        result[platform] =
          String(
            row.value || ''
          ).trim();
      }
    }
  } catch (_) {}

  latestVersionsCache = {
    at: now,
    value: {
      ...result,
    },
  };

  return result;
}

async function writeLatestVersion(env, platform, version, user) {
  if (!VERSION_PLATFORMS.includes(platform)) return;

  const cleanVersion =
    cleanText(version, 20);

  if (
    env.CONFIG_KV &&
    typeof env.CONFIG_KV.put === 'function' &&
    /^\d+\.\d+\.\d+$/.test(cleanVersion)
  ) {
    await env.CONFIG_KV.put(
      platform + '_version',
      cleanVersion
    );

    latestVersionsCache = {
      at: 0,
      value: {},
    };
  }

  await env.DB.prepare(`
    INSERT INTO app_settings (key, value, updated_at, updated_by)
    VALUES (?, ?, ?, ?)
    ON CONFLICT(key) DO UPDATE SET
      value = excluded.value,
      updated_at = excluded.updated_at,
      updated_by = excluded.updated_by
  `).bind(
    'latest_version_' + platform,
    cleanVersion,
    Date.now(),
    cleanText(user?.login, 80)
  ).run();
}

// Индивидуальная остановка одной установки. Работает независимо
// от глобального стоп-крана: помощник молчит, если включено любое из двух.
async function readInstallationStop(env, installationId) {
  try {
    const row = await env.DB.prepare(
      'SELECT stopped FROM installation_stops WHERE installation_id = ?'
    ).bind(String(installationId || '')).first();

    return Number(row?.stopped || 0) === 1;
  } catch (_) {
    return false;
  }
}

async function writeInstallationStop(env, installationId, stopped, user) {
  await env.DB.prepare(`
    INSERT INTO installation_stops (installation_id, stopped, updated_at, updated_by)
    VALUES (?, ?, ?, ?)
    ON CONFLICT(installation_id) DO UPDATE SET
      stopped = excluded.stopped,
      updated_at = excluded.updated_at,
      updated_by = excluded.updated_by
  `).bind(
    String(installationId || ''),
    stopped ? 1 : 0,
    Date.now(),
    cleanText(user?.login, 80)
  ).run();
}

async function writeAudit(env, user, action, target, request) {
  try {
    await env.DB.prepare(`
      INSERT INTO audit_log (at, user_id, login, action, target, ip)
      VALUES (?, ?, ?, ?, ?, ?)
    `).bind(
      Date.now(),
      Number(user?.id || 0),
      cleanText(user?.login, 80),
      cleanText(action, 80),
      cleanText(target, 200),
      cleanText(request?.headers?.get('CF-Connecting-IP'), 60)
    ).run();
  } catch (_) {
    // Аудит не должен ломать основной запрос
  }
}

// ---------- Вход ----------
async function handleUserLogin(request, env) {
  await ensureAuthSchema(env);

  if (!isSameOriginRequest(request)) {
    return htmlResponse('Запрос отклонён', 403);
  }

  const form = await readFormDataLimited(request);
  const login = cleanText(form.get('login'), 80).toLowerCase();
  const password = String(form.get('password') || '');
  const now = Date.now();

  await enforceRateLimit(
    env.LOGIN_RATE_LIMITER,
    `login:${request.headers.get('CF-Connecting-IP') || 'unknown'}:${login}`,
    'LOGIN_RATE_LIMITED'
  );

  const user = await env.DB.prepare(`
    SELECT * FROM users WHERE login = ?
  `).bind(login).first();

  const fail = async (reason) => {
    await writeAudit(env, { id: 0, login }, 'login_failed', reason, request);

    return htmlResponse(
      loginPageHtml('Неверный логин или пароль'),
      401
    );
  };

  if (password.length > 256 || !user || !Number(user.is_active)) {
    // Небольшая задержка против перебора
    await new Promise((resolve) => setTimeout(resolve, 400));
    return fail('unknown_or_disabled');
  }

  if (Number(user.locked_until || 0) > now) {
    const minutes = Math.ceil(
      (Number(user.locked_until) - now) / 60000
    );

    return htmlResponse(
      loginPageHtml(
        `Учётная запись временно заблокирована. Повторите через ${minutes} мин.`
      ),
      429
    );
  }

  const valid = await verifyPassword(
    password,
    user.password_hash,
    user.password_salt
  );

  if (!valid) {
    const attempts = Number(user.failed_attempts || 0) + 1;
    const lockedUntil = attempts >= LOGIN_MAX_ATTEMPTS
      ? now + LOGIN_LOCK_MS
      : 0;

    await env.DB.prepare(`
      UPDATE users
      SET failed_attempts = ?, locked_until = ?
      WHERE id = ?
    `).bind(
      lockedUntil ? 0 : attempts,
      lockedUntil,
      user.id
    ).run();

    await new Promise((resolve) => setTimeout(resolve, 400));
    return fail('bad_password');
  }

  await env.DB.prepare(`
    UPDATE users
    SET failed_attempts = 0, locked_until = 0, last_login_at = ?
    WHERE id = ?
  `).bind(now, user.id).run();

  const token = await createUserSession(env, user, request);
  await writeAudit(env, user, 'login', user.role, request);

  return new Response(null, {
    status: 303,
    headers: {
      Location: Number(user.must_change_password) === 1
        ? '/admin/password'
        : '/admin',
      'Set-Cookie': sessionCookie(token),
      ...securityHeaders(),
    },
  });
}

async function handleUserLogout(request, env) {
  if (!isSameOriginRequest(request)) {
    return htmlResponse('Запрос отклонён', 403);
  }

  const user = await getSessionUser(request, env);

  if (user) {
    await env.DB.prepare(
      'DELETE FROM user_sessions WHERE token_hash = ?'
    ).bind(user.tokenHash).run();

    await writeAudit(env, user, 'logout', '', request);
  }

  return new Response(null, {
    status: 303,
    headers: {
      Location: '/admin',
      'Set-Cookie': clearSessionCookie(),
      ...securityHeaders(),
    },
  });
}

async function handlePasswordChange(request, env) {
  if (!isSameOriginRequest(request)) {
    return htmlResponse('Запрос отклонён', 403);
  }

  const user = await getSessionUser(request, env);

  if (!user) {
    return new Response(null, {
      status: 303,
      headers: { Location: '/admin', ...securityHeaders() },
    });
  }

  const form = await readFormDataLimited(request);
  const current = String(form.get('current') || '');
  const next = String(form.get('next') || '');
  const repeat = String(form.get('repeat') || '');

  const row = await env.DB.prepare(
    'SELECT password_hash, password_salt FROM users WHERE id = ?'
  ).bind(user.id).first();

  if (!(await verifyPassword(current, row.password_hash, row.password_salt))) {
    return htmlResponse(
      passwordPageHtml(user, 'Текущий пароль указан неверно'),
      401
    );
  }

  if (next.length < 10) {
    return htmlResponse(
      passwordPageHtml(user, 'Новый пароль должен быть не короче 10 символов'),
      400
    );
  }

  if (next.length > 256) {
    return htmlResponse(
      passwordPageHtml(user, 'Новый пароль не должен превышать 256 символов'),
      400
    );
  }

  if (next !== repeat) {
    return htmlResponse(
      passwordPageHtml(user, 'Новый пароль и подтверждение не совпадают'),
      400
    );
  }

  const { hash, salt } = await hashPassword(next);

  await env.DB.prepare(`
    UPDATE users
    SET password_hash = ?, password_salt = ?, must_change_password = 0
    WHERE id = ?
  `).bind(hash, salt, user.id).run();

  // Все прочие сессии этого пользователя завершаем
  await env.DB.prepare(`
    DELETE FROM user_sessions
    WHERE user_id = ? AND token_hash <> ?
  `).bind(user.id, user.tokenHash).run();

  await writeAudit(env, user, 'password_changed', '', request);

  return new Response(null, {
    status: 303,
    headers: { Location: '/admin', ...securityHeaders() },
  });
}

// ================================================================
// УПРАВЛЕНИЕ ПОЛЬЗОВАТЕЛЯМИ (только admin)
// ================================================================
function randomPassword() {
  const alphabet = 'abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789';
  const bytes = crypto.getRandomValues(new Uint8Array(14));

  return Array.from(bytes)
    .map((byte) => alphabet[byte % alphabet.length])
    .join('');
}

// Страница загрузчиков: воркер сам собирает готовый текст для Tampermonkey.
// Версия берётся из настроек, адрес и ключ конфиг-воркера — из app_settings
// (значения по умолчанию заданы ниже, менять их можно на этой же странице).
const LOADER_DEFAULT_BASE = 'https://ai-asstistant-config.mikhailkorolev555.workers.dev';

const LOADER_PLATFORMS = [
  {
    key: 'telegram',
    title: 'Telegram',
    name: 'AI Помощник для Telegram Web K',
    match: 'https://web.telegram.org/k/*',
    site: 'web.telegram.org/k/',
    extraConnect: [],
  },
  {
    key: 'max',
    title: 'MAX',
    name: 'AI Помощник для MAX (web.max.ru)',
    match: 'https://web.max.ru/*',
    site: 'web.max.ru',
    extraConnect: ['i.oneme.ru', 'raw.githubusercontent.com', 'openrouter.ai'],
  },
  {
    key: 'whatsapp',
    title: 'WhatsApp',
    name: 'AI Помощник для WhatsApp Web',
    match: 'https://web.whatsapp.com/*',
    site: 'web.whatsapp.com',
    extraConnect: ['raw.githubusercontent.com'],
  },
  {
    key: 'gemspace',
    title: 'Gem Space',
    name: 'AI Помощник для Gem Space',
    match: 'https://web.gemspace.com/*',
    site: 'web.gemspace.com',
    extraConnect: [
      'storage.googleapis.com',
      'firebasestorage.googleapis.com',
      'lh3.googleusercontent.com',
    ],
  },
];

function buildLoaderSource(platform, version, base, key, monitorHost) {
  const connects = [
    monitorHost,
    'cdn.aiclientspr.xyz',
    'api.anthropic.com',
    'api.telegram.org',
    'api.groq.com',
    'gist.githubusercontent.com',
    base.replace(/^https?:\/\//, '').replace(/\/$/, ''),
  ].concat(platform.extraConnect);

  const unique = [];
  for (const host of connects) {
    if (host && unique.indexOf(host) === -1) unique.push(host);
  }

  const loaderUrl = base + '/' + platform.key + '-loader.user.js?key=' + key;
  const requireUrl = base + '/' + platform.key + '.require.js?key=' + key + '&v=' + version;

  return [
    '// ==UserScript==',
    '// @name         ' + platform.name,
    '// @namespace    local',
    '// @version      ' + version,
    '// @description  Облачный загрузчик помощника',
    '// @match        ' + platform.match,
    '// @run-at       document-idle',
    '// @noframes',
    '// @grant        GM_xmlhttpRequest',
    '// @grant        GM_setValue',
    '// @grant        GM_getValue',
    '// @grant        GM_notification',
    '// @grant        GM_registerMenuCommand',
    '// @grant        GM_listValues',
    '// @grant        GM_deleteValue',
    '// @grant        GM_info',
    '// @grant        unsafeWindow',
  ].concat(
    unique.map((host) => '// @connect      ' + host)
  ).concat([
    '// @updateURL    ' + loaderUrl,
    '// @downloadURL  ' + loaderUrl,
    '// @require      ' + requireUrl,
    '// ==/UserScript==',
    '// Полный код загружается директивой @require.',
    '// eval и new Function здесь намеренно не используются.',
    '',
  ]).join('\n');
}

async function handleLoadersPage(request, env, user) {
  const versions = await readLatestVersions(env);
  const monitorHost = new URL(request.url).host;

  let base = LOADER_DEFAULT_BASE;
  let key = '';

  try {
    const rows = await env.DB.prepare(
      "SELECT key, value FROM app_settings WHERE key IN ('config_base_url', 'config_key')"
    ).all();

    for (const row of rows.results || []) {
      if (row.key === 'config_base_url' && String(row.value || '').trim()) {
        base = String(row.value).trim().replace(/\/$/, '');
      }
      if (row.key === 'config_key') {
        key = String(row.value || '').trim();
      }
    }
  } catch (_) {}

  const columns = LOADER_PLATFORMS.map((platform) => {
    const version = versions[platform.key] || '';

    if (!version) {
      return '<div class="loader-col">' +
        '<div class="loader-top"><div>' +
          '<div class="loader-name">' + escapeHtml(platform.title) + '</div>' +
          '<div class="muted">' + escapeHtml(platform.site) + '</div>' +
        '</div></div>' +
        '<div class="loader-error">Версия не задана. Впишите её на главной странице ' +
        'в поле «Актуальные версии».</div>' +
      '</div>';
    }

    const code = buildLoaderSource(platform, version, base, key || 'ВСТАВЬТЕ_КЛЮЧ', monitorHost);

    return '<div class="loader-col">' +
      '<div class="loader-top">' +
        '<div>' +
          '<div class="loader-name">' + escapeHtml(platform.title) + '</div>' +
          '<div class="muted">' + escapeHtml(platform.site) + ' · ' + escapeHtml(version) + '</div>' +
        '</div>' +
        '<button class="copy-loader" data-target="code-' + platform.key + '">Копировать</button>' +
      '</div>' +
      (key ? '' : '<div class="loader-error">Ключ конфиг-воркера не задан — вставьте его ниже.</div>') +
      '<textarea id="code-' + platform.key + '" readonly spellcheck="false">' +
        escapeHtml(code) + '</textarea>' +
    '</div>';
  });

  const page =
    '<!doctype html><html lang="ru"><head><meta charset="utf-8">' +
    '<meta name="viewport" content="width=device-width,initial-scale=1">' +
    '<title>Загрузчики</title><style>' + PAGE_STYLE +
    '.wrap { max-width: 1500px; }' +
    '.loader-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:14px; }' +
    '@media (max-width:1000px) { .loader-grid { grid-template-columns:1fr; } }' +
    '.loader-col { display:flex; flex-direction:column; padding:14px;' +
    ' border:1px solid #26364c; border-radius:14px; background:#0c1929; }' +
    '.loader-top { display:flex; align-items:flex-start; justify-content:space-between;' +
    ' gap:10px; margin-bottom:10px; }' +
    '.loader-name { font-size:16px; font-weight:700; }' +
    '.loader-error { margin-bottom:10px; padding:9px 11px; border-radius:9px;' +
    ' background:#481c27; color:#ffb5c0; font-size:12px; }' +
    'textarea { width:100%; height:60vh; min-height:320px; resize:vertical; padding:10px;' +
    ' border:1px solid #344a66; border-radius:10px; background:#07111f; color:#cbd5e1;' +
    ' font-family:ui-monospace,Menlo,Consolas,monospace; font-size:11px; line-height:1.45; }' +
    '.copy-loader { white-space:nowrap; }' +
    '.config-row { display:flex; flex-wrap:wrap; gap:9px; align-items:center;' +
    ' margin:0 0 16px; padding:11px 13px; border:1px solid #26364c; border-radius:11px;' +
    ' background:#0c1929; font-size:13px; }' +
    '.config-row input { padding:8px 10px; border:1px solid #344a66; border-radius:8px;' +
    ' background:#07111f; color:#fff; font-size:12px; }' +
    ".loader-grid { grid-template-columns:repeat(auto-fit,minmax(min(100%,360px),1fr)); gap:16px; }.loader-col { padding:18px; border-color:rgba(164,193,234,.22); border-radius:17px; background:linear-gradient(145deg,#19283f,#101b2d); box-shadow:0 16px 35px rgba(0,3,15,.17); }.loader-name { font-size:17px; letter-spacing:-.02em; }.loader-top { margin-bottom:14px; }.loader-error { background:rgba(224,70,102,.16); border:1px solid rgba(255,139,162,.2); color:#ffbbc8; }.loader-col textarea { height:clamp(320px,48vh,560px); border-color:#3b536f; background:#0b1627; color:#d5e4f9; line-height:1.6; }.config-row { padding:16px; gap:12px; border-color:rgba(164,193,234,.22); border-radius:15px; background:linear-gradient(145deg,#1b2b42,#101d31); }.config-row input { min-height:38px; border-color:#3b536f; background:#0b1627; }" +
    '</style></head><body><div class="wrap">' +
    '<h1>Загрузчики</h1>' +
    '<p class="muted"><a href="/admin">← к мониторингу</a> · ' +
    'версии подставляются автоматически</p>' +
    '<div class="config-row">' +
      '<span class="muted">Конфиг-воркер:</span>' +
      '<input id="config-base" value="' + escapeHtml(base) + '" style="flex:1 1 340px">' +
      '<input id="config-key" value="' + escapeHtml(key) + '" placeholder="ключ доступа" style="flex:1 1 220px">' +
      '<button id="save-config">Сохранить</button>' +
      '<span class="muted">задаётся один раз</span>' +
    '</div>' +
    '<div class="loader-grid">' + columns.join('') + '</div>' +
    '</div><script>' +
    'document.getElementById("save-config").addEventListener("click", async (event) => {' +
    '  const button = event.currentTarget;' +
    '  button.disabled = true;' +
    '  try {' +
    '    const response = await fetch("/admin/loader-config", {' +
    '      method: "POST",' +
    '      headers: { "Content-Type": "application/json" },' +
    '      credentials: "same-origin",' +
    '      body: JSON.stringify({' +
    '        base: document.getElementById("config-base").value.trim(),' +
    '        key: document.getElementById("config-key").value.trim(),' +
    '      }),' +
    '    });' +
    '    const data = await response.json();' +
    '    if (!response.ok || !data.ok) throw new Error(data.error || response.status);' +
    '    location.reload();' +
    '  } catch (error) {' +
    '    alert("Не удалось сохранить: " + error.message);' +
    '    button.disabled = false;' +
    '  }' +
    '});' +
    'document.addEventListener("click", async (event) => {' +
    '  const button = event.target.closest(".copy-loader");' +
    '  if (!button) return;' +
    '  const area = document.getElementById(button.dataset.target);' +
    '  if (!area) return;' +
    '  try {' +
    '    await navigator.clipboard.writeText(area.value);' +
    '    button.textContent = "\u2713 Готово";' +
    '    setTimeout(() => { button.textContent = "Копировать"; }, 1500);' +
    '  } catch (_) {' +
    '    area.focus(); area.select(); document.execCommand("copy");' +
    '  }' +
    '});' +
    '</script></body></html>';

  return htmlResponse(page, 200);
}

async function handleUsersPage(request, env, user, notice = '') {
  const users = (await env.DB.prepare(`
    SELECT id, login, role, display_name, is_active,
           must_change_password, last_login_at, locked_until
    FROM users
    ORDER BY role, login
  `).all()).results || [];

  const stageRows = (await env.DB.prepare(
    'SELECT user_id, stage FROM user_stages ORDER BY stage'
  ).all()).results || [];

  const stagesByUser = {};
  for (const row of stageRows) {
    if (!stagesByUser[row.user_id]) stagesByUser[row.user_id] = [];
    stagesByUser[row.user_id].push(row.stage);
  }

  const knownStages = (await env.DB.prepare(`
    SELECT stage
    FROM (
      SELECT canonical_stage AS stage
      FROM manager_stages
      WHERE active = 1
      UNION
      SELECT TRIM(stage) AS stage
      FROM installations
      WHERE TRIM(COALESCE(stage, '')) <> ''
    )
    ORDER BY stage COLLATE NOCASE
  `).all()).results?.map((row) => row.stage) || [];

  return htmlResponse(
    usersPageHtml(user, users, stagesByUser, knownStages, notice),
    200
  );
}

async function handleUserCreate(request, env, actor) {
  if (!isSameOriginRequest(request)) {
    return htmlResponse('Запрос отклонён', 403);
  }

  const form = await readFormDataLimited(request);
  const login = cleanText(form.get('login'), 80).toLowerCase().trim();
  const role = cleanText(form.get('role'), 20);
  const displayName = cleanText(form.get('display_name'), 120);
  const stages = cleanText(form.get('stages'), 500)
    .split(',')
    .map((item) => item.trim())
    .filter(Boolean);

  if (!login || !/^[a-z0-9._-]{3,40}$/.test(login)) {
    return handleUsersPage(
      request, env, actor,
      'Логин: латиница, цифры, точка, дефис, подчёркивание, 3–40 символов'
    );
  }

  if (!ROLE_VALUES.has(role)) {
    return handleUsersPage(request, env, actor, 'Некорректная роль');
  }

  const exists = await env.DB.prepare(
    'SELECT id FROM users WHERE login = ?'
  ).bind(login).first();

  if (exists) {
    return handleUsersPage(request, env, actor, 'Такой логин уже занят');
  }

  const password = randomPassword();
  const { hash, salt } = await hashPassword(password);

  const result = await env.DB.prepare(`
    INSERT INTO users (
      login, password_hash, password_salt, role, display_name,
      is_active, must_change_password, created_at
    ) VALUES (?, ?, ?, ?, ?, 1, 1, ?)
  `).bind(login, hash, salt, role, displayName, Date.now()).run();

  const userId = result.meta?.last_row_id;

  if (role === 'lead' && stages.length && userId) {
    for (const stage of stages) {
      await env.DB.prepare(
        'INSERT OR IGNORE INTO user_stages (user_id, stage) VALUES (?, ?)'
      ).bind(userId, stage).run();
    }
  }

  await writeAudit(env, actor, 'user_created', `${login} (${role})`, request);

  return handleUsersPage(
    request, env, actor,
    `Создан пользователь ${login}. Временный пароль: ${password} — передайте лично, при первом входе система потребует его сменить.`
  );
}

async function handleUserUpdate(request, env, actor) {
  if (!isSameOriginRequest(request)) {
    return htmlResponse('Запрос отклонён', 403);
  }

  const form = await readFormDataLimited(request);
  const userId = clampInt(form.get('user_id'), 1, 1e9);
  const action = cleanText(form.get('action'), 40);

  const target = await env.DB.prepare(
    'SELECT id, login, role FROM users WHERE id = ?'
  ).bind(userId).first();

  if (!target) {
    return handleUsersPage(request, env, actor, 'Пользователь не найден');
  }

  if (action === 'reset_password') {
    const password = randomPassword();
    const { hash, salt } = await hashPassword(password);

    await env.DB.prepare(`
      UPDATE users
      SET password_hash = ?, password_salt = ?, must_change_password = 1,
          failed_attempts = 0, locked_until = 0
      WHERE id = ?
    `).bind(hash, salt, userId).run();

    await env.DB.prepare(
      'DELETE FROM user_sessions WHERE user_id = ?'
    ).bind(userId).run();

    await writeAudit(env, actor, 'password_reset', target.login, request);

    return handleUsersPage(
      request, env, actor,
      `Новый временный пароль для ${target.login}: ${password}`
    );
  }

  if (action === 'toggle_active') {
    if (target.id === actor.id) {
      return handleUsersPage(
        request, env, actor, 'Нельзя отключить собственную учётную запись'
      );
    }

    await env.DB.prepare(`
      UPDATE users SET is_active = CASE is_active WHEN 1 THEN 0 ELSE 1 END
      WHERE id = ?
    `).bind(userId).run();

    await env.DB.prepare(
      'DELETE FROM user_sessions WHERE user_id = ?'
    ).bind(userId).run();

    await writeAudit(env, actor, 'user_toggled', target.login, request);

    return handleUsersPage(request, env, actor, `Статус ${target.login} изменён`);
  }

  if (action === 'set_stages') {
    const stages = cleanText(form.get('stages'), 500)
      .split(',')
      .map((item) => item.trim())
      .filter(Boolean);

    await env.DB.prepare(
      'DELETE FROM user_stages WHERE user_id = ?'
    ).bind(userId).run();

    for (const stage of stages) {
      await env.DB.prepare(
        'INSERT OR IGNORE INTO user_stages (user_id, stage) VALUES (?, ?)'
      ).bind(userId, stage).run();
    }

    await writeAudit(
      env, actor, 'stages_updated',
      `${target.login}: ${stages.join(', ') || 'нет'}`,
      request
    );

    return handleUsersPage(
      request, env, actor,
      `Стейджи ${target.login}: ${stages.join(', ') || 'не назначены'}`
    );
  }

  return handleUsersPage(request, env, actor, 'Неизвестное действие');
}

// ---------- HTML ----------
const PAGE_STYLE = `
  :root {
    color-scheme: dark;
    font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
    --bg: #080d18;
    --surface: #111a2a;
    --line: rgba(164, 191, 235, .15);
    --text: #edf3ff;
    --muted: #a0b2cd;
    --accent: #75a7ff;
  }
  * { box-sizing: border-box; }
  html { scroll-behavior: smooth; }
  body {
    margin: 0;
    min-height: 100vh;
    background:
      radial-gradient(circle at 8% 0%, rgba(69, 105, 209, .18), transparent 32rem),
      radial-gradient(circle at 100% 15%, rgba(17, 168, 173, .12), transparent 34rem),
      var(--bg);
    color: var(--text);
    line-height: 1.5;
  }
  ::selection { color: #fff; background: #3464af; }
  a { color: #9ac0ff; text-decoration: none; }
  a:hover { color: #c4d9ff; text-decoration: underline; }
  .wrap { max-width: 1180px; margin: 0 auto; padding: 42px 22px 80px; }
  h1 { font-size: clamp(24px, 3vw, 32px); letter-spacing: -.035em; margin: 0 0 7px; }
  h2 { font-size: 18px; letter-spacing: -.02em; margin: 30px 0 14px; }
  .muted { color: var(--muted); font-size: 13px; }
  .notice, .error {
    margin: 18px 0; padding: 13px 16px; border-radius: 13px;
    line-height: 1.55; overflow-wrap: anywhere;
  }
  .notice { color: #c8e4ff; background: rgba(54, 116, 196, .14); border: 1px solid rgba(104, 166, 242, .28); }
  .error { color: #ffc6cf; background: rgba(225, 67, 98, .12); border: 1px solid rgba(255, 115, 143, .3); }
  .card {
    padding: 22px; border: 1px solid var(--line); border-radius: 19px;
    background: linear-gradient(155deg, rgba(26, 39, 61, .96), rgba(14, 23, 39, .98));
    box-shadow: 0 18px 44px rgba(0, 3, 15, .24), inset 0 1px rgba(255,255,255,.035);
  }
  .grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(min(100%, 200px), 1fr)); }
  table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 13px; }
  th, td { padding: 12px 13px; border-bottom: 1px solid rgba(165, 190, 225, .1); text-align: left; vertical-align: middle; }
  th { color: #aebfda; font-weight: 650; background: rgba(88, 127, 185, .08); white-space: nowrap; }
  tbody tr:last-child td { border-bottom: 0; }
  tbody tr:hover td { background: rgba(110, 149, 220, .055); }
  input, select, textarea {
    max-width: 100%; padding: 10px 12px; border: 1px solid #344762; border-radius: 10px;
    background: #0b1525; color: #f5f8ff; outline: none; font: inherit;
    transition: border-color .18s ease, box-shadow .18s ease, background-color .18s ease;
  }
  input::placeholder, textarea::placeholder { color: #8295b1; }
  input:focus, select:focus, textarea:focus { border-color: #80adff; box-shadow: 0 0 0 3px rgba(91, 149, 255, .17); }
  button {
    padding: 10px 14px; border: 1px solid rgba(122, 173, 255, .52); border-radius: 10px;
    background: linear-gradient(135deg, #397ce7, #2b63cf); color: #fff;
    font: inherit; font-size: 13px; font-weight: 700; cursor: pointer;
    box-shadow: 0 5px 16px rgba(34, 94, 204, .18);
    transition: transform .18s ease, box-shadow .18s ease, background-color .18s ease, border-color .18s ease;
  }
  button:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 8px 20px rgba(34, 94, 204, .28); }
  button:active:not(:disabled) { transform: translateY(0); }
  button:disabled { opacity: .55; cursor: not-allowed; }
  button.ghost { background: #23354e; border-color: #3a5473; box-shadow: none; }
  form.inline { display: inline; }
  .badge { display: inline-block; padding: 4px 9px; border-radius: 999px; font-size: 11px; font-weight: 750; white-space: nowrap; }
  .badge.green { background: rgba(31, 159, 111, .18); color: #75e5b8; }
  .badge.red { background: rgba(235, 74, 104, .18); color: #ffadbe; }
  tr.overdue-warn > td { background: rgba(240, 184, 61, .1); }
  tr.overdue-warn > td:first-child { box-shadow: inset 3px 0 0 #f0b83d; }
  tr.overdue-alert > td { background: rgba(239, 88, 111, .13); }
  tr.overdue-alert > td:first-child { box-shadow: inset 3px 0 0 #f0647c; }
  :is(a, button, input, select, textarea, summary):focus-visible { outline: 2px solid #a5c7ff; outline-offset: 3px; }
  @media (max-width: 700px) {
    .wrap { padding: 24px 14px 56px; }
    .card { padding: 16px; border-radius: 16px; }
    th, td { padding: 10px; }
  }
  @media (prefers-reduced-motion: reduce) {
    html { scroll-behavior: auto; }
    *, *::before, *::after { transition: none !important; animation: none !important; }
  }
  body { position: relative; isolation: isolate; overflow-x: hidden; }
  body::before, body::after { content: ''; position: fixed; pointer-events: none; z-index: 0; }
  body::before {
    inset: -18%; background: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22960%22%20height%3D%22540%22%20viewBox%3D%220%200%20960%20540%22%3E%0A%20%20%3Cg%20fill%3D%22none%22%20stroke%3D%22%2378b8ff%22%20stroke-opacity%3D%22.34%22%20stroke-width%3D%221%22%3E%0A%20%20%20%20%3Cpath%20d%3D%22M-25%20393%20C%2058%20400%2090%20298%20174%20303%20S%20304%20368%20366%20270%20S%20490%20176%20562%20218%20S%20724%20294%20801%20189%20S%20909%20115%20993%20135%22%2F%3E%0A%20%20%20%20%3Cpath%20d%3D%22M-24%20425%20C%2079%20455%20130%20399%20210%20366%20S%20344%20373%20411%20309%20S%20559%20295%20612%20338%20S%20799%20382%20857%20290%20S%20916%20226%20992%20241%22%2F%3E%0A%20%20%20%20%3Cpath%20d%3D%22M-25%20318%20C%2055%20298%20115%20213%20185%20252%20S%20317%20291%20365%20220%20S%20500%20108%20552%20158%20S%20683%20230%20750%20151%20S%20881%2058%20992%2089%22%2F%3E%0A%20%20%20%20%3Cpath%20d%3D%22M174%20303%20L185%20252%20M185%20252%20L210%20366%20M366%20270%20L365%20220%20M366%20270%20L411%20309%20M562%20218%20L552%20158%20M562%20218%20L612%20338%20M801%20189%20L750%20151%20M801%20189%20L857%20290%22%20stroke-opacity%3D%22.22%22%2F%3E%0A%20%20%3C%2Fg%3E%0A%20%20%3Cg%20fill%3D%22%2398d9ff%22%20fill-opacity%3D%22.75%22%3E%0A%20%20%20%20%3Ccircle%20cx%3D%22174%22%20cy%3D%22303%22%20r%3D%223%22%2F%3E%3Ccircle%20cx%3D%22185%22%20cy%3D%22252%22%20r%3D%222.3%22%2F%3E%3Ccircle%20cx%3D%22210%22%20cy%3D%22366%22%20r%3D%222.1%22%2F%3E%0A%20%20%20%20%3Ccircle%20cx%3D%22366%22%20cy%3D%22270%22%20r%3D%223.4%22%2F%3E%3Ccircle%20cx%3D%22365%22%20cy%3D%22220%22%20r%3D%222.1%22%2F%3E%3Ccircle%20cx%3D%22411%22%20cy%3D%22309%22%20r%3D%222.5%22%2F%3E%0A%20%20%20%20%3Ccircle%20cx%3D%22562%22%20cy%3D%22218%22%20r%3D%223%22%2F%3E%3Ccircle%20cx%3D%22552%22%20cy%3D%22158%22%20r%3D%222.3%22%2F%3E%3Ccircle%20cx%3D%22612%22%20cy%3D%22338%22%20r%3D%222.2%22%2F%3E%0A%20%20%20%20%3Ccircle%20cx%3D%22801%22%20cy%3D%22189%22%20r%3D%223.2%22%2F%3E%3Ccircle%20cx%3D%22750%22%20cy%3D%22151%22%20r%3D%222.2%22%2F%3E%3Ccircle%20cx%3D%22857%22%20cy%3D%22290%22%20r%3D%222.5%22%2F%3E%0A%20%20%3C%2Fg%3E%0A%3C%2Fsvg%3E") center / 960px 540px repeat;
    opacity: .24; animation: neural-drift 48s ease-in-out infinite alternate;
  }
  body::after {
    inset: -20%; background:
      radial-gradient(ellipse 34% 20% at 25% 68%, rgba(62,126,234,.18), transparent 80%),
      radial-gradient(ellipse 30% 18% at 78% 28%, rgba(31,194,189,.14), transparent 80%);
    animation: ambient-wave 34s ease-in-out infinite alternate;
  }
  .wrap, body > main { position: relative; z-index: 1; }
  .wrap { max-width: 1240px; }
  .wrap > h1 { display: flex; align-items: center; gap: 11px; }
  .wrap > h1::before {
    content: '✦'; display: inline-grid; place-items: center; width: 36px; height: 36px;
    flex: 0 0 36px; border: 1px solid rgba(126,178,255,.48); border-radius: 11px;
    background: linear-gradient(135deg,#3172c7,#433b9a); color: #fff; font-size: 18px;
    box-shadow: 0 9px 21px rgba(60,88,196,.24);
  }
  .wrap > p.muted { margin: 8px 0 26px; line-height: 1.6; }
  .wrap > p.muted a { display: inline-block; padding: 5px 9px; border: 1px solid rgba(137,180,241,.18); border-radius: 8px; background: rgba(35,56,86,.45); }
  .wrap > h2 { display: flex; align-items: center; gap: 9px; margin-top: 34px; }
  .wrap > h2::before { content: ''; width: 4px; height: 17px; border-radius: 9px; background: linear-gradient(#89b6ff,#66dccb); }
  .card { transition: border-color .22s ease, box-shadow .22s ease, transform .22s ease; }
  .card:hover { border-color: rgba(160,199,255,.26); box-shadow: 0 21px 49px rgba(0,3,15,.27), inset 0 1px rgba(255,255,255,.04); }
  .card:focus-within { border-color: rgba(141,190,255,.38); }
  body > main.card { width: min(460px, calc(100% - 32px)); padding: clamp(23px,4vw,34px); }
  .auth-brand { display: grid; place-items: center; width: 48px; height: 48px; margin-bottom: 18px; border-radius: 14px; color: #fff; font-size: 25px; background: linear-gradient(135deg,#78b6ff,#496bdb,#5146aa); box-shadow: 0 12px 27px rgba(64,103,219,.28); }
  .auth-eyebrow { margin: 0 0 8px; color: #83e1d0; font-size: 10px; font-weight: 800; letter-spacing: .16em; text-transform: uppercase; }
  body > main.card h1 { margin-bottom: 7px; }
  body > main.card p.muted { line-height: 1.55; }
  body > main.card label { font-size: 12px; font-weight: 700; }
  body > main.card input { min-height: 43px; }
  body > main.card button { min-height: 44px; }
  input[type="checkbox"] { accent-color: #78b0ff; }
  .wrap .card[style*="overflow:auto"] { overflow: auto; scrollbar-color: #4c6585 transparent; }
  .wrap .card td form.inline { display: inline-flex; margin: 2px 4px 2px 0; }
  .wrap table th { position: sticky; top: 0; z-index: 1; background: #1b2b43; }
  .wrap .grid > * { min-width: 0; }
  @keyframes neural-drift {
    from { transform: translate3d(-2%, 1%, 0); }
    to { transform: translate3d(4%, -3%, 0); }
  }
  @keyframes ambient-wave {
    from { transform: translate3d(-4%, 0, 0) scale(1); }
    to { transform: translate3d(5%, 3%, 0) scale(1.06); }
  }
  @media (max-width: 700px) {
    body::before { opacity: .15; }
    .wrap > h1 { font-size: 24px; }
  }
  @media (prefers-reduced-motion: reduce) {
    body::before, body::after { animation: none !important; }
  }
`;

function loginPageHtml(error = '') {
  return `<!doctype html>
<html lang="ru">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <title>Вход — Панель ИИ</title>
  <style>
    ${PAGE_STYLE}
    body { display: grid; place-items: center; }
    .card { width: min(430px, calc(100% - 32px)); }
    label { display: block; margin: 12px 0 6px; color: #b8c7d9; font-size: 14px; }
    input { width: 100%; }
    button { width: 100%; margin-top: 16px; padding: 13px; }
  </style>
</head>
<body>
  <main class="card">
    <div class="auth-brand" aria-hidden="true">✦</div>
    <div class="auth-eyebrow">Operations / secure access</div>
    <h1>Панель ИИ</h1>
    <p class="muted">Вход для менеджмента</p>
    ${error ? `<div class="error">${escapeHtml(error)}</div>` : ''}
    <form method="post" action="/admin/login" autocomplete="off">
      <label for="login">Логин</label>
      <input id="login" name="login" required autofocus>
      <label for="password">Пароль</label>
      <input id="password" name="password" type="password" required>
      <button type="submit">Войти</button>
    </form>
  </main>
</body>
</html>`;
}

function passwordPageHtml(user, error = '') {
  return `<!doctype html>
<html lang="ru">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <title>Смена пароля</title>
  <style>
    ${PAGE_STYLE}
    body { display: grid; place-items: center; }
    .card { width: min(430px, calc(100% - 32px)); }
    label { display: block; margin: 12px 0 6px; color: #b8c7d9; font-size: 14px; }
    input { width: 100%; }
    button { width: 100%; margin-top: 16px; padding: 13px; }
  </style>
</head>
<body>
  <main class="card">
    <div class="auth-brand" aria-hidden="true">✦</div>
    <div class="auth-eyebrow">Operations / account</div>
    <h1>Смена пароля</h1>
    <p class="muted">
      ${user.mustChangePassword
        ? 'Временный пароль нужно заменить на собственный.'
        : 'Задайте новый пароль.'}
      Минимум 10 символов.
    </p>
    ${error ? `<div class="error">${escapeHtml(error)}</div>` : ''}
    <form method="post" action="/admin/password" autocomplete="off">
      <label for="current">Текущий пароль</label>
      <input id="current" name="current" type="password" required autofocus>
      <label for="next">Новый пароль</label>
      <input id="next" name="next" type="password" required minlength="10">
      <label for="repeat">Повторите новый пароль</label>
      <input id="repeat" name="repeat" type="password" required minlength="10">
      <button type="submit">Сохранить</button>
    </form>
  </main>
</body>
</html>`;
}

function usersPageHtml(actor, users, stagesByUser, knownStages, notice) {
  const roleName = {
    admin: 'Администратор',
    head: 'Хед',
    lead: 'Тимлидер',
  };

  const rows = users.map((row) => {
    const stages = (stagesByUser[row.id] || []).join(', ');
    const active = Number(row.is_active) === 1;

    return `<tr>
      <td>${escapeHtml(row.login)}</td>
      <td>${escapeHtml(row.display_name || '—')}</td>
      <td>${escapeHtml(roleName[row.role] || row.role)}</td>
      <td>${row.role === 'lead'
        ? `<form class="inline" method="post" action="/admin/users/update">
             <input type="hidden" name="user_id" value="${row.id}">
             <input type="hidden" name="action" value="set_stages">
             <input name="stages" value="${escapeHtml(stages)}"
                    placeholder="стейдж1, стейдж2" style="width:190px">
             <button class="ghost" type="submit">Сохранить</button>
           </form>`
        : '<span class="muted">все</span>'}</td>
      <td><span class="badge ${active ? 'green' : 'red'}">${active ? 'активен' : 'отключён'}</span></td>
      <td class="muted">${row.last_login_at
        ? new Date(row.last_login_at).toLocaleString('ru-RU', { timeZone: 'Europe/Moscow' })
        : 'ни разу'}</td>
      <td>
        <form class="inline" method="post" action="/admin/users/update">
          <input type="hidden" name="user_id" value="${row.id}">
          <input type="hidden" name="action" value="reset_password">
          <button class="ghost" type="submit">Сбросить пароль</button>
        </form>
        <form class="inline" method="post" action="/admin/users/update">
          <input type="hidden" name="user_id" value="${row.id}">
          <input type="hidden" name="action" value="toggle_active">
          <button class="ghost" type="submit">${active ? 'Отключить' : 'Включить'}</button>
        </form>
      </td>
    </tr>`;
  }).join('');

  return `<!doctype html>
<html lang="ru">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <title>Пользователи — Панель ИИ</title>
  <style>${PAGE_STYLE}</style>
</head>
<body>
  <div class="wrap">
    <h1>Пользователи</h1>
    <p class="muted">
      <a href="/admin">← к мониторингу</a> ·
      вы вошли как ${escapeHtml(actor.login)} ·
      <a href="/admin/audit">журнал входов</a>
    </p>

    ${notice ? `<div class="notice">${escapeHtml(notice)}</div>` : ''}

    <h2>Список</h2>
    <div class="card" style="overflow:auto">
      <table>
        <thead>
          <tr>
            <th>Логин</th><th>Имя</th><th>Роль</th><th>Стейджи</th>
            <th>Статус</th><th>Последний вход</th><th></th>
          </tr>
        </thead>
        <tbody>${rows || '<tr><td colspan="7" class="muted">Пусто</td></tr>'}</tbody>
      </table>
    </div>

    <h2>Новый пользователь</h2>
    <div class="card">
      <form method="post" action="/admin/users/create" autocomplete="off">
        <div class="grid">
          <input name="login" placeholder="логин (латиница)" required>
          <input name="display_name" placeholder="Имя и фамилия">
          <select name="role">
            <option value="lead">Тимлидер — только назначенные стейджи</option>
            <option value="head">Хед — все стейджи и клиенты, без логов</option>
            <option value="admin">Администратор — полный доступ</option>
          </select>
          <input name="stages" placeholder="стейджи через запятую (для тимлидера)">
        </div>
        <p class="muted" style="margin:12px 0">
          Пароль сгенерируется автоматически и покажется один раз.
          ${knownStages.length
            ? `Стейджи из данных: ${escapeHtml(knownStages.join(', '))}`
            : 'Стейджи появятся, когда установки начнут присылать данные.'}
        </p>
        <button type="submit">Создать</button>
      </form>
    </div>
  </div>
</body>
</html>`;
}

async function handleAuditPage(request, env, actor) {
  const rows = (await env.DB.prepare(`
    SELECT at, login, action, target, ip
    FROM audit_log
    ORDER BY at DESC
    LIMIT 300
  `).all()).results || [];

  const list = rows.map((row) => `<tr>
    <td class="muted">${new Date(row.at).toLocaleString('ru-RU', { timeZone: 'Europe/Moscow' })}</td>
    <td>${escapeHtml(row.login || '—')}</td>
    <td>${escapeHtml(row.action)}</td>
    <td>${escapeHtml(row.target || '—')}</td>
    <td class="muted">${escapeHtml(row.ip || '—')}</td>
  </tr>`).join('');

  return htmlResponse(`<!doctype html>
<html lang="ru">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <title>Журнал доступа</title>
  <style>${PAGE_STYLE}</style>
</head>
<body>
  <div class="wrap">
    <h1>Журнал доступа</h1>
    <p class="muted"><a href="/admin">← к мониторингу</a> · <a href="/admin/users">пользователи</a></p>
    <div class="card" style="overflow:auto">
      <table>
        <thead><tr><th>Время, МСК</th><th>Логин</th><th>Действие</th><th>Детали</th><th>IP</th></tr></thead>
        <tbody>${list || '<tr><td colspan="5" class="muted">Пусто</td></tr>'}</tbody>
      </table>
    </div>
  </div>
</body>
</html>`, 200);
}

async function handleInstallationSecurityPage(request, env, actor) {
  if (request.method !== 'GET') {
    return htmlResponse('Метод не поддерживается', 405);
  }

  await writeAudit(env, actor, 'installation_security_opened', '', request);

  return htmlResponse(`<!doctype html>
<html lang="ru">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <title>Безопасность установок</title>
  <style>
    ${PAGE_STYLE}
    code { color:#a9d0ff; word-break:break-all; }
    .actions { display:flex; gap:7px; flex-wrap:wrap; }
    .secret { user-select:all; white-space:pre-wrap; }
    .invite-field label { display:block; margin:0 0 6px; color:#9fb4ce; font-size:12px; font-weight:650; }
    .invite-field input, .invite-field select { width:100%; }
  </style>
</head>
<body>
  <div class="wrap">
    <h1>Установки и персональные токены</h1>
    <p class="muted">
      <a href="/admin">← к мониторингу</a> ·
      код активации и новый токен показываются только один раз
    </p>
    <div id="notice"></div>
    <div id="security-status" class="notice">Проверяю серверные защиты…</div>

    <h2>Новая установка</h2>
    <div class="card">
      <form id="invite-form">
        <div class="grid">
          <div class="invite-field">
            <label for="invite-stage">Стейдж менеджера</label>
            <select id="invite-stage" name="stage" required>
              <option value="">Загрузка стейджей…</option>
            </select>
            <input id="invite-new-stage" placeholder="Название нового стейджа" maxlength="120" style="display:none;margin-top:8px">
          </div>
          <div class="invite-field">
            <label for="invite-platform">Мессенджер</label>
            <select id="invite-platform" name="platform" required>
              <option value="telegram">Telegram</option>
              <option value="max">MAX</option>
              <option value="whatsapp">WhatsApp</option>
              <option value="gemspace">Gem Space</option>
            </select>
          </div>
          <div class="invite-field">
            <label for="invite-ttl">Код действует, минут</label>
            <input id="invite-ttl" name="ttl_minutes" type="number" min="5" max="1440" value="15" required>
          </div>
        </div>
        <p class="muted">Сервер сам создаст внутренний ID и закрепит стейдж с выбранным мессенджером. Менеджер введёт только одноразовый код, а имя, возраст, город и пол персонажа заполнит у себя в мессенджере.</p>
        <button type="submit">Создать одноразовый код</button>
      </form>
    </div>

    <h2>Объединить ошибочный стейдж</h2>
    <div class="card">
      <form id="merge-stage-form">
        <div class="grid">
          <div class="invite-field">
            <label for="merge-source">Ошибочный стейдж</label>
            <select id="merge-source" required></select>
          </div>
          <div class="invite-field">
            <label for="merge-target">Правильный стейдж</label>
            <select id="merge-target" required></select>
          </div>
        </div>
        <p class="muted">Сервер перенесёт историю и доступы на правильный стейдж. На каждой платформе останется только самая свежая установка, остальные токены будут отозваны.</p>
        <button type="submit">Объединить стейджи</button>
      </form>
    </div>

    <h2>Зарегистрированные установки</h2>
    <div class="card" style="overflow:auto">
      <table>
        <thead><tr><th>Стейдж</th><th>Платформа</th><th>Статус</th><th>Последнее использование, МСК</th><th>Installation ID</th><th></th></tr></thead>
        <tbody id="credentials"><tr><td colspan="6" class="muted">Загрузка…</td></tr></tbody>
      </table>
    </div>

    <h2>Последние коды активации</h2>
    <div class="card" style="overflow:auto">
      <table>
        <thead><tr><th>Создан, МСК</th><th>Стейдж</th><th>Платформа</th><th>Статус</th><th>Истекает, МСК</th></tr></thead>
        <tbody id="codes"></tbody>
      </table>
    </div>
  </div>
  <script>
    const esc = (value) => String(value ?? '').replace(/[&<>"']/g, (ch) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[ch]));
    const time = (value) => Number(value) ? new Date(Number(value)).toLocaleString('ru-RU', {timeZone:'Europe/Moscow'}) : '—';
    const notice = document.getElementById('notice');

    function renderStageOptions(stages) {
      const list = [...new Set((stages || []).map((value) => String(value || '').trim()).filter(Boolean))]
        .sort((left, right) => left.localeCompare(right, 'ru', {sensitivity:'base', numeric:true}));
      const options = list.map((stage) => '<option value="' + esc(stage) + '">' + esc(stage) + '</option>').join('');
      const invite = document.getElementById('invite-stage');
      const previousInvite = invite.value;
      invite.innerHTML = '<option value="">Выберите существующий стейдж</option>' + options + '<option value="__new__">＋ Создать новый стейдж</option>';
      if ([...invite.options].some((option) => option.value === previousInvite)) invite.value = previousInvite;

      for (const id of ['merge-source', 'merge-target']) {
        const select = document.getElementById(id);
        const previous = select.value;
        select.innerHTML = '<option value="">Выберите стейдж</option>' + options;
        if ([...select.options].some((option) => option.value === previous)) select.value = previous;
      }
    }

    async function api(path, options = {}) {
      const response = await fetch(path, {
        credentials: 'same-origin',
        headers: {'Content-Type':'application/json'},
        ...options,
      });
      const body = await response.json().catch(() => ({}));
      if (!response.ok || !body.ok) throw new Error(body.error || ('HTTP ' + response.status));
      return body;
    }

    function showSecret(title, value) {
      notice.innerHTML = '<div class="notice"><strong>' + esc(title) + '</strong><div class="secret"><code>' + esc(value) + '</code></div><div class="muted">Скопируйте сейчас: повторно сервер секрет не покажет.</div></div>';
      window.scrollTo({top:0, behavior:'smooth'});
    }

    async function load() {
      const data = await api('/api/admin/installations/security');
      const security = data.security || {};
      const checks = [
        ['Шифрование решений', security.data_encryption_key_configured],
        ['Лимит API', security.api_rate_limiter_configured],
        ['Anthropic API Gateway', security.ai_gateway_configured],
        ['Лимит AI', security.ai_rate_limiter_configured],
        ['Лимит активаций', security.enrollment_rate_limiter_configured],
        ['Лимит входа', security.login_rate_limiter_configured],
      ];
      document.getElementById('security-status').innerHTML = checks.map(([label, ok]) => '<span class="badge ' + (ok ? 'green' : 'red') + '">' + esc(label) + ': ' + (ok ? 'включено' : 'не настроено') + '</span>').join(' ') + '<div class="muted" style="margin-top:8px">Дневной бюджет AI: $' + esc(security.ai_installation_daily_limit_usd) + ' на установку, $' + esc(security.ai_global_daily_limit_usd) + ' общий</div>' + (security.legacy_ingest_enabled ? '<div class="muted" style="margin-top:8px">Общий INGEST_TOKEN временно включён только для старой телеметрии. Решения всё равно требуют персональный токен.</div>' : '');
      renderStageOptions(data.stages || []);
      document.getElementById('credentials').innerHTML = data.credentials.length
        ? data.credentials.map((row) => '<tr><td>' + esc(row.stage) + '</td><td>' + esc(row.platform) + '</td><td>' + esc(row.status) + '</td><td>' + esc(time(row.last_used_at)) + '</td><td><code>' + esc(row.installation_id) + '</code></td><td><div class="actions"><button data-rotate="' + esc(row.installation_id) + '">Сменить токен</button><button class="ghost" data-revoke="' + esc(row.installation_id) + '" ' + (row.status === 'revoked' ? 'disabled' : '') + '>Отозвать</button></div></td></tr>').join('')
        : '<tr><td colspan="6" class="muted">Установок пока нет</td></tr>';
      document.getElementById('codes').innerHTML = data.enrollment_codes.length
        ? data.enrollment_codes.map((row) => '<tr><td>' + esc(time(row.created_at)) + '</td><td>' + esc(row.stage) + '</td><td>' + esc(row.platform) + '</td><td>' + esc(row.status) + '</td><td>' + esc(time(row.expires_at)) + '</td></tr>').join('')
        : '<tr><td colspan="5" class="muted">Кодов пока нет</td></tr>';
    }

    document.getElementById('invite-stage').addEventListener('change', (event) => {
      const input = document.getElementById('invite-new-stage');
      const creating = event.currentTarget.value === '__new__';
      input.style.display = creating ? 'block' : 'none';
      input.required = creating;
      if (creating) input.focus();
    });

    document.getElementById('invite-form').addEventListener('submit', async (event) => {
      event.preventDefault();
      const values = Object.fromEntries(new FormData(event.currentTarget).entries());
      if (values.stage === '__new__') {
        values.stage = document.getElementById('invite-new-stage').value.trim();
      }
      try {
        const result = await api('/api/admin/installations/invite', {method:'POST', body:JSON.stringify(values)});
        showSecret('Одноразовый код активации', result.activation_code);
        await load();
      } catch (error) { notice.innerHTML = '<div class="error">' + esc(error.message) + '</div>'; }
    });

    document.getElementById('merge-stage-form').addEventListener('submit', async (event) => {
      event.preventDefault();
      const source = document.getElementById('merge-source').value;
      const target = document.getElementById('merge-target').value;
      if (!source || !target || source === target) {
        notice.innerHTML = '<div class="error">Выберите два разных стейджа.</div>';
        return;
      }
      if (!confirm('Объединить «' + source + '» со стейджем «' + target + '»? Старые установки одной платформы будут отключены.')) return;
      try {
        const result = await api('/api/admin/stages/merge', {
          method:'POST',
          body:JSON.stringify({source_stage:source, target_stage:target})
        });
        notice.innerHTML = '<div class="notice"><strong>Стейджи объединены.</strong><div class="muted">Канонический стейдж: ' + esc(result.canonical_stage) + '. Отключено старых установок: ' + esc((result.revoked_installations || []).length) + '.</div></div>';
        window.scrollTo({top:0, behavior:'smooth'});
        await load();
      } catch (error) {
        notice.innerHTML = '<div class="error">' + esc(error.message) + '</div>';
      }
    });

    document.addEventListener('click', async (event) => {
      const rotate = event.target.closest('[data-rotate]');
      const revoke = event.target.closest('[data-revoke]');
      try {
        if (rotate) {
          if (!confirm('Старый токен сразу перестанет работать. Продолжить?')) return;
          const result = await api('/api/admin/installations/rotate', {method:'POST', body:JSON.stringify({installation_id:rotate.dataset.rotate})});
          showSecret('Новый токен установки', result.installation_token);
          await load();
        }
        if (revoke) {
          if (!confirm('Отозвать доступ этой установки?')) return;
          await api('/api/admin/installations/revoke', {method:'POST', body:JSON.stringify({installation_id:revoke.dataset.revoke})});
          notice.innerHTML = '<div class="notice">Доступ установки отозван.</div>';
          await load();
        }
      } catch (error) { notice.innerHTML = '<div class="error">' + esc(error.message) + '</div>'; }
    });

    load().catch((error) => { notice.innerHTML = '<div class="error">' + esc(error.message) + '</div>'; });
  </script>
</body>
</html>`, 200);
}

function escapeHtml(value) {
  return String(value ?? '')
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;');
}


// Дашборд собирается под роль: подробные ошибки доступны только admin.
// Хед видит все стейджи, тимлидер — только назначенные ему.
function dashboardHtml(user) {
  const showErrors = canSeeErrors(user);
  const showAdminTools = user.role === 'admin';
  const roleName = {
    admin: 'администратор',
    head: 'хед',
    lead: 'тимлидер',
  }[user.role] || user.role;

  const scopeNote = user.role === 'lead'
    ? (user.stages.length
        ? 'стейджи: ' + escapeHtml(user.stages.join(', '))
        : 'стейджи не назначены — данных не будет')
    : 'все стейджи';

  return `<!doctype html>
<html lang="ru">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <title>AI Assistant Monitor</title>
  <style>
    :root {
      color-scheme: dark;
      font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
      --bg: #080d18;
      --surface: #111b2b;
      --surface-raised: #172339;
      --line: rgba(163, 192, 234, .14);
      --line-strong: rgba(164, 193, 234, .24);
      --muted: #a3b4d0;
      --text: #f0f5ff;
      --blue: #86b2ff;
      --teal: #62ddca;
      --green: #7be3b6;
      --amber: #ffd17c;
      --red: #ff9bad;
      --shadow: 0 18px 50px rgba(0, 3, 15, .23);
    }
    * { box-sizing: border-box; }
    html { scroll-behavior: smooth; }
    body {
      min-height: 100vh; margin: 0; color: var(--text); line-height: 1.45;
      background:
        radial-gradient(circle at 4% 1%, rgba(58, 100, 201, .18), transparent 36rem),
        radial-gradient(circle at 98% 6%, rgba(16, 169, 174, .12), transparent 38rem),
        var(--bg);
    }
    ::selection { color: #fff; background: #376bbd; }
    button, input, textarea { font: inherit; }
    a { color: inherit; text-decoration: none; }
    header {
      position: sticky; top: 0; z-index: 10;
      display: flex; align-items: center; justify-content: space-between; gap: 18px;
      padding: 15px clamp(18px, 2.5vw, 40px);
      border-bottom: 1px solid var(--line);
      background: rgba(10, 16, 29, .87);
      -webkit-backdrop-filter: blur(18px); backdrop-filter: blur(18px);
      box-shadow: 0 9px 30px rgba(1, 5, 15, .14);
    }
    .brand { display: flex; align-items: center; gap: 13px; min-width: 215px; }
    .brand-mark {
      display: grid; place-items: center; flex: 0 0 43px; height: 43px;
      border-radius: 13px; color: #fff; font-size: 23px; font-weight: 700;
      background: linear-gradient(135deg, #70bbff 0%, #4f7ae7 52%, #4b47b4 100%);
      box-shadow: inset 0 1px rgba(255,255,255,.3), 0 9px 22px rgba(61, 104, 216, .25);
    }
    .brand-eyebrow { color: #7ee0ce; font-size: 10px; font-weight: 800; letter-spacing: .14em; text-transform: uppercase; }
    h1 { margin: 1px 0 2px; font-size: 18px; line-height: 1.15; letter-spacing: -.035em; }
    .brand-caption { color: var(--muted); font-size: 11px; line-height: 1.35; }
    .header-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
    .header-actions form { margin: 0; }
    .header-actions > a { display: inline-flex; }
    #status {
      display: inline-flex; align-items: center; gap: 7px;
      max-width: 380px; color: #aac6e8; font-size: 12px;
      white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
    }
    #status::before { content: ''; flex: 0 0 7px; height: 7px; border-radius: 50%; background: var(--teal); box-shadow: 0 0 0 4px rgba(98,221,202,.12); }
    main { width: min(1640px, 100%); margin: 0 auto; padding: clamp(18px, 2.3vw, 34px); }
    .overview-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; margin: 6px 0 17px; }
    .overview-kicker { color: var(--teal); font-size: 11px; font-weight: 800; letter-spacing: .13em; text-transform: uppercase; }
    .overview-heading h2 { margin: 4px 0 2px; font-size: clamp(25px, 2.7vw, 36px); line-height: 1.1; letter-spacing: -.045em; }
    .overview-heading p { margin: 4px 0 0; color: var(--muted); font-size: 13px; }
    .overview-pill { display: inline-flex; align-items: center; gap: 7px; padding: 8px 12px; border: 1px solid rgba(98,221,202,.23); border-radius: 999px; background: rgba(42,154,143,.1); color: #9aeadd; font-size: 12px; font-weight: 750; white-space: nowrap; }
    .overview-pill::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: var(--teal); }
    .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 178px), 1fr)); gap: 12px; }
    .card {
      position: relative; isolation: isolate; min-width: 0; min-height: 112px;
      display: flex; flex-direction: column; justify-content: space-between; overflow: hidden;
      padding: 17px 18px; border: 1px solid var(--line); border-radius: 17px;
      background: linear-gradient(155deg, rgba(30,44,68,.97), rgba(17,27,44,.98));
      box-shadow: 0 10px 30px rgba(0,3,15,.13), inset 0 1px rgba(255,255,255,.03);
      transition: border-color .2s ease, transform .2s ease, box-shadow .2s ease;
    }
    .card::before { content: ''; position: absolute; inset: 0 auto 0 0; width: 3px; background: linear-gradient(var(--blue), var(--teal)); opacity: .75; }
    .card::after { content: ''; position: absolute; width: 110px; height: 110px; right: -40px; top: -54px; border-radius: 50%; background: rgba(108,157,248,.06); z-index: -1; }
    .card:hover { border-color: rgba(136,178,245,.36); transform: translateY(-2px); box-shadow: var(--shadow); }
    .card:nth-child(5)::before, .card:nth-child(7)::before { background: var(--teal); }
    .card:nth-child(6)::before, .card:nth-child(8)::before { background: var(--amber); }
    .card:nth-child(9)::before, .card:nth-child(10)::before, .card:nth-child(11)::before { background: var(--red); }
    .card .label { color: #aebfda; font-size: 12px; font-weight: 650; line-height: 1.4; }
    .card .value { margin-top: 8px; font-size: clamp(28px, 2.2vw, 35px); line-height: 1; font-weight: 800; letter-spacing: -.055em; font-variant-numeric: tabular-nums; }
    section {
      margin-top: 20px; overflow: hidden;
      scroll-margin-top: 120px;
      border: 1px solid var(--line); border-radius: 19px;
      background: linear-gradient(155deg, rgba(22,34,53,.98), rgba(14,23,39,.98));
      box-shadow: var(--shadow), inset 0 1px rgba(255,255,255,.028);
    }
    #decision-section { border-top: 2px solid rgba(255,154,174,.5); }
    .section-title { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 19px 21px; border-bottom: 1px solid var(--line); }
    h2 { margin: 0; font-size: 17px; font-weight: 750; letter-spacing: -.025em; }
    .section-title h2::before { content: ''; display: inline-block; width: 8px; height: 8px; margin-right: 10px; border-radius: 3px; background: var(--blue); box-shadow: 0 0 14px rgba(134,178,255,.38); vertical-align: 2px; }
    #decision-section .section-title h2::before { background: var(--red); box-shadow: 0 0 14px rgba(255,155,173,.35); }
    .muted { color: var(--muted); }
    .table-wrap { overflow: auto; max-height: 430px; scrollbar-color: #3f5673 transparent; }
    table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 13px; }
    th, td { padding: 11px 14px; border-bottom: 1px solid rgba(152,184,226,.095); text-align: left; white-space: nowrap; vertical-align: middle; }
    th { position: sticky; top: 0; z-index: 1; color: #b2c4df; font-size: 11px; font-weight: 750; letter-spacing: .03em; background: #1b2a40; }
    tbody tr:last-child td { border-bottom: 0; }
    tbody tr:hover td { background: rgba(101,146,216,.07); }
    tr.overdue-warn > td { background: rgba(240,184,61,.105); }
    tr.overdue-warn > td:first-child { box-shadow: inset 3px 0 0 #f0b83d; }
    tr.overdue-alert > td { background: rgba(239,88,111,.135); }
    tr.overdue-alert > td:first-child { box-shadow: inset 3px 0 0 #f0647c; }
    .error-message { max-width: 420px; white-space: normal; overflow-wrap: anywhere; }
    .badge { display: inline-flex; align-items: center; min-height: 24px; padding: 3px 9px; border: 1px solid transparent; border-radius: 999px; font-size: 11px; line-height: 1.2; font-weight: 750; white-space: nowrap; background: #243550; }
    .green { background: rgba(35,155,112,.17); border-color: rgba(87,214,165,.19); color: var(--green); }
    .yellow { background: rgba(213,151,49,.16); border-color: rgba(255,195,93,.2); color: var(--amber); }
    .red { background: rgba(224,70,102,.17); border-color: rgba(255,139,162,.21); color: var(--red); }
    .blue { background: rgba(70,124,207,.2); border-color: rgba(137,186,255,.18); color: #acd0ff; }
    button {
      min-height: 35px; padding: 7px 12px; border: 1px solid #3a506e; border-radius: 10px;
      background: #23364f; color: #eaf2ff; cursor: pointer;
      font-size: 12px; font-weight: 700; line-height: 1.3;
      transition: border-color .18s ease, background-color .18s ease, box-shadow .18s ease, transform .18s ease;
    }
    button:hover:not(:disabled) { transform: translateY(-1px); border-color: #779ccd; background: #2e4666; box-shadow: 0 7px 17px rgba(0,3,15,.17); }
    button:active:not(:disabled) { transform: translateY(0); }
    button.primary, .decision-tab.active { color: #fff; border-color: #77aafa; background: linear-gradient(125deg, #347ee6, #345ec2); box-shadow: 0 6px 20px rgba(57,107,219,.23); }
    button.success { color: #d8fff0; border-color: #349b7b; background: #176d58; }
    button.danger, .stop-button { color: #ffd5dc; border-color: #9d455c; background: #682b3c; }
    .stop-button.active { color: #fff; border-color: #fa819a; background: #c44964; }
    button:disabled { opacity: .5; cursor: not-allowed; box-shadow: none; }
    :is(a, button, input, textarea, summary):focus-visible { outline: 2px solid #a4c7ff; outline-offset: 3px; }
    input, textarea { color: #f5f8ff; background: #0c1728; border: 1px solid #3a4f6d; border-radius: 10px; outline: none; transition: border-color .18s ease, box-shadow .18s ease; }
    input::placeholder, textarea::placeholder { color: #8d9db5; }
    input:focus, textarea:focus { border-color: #81afff; box-shadow: 0 0 0 3px rgba(89,150,255,.16); }
    .stop-banner { display: flex; align-items: center; gap: 10px; margin-bottom: 18px; padding: 14px 17px; border: 1px solid #bd596c; border-radius: 13px; background: rgba(165,46,72,.2); color: #ffdbe3; font-weight: 700; }
    .stop-banner[hidden] { display: none; }
    .details-hint { margin-top: 5px; color: #aabbd2; font-size: 11px; line-height: 1.45; }
    .decision-toolbar { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; }
    .decision-summary { display: flex; flex-wrap: wrap; gap: 8px; padding: 17px 21px 0; }
    .decision-tabs { display: flex; flex-wrap: wrap; gap: 5px; padding: 17px 21px 0; border-bottom: 1px solid var(--line); }
    .decision-tab { border-radius: 10px 10px 0 0; margin-bottom: -1px; }
    .decision-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 400px), 1fr)); gap: 14px; padding: 20px; }
    .decision-card { min-width: 0; padding: 18px; border: 1px solid var(--line-strong); border-radius: 16px; background: linear-gradient(150deg, #18253a, #101c2e); }
    .decision-card.urgent { border-color: rgba(254,125,147,.52); box-shadow: inset 3px 0 #e9748b, 0 12px 28px rgba(0,3,15,.15); }
    .decision-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
    .decision-head > div:last-child { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 4px; }
    .decision-title { font-size: 16px; font-weight: 800; letter-spacing: -.02em; overflow-wrap: anywhere; }
    .decision-meta { margin-top: 7px; color: #a9bad3; font-size: 12px; line-height: 1.6; }
    .decision-block { margin-top: 11px; padding: 12px 13px; border: 1px solid rgba(155,189,235,.1); border-radius: 11px; background: rgba(8,16,29,.44); white-space: pre-wrap; overflow-wrap: anywhere; }
    .decision-block strong { display: block; margin-bottom: 5px; color: #a8caff; font-size: 11px; letter-spacing: .02em; }
    .decision-context { margin-top: 13px; padding-top: 10px; border-top: 1px solid var(--line); }
    .context-line { margin: 8px 0; font-size: 12px; white-space: normal; overflow-wrap: anywhere; }
    .context-who { color: #93c6ff; font-weight: 750; }
    .decision-input { width: 100%; min-height: 90px; margin-top: 14px; padding: 12px; resize: vertical; line-height: 1.5; }
    .decision-time { width: 100%; margin-top: 10px; padding: 10px; }
    .decision-actions { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 11px; }
    .decision-result { margin-top: 12px; padding: 12px; border: 1px solid rgba(98,221,202,.17); border-radius: 11px; background: rgba(34,122,116,.12); white-space: pre-wrap; overflow-wrap: anywhere; }
    .empty-state { grid-column: 1 / -1; padding: 38px 18px; color: var(--muted); text-align: center; }
    .empty-state::before { content: '✦'; display: block; margin-bottom: 7px; color: #83aacd; font-size: 24px; }
    .version-form { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin: 17px 20px; padding: 14px; border: 1px solid var(--line); border-radius: 13px; background: rgba(9,17,30,.43); font-size: 12px; }
    .version-form + .version-form { margin-top: -7px; }
    .version-form label { color: #c0cee2; }
    .version-form input { min-height: 33px; padding: 6px 9px; }
    .version-form input[readonly] { color: #9dc3ff; background: rgba(46,83,131,.1); }
    .manager-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 365px), 1fr)); gap: 13px; padding: 20px; }
    .mgr-card { position: relative; display: flex; flex-direction: column; gap: 12px; min-width: 0; overflow: hidden; padding: 16px 17px; border: 1px solid var(--line-strong); border-radius: 15px; background: #142136; }
    .mgr-card::before { content: ''; position: absolute; inset: 0 auto 0 0; width: 3px; background: var(--green); }
    .mgr-card.background { border-color: rgba(238,182,81,.35); background: #211f27; }
    .mgr-card.background::before { background: var(--amber); }
    .mgr-card.offline { border-color: rgba(174,128,146,.2); background: #1b1e2c; }
    .mgr-card.offline::before { background: #9c9eb0; }
    .mgr-card.stopped { border-color: rgba(241,109,136,.43); background: #2a202d; }
    .mgr-card.stopped::before { background: var(--red); }
    .mgr-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
    .mgr-name { font-size: 15px; font-weight: 800; letter-spacing: -.02em; overflow-wrap: anywhere; }
    .mgr-head .muted { margin-top: 3px; font-size: 11px; }
    .mgr-lines { display: flex; flex-direction: column; gap: 5px; }
    .mgr-empty { padding: 7px 0; font-size: 12px; }
    .mgr-line { display: grid; grid-template-columns: 73px 100px minmax(0,1fr) auto auto; align-items: center; gap: 6px; min-width: 0; padding: 7px 0; border-top: 1px solid rgba(170,196,235,.09); font-size: 11px; }
    .mgr-platform { font-weight: 750; color: #d7e8ff; }
    .mgr-clients { color: #b7c6da; overflow-wrap: anywhere; }
    .mgr-version { color: #9badc5; font-size: 10px; white-space: nowrap; }
    .mgr-version.old { color: var(--amber); font-weight: 800; }
    .mgr-foot { margin-top: auto; padding-top: 11px; border-top: 1px solid var(--line); font-size: 11px; line-height: 1.5; }
    .row-stop { min-width: 30px; padding: 4px 8px; color: #ffd3dd; border: 1px solid #a34d61; border-radius: 8px; background: #682d40; font-weight: 750; cursor: pointer; }
    .row-stop.active { color: #d9ffed; border-color: #3c9d79; background: #216d57; }
    .mgr-line .row-stop { min-height: 27px; font-size: 11px; }
    .forget-stale { min-height: 25px; padding: 3px 8px; color: var(--amber); border: 1px solid rgba(230,172,71,.32); border-radius: 7px; background: rgba(132,86,21,.2); font-size: 11px; font-weight: 700; cursor: pointer; }
    .client-groups { display: grid; gap: 12px; padding: 20px; }
    .stage-group { min-width: 0; overflow: hidden; border: 1px solid var(--line-strong); border-radius: 13px; background: #101c2e; }
    .stage-group > summary { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 17px; background: #192940; cursor: pointer; list-style: none; transition: background-color .18s ease; }
    .stage-group > summary:hover { background: #223651; }
    .stage-group > summary::-webkit-details-marker { display: none; }
    .stage-group > summary::before { content: '›'; display: inline-block; width: 11px; color: #a4c9ff; font-size: 24px; font-weight: 400; line-height: .7; transition: transform .18s ease; }
    .stage-group[open] > summary::before { transform: rotate(90deg); }
    .stage-summary-title { margin-right: auto; font-weight: 800; overflow-wrap: anywhere; }
    .stage-summary-stats { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 5px; }
    .stage-group .table-wrap { max-height: 355px; border-top: 1px solid var(--line); }
    @media (max-width: 1100px) {
      header { align-items: flex-start; flex-direction: column; }
      .header-actions { justify-content: flex-start; }
    }
    @media (max-width: 700px) {
      header { position: static; padding: 13px 15px; }
      .header-actions { width: 100%; justify-content: flex-start; flex-wrap: nowrap; gap: 6px; overflow-x: auto; padding: 2px 2px 7px; scrollbar-color: #4c668a transparent; }
      .header-actions button { min-height: 33px; padding: 6px 9px; white-space: nowrap; }
      main { padding: 15px 12px 32px; }
      .overview-heading { align-items: flex-start; flex-direction: column; gap: 9px; }
      .cards { grid-template-columns: repeat(2, minmax(0,1fr)); gap: 8px; }
      .card { min-height: 95px; padding: 13px; }
      .card .label { font-size: 11px; }
      .card .value { font-size: 28px; }
      section { margin-top: 14px; border-radius: 15px; }
      .section-title { flex-wrap: wrap; padding: 15px; }
      .decision-summary, .decision-tabs { padding-left: 15px; padding-right: 15px; }
      .decision-grid, .manager-grid, .client-groups { grid-template-columns: 1fr; padding: 12px; }
      .version-form { margin: 12px; }
      .version-form + .version-form { margin-top: 0; }
      .stage-group > summary { flex-wrap: wrap; }
      .stage-summary-stats { justify-content: flex-start; width: 100%; padding-left: 23px; }
    }
    @media (max-width: 420px) {
      .brand-mark { flex-basis: 38px; height: 38px; }
      .mgr-line { grid-template-columns: minmax(68px,1fr) auto minmax(0,1fr) auto; }
      .mgr-line .mgr-version { grid-column: 3; grid-row: 2; }
      .mgr-line .row-stop { grid-column: 4; grid-row: 1 / span 2; }
      .mgr-line .mgr-clients { grid-column: 1 / span 2; grid-row: 2; }
      .decision-head { flex-wrap: wrap; }
      .decision-head > div:last-child { justify-content: flex-start; }
    }
    @media (prefers-reduced-motion: reduce) {
      html { scroll-behavior: auto; }
      *, *::before, *::after { transition: none !important; animation: none !important; }
    }
    body { position: relative; isolation: isolate; overflow-x: hidden; }
    body::before, body::after { content: ''; position: fixed; z-index: 0; pointer-events: none; }
    body::before {
      inset: -18%; background: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22960%22%20height%3D%22540%22%20viewBox%3D%220%200%20960%20540%22%3E%0A%20%20%3Cg%20fill%3D%22none%22%20stroke%3D%22%2378b8ff%22%20stroke-opacity%3D%22.34%22%20stroke-width%3D%221%22%3E%0A%20%20%20%20%3Cpath%20d%3D%22M-25%20393%20C%2058%20400%2090%20298%20174%20303%20S%20304%20368%20366%20270%20S%20490%20176%20562%20218%20S%20724%20294%20801%20189%20S%20909%20115%20993%20135%22%2F%3E%0A%20%20%20%20%3Cpath%20d%3D%22M-24%20425%20C%2079%20455%20130%20399%20210%20366%20S%20344%20373%20411%20309%20S%20559%20295%20612%20338%20S%20799%20382%20857%20290%20S%20916%20226%20992%20241%22%2F%3E%0A%20%20%20%20%3Cpath%20d%3D%22M-25%20318%20C%2055%20298%20115%20213%20185%20252%20S%20317%20291%20365%20220%20S%20500%20108%20552%20158%20S%20683%20230%20750%20151%20S%20881%2058%20992%2089%22%2F%3E%0A%20%20%20%20%3Cpath%20d%3D%22M174%20303%20L185%20252%20M185%20252%20L210%20366%20M366%20270%20L365%20220%20M366%20270%20L411%20309%20M562%20218%20L552%20158%20M562%20218%20L612%20338%20M801%20189%20L750%20151%20M801%20189%20L857%20290%22%20stroke-opacity%3D%22.22%22%2F%3E%0A%20%20%3C%2Fg%3E%0A%20%20%3Cg%20fill%3D%22%2398d9ff%22%20fill-opacity%3D%22.75%22%3E%0A%20%20%20%20%3Ccircle%20cx%3D%22174%22%20cy%3D%22303%22%20r%3D%223%22%2F%3E%3Ccircle%20cx%3D%22185%22%20cy%3D%22252%22%20r%3D%222.3%22%2F%3E%3Ccircle%20cx%3D%22210%22%20cy%3D%22366%22%20r%3D%222.1%22%2F%3E%0A%20%20%20%20%3Ccircle%20cx%3D%22366%22%20cy%3D%22270%22%20r%3D%223.4%22%2F%3E%3Ccircle%20cx%3D%22365%22%20cy%3D%22220%22%20r%3D%222.1%22%2F%3E%3Ccircle%20cx%3D%22411%22%20cy%3D%22309%22%20r%3D%222.5%22%2F%3E%0A%20%20%20%20%3Ccircle%20cx%3D%22562%22%20cy%3D%22218%22%20r%3D%223%22%2F%3E%3Ccircle%20cx%3D%22552%22%20cy%3D%22158%22%20r%3D%222.3%22%2F%3E%3Ccircle%20cx%3D%22612%22%20cy%3D%22338%22%20r%3D%222.2%22%2F%3E%0A%20%20%20%20%3Ccircle%20cx%3D%22801%22%20cy%3D%22189%22%20r%3D%223.2%22%2F%3E%3Ccircle%20cx%3D%22750%22%20cy%3D%22151%22%20r%3D%222.2%22%2F%3E%3Ccircle%20cx%3D%22857%22%20cy%3D%22290%22%20r%3D%222.5%22%2F%3E%0A%20%20%3C%2Fg%3E%0A%3C%2Fsvg%3E") center / 960px 540px repeat;
      opacity: .24; animation: neural-drift 48s ease-in-out infinite alternate;
    }
    body::after {
      inset: -20%; background:
        radial-gradient(ellipse 34% 20% at 25% 68%, rgba(62,126,234,.18), transparent 80%),
        radial-gradient(ellipse 30% 18% at 78% 28%, rgba(31,194,189,.14), transparent 80%);
      animation: ambient-wave 34s ease-in-out infinite alternate;
    }
    header { z-index: 10; }
    main { position: relative; z-index: 1; }
    .quicknav {
      display: flex; align-items: center; gap: 6px; width: max-content; max-width: 100%;
      overflow-x: auto; margin: 2px 0 18px; padding: 5px;
      border: 1px solid var(--line); border-radius: 13px;
      background: rgba(16,27,44,.78); scrollbar-color: #4b6689 transparent;
      box-shadow: 0 10px 26px rgba(0,3,15,.13);
    }
    .quicknav a {
      display: inline-flex; align-items: center; gap: 7px;
      padding: 8px 12px; border: 1px solid transparent; border-radius: 9px;
      color: #bac9de; font-size: 12px; font-weight: 700; white-space: nowrap;
      transition: background-color .18s ease, color .18s ease, transform .18s ease;
    }
    .quicknav a:hover, .quicknav a:focus-visible { color: #fff; background: rgba(83,132,203,.22); transform: translateY(-1px); }
    .quicknav a::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: #85b0ef; }
    .quicknav a[href="#decision-section"]::before { background: var(--red); }
    .quicknav a[href="#installation-section"]::before { background: var(--teal); }
    .quicknav a[href="#client-section"]::before { background: var(--blue); }
    .quicknav a[href="#event-section"]::before { background: var(--amber); }
    .section-title { background: linear-gradient(90deg, rgba(90,129,197,.07), transparent 70%); }
    .section-title .muted { line-height: 1.5; }
    .section-subtitle { margin-top: 5px; font-size: 12px; }
    section { position: relative; }
    section:target { border-color: rgba(136,180,253,.48); box-shadow: 0 0 0 3px rgba(94,148,241,.1), var(--shadow); }
    #installation-section { border-top: 2px solid rgba(98,221,202,.42); }
    #installation-section .section-title h2::before { background: var(--teal); box-shadow: 0 0 14px rgba(98,221,202,.36); }
    #client-section { border-top: 2px solid rgba(130,177,251,.4); }
    #event-section { border-top: 2px solid rgba(255,209,124,.42); }
    #event-section .section-title h2::before { background: var(--amber); box-shadow: 0 0 14px rgba(255,209,124,.35); }
    .decision-tab { transition: color .18s ease, background-color .18s ease, border-color .18s ease; }
    .decision-tab:not(.active):hover { background: #2a4567; }
    .decision-card, .mgr-card, .stage-group { transition: border-color .2s ease, box-shadow .2s ease; }
    .decision-card:hover, .mgr-card:hover, .stage-group:hover { border-color: rgba(145,191,255,.41); box-shadow: 0 14px 27px rgba(0,3,15,.18); }
    .decision-card.urgent:hover { border-color: rgba(254,125,147,.75); }
    .decision-actions button { min-height: 37px; }
    .mgr-line .badge { justify-content: center; }
    .version-form:focus-within { border-color: rgba(133,180,250,.34); }
    .version-form label { line-height: 1.55; }
    .version-form input[type="checkbox"] { accent-color: #78b0ff; }
    @keyframes neural-drift {
      from { transform: translate3d(-2%, 1%, 0); }
      to { transform: translate3d(4%, -3%, 0); }
    }
    @keyframes ambient-wave {
      from { transform: translate3d(-4%, 0, 0) scale(1); }
      to { transform: translate3d(5%, 3%, 0) scale(1.06); }
    }
    @media (max-width: 1100px) { section { scroll-margin-top: 185px; } }
    @media (max-width: 700px) {
      body::before { opacity: .15; }
      .quicknav { width: 100%; margin-bottom: 14px; }
      section { scroll-margin-top: 16px; }
      .decision-card, .mgr-card { padding: 14px; }
    }
    @media (prefers-reduced-motion: reduce) {
      body::before, body::after { animation: none !important; }
    }
  </style>
</head>
<body>
  <header>
    <div class="brand">
      <span class="brand-mark" aria-hidden="true">✦</span>
      <div>
        <div class="brand-eyebrow">Operations / live</div>
        <h1>AI Assistant Monitor</h1>
        <div id="status">Загрузка…</div>
        <div class="brand-caption">
          ${escapeHtml(user.displayName || user.login)} · ${roleName} · ${scopeNote}
        </div>
      </div>
    </div>
    <div class="header-actions">
      ${canManageUsers(user)
        ? '<button id="global-stop" class="stop-button">⛔ Остановить всех</button>'
        : ''}
      <button id="notifications">🔔 Уведомления</button>
      ${showAdminTools ? `
      <button class="primary" id="refresh">Обновить</button>
      <button id="export">Скачать диагностику</button>` : ''}
      ${canManageUsers(user)
        ? '<a href="/admin/loaders"><button type="button">Загрузчики</button></a><a href="/admin/installations/security"><button type="button">Установки</button></a><a href="/admin/users"><button type="button">Пользователи</button></a>'
        : ''}
      <a href="/admin/password"><button type="button">Пароль</button></a>
      <form method="post" action="/admin/logout"><button type="submit">Выйти</button></form>
    </div>
  </header>
  <main>
    <div id="global-stop-banner" class="stop-banner" hidden></div>
    <div class="overview-heading" id="overview">
      <div>
        <div class="overview-kicker">Центр управления</div>
        <h2>Рабочая панель</h2>
        <p>Состояние менеджеров, клиентов и решений в одном месте</p>
      </div>
      <span class="overview-pill">Живые данные</span>
    </div>
    <nav class="quicknav" aria-label="Разделы панели">
      <a href="#overview">Сводка</a>
      <a href="#decision-section">Решения</a>
      <a href="#installation-section">Менеджеры</a>
      <a href="#client-section">Клиенты</a>
      ${showErrors ? '<a href="#event-section">Ошибки</a>' : ''}
    </nav>
    <div class="cards" id="cards"></div>

    <section id="decision-section">
      <div class="section-title">
        <div>
          <h2>Требуют решения</h2>
          <div class="muted" style="font-size:12px;margin-top:4px">Решение менеджера текстом; время звонка укажите в инструкции ИИ</div>
        </div>
        <div class="decision-toolbar">
          <span class="muted" id="decision-count"></span>
          ${showAdminTools ? `
          <button type="button" id="test-general">Тест красной зоны</button>` : ''}
        </div>
      </div>
      <div class="decision-summary" id="decision-summary"></div>
      <div class="decision-tabs">
        <button type="button" class="decision-tab active" data-decision-filter="pending">Ожидают</button>
        <button type="button" class="decision-tab" data-decision-filter="general">Красная зона</button>
        <button type="button" class="decision-tab" data-decision-filter="history">История</button>
      </div>
      <div class="decision-grid" id="decisions"></div>
    </section>

    <section id="installation-section">
      <div class="section-title"><div><h2>Менеджеры и установки</h2><div class="muted section-subtitle">Подключения, версии и активность по платформам</div></div><span class="muted" id="install-count"></span></div>
      ${canManageUsers(user) ? `
      <div class="version-form">
        <span class="muted">Версии из скриптов:</span>
        <label>Telegram <input id="ver-telegram" readonly placeholder="2.5.2" size="8"></label>
        <label>MAX <input id="ver-max" readonly placeholder="2.4.2" size="8"></label>
        <label>WhatsApp <input id="ver-whatsapp" readonly placeholder="2.0.2" size="8"></label>
        <label>Gem Space <input id="ver-gemspace" readonly placeholder="1.0.0" size="8"></label>
        <button id="save-versions">Сохранить ссылки</button>
        <label style="flex:1 1 260px">Ссылка на загрузчик TG
          <input id="loader-telegram" placeholder="https://…/telegram-loader.user.js?key=…" style="width:100%"></label>
        <label style="flex:1 1 260px">MAX
          <input id="loader-max" placeholder="https://…/max-loader.user.js?key=…" style="width:100%"></label>
        <label style="flex:1 1 260px">WhatsApp
          <input id="loader-whatsapp" placeholder="https://…/whatsapp-loader.user.js?key=…" style="width:100%"></label>
        <label style="flex:1 1 260px">Gem Space
          <input id="loader-gemspace" placeholder="https://…/gemspace-loader.user.js?key=…" style="width:100%"></label>
        <span class="muted" id="version-hint">Версии берутся автоматически из @version скриптов в KV</span>
      </div>
      <div class="version-form">
        <label style="display:flex;align-items:center;gap:6px">
          <input type="checkbox" id="restart-enabled" style="width:auto">
          Напоминание о перезапуске браузера
        </label>
        <label>в <input id="restart-time" placeholder="18:50" size="5"> МСК</label>
        <input id="restart-text" placeholder="Текст напоминания" style="flex:1 1 320px">
        <button id="save-restart">Сохранить</button>
        <span class="muted" id="restart-hint">Появится у всех менеджеров одновременно на весь экран</span>
      </div>` : ''}
      <div class="manager-grid" id="installations"></div>
    </section>

    <section id="client-section">
      <div class="section-title"><div><h2>Клиенты по стейджам</h2><div class="muted section-subtitle">Откройте стейдж, чтобы увидеть диалоги и ожидание ответа</div></div><span class="muted" id="client-count"></span></div>
      <div class="client-groups" id="client-groups"></div>
    </section>

    ${showErrors ? `
    <section id="event-section">
      <div class="section-title"><div><h2>Ошибки за 24 часа</h2><div class="muted section-subtitle">События для диагностики установок и ответов</div></div><span class="muted" id="event-count"></span></div>
      <div class="table-wrap"><table><thead><tr><th>Получено, МСК</th><th>Стейдж и менеджер</th><th>Платформа</th><th>Клиент</th><th>Этап</th><th>Код</th><th>Сообщение</th><th>Trace ID</th></tr></thead><tbody id="events"></tbody></table></div>
    </section>` : ''}
  </main>
  <script>
    const adminView = ${showErrors ? 'true' : 'false'};
    const canTestDecisions = ${showAdminTools ? 'true' : 'false'};
    let decisionFilter = 'pending';
    let latestDecisionRows = [];
    let decisionNotificationsInitialized = false;
    let knownPendingDecisionIds = new Set();
    let notificationSoundEnabled = false;
    let alertAudioContext = null;
    let dashboardLoading = false;
    const fmt = new Intl.DateTimeFormat('ru-RU', { timeZone:'Europe/Moscow', day:'2-digit', month:'2-digit', year:'numeric', hour:'2-digit', minute:'2-digit', second:'2-digit' });
    const esc = (value) => String(value == null ? '' : value).replace(/[&<>"']/g, (char) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[char]));
    const time = (value) => Number(value) > 0 ? fmt.format(new Date(Number(value))) : '—';
    const shortId = (value) => { const text = String(value || ''); return text ? text.slice(0,8) : '—'; };
    const statusText = {active:'Активен',waiting_ai:'Ждёт ИИ',waiting_client:'Ждём клиента',call:'Созвон',human:'Красная зона',paused:'Пауза',error:'Ошибка',unknown:'Неизвестно'};
    const statusClass = (value) => value === 'waiting_ai' || value === 'human' || value === 'error' ? 'red' : value === 'call' ? 'yellow' : value === 'active' || value === 'waiting_client' ? 'green' : 'blue';

    async function getJson(path) {
      const response = await fetch(path, { credentials:'same-origin', cache:'no-store' });
      if (response.status === 401) { location.href='/admin'; throw new Error('Сессия завершена'); }
      const data = await response.json();
      if (!response.ok || !data.ok) throw new Error(data.error || ('HTTP '+response.status));
      return data;
    }

    async function postJson(path, payload) {
      const response = await fetch(path, {
        method:'POST',
        credentials:'same-origin',
        cache:'no-store',
        headers:{'Content-Type':'application/json'},
        body:JSON.stringify(payload || {}),
      });
      if (response.status === 401) { location.href='/admin'; throw new Error('Сессия завершена'); }
      const data = await response.json();
      if (!response.ok || !data.ok) throw new Error(data.error || ('HTTP '+response.status));
      return data;
    }

    function renderCards(summary, decisionSummary) {
      const items = [
        ['Менеджеров онлайн', summary.managers_online],
        ['Подключений в фоне', summary.installations_background],
        ['Менеджеров всего', summary.managers_total],
        // Только фактические уникальные строки актуальных снимков.
        // reported_clients_total оставлен в API лишь для диагностики и
        // больше не подменяет ноль старыми heartbeat-счётчиками.
        ['Клиентов подключено', Number(summary.clients_total || 0)],
        ['ИИ ведёт сейчас', Number(summary.clients_active_now || 0)],
        ['Ждут ответа ИИ', summary.waiting_ai],
        ['Ждём клиента', summary.waiting_client],
        ['На созвоне', summary.clients_on_call],
        ['Красная зона', summary.clients_human],
        ['Решений ожидают', decisionSummary.pending_total],
      ];
      if (adminView) items.push(['Ошибок за 24 ч', summary.errors_24h]);
      document.getElementById('cards').innerHTML = items.map((item) => '<div class="card"><div class="label">'+esc(item[0])+'</div><div class="value">'+esc(item[1] || 0)+'</div></div>').join('');
    }

    const decisionStatusText = {
      pending:'Ожидает решения',
      resolved:'Решение ждёт скрипт',
      executed:'Выполнено',
      failed:'Ошибка выполнения',
      expired:'Запрос устарел',
      cancelled:'Отменено',
    };
    const decisionActionText = {
      send_recommendation:'Отправить рекомендацию',
      ai_instruction:'Ответ ИИ по инструкции',
      custom_text:'Отправить готовый текст',
      manager_manual:'Менеджер ответит вручную',
      pause:'Поставить на паузу',
      confirm_time:'Подтвердить время',
      plus_2h:'Перенести на два часа',
      custom_time:'Своё время',
      decline:'Отказаться от звонка',
    };

    function decisionTimeInput(value) {
      if (!Number(value)) return '';
      const formatter = new Intl.DateTimeFormat('sv-SE', {
        timeZone:'Europe/Moscow',
        year:'numeric', month:'2-digit', day:'2-digit',
        hour:'2-digit', minute:'2-digit', hour12:false,
      });
      return formatter.format(new Date(Number(value))).replace(' ', 'T');
    }

    function parseMoscowInput(value) {
      const text = String(value || '').trim();
      if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(text)) return 0;
      const timestamp = Date.parse(text+':00+03:00');
      return Number.isFinite(timestamp) ? timestamp : 0;
    }

    function contextHtml(context) {
      if (!Array.isArray(context) || !context.length) return '';
      return '<div class="decision-context"><strong class="muted">Последние сообщения</strong>'+context.map((item) => {
        const when = Number(item.at) ? ' · '+time(item.at) : (item.time ? ' · '+esc(item.time) : '');
        return '<div class="context-line"><span class="context-who">'+esc(item.who || '—')+'</span>'+when+': '+esc(item.text || '')+'</div>';
      }).join('')+'</div>';
    }

    function decisionCardHtml(row) {
      const pending = row.status === 'pending';
      const isCall = row.decision_type === 'call';
      const typeLabel = 'Красная зона';
      const typeClass = 'red';
      const client = row.client_label || ('#'+shortId(row.client_key));
      const manager = row.manager_name || row.manager_id || '—';
      const statusLabel = decisionStatusText[row.status] || row.status;
      const statusBadge = row.status === 'executed' ? 'green' : row.status === 'pending' ? 'red' : row.status === 'resolved' ? 'yellow' : 'blue';
      let body = '<article class="decision-card '+(pending?'urgent':'')+'" data-request-id="'+esc(row.request_id)+'">';
      body += '<div class="decision-head"><div><div class="decision-title">'+esc(client)+'</div><div class="decision-meta">'+esc(row.stage || '—')+' · '+esc(manager)+' · '+esc(row.platform || '—')+'<br>Создан: '+esc(time(row.created_at))+(pending?' · истекает: '+esc(time(row.expires_at)):'')+'</div></div><div><span class="badge '+typeClass+'">'+typeLabel+'</span> <span class="badge '+statusBadge+'">'+esc(statusLabel)+'</span></div></div>';

      if (row.reason) body += '<div class="decision-block"><strong>Причина</strong>'+esc(row.reason)+'</div>';
      if (row.manager_task) body += '<div class="decision-block"><strong>Что требуется</strong>'+esc(row.manager_task)+'</div>';
      if (row.recommendation) body += '<div class="decision-block"><strong>Рекомендация ИИ</strong>'+esc(row.recommendation)+'</div>';
      if (isCall && (Number(row.proposed_time_at) || row.proposed_time_text)) {
        body += '<div class="decision-block"><strong>Предложенное время, МСК</strong>'+esc(Number(row.proposed_time_at)?time(row.proposed_time_at):row.proposed_time_text)+'</div>';
      }
      body += contextHtml(row.context);

      if (pending) {
        body += '<textarea class="decision-input" maxlength="2200" placeholder="Укажите решение; для звонка напишите точную дату, время и часовой пояс — ИИ передаст клиенту"></textarea>';
        body += '<div class="decision-actions">';
        if (row.recommendation && !isCall) body += '<button class="success" data-decision-action="send_recommendation">Отправить рекомендацию</button>';
        body += '<button data-decision-action="ai_instruction">Инструкция ИИ</button><button data-decision-action="custom_text">Готовый текст</button><button data-decision-action="manager_manual">Менеджер вручную</button><button class="danger" data-decision-action="pause">Пауза</button></div>';
      } else {
        const actionLabel = decisionActionText[row.resolution_action] || row.resolution_action || '—';
        body += '<div class="decision-result"><strong>Решение: '+esc(actionLabel)+'</strong>'+(row.resolution_text?'\\n'+esc(row.resolution_text):'')+(Number(row.resolution_time_at)?'\\nВремя, МСК: '+esc(time(row.resolution_time_at)):'')+(row.resolved_login?'\\nПринял: '+esc(row.resolved_login)+' · '+esc(time(row.resolved_at)):'')+(row.execution_error?'\\nОшибка: '+esc(row.execution_error):'')+'</div>';
      }

      body += '</article>';
      return body;
    }

    function decisionMatchesFilter(row) {
      if (decisionFilter === 'pending') return row.status === 'pending';
      if (decisionFilter === 'general') return row.status === 'pending';
      return row.status !== 'pending';
    }

    function renderDecisionRows() {
      const rows = latestDecisionRows.filter(decisionMatchesFilter);
      document.getElementById('decisions').innerHTML = rows.length
        ? rows.map(decisionCardHtml).join('')
        : '<div class="empty-state">В этом разделе пока нет запросов</div>';
    }

    function renderDecisionSummary(summary) {
      const items = [
        ['Ожидают', summary.pending_total, 'red'],
        ['Красная зона', summary.pending_total, 'red'],
        ['Ждут выполнения', summary.awaiting_execution, 'blue'],
      ];
      document.getElementById('decision-summary').innerHTML = items.map((item) => '<span class="badge '+item[2]+'">'+esc(item[0])+': '+esc(item[1] || 0)+'</span>').join('');
      document.getElementById('decision-count').textContent = (summary.pending_total || 0)+' ожидают';
    }

    function playDecisionAlert() {
      if (!notificationSoundEnabled || !alertAudioContext) return;
      try {
        const oscillator = alertAudioContext.createOscillator();
        const gain = alertAudioContext.createGain();
        oscillator.frequency.value = 760;
        gain.gain.setValueAtTime(.08, alertAudioContext.currentTime);
        gain.gain.exponentialRampToValueAtTime(.001, alertAudioContext.currentTime+.35);
        oscillator.connect(gain);
        gain.connect(alertAudioContext.destination);
        oscillator.start();
        oscillator.stop(alertAudioContext.currentTime+.36);
      } catch (_) {}
    }

    function notifyAboutNewDecisions(rows) {
      const pending = rows.filter((row) => row.status === 'pending');
      const currentIds = new Set(pending.map((row) => row.request_id));

      if (decisionNotificationsInitialized) {
        const fresh = pending.filter((row) => !knownPendingDecisionIds.has(row.request_id));
        if (fresh.length) {
          playDecisionAlert();
          if ('Notification' in window && Notification.permission === 'granted') {
            const first = fresh[0];
            new Notification(fresh.length > 1 ? 'Новых решений: '+fresh.length : (first.decision_type === 'call' ? 'Новый запрос по звонку' : 'Новая красная зона'), {
              body:(first.stage || 'Без стейджа')+' · '+(first.manager_name || first.manager_id || 'Менеджер')+' · '+(first.client_label || 'Клиент'),
              tag:'decision-'+first.request_id,
            });
          }
        }
      }

      knownPendingDecisionIds = currentIds;
      decisionNotificationsInitialized = true;
    }

    function renderDecisions(data) {
      latestDecisionRows = Array.isArray(data.decisions) ? data.decisions : [];
      renderDecisionSummary(data.summary || {});
      notifyAboutNewDecisions(latestDecisionRows);

      // Сохраняем то, что руководитель уже набрал: перерисовка раз в 10 секунд
      // иначе стирала бы инструкцию прямо во время ввода.
      const drafts = new Map();
      let focusedRequestId = '';

      document.querySelectorAll('#decisions .decision-card').forEach((card) => {
        const id = card.getAttribute('data-request-id');
        const input = card.querySelector('.decision-input');
        if (!id || !input) return;
        if (input.value) drafts.set(id, input.value);
        if (document.activeElement === input) focusedRequestId = id;
      });

      renderDecisionRows();

      drafts.forEach((value, id) => {
        const input = document.querySelector(
          '#decisions .decision-card[data-request-id="' + id + '"] .decision-input'
        );
        if (input) input.value = value;
      });

      if (focusedRequestId) {
        const input = document.querySelector(
          '#decisions .decision-card[data-request-id="' + focusedRequestId + '"] .decision-input'
        );
        if (input) {
          input.focus();
          input.selectionStart = input.selectionEnd = input.value.length;
        }
      }
    }

    const decisionErrorText = {
      DECISION_ALREADY_PROCESSED:'Запрос уже обработал другой руководитель',
      DECISION_EXPIRED:'Время ожидания запроса закончилось',
      DECISION_TEXT_REQUIRED:'Введите инструкцию или готовый текст',
      RECOMMENDATION_EMPTY:'ИИ не оставил готовой рекомендации',
      PROPOSED_TIME_REQUIRED:'В запросе не указано предлагаемое время',
      CUSTOM_TIME_INVALID:'Укажите корректное будущее время по Москве',
      REQUEST_REJECTED:'Защитная проверка отклонила запрос',
    };

    async function resolveDecision(card, action) {
      const requestId = card.getAttribute('data-request-id');
      const textValue = card.querySelector('.decision-input')?.value?.trim() || '';
      const payload = { request_id:requestId, action:action, text:textValue };

      if ((action === 'ai_instruction' || action === 'custom_text') && !textValue) {
        window.alert('Сначала введите текст или инструкцию');
        return;
      }

      if (action === 'custom_time') {
        payload.time_at = parseMoscowInput(card.querySelector('.decision-time')?.value);
        if (!payload.time_at) {
          window.alert('Укажите дату и время по Москве');
          return;
        }
      }

      const buttons = [...card.querySelectorAll('button')];
      buttons.forEach((button) => { button.disabled = true; });
      try {
        await postJson('/api/admin/decisions/resolve', payload);
        await loadAll();
      } catch (error) {
        window.alert(decisionErrorText[error.message] || ('Не удалось принять решение: '+error.message));
      } finally {
        buttons.forEach((button) => { button.disabled = false; });
      }
    }

    async function createTestDecision(type) {
      try {
        await postJson('/api/admin/decisions/test', { decision_type:type });
        decisionFilter = type;
        document.querySelectorAll('.decision-tab').forEach((button) => button.classList.toggle('active', button.dataset.decisionFilter === type));
        await loadAll();
      } catch (error) {
        window.alert('Не удалось создать тест: '+error.message);
      }
    }

    async function enableDecisionNotifications() {
      const button = document.getElementById('notifications');
      try {
        if ('Notification' in window && Notification.permission === 'default') {
          await Notification.requestPermission();
        }
        const AudioContextClass = window.AudioContext || window.webkitAudioContext;
        if (AudioContextClass) {
          alertAudioContext = alertAudioContext || new AudioContextClass();
          await alertAudioContext.resume();
          notificationSoundEnabled = true;
          playDecisionAlert();
        }
        button.textContent = '🔔 Уведомления включены';
        button.classList.add('success');
      } catch (_) {
        button.textContent = '🔕 Уведомления недоступны';
      }
    }

    let latestVersions = {};
    let loaderUrlsState = {};

    const canStopInstallations = ${canManageUsers(user) ? 'true' : 'false'};

            // Карточка на менеджера: все мессенджеры одного человека собраны вместе.
    // Порядок карточек — по имени, чтобы плитка не прыгала при обновлении
    // каждые 10 секунд (раньше сортировали по времени сигнала).
    const PLATFORM_LABELS = {
      telegram: 'Telegram',
      max: 'MAX',
      whatsapp: 'WhatsApp',
      gemspace: 'Gem Space',
    };

    const PLATFORM_ORDER = ['telegram', 'max', 'whatsapp', 'gemspace'];

    function installationState(
      row,
      serverTime,
      onlineWindowMs,
      backgroundWarningMs
    ) {
      const isOnline =
        Number(row.online) === 1 &&
        serverTime - Number(row.last_seen_at || 0) <= onlineWindowMs;

      if (!isOnline) {
        return { text: 'офлайн', tone: 'red', online: false, background: false };
      }
      if (Number(row.admin_stopped) === 1) {
        return { text: 'остановлен', tone: 'red', online: true, background: false };
      }
      if (Number(row.paused)) {
        return { text: 'пауза', tone: 'yellow', online: true, background: false };
      }

      const hiddenSince = Number(row.page_hidden_since || 0);
      const inBackground =
        Number(row.page_visible) === 0 &&
        hiddenSince > 0 &&
        serverTime - hiddenSince >= backgroundWarningMs;

      if (inBackground) {
        return { text: 'в фоне', tone: 'yellow', online: true, background: true };
      }
      if (Number(row.busy)) {
        return { text: 'работает', tone: 'green', online: true, background: false };
      }
      return { text: 'онлайн', tone: 'green', online: true, background: false };
    }

    function sinceText(timestamp, serverTime) {
      const ms = serverTime - Number(timestamp || 0);
      if (!timestamp) return 'сигнала не было';
      if (ms < 60000) return Math.max(1, Math.round(ms / 1000)) + ' сек назад';
      if (ms < 3600000) return Math.round(ms / 60000) + ' мин назад';
      return Math.round(ms / 3600000) + ' ч назад';
    }

    function renderInstallations(
      rows,
      managers,
      serverTime,
      onlineWindowMs,
      backgroundWarningMs
    ) {
      const outdated = rows.filter((row) => {
        const want = latestVersions[row.platform];
        return want && String(row.script_version || '') !== want;
      }).length;

      const registry = Array.isArray(managers) ? managers : [];

      const container = document.getElementById('installations');

      // Сначала создаём карточки из постоянного реестра. Установки могут
      // очищаться по ретеншну, но менеджер из-за этого больше не исчезает.
      const grouped = new Map();
      const registryKeyByStage = new Map();

      for (const managerRow of registry) {
        const managerId = String(managerRow.manager_id || '').trim();
        const stage = String(
          managerRow.canonical_stage || managerRow.stage || ''
        ).trim();
        const normalizedStage = stage.toLocaleLowerCase('ru-RU');
        const key = managerId
          ? 'id|' + managerId.toLocaleLowerCase('ru-RU')
          : 'stage|' + normalizedStage;

        if (!grouped.has(key)) {
          grouped.set(key, {
            manager_id: managerId,
            manager: stage || managerId || '—',
            stage,
            items: [],
          });
        }
        if (normalizedStage) registryKeyByStage.set(normalizedStage, key);
      }

      for (const row of rows) {
        const manager = String(row.manager_name || row.manager_id || '—').trim();
        const stage = String(row.stage || '').trim();
        const managerId = String(row.manager_id || '').trim().toLocaleLowerCase('ru-RU');
        const normalizedStage = stage.toLocaleLowerCase('ru-RU');
        const idKey = managerId ? 'id|' + managerId : '';
        const key =
          (idKey && grouped.has(idKey) && idKey) ||
          registryKeyByStage.get(normalizedStage) ||
          idKey ||
          (normalizedStage
            ? 'stage|' + normalizedStage
            : 'legacy|' + manager.toLocaleLowerCase('ru-RU'));

        if (!grouped.has(key)) {
          grouped.set(key, {
            manager_id: String(row.manager_id || '').trim(),
            manager: stage || manager,
            stage,
            items: [],
          });
        }

        grouped.get(key).items.push(row);
      }

      const cards = [...grouped.values()].sort((left, right) => {
        const byStage = left.stage.localeCompare(right.stage, 'ru');
        return byStage || left.manager.localeCompare(right.manager, 'ru');
      });

      document.getElementById('install-count').textContent =
        cards.length + ' менеджеров · ' + rows.length + ' подключений' +
        (outdated ? ' · на старой версии: ' + outdated : '');

      if (!cards.length) {
        container.innerHTML = '<div class="empty-state">Реестр менеджеров пока пуст</div>';
        return;
      }

      container.innerHTML = cards.map((card) => {
        // На одну платформу оставляем только самое свежее подключение.
        // Остальные — следы прошлых установок: их предлагаем удалить.
        const freshestByPlatform = new Map();
        const staleItems = [];

        for (const row of card.items) {
          const current = freshestByPlatform.get(row.platform);

          if (!current) {
            freshestByPlatform.set(row.platform, row);
            continue;
          }

          const isNewer =
            Number(row.last_seen_at || 0) > Number(current.last_seen_at || 0);

          if (isNewer) {
            freshestByPlatform.set(row.platform, row);
            staleItems.push(current);
          } else {
            staleItems.push(row);
          }
        }

        card.items = [...freshestByPlatform.values()];

        card.items.sort((left, right) =>
          PLATFORM_ORDER.indexOf(left.platform) - PLATFORM_ORDER.indexOf(right.platform)
        );

        const cardStates = card.items.map((row) =>
          installationState(
            row,
            serverTime,
            onlineWindowMs,
            backgroundWarningMs
          )
        );
        const onlineStates = cardStates.filter((state) => state.online);
        const anyOnline = onlineStates.length > 0;
        const allOnlineInBackground =
          anyOnline && onlineStates.every((state) => state.background);

        const stopped = card.items.some((row) => Number(row.admin_stopped) === 1);

        const totalClients = card.items.reduce(
          (sum, row) => sum + Number(row.clients_total || 0), 0
        );

        const activeClients = card.items.reduce(
          (sum, row) => sum + Number(row.clients_active || 0), 0
        );

        const freshest = card.items.reduce(
          (max, row) => Math.max(max, Number(row.last_seen_at || 0)), 0
        );

        const lines = card.items.length ? card.items.map((row) => {
          const state = installationState(
            row,
            serverTime,
            onlineWindowMs,
            backgroundWarningMs
          );
          const want = latestVersions[row.platform];
          const isOld = want && String(row.script_version || '') !== want;

          return '<div class="mgr-line">' +
            '<span class="mgr-platform">' +
              esc(PLATFORM_LABELS[row.platform] || row.platform) +
            '</span>' +
            '<span class="badge ' + state.tone + '">' + esc(state.text) + '</span>' +
            '<span class="mgr-clients">' + esc(row.clients_total || 0) + ' кл.' +
              (Number(row.clients_active) ? ' · ' + esc(row.clients_active) + ' активн.' : '') +
            '</span>' +
            '<span class="mgr-version' + (isOld ? ' old' : '') + '"' +
              (isOld ? ' title="Актуальная: ' + esc(want) + '"' : '') + '>' +
              esc(row.script_version || '—') + (isOld ? ' ⟳' : '') +
            '</span>' +
            (canStopInstallations
              ? '<button class="row-stop' + (Number(row.admin_stopped) === 1 ? ' active' : '') + '"' +
                ' data-installation="' + esc(row.installation_id) + '"' +
                ' data-stopped="' + (Number(row.admin_stopped) === 1 ? '1' : '0') + '">' +
                (Number(row.admin_stopped) === 1 ? '▶' : '⛔') +
                '</button>'
              : '') +
          '</div>';
        }).join('') :
          '<div class="mgr-empty muted">Нет подключений — создайте код активации для нужной платформы</div>';

        const cardClass = stopped
          ? 'mgr-card stopped'
          : (allOnlineInBackground
              ? 'mgr-card background'
              : (anyOnline ? 'mgr-card' : 'mgr-card offline'));
        const cardBadgeTone = stopped
          ? 'red'
          : (allOnlineInBackground ? 'yellow' : (anyOnline ? 'green' : 'red'));
        const cardBadgeText = stopped
          ? 'остановлен'
          : (allOnlineInBackground ? 'в фоне' : (anyOnline ? 'онлайн' : 'офлайн'));

        return '<div class="' + cardClass + '">' +
          '<div class="mgr-head">' +
            '<div>' +
              '<div class="mgr-name">' + esc(card.manager) + '</div>' +
              (card.stage && card.stage !== card.manager
                ? '<div class="muted">' + esc(card.stage) + '</div>'
                : '') +
            '</div>' +
            '<span class="badge ' + cardBadgeTone + '">' +
              cardBadgeText +
            '</span>' +
          '</div>' +
          '<div class="mgr-lines">' + lines + '</div>' +
          '<div class="mgr-foot muted">' +
            esc(totalClients) + ' клиентов' +
            (activeClients ? ' · ' + esc(activeClients) + ' активных' : '') +
            ' · сигнал ' + esc(sinceText(freshest, serverTime)) +
            (staleItems.length && canStopInstallations
              ? ' · <button class="forget-stale" data-ids="' +
                esc(staleItems.map((row) => row.installation_id).join(',')) +
                '">старых подключений: ' + esc(staleItems.length) + ' — убрать</button>'
              : '') +
          '</div>' +
        '</div>';
      }).join('');
    }

    // Клиент считается просроченным, если ждёт ответа дольше порога.
    // 90 минут — жёлтый «Ответить», 180 — красный «Срочно».
    const OVERDUE_WARN_MS = 90 * 60 * 1000;
    const OVERDUE_ALERT_MS = 180 * 60 * 1000;

    function waitingSince(row) {
      if (row.status !== 'waiting_ai' && row.status !== 'human') return 0;
      const since = Number(row.unanswered_since || 0) || Number(row.last_in_at || 0);
      if (!since) return 0;
      const lastOut = Number(row.last_out_at || 0);
      if (lastOut && lastOut >= since) return 0;
      return since;
    }

    function overdueMs(row, now) {
      const since = waitingSince(row);
      return since ? Math.max(0, now - since) : 0;
    }

    function humanDuration(ms) {
      const minutes = Math.floor(ms / 60000);
      if (minutes < 60) return minutes + ' мин';
      const hours = Math.floor(minutes / 60);
      const rest = minutes % 60;
      return rest ? hours + ' ч ' + rest + ' мин' : hours + ' ч';
    }

    function overdueBadge(row, now) {
      const waited = overdueMs(row, now);
      if (waited < OVERDUE_WARN_MS) return '';
      const alert = waited >= OVERDUE_ALERT_MS;
      return '<span class="badge ' + (alert ? 'red' : 'yellow') + '" title="Клиент ждёт ответа">'
        + (alert ? '🔥 Срочно ответить' : '⏰ Ответить')
        + ' · ' + esc(humanDuration(waited)) + '</span>';
    }

    // Какие группы стейджей свёрнуты вручную. Без этого перерисовка
    // каждые 10 секунд заново раскрывала бы всё, что закрыл пользователь.
    const collapsedStages = new Set();

    function renderClients(rows) {
      // Сервер уже возвращает актуальные установки, но интерфейс повторно
      // страхуется от дубля одного client_key при кэшированном ответе или
      // во время переключения установки. Побеждает самая свежая строка.
      const uniqueRows = new Map();

      for (const row of rows || []) {
        const managerKey = String(
          row.stage || row.manager_name || row.manager_id || row.installation_id || ''
        ).trim().toLocaleLowerCase('ru-RU');
        const key = [
          managerKey,
          String(row.platform || '').trim().toLowerCase(),
          String(row.client_key || '').trim(),
        ].join('|');
        const previous = uniqueRows.get(key);
        const rowFreshness = Math.max(
          Number(row.updated_at || 0),
          Number(row.installation_last_seen_at || 0)
        );
        const previousFreshness = previous
          ? Math.max(
              Number(previous.updated_at || 0),
              Number(previous.installation_last_seen_at || 0)
            )
          : -1;

        if (!previous || rowFreshness >= previousFreshness) {
          uniqueRows.set(key, row);
        }
      }

      rows = [...uniqueRows.values()];
      document.getElementById('client-count').textContent = rows.length;
      const container = document.getElementById('client-groups');

      if (!rows.length) {
        container.innerHTML = '<div class="empty-state">Состояния клиентов ещё не поступали</div>';
        return;
      }

      const nowTs = Date.now();
      const grouped = new Map();

      for (const row of rows) {
        const stage = String(row.stage || '').trim() || 'Без стейджа';
        if (!grouped.has(stage)) grouped.set(stage, []);
        grouped.get(stage).push(row);
      }

      // Внутри стейджа поднимаем наверх тех, кто ждёт дольше всех
      for (const list of grouped.values()) {
        list.sort((left, right) => overdueMs(right, nowTs) - overdueMs(left, nowTs));
      }

      const stages = [...grouped.keys()].sort((left, right) =>
        left.localeCompare(right, 'ru', { sensitivity:'base', numeric:true })
      );

      container.innerHTML = stages.map((stage) => {
        const stageRows = grouped.get(stage) || [];
        const waitingAi = stageRows.filter((row) => row.status === 'waiting_ai').length;
        const human = stageRows.filter((row) => row.status === 'human').length;
        const onCall = stageRows.filter((row) => row.status === 'call').length;
        const overdueCount = stageRows.filter((row) => overdueMs(row, nowTs) >= OVERDUE_WARN_MS).length;
        const stats = [
          '<span class="badge blue">Клиентов: '+esc(stageRows.length)+'</span>',
          overdueCount ? '<span class="badge red">Ждут дольше 90 мин: '+esc(overdueCount)+'</span>' : '',
          waitingAi ? '<span class="badge yellow">Ждут ИИ: '+esc(waitingAi)+'</span>' : '',
          onCall ? '<span class="badge yellow">Созвон: '+esc(onCall)+'</span>' : '',
          human ? '<span class="badge red">Красная зона: '+esc(human)+'</span>' : '',
        ].filter(Boolean).join('');

        const headers = '<tr><th>Менеджер</th><th>Клиент</th><th>Платформа</th><th>Статус</th><th>Входящее, МСК</th><th>Ответ, МСК</th><th>Следующее действие</th>'+(adminView?'<th>Ошибка</th>':'')+'</tr>';
        const body = stageRows.map((row) => {
          const errorCell = adminView ? '<td>'+esc(row.last_error_code || '—')+'</td>' : '';
          const status = row.status || 'unknown';
          const waited = overdueMs(row, nowTs);
          const rowClass = waited >= OVERDUE_ALERT_MS
            ? ' class="overdue-alert"'
            : (waited >= OVERDUE_WARN_MS ? ' class="overdue-warn"' : '');
          const badge = overdueBadge(row, nowTs);
          return '<tr'+rowClass+'><td>'+esc(row.manager_name || row.manager_id || '—')+'</td><td>'+esc(row.client_label || ('#'+shortId(row.client_key)))+(badge?' '+badge:'')+(row.memory_key?'<div class="muted">Память: <code style="user-select:all">'+esc(row.memory_key)+'</code><br>'+esc((row.memory_platforms || []).join(', '))+(row.memory_incomplete?' · неполная':'')+'</div>':'')+'</td><td>'+esc(row.platform || '—')+'</td><td><span class="badge '+statusClass(status)+'">'+esc(statusText[status] || status)+'</span></td><td>'+esc(time(row.last_in_at))+'</td><td>'+esc(time(row.last_out_at))+'</td><td>'+esc(time(row.next_action_at))+'</td>'+errorCell+'</tr>';
        }).join('');

        const openAttr = collapsedStages.has(stage) ? '' : ' open';
        return '<details class="stage-group" data-stage="'+esc(stage)+'"'+openAttr+'><summary><span class="stage-summary-title">'+esc(stage)+'</span><span class="stage-summary-stats">'+stats+'</span></summary><div class="table-wrap"><table><thead>'+headers+'</thead><tbody>'+body+'</tbody></table></div></details>';
      }).join('');
    }

    // Подробности попытки: маршрут навигации, длительность, номер попытки.
    // Маршрут показывает, на каком шаге оборвался переход в чат.
    function detailsHint(row) {
      let details = {};

      try {
        details = JSON.parse(row.details_json || '{}') || {};
      } catch (_) {
        return '';
      }

      const parts = [];

      if (details.nav_route) parts.push('маршрут: ' + esc(details.nav_route));
      else if (details.steps) parts.push('этапы: ' + esc(details.steps));
      if (details.nav_dom_peer) parts.push('чат в DOM: ' + esc(details.nav_dom_peer));
      if (details.nav_hash_peer) parts.push('хэш: ' + esc(details.nav_hash_peer));
      if (details.attempt) parts.push('попытка ' + esc(details.attempt));
      if (details.duration_ms) parts.push(Math.round(Number(details.duration_ms) / 1000) + ' с');
      if (details.kind) parts.push(esc(details.kind));

      if (!parts.length) return '';

      return '<div class="details-hint">' + parts.join(' · ') + '</div>';
    }

    function renderEvents(rows) {
      document.getElementById('event-count').textContent = rows.length;
      document.getElementById('events').innerHTML = rows.length ? rows.map((row) => '<tr><td>'+esc(time(row.received_at || row.created_at))+'</td><td>'+esc(row.stage || '—')+(row.manager_name?' · '+esc(row.manager_name):'')+'</td><td>'+esc(row.platform)+'</td><td>'+esc(row.client_label || (row.client_key?'#'+shortId(row.client_key):'—'))+'</td><td>'+esc(row.step || row.event_type || '—')+'</td><td><span class="badge '+(row.level==='error'?'red':'yellow')+'">'+esc(row.code || row.level)+'</span></td><td class="error-message">'+esc(row.message || '—')+detailsHint(row)+'</td><td>'+esc(shortId(row.trace_id))+'</td></tr>').join('') : '<tr><td colspan="8" class="muted">Ошибок пока нет</td></tr>';
    }

    async function loadAll() {
      if (dashboardLoading) return;
      dashboardLoading = true;
      const status = document.getElementById('status');
      status.textContent = 'Обновляю…';
      try {
        const [summary, installations, clients, decisions, events] = await Promise.all([
          getJson('/api/admin/summary'),
          getJson('/api/admin/installations'),
          getJson('/api/admin/clients?limit=1000'),
          getJson('/api/admin/decisions?hours=72&limit=300'),
          ${showErrors
            ? "getJson('/api/admin/events?hours=24&limit=500')"
            : 'Promise.resolve({ events: [] })'},
        ]);
        renderGlobalStop(summary.global_stop || { stopped: false });
        loaderUrlsState = summary.loader_urls || {};
        renderVersions(summary.latest_versions || {});
        renderRestartReminder(summary.restart_reminder || {});
        renderCards(summary.summary || {}, decisions.summary || {});
        renderDecisions(decisions);
        renderInstallations(
          installations.installations || [],
          installations.managers || [],
          Number(summary.server_time || Date.now()),
          Number(summary.online_window_ms || ${ONLINE_WINDOW_MS}),
          Number(
            summary.background_warning_ms ||
              installations.background_warning_ms ||
              ${BACKGROUND_WARNING_MS}
          )
        );
        renderClients(clients.clients || []);
        if (document.getElementById('events')) renderEvents(events.events || []);
        status.textContent = 'Обновлено '+time(summary.server_time)+' · автообновление каждые 10 секунд';
      } catch (error) {
        status.textContent = 'Ошибка обновления: '+error.message;
      } finally {
        dashboardLoading = false;
      }
    }

    // ---------- Стоп-кран ----------
    let globalStopState = { stopped: false, at: 0, by: '' };

    function renderGlobalStop(state) {
      globalStopState = state || globalStopState;
      const banner = document.getElementById('global-stop-banner');
      const button = document.getElementById('global-stop');
      const stopped = Boolean(globalStopState.stopped);

      if (banner) {
        banner.hidden = !stopped;
        if (stopped) {
          const who = globalStopState.by ? ' · ' + esc(globalStopState.by) : '';
          const when = globalStopState.at ? ' · ' + esc(time(globalStopState.at)) : '';
          banner.innerHTML = '<span>⛔</span><span>ВСЕ ПОМОЩНИКИ ОСТАНОВЛЕНЫ'
            + who + when
            + ' · скрипты прекращают работу в течение минуты</span>';
        }
      }

      if (button) {
        button.textContent = stopped ? '▶ Запустить всех' : '⛔ Остановить всех';
        button.classList.toggle('active', stopped);
      }
    }

    async function toggleGlobalStop() {
      const button = document.getElementById('global-stop');
      const next = !globalStopState.stopped;

      const question = next
        ? 'Остановить помощников у ВСЕХ менеджеров? Боты прекратят отвечать клиентам в течение минуты. Начатое сообщение будет дописано, новые задачи браться не будут.'
        : 'Разрешить работу помощникам у всех менеджеров?';

      if (!window.confirm(question)) return;

      if (button) {
        button.disabled = true;
        button.textContent = 'Применяю…';
      }

      try {
        const response = await fetch('/admin/global-stop', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          credentials: 'same-origin',
          body: JSON.stringify({ stopped: next }),
        });

        const data = await response.json();
        if (!response.ok || !data.ok) throw new Error(data.error || response.status);

        renderGlobalStop(data.global_stop);
      } catch (error) {
        window.alert('Не удалось изменить состояние: ' + error.message);
      } finally {
        if (button) button.disabled = false;
        renderGlobalStop(globalStopState);
      }
    }

    // ---------- Актуальные версии ----------
    function renderVersions(map) {
      latestVersions = map || {};
      const hint = document.getElementById('version-hint');

      for (const platform of ['telegram', 'max', 'whatsapp', 'gemspace']) {
        const input = document.getElementById('ver-' + platform);
        if (input && document.activeElement !== input) {
          input.value = latestVersions[platform] || '';
        }

        const loaderInput = document.getElementById('loader-' + platform);
        if (loaderInput && document.activeElement !== loaderInput) {
          loaderInput.value = (loaderUrlsState || {})[platform] || '';
        }
      }

      if (hint && !Object.keys(latestVersions).length) {
        hint.textContent = 'Версии не заданы — подсказки об обновлении не показываются';
      }
    }

    function renderRestartReminder(settings) {
      const data = settings || {};
      const enabled = document.getElementById('restart-enabled');
      const time = document.getElementById('restart-time');
      const text = document.getElementById('restart-text');

      if (enabled && document.activeElement !== enabled) {
        enabled.checked = data.enabled === true;
      }
      if (time && document.activeElement !== time) {
        time.value = data.time || '18:50';
      }
      if (text && document.activeElement !== text) {
        text.value = data.text || '';
      }
    }

    const saveRestartButton = document.getElementById('save-restart');
    if (saveRestartButton) {
      saveRestartButton.addEventListener('click', async () => {
        saveRestartButton.disabled = true;

        try {
          const response = await fetch('/admin/restart-reminder', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            credentials: 'same-origin',
            body: JSON.stringify({
              enabled: document.getElementById('restart-enabled').checked,
              time: document.getElementById('restart-time').value.trim(),
              text: document.getElementById('restart-text').value.trim(),
            }),
          });

          const data = await response.json();
          if (!response.ok || !data.ok) throw new Error(data.error || response.status);

          renderRestartReminder(data.restart_reminder);
          const hint = document.getElementById('restart-hint');
          if (hint) {
            hint.textContent = data.restart_reminder.enabled
              ? 'Сохранено. Напоминание появится в ' + data.restart_reminder.time + ' МСК'
              : 'Напоминание выключено';
          }
        } catch (error) {
          window.alert('Не удалось сохранить: ' + error.message);
        } finally {
          saveRestartButton.disabled = false;
        }
      });
    }

    const saveVersionsButton = document.getElementById('save-versions');
    if (saveVersionsButton) {
      saveVersionsButton.addEventListener('click', async () => {
        const payload = {};
        for (const platform of ['telegram', 'max', 'whatsapp', 'gemspace']) {
          const loaderInput =
            document.getElementById(
              'loader-' + platform
            );

          if (loaderInput) {
            payload[
              'loader_' + platform
            ] =
              loaderInput.value.trim();
          }
        }

        saveVersionsButton.disabled = true;

        try {
          const response = await fetch('/admin/versions', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            credentials: 'same-origin',
            body: JSON.stringify(payload),
          });

          const data = await response.json();
          if (!response.ok || !data.ok) throw new Error(data.error || response.status);

          renderVersions(data.versions);
          const hint = document.getElementById('version-hint');
          if (hint) hint.textContent = 'Ссылки сохранены. Версии синхронизируются автоматически';
        } catch (error) {
          window.alert('Не удалось сохранить версии: ' + error.message);
        } finally {
          saveVersionsButton.disabled = false;
        }
      });
    }

    // Удаление устаревших подключений менеджера
    document.getElementById('installations').addEventListener('click', async (event) => {
      const forgetButton = event.target.closest('.forget-stale');
      if (!forgetButton) return;

      const ids = String(forgetButton.dataset.ids || '')
        .split(',')
        .map((item) => item.trim())
        .filter(Boolean);

      if (!ids.length) return;

      if (!window.confirm(
        'Удалить ' + ids.length + ' устаревших подключений? ' +
        'Это следы прошлых установок скрипта, на работу помощника они не влияют.'
      )) return;

      forgetButton.disabled = true;

      try {
        const response = await fetch('/admin/installations/forget', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          credentials: 'same-origin',
          body: JSON.stringify({ installation_ids: ids }),
        });

        const data = await response.json();
        if (!response.ok || !data.ok) throw new Error(data.error || response.status);

        loadAll();
      } catch (error) {
        window.alert('Не удалось удалить: ' + error.message);
        forgetButton.disabled = false;
      }
    });

    // Индивидуальная остановка установки
    document.getElementById('installations').addEventListener('click', async (event) => {
      const button = event.target.closest('.row-stop');
      if (!button) return;

      const installationId = button.dataset.installation || '';
      const next = button.dataset.stopped !== '1';

      if (!installationId) return;

      if (!window.confirm(next
        ? 'Остановить помощника у этого менеджера? Он прекратит отвечать клиентам в течение минуты.'
        : 'Разрешить работу помощнику этого менеджера?')) return;

      button.disabled = true;

      try {
        const response = await fetch('/admin/installation-stop', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          credentials: 'same-origin',
          body: JSON.stringify({ installation_id: installationId, stopped: next }),
        });

        const data = await response.json();
        if (!response.ok || !data.ok) throw new Error(data.error || response.status);

        loadAll();
      } catch (error) {
        window.alert('Не удалось изменить состояние: ' + error.message);
        button.disabled = false;
      }
    });

    const globalStopButton = document.getElementById('global-stop');
    if (globalStopButton) globalStopButton.addEventListener('click', toggleGlobalStop);

    const refreshButton = document.getElementById('refresh');
    const exportButton = document.getElementById('export');
    const notificationsButton = document.getElementById('notifications');
    const testGeneralButton = document.getElementById('test-general');
    const testCallButton = document.getElementById('test-call');
    if (refreshButton) refreshButton.addEventListener('click', loadAll);
    if (exportButton) exportButton.addEventListener('click', () => { window.location.href='/api/admin/export?hours=24'; });
    if (notificationsButton) notificationsButton.addEventListener('click', enableDecisionNotifications);
    if (canTestDecisions && testGeneralButton) testGeneralButton.addEventListener('click', () => createTestDecision('general'));
    if (canTestDecisions && testCallButton) testCallButton.addEventListener('click', () => createTestDecision('call'));
    document.querySelectorAll('.decision-tab').forEach((button) => {
      button.addEventListener('click', () => {
        decisionFilter = button.dataset.decisionFilter || 'pending';
        document.querySelectorAll('.decision-tab').forEach((item) => item.classList.toggle('active', item === button));
        renderDecisionRows();
      });
    });
    // Запоминаем, какие группы свернул пользователь
    document.getElementById('client-groups').addEventListener('toggle', (event) => {
      const group = event.target;
      if (!group || group.tagName !== 'DETAILS') return;
      const stage = group.getAttribute('data-stage');
      if (!stage) return;
      if (group.open) collapsedStages.delete(stage);
      else collapsedStages.add(stage);
    }, true);

    document.getElementById('decisions').addEventListener('click', (event) => {
      const button = event.target.closest('[data-decision-action]');
      if (!button) return;
      const card = button.closest('.decision-card');
      if (!card) return;
      resolveDecision(card, button.dataset.decisionAction || '');
    });
    loadAll();
    setInterval(loadAll, 10000);
  </script>
</body>
</html>`;
}
