'use strict';

/**
 * ET-011 — поведенческие JS unit-тесты UI для скачивания GPX из popup
 * публичного трека (src/web/gps_tracks.js).
 *
 * Контекст: test-plan §E2E-01..E2E-04 предусматривал Playwright-спеку
 * (`tests/web/test_track_download.spec.ts`), но в проекте нет настроенного
 * Playwright-раннера. Reviewer ET-011 (12-review.md, P1-01) явно разрешил
 * закрыть UI-сторону AC-1 / AC-2 / AC-7 этими JS unit-тестами, оставив
 * AC-13 (mobile-bbox) как manual smoke (см. 04b-ui-test-cases.md TC-UI-02).
 *
 * Покрываются:
 *   - _parseFilenameFromCD       — REQ-F-05.2, AC-2 (UI-чтение хедера)
 *   - _handleDownloadError       — REQ-F-05.4, AC-7 (toast по статусу)
 *   - _renderTrackPopupHtml      — REQ-F-01, AC-1 (кнопка в popup,
 *                                  aria-label, тапабельный data-track-id)
 *
 * Запуск:  node --test tests/web/track_download.test.js
 * В CI оборачивается pytest-тестом tests/web/test_track_download.py.
 */

const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');

const GPS_TRACKS_PATH = path.join(__dirname, '..', '..', 'src', 'web', 'gps_tracks.js');

// ─── Загрузчик модуля ─────────────────────────────────────────────────────────

/**
 * Загружает gps_tracks.js в изолированный контекст new Function, подставляя
 * мок-объекты вместо браузерных глобалов (`window`, `document`, `showToast`).
 *
 * Возвращает приватные функции, требуемые в тестах:
 *   _parseFilenameFromCD, _handleDownloadError, _renderTrackPopupHtml.
 *
 * @param {object} [opts]
 * @param {object} [opts.win]           мок window
 * @param {object} [opts.doc]           мок document
 * @param {Function|null} [opts.showToast] мок showToast (null → отсутствует)
 * @returns {{
 *   _parseFilenameFromCD: Function,
 *   _handleDownloadError: Function,
 *   _renderTrackPopupHtml: Function,
 * }}
 */
function loadDownloadModule(opts) {
  const o = opts || {};
  const win = o.win || {};
  win.localStorage = win.localStorage || {
    getItem: () => null,
    setItem: () => {},
  };
  const doc = o.doc || {
    getElementById: () => null,
    querySelectorAll: () => ({ forEach: () => {} }),
  };
  // showToast === undefined → typeof === 'undefined' → ранний return в
  // _handleDownloadError (defensive). null → typeof === 'object' → тоже return.
  const showToast = Object.prototype.hasOwnProperty.call(o, 'showToast')
    ? o.showToast
    : undefined;

  const src = fs.readFileSync(GPS_TRACKS_PATH, 'utf8');
  const factory = new Function(
    'window', 'document', 'showToast',
    src +
    '\nreturn {' +
    '  _parseFilenameFromCD,' +
    '  _handleDownloadError,' +
    '  _renderTrackPopupHtml,' +
    '};'
  );
  return factory(win, doc, showToast);
}

// ═══════════════════════════════════════════════════════════════════════════
// _parseFilenameFromCD — RFC 5987 + plain filename
// ═══════════════════════════════════════════════════════════════════════════

test('CD: null → null', () => {
  const { _parseFilenameFromCD } = loadDownloadModule();
  assert.equal(_parseFilenameFromCD(null), null);
});

test('CD: undefined → null', () => {
  const { _parseFilenameFromCD } = loadDownloadModule();
  assert.equal(_parseFilenameFromCD(undefined), null);
});

test('CD: пустая строка → null', () => {
  const { _parseFilenameFromCD } = loadDownloadModule();
  assert.equal(_parseFilenameFromCD(''), null);
});

