// ═══════════════════════════════════════════════════════════════════
// gps_tracks.js — ET-008: Публичные GPS-треки
// ═══════════════════════════════════════════════════════════════════

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

const GPS_TRACKS_ZOOM_CUTOFF = 12;  // ниже — MVT, выше — GeoJSON
// ET-012 (ADR-016): порог понижен с 8 до 5, чтобы при обзорном зуме
// пользователь видел общее покрытие сети треков. Серверная сторона
// (build_gps_mvt z<=5 / z==6) даёт корректный размер MVT и читаемость.
const GPS_TRACKS_MIN_ZOOM = 5;       // ниже — слой скрыт

const GPS_SOURCE_COLORS = {
  osm:           '#3cb44b',
  enduro_russia: '#e6194b',
  wikiloc:       '#4363d8',
  ttrails:       '#911eb4',
  offmaps:       '#f58231',
  nakarte:       '#f032e6',
};
const GPS_FALLBACK_COLORS = ['#42d4f4', '#f032e6', '#bfef45', '#fabed4', '#469990', '#dcbeff', '#9A6324', '#fffac8'];

// ET-009: атрибуция для каждого источника. Используется при сборке
// MapLibre attribution control: к строке source-attribution добавляются
// все источники, у которых tracks_by_source > 0.
const GPS_SOURCE_ATTRIBUTIONS = {
  osm:           '© OpenStreetMap contributors (ODbL)',
  enduro_russia: 'EnduroRussia.ru',
  wikiloc:       '© Wikiloc contributors',
  ttrails:       'ttrails.ru',
};

// ET-009 (ADR-013 §3 Решение D, опция D2): маппинг source_id → human label.
// Используется для построения списка чекбоксов в фильтре источников.
// Источники подтягиваются динамически из /api/gps-tracks/health, а лейбл
// берётся отсюда; при отсутствии source_id в этом маппинге используется сам id.
const GPS_SOURCE_LABELS = {
  osm:           'OSM',
  enduro_russia: 'EnduroRussia',
  wikiloc:       'Wikiloc',
  ttrails:       'Тропинки.ру',
};

// Fallback-список источников при сетевой ошибке /health (показываем все
// потенциально доступные источники, чтобы UI не оставался пустым).
const GPS_FALLBACK_SOURCES = ['osm', 'enduro_russia', 'wikiloc', 'ttrails'];

const GPS_ACTIVITY_COLORS = {
  enduro:  '#e6194b',
  moto:    '#f58231',
  offroad: '#ffe119',
  bicycle: '#3cb44b',
  hike:    '#4363d8',
  ski:     '#42d4f4',
  other:   '#808080',
};

const GPS_ACTIVITY_ICONS = {
  enduro:  '🏍',
  moto:    '🛵',
  offroad: '🚙',
  bicycle: '🚵',
  hike:    '🥾',
  ski:     '⛷️',
  other:   '📍',
};

const GPS_ACTIVITY_LABELS = {
  enduro:  'Эндуро',
  moto:    'Мото',
  offroad: 'Off-road',
  bicycle: 'Велосипед',
  hike:    'Пешком',
  ski:     'Лыжи',
  other:   'Другое',
};

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

window.gpsTracksLayer = {
  enabled: false,
  filters: {
    activities: ['enduro', 'moto', 'offroad', 'bicycle', 'hike', 'ski', 'other'],
    sources: ['osm', 'enduro_russia', 'wikiloc', 'ttrails'],
    colorMode: 'source'
  },
  sourceId: 'gps-tracks-tiles',
  sourceGeoId: 'gps-tracks-geo',
  layerId: 'gps-tracks-layer-mvt',
  layerGeoId: 'gps-tracks-layer-geo',
  layerHaloId: 'gps-tracks-halo-mvt-satellite',
  layerHaloGeoId: 'gps-tracks-halo-geo-satellite',
  geojsonAbortController: null,
  geojsonReqDebounceTimer: null,
  stats: { total: 0, shown: 0 },
  // ET-009 (F-01/F-02 fix): cached /api/gps-tracks/health response.
  // Populated by _fetchGpsHealth; используется и для атрибуции (передаётся
  // в addSource), и для построения динамического списка чекбоксов источников.
  _healthCache: null,
  _healthFetchPromise: null,
};

// ─── Цветовые выражения MapLibre ──────────────────────────────────

function _buildColorExpression(mode) {
  if (mode === 'activity') {
    const expr = ['match', ['get', 'activity']];
    for (const [act, color] of Object.entries(GPS_ACTIVITY_COLORS)) {
      expr.push(act, color);
    }
    expr.push('#808080'); // fallback
    return expr;
  } else {
    // по источнику
    const expr = ['match', ['get', 'source']];
    for (const [src, color] of Object.entries(GPS_SOURCE_COLORS)) {
      expr.push(src, color);
    }
    expr.push('#808080'); // fallback
    return expr;
  }
}

