/**
 * gpx.js — ET-006: загрузка и визуализация GPX-треков.
 *
 * Самодостаточный модуль фичи GPX: парсинг GPX 1.1, внутренняя модель
 * `window.gpxTracks`, управление слоями/маркерами карты, bottom sheet
 * `#sheet-gpx`, статистика трека и canvas-профиль высот.
 *
 * Подключается в index.html как классический скрипт СТРОГО после app.js
 * (ADR-002): модуль потребляет глобали app.js — `window._map`,
 * `openSheet()`, `closeSheet()`, `minimizeSheet()` — в момент события,
 * когда они уже определены. Публикует обработчики inline-`onclick`
 * (`onGpxFileSelected`, `toggleGpxSheet`, `selectGpxTrack`,
 * `removeGpxTrack`), хелпер `showToast()` и хук `rebuildGpxOverlays()`
 * (вызывается из `rebuildMapOverlays()` app.js — REQ-F-13).
 *
 * Парсинг — `DOMParser` в основном потоке; конвертация DOM → модель и
 * расчёт статистики выполняются чанками с отдачей управления event loop
 * (ADR-003).
 *
 * Для unit-тестов модуль дополнительно экспортируется через
 * `module.exports` (среда Node) — публикуются чистые функции и парсер.
 *
 * См. docs/work-items/ET-006/06-adr/ADR-002, ADR-003.
 */