test('CD: без параметров filename → null', () => {
  const { _parseFilenameFromCD } = loadDownloadModule();
  assert.equal(_parseFilenameFromCD('attachment'), null);
});

test('CD: plain filename="track.gpx" → "track.gpx"', () => {
  const { _parseFilenameFromCD } = loadDownloadModule();
  assert.equal(
    _parseFilenameFromCD('attachment; filename="track.gpx"'),
    'track.gpx',
  );
});

test('CD: plain filename без кавычек → значение до ; ', () => {
  const { _parseFilenameFromCD } = loadDownloadModule();
  assert.equal(
    _parseFilenameFromCD('attachment; filename=track.gpx'),
    'track.gpx',
  );
});

test('CD: filename*=UTF-8\'\'<percent> приоритетнее plain filename (RFC 5987)', () => {
  const { _parseFilenameFromCD } = loadDownloadModule();
  // backend всегда отдаёт оба параметра; для не-ASCII имени берётся star
  const cd = 'attachment; filename="track-1.gpx"; '
    + "filename*=UTF-8''%D0%9F%D0%BE%20%D0%B3%D1%80%D1%8F%D0%B7%D0%B8.gpx";
  assert.equal(_parseFilenameFromCD(cd), 'По грязи.gpx');
});

test('CD: filename* с битым percent-encoding → fallback на plain filename', () => {
  const { _parseFilenameFromCD } = loadDownloadModule();
  // %ZZ — невалидный percent (decodeURIComponent кинет)
  const cd = 'attachment; filename="track-1.gpx"; '
    + "filename*=UTF-8''%ZZbroken.gpx";
  assert.equal(_parseFilenameFromCD(cd), 'track-1.gpx');
});

test('CD: filename* без последующего ; (конец строки) — декодируется до конца', () => {
  const { _parseFilenameFromCD } = loadDownloadModule();
  // ADR-014 §F: backend кладёт filename* последним параметром
  const cd = "attachment; filename=\"a.gpx\"; filename*=UTF-8''%D0%90.gpx";
  assert.equal(_parseFilenameFromCD(cd), 'А.gpx');
});

// ═══════════════════════════════════════════════════════════════════════════
// _handleDownloadError — REQ-F-05.4, AC-7
// ═══════════════════════════════════════════════════════════════════════════

/** Создаёт мок showToast, копящий последние вызовы. */
function makeToastSpy() {
  const calls = [];
  const fn = (msg) => { calls.push(msg); };
  return { fn, calls };
}

test('Error: 404 → toast «Трек не найден.»', () => {
  const spy = makeToastSpy();
  const { _handleDownloadError } = loadDownloadModule({ showToast: spy.fn });
  _handleDownloadError(404, {});
  assert.equal(spy.calls.length, 1);
  assert.equal(spy.calls[0], 'Трек не найден.');
});

test('Error: 413 → toast про размер', () => {
  const spy = makeToastSpy();
  const { _handleDownloadError } = loadDownloadModule({ showToast: spy.fn });
  _handleDownloadError(413, {});
  assert.equal(spy.calls.length, 1);
  assert.match(spy.calls[0], /слишком большой/i);
});

test('Error: 400 → toast про формат', () => {
  const spy = makeToastSpy();
  const { _handleDownloadError } = loadDownloadModule({ showToast: spy.fn });
  _handleDownloadError(400, {});
  assert.equal(spy.calls.length, 1);
  assert.match(spy.calls[0], /формат/i);
});

test('Error: 500 / unknown → дефолтный toast «Не удалось скачать.»', () => {
  const spy = makeToastSpy();
  const { _handleDownloadError } = loadDownloadModule({ showToast: spy.fn });
  _handleDownloadError(500, {});
  assert.equal(spy.calls.length, 1);
  assert.match(spy.calls[0], /Не удалось скачать/);
});

