'use strict';

/**
 * ET-007 — поведенческие unit-тесты переключателя подложки «Схема / Спутник».
 *
 * Покрывают U-01..U-05 и U-10..U-11 из docs/work-items/ET-007/04-test-plan.yaml,
 * а также часть интеграционных кейсов (I-01..I-04, I-06, I-07, I-24, I-25),
 * проверяемых на мок-карте.
 *
 * Тесты исполняют РЕАЛЬНЫЙ код из блока ET-007 в src/web/app.js: блок
 * извлекается по маркерам `>>> ET-007 base layer toggle block` и
 * оборачивается в фабрику через `new Function`, которой передаются
 * мок-зависимости (window, document, localStorage). Так монолитный
 * browser-скрипт проверяется без полной загрузки в Node.
 *
 * Запуск: `node --test tests/unit/base_layer.test.js`
 * (в CI оборачивается pytest-тестом tests/unit/test_base_layer.py).
 */

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

const APP_JS = path.join(__dirname, '..', '..', 'src', 'web', 'app.js');

/**
 * Извлекает блок ET-007 из app.js и собирает из него модуль,
 * подставляя переданные зависимости.
 */
function loadBaseLayerModule(deps) {
  const src = fs.readFileSync(APP_JS, 'utf8');
  const m = src.match(
    /\/\/ >>> ET-007 base layer toggle block[^\n]*\n([\s\S]*?)\/\/ <<< ET-007 base layer toggle block/
  );
  assert.ok(m, 'ET-007-блок не найден в app.js (маркеры отсутствуют)');
  const factory = new Function(
    'window', 'document',
    m[1] + '\nreturn { getStoredBaseLayer, onBaseLayerToggle, applyBaseLayer, restoreBaseLayerState, syncBaseLayerUI, _firstOverlayLayerId, SATELLITE_SOURCE_ID, SATELLITE_LAYER_ID, SATELLITE_TILE_URL, SATELLITE_ATTRIBUTION, SATELLITE_HALO_LAYER_IDS };'
  );
  return factory(deps.window, deps.document);
}

/**
 * Готовит изолированное мок-окружение для одного теста.
 *
 * Создаёт мок-карту с журналом вызовов (addSource/addLayer/
 * setLayoutProperty/setPaintProperty), мок-DOM с кнопками
 * #base-btn-schematic и #base-btn-satellite, мок-localStorage и
 * мок-document.body.classList для определения активной темы.
 */
function makeEnv({
  stored,
  noStorage = false,
  layers = ['background', 'osm-base', 'trails-track-halo-satellite', 'trails-track', 'trails-path-bridleway-halo-satellite', 'trails-path-bridleway', 'poi-circles', 'poi-labels'],
  themeDark = false,
  noMap = false,
} = {}) {
  const calls = {
    addSource: [],
    addLayer: [],
    setLayoutProperty: [],
    setPaintProperty: [],
    setItem: [],
  };
  const store = {};
  if (stored !== undefined) store['map-base-layer'] = stored;

  const localStorage = {
    getItem: (k) => (k in store ? store[k] : null),
    setItem: (k, v) => { store[k] = String(v); calls.setItem.push([k, String(v)]); },
  };

  const sourceSet = new Set();
  const layerSet = new Set(layers);
  const layoutByLayer = {};
  const map = {
    getSource: (id) => (sourceSet.has(id) ? { id } : undefined),
    addSource: (id, spec) => { sourceSet.add(id); calls.addSource.push([id, spec]); },
    getLayer: (id) => (layerSet.has(id) ? { id } : undefined),
    addLayer: (spec, before) => {
      layerSet.add(spec.id);
      if (spec.layout && spec.layout.visibility) {
        layoutByLayer[spec.id] = layoutByLayer[spec.id] || {};
        layoutByLayer[spec.id].visibility = spec.layout.visibility;
      }
      calls.addLayer.push([spec, before]);
    },
    setLayoutProperty: (id, prop, val) => {
      layoutByLayer[id] = layoutByLayer[id] || {};
      layoutByLayer[id][prop] = val;
      calls.setLayoutProperty.push([id, prop, val]);
    },
    setPaintProperty: (id, prop, val) => {
      calls.setPaintProperty.push([id, prop, val]);
    },
    getLayoutProperty: (id, prop) => (layoutByLayer[id] || {})[prop],
    getStyle: () => ({ layers: layers.map((id) => ({ id })) }),
  };

  const schBtn = { classList: { _classes: new Set(['seg-btn', 'active']),
    toggle(name, on) { if (on) this._classes.add(name); else this._classes.delete(name); },
    contains(n) { return this._classes.has(n); } } };
  const satBtn = { classList: { _classes: new Set(['seg-btn']),
    toggle(name, on) { if (on) this._classes.add(name); else this._classes.delete(name); },
    contains(n) { return this._classes.has(n); } } };
  const bodyClasses = new Set(themeDark ? ['theme-dark'] : ['theme-light']);
  const document = {
    getElementById: (id) => {
      if (id === 'base-btn-schematic') return schBtn;
      if (id === 'base-btn-satellite') return satBtn;
      return null;
    },
    body: {
      classList: {
        contains: (c) => bodyClasses.has(c),
      },
    },
  };
  const win = noMap
    ? { localStorage }
    : (noStorage
      ? { _map: map, get localStorage() { throw new Error('localStorage disabled'); } }
      : { _map: map, localStorage });

  const mod = loadBaseLayerModule({ window: win, document });
  return { mod, calls, store, schBtn, satBtn, map, sourceSet, layerSet, layoutByLayer };
}

