'use strict';

/**
 * ET-008 — unit-тесты модуля публичных GPS-треков (gps_tracks.js).
 *
 * Покрывают:
 *   - _findGpsInsertPosition: логика приоритетного поиска позиции вставки
 *     (F-05: gpx-layer-* > route-*)
 *   - Filter state management: начальное состояние window.gpsTracksLayer.filters
 *   - Color palette mapping: GPS_SOURCE_COLORS, GPS_ACTIVITY_COLORS,
 *     GPS_FALLBACK_COLORS и _buildColorExpression()
 *
 * Тесты запускают РЕАЛЬНЫЙ код src/web/gps_tracks.js через new Function,
 * подставляя мок-окружение (window, document) вместо браузерных глобалов.
 * Браузерный примитив `maplibregl`, `fetch`, `AbortController` не нужны —
 * тестируемые пути кода к ним не обращаются при инициализации.
 *
 * Запуск: `node --test tests/web/gps_tracks.test.js`
 * (в CI оборачивается pytest-тестом tests/web/test_gps_tracks.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,
 * подставляя мок-объекты вместо браузерных глобалов.
 *
 * После загрузки в mockWin появится свойство .gpsTracksLayer с начальным
 * состоянием модуля. Возвращает приватные функции и константы.
 *
 * @param {object} [mockWin={}]  мок-объект window
 * @param {object} [mockDoc={}]  мок-объект document
 * @returns {{
 *   _findGpsInsertPosition: Function,
 *   _buildColorExpression: Function,
 *   GPS_SOURCE_COLORS: object,
 *   GPS_ACTIVITY_COLORS: object,
 *   GPS_FALLBACK_COLORS: string[],
 *   GPS_ACTIVITY_ICONS: object,
 *   GPS_ACTIVITY_LABELS: object,
 * }}
 */
function loadGpsTracksModule(mockWin, mockDoc) {
  const win = mockWin || {};
  // Stub localStorage — используется в onPublicTracksCheckbox/restorePublicTracksState,
  // но не при инициализации модуля.
  win.localStorage = win.localStorage || {
    getItem: () => null,
    setItem: () => {},
  };

  const doc = mockDoc || {
    getElementById: () => null,
    querySelectorAll: () => ({ forEach: () => {} }),
  };

  const src = fs.readFileSync(GPS_TRACKS_PATH, 'utf8');

  // new Function создаёт функцию в глобальном контексте Node.js,
  // поэтому clearTimeout/setTimeout доступны как Node.js-глобалы.
  const factory = new Function(
    'window', 'document',
    src +
    '\nreturn {' +
    '  _findGpsInsertPosition,' +
    '  _buildColorExpression,' +
    '  GPS_SOURCE_COLORS,' +
    '  GPS_ACTIVITY_COLORS,' +
    '  GPS_FALLBACK_COLORS,' +
    '  GPS_ACTIVITY_ICONS,' +
    '  GPS_ACTIVITY_LABELS,' +
    '};'
  );

  return factory(win, doc);
}

// ─── Вспомогательные функции ──────────────────────────────────────────────────

/** Создаёт мок-карту с заданным списком слоёв. */
function makeMap(layerIds) {
  return {
    getStyle: () => ({ layers: layerIds.map((id) => ({ id })) }),
  };
}

// ─── _findGpsInsertPosition: логика приоритетов ───────────────────────────────

test('F-05: нет подходящих слоёв → undefined', () => {
  const { _findGpsInsertPosition } = loadGpsTracksModule();
  const map = makeMap(['background', 'osm-base', 'trails-track', 'poi-circles']);
  assert.equal(_findGpsInsertPosition(map), undefined);
});

test('F-05: только gpx-layer-* → возвращает первый gpx-layer-*', () => {
  const { _findGpsInsertPosition } = loadGpsTracksModule();
  const map = makeMap(['background', 'gpx-layer-file1', 'gpx-layer-file2', 'trails-track']);
  assert.equal(_findGpsInsertPosition(map), 'gpx-layer-file1');
});

test('F-05: только route-* → возвращает первый route-*', () => {
  const { _findGpsInsertPosition } = loadGpsTracksModule();
  const map = makeMap(['background', 'osm-base', 'route-line', 'route-alt-1']);
  assert.equal(_findGpsInsertPosition(map), 'route-line');
});

test('F-05: gpx-layer-* приоритетнее route-* даже когда route-* идёт раньше в списке', () => {
  const { _findGpsInsertPosition } = loadGpsTracksModule();
  const map = makeMap(['background', 'route-line', 'gpx-layer-file1', 'poi-labels']);
  // gpx-layer-* — приоритет 1, должен победить route-line несмотря на порядок
  assert.equal(_findGpsInsertPosition(map), 'gpx-layer-file1');
});