test('Error: 403 с external_urls (ADR-015 §G flat-форма) → toast с URL', () => {
  // ADR-015 §G: backend → JSONResponse{detail, external_urls} (без вложенности).
  const spy = makeToastSpy();
  const { _handleDownloadError } = loadDownloadModule({ showToast: spy.fn });
  _handleDownloadError(403, {
    detail: 'source_forbidden',
    external_urls: ['https://www.wikiloc.com/abc'],
  });
  assert.equal(spy.calls.length, 1);
  assert.match(spy.calls[0], /Источник запрещает/);
  assert.ok(
    spy.calls[0].includes('https://www.wikiloc.com/abc'),
    `toast должен содержать external_url, было: ${spy.calls[0]}`,
  );
});

test('Error: 403 с body.detail.external_urls (legacy wrapped-форма) → URL читается', () => {
  // Defensive fallback на старую форму до P2-01 (когда HTTPException
  // оборачивал detail в {"detail": {...}}). Тест защищает frontend от
  // регресса, если кто-то восстановит HTTPException-вариант.
  const spy = makeToastSpy();
  const { _handleDownloadError } = loadDownloadModule({ showToast: spy.fn });
  _handleDownloadError(403, {
    detail: {
      detail: 'source_forbidden',
      external_urls: ['https://wikiloc.com/x'],
    },
  });
  assert.equal(spy.calls.length, 1);
  assert.ok(
    spy.calls[0].includes('https://wikiloc.com/x'),
    `toast должен содержать external_url из legacy формы, было: ${spy.calls[0]}`,
  );
});

test('Error: 403 без external_urls → toast без URL', () => {
  const spy = makeToastSpy();
  const { _handleDownloadError } = loadDownloadModule({ showToast: spy.fn });
  _handleDownloadError(403, { detail: 'source_forbidden' });
  assert.equal(spy.calls.length, 1);
  assert.match(spy.calls[0], /Источник запрещает/);
  // в сообщении не должно быть http-URL
  assert.ok(
    !/https?:\/\//.test(spy.calls[0]),
    `toast не должен содержать URL когда external_urls пуст, было: ${spy.calls[0]}`,
  );
});