// ─── Layer definitions ────────────────────────────────────────────

function _gpsLayerDef(id, source, sourceLayer) {
  const colorExpr = _buildColorExpression(window.gpsTracksLayer.filters.colorMode);
  return {
    id,
    type: 'line',
    source,
    'source-layer': sourceLayer || undefined,
    paint: {
      'line-color': colorExpr,
      // ET-012 (REQ-F-05): stop на z=5 = 0.8 CSS-px. На 1×-дисплеях это
      // даёт 1 физ.px (с округлением GPU), на 2× — 1.6, на 3× — 2.4.
      // Линия гарантированно видна на любом DPR.
      'line-width': ['interpolate', ['linear'], ['zoom'],
        5, 0.8,
        8, 1.0,
        12, 2.0,
        16, 3.0],
      'line-opacity': 0.75,
    },
    layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'none' }
  };
}

function _gpsHaloDef(id, source, sourceLayer) {
  return {
    id,
    type: 'line',
    source,
    'source-layer': sourceLayer || undefined,
    paint: {
      'line-color': '#ffffff',
      // ET-012 (REQ-F-06): halo на z=5 = 1.8 CSS-px при основной линии 0.8 px
      // (соотношение ~2.25×). Ореол не «съедает» линию: по 0.5 px с каждой
      // стороны, остаётся видна цветная сердцевина.
      'line-width': ['interpolate', ['linear'], ['zoom'],
        5, 1.8,
        8, 2.5,
        12, 4.0,
        16, 6.0],
      'line-opacity': 0.6,
    },
    layout: { visibility: 'none' }
  };
}

// ─── Создание/удаление sources и layers ──────────────────────────

/**
 * Добавляет vector- и geojson-источники для GPS-треков, если их ещё нет.
 *
 * ET-009 (F-02 fix): attribution передаётся параметром и фиксируется в
 * момент addSource. Это единственный надёжный способ заставить MapLibre
 * AttributionControl показать строку: мутация `source.attribution` после
 * addSource не вызывает обновления control'а. Вызывающий код обязан
 * сначала получить /api/gps-tracks/health (через _fetchGpsHealth) и
 * собрать строку через _buildGpsAttributionString, а уж потом передавать
 * её сюда.
 *
 * @param {object} map         MapLibre map instance
 * @param {string} attribution Готовая строка атрибуции (joined по ", ")
 */
function _ensureGpsSources(map, attribution) {
  const basePath = window.location.pathname.replace(/\/[^/]*$/, '') || '';
  const attr = attribution || GPS_SOURCE_ATTRIBUTIONS.osm;

  if (!map.getSource(window.gpsTracksLayer.sourceId)) {
    map.addSource(window.gpsTracksLayer.sourceId, {
      type: 'vector',
      tiles: [`${window.location.origin}${basePath}/api/gps-tracks/tiles/{z}/{x}/{y}.mvt`],
      minzoom: GPS_TRACKS_MIN_ZOOM,
      maxzoom: 11,
      attribution: attr,
    });
  }

  if (!map.getSource(window.gpsTracksLayer.sourceGeoId)) {
    map.addSource(window.gpsTracksLayer.sourceGeoId, {
      type: 'geojson',
      data: { type: 'FeatureCollection', features: [] },
      attribution: attr,
    });
  }
}

function _ensureGpsLayers(map) {
  if (!map.getLayer(window.gpsTracksLayer.layerId)) {
    const def = _gpsLayerDef(
      window.gpsTracksLayer.layerId,
      window.gpsTracksLayer.sourceId,
      'gps_tracks'
    );
    // Добавить поверх trails, ниже route (если есть)
    const before = _findGpsInsertPosition(map);
    map.addLayer(def, before);
  }

  if (!map.getLayer(window.gpsTracksLayer.layerGeoId)) {
    const def = _gpsLayerDef(
      window.gpsTracksLayer.layerGeoId,
      window.gpsTracksLayer.sourceGeoId,
      null
    );
    delete def['source-layer'];
    const before = _findGpsInsertPosition(map);
    map.addLayer(def, before);
  }

  if (!map.getLayer(window.gpsTracksLayer.layerHaloId)) {
    const def = _gpsHaloDef(
      window.gpsTracksLayer.layerHaloId,
      window.gpsTracksLayer.sourceId,
      'gps_tracks'
    );
    const before = window.gpsTracksLayer.layerId;
    map.addLayer(def, before);
  }

  if (!map.getLayer(window.gpsTracksLayer.layerHaloGeoId)) {
    const def = _gpsHaloDef(
      window.gpsTracksLayer.layerHaloGeoId,
      window.gpsTracksLayer.sourceGeoId,
      null
    );
    delete def['source-layer'];
    const before = window.gpsTracksLayer.layerGeoId;
    map.addLayer(def, before);
  }
}