// ── U-01: Default — Схема, если localStorage пуст ─────────────────────
test('U-01: без сохранённого значения getStoredBaseLayer() возвращает "schematic"', () => {
  const env = makeEnv();
  assert.equal(env.mod.getStoredBaseLayer(), 'schematic');
});

test('U-01: restoreBaseLayerState() при пустом localStorage активирует «Схему»', () => {
  const env = makeEnv();
  env.mod.restoreBaseLayerState();

  assert.ok(env.schBtn.classList.contains('active'));
  assert.ok(!env.satBtn.classList.contains('active'));
  // На схеме спутниковый источник НЕ создаётся.
  assert.deepEqual(env.calls.addSource, []);
  assert.deepEqual(env.calls.addLayer, []);
});

// ── U-02: Чтение значения 'satellite' из localStorage ─────────────────
test('U-02: restoreBaseLayerState() при stored=satellite активирует «Спутник»', () => {
  const env = makeEnv({ stored: 'satellite' });
  env.mod.restoreBaseLayerState();

  assert.ok(env.satBtn.classList.contains('active'));
  assert.ok(!env.schBtn.classList.contains('active'));
  // Создан спутниковый source/layer.
  assert.equal(env.calls.addSource.length, 1);
  assert.equal(env.calls.addSource[0][0], 'satellite-raster');
  assert.equal(env.calls.addLayer.length, 1);
  assert.equal(env.calls.addLayer[0][0].id, 'satellite-base');
});

// ── U-03: Запись значения при переключении ────────────────────────────
test('U-03: onBaseLayerToggle("satellite") пишет в localStorage', () => {
  const env = makeEnv();
  env.mod.onBaseLayerToggle('satellite');
  assert.deepEqual(env.calls.setItem, [['map-base-layer', 'satellite']]);
  assert.equal(env.store['map-base-layer'], 'satellite');
});

// ── U-04: Игнор некорректного значения в localStorage ─────────────────
test('U-04: некорректное stored значение даёт дефолт "schematic"', () => {
  const env = makeEnv({ stored: 'unknown' });
  assert.equal(env.mod.getStoredBaseLayer(), 'schematic');
});

test('U-04: пустая строка трактуется как дефолт', () => {
  const env = makeEnv({ stored: '' });
  assert.equal(env.mod.getStoredBaseLayer(), 'schematic');
});

// ── U-05: Toggle на уже активный режим — no-op ────────────────────────
test('U-05: повторный onBaseLayerToggle("schematic") при активной схеме — no-op', () => {
  const env = makeEnv();
  env.mod.onBaseLayerToggle('schematic');
  assert.deepEqual(env.calls.setItem, []);
  assert.deepEqual(env.calls.addSource, []);
  assert.deepEqual(env.calls.addLayer, []);
  assert.deepEqual(env.calls.setLayoutProperty, []);
});

test('U-05: повторный onBaseLayerToggle("satellite") при активном спутнике — no-op', () => {
  const env = makeEnv({ stored: 'satellite' });
  // первый вызов вернёт source/layer
  env.mod.restoreBaseLayerState();
  const setLayoutBefore = env.calls.setLayoutProperty.length;
  const setItemBefore = env.calls.setItem.length;

  env.mod.onBaseLayerToggle('satellite');

  // никаких новых обращений
  assert.equal(env.calls.setLayoutProperty.length, setLayoutBefore);
  assert.equal(env.calls.setItem.length, setItemBefore);
});

