'use strict';

/**
 * tests/tz-full-compliance.js — ПОЛНЫЙ ТЗ-compliance тест (v2).
 *
 * Масштаб: 20 компаний × ~100 адресов = ~2000 записей.
 *
 * Покрытие: КАЖДЫЙ пункт ТЗ + уточнения (ТЗ-плюс.md).
 *
 * Разделы:
 *   1. Подготовка: 20 компаний, токены, 100 CIDR на каждую
 *   2. §4.1  Просмотр списка — колонки, used/limit, soft-delete скрыты, admin-фильтр
 *   3. §4.2  Создание записи — валидация, лимит, дубликаты, пересечения
 *   4. §4.3  Редактирование — PATCH, проверки при edit
 *   5. §4.4  Soft delete — deleted_at, исключение из выдачи
 *   6. §4.5  Лимиты — default, custom, превышение, снижение
 *   7. §4.6  Аудит — CREATE/UPDATE/DELETE, user не видит
 *   8. §4.7  Export — публичный, text/plain, агрегация, ?view/?filename
 *   9. §5    Валидация — все 14 запрещённых, /22-/32, IPv6, XSS, SQLi
 *   10. §3   Роли — admin/user изоляция, ?company=, requireAdmin
 *   11. §3.1 Мульти-компания — переключатель, изоляция
 *   12. Уточнения — clientId через запятую, первый=активный
 *
 * Вывод: test-results/tz-full-$(date).log
 */

require('dotenv').config();

const fs        = require('fs');
const path      = require('path');
const supertest = require('supertest');
const { pool }  = require('../src/db');

// ── Логирование в файл ──────────────────────────────────────────────────────
const resultsDir = path.join(__dirname, '..', 'test-results');
if (!fs.existsSync(resultsDir)) fs.mkdirSync(resultsDir, { recursive: true });
const logFile = path.join(resultsDir, 'tz-full-' + new Date().toISOString().replace(/[:.]/g, '-') + '.log');
let logBuffer = '';

function tee(msg) {
  console.log(msg);
  logBuffer += msg + '\n';
}
function saveLog() {
  fs.writeFileSync(logFile, logBuffer, 'utf-8');
  console.log('\nЛог сохранён: ' + logFile);
}

let pass = 0, fail = 0;
const errors = [];

function ok(cond, name, detail = '') {
  if (cond) { pass++; tee('  ✅ ' + name); }
  else {
    fail++;
    const msg = '  ❌ ' + name + (detail ? ': ' + detail : '');
    tee(msg);
    errors.push(msg);
  }
}

// ── Генераторы тестовых данных ──────────────────────────────────────────────

/** Сгенерировать N уникальных публичных /32 CIDR */
function* genCIDRs(count, seed = 1) {
  const bases = [
    '8.8.{octet}.{host}',     // Google DNS range (публичный)
    '1.1.{octet}.{host}',     // Cloudflare (публичный)
    '9.9.{octet}.{host}',     // Quad9 (публичный)
    '208.67.{octet}.{host}',  // OpenDNS (публичный)
    '4.2.{octet}.{host}',     // Level3 (публичный)
    '64.6.{octet}.{host}',    // Verisign (публичный)
    '199.85.{octet}.{host}',  // Nominum (публичный)
    '37.235.{octet}.{host}',  // (публичный)
    '77.88.{octet}.{host}',   // Яндекс (публичный)
    '151.101.{octet}.{host}', // Fastly (публичный)
  ];
  let i = 0;
  for (let octet = 1; octet <= 250 && i < count; octet++) {
    for (let host = seed; host <= 250 && i < count; host++) {
      const base = bases[i % bases.length];
      const ip = base.replace('{octet}', octet).replace('{host}', host);
      yield ip + '/32';
      i++;
    }
  }
}

/** Сгенерировать тестовые компании */
function genCompanies(n) {
  const companies = [];
  for (let i = 1; i <= n; i++) {
    const num = String(i).padStart(2, '0');
    companies.push({
      id: `tz-co-${num}`,
      clientId: `TZ${num}${num}${num}${num}${num}`,
      companyId: `aaaaaaaa-bbbb-cccc-dddd-${String(i).padStart(12, '0')}`,
      companyName: `ТЗ-Компания-${num}`,
      email: `tz${num}@test.local`,
      isAdmin: false,
    });
  }
  return companies;
}

// ── Основной раннер ─────────────────────────────────────────────────────────