/**
 * ET-009 (F-01/F-02 fix): получает /api/gps-tracks/health и кэширует
 * результат в `window.gpsTracksLayer._healthCache`. Многократные параллельные
 * вызовы переиспользуют один in-flight Promise (`_healthFetchPromise`),
 * чтобы не плодить дублирующих запросов при включении слоя + одновременном
 * открытии sheet'а фильтров.
 *
 * При сетевой ошибке/не-2xx — возвращает null, кэш не обновляется (но и не
 * затирается); вызывающий код должен fallback'ить на дефолты.
 *
 * @param {object} [opts]
 * @param {boolean} [opts.force=false] — игнорировать кэш и сходить заново
 * @returns {Promise<object|null>}
 */
async function _fetchGpsHealth(opts) {
  const force = !!(opts && opts.force);
  if (!force && window.gpsTracksLayer._healthCache) {
    return window.gpsTracksLayer._healthCache;
  }
  if (!force && window.gpsTracksLayer._healthFetchPromise) {
    return window.gpsTracksLayer._healthFetchPromise;
  }
  const basePath = window.location.pathname.replace(/\/[^/]*$/, '') || '';
  const promise = (async () => {
    try {
      const resp = await fetch(`${basePath}/api/gps-tracks/health`);
      if (!resp.ok) return null;
      const data = await resp.json();
      window.gpsTracksLayer._healthCache = data;
      return data;
    } catch (_) {
      return null;
    } finally {
      window.gpsTracksLayer._healthFetchPromise = null;
    }
  })();
  window.gpsTracksLayer._healthFetchPromise = promise;
  return promise;
}

/**
 * ET-009 (F-02 fix): собирает строку атрибуции из ответа /health.
 * Для каждого известного источника (osm, enduro_russia, wikiloc, ttrails),
 * у которого `tracks_by_source[id] > 0`, добавляет соответствующую запись
 * из GPS_SOURCE_ATTRIBUTIONS. Если данных нет или все нули — fallback на
 * OSM-атрибуцию (она всегда обязательна по лицензии).
 *
 * @param {object|null} healthData ответ /api/gps-tracks/health или null
 * @returns {string} строка атрибуции, готовая к передаче в addSource
 */
function _buildGpsAttributionString(healthData) {
  const counts = healthData && healthData.tracks_by_source ? healthData.tracks_by_source : {};
  const labels = [];
  for (const src of Object.keys(GPS_SOURCE_ATTRIBUTIONS)) {
    if (counts[src] && counts[src] > 0) {
      labels.push(GPS_SOURCE_ATTRIBUTIONS[src]);
    }
  }
  if (labels.length === 0) {
    labels.push(GPS_SOURCE_ATTRIBUTIONS.osm);
  }
  return labels.join(', ');
}

/**
 * ET-009 (F-01 fix): возвращает список source_id, по которым в БД есть
 * треки, согласно ответу /health. Если ответ отсутствует / не содержит
 * tracks_by_source — fallback на GPS_FALLBACK_SOURCES (статический список
 * потенциально доступных источников), чтобы UI фильтра не оставался пустым.
 *
 * @param {object|null} healthData ответ /api/gps-tracks/health или null
 * @returns {string[]} список source_id для отрисовки чекбоксов
 */
function _getAvailableGpsSources(healthData) {
  const counts = healthData && healthData.tracks_by_source ? healthData.tracks_by_source : null;
  if (!counts) return GPS_FALLBACK_SOURCES.slice();
  const ids = Object.keys(counts).filter(s => counts[s] > 0);
  if (ids.length === 0) return GPS_FALLBACK_SOURCES.slice();
  return ids;
}

function _findGpsInsertPosition(map) {
  /**
   * Returns the id of the first layer that GPS tracks should be inserted
   * below, using priority order:
   *   1. gpx-layer-*  — ET-006 GPX file layers (highest priority)
   *   2. route-*      — ET-002 routing layers
   * Returns undefined if neither is present (GPS tracks go on top).
   */
  const style = map.getStyle && map.getStyle();
  if (!style || !style.layers) return undefined;

  // Priority 1: gpx-layer-* (ET-006 GPX file layers)
  const gpxLayer = style.layers.find(l => l.id.startsWith('gpx-layer-'));
  if (gpxLayer) return gpxLayer.id;

  // Priority 2: route-* (ET-002 routing layers)
  const routeLayer = style.layers.find(l => l.id.startsWith('route-'));
  if (routeLayer) return routeLayer.id;

  return undefined;
}