test('F-05: gpx-layer-* и route-* присутствуют — возвращает gpx-layer-* (приоритет 1)', () => {
  const { _findGpsInsertPosition } = loadGpsTracksModule();
  const map = makeMap(['background', 'gpx-layer-abc', 'route-line', 'route-alt-2']);
  assert.equal(_findGpsInsertPosition(map), 'gpx-layer-abc');
});

test('F-05: map.getStyle() возвращает null → undefined', () => {
  const { _findGpsInsertPosition } = loadGpsTracksModule();
  const map = { getStyle: () => null };
  assert.equal(_findGpsInsertPosition(map), undefined);
});

test('F-05: map.getStyle отсутствует → undefined (нет TypeError)', () => {
  const { _findGpsInsertPosition } = loadGpsTracksModule();
  assert.doesNotThrow(() => {
    const result = _findGpsInsertPosition({});
    assert.equal(result, undefined);
  });
});

test('F-05: style.layers пустой → undefined', () => {
  const { _findGpsInsertPosition } = loadGpsTracksModule();
  const map = { getStyle: () => ({ layers: [] }) };
  assert.equal(_findGpsInsertPosition(map), undefined);
});

test('F-05: слой с именем ровно "route-" (без суффикса) распознаётся как route-*', () => {
  const { _findGpsInsertPosition } = loadGpsTracksModule();
  const map = makeMap(['background', 'route-']);
  assert.equal(_findGpsInsertPosition(map), 'route-');
});

// ─── Filter state management ──────────────────────────────────────────────────

test('Filters: начальный список активностей содержит все 7 типов', () => {
  const win = {};
  loadGpsTracksModule(win);
  const { activities } = win.gpsTracksLayer.filters;
  const expected = ['enduro', 'moto', 'offroad', 'bicycle', 'hike', 'ski', 'other'];
  assert.deepEqual(
    [...activities].sort(),
    [...expected].sort(),
    'начальный filters.activities не совпадает с ожидаемым набором',
  );
});

test('Filters: начальный colorMode === "source"', () => {
  const win = {};
  loadGpsTracksModule(win);
  assert.equal(win.gpsTracksLayer.filters.colorMode, 'source');
});

test('Filters: начальные источники включают osm, enduro_russia, wikiloc, ttrails (ET-009)', () => {
  const win = {};
  loadGpsTracksModule(win);
  const { sources } = win.gpsTracksLayer.filters;
  assert.ok(Array.isArray(sources), 'filters.sources должен быть массивом');
  assert.ok(sources.includes('osm'), 'отсутствует источник osm');
  assert.ok(sources.includes('enduro_russia'), 'отсутствует источник enduro_russia');
  assert.ok(sources.includes('wikiloc'), 'отсутствует источник wikiloc');
  assert.ok(sources.includes('ttrails'), 'отсутствует источник ttrails');
});