(function (global) {
  'use strict';

  // ─── Константы ───────────────────────────────────────────────────────────

  /**
   * Палитра треков — 8 цветов, назначаются циклически (TRZ REQ-F-04 §5.3).
   * Намеренно отличается от цветов роутинга (синий/зелёный/оранжевый).
   * @type {string[]}
   */
  var PALETTE = [
    '#e6194b', '#3cb44b', '#ffe119', '#4363d8',
    '#f58231', '#911eb4', '#42d4f4', '#f032e6',
  ];

  /** Максимальный размер GPX-файла — 50 МБ (TRZ REQ-F-03). */
  var MAX_FILE_BYTES = 50 * 1024 * 1024;

  /** Порог фильтрации GPS-шума высот: дельты < 2 м игнорируются (TRZ §5.2). */
  var ELE_NOISE_M = 2;

  /** Размер чанка конвертации точек DOM → модель (ADR-003). */
  var CHUNK_SIZE = 8000;

  /** Высота canvas профиля высот, px (TRZ REQ-F-10). */
  var PROFILE_HEIGHT = 120;

  /** id слоёв OSRM-маршрута, под которые вставляются GPX-слои (TRZ REQ-F-04). */
  var ROUTE_BASE_LAYERS = ['route-line-0-outline', 'route-line-0'];

  // ─── Состояние ───────────────────────────────────────────────────────────

  // Каноническая модель загруженных файлов (TRZ §4). Живёт на window, чтобы
  // переживать map.setStyle() — пересоздаются лишь объекты карты (REQ-F-13).
  if (!global.gpxTracks) global.gpxTracks = [];

  /** id активного (выбранного) файла — для статистики и профиля высот. */
  var activeGpxId = null;

  /** Обработчики событий карты по id файла — чтобы корректно их снимать. */
  var mapHandlers = {};

  /** Маркер-курсор на карте при наведении на профиль высот. */
  var cursorMarker = null;

  /** Состояние отрисованного профиля высот — для интерактивности canvas. */
  var profileState = null;

  // ─── Чистые функции: гео и статистика ────────────────────────────────────

  /**
   * Расстояние между двумя точками по формуле Haversine.
   * @param {number[]} a - точка [lon, lat, ...].
   * @param {number[]} b - точка [lon, lat, ...].
   * @returns {number} расстояние в километрах.
   */
  function haversineKm(a, b) {
    var R = 6371;
    var dLat = (b[1] - a[1]) * Math.PI / 180;
    var dLon = (b[0] - a[0]) * Math.PI / 180;
    var s = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
      Math.cos(a[1] * Math.PI / 180) * Math.cos(b[1] * Math.PI / 180) *
      Math.sin(dLon / 2) * Math.sin(dLon / 2);
    return R * 2 * Math.atan2(Math.sqrt(s), Math.sqrt(1 - s));
  }

  /**
   * Создаёт аккумулятор статистики трека для однопроходного расчёта.
   *
   * Однопроходный обход (вместо `Math.min/max.apply` по массиву высот)
   * обязателен для больших треков: `Function.prototype.apply` на сотнях
   * тысяч аргументов бросает `RangeError: Maximum call stack size
   * exceeded` и валит загрузку файла (ревью P1-1, REQ-NF-01).
   *
   * @returns {{distanceKm:number, elevGain:number, elevLoss:number,
   *            eleMin:number, eleMax:number, hasEle:boolean, ref:?number}}
   */
  function makeStatsAccumulator() {
    return {
      distanceKm: 0, elevGain: 0, elevLoss: 0,
      eleMin: Infinity, eleMax: -Infinity, hasEle: false, ref: null,
    };
  }

  /**
   * Учитывает точку `points[i]` в аккумуляторе: длина (Haversine от
   * предыдущей точки), мин/макс высот и набор/сброс с фильтрацией шума
   * < 2 м — мелкие колебания не сдвигают опорную высоту (TRZ §5.2,
   * тест U-12).
   * @param {object} acc - аккумулятор `makeStatsAccumulator()`.
   * @param {Array<number[]>} points - точки трека.
   * @param {number} i - индекс обрабатываемой точки.
   */
  function accumulatePoint(acc, points, i) {
    if (i > 0) acc.distanceKm += haversineKm(points[i - 1], points[i]);
    var e = points[i][2];
    if (e === null || e === undefined || isNaN(e)) return;
    acc.hasEle = true;
    if (e < acc.eleMin) acc.eleMin = e;
    if (e > acc.eleMax) acc.eleMax = e;
    if (acc.ref === null) { acc.ref = e; return; }
    var d = e - acc.ref;
    if (Math.abs(d) >= ELE_NOISE_M) {
      if (d > 0) acc.elevGain += d; else acc.elevLoss += -d;
      acc.ref = e;
    }
  }

  /**
   * Сворачивает аккумулятор в итоговый объект статистики. При отсутствии
   * данных высот поля высот — `null` (TRZ REQ-F-11; тесты U-05, U-14).
   * @param {object} acc
   * @returns {{distanceKm:number, elevGain:?number, elevLoss:?number,
   *            eleMin:?number, eleMax:?number}}
   */
  function finalizeStats(acc) {
    if (!acc.hasEle) {
      return {
        distanceKm: acc.distanceKm, elevGain: null, elevLoss: null,
        eleMin: null, eleMax: null,
      };
    }
    return {
      distanceKm: acc.distanceKm,
      elevGain: Math.round(acc.elevGain),
      elevLoss: Math.round(acc.elevLoss),
      eleMin: Math.round(acc.eleMin),
      eleMax: Math.round(acc.eleMax),
    };
  }

  /**
   * Считает статистику трека по массиву точек [lon, lat, ele, time]
   * однопроходно (синхронно).
   *
   * Длина — сумма Haversine-сегментов. Набор/сброс высот — сумма дельт
   * `ele` с фильтрацией шума < 2 м (TRZ §5.2). При отсутствии данных
   * высот поля высот возвращают `null` (TRZ REQ-F-11; тесты U-05, U-14).
   *
   * @param {Array<number[]>} points - точки трека.
   * @returns {{distanceKm:number, elevGain:?number, elevLoss:?number,
   *            eleMin:?number, eleMax:?number}}
   */
  function trackStats(points) {
    var acc = makeStatsAccumulator();
    for (var i = 0; i < points.length; i++) accumulatePoint(acc, points, i);
    return finalizeStats(acc);
  }

  /**
   * Считает статистику трека чанками, отдавая управление event loop между
   * порциями. Реализует ADR-003 §2 («расчёт статистики — чанками»);
   * применяется на пути асинхронного парсинга больших файлов (ревью P2-2).
   *
   * @param {Array<number[]>} points - точки трека.
   * @returns {Promise<{distanceKm:number, elevGain:?number,
   *            elevLoss:?number, eleMin:?number, eleMax:?number}>}
   */
  function trackStatsChunked(points) {
    return new Promise(function (resolve) {
      var acc = makeStatsAccumulator();
      var i = 0;
      function run() {
        var end = Math.min(i + CHUNK_SIZE, points.length);
        for (; i < end; i++) accumulatePoint(acc, points, i);
        if (i < points.length) { setTimeout(run, 0); return; }
        resolve(finalizeStats(acc));
      }
      run();
    });
  }

  /**
   * Агрегирует статистику всех треков файла в одну сводку.
   *
   * Активная сущность панели — файл (TRZ REQ-F-09, AC-02), но файл может
   * содержать несколько `<trk>` (REQ-F-02). Длина и набор/сброс — суммы
   * по трекам; мин/макс — экстремумы по трекам. Набор/сброс считаются в
   * каждом треке отдельно и суммируются (а не по сквозному потоку точек):
   * так скачок высоты на стыке треков не даёт ложный набор/сброс. Если
   * ни у одного трека нет высот — поля высот `null` (ревью P2-1).
   *
   * @param {Array<{stats:object}>} tracks - треки файла.
   * @returns {{distanceKm:number, elevGain:?number, elevLoss:?number,
   *            eleMin:?number, eleMax:?number}}
   */
  function aggregateStats(tracks) {
    var distanceKm = 0;
    var elevGain = null, elevLoss = null, eleMin = null, eleMax = null;
    tracks.forEach(function (t) {
      var s = t.stats;
      if (!s) return;
      distanceKm += s.distanceKm || 0;
      if (s.elevGain === null || s.elevGain === undefined) return;
      elevGain = (elevGain === null ? 0 : elevGain) + s.elevGain;
      elevLoss = (elevLoss === null ? 0 : elevLoss) + s.elevLoss;
      eleMin = (eleMin === null) ? s.eleMin : Math.min(eleMin, s.eleMin);
      eleMax = (eleMax === null) ? s.eleMax : Math.max(eleMax, s.eleMax);
    });
    return {
      distanceKm: distanceKm, elevGain: elevGain, elevLoss: elevLoss,
      eleMin: eleMin, eleMax: eleMax,
    };
  }

  /**
   * Возвращает цвет палитры для файла с указанным индексом (циклически).
   * @param {number} index - порядковый индекс файла на момент добавления.
   * @returns {string} hex-цвет (TRZ §5.3; тесты U-20, U-21).
   */
  function colorForIndex(index) {
    return PALETTE[((index % PALETTE.length) + PALETTE.length) % PALETTE.length];
  }

  /**
   * Строит GeoJSON FeatureCollection из треков файла (по линии на трек).
   * @param {Array<{points:Array<number[]>}>} tracks - треки файла.
   * @returns {object} GeoJSON FeatureCollection с LineString-фичами.
   */
  function tracksToGeoJSON(tracks) {
    return {
      type: 'FeatureCollection',
      features: tracks.map(function (t, idx) {
        return {
          type: 'Feature',
          properties: { trackIndex: idx },
          geometry: {
            type: 'LineString',
            coordinates: t.points.map(function (p) { return [p[0], p[1]]; }),
          },
        };
      }),
    };
  }

  /**
   * Строит GeoJSON FeatureCollection из waypoints файла.
   * @param {Array<{lon:number, lat:number, name:?string}>} waypoints
   * @returns {object} GeoJSON FeatureCollection с Point-фичами.
   */
  function waypointsToGeoJSON(waypoints) {
    return {
      type: 'FeatureCollection',
      features: waypoints.map(function (w) {
        return {
          type: 'Feature',
          properties: { name: w.name || '' },
          geometry: { type: 'Point', coordinates: [w.lon, w.lat] },
        };
      }),
    };
  }

  /**
   * Считает bbox всех точек файла (треки + waypoints).
   * @param {object} file - элемент модели window.gpxTracks.
   * @returns {?Array<number[]>} [[minLon,minLat],[maxLon,maxLat]] или null.
   */
  function fileBounds(file) {
    var minLon = Infinity, minLat = Infinity;
    var maxLon = -Infinity, maxLat = -Infinity;
    var seen = false;

    function extend(lon, lat) {
      seen = true;
      if (lon < minLon) minLon = lon;
      if (lat < minLat) minLat = lat;
      if (lon > maxLon) maxLon = lon;
      if (lat > maxLat) maxLat = lat;
    }

    file.tracks.forEach(function (t) {
      t.points.forEach(function (p) { extend(p[0], p[1]); });
    });
    file.waypoints.forEach(function (w) { extend(w.lon, w.lat); });

    return seen ? [[minLon, minLat], [maxLon, maxLat]] : null;
  }

  // ─── Парсинг GPX ─────────────────────────────────────────────────────────

  /**
   * Преобразует HTMLCollection / NodeList в массив.
   * @param {*} collection
   * @returns {Array}
   */
  function toArray(collection) {
    var arr = [];
    if (!collection) return arr;
    for (var i = 0; i < collection.length; i++) arr.push(collection[i]);
    return arr;
  }

  /**
   * Возвращает текст первого элемента-потомка с указанным тегом.
   * Поиск ведётся по всем потомкам (`getElementsByTagName`), не только
   * по прямым детям — для структуры GPX это безопасно.
   * @param {Element} parent
   * @param {string} tag
   * @returns {string}
   */
  function firstTagText(parent, tag) {
    var els = parent.getElementsByTagName(tag);
    return els.length ? String(els[0].textContent || '').trim() : '';
  }

  /**
   * Конвертирует элемент точки (`<trkpt>` / `<rtept>`) в кортеж модели.
   * @param {Element} el
   * @returns {Array} [lon, lat, ele|null, time|null]
   */
  function pointFromEl(el) {
    var lat = parseFloat(el.getAttribute('lat'));
    var lon = parseFloat(el.getAttribute('lon'));
    var eleEls = el.getElementsByTagName('ele');
    var ele = eleEls.length ? parseFloat(eleEls[0].textContent) : NaN;
    var timeEls = el.getElementsByTagName('time');
    var time = timeEls.length ? String(timeEls[0].textContent || '').trim() : '';
    return [lon, lat, isNaN(ele) ? null : ele, time || null];
  }

  /**
   * Конвертирует элемент `<wpt>` в объект waypoint.
   * @param {Element} el
   * @returns {{lon:number, lat:number, name:?string, ele:?number}}
   */
  function waypointFromEl(el) {
    var name = firstTagText(el, 'name');
    var eleEls = el.getElementsByTagName('ele');
    var ele = eleEls.length ? parseFloat(eleEls[0].textContent) : NaN;
    return {
      lon: parseFloat(el.getAttribute('lon')),
      lat: parseFloat(el.getAttribute('lat')),
      name: name || null,
      ele: isNaN(ele) ? null : ele,
    };
  }

  /** Точка валидна, если координаты — конечные числа. */
  function isValidPoint(p) {
    return isFinite(p[0]) && isFinite(p[1]);
  }

  /** Waypoint валиден, если координаты — конечные числа. */
  function isValidWaypoint(w) {
    return isFinite(w.lon) && isFinite(w.lat);
  }

  /**
   * Собирает «сырые» треки документа: `<trk>` (со всеми `<trkseg>`) и
   * `<rte>` (трактуется как трек — TRZ REQ-F-02). Возвращает имена и
   * массивы элементов точек, не конвертируя их (для чанковой обработки).
   * @param {Document} doc
   * @returns {Array<{name:string, ptEls:Element[]}>}
   */
  function collectRawTracks(doc) {
    var result = [];
    var trks = doc.getElementsByTagName('trk');
    var i, j, k;
    for (i = 0; i < trks.length; i++) {
      var trk = trks[i];
      var ptEls = [];
      var segs = trk.getElementsByTagName('trkseg');
      for (j = 0; j < segs.length; j++) {
        var tps = segs[j].getElementsByTagName('trkpt');
        for (k = 0; k < tps.length; k++) ptEls.push(tps[k]);
      }
      result.push({ name: firstTagText(trk, 'name') || ('Трек ' + (i + 1)), ptEls: ptEls });
    }
    var rtes = doc.getElementsByTagName('rte');
    for (i = 0; i < rtes.length; i++) {
      var rte = rtes[i];
      var rps = toArray(rte.getElementsByTagName('rtept'));
      result.push({ name: firstTagText(rte, 'name') || ('Маршрут ' + (i + 1)), ptEls: rps });
    }
    return result;
  }

  /**
   * Извлекает модель GPX из DOM-документа синхронно.
   *
   * Использует не-namespace-aware `getElementsByTagName`, поэтому
   * корректно обрабатывает и документы с `xmlns`, и без него
   * (TRZ REQ-F-02, тест U-08).
   *
   * @param {Document} doc - распарсенный XML-документ.
   * @returns {{tracks:Array, waypoints:Array}}
   */
  function extractGpxModel(doc) {
    var tracks = collectRawTracks(doc).map(function (raw) {
      var points = raw.ptEls.map(pointFromEl).filter(isValidPoint);
      return { name: raw.name, points: points, stats: trackStats(points) };
    });
    var waypoints = toArray(doc.getElementsByTagName('wpt'))
      .map(waypointFromEl).filter(isValidWaypoint);
    return { tracks: tracks, waypoints: waypoints };
  }

  /**
   * Обрабатывает массив порциями, отдавая управление event loop между
   * чанками (ADR-003) — сохраняет отзывчивость UI на больших файлах.
   * @param {Array} items
   * @param {Function} fn - преобразование элемента.
   * @returns {Promise<Array>}
   */
  function mapChunked(items, fn) {
    return new Promise(function (resolve) {
      if (items.length === 0) { resolve([]); return; }
      var out = [];
      var i = 0;
      function run() {
        var end = Math.min(i + CHUNK_SIZE, items.length);
        for (; i < end; i++) out.push(fn(items[i]));
        if (i < items.length) setTimeout(run, 0);
        else resolve(out);
      }
      run();
    });
  }

  /**
   * Извлекает модель GPX из DOM-документа чанками (асинхронно).
   * @param {Document} doc
   * @returns {Promise<{tracks:Array, waypoints:Array}>}
   */
  function extractGpxModelChunked(doc) {
    var raw = collectRawTracks(doc);
    var tracks = [];

    function nextTrack(idx) {
      if (idx >= raw.length) {
        return mapChunked(toArray(doc.getElementsByTagName('wpt')), waypointFromEl)
          .then(function (wpts) {
            return { tracks: tracks, waypoints: wpts.filter(isValidWaypoint) };
          });
      }
      return mapChunked(raw[idx].ptEls, pointFromEl).then(function (pts) {
        var points = pts.filter(isValidPoint);
        // Расчёт статистики — тоже чанками (ADR-003 §2; ревью P2-2).
        return trackStatsChunked(points).then(function (stats) {
          tracks.push({ name: raw[idx].name, points: points, stats: stats });
          return nextTrack(idx + 1);
        });
      });
    }

    return nextTrack(0);
  }

  /**
   * Парсит XML-текст в документ, проверяя корректность и GPX-корень.
   * @param {string} text
   * @returns {Document}
   * @throws {Error} 'PARSE_ERROR' — невалидный XML или не-GPX документ.
   */
  function parseXmlDoc(text) {
    var doc;
    try {
      doc = new DOMParser().parseFromString(text, 'application/xml');
    } catch (e) {
      throw new Error('PARSE_ERROR');
    }
    if (!doc || doc.getElementsByTagName('parsererror').length > 0) {
      throw new Error('PARSE_ERROR');
    }
    var root = doc.documentElement;
    if (!root || String(root.tagName || root.nodeName || '').toLowerCase() !== 'gpx') {
      throw new Error('PARSE_ERROR');
    }
    return doc;
  }

  /**
   * Синхронно парсит GPX-текст во внутреннюю модель.
   * @param {string} text - содержимое GPX-файла.
   * @returns {{tracks:Array, waypoints:Array}}
   * @throws {Error} 'PARSE_ERROR' — невалидный GPX; 'EMPTY' — нет данных.
   */
  function parseGpxText(text) {
    var model = extractGpxModel(parseXmlDoc(text));
    if (model.tracks.length === 0 && model.waypoints.length === 0) {
      throw new Error('EMPTY');
    }
    return model;
  }

  /**
   * Асинхронно парсит GPX-текст во внутреннюю модель чанками (ADR-003).
   *
   * `DOMParser.parseFromString` атомарен и выполняется в основном потоке;
   * доминирующая по времени конвертация DOM → модель идёт чанками, что
   * сохраняет отзывчивость UI и анимацию индикатора загрузки.
   *
   * @param {string} text - содержимое GPX-файла.
   * @returns {Promise<{tracks:Array, waypoints:Array}>}
   */
  function parseGpxAsync(text) {
    return new Promise(function (resolve, reject) {
      var raf = (typeof requestAnimationFrame !== 'undefined')
        ? requestAnimationFrame
        : function (cb) { return setTimeout(cb, 0); };
      // Отдаём кадр, чтобы индикатор загрузки успел отрисоваться.
      raf(function () {
        var doc;
        try {
          doc = parseXmlDoc(text);
        } catch (e) {
          reject(e);
          return;
        }
        extractGpxModelChunked(doc).then(function (model) {
          if (model.tracks.length === 0 && model.waypoints.length === 0) {
            reject(new Error('EMPTY'));
          } else {
            resolve(model);
          }
        }).catch(function () {
          reject(new Error('PARSE_ERROR'));
        });
      });
    });
  }

  // ─── Toast-уведомления (TRZ §3.4, ADR-002) ───────────────────────────────

  /**
   * Показывает toast-уведомление вверху экрана (автоскрытие — 4 с).
   * Переиспользуемый хелпер: не alert/confirm (TRZ REQ-NF-03).
   * @param {string} message
   */
  function showToast(message) {
    var el = document.getElementById('app-toast');
    if (!el) return;
    el.textContent = message;
    el.classList.add('visible');
    if (showToast._timer) clearTimeout(showToast._timer);
    showToast._timer = setTimeout(function () {
      el.classList.remove('visible');
    }, 4000);
  }

  /**
   * Показывает/скрывает индикатор парсинга GPX (TRZ REQ-NF-01, AC-11).
   * @param {boolean} on
   */
  function setLoading(on) {
    var el = document.getElementById('gpx-loading');
    if (el) el.classList.toggle('visible', !!on);
  }

  // ─── Рендеринг на карте ──────────────────────────────────────────────────

  /**
   * Возвращает id слоя, ПЕРЕД которым вставлять GPX-слои, чтобы они были
   * ниже маршрута OSRM, но выше базовых слоёв (TRZ REQ-F-04; тест I-06).
   * @param {object} map - экземпляр MapLibre.
   * @returns {(string|undefined)}
   */
  function gpxBeforeId(map) {
    for (var i = 0; i < ROUTE_BASE_LAYERS.length; i++) {
      if (map.getLayer(ROUTE_BASE_LAYERS[i])) return ROUTE_BASE_LAYERS[i];
    }
    return undefined;
  }

  /**
   * Снимает обработчики событий карты, навешанные для файла.
   * @param {object} map
   * @param {string} fileId
   */
  function clearMapHandlers(map, fileId) {
    var h = mapHandlers[fileId];
    if (!h) return;
    try { map.off('click', h.layerId, h.click); } catch (e) { /* слой удалён */ }
    try { map.off('mouseenter', h.layerId, h.enter); } catch (e) { /* слой удалён */ }
    try { map.off('mouseleave', h.layerId, h.leave); } catch (e) { /* слой удалён */ }
    delete mapHandlers[fileId];
  }

  /**
   * Удаляет с карты слои, источники и обработчики файла (TRZ REQ-F-08).
   * @param {object} file
   */
  function removeFileLayers(file) {
    var map = global._map;
    if (!map) return;
    clearMapHandlers(map, file.id);
    var layers = [
      file.layerId, file.waypointLayerId, file.waypointLayerId + '-label',
    ];
    layers.forEach(function (id) {
      try { if (map.getLayer(id)) map.removeLayer(id); } catch (e) { /* нет слоя */ }
    });
    [file.sourceId, file.sourceId + '-wpt'].forEach(function (id) {
      try { if (map.getSource(id)) map.removeSource(id); } catch (e) { /* нет источника */ }
    });
  }

  /**
   * Добавляет (или пересоздаёт) слои файла на карте: линия трека,
   * waypoints-маркеры (circle + symbol). GPX-слои встают ниже маршрута
   * OSRM (TRZ REQ-F-04, REQ-F-05).
   * @param {object} file
   */
  function addFileLayers(file) {
    var map = global._map;
    if (!map) return;
    removeFileLayers(file);

    var before = gpxBeforeId(map);

    map.addSource(file.sourceId, {
      type: 'geojson',
      data: tracksToGeoJSON(file.tracks),
    });
    map.addLayer({
      id: file.layerId,
      type: 'line',
      source: file.sourceId,
      layout: { 'line-cap': 'round', 'line-join': 'round' },
      paint: {
        'line-color': file.color,
        'line-width': 4,
        'line-opacity': 0.85,
      },
    }, before);

    var onClick = function () { selectGpxTrack(file.id); };
    var onEnter = function () { map.getCanvas().style.cursor = 'pointer'; };
    var onLeave = function () { map.getCanvas().style.cursor = ''; };
    map.on('click', file.layerId, onClick);
    map.on('mouseenter', file.layerId, onEnter);
    map.on('mouseleave', file.layerId, onLeave);
    mapHandlers[file.id] = {
      layerId: file.layerId, click: onClick, enter: onEnter, leave: onLeave,
    };

    if (file.waypoints.length > 0) {
      map.addSource(file.sourceId + '-wpt', {
        type: 'geojson',
        data: waypointsToGeoJSON(file.waypoints),
      });
      map.addLayer({
        id: file.waypointLayerId,
        type: 'circle',
        source: file.sourceId + '-wpt',
        paint: {
          'circle-radius': 5,
          'circle-color': file.color,
          'circle-stroke-color': '#ffffff',
          'circle-stroke-width': 2,
          'circle-opacity': 0.95,
        },
      }, before);
      map.addLayer({
        id: file.waypointLayerId + '-label',
        type: 'symbol',
        source: file.sourceId + '-wpt',
        layout: {
          'text-field': ['get', 'name'],
          'text-font': ['Open Sans Regular'],
          'text-size': 11,
          'text-offset': [0, 1.1],
          'text-anchor': 'top',
          'text-optional': true,
        },
        paint: {
          'text-color': file.color,
          'text-halo-color': '#ffffff',
          'text-halo-width': 1.5,
        },
      }, before);
    }
  }

  /**
   * Пересоздаёт все GPX-слои после смены стиля карты (TRZ REQ-F-13).
   *
   * Вызывается из `rebuildMapOverlays()` (app.js) по событию `idle`
   * после `map.setStyle()`. Данные (`window.gpxTracks`, активный трек)
   * не теряются — пересоздаются только объекты карты.
   */
  function rebuildGpxOverlays() {
    if (!global._map) return;
    global.gpxTracks.forEach(addFileLayers);
    renderGpxList();
  }

  // ─── Загрузка и обработка файлов ─────────────────────────────────────────

  /**
   * Обработчик `onchange` для `<input type="file">` (TRZ REQ-F-01).
   * @param {HTMLInputElement} input
   */
  function onGpxFileSelected(input) {
    if (input && input.files && input.files.length) {
      handleGpxFiles(input.files);
    }
    if (input) input.value = '';
  }

  /**
   * Загружает, валидирует и парсит выбранные GPX-файлы (TRZ REQ-F-01..F-07).
   *
   * Файлы обрабатываются последовательно; `fitBounds` выполняется только
   * по последнему успешно загруженному файлу (TRZ REQ-F-06).
   *
   * @param {FileList|File[]} fileList
   */
  function handleGpxFiles(fileList) {
    var files = toArray(fileList);
    if (files.length === 0) return;

    var wasEmpty = global.gpxTracks.length === 0;
    setLoading(true);

    var lastAdded = null;
    var idx = 0;

    function done() {
      setLoading(false);
      renderGpxList();
      if (wasEmpty && global.gpxTracks.length > 0) {
        if (typeof openSheet === 'function') openSheet('sheet-gpx');
        syncToolbarButton(true);
      }
      if (lastAdded) {
        selectGpxTrack(lastAdded.id);
        fitToFile(lastAdded);
      }
    }

    function next() {
      if (idx >= files.length) { done(); return; }
      var file = files[idx++];

      if (file.size > MAX_FILE_BYTES) {
        showToast('Файл слишком большой (макс. 50 МБ)');
        next();
        return;
      }

      var reader = new FileReader();
      reader.onload = function () {
        parseGpxAsync(String(reader.result || '')).then(function (model) {
          var added = addParsedFile(file.name, model);
          lastAdded = added;
          next();
        }).catch(function (err) {
          var msg = (err && err.message === 'EMPTY')
            ? 'GPX-файл не содержит данных'
            : 'Не удалось прочитать GPX-файл';
          showToast(msg);
          next();
        });
      };
      reader.onerror = function () {
        showToast('Не удалось прочитать GPX-файл');
        next();
      };
      reader.readAsText(file);
    }

    next();
  }

  /**
   * Создаёт элемент модели по распарсенному GPX и отрисовывает его на карте.
   * @param {string} filename - имя файла (с расширением).
   * @param {{tracks:Array, waypoints:Array}} model
   * @returns {object} созданный элемент window.gpxTracks.
   */
  function addParsedFile(filename, model) {
    var ts = Date.now() + '-' + Math.floor(Math.random() * 1e6);
    var color = colorForIndex(global.gpxTracks.length);
    var file = {
      id: 'gpx-' + ts,
      filename: stripExtension(filename),
      color: color,
      tracks: model.tracks,
      waypoints: model.waypoints,
      sourceId: 'gpx-source-' + ts,
      layerId: 'gpx-layer-' + ts,
      waypointLayerId: 'gpx-wpt-' + ts,
    };
    global.gpxTracks.push(file);
    addFileLayers(file);
    return file;
  }

  /**
   * Убирает расширение `.gpx` из имени файла.
   * @param {string} name
   * @returns {string}
   */
  function stripExtension(name) {
    return String(name || 'track').replace(/\.gpx$/i, '');
  }

  /**
   * Центрирует карту по bbox файла с отступом 50px (TRZ REQ-F-06).
   * @param {object} file
   */
  function fitToFile(file) {
    var map = global._map;
    if (!map || typeof maplibregl === 'undefined') return;
    var b = fileBounds(file);
    if (!b) return;
    map.fitBounds(new maplibregl.LngLatBounds(b[0], b[1]), {
      padding: 50,
      duration: 800,
    });
  }

  /**
   * Удаляет загруженный трек по id (TRZ REQ-F-08).
   * @param {string} id
   */
  function removeGpxTrack(id) {
    var i = global.gpxTracks.findIndex(function (f) { return f.id === id; });
    if (i === -1) return;
    removeFileLayers(global.gpxTracks[i]);
    global.gpxTracks.splice(i, 1);
    // Удаление активного трека скрывает статистику и профиль (REQ-F-08);
    // соседний трек НЕ выбирается автоматически (AC-05).
    if (activeGpxId === id) {
      activeGpxId = null;
      hideCursorMarker();
    }
    renderGpxList();
  }

  // ─── Bottom sheet: список треков ─────────────────────────────────────────

  /**
   * Переключает видимость панели управления GPX-треками (TRZ REQ-F-09).
   */
  function toggleGpxSheet() {
    var sheet = document.getElementById('sheet-gpx');
    if (!sheet) return;
    if (sheet.classList.contains('open')) {
      if (typeof closeSheet === 'function') closeSheet('sheet-gpx');
      syncToolbarButton(false);
    } else {
      renderGpxList();
      if (typeof openSheet === 'function') openSheet('sheet-gpx');
      syncToolbarButton(true);
    }
  }

  /**
   * Синхронизирует подсветку кнопки «GPX» в нижнем тулбаре.
   * @param {boolean} active
   */
  function syncToolbarButton(active) {
    var btn = document.getElementById('tb-gpx');
    if (btn) btn.classList.toggle('active', !!active);
  }

  /**
   * Перерисовывает список загруженных треков и блок деталей (TRZ REQ-F-09).
   */
  function renderGpxList() {
    var listEl = document.getElementById('gpx-list');
    var emptyEl = document.getElementById('gpx-empty');
    var detailEl = document.getElementById('gpx-detail');
    if (!listEl) return;

    var tracks = global.gpxTracks;
    if (emptyEl) emptyEl.style.display = tracks.length ? 'none' : 'block';

    listEl.innerHTML = tracks.map(function (f) {
      var isActive = f.id === activeGpxId;
      return '<div class="gpx-row' + (isActive ? ' active' : '') +
        '" onclick="selectGpxTrack(\'' + f.id + '\')">' +
        '<span class="gpx-dot" style="background:' + f.color + '"></span>' +
        '<span class="gpx-name">' + escapeHtml(f.filename) + '</span>' +
        (isActive ? '<span class="gpx-check">✓</span>' : '') +
        '<button class="gpx-del" title="Удалить трек" ' +
        'onclick="event.stopPropagation();removeGpxTrack(\'' + f.id + '\')">✕</button>' +
        '</div>';
    }).join('');

    var active = getActiveFile();
    if (detailEl) detailEl.style.display = active ? 'block' : 'none';
    if (active) {
      renderStats(active);
      renderElevationProfile(active);
    } else {
      hideCursorMarker();
    }
  }

  /**
   * Делает трек активным: подсветка в списке, статистика, профиль высот
   * (TRZ REQ-F-09, REQ-F-12; AC-06, AC-09).
   * @param {string} id
   */
  function selectGpxTrack(id) {
    if (!global.gpxTracks.some(function (f) { return f.id === id; })) return;
    activeGpxId = id;
    renderGpxList();
    var sheet = document.getElementById('sheet-gpx');
    if (sheet && !sheet.classList.contains('open') && typeof openSheet === 'function') {
      openSheet('sheet-gpx');
      syncToolbarButton(true);
    }
  }

  /**
   * Возвращает активный файл модели или null.
   * @returns {?object}
   */
  function getActiveFile() {
    return global.gpxTracks.find(function (f) { return f.id === activeGpxId; }) || null;
  }

  /**
   * Экранирует HTML-спецсимволы в пользовательской строке.
   * @param {string} s
   * @returns {string}
   */
  function escapeHtml(s) {
    return String(s).replace(/[&<>"']/g, function (c) {
      return {
        '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
      }[c];
    });
  }

  // ─── Статистика трека ────────────────────────────────────────────────────

  /**
   * Форматирует расстояние (км). Если доступен модуль ET-005 `Units` —
   * делегирует ему (учёт выбора км/мили); иначе — километры.
   * @param {number} km
   * @returns {string}
   */
  function formatKm(km) {
    if (typeof Units !== 'undefined' && Units && typeof Units.formatDistance === 'function') {
      return Units.formatDistance(km * 1000);
    }
    return km.toFixed(1) + ' км';
  }

  /**
   * Отрисовывает компактную сетку статистики активного файла —
   * сводно по всем его трекам (TRZ REQ-F-11; AC-08; ревью P2-1).
   * @param {object} file
   */
  function renderStats(file) {
    var el = document.getElementById('gpx-stats');
    if (!el) return;
    // Сводка по всем трекам файла, а не только tracks[0] (ревью P2-1).
    var st = file.tracks.length ? aggregateStats(file.tracks) : null;
    var hasEle = st && st.elevGain !== null;

    var cells = [
      { v: st ? formatKm(st.distanceKm) : '—', l: 'длина' },
      { v: hasEle ? st.elevGain + ' м' : '—', l: 'набор' },
      { v: hasEle ? st.elevLoss + ' м' : '—', l: 'сброс' },
      { v: hasEle ? st.eleMin + ' м' : '—', l: 'мин' },
      { v: hasEle ? st.eleMax + ' м' : '—', l: 'макс' },
    ];

    el.innerHTML = cells.map(function (c) {
      return '<div class="gpx-stat">' +
        '<div class="gpx-stat-val">' + c.v + '</div>' +
        '<div class="gpx-stat-lbl">' + c.l + '</div></div>';
    }).join('');
  }

  // ─── Профиль высот (canvas) ──────────────────────────────────────────────

  /**
   * Отрисовывает canvas-профиль высот активного файла (по всем его
   * трекам) и подключает интерактивность (tooltip + маркер-курсор на
   * карте) — TRZ REQ-F-10; ревью P2-1.
   * @param {object} file
   */
  function renderElevationProfile(file) {
    var canvas = document.getElementById('gpx-elevation-canvas');
    var emptyEl = document.getElementById('gpx-elevation-empty');
    var axisEl = document.getElementById('gpx-elevation-axis');
    if (!canvas) return;

    var samples = buildFileProfileSamples(file);

    if (samples.length < 2) {
      canvas.style.display = 'none';
      if (axisEl) axisEl.style.display = 'none';
      if (emptyEl) emptyEl.style.display = 'block';
      profileState = null;
      return;
    }

    canvas.style.display = 'block';
    if (emptyEl) emptyEl.style.display = 'none';
    if (axisEl) axisEl.style.display = 'flex';

    var wrap = canvas.parentElement;
    var cssW = (wrap && wrap.clientWidth) ? wrap.clientWidth : 320;
    var dpr = global.devicePixelRatio || 1;
    canvas.width = cssW * dpr;
    canvas.height = PROFILE_HEIGHT * dpr;
    canvas.style.width = cssW + 'px';
    canvas.style.height = PROFILE_HEIGHT + 'px';

    var ctx = canvas.getContext('2d');
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    ctx.clearRect(0, 0, cssW, PROFILE_HEIGHT);

    var totalKm = samples[samples.length - 1].d;
    var minE = Infinity, maxE = -Infinity;
    samples.forEach(function (s) {
      if (s.e < minE) minE = s.e;
      if (s.e > maxE) maxE = s.e;
    });
    var pad = 8;
    var spanE = (maxE - minE) || 1;

    function xOf(d) { return pad + (d / totalKm) * (cssW - 2 * pad); }
    function yOf(e) {
      return PROFILE_HEIGHT - pad - ((e - minE) / spanE) * (PROFILE_HEIGHT - 2 * pad);
    }

    // Заливка под линией — цвет трека с пониженной прозрачностью.
    ctx.beginPath();
    ctx.moveTo(xOf(samples[0].d), PROFILE_HEIGHT - pad);
    samples.forEach(function (s) { ctx.lineTo(xOf(s.d), yOf(s.e)); });
    ctx.lineTo(xOf(totalKm), PROFILE_HEIGHT - pad);
    ctx.closePath();
    ctx.globalAlpha = 0.2;
    ctx.fillStyle = file.color;
    ctx.fill();
    ctx.globalAlpha = 1;

    // Линия профиля — цвет трека.
    ctx.beginPath();
    samples.forEach(function (s, i) {
      var x = xOf(s.d), y = yOf(s.e);
      if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
    });
    ctx.strokeStyle = file.color;
    ctx.lineWidth = 2;
    ctx.lineJoin = 'round';
    ctx.stroke();

    if (axisEl) {
      var spans = axisEl.getElementsByTagName('span');
      if (spans.length >= 3) {
        // Единицы — через formatKm, чтобы ось согласовалась со
        // статистикой при выборе миль в ET-005 (ревью P3-1).
        spans[0].textContent = formatKm(0);
        spans[1].textContent = formatKm(totalKm / 2);
        spans[2].textContent = formatKm(totalKm);
      }
    }

    profileState = {
      file: file, samples: samples, cssW: cssW,
      totalKm: totalKm, color: file.color,
    };
    attachProfileInteraction(canvas);
  }

  /**
   * Строит точки профиля {d: км от старта, e: высота, lon, lat} —
   * только для точек, у которых есть данные высот.
   * @param {object} track
   * @returns {Array<{d:number, e:number, lon:number, lat:number}>}
   */
  function buildProfileSamples(track) {
    var samples = [];
    var cum = 0;
    var pts = track.points;
    for (var i = 0; i < pts.length; i++) {
      if (i > 0) cum += haversineKm(pts[i - 1], pts[i]);
      var e = pts[i][2];
      if (e !== null && e !== undefined && !isNaN(e)) {
        samples.push({ d: cum, e: e, lon: pts[i][0], lat: pts[i][1] });
      }
    }
    return samples;
  }

  /**
   * Строит точки профиля высот для всего файла: треки склеиваются
   * последовательно, расстояние `d` — нарастающим итогом от старта
   * первого трека. Покрывает многотрековые файлы — профиль показывает
   * все треки файла, а не только tracks[0] (ревью P2-1).
   * @param {object} file - элемент модели window.gpxTracks.
   * @returns {Array<{d:number, e:number, lon:number, lat:number}>}
   */
  function buildFileProfileSamples(file) {
    var samples = [];
    var offset = 0;
    file.tracks.forEach(function (track) {
      buildProfileSamples(track).forEach(function (s) {
        samples.push({ d: s.d + offset, e: s.e, lon: s.lon, lat: s.lat });
      });
      offset += (track.stats && track.stats.distanceKm) || 0;
    });
    return samples;
  }

  /**
   * Навешивает обработчики наведения/тапа на canvas профиля: tooltip с
   * высотой и расстоянием + маркер-курсор на карте (TRZ REQ-F-10; AC-07).
   * @param {HTMLCanvasElement} canvas
   */
  function attachProfileInteraction(canvas) {
    function handleMove(clientX) {
      if (!profileState) return;
      var rect = canvas.getBoundingClientRect();
      var x = clientX - rect.left;
      var frac = Math.max(0, Math.min(1, x / profileState.cssW));
      var targetD = frac * profileState.totalKm;

      var samples = profileState.samples;
      var nearest = samples[0];
      var bestDelta = Infinity;
      for (var i = 0; i < samples.length; i++) {
        var delta = Math.abs(samples[i].d - targetD);
        if (delta < bestDelta) { bestDelta = delta; nearest = samples[i]; }
      }

      var tip = document.getElementById('gpx-elevation-tip');
      if (tip) {
        // Расстояние — через formatKm (учёт км/мили из ET-005, ревью P3-1).
        tip.textContent = Math.round(nearest.e) + ' м · ' + formatKm(nearest.d);
        tip.style.display = 'block';
        tip.style.left = Math.max(0, Math.min(profileState.cssW - 90, x - 45)) + 'px';
      }
      showCursorMarker(nearest.lon, nearest.lat, profileState.color);
    }

    function hide() {
      var tip = document.getElementById('gpx-elevation-tip');
      if (tip) tip.style.display = 'none';
      hideCursorMarker();
    }

    canvas.onmousemove = function (e) { handleMove(e.clientX); };
    canvas.onmouseleave = hide;
    canvas.ontouchstart = function (e) {
      if (e.touches.length) handleMove(e.touches[0].clientX);
    };
    canvas.ontouchmove = function (e) {
      if (e.touches.length) {
        handleMove(e.touches[0].clientX);
        e.preventDefault();
      }
    };
    canvas.ontouchend = hide;
  }

  /**
   * Показывает на карте маркер-курсор в указанной точке трека.
   * @param {number} lon
   * @param {number} lat
   * @param {string} color
   */
  function showCursorMarker(lon, lat, color) {
    var map = global._map;
    if (!map || typeof maplibregl === 'undefined') return;
    if (!cursorMarker) {
      var el = document.createElement('div');
      el.className = 'gpx-cursor-marker';
      cursorMarker = new maplibregl.Marker({ element: el, anchor: 'center' });
    }
    cursorMarker.getElement().style.background = color;
    cursorMarker.setLngLat([lon, lat]).addTo(map);
  }

  /** Скрывает маркер-курсор профиля высот с карты. */
  function hideCursorMarker() {
    if (cursorMarker) cursorMarker.remove();
  }

  // ─── Экспорт ─────────────────────────────────────────────────────────────

  /** Публичный контракт модуля. */
  var Gpx = {
    PALETTE: PALETTE,
    MAX_FILE_BYTES: MAX_FILE_BYTES,
    haversineKm: haversineKm,
    trackStats: trackStats,
    trackStatsChunked: trackStatsChunked,
    aggregateStats: aggregateStats,
    colorForIndex: colorForIndex,
    tracksToGeoJSON: tracksToGeoJSON,
    waypointsToGeoJSON: waypointsToGeoJSON,
    fileBounds: fileBounds,
    buildFileProfileSamples: buildFileProfileSamples,
    extractGpxModel: extractGpxModel,
    parseGpxText: parseGpxText,
    parseGpxAsync: parseGpxAsync,
  };

  // Браузер: глобальный неймспейс + inline-onclick-обработчики и хук
  // rebuildGpxOverlays / showToast (контракт интеграции — ADR-002).
  if (global) {
    global.Gpx = Gpx;
    global.onGpxFileSelected = onGpxFileSelected;
    global.handleGpxFiles = handleGpxFiles;
    global.toggleGpxSheet = toggleGpxSheet;
    global.selectGpxTrack = selectGpxTrack;
    global.removeGpxTrack = removeGpxTrack;
    global.rebuildGpxOverlays = rebuildGpxOverlays;
    if (typeof global.showToast !== 'function') global.showToast = showToast;
  }

  // Node: экспорт для изолированных unit-тестов.
  if (typeof module === 'object' && module.exports) {
    module.exports = Gpx;
  }
})(typeof window !== 'undefined' ? window : (typeof globalThis !== 'undefined' ? globalThis : null));