// ─── Управление видимостью ────────────────────────────────────────

function _syncGpsLayersVisibility(map) {
  const enabled = window.gpsTracksLayer.enabled;
  const zoom = map.getZoom ? map.getZoom() : 0;
  const mvtVisible = enabled && zoom >= GPS_TRACKS_MIN_ZOOM && zoom < GPS_TRACKS_ZOOM_CUTOFF;
  const geoVisible = enabled && zoom >= GPS_TRACKS_ZOOM_CUTOFF;

  const setVis = (layerId, visible) => {
    if (map.getLayer(layerId)) {
      map.setLayoutProperty(layerId, 'visibility', visible ? 'visible' : 'none');
    }
  };

  setVis(window.gpsTracksLayer.layerId, mvtVisible);
  setVis(window.gpsTracksLayer.layerGeoId, geoVisible);

  // Hint «Зум 5+» (ET-012: порог переехал автоматически через GPS_TRACKS_MIN_ZOOM)
  const hint = document.getElementById('public-tracks-zoom-hint');
  if (hint) {
    hint.style.display = (enabled && zoom < GPS_TRACKS_MIN_ZOOM) ? 'inline' : 'none';
  }

  // Halo обновляется через applyGpsHaloVisibility
  applyGpsHaloVisibility(map);
}

// ─── Halo ──────────────────────────────────────────────────────────

function applyGpsHaloVisibility(map) {
  if (!map) return;
  const zoom = map.getZoom ? map.getZoom() : 0;
  const isSatellite = document.body.classList.contains('satellite-active');
  const enabled = window.gpsTracksLayer.enabled;

  const mvtHaloOn = enabled && isSatellite && zoom >= GPS_TRACKS_MIN_ZOOM && zoom < GPS_TRACKS_ZOOM_CUTOFF;
  const geoHaloOn = enabled && isSatellite && zoom >= GPS_TRACKS_ZOOM_CUTOFF;

  if (map.getLayer(window.gpsTracksLayer.layerHaloId)) {
    map.setLayoutProperty(window.gpsTracksLayer.layerHaloId, 'visibility', mvtHaloOn ? 'visible' : 'none');
  }
  if (map.getLayer(window.gpsTracksLayer.layerHaloGeoId)) {
    map.setLayoutProperty(window.gpsTracksLayer.layerHaloGeoId, 'visibility', geoHaloOn ? 'visible' : 'none');
  }
}

// ─── Фильтрация ───────────────────────────────────────────────────

function applyGpsFilter() {
  const map = window._map;
  if (!map) return;
  const { activities, sources } = window.gpsTracksLayer.filters;
  const filter = ['all',
    ['in', ['get', 'activity'], ['literal', activities]],
    ['in', ['get', 'source'], ['literal', sources]]
  ];
  if (map.getLayer(window.gpsTracksLayer.layerId)) {
    map.setFilter(window.gpsTracksLayer.layerId, filter);
  }
  if (map.getLayer(window.gpsTracksLayer.layerGeoId)) {
    map.setFilter(window.gpsTracksLayer.layerGeoId, filter);
  }
  if (map.getLayer(window.gpsTracksLayer.layerHaloId)) {
    map.setFilter(window.gpsTracksLayer.layerHaloId, filter);
  }
  if (map.getLayer(window.gpsTracksLayer.layerHaloGeoId)) {
    map.setFilter(window.gpsTracksLayer.layerHaloGeoId, filter);
  }
  _updateGpsStatsUI();
}

// ─── GeoJSON загрузка ─────────────────────────────────────────────

function onGpsMapMoveEnd() {
  const map = window._map;
  if (!map || !window.gpsTracksLayer.enabled) return;
  if (map.getZoom() < GPS_TRACKS_ZOOM_CUTOFF) return;

  clearTimeout(window.gpsTracksLayer.geojsonReqDebounceTimer);
  window.gpsTracksLayer.geojsonReqDebounceTimer = setTimeout(() => {
    fetchAndUpdateGpsGeoJson(map.getBounds());
  }, 500);
}