test('Error: 403 с external_urls = [] → toast без URL', () => {
  const spy = makeToastSpy();
  const { _handleDownloadError } = loadDownloadModule({ showToast: spy.fn });
  _handleDownloadError(403, { detail: 'source_forbidden', external_urls: [] });
  assert.equal(spy.calls.length, 1);
  assert.ok(!/https?:\/\//.test(spy.calls[0]));
});

test('Error: showToast отсутствует → не падаем (defensive)', () => {
  // showToast не передан → typeof === 'undefined' → ранний return
  const { _handleDownloadError } = loadDownloadModule();
  assert.doesNotThrow(() => _handleDownloadError(404, {}));
  assert.doesNotThrow(() => _handleDownloadError(403, { external_urls: ['x'] }));
  assert.doesNotThrow(() => _handleDownloadError(500, {}));
});

// ═══════════════════════════════════════════════════════════════════════════
// _renderTrackPopupHtml — REQ-F-01, AC-1
// ═══════════════════════════════════════════════════════════════════════════

test('Popup: при валидном числовом id рендерится кнопка «Скачать GPX»', () => {
  const { _renderTrackPopupHtml } = loadDownloadModule();
  const html = _renderTrackPopupHtml({
    id: 42,
    name: 'Test Trail',
    activity_type: 'enduro',
    length_km: 5.3,
    points_count: 100,
  });
  // AC-1: aria-label «Скачать GPX» обязателен (REQ-F-01)
  assert.match(html, /aria-label="Скачать GPX"/);
  // структура / классы для CSS (.track-popup-download-btn — ADR-014 §3.a)
  assert.match(html, /class="track-popup-download-btn"/);
  assert.match(html, /<div class="track-popup-actions">/);
  // data-track-id — для делегированного обработчика (ADR-014 §3.b)
  assert.match(html, /data-track-id="42"/);
  // SVG download-иконка
  assert.match(html, /<svg [^>]*viewBox="0 0 24 24"/);
});

test('Popup: id в виде строки "7" тоже даёт кнопку (Number() приводит)', () => {
  const { _renderTrackPopupHtml } = loadDownloadModule();
  const html = _renderTrackPopupHtml({
    id: '7',
    name: 'Test',
  });
  assert.match(html, /data-track-id="7"/);
  assert.match(html, /aria-label="Скачать GPX"/);
});

test('Popup: id = 0 → кнопка НЕ рендерится (Path int ge=1 на бэке)', () => {
  // backend требует ge=1; защищаем frontend от запроса /download/0
  const { _renderTrackPopupHtml } = loadDownloadModule();
  const html = _renderTrackPopupHtml({
    id: 0,
    name: 'Test',
  });
  assert.doesNotMatch(html, /track-popup-download-btn/);
  assert.doesNotMatch(html, /Скачать GPX/);
});

test('Popup: id = null → кнопка НЕ рендерится', () => {
  const { _renderTrackPopupHtml } = loadDownloadModule();
  const html = _renderTrackPopupHtml({ id: null, name: 'Test' });
  assert.doesNotMatch(html, /track-popup-download-btn/);
});

test('Popup: id отсутствует → кнопка НЕ рендерится', () => {
  const { _renderTrackPopupHtml } = loadDownloadModule();
  const html = _renderTrackPopupHtml({ name: 'Test' });
  assert.doesNotMatch(html, /track-popup-download-btn/);
});

test('Popup: id = "abc" (мусор) → кнопка НЕ рендерится', () => {
  const { _renderTrackPopupHtml } = loadDownloadModule();
  const html = _renderTrackPopupHtml({ id: 'abc', name: 'Test' });
  assert.doesNotMatch(html, /track-popup-download-btn/);
});

test('Popup: id = -1 → кнопка НЕ рендерится (защита от patho-кейсов)', () => {
  const { _renderTrackPopupHtml } = loadDownloadModule();
  const html = _renderTrackPopupHtml({ id: -1, name: 'Test' });
  assert.doesNotMatch(html, /track-popup-download-btn/);
});

test('Popup-регрессия: остаются прежние поля (имя, активность, длина)', () => {
  const { _renderTrackPopupHtml } = loadDownloadModule();
  const html = _renderTrackPopupHtml({
    id: 1,
    name: 'Озеро',
    activity_type: 'enduro',
    length_km: 12.5,
    points_count: 250,
    user: 'tester',
    created_at: '2024-05-01T00:00:00Z',
  });
  assert.match(html, /<div class="track-popup-name">Озеро<\/div>/);
  assert.match(html, /Эндуро/);          // GPS_ACTIVITY_LABELS.enduro
  assert.match(html, /12\.5 км/);
  assert.match(html, /250 точек/);
  assert.match(html, /tester/);
});

test('Popup: actionsHtml идёт ПЕРЕД sourcesHtml (ADR-014 §3.a)', () => {
  const { _renderTrackPopupHtml } = loadDownloadModule();
  const html = _renderTrackPopupHtml({
    id: 9,
    name: 'X',
    sources: ['osm'],
    external_urls: ['https://www.openstreetmap.org/way/9'],
  });
  const idxActions = html.indexOf('track-popup-actions');
  const idxSources = html.indexOf('track-popup-sources');
  assert.notEqual(idxActions, -1, 'actionsHtml присутствует');
  assert.notEqual(idxSources, -1, 'sourcesHtml присутствует');
  assert.ok(
    idxActions < idxSources,
    'actionsHtml (кнопка) должен идти раньше sourcesHtml',
  );
});

test('Popup: без источников всё равно рендерится кнопка (если id ок)', () => {
  const { _renderTrackPopupHtml } = loadDownloadModule();
  const html = _renderTrackPopupHtml({ id: 3, name: 'NoSources' });
  assert.match(html, /track-popup-download-btn/);
  assert.doesNotMatch(html, /track-popup-sources/);
});