// ── U-10..U-11: syncBaseLayerUI() ─────────────────────────────────────
test('U-10: syncBaseLayerUI("satellite") переносит .active на «Спутник»', () => {
  const env = makeEnv();
  env.mod.syncBaseLayerUI('satellite');
  assert.ok(env.satBtn.classList.contains('active'));
  assert.ok(!env.schBtn.classList.contains('active'));
});

test('U-11: syncBaseLayerUI("schematic") переносит .active на «Схему»', () => {
  const env = makeEnv();
  // сначала сделаем спутник активным
  env.mod.syncBaseLayerUI('satellite');
  env.mod.syncBaseLayerUI('schematic');
  assert.ok(env.schBtn.classList.contains('active'));
  assert.ok(!env.satBtn.classList.contains('active'));
});

// ── I-01..I-02: спутниковый source/layer создаются при первом включении ─
test('I-01: applyBaseLayer("satellite") добавляет source satellite-raster (Esri URL)', () => {
  const env = makeEnv();
  env.mod.applyBaseLayer('satellite');
  assert.equal(env.calls.addSource.length, 1);
  const [id, spec] = env.calls.addSource[0];
  assert.equal(id, 'satellite-raster');
  assert.equal(spec.type, 'raster');
  assert.equal(spec.tileSize, 256);
  assert.ok(spec.tiles[0].includes('arcgisonline.com'));
});

test('I-02: applyBaseLayer("satellite") добавляет layer satellite-base типа raster', () => {
  const env = makeEnv();
  env.mod.applyBaseLayer('satellite');
  const [spec, before] = env.calls.addLayer[0];
  assert.equal(spec.id, 'satellite-base');
  assert.equal(spec.type, 'raster');
  assert.equal(spec.source, 'satellite-raster');
  // beforeId — первый terrain/trails/poi слой; в наборе по умолчанию это
  // trails-track-halo-satellite (первый с префиксом trails-/poi-/terrain-).
  assert.equal(before, 'trails-track-halo-satellite');
});

// ── I-03: visibility osm-base скрыт после переключения на спутник ──────
test('I-03: osm-base скрывается при включении спутника', () => {
  const env = makeEnv();
  env.mod.applyBaseLayer('satellite');
  const osmCalls = env.calls.setLayoutProperty.filter((c) => c[0] === 'osm-base');
  assert.ok(osmCalls.some((c) => c[1] === 'visibility' && c[2] === 'none'));
});

// ── I-04: возврат на схему — osm-base видим, satellite-base скрыт ──────
test('I-04: возврат на «Схему» возвращает osm-base в visible и скрывает satellite-base', () => {
  const env = makeEnv();
  env.mod.applyBaseLayer('satellite');
  env.calls.setLayoutProperty.length = 0;
  env.mod.applyBaseLayer('schematic');

  const osmVisible = env.calls.setLayoutProperty.find(
    (c) => c[0] === 'osm-base' && c[1] === 'visibility' && c[2] === 'visible'
  );
  const satHidden = env.calls.setLayoutProperty.find(
    (c) => c[0] === 'satellite-base' && c[1] === 'visibility' && c[2] === 'none'
  );
  assert.ok(osmVisible, 'osm-base не возвращён в visible');
  assert.ok(satHidden, 'satellite-base не скрыт при возврате на схему');
  // source НЕ удаляется (TRZ §1 REQ-F-03).
  assert.ok(env.sourceSet.has('satellite-raster'));
});

// ── I-07: атрибуция Esri зарегистрирована ──────────────────────────────
test('I-07: source содержит атрибуцию Esri', () => {
  const env = makeEnv();
  env.mod.applyBaseLayer('satellite');
  const [, spec] = env.calls.addSource[0];
  assert.ok(/Esri/.test(spec.attribution),
    'attribution source не упоминает Esri');
});