async function fetchAndUpdateGpsGeoJson(bounds) {
  const map = window._map;
  if (!map) return;

  if (window.gpsTracksLayer.geojsonAbortController) {
    window.gpsTracksLayer.geojsonAbortController.abort();
  }
  const ctrl = new AbortController();
  window.gpsTracksLayer.geojsonAbortController = ctrl;

  const { activities, sources } = window.gpsTracksLayer.filters;
  const basePath = window.location.pathname.replace(/\/[^/]*$/, '') || '';
  const bbox = `${bounds.getWest()},${bounds.getSouth()},${bounds.getEast()},${bounds.getNorth()}`;
  const url = `${basePath}/api/gps-tracks?bbox=${bbox}&activity=${activities.join(',')}&source=${sources.join(',')}&limit=500`;

  try {
    const resp = await fetch(url, { signal: ctrl.signal });
    if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
    const json = await resp.json();
    if (map.getSource(window.gpsTracksLayer.sourceGeoId)) {
      map.getSource(window.gpsTracksLayer.sourceGeoId).setData(json);
    }
    window.gpsTracksLayer.stats = { total: json.total_in_bbox || 0, shown: json.returned || 0 };
    if (json.truncated) {
      // показываем toast один раз
      if (typeof showToast === 'function') {
        showToast(`Показаны ${json.returned} треков из ${json.total_in_bbox}. Увеличьте zoom для полной выборки`);
      }
    }
    _updateGpsStatsUI();
  } catch (e) {
    if (e.name === 'AbortError') return;
    if (typeof showToast === 'function') showToast('Не удалось загрузить треки');
  }
}

// ─── Popup при клике ──────────────────────────────────────────────

// ET-011: SVG-иконка «download», копия из index.html sheet-route::downloadGPX
// (см. ADR-014 §3.a). Inline-SVG, чтобы popup не зависел от внешнего ассета.
const _GPS_DOWNLOAD_ICON_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';

function _renderTrackPopupHtml(props) {
  const name = props.name || 'Без названия';
  const activity = props.activity_type || props.activity || 'other';
  const icon = GPS_ACTIVITY_ICONS[activity] || '📍';
  const actLabel = GPS_ACTIVITY_LABELS[activity] || activity;
  const lengthKm = typeof props.length_km === 'number' ? props.length_km.toFixed(1) : '—';
  const points = props.points_count || '—';
  const dateStr = props.created_at ? new Date(props.created_at).toLocaleDateString('ru-RU', {day:'numeric',month:'long',year:'numeric'}) : null;
  const user = props.user || null;

  let sourcesHtml = '';
  try {
    let srcs = props.sources;
    let urls = props.external_urls;
    if (typeof srcs === 'string') srcs = srcs.split(',').filter(Boolean);
    if (typeof urls === 'string') urls = urls.split(',').filter(Boolean);
    if (Array.isArray(srcs) && srcs.length) {
      sourcesHtml = '<div class="track-popup-sources">Источники: ' +
        srcs.map((s, i) => {
          const url = Array.isArray(urls) && urls[i] ? urls[i] : null;
          const label = s;
          return url
            ? `<a href="${url}" target="_blank" rel="noopener">${label} ↗</a>`
            : `<span>${label}</span>`;
        }).join(' · ') + '</div>';
    }
  } catch(e) {}

  // ET-011 / REQ-F-01: кнопка «Скачать» в popup публичного трека.
  // Безопасно используем числовой id (FastAPI Path int ge=1 на сервере),
  // но всё равно делаем явный Number() — на случай, если MVT отдал строку.
  const trackId = Number(props.id);
  const actionsHtml = Number.isFinite(trackId) && trackId > 0
    ? `<div class="track-popup-actions">
         <button type="button"
                 class="track-popup-download-btn"
                 aria-label="Скачать GPX"
                 title="Скачать GPX"
                 data-track-id="${trackId}">
           ${_GPS_DOWNLOAD_ICON_SVG}
         </button>
       </div>`
    : '';

  return `
    <div class="track-popup">
      <div class="track-popup-name">${name}</div>
      <div class="track-popup-row">${icon} ${actLabel}</div>
      <div class="track-popup-row">📏 ${lengthKm} км · ${points} точек</div>
      ${dateStr ? `<div class="track-popup-row">📅 ${dateStr}</div>` : ''}
      ${user ? `<div class="track-popup-row">👤 ${user}</div>` : ''}
      ${actionsHtml}
      ${sourcesHtml}
    </div>
  `;
}

// ─── ET-011: Скачивание GPX из popup ─────────────────────────────

/**
 * ET-011 (ADR-014 §3): парсит заголовок Content-Disposition и возвращает имя
 * файла. Приоритет — `filename*=UTF-8''<percent-encoded>` (RFC 5987);
 * fallback — `filename="…"`; при отсутствии обоих — null.
 *
 * @param {string|null} cd
 * @returns {string|null}
 */
function _parseFilenameFromCD(cd) {
  if (!cd) return null;
  // RFC 5987: filename*=UTF-8''<encoded>
  const ext = cd.match(/filename\*=UTF-8''([^;]+)/i);
  if (ext && ext[1]) {
    try {
      return decodeURIComponent(ext[1].trim());
    } catch (_) {
      // битый percent-encoding — упадём в обычный filename
    }
  }
  const plain = cd.match(/filename="([^"]+)"/i) || cd.match(/filename=([^;]+)/i);
  if (plain && plain[1]) return plain[1].trim();
  return null;
}