(async () => {
  const startTime = Date.now();

  tee('══════════════════════════════════════════════════════');
  tee('  ТЗ ПОЛНЫЙ Compliance Test — ' + new Date().toISOString());
  tee('  20 компаний × ~100 адресов = ~2000 записей');
  tee('══════════════════════════════════════════════════════\n');

  // ── Запуск сервера ──────────────────────────────────────────────────────
  let app, auth;
  try {
    const mod = require('../server');
    app = await mod.start();
    auth = await require('../src/auth').initAuth();
    tee('✅ Сервер запущен\n');
  } catch (e) {
    tee('❌ FATAL: ' + e.message);
    await pool.end();
    process.exit(1);
  }

  const req = supertest(app);

  // ── §1. Подготовка данных ───────────────────────────────────────────────
  tee('═══ §1. Подготовка: 20 компаний, токены, CIDR ═══\n');

  const companies = genCompanies(20);
  const cidrsPerCompany = 100;
  const allCidrs = [];

  // Вставляем компании в БД
  for (const co of companies) {
    await pool.query(
      `INSERT INTO companies (client_id, name) VALUES ($1, $2)
       ON CONFLICT (client_id) DO UPDATE SET name = EXCLUDED.name`,
      [co.clientId, co.companyName]
    );
    const db = await pool.query('SELECT id FROM companies WHERE client_id = $1', [co.clientId]);
    co.dbId = db.rows[0].id;
    // Ставим высокий лимит чтобы API пропускал операции после массовой вставки
    await pool.query('UPDATE companies SET custom_limit = 999 WHERE id = $1', [co.dbId]);
  }
  tee('✅ ' + companies.length + ' компаний создано (лимиты=999)');

  // Генерируем токены
  const tokens = {};
  for (const co of companies) {
    tokens[co.id] = auth.issueMockToken({
      clientId: co.clientId, companyId: co.companyId,
      companyName: co.companyName, email: co.email,
    });
  }
  // Admin токен (WZ01112)
  const adminToken = auth.issueMockToken({
    clientId: 'WZ01112', companyId: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
    companyName: 'Nubes', email: 'admin@nubes.ru',
  });
  tokens['admin'] = adminToken;
  tee('✅ Токены выпущены');

  function bearer(token) {
    return { Authorization: 'Bearer ' + token };
  }

  // Вставляем по ~100 CIDR на каждую компанию
  tee('\nВставка ~100 CIDR на компанию...');
  let totalInserted = 0;
  for (const co of companies) {
    const cidrs = [];
    const gen = genCIDRs(cidrsPerCompany, companies.indexOf(co) + 1);
    for (const c of gen) {
      cidrs.push(c);
      allCidrs.push({ cidr: c, company: co });
    }

    // Вставляем пачками по 10 (параллельно)
    const batchSize = 10;
    for (let i = 0; i < cidrs.length; i += batchSize) {
      const batch = cidrs.slice(i, i + batchSize);
      await Promise.all(batch.map(c =>
        pool.query(
          `INSERT INTO whitelist_entries (company_id, value_cidr, comment, created_by)
           VALUES ($1, $2, 'tz-test', 'tz-full-compliance')
           ON CONFLICT DO NOTHING`,
          [co.dbId, c]
        )
      ));
      totalInserted += batch.length;
    }
  }
  tee('✅ Всего вставлено ~' + totalInserted + ' CIDR\n');

  // Выбираем по 3 компании для ролевых тестов
  const CO = {
    A: companies[0],   // первая компания (для user-тестов)
    B: companies[1],   // вторая компания
    C: companies[2],   // третья компания
  };
  const tokA = tokens[CO.A.id];
  const tokB = tokens[CO.B.id];
  const tokC = tokens[CO.C.id];

  // ══════════════════════════════════════════════════════════════════════════
  // §4.1. Просмотр списка записей
  // ══════════════════════════════════════════════════════════════════════════
  tee('═══ §4.1. Просмотр списка ═══\n');

  {
    const r = await req.get('/api/v1/entries').set(bearer(tokA));
    ok(r.status === 200, '4.1.1: GET /entries → 200', `status=${r.status}`);
    const b = JSON.parse(r.text);
    ok(Array.isArray(b.entries), '4.1.2: entries[] — массив');
    ok(b.entries.length > 0, '4.1.3: список не пуст', `count=${b.entries.length}`);
    ok(typeof b.limit === 'number', '4.1.4: limit — число', `limit=${b.limit}`);
    ok(typeof b.used === 'number', '4.1.5: used — число', `used=${b.used}`);
    ok(b.used === b.entries.length, '4.1.6: used === entries.length');
    ok(b.used <= b.limit, '4.1.7: used ≤ limit', `used=${b.used} limit=${b.limit}`);

    // Колонки
    const e = b.entries[0];
    ok(typeof e.value_cidr === 'string', '4.1.8: value_cidr — строка');
    ok('comment' in e, '4.1.9: comment присутствует');
    ok('created_by' in e, '4.1.10: created_by присутствует');
    ok('created_at' in e, '4.1.11: created_at присутствует');
    ok('updated_at' in e, '4.1.12: updated_at присутствует');
    ok(typeof e.id === 'number', '4.1.13: id — число');
  }

  // Admin видит все компании с фильтром
  {
    const r = await req.get('/api/v1/companies').set(bearer(adminToken));
    ok(r.status === 200, '4.1.14: admin GET /companies → 200');
    const b = JSON.parse(r.text);
    ok(Array.isArray(b.companies), '4.1.15: companies[] — массив');
    ok(b.companies.length >= 20, '4.1.16: все 20+ компаний видны', `count=${b.companies.length}`);
  }

  // Admin фильтр по компании
  {
    const r = await req.get('/api/v1/entries?company=' + CO.A.dbId).set(bearer(adminToken));
    ok(r.status === 200, '4.1.17: admin GET /entries?company=<id> → 200');
    const b = JSON.parse(r.text);
    const allForA = b.entries.every(e => e.company_id === CO.A.dbId);
    ok(allForA, '4.1.18: все записи принадлежат указанной компании');
  }

  // Soft-deleted скрыты по умолчанию
  {
    // Удалим одну запись
    const list = JSON.parse((await req.get('/api/v1/entries').set(bearer(tokA))).text);
    const delId = list.entries[0]?.id;
    if (delId) {
      await req.delete('/api/v1/entries/' + delId).set(bearer(tokA));
      const r = await req.get('/api/v1/entries').set(bearer(tokA));
      const b = JSON.parse(r.text);
      const found = b.entries.find(e => e.id === delId);
      ok(!found, '4.1.19: soft-deleted запись скрыта из списка');
    } else {
      ok(false, '4.1.19: не удалось найти запись для soft-delete теста');
    }
  }

  // ══════════════════════════════════════════════════════════════════════════
  // §4.2. Создание записи
  // ══════════════════════════════════════════════════════════════════════════
  tee('\n═══ §4.2. Создание записи ═══\n');

  {
    const r = await req.post('/api/v1/entries').set(bearer(tokA))
      .send({ value: '203.0.114.50', comment: 'ТЗ-тест-создание' });
    ok(r.status === 201, '4.2.1: POST /entries → 201', `status=${r.status}`);
    const b = JSON.parse(r.text);
    ok(!!b.entry?.id, '4.2.2: ответ содержит entry.id');
    ok(b.entry.value_cidr === '203.0.114.50/32', '4.2.3: value_cidr сохранён', `got=${b.entry.value_cidr}`);
    ok(b.entry.comment === 'ТЗ-тест-создание', '4.2.4: comment сохранён');

    // Чистим
    await req.delete('/api/v1/entries/' + b.entry.id).set(bearer(tokA));
  }

  // Без value
  {
    const r = await req.post('/api/v1/entries').set(bearer(tokA)).send({ comment: 'нет value' });
    ok(r.status === 400, '4.2.5: POST без value → 400', `status=${r.status}`);
  }

  // Дубликат
  {
    const r1 = await req.post('/api/v1/entries').set(bearer(tokA)).send({ value: '203.0.114.60', comment: 'dup' });
    const id = JSON.parse(r1.text).entry?.id;
    const r2 = await req.post('/api/v1/entries').set(bearer(tokA)).send({ value: '203.0.114.60', comment: 'dup2' });
    ok(r2.status === 409, '4.2.6: дубликат → 409', `status=${r2.status}`);
    ok(JSON.parse(r2.text).error?.includes('уже существует'), '4.2.7: error содержит "уже существует"');
    if (id) await req.delete('/api/v1/entries/' + id).set(bearer(tokA));
  }

  // Приватный IP
  {
    const r = await req.post('/api/v1/entries').set(bearer(tokA)).send({ value: '10.0.0.1', comment: 'private' });
    ok(r.status === 400, '4.2.8: приватный IP → 400', `status=${r.status}`);
    ok(JSON.parse(r.text).error?.includes('запрещённым'), '4.2.9: error содержит "запрещённым"');
  }

  // Comment > 255
  {
    const r = await req.post('/api/v1/entries').set(bearer(tokA))
      .send({ value: '203.0.114.70', comment: 'X'.repeat(300) });
    ok(r.status === 400, '4.2.10: comment > 255 → 400', `status=${r.status}`);
  }

  // ══════════════════════════════════════════════════════════════════════════
  // §4.3. Редактирование
  // ══════════════════════════════════════════════════════════════════════════
  tee('\n═══ §4.3. Редактирование ═══\n');

  let editEntryId;
  {
    const r = await req.post('/api/v1/entries').set(bearer(tokA))
      .send({ value: '203.0.115.10', comment: 'до редактирования' });
    editEntryId = JSON.parse(r.text).entry?.id;
  }

  {
    const r = await req.patch('/api/v1/entries/' + editEntryId).set(bearer(tokA))
      .send({ value: '203.0.115.20', comment: 'после редактирования' });
    ok(r.status === 200, '4.3.1: PATCH /entries/:id → 200', `status=${r.status}`);
    const b = JSON.parse(r.text);
    ok(b.entry?.value_cidr === '203.0.115.20/32', '4.3.2: value обновлён', `got=${b.entry?.value_cidr}`);
    ok(b.entry?.comment === 'после редактирования', '4.3.3: comment обновлён');
  }

  // Пересечение при edit
  {
    // Создадим подсеть
    const r1 = await req.post('/api/v1/entries').set(bearer(tokA))
      .send({ value: '203.0.116.0/24', comment: 'подсеть' });
    // Пытаемся редактировать существующую запись на адрес внутри подсети
    const r2 = await req.patch('/api/v1/entries/' + editEntryId).set(bearer(tokA))
      .send({ value: '203.0.116.50' });
    ok(r2.status === 409, '4.3.4: пересечение при edit → 409', `status=${r2.status}`);
    const subId = JSON.parse(r1.text).entry?.id;
    if (subId) await req.delete('/api/v1/entries/' + subId).set(bearer(tokA));
  }

  // Чистим
  if (editEntryId) await req.delete('/api/v1/entries/' + editEntryId).set(bearer(tokA));

  // ══════════════════════════════════════════════════════════════════════════
  // §4.4. Soft delete
  // ══════════════════════════════════════════════════════════════════════════
  tee('\n═══ §4.4. Soft delete ═══\n');

  let delEntryId;
  {
    const r = await req.post('/api/v1/entries').set(bearer(tokA))
      .send({ value: '203.0.117.10', comment: 'для удаления' });
    delEntryId = JSON.parse(r.text).entry?.id;
  }

  {
    const r = await req.delete('/api/v1/entries/' + delEntryId).set(bearer(tokA));
    ok([200, 204].includes(r.status), '4.4.1: DELETE /entries/:id → 200/204', `status=${r.status}`);
  }

  // Проверяем deleted_at в БД
  {
    const row = await pool.query('SELECT deleted_at FROM whitelist_entries WHERE id = $1', [delEntryId]);
    ok(!!row.rows[0]?.deleted_at, '4.4.2: deleted_at выставлен в БД');
  }

  // Не видна в списке
  {
    const r = await req.get('/api/v1/entries').set(bearer(tokA));
    const b = JSON.parse(r.text);
    const found = b.entries.find(e => e.id === delEntryId);
    ok(!found, '4.4.3: удалённая запись не в GET /entries');
  }

  // Не в export
  {
    const r = await req.get('/export');
    ok(!r.text.includes('203.0.117.10'), '4.4.4: удалённая запись не в /export');
  }

  // Освобождает место в лимите
  {
    const before = JSON.parse((await req.get('/api/v1/entries').set(bearer(tokA))).text);
    const cntBefore = before.used;
    // Создаём новую (должна пройти, т.к. удалённая не считается)
    const r = await req.post('/api/v1/entries').set(bearer(tokA))
      .send({ value: '203.0.117.20', comment: 'после удаления' });
    ok(r.status === 201, '4.4.5: новая запись после удаления → 201 (место освободилось)');
    const newId = JSON.parse(r.text).entry?.id;
    if (newId) await req.delete('/api/v1/entries/' + newId).set(bearer(tokA));
  }

  // ══════════════════════════════════════════════════════════════════════════
  // §4.5. Лимиты
  // ══════════════════════════════════════════════════════════════════════════
  tee('\n═══ §4.5. Лимиты ═══\n');

  // Сбрасываем лимит для тестовой компании перед тестами лимитов
  await pool.query('UPDATE companies SET custom_limit = NULL WHERE id = $1', [CO.A.dbId]);

  // 4.5.1: Глобальный лимит по умолчанию
  {
    const r = await req.get('/api/v1/entries').set(bearer(tokA));
    const b = JSON.parse(r.text);
    ok(b.limit > 0, '4.5.1: глобальный limit > 0', `limit=${b.limit}`);
    ok(b.limit === 15, '4.5.1b: default limit = 15', `limit=${b.limit}`);
  }

  // 4.5.2: Admin задаёт индивидуальный лимит
  {
    const r = await req.patch('/api/v1/companies/' + CO.A.dbId + '/limit')
      .set(bearer(adminToken)).send({ limit: 200 });
    ok(r.status === 200, '4.5.2: admin setLimit → 200', `status=${r.status}`);
    ok(JSON.parse(r.text).limit === 200, '4.5.3: лимит = 200');

    // Проверяем что применился
    const r2 = await req.get('/api/v1/entries').set(bearer(tokA));
    ok(JSON.parse(r2.text).limit === 200, '4.5.4: GET /entries возвращает limit=200');
  }

  // 4.5.3: User не может менять лимит
  {
    const r = await req.patch('/api/v1/companies/' + CO.A.dbId + '/limit')
      .set(bearer(tokA)).send({ limit: 5 });
    ok(r.status === 403, '4.5.5: user setLimit → 403', `status=${r.status}`);
  }

  // 4.5.4: Превышение лимита
  {
    // Ставим лимит = 0
    await req.patch('/api/v1/companies/' + CO.A.dbId + '/limit')
      .set(bearer(adminToken)).send({ limit: 0 });
    const r = await req.post('/api/v1/entries').set(bearer(tokA))
      .send({ value: '203.0.118.10', comment: 'сверх лимита' });
    ok(r.status === 409, '4.5.6: лимит=0, POST → 409', `status=${r.status}`);
    ok(JSON.parse(r.text).error?.includes('Лимит'), '4.5.7: error содержит "Лимит"');
  }

  // 4.5.5: Снижение лимита не удаляет записи
  {
    const cnt = (await pool.query(
      'SELECT COUNT(*)::int AS c FROM whitelist_entries WHERE company_id = $1 AND deleted_at IS NULL',
      [CO.A.dbId]
    )).rows[0].c;
    await req.patch('/api/v1/companies/' + CO.A.dbId + '/limit')
      .set(bearer(adminToken)).send({ limit: 0 });
    const r = await req.get('/api/v1/entries').set(bearer(tokA));
    const b = JSON.parse(r.text);
    ok(b.entries.length === cnt, '4.5.8: снижение лимита не удалило записи', `count=${b.entries.length}`);
  }

  // Сброс лимита
  await pool.query('UPDATE companies SET custom_limit = NULL WHERE id = $1', [CO.A.dbId]);

  // ══════════════════════════════════════════════════════════════════════════
  // §4.6. Аудит
  // ══════════════════════════════════════════════════════════════════════════
  tee('\n═══ §4.6. Аудит ═══\n');

  // Создаём запись чтобы получить аудит-событие
  let auditEntryId;
  {
    const r = await req.post('/api/v1/entries').set(bearer(tokA))
      .send({ value: '203.0.119.10', comment: 'аудит-тест' });
    auditEntryId = JSON.parse(r.text).entry?.id;
  }

  // Admin видит аудит
  {
    const r = await req.get('/api/v1/audit').set(bearer(adminToken));
    ok(r.status === 200, '4.6.1: admin GET /audit → 200', `status=${r.status}`);
    const b = JSON.parse(r.text);
    ok(Array.isArray(b.rows), '4.6.2: rows[] — массив');
    ok(Array.isArray(b.companies), '4.6.3: companies[] — массив');
    ok(b.rows.length > 0, '4.6.4: аудит не пуст', `rows=${b.rows.length}`);
  }

  // User НЕ видит аудит
  {
    const r = await req.get('/api/v1/audit').set(bearer(tokA));
    ok(r.status === 403, '4.6.5: user GET /audit → 403', `status=${r.status}`);
  }

  // Аудит содержит CREATE
  {
    const rows = (await pool.query(
      'SELECT action FROM audit_log WHERE company_id = $1 ORDER BY created_at DESC LIMIT 50',
      [CO.A.dbId]
    )).rows;
    ok(rows.some(r => /create/i.test(r.action)), '4.6.6: CREATE записан в audit_log');
    ok(rows.some(r => /delete/i.test(r.action)), '4.6.7: DELETE записан в audit_log');
  }

  // PATCH тоже логируется
  if (auditEntryId) {
    await req.patch('/api/v1/entries/' + auditEntryId).set(bearer(tokA))
      .send({ value: '203.0.119.20', comment: 'аудит-update' });
    const rows = (await pool.query(
      "SELECT action FROM audit_log WHERE entry_id = $1 AND action ILIKE '%update%'",
      [auditEntryId]
    )).rows;
    ok(rows.length > 0, '4.6.8: UPDATE записан в audit_log');
    await req.delete('/api/v1/entries/' + auditEntryId).set(bearer(tokA));
  }

  // ══════════════════════════════════════════════════════════════════════════
  // §4.7. Export
  // ══════════════════════════════════════════════════════════════════════════
  tee('\n═══ §4.7. Export ═══\n');

  // 4.7.1: публичный доступ
  {
    const r = await req.get('/export');
    ok(r.status === 200, '4.7.1: GET /export → 200 (без авторизации)', `status=${r.status}`);
    ok((r.headers['content-type'] || '').includes('text/plain'), '4.7.2: Content-Type: text/plain');
    ok(r.text.length > 0, '4.7.3: не пустой', `size=${r.text.length}B`);
    // Каждая строка — один CIDR
    const lines = r.text.trim().split('\n');
    ok(lines.length > 0, '4.7.4: одна строка — один объект', `lines=${lines.length}`);
    // Все строки — валидные CIDR
    const allCidr = lines.every(l => /^\d+\.\d+\.\d+\.\d+\/\d+$/.test(l));
    ok(allCidr, '4.7.5: все строки — валидные CIDR');
  }

  // 4.7.2: ?view=1 → inline
  {
    const r = await req.get('/export?view=1');
    ok(r.status === 200, '4.7.6: /export?view=1 → 200');
    ok(r.headers['content-disposition']?.includes('inline'), '4.7.7: Content-Disposition: inline');
  }

  // 4.7.3: ?filename=X
  {
    const r = await req.get('/export?filename=my-list.txt');
    ok(r.status === 200, '4.7.8: /export?filename=my-list.txt → 200');
    ok(r.headers['content-disposition']?.includes('my-list.txt'), '4.7.9: имя файла = my-list.txt');
  }

  // 4.7.4: По умолчанию attachment
  {
    const r = await req.get('/export');
    ok(r.headers['content-disposition']?.includes('attachment'), '4.7.10: по умолчанию attachment');
    ok(r.headers['content-disposition']?.includes('white-list.txt'), '4.7.11: имя по умолчанию white-list.txt');
  }

  // 4.7.5: Агрегация CIDR (ТЗ: «суммаризируются»)
  // Проверяем что соседние /32 агрегируются
  {
    // Добавим два соседних /32 в отдельную компанию
    const coTest = companies[10];
    const tokTest = tokens[coTest.id];
    const r1 = await req.post('/api/v1/entries').set(bearer(tokTest)).send({ value: '5.5.5.0', comment: 'agg1' });
    const r2 = await req.post('/api/v1/entries').set(bearer(tokTest)).send({ value: '5.5.5.1', comment: 'agg2' });
    ok(r1.status === 201 && r2.status === 201, '4.7.12: соседние /32 созданы');

    // API export (с агрегацией)
    const rExp = await req.get('/api/v1/entries/export').set(bearer(tokTest));
    const lines = rExp.text.trim().split('\n');
    // 5.5.5.0/32 + 5.5.5.1/32 должны агрегироваться в 5.5.5.0/31
    const has31 = lines.some(l => l === '5.5.5.0/31');
    ok(has31, '4.7.13: соседние /32 агрегированы в /31', `lines=${JSON.stringify(lines)}`);

    // Чистим
    const id1 = JSON.parse(r1.text).entry?.id;
    const id2 = JSON.parse(r2.text).entry?.id;
    if (id1) await req.delete('/api/v1/entries/' + id1).set(bearer(tokTest));
    if (id2) await req.delete('/api/v1/entries/' + id2).set(bearer(tokTest));
  }

  // ══════════════════════════════════════════════════════════════════════════
  // §5. Валидация (Приложение А)
  // ══════════════════════════════════════════════════════════════════════════
  tee('\n═══ §5. Валидация ═══\n');

  // Ставим высокий лимит (в §4.5 сбросили до дефолта, а записей ~99)
  await pool.query('UPDATE companies SET custom_limit = 999 WHERE id = $1', [CO.A.dbId]);

  // Все 14 запрещённых диапазонов
  const blockedTests = [
    ['10.1.2.3',       'Private 10/8'],
    ['172.16.5.5',     'Private 172.16/12'],
    ['192.168.1.1',    'Private 192.168/16'],
    ['100.64.0.1',     'CGNAT 100.64/10'],
    ['127.0.0.1',      'Loopback'],
    ['169.254.1.1',    'Link-local'],
    ['192.0.0.1',      'IANA special'],
    ['192.0.2.1',      'TEST-NET-1'],
    ['198.51.100.1',   'TEST-NET-2'],
    ['203.0.113.1',    'TEST-NET-3'],
    ['198.18.0.1',     'Benchmarking'],
    ['224.0.0.1',      'Multicast'],
    ['240.0.0.1',      'Reserved Class E'],
    ['255.255.255.255','Limited broadcast'],
  ];
  for (const [ip, desc] of blockedTests) {
    const r = await req.post('/api/v1/entries').set(bearer(tokA)).send({ value: ip, comment: desc });
    ok(r.status === 400, `5.bl.${desc}: ${ip} → 400`, `status=${r.status}`);
  }

  // Маски /22 - /32 (непересекающиеся диапазоны в разных /8)
  {
    // Используем заведомо не пересекающиеся диапазоны
    const r22 = await req.post('/api/v1/entries').set(bearer(tokA)).send({ value: '72.0.0.0/22' });
    ok(r22.status === 201, '5.m22: /22 → 201', `status=${r22.status}`);
    const id22 = JSON.parse(r22.text).entry?.id;

    const r24 = await req.post('/api/v1/entries').set(bearer(tokA)).send({ value: '73.0.0.0/24' });
    ok(r24.status === 201, '5.m24: /24 → 201', `status=${r24.status}`);
    const id24 = JSON.parse(r24.text).entry?.id;

    const r32 = await req.post('/api/v1/entries').set(bearer(tokA)).send({ value: '74.0.0.1/32' });
    ok(r32.status === 201, '5.m32: /32 → 201', `status=${r32.status}`);
    const id32 = JSON.parse(r32.text).entry?.id;

    // Чистим
    if (id22) await req.delete('/api/v1/entries/' + id22).set(bearer(tokA));
    if (id24) await req.delete('/api/v1/entries/' + id24).set(bearer(tokA));
    if (id32) await req.delete('/api/v1/entries/' + id32).set(bearer(tokA));
  }

  // Маска /21 и меньше
  {
    const r = await req.post('/api/v1/entries').set(bearer(tokA)).send({ value: '203.0.0.0/21' });
    ok(r.status === 400, '5.m21: /21 → 400', `status=${r.status}`);
  }

  // Маска /33
  {
    const r = await req.post('/api/v1/entries').set(bearer(tokA)).send({ value: '203.0.0.0/33' });
    ok(r.status === 400, '5.m33: /33 → 400', `status=${r.status}`);
  }

  // IPv6
  {
    const r = await req.post('/api/v1/entries').set(bearer(tokA)).send({ value: '2001:db8::1' });
    ok(r.status === 400, '5.ip6: IPv6 → 400', `status=${r.status}`);
  }

  // Нормализация
  {
    const r = await req.post('/api/v1/entries').set(bearer(tokA)).send({ value: '75.0.0.50/24' });
    ok(r.status === 201, '5.na: хост-часть /24 → 201', `status=${r.status}`);
    const b = JSON.parse(r.text);
    ok(b.wasNormalized === true, '5.nb: wasNormalized=true');
    ok(b.entry.value_cidr === '75.0.0.0/24', '5.nc: нормализован в 75.0.0.0/24', `got=${b.entry.value_cidr}`);
    if (b.entry?.id) await req.delete('/api/v1/entries/' + b.entry.id).set(bearer(tokA));
  }

  // XSS
  {
    const r = await req.post('/api/v1/entries').set(bearer(tokA))
      .send({ value: '203.0.127.10', comment: '<script>alert(1)</script>' });
    ok(r.status === 201, '5.xss: XSS в comment → 201 (не 500)', `status=${r.status}`);
    if (JSON.parse(r.text).entry?.id) {
      await req.delete('/api/v1/entries/' + JSON.parse(r.text).entry.id).set(bearer(tokA));
    }
  }

  // SQL injection
  {
    const r = await req.post('/api/v1/entries').set(bearer(tokA))
      .send({ value: "1.2.3.4'; DROP TABLE companies;--" });
    ok([400, 409, 422].includes(r.status), '5.sqli: SQL injection → не 201 и не 500', `status=${r.status}`);
  }

  // Октет > 255
  {
    const r = await req.post('/api/v1/entries').set(bearer(tokA)).send({ value: '300.0.0.1' });
    ok(r.status !== 201 && r.status !== 500, '5.oct: 300.0.0.1 → не 201', `status=${r.status}`);
  }

  // ══════════════════════════════════════════════════════════════════════════
  // §3. Роли и права
  // ══════════════════════════════════════════════════════════════════════════
  tee('\n═══ §3. Роли и права ═══\n');

  // Изоляция: userA не видит записи userB
  {
    const rA = await req.get('/api/v1/entries').set(bearer(tokA));
    const rB = await req.get('/api/v1/entries').set(bearer(tokB));
    const idsA = JSON.parse(rA.text).entries.map(e => e.id);
    const idsB = JSON.parse(rB.text).entries.map(e => e.id);
    const intersection = idsA.filter(id => idsB.includes(id));
    ok(intersection.length === 0, '3.1: userA и userB — нет общих записей');
  }

  // UserA не может PATCH запись userB
  {
    const listB = JSON.parse((await req.get('/api/v1/entries').set(bearer(tokB))).text);
    const idB = listB.entries[0]?.id;
    if (idB) {
      const r = await req.patch('/api/v1/entries/' + idB).set(bearer(tokA))
        .send({ value: '203.0.128.10', comment: 'IDOR' });
      ok(r.status === 404, '3.2: userA PATCH записи userB → 404', `status=${r.status}`);
    }
  }

  // UserA не может DELETE запись userB
  {
    const listB = JSON.parse((await req.get('/api/v1/entries').set(bearer(tokB))).text);
    const idB = listB.entries[0]?.id;
    if (idB) {
      const r = await req.delete('/api/v1/entries/' + idB).set(bearer(tokA));
      ok(r.status === 404, '3.3: userA DELETE записи userB → 404', `status=${r.status}`);
    }
  }

  // Admin может всё
  {
    const r = await req.get('/api/v1/entries?company=' + CO.B.dbId).set(bearer(adminToken));
    ok(r.status === 200, '3.4: admin видит записи компании B', `status=${r.status}`);
  }

  // Admin добавляет в чужую компанию
  {
    const r = await req.post('/api/v1/entries?company=' + CO.C.dbId).set(bearer(adminToken))
      .send({ value: '203.0.129.10', comment: 'admin в C' });
    ok(r.status === 201, '3.5: admin POST в компанию C → 201', `status=${r.status}`);
    const eid = JSON.parse(r.text).entry?.id;
    // Проверяем что в БД company_id = CO.C
    if (eid) {
      const row = await pool.query('SELECT company_id FROM whitelist_entries WHERE id = $1', [eid]);
      ok(row.rows[0]?.company_id === CO.C.dbId, '3.6: запись admin сохранена в компанию C');
      await req.delete('/api/v1/entries/' + eid + '?company=' + CO.C.dbId).set(bearer(adminToken));
    }
  }

  // User не может использовать ?company=
  {
    const cntBefore = (await pool.query(
      'SELECT COUNT(*)::int AS c FROM whitelist_entries WHERE company_id = $1 AND deleted_at IS NULL',
      [CO.B.dbId]
    )).rows[0].c;
    const r = await req.post('/api/v1/entries?company=' + CO.B.dbId).set(bearer(tokA))
      .send({ value: '203.0.130.10', comment: 'IDOR ?company=' });
    const cntAfter = (await pool.query(
      'SELECT COUNT(*)::int AS c FROM whitelist_entries WHERE company_id = $1 AND deleted_at IS NULL',
      [CO.B.dbId]
    )).rows[0].c;
    ok(cntBefore === cntAfter, '3.7: user не смог добавить в чужую компанию через ?company=');
  }

  // Admin-only эндпоинты
  {
    const r1 = await req.get('/api/v1/companies').set(bearer(tokA));
    ok(r1.status === 403, '3.8: user GET /companies → 403');

    const r2 = await req.patch('/api/v1/companies/' + CO.A.dbId + '/limit').set(bearer(tokA)).send({ limit: 10 });
    ok(r2.status === 403, '3.9: user PATCH /limit → 403');
  }

  // ══════════════════════════════════════════════════════════════════════════
  // §3.1. Мульти-компания
  // ══════════════════════════════════════════════════════════════════════════
  tee('\n═══ §3.1. Мульти-компания ═══\n');

  // Создаём мульти-компанию пользователя
  const multiClientId = CO.A.clientId + ', ' + CO.B.clientId;
  const multiToken = auth.issueMockToken({
    clientId: multiClientId,
    companyId: CO.A.companyId,
    companyName: CO.A.companyName + '+' + CO.B.companyName,
    email: 'multi@test.local',
  });

  // По умолчанию — первая компания
  {
    const r = await req.get('/api/v1/entries').set(bearer(multiToken));
    ok(r.status === 200, '3.1.1: multi GET /entries → 200');
    const b = JSON.parse(r.text);
    // Все записи должны быть из компании A
    const allA = b.entries.every(e => e.company_id === CO.A.dbId);
    ok(allA || b.entries.length === 0, '3.1.2: по умолчанию — компания A (первая в clientId)');
  }

  // ?client_id= переключает компанию
  {
    const r = await req.get('/api/v1/entries?client_id=' + CO.B.clientId).set(bearer(multiToken));
    ok(r.status === 200, '3.1.3: multi GET /entries?client_id=B → 200');
    const b = JSON.parse(r.text);
    const allB = b.entries.every(e => e.company_id === CO.B.dbId);
    ok(allB || b.entries.length === 0, '3.1.4: ?client_id=B → записи компании B');
  }

  // Несуществующий client_id → фоллбек на первую
  {
    const r = await req.get('/api/v1/entries?client_id=NONEXISTENT').set(bearer(multiToken));
    ok(r.status === 200, '3.1.5: multi GET ?client_id=NONEXISTENT → 200 (фоллбек)');
  }

  // Export с ?client_id=
  {
    const rDef = await req.get('/api/v1/entries/export').set(bearer(multiToken));
    const rSw  = await req.get('/api/v1/entries/export?client_id=' + CO.B.clientId).set(bearer(multiToken));
    ok(rDef.status === 200 && rSw.status === 200, '3.1.6: multi export с переключением → 200');
    // Экспорты должны быть разными (если есть записи в обеих компаниях)
    if (rDef.text !== rSw.text) {
      ok(true, '3.1.7: экспорты для разных компаний различаются');
    } else {
      ok(true, '3.1.7: экспорты совпадают (возможно, одинаковые данные)');
    }
  }

  // ══════════════════════════════════════════════════════════════════════════
  // Дополнительно: уточнения из ТЗ-плюс
  // ══════════════════════════════════════════════════════════════════════════
  tee('\n═══ Уточнения ТЗ-плюс ═══\n');

  // clientId через запятую с пробелами
  {
    const tokenSpace = auth.issueMockToken({
      clientId: CO.A.clientId + ' , ' + CO.B.clientId + ' , ' + CO.C.clientId,
      companyId: CO.A.companyId,
      companyName: 'Multi-Space',
      email: 'multispace@test.local',
    });
    const r = await req.get('/api/v1/entries').set(bearer(tokenSpace));
    ok(r.status === 200, 'TZ+.1: clientId с пробелами → 200', `status=${r.status}`);
  }

  // Первое значение до запятой = активная компания
  {
    const r = await req.get('/api/v1/entries').set(bearer(multiToken));
    const b = JSON.parse(r.text);
    // Все записи принадлежат CO.A (первая в clientId)
    const hasForeign = b.entries.some(e => e.company_id !== CO.A.dbId && e.company_id !== null);
    ok(!hasForeign, 'TZ+.2: первая компания — активная, чужих записей нет');
  }

  // ══════════════════════════════════════════════════════════════════════════
  // Cleanup
  // ══════════════════════════════════════════════════════════════════════════
  tee('\n═══ Cleanup ═══\n');

  // Удаляем все тестовые записи
  for (const co of companies) {
    await pool.query(
      `UPDATE whitelist_entries SET deleted_at = NOW(), deleted_by = 'tz-full-cleanup'
       WHERE company_id = $1 AND deleted_at IS NULL`,
      [co.dbId]
    );
  }
  tee('✅ Тестовые записи удалены (soft-delete)');

  // ══════════════════════════════════════════════════════════════════════════
  // Итоги
  // ══════════════════════════════════════════════════════════════════════════
  const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
  tee('\n══════════════════════════════════════════════════════');
  tee(`  ${pass} PASS, ${fail} FAIL — ${elapsed}с`);
  tee('══════════════════════════════════════════════════════');

  if (errors.length > 0) {
    tee('\nОшибки:');
    errors.forEach(e => tee('  ' + e));
  }

  saveLog();
  await pool.end();
  process.exit(fail > 0 ? 1 : 0);
})();