// ── I-23/halo: halo-underlay-слои включаются на спутнике ──────────────
test('halo-underlay-слои включаются при «Спутнике» и скрываются при «Схеме»', () => {
  const env = makeEnv();
  env.mod.applyBaseLayer('satellite');
  const trackHaloOn = env.calls.setLayoutProperty.find(
    (c) => c[0] === 'trails-track-halo-satellite' && c[2] === 'visible'
  );
  const pathHaloOn = env.calls.setLayoutProperty.find(
    (c) => c[0] === 'trails-path-bridleway-halo-satellite' && c[2] === 'visible'
  );
  assert.ok(trackHaloOn, 'halo для trails-track не включён');
  assert.ok(pathHaloOn, 'halo для path/bridleway не включён');

  env.calls.setLayoutProperty.length = 0;
  env.mod.applyBaseLayer('schematic');
  const trackHaloOff = env.calls.setLayoutProperty.find(
    (c) => c[0] === 'trails-track-halo-satellite' && c[2] === 'none'
  );
  const pathHaloOff = env.calls.setLayoutProperty.find(
    (c) => c[0] === 'trails-path-bridleway-halo-satellite' && c[2] === 'none'
  );
  assert.ok(trackHaloOff && pathHaloOff, 'halo не скрыт при возврате на схему');
});

// ── I-24: POI text-halo на спутнике становится чёрным ─────────────────
test('I-24: POI labels на спутнике получают чёрный halo и width=2', () => {
  const env = makeEnv();
  env.mod.applyBaseLayer('satellite');
  const haloColor = env.calls.setPaintProperty.find(
    (c) => c[0] === 'poi-labels' && c[1] === 'text-halo-color'
  );
  const haloWidth = env.calls.setPaintProperty.find(
    (c) => c[0] === 'poi-labels' && c[1] === 'text-halo-width'
  );
  assert.deepEqual(haloColor, ['poi-labels', 'text-halo-color', '#000000']);
  assert.deepEqual(haloWidth, ['poi-labels', 'text-halo-width', 2]);
});

// ── I-25: POI text-halo на схеме возвращается к значениям из style.json ─
test('I-25: возврат на «Схему» восстанавливает POI halo из светлого style.json', () => {
  const env = makeEnv();
  env.mod.applyBaseLayer('satellite');
  env.calls.setPaintProperty.length = 0;
  env.mod.applyBaseLayer('schematic');
  const haloColor = env.calls.setPaintProperty.find(
    (c) => c[0] === 'poi-labels' && c[1] === 'text-halo-color'
  );
  assert.deepEqual(haloColor, ['poi-labels', 'text-halo-color', '#ffffff']);
});

test('I-25/dark: возврат на «Схему» в тёмной теме даёт halo из style-dark.json', () => {
  const env = makeEnv({ themeDark: true });
  env.mod.applyBaseLayer('satellite');
  env.calls.setPaintProperty.length = 0;
  env.mod.applyBaseLayer('schematic');
  const haloColor = env.calls.setPaintProperty.find(
    (c) => c[0] === 'poi-labels' && c[1] === 'text-halo-color'
  );
  assert.deepEqual(haloColor, ['poi-labels', 'text-halo-color', '#1a1a2e']);
});

// ── background — единая satellite-константа #2a2a2a для обеих тем ─────
// (P1-4: ранее в спецификации был расходящийся набор констант, в т.ч.
// ошибочный #1a1a1a для светлой темы тёмнее, чем #2a2a2a для тёмной.
// ADR-004 §6 — одна константа #2a2a2a на обе темы.)
test('фон под спутником в светлой теме — единая константа #2a2a2a (P1-4)', () => {
  const env = makeEnv();
  env.mod.applyBaseLayer('satellite');
  const bg = env.calls.setPaintProperty.find(
    (c) => c[0] === 'background' && c[1] === 'background-color'
  );
  assert.deepEqual(bg, ['background', 'background-color', '#2a2a2a']);
});

test('фон под спутником в тёмной теме — та же константа #2a2a2a (P1-4)', () => {
  const env = makeEnv({ themeDark: true });
  env.mod.applyBaseLayer('satellite');
  const bg = env.calls.setPaintProperty.find(
    (c) => c[0] === 'background' && c[1] === 'background-color'
  );
  assert.deepEqual(bg, ['background', 'background-color', '#2a2a2a']);
});

test('фон при возврате на «Схему» (light) — baseline #f0ede6 (Data §5)', () => {
  const env = makeEnv();
  env.mod.applyBaseLayer('satellite');
  env.calls.setPaintProperty.length = 0;
  env.mod.applyBaseLayer('schematic');
  const bg = env.calls.setPaintProperty.find(
    (c) => c[0] === 'background' && c[1] === 'background-color'
  );
  assert.deepEqual(bg, ['background', 'background-color', '#f0ede6']);
});