/**
 * ET-011 (ADR-014 §3.b): человекочитаемое сообщение по HTTP-статусу.
 *
 * @param {number} status
 * @param {object} body уже распарсенный JSON ответа (может быть пустым)
 */
function _handleDownloadError(status, body) {
  if (typeof showToast !== 'function') return;
  if (status === 403) {
    // ADR-015 §G: backend отдаёт одноуровневый JSON
    //   {"detail":"source_forbidden","external_urls":[...]}
    // Защитный fallback на старую форму {"detail":{"external_urls":[...]}}
    // оставлен на случай legacy-обёрток (см. P2-01 в 12-review.md).
    const urls = (body && body.external_urls)
                  || (body && body.detail && body.detail.external_urls);
    const firstUrl = Array.isArray(urls) && urls.length ? urls[0] : null;
    if (firstUrl) {
      showToast(`Источник запрещает скачивание. Откройте трек на сайте источника: ${firstUrl}`);
    } else {
      showToast('Источник запрещает скачивание. Откройте трек на сайте источника.');
    }
  } else if (status === 404) {
    showToast('Трек не найден.');
  } else if (status === 413) {
    showToast('Трек слишком большой для скачивания.');
  } else if (status === 400) {
    showToast('Неподдерживаемый формат файла.');
  } else {
    showToast('Не удалось скачать. Попробуйте ещё раз.');
  }
}

/**
 * ET-011: скачивает GPX для трека с публичного слоя.
 * Использует тот же паттерн (fetch → Blob → URL.createObjectURL → a.download),
 * что и app.js::downloadGPX(), — он уже отлажен на iOS Safari (BRD R-1).
 *
 * @param {number|string} trackId
 * @param {HTMLElement|null} btnEl кнопка, на которой показываем индикатор
 */
async function _downloadPublicTrack(trackId, btnEl) {
  if (btnEl) btnEl.classList.add('is-loading');
  const basePath = window.location.pathname.replace(/\/[^/]*$/, '') || '';
  const url = `${basePath}/api/gps-tracks/${encodeURIComponent(trackId)}/download`;
  try {
    const resp = await fetch(url);
    if (!resp.ok) {
      let body = {};
      try { body = await resp.json(); } catch (_) {}
      _handleDownloadError(resp.status, body);
      return;
    }
    const blob = await resp.blob();
    const filename = _parseFilenameFromCD(resp.headers.get('Content-Disposition'))
                      || `track-${trackId}.gpx`;
    const objectUrl = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = objectUrl;
    a.download = filename;
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
    // Освобождаем blob чуть позже — Safari иногда отменяет скачивание,
    // если revoke сработал синхронно с click().
    setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
  } catch (err) {
    if (typeof showToast === 'function') {
      showToast('Не удалось скачать. Попробуйте ещё раз.');
    }
  } finally {
    if (btnEl) btnEl.classList.remove('is-loading');
  }
}

function _setupGpsClickHandler(map) {
  const layerIds = [window.gpsTracksLayer.layerId, window.gpsTracksLayer.layerGeoId];

  layerIds.forEach(layerId => {
    map.on('click', layerId, (e) => {
      // Не открывать popup если активен другой режим
      if (window._routeMode || window._reconMode || window._scenicMode || window._rulerMode) return;

      const feature = e.features && e.features[0];
      if (!feature) return;

      const popup = new maplibregl.Popup({ closeOnClick: true, maxWidth: '300px' })
        .setLngLat(e.lngLat)
        .setHTML(_renderTrackPopupHtml(feature.properties))
        .addTo(map);

      // ET-011 / ADR-014 §3.b: делегированный обработчик клика на
      // кнопку «Скачать». Popup в проекте перерисовывается при каждом
      // открытии, так что листенер живёт ровно столько, сколько popup.
      const popupEl = popup.getElement && popup.getElement();
      if (popupEl) {
        popupEl.addEventListener('click', (ev) => {
          const btn = ev.target.closest && ev.target.closest('.track-popup-download-btn');
          if (!btn) return;
          ev.preventDefault();
          ev.stopPropagation();
          const tid = btn.getAttribute('data-track-id');
          if (!tid) return;
          _downloadPublicTrack(tid, btn);
        });
      }
    });

    map.on('mouseenter', layerId, () => { map.getCanvas().style.cursor = 'pointer'; });
    map.on('mouseleave', layerId, () => { map.getCanvas().style.cursor = ''; });
  });
}

// ─── Включение/выключение слоя ────────────────────────────────────