// ET-009 — REQ-F-13/REQ-F-14: цвета и атрибуция новых источников
test('ET-009: GPS_SOURCE_COLORS содержит цвет для wikiloc', () => {
  const { GPS_SOURCE_COLORS } = loadGpsTracksModule();
  assert.ok(GPS_SOURCE_COLORS.wikiloc, 'GPS_SOURCE_COLORS.wikiloc отсутствует');
  assert.match(GPS_SOURCE_COLORS.wikiloc, /^#[0-9a-fA-F]{6}$/);
});

test('ET-009: цвета osm, enduro_russia, wikiloc различны', () => {
  const { GPS_SOURCE_COLORS } = loadGpsTracksModule();
  const colors = new Set([
    GPS_SOURCE_COLORS.osm,
    GPS_SOURCE_COLORS.enduro_russia,
    GPS_SOURCE_COLORS.wikiloc,
  ]);
  assert.equal(colors.size, 3, 'цвета osm/enduro_russia/wikiloc должны быть уникальны');
});

test('Filters: enabled=false при инициализации', () => {
  const win = {};
  loadGpsTracksModule(win);
  assert.equal(win.gpsTracksLayer.enabled, false);
});

test('Filters: filters.activities — массив, не объект', () => {
  const win = {};
  loadGpsTracksModule(win);
  assert.ok(Array.isArray(win.gpsTracksLayer.filters.activities));
});

// ─── Color palette mapping ────────────────────────────────────────────────────

test('Colors: GPS_SOURCE_COLORS содержит ключи osm, enduro_russia, ttrails, offmaps, nakarte', () => {
  const { GPS_SOURCE_COLORS } = loadGpsTracksModule();
  for (const src of ['osm', 'enduro_russia', 'ttrails', 'offmaps', 'nakarte']) {
    assert.ok(
      GPS_SOURCE_COLORS[src],
      `GPS_SOURCE_COLORS: отсутствует источник ${src}`,
    );
  }
});

test('Colors: GPS_ACTIVITY_COLORS содержит все 7 типов активности', () => {
  const { GPS_ACTIVITY_COLORS } = loadGpsTracksModule();
  for (const act of ['enduro', 'moto', 'offroad', 'bicycle', 'hike', 'ski', 'other']) {
    assert.ok(
      GPS_ACTIVITY_COLORS[act],
      `GPS_ACTIVITY_COLORS: отсутствует активность ${act}`,
    );
  }
});

test('Colors: GPS_FALLBACK_COLORS — массив из 8 уникальных цветов', () => {
  const { GPS_FALLBACK_COLORS } = loadGpsTracksModule();
  assert.ok(Array.isArray(GPS_FALLBACK_COLORS), 'GPS_FALLBACK_COLORS не является массивом');
  assert.equal(GPS_FALLBACK_COLORS.length, 8, 'GPS_FALLBACK_COLORS должен содержать 8 цветов');
  const unique = new Set(GPS_FALLBACK_COLORS);
  assert.equal(
    unique.size,
    GPS_FALLBACK_COLORS.length,
    'GPS_FALLBACK_COLORS содержит дубли',
  );
});

test('Colors: все цвета GPS_SOURCE_COLORS — строки в формате #RRGGBB', () => {
  const { GPS_SOURCE_COLORS } = loadGpsTracksModule();
  const hexRe = /^#[0-9a-fA-F]{6}$/;
  for (const [src, color] of Object.entries(GPS_SOURCE_COLORS)) {
    assert.match(color, hexRe, `GPS_SOURCE_COLORS[${src}] = "${color}" не является hex-цветом`);
  }
});

test('Colors: все цвета GPS_ACTIVITY_COLORS — строки в формате #RRGGBB', () => {
  const { GPS_ACTIVITY_COLORS } = loadGpsTracksModule();
  const hexRe = /^#[0-9a-fA-F]{6}$/;
  for (const [act, color] of Object.entries(GPS_ACTIVITY_COLORS)) {
    assert.match(color, hexRe, `GPS_ACTIVITY_COLORS[${act}] = "${color}" не является hex-цветом`);
  }
});

test('Colors: _buildColorExpression("source") — MapLibre match по полю "source"', () => {
  const { _buildColorExpression, GPS_SOURCE_COLORS } = loadGpsTracksModule();
  const expr = _buildColorExpression('source');

  assert.ok(Array.isArray(expr), 'выражение должно быть массивом');
  assert.equal(expr[0], 'match', 'первый элемент должен быть "match"');
  assert.deepEqual(expr[1], ['get', 'source'], 'второй элемент должен быть ["get", "source"]');

  // Каждый источник присутствует в выражении с правильным цветом
  for (const [src, color] of Object.entries(GPS_SOURCE_COLORS)) {
    const idx = expr.indexOf(src);
    assert.notEqual(idx, -1, `источник "${src}" не найден в match-выражении`);
    assert.equal(expr[idx + 1], color, `цвет для источника "${src}" не совпадает`);
  }

  // Последний элемент — fallback цвет
  assert.equal(expr[expr.length - 1], '#808080', 'последний элемент должен быть fallback #808080');
});

test('Colors: _buildColorExpression("activity") — MapLibre match по полю "activity"', () => {
  const { _buildColorExpression, GPS_ACTIVITY_COLORS } = loadGpsTracksModule();
  const expr = _buildColorExpression('activity');

  assert.ok(Array.isArray(expr), 'выражение должно быть массивом');
  assert.equal(expr[0], 'match', 'первый элемент должен быть "match"');
  assert.deepEqual(expr[1], ['get', 'activity'], 'второй элемент должен быть ["get", "activity"]');

  // Каждый тип активности присутствует в выражении с правильным цветом
  for (const [act, color] of Object.entries(GPS_ACTIVITY_COLORS)) {
    const idx = expr.indexOf(act);
    assert.notEqual(idx, -1, `активность "${act}" не найдена в match-выражении`);
    assert.equal(expr[idx + 1], color, `цвет для активности "${act}" не совпадает`);
  }

  // Последний элемент — fallback цвет
  assert.equal(expr[expr.length - 1], '#808080', 'последний элемент должен быть fallback #808080');
});

test('Colors: _buildColorExpression — незнакомый режим даёт выражение по источнику', () => {
  const { _buildColorExpression } = loadGpsTracksModule();
  // Любое значение, отличное от 'activity', даёт режим 'source'
  const expr = _buildColorExpression('unknown');
  assert.deepEqual(expr[1], ['get', 'source']);
});