test('фон при возврате на «Схему» (dark) — baseline #1a1a2e, не #1a1a1a (P1-4 / P2-3)', () => {
  const env = makeEnv({ themeDark: true });
  env.mod.applyBaseLayer('satellite');
  env.calls.setPaintProperty.length = 0;
  env.mod.applyBaseLayer('schematic');
  const bg = env.calls.setPaintProperty.find(
    (c) => c[0] === 'background' && c[1] === 'background-color'
  );
  assert.deepEqual(bg, ['background', 'background-color', '#1a1a2e']);
});

// ── P1-2: POI text-color синхронно с halo ─────────────────────────────
test('P1-2: на спутнике poi-labels text-color === #ffffff (читаемо поверх чёрного halo)', () => {
  const env = makeEnv();
  env.mod.applyBaseLayer('satellite');
  const textColor = env.calls.setPaintProperty.find(
    (c) => c[0] === 'poi-labels' && c[1] === 'text-color'
  );
  assert.deepEqual(textColor, ['poi-labels', 'text-color', '#ffffff']);
});

test('P1-2: возврат на «Схему» (light) восстанавливает poi-labels text-color === #333333', () => {
  const env = makeEnv();
  env.mod.applyBaseLayer('satellite');
  env.calls.setPaintProperty.length = 0;
  env.mod.applyBaseLayer('schematic');
  const textColor = env.calls.setPaintProperty.find(
    (c) => c[0] === 'poi-labels' && c[1] === 'text-color'
  );
  assert.deepEqual(textColor, ['poi-labels', 'text-color', '#333333']);
});

test('P1-2: возврат на «Схему» (dark) восстанавливает poi-labels text-color === #e0e0e0', () => {
  const env = makeEnv({ themeDark: true });
  env.mod.applyBaseLayer('satellite');
  env.calls.setPaintProperty.length = 0;
  env.mod.applyBaseLayer('schematic');
  const textColor = env.calls.setPaintProperty.find(
    (c) => c[0] === 'poi-labels' && c[1] === 'text-color'
  );
  assert.deepEqual(textColor, ['poi-labels', 'text-color', '#e0e0e0']);
});

// ── валидация входа onBaseLayerToggle() ───────────────────────────────
test('onBaseLayerToggle() игнорирует некорректное значение', () => {
  const env = makeEnv();
  env.mod.onBaseLayerToggle('hybrid');
  assert.deepEqual(env.calls.setItem, []);
  assert.deepEqual(env.calls.addSource, []);
});

// ── устойчивость: отсутствует window._map ─────────────────────────────
test('applyBaseLayer() без window._map не падает', () => {
  const env = makeEnv({ noMap: true });
  assert.doesNotThrow(() => env.mod.applyBaseLayer('satellite'));
});

// ── устойчивость: недоступный localStorage (private mode) ─────────────
test('getStoredBaseLayer() при недоступном localStorage возвращает "schematic"', () => {
  const env = makeEnv({ noStorage: true });
  assert.equal(env.mod.getStoredBaseLayer(), 'schematic');
});

test('onBaseLayerToggle() не падает при недоступном localStorage', () => {
  const env = makeEnv({ noStorage: true });
  assert.doesNotThrow(() => env.mod.onBaseLayerToggle('satellite'));
});

// ── z-order: спутник вставляется ПОД terrain/trails/POI ───────────────
test('Z-order: satellite-base вставляется beforeId=первый terrain/trails/poi слой', () => {
  const env = makeEnv({
    layers: ['background', 'osm-base', 'terrain-hillshade', 'trails-track', 'poi-circles'],
  });
  env.mod.applyBaseLayer('satellite');
  const [, before] = env.calls.addLayer[0];
  assert.equal(before, 'terrain-hillshade');
});

test('Z-order: если overlay-слоёв ещё нет — addLayer вызывается без beforeId', () => {
  const env = makeEnv({ layers: ['background', 'osm-base'] });
  env.mod.applyBaseLayer('satellite');
  const [, before] = env.calls.addLayer[0];
  assert.equal(before, undefined);
});

// ── повторный applyBaseLayer('satellite') не пересоздаёт source/layer ─
test('повторный applyBaseLayer("satellite") не дублирует addSource/addLayer', () => {
  const env = makeEnv();
  env.mod.applyBaseLayer('satellite');
  env.mod.applyBaseLayer('schematic');
  env.mod.applyBaseLayer('satellite');
  assert.equal(env.calls.addSource.length, 1);
  assert.equal(env.calls.addLayer.length, 1);
});