async function onPublicTracksCheckbox() {
  const cb = document.getElementById('public-tracks-cb');
  const filterBtn = document.getElementById('public-tracks-filters-btn');
  if (!cb) return;

  window.gpsTracksLayer.enabled = cb.checked;
  localStorage.setItem('gps-tracks-enabled', cb.checked ? 'true' : 'false');

  const map = window._map;
  if (!map) return;

  if (cb.checked) {
    // ET-009 (F-02 fix): обязательно дождаться /health ДО addSource —
    // иначе attribution зафиксируется на дефолтном «© OSM» и
    // AttributionControl никогда не обновится (см. ADR-013 §3 Решение D,
    // F-02 в 12-review.md).
    const health = await _fetchGpsHealth();
    const attribution = _buildGpsAttributionString(health);
    _ensureGpsSources(map, attribution);
    _ensureGpsLayers(map);
    _setupGpsClickHandler(map);

    // Убедиться, что moveend listener есть
    map.off('moveend', onGpsMapMoveEnd);
    map.on('moveend', onGpsMapMoveEnd);
    map.off('zoomend', onGpsZoomEnd);
    map.on('zoomend', onGpsZoomEnd);
  }

  _syncGpsLayersVisibility(map);
  applyGpsFilter();

  // Фильтры btn
  if (filterBtn) filterBtn.style.display = cb.checked ? 'block' : 'none';

  // Если включили и zoom >= 12 — загрузить GeoJSON
  if (cb.checked && map.getZoom() >= GPS_TRACKS_ZOOM_CUTOFF) {
    fetchAndUpdateGpsGeoJson(map.getBounds());
  }
}

function onGpsZoomEnd() {
  const map = window._map;
  if (!map) return;
  _syncGpsLayersVisibility(map);
  // При переходе на z>=12 загрузить GeoJSON
  if (window.gpsTracksLayer.enabled && map.getZoom() >= GPS_TRACKS_ZOOM_CUTOFF) {
    clearTimeout(window.gpsTracksLayer.geojsonReqDebounceTimer);
    window.gpsTracksLayer.geojsonReqDebounceTimer = setTimeout(() => {
      fetchAndUpdateGpsGeoJson(map.getBounds());
    }, 500);
  }
}

// ─── Sheet фильтров ───────────────────────────────────────────────

function togglePublicTracksFiltersSheet() {
  const sheet = document.getElementById('sheet-gps-filters');
  if (!sheet) return;
  const isOpen = sheet.classList.contains('open');
  if (!isOpen) {
    // ET-009 (F-01 fix): _buildGpsFiltersUI асинхронно подтянет /health
    // для динамического списка источников. Sheet можно открывать сразу —
    // чекбоксы источников появятся как только промис разрешится.
    _buildGpsFiltersUI();
    openSheet('sheet-gps-filters');
  } else {
    closeAllSheets();
  }
}

/**
 * ET-009 (F-01 fix): строит UI фильтра. Активности — статический список;
 * источники подтягиваются из /api/gps-tracks/health (ADR-013 §3 Решение D,
 * опция D2): чекбокс отображается для каждого source_id с tracks_by_source > 0.
 * Маппинг id → label берётся из GPS_SOURCE_LABELS. Активация четвёртого
 * источника не требует правки этого кода — нужен только новый ключ в
 * GPS_SOURCE_LABELS (для красивого названия) или fallback к самому id.
 *
 * При сетевой ошибке /health список источников fallback'ит на
 * GPS_FALLBACK_SOURCES (см. _getAvailableGpsSources).
 */
async function _buildGpsFiltersUI() {
  // Активности
  const actGrid = document.getElementById('gps-activity-grid');
  if (actGrid) {
    const all = ['enduro', 'moto', 'offroad', 'bicycle', 'hike', 'ski', 'other'];
    actGrid.innerHTML = all.map(act => {
      const checked = window.gpsTracksLayer.filters.activities.includes(act);
      return `
        <label class="gps-filter-chip">
          <input type="checkbox" value="${act}" ${checked ? 'checked' : ''} onchange="onGpsActivityFilterChange()">
          <span>${GPS_ACTIVITY_ICONS[act]} ${GPS_ACTIVITY_LABELS[act]}</span>
        </label>`;
    }).join('');
  }

  // Color mode (синхронная часть — обновляем до await чтобы UI отозвался
  // максимально быстро при открытии sheet'а)
  const colorMode = window.gpsTracksLayer.filters.colorMode;
  const btnSrc = document.getElementById('gps-color-by-source');
  const btnAct = document.getElementById('gps-color-by-activity');
  if (btnSrc) btnSrc.classList.toggle('active', colorMode === 'source');
  if (btnAct) btnAct.classList.toggle('active', colorMode === 'activity');

  _updateGpsStatsUI();

  // Источники — динамически из /health (ADR-013 §3 Решение D, опция D2)
  const srcGrid = document.getElementById('gps-source-grid');
  if (srcGrid) {
    const health = await _fetchGpsHealth();
    const allSources = _getAvailableGpsSources(health);
    srcGrid.innerHTML = allSources.map(src => {
      const checked = window.gpsTracksLayer.filters.sources.includes(src);
      const label = GPS_SOURCE_LABELS[src] || src;
      return `
        <label class="gps-filter-chip">
          <input type="checkbox" value="${src}" ${checked ? 'checked' : ''} onchange="onGpsSourceFilterChange()">
          <span>${label}</span>
        </label>`;
    }).join('');
  }
}

function onGpsActivityFilterChange() {
  const checked = [];
  document.querySelectorAll('#gps-activity-grid input[type=checkbox]:checked').forEach(cb => checked.push(cb.value));
  window.gpsTracksLayer.filters.activities = checked;
  localStorage.setItem('gps-tracks-activities', JSON.stringify(checked));
  applyGpsFilter();
}

function onGpsSourceFilterChange() {
  const checked = [];
  document.querySelectorAll('#gps-source-grid input[type=checkbox]:checked').forEach(cb => checked.push(cb.value));
  window.gpsTracksLayer.filters.sources = checked;
  localStorage.setItem('gps-tracks-sources', JSON.stringify(checked));
  applyGpsFilter();
}

function onGpsColorModeChange(mode) {
  window.gpsTracksLayer.filters.colorMode = mode;
  localStorage.setItem('gps-tracks-color-mode', mode);

  const btnSrc = document.getElementById('gps-color-by-source');
  const btnAct = document.getElementById('gps-color-by-activity');
  if (btnSrc) btnSrc.classList.toggle('active', mode === 'source');
  if (btnAct) btnAct.classList.toggle('active', mode === 'activity');

  // Перестроить color expression
  const map = window._map;
  if (!map) return;
  const colorExpr = _buildColorExpression(mode);
  [window.gpsTracksLayer.layerId, window.gpsTracksLayer.layerGeoId].forEach(layerId => {
    if (map.getLayer(layerId)) {
      map.setPaintProperty(layerId, 'line-color', colorExpr);
    }
  });
}

function _updateGpsStatsUI() {
  const totalEl = document.getElementById('gps-stat-total');
  const shownEl = document.getElementById('gps-stat-shown');
  if (totalEl) totalEl.textContent = window.gpsTracksLayer.stats.total || '—';
  if (shownEl) shownEl.textContent = window.gpsTracksLayer.stats.shown || '—';
}

// ─── restorePublicTracksState ──────────────────────────────────────
/**
 * Восстанавливает состояние слоя публичных треков из localStorage.
 * Вызывается из rebuildMapOverlays() в app.js.
 *
 * ET-009 (F-02 fix): теперь async, потому что при `enabled=true` нужно
 * сначала дождаться /api/gps-tracks/health и только потом вызвать
 * addSource с корректным attribution — иначе AttributionControl
 * зафиксируется на дефолтной OSM-строке.
 */
async function restorePublicTracksState() {
  const enabled = localStorage.getItem('gps-tracks-enabled') === 'true';
  const cb = document.getElementById('public-tracks-cb');
  const filterBtn = document.getElementById('public-tracks-filters-btn');

  const activitiesRaw = localStorage.getItem('gps-tracks-activities');
  if (activitiesRaw) {
    try { window.gpsTracksLayer.filters.activities = JSON.parse(activitiesRaw); } catch(e) {}
  }

  const sourcesRaw = localStorage.getItem('gps-tracks-sources');
  if (sourcesRaw) {
    try { window.gpsTracksLayer.filters.sources = JSON.parse(sourcesRaw); } catch(e) {}
  }

  const colorMode = localStorage.getItem('gps-tracks-color-mode') || 'source';
  window.gpsTracksLayer.filters.colorMode = colorMode;

  if (cb) cb.checked = enabled;
  if (filterBtn) filterBtn.style.display = enabled ? 'block' : 'none';
  window.gpsTracksLayer.enabled = enabled;

  const map = window._map;
  if (!map) return;

  if (enabled) {
    const health = await _fetchGpsHealth();
    const attribution = _buildGpsAttributionString(health);
    _ensureGpsSources(map, attribution);
    _ensureGpsLayers(map);
    _setupGpsClickHandler(map);
    map.off('moveend', onGpsMapMoveEnd);
    map.on('moveend', onGpsMapMoveEnd);
    map.off('zoomend', onGpsZoomEnd);
    map.on('zoomend', onGpsZoomEnd);
    _syncGpsLayersVisibility(map);
    applyGpsFilter();
    if (map.getZoom() >= GPS_TRACKS_ZOOM_CUTOFF) {
      fetchAndUpdateGpsGeoJson(map.getBounds());
    }
  }
}
