(function (ns, $) {
  'use strict';

  const state = {
    routes: [],
    route: null,
    position: null,
    triggered: new Set(),
    routeState: 'idle',
    activePage: null,
    routeProgressIndex: null,
    deviceHeading: null,
    viewed: new Set(),
    playedAudio: new Set(),
    activePoiId: null,
    persistTimer: null
  };

  function escapeHtml(value) {
    return $('<div>').text(value ?? '').html();
  }


  function persistRouteProgress() {
    if (!state.route) return;
    if (state.persistTimer != null) {
      globalThis.clearTimeout(state.persistTimer);
      state.persistTimer = null;
    }

    ns.PrivacyStorage.saveRouteState(
      state.route,
      state.routeState,
      state.routeProgressIndex,
      [...state.triggered],
      [...state.viewed],
      [...state.playedAudio]
    );
  }

  function scheduleRouteProgressPersist() {
    if (!state.route || state.persistTimer != null) return;
    state.persistTimer = globalThis.setTimeout(persistRouteProgress, 1200);
  }

  function restoreRouteProgress(route) {
    const saved = ns.PrivacyStorage.activateRoute(route);
    state.triggered = new Set(saved?.visitedPoiIds ?? []);
    state.viewed = new Set(saved?.viewedPoiIds ?? []);
    state.playedAudio = new Set(saved?.playedAudioPoiIds ?? []);
    state.routeProgressIndex = Number.isInteger(saved?.routeProgressIndex)
      ? saved.routeProgressIndex
      : null;
    state.routeState = saved?.routeState === 'running' || saved?.routeState === 'paused'
      ? 'paused'
      : 'idle';

    if (state.routeState === 'paused') {
      $('#tracking-status')
        .prop('hidden', false)
        .text('Gespeicherter Streckenfortschritt gefunden. Die Route kann fortgesetzt werden.');
    } else {
      $('#tracking-status').prop('hidden', true).text('');
    }
  }

  function activate(selector, handler) {
    $(document)
      .on('pointerup', selector, function (event) {
        const pointer = event.originalEvent;
        if (pointer?.isPrimary === false) return;
        if (pointer?.pointerType === 'mouse' && pointer.button !== 0) return;
        event.preventDefault();
        handler.call(this, event);
      })
      .on('keydown', selector, function (event) {
        if (event.key !== 'Enter' && event.key !== ' ') return;
        event.preventDefault();
        handler.call(this, event);
      });
  }

  function navigate(pageSelector, options = {}) {
    const target = $(pageSelector);
    if (!target.length) throw new Error(`Unbekannte Ansicht: ${pageSelector}`);

    const previous = state.activePage;
    $('.app-page').attr('hidden', true);
    target.removeAttr('hidden');
    state.activePage = pageSelector;
    globalThis.scrollTo({ top: 0, behavior: options.instant ? 'auto' : 'smooth' });

    if (options.updateHistory === false) return;
    const historyState = { page: pageSelector, previous };
    if (options.replace) history.replaceState(historyState, '', pageSelector);
    else if (previous !== pageSelector) history.pushState(historyState, '', pageSelector);
  }

  function normalizeSearch(value) {
    return String(value ?? '')
      .normalize('NFKD')
      .replaceAll(/[\u0300-\u036f]/g, '')
      .toLocaleLowerCase('de');
  }

  function routeMatchesSearch(route, searchTerm) {
    if (!searchTerm) return true;
    return normalizeSearch([
      route.name,
      route.schoolName,
      route.description
    ].filter(Boolean).join(' ')).includes(searchTerm);
  }

  function renderRouteItems(listSelector, routes, emptyText) {
    const list = $(listSelector).empty();
    const items = Array.isArray(routes) ? routes.filter(Boolean) : [];
    if (!items.length) {
      list.append(`<li><p>${escapeHtml(emptyText)}</p></li>`);
      return;
    }

    items.forEach(route => {
      const proximity = route.proximityM == null
        ? ''
        : `<small>${escapeHtml(ns.Distance.format(route.proximityM))} entfernt</small>`;

      list.append(`
        <li>
          <button type="button" data-route-id="${route.id}">
            <strong>${escapeHtml(route.name)}</strong>
            <span>${escapeHtml(route.schoolName || route.description || '')}</span>
            ${proximity}
          </button>
        </li>
      `);
    });
  }

  function renderRoutes() {
    const routes = Array.isArray(state.routes) ? state.routes.filter(Boolean) : [];
    const radiusM = ns.Config.routeRadiusKm * 1000;
    const nearbyRoutes = state.position
      ? routes
        .filter(route => route.proximityM != null && route.proximityM <= radiusM)
        .slice()
        .sort((a, b) => a.proximityM - b.proximityM)
      : [];

    const searchTerm = normalizeSearch($('#route-search').val());
    const allRoutes = routes
      .filter(route => routeMatchesSearch(route, searchTerm))
      .slice()
      .sort((a, b) => String(a.name ?? '').localeCompare(String(b.name ?? ''), 'de'));

    const nearbyEmptyText = state.position
      ? `Keine Route im Umkreis von ${ns.Config.routeRadiusKm} km gefunden. Wähle unten eine beliebige Route aus.`
      : 'Für Empfehlungen in der Nähe ist ein Standort erforderlich. Alle Routen bleiben unten auswählbar.';

    renderRouteItems('#nearby-route-list', nearbyRoutes, nearbyEmptyText);
    renderRouteItems(
      '#all-route-list',
      allRoutes,
      searchTerm ? 'Keine Route entspricht dem Suchbegriff.' : 'Es sind noch keine Routen vorhanden.'
    );

    $('#all-routes-summary').text(
      searchTerm
        ? `${allRoutes.length} von ${routes.length} Routen gefunden.`
        : `${routes.length} Routen verfügbar.`
    );
  }

  function initializeRouteSearch() {
    const input = $('#route-search');
    if (!input.length || typeof input.autocomplete !== 'function') return;

    input.autocomplete({
      minLength: 0,
      source(request, response) {
        const term = normalizeSearch(request.term);
        response(state.routes
          .filter(route => routeMatchesSearch(route, term))
          .slice(0, 12)
          .map(route => ({ label: route.name, value: route.name })));
      },
      select(event, ui) {
        input.val(ui.item.value);
        renderRoutes();
        return false;
      }
    });
  }

  function hideNavigation() {
    $('#navigation-footer').prop('hidden', true);
    $('#app-shell').removeClass('has-navigation');
  }

  function showNavigation(message = 'Position wird ermittelt', distance = '–') {
    $('#navigation-mode').text(message);
    $('#navigation-distance').text(distance);
    $('#navigation-footer').prop('hidden', false);
    $('#app-shell').addClass('has-navigation');
  }

  function stopRouteSensors() {
    ns.Geo.stop();
    ns.Orientation.stop();
    ns.Vibration.stop();
    state.deviceHeading = null;
  }

  function updateRouteControls() {
    const running = state.routeState === 'running';
    const paused = state.routeState === 'paused';
    const startLabel = paused ? 'Route fortsetzen' : 'Route starten';

    $('#start-route')
      .prop('disabled', running)
      .attr({ 'aria-label': startLabel, title: startLabel });
    $('#pause-route').prop('disabled', !running);
    $('#stop-route').prop('disabled', state.routeState === 'idle');
  }

  function setActivePoi(poiId) {
    $('#poi-list [data-poi-id]').removeAttr('aria-current');
    if (poiId == null) return;

    $(`#poi-list [data-poi-id="${Number(poiId)}"]`).attr('aria-current', 'step');
  }

  function endRoute({ announce = true, returnToRoutes = false } = {}) {
    const wasActive = state.routeState !== 'idle';
    state.routeState = 'idle';
    persistRouteProgress();
    state.routeProgressIndex = null;
    state.triggered.clear();
    state.viewed.clear();
    state.playedAudio.clear();
    state.activePoiId = null;
    stopRouteSensors();
    ns.AudioPlayer.unload();
    setActivePoi(null);
    hideNavigation();

    if (announce && wasActive && !returnToRoutes) {
      $('#tracking-status').prop('hidden', false).text('Route beendet.');
    } else {
      $('#tracking-status').prop('hidden', true).text('');
    }

    updateRouteControls();

    if (returnToRoutes) {
      state.route = null;
      navigate('#routes-page', { replace: true });
    }
  }

  function pauseRoute() {
    if (state.routeState !== 'running') return;

    state.routeState = 'paused';
    persistRouteProgress();
    stopRouteSensors();
    ns.AudioPlayer.pause();
    $('#tracking-status')
      .prop('hidden', false)
      .text('Route pausiert. Die Standortverfolgung ist angehalten.');
    showNavigation('Route pausiert', $('#navigation-distance').text() || '–');
    updateRouteControls();
  }

  function renderRoute(route) {
    if (state.routeState !== 'idle') endRoute({ announce: false });

    state.route = route;
    state.activePoiId = null;
    restoreRouteProgress(route);
    setActivePoi(null);
    $('#route-title').text(route.name);
    $('#route-description').text(route.description || '');
    $('#route-distance').text(ns.Distance.format(route.distanceM));
    $('#route-elevation').text(`${Math.round(route.elevationGainM)} m`);
    $('#route-poi-count').text(route.pois.length);
    updateRouteControls();

    const list = $('#poi-list').empty();
    if (!route.pois.length) {
      list.append('<li><p>Diese Route enthält noch keine Stationen.</p></li>');
      return;
    }

    route.pois.forEach(poi => {
      list.append(`
        <li>
          <button type="button" data-poi-id="${poi.id}">
            <strong>${escapeHtml(poi.title)}</strong>
            <span>${escapeHtml(poi.description || '')}</span>
            <span class="poi-proximity" aria-label="Entfernung wird ermittelt">
              <svg viewBox="0 0 32 32" aria-hidden="true" focusable="false">
                <path d="M16 2 27 28 16 23 5 28Z"/>
              </svg>
              <small>–</small>
            </span>
          </button>
        </li>
      `);
    });

    updatePoiIndicators(state.position);
  }

  function showPoi(poi) {
    state.activePoiId = Number(poi.id);
    state.viewed.add(state.activePoiId);
    persistRouteProgress();
    setActivePoi(poi.id);
    $('#poi-title').text(poi.title);
    $('#poi-description').text(poi.description || '');
    ns.Slideshow.show(poi.images);
    ns.AudioPlayer.load(poi.audioUrl);
    navigate('#poi-page');
  }

  function nearestRoutePointIndex(position) {
    const points = state.route?.points || [];
    if (!points.length) return null;

    let start = 0;
    let end = points.length;
    if (state.routeProgressIndex != null) {
      start = Math.max(0, state.routeProgressIndex - 3);
      end = Math.min(points.length, state.routeProgressIndex + 201);
    }

    let nearestIndex = start;
    let nearestDistance = Number.POSITIVE_INFINITY;
    for (let index = start; index < end; index += 1) {
      const distance = ns.Distance.meters(position, points[index]);
      if (distance < nearestDistance) {
        nearestDistance = distance;
        nearestIndex = index;
      }
    }

    return nearestIndex;
  }

  function effectiveHeading(position) {
    const movementHeading = position
      && Number.isFinite(position.heading)
      && Number(position.speed) > 0.5
      ? position.heading
      : null;
    return state.deviceHeading ?? movementHeading;
  }

  function poiMetrics(position) {
    if (!position || !state.route?.pois?.length) return [];
    const heading = effectiveHeading(position);

    return state.route.pois.map(poi => {
      const distance = ns.Distance.meters(position, poi);
      const bearing = ns.Distance.bearing(position, poi);
      return {
        poi,
        distance,
        bearing,
        rotation: heading == null
          ? bearing
          : ns.Distance.normalizeDegrees(bearing - heading)
      };
    });
  }

  function updatePoiIndicators(position) {
    const metrics = poiMetrics(position);

    metrics.forEach(metric => {
      const indicator = $(`#poi-list [data-poi-id="${metric.poi.id}"] .poi-proximity`);
      const distance = Math.round(metric.distance);
      indicator
        .attr('aria-label', `${distance} Meter entfernt`)
        .attr('title', `${distance} m entfernt`);
      indicator.find('svg').css('transform', `rotate(${metric.rotation.toFixed(1)}deg)`);
      indicator.find('small').text(`${distance} m`);
    });

    return metrics;
  }

  function nearestPendingPoi(metrics) {
    return metrics
      .filter(metric => !state.triggered.has(metric.poi.id))
      .reduce((nearest, metric) => (
        !nearest || metric.distance < nearest.distance ? metric : nearest
      ), null);
  }

  function updateRouteProgress(position) {
    const previousProgressIndex = state.routeProgressIndex;
    const points = state.route?.points || [];
    if (!points.length) return null;

    const nearestIndex = nearestRoutePointIndex(position);
    if (nearestIndex == null) return null;

    state.routeProgressIndex = state.routeProgressIndex == null
      ? nearestIndex
      : Math.max(state.routeProgressIndex, nearestIndex);

    const lastIndex = points.length - 1;
    let targetIndex = Math.min(lastIndex, state.routeProgressIndex + 1);
    let target = points[targetIndex];
    let distance = ns.Distance.meters(position, target);
    const reachedRadius = Math.max(4, Math.min(12, Math.round(position.accuracy || 4)));

    while (targetIndex < lastIndex && distance <= reachedRadius) {
      state.routeProgressIndex = targetIndex;
      targetIndex += 1;
      target = points[targetIndex];
      distance = ns.Distance.meters(position, target);
    }

    if (targetIndex === lastIndex && distance <= reachedRadius) {
      state.routeProgressIndex = lastIndex;
      if (state.routeProgressIndex !== previousProgressIndex) scheduleRouteProgressPersist();
      return { reached: true, target, distance: 0 };
    }

    if (state.routeProgressIndex !== previousProgressIndex) scheduleRouteProgressPersist();
    return { reached: false, target, distance };
  }

  function showPoiNavigation(position, metric) {
    const heading = effectiveHeading(position);
    const distance = Math.round(metric.distance);

    $('#navigation-arrow').css('transform', `rotate(${metric.rotation.toFixed(1)}deg)`);
    showNavigation(
      heading == null
        ? `Norden oben · nächster POI: ${metric.poi.title}`
        : `Nächster POI: ${metric.poi.title}`,
      `${distance} m`
    );
    $('#navigation-footer').attr(
      'aria-label',
      `Nächster POI ${metric.poi.title} in ${distance} Metern`
    );
  }

  function showRoutePointNavigation(position, progress) {
    if (!progress) {
      $('#navigation-arrow').css('transform', 'rotate(0deg)');
      showNavigation('Keine Navigationspunkte vorhanden', '–');
      return;
    }

    if (progress.reached) {
      $('#navigation-arrow').css('transform', 'rotate(0deg)');
      showNavigation('Alle Stationen und das Routenziel erreicht', '0 m');
      $('#navigation-footer').attr('aria-label', 'Alle Stationen und das Routenziel erreicht');
      return;
    }

    const bearing = ns.Distance.bearing(position, progress.target);
    const heading = effectiveHeading(position);
    const rotation = heading == null
      ? bearing
      : ns.Distance.normalizeDegrees(bearing - heading);
    const distance = Math.round(progress.distance);

    $('#navigation-arrow').css('transform', `rotate(${rotation.toFixed(1)}deg)`);
    showNavigation(
      heading == null ? 'Norden oben · nächster Routenpunkt' : 'Nächster Routenpunkt',
      `${distance} m`
    );
    $('#navigation-footer').attr(
      'aria-label',
      `Nächster Routenpunkt in ${distance} Metern`
    );
  }

  function updateNavigation(position) {
    if (state.routeState !== 'running') return;

    const progress = updateRouteProgress(position);
    const metrics = updatePoiIndicators(position);
    const nextPoi = nearestPendingPoi(metrics);

    if (nextPoi) showPoiNavigation(position, nextPoi);
    else showRoutePointNavigation(position, progress);
  }

  function updateHeading(heading) {
    if (state.deviceHeading == null) {
      state.deviceHeading = heading;
    } else {
      const delta = ((heading - state.deviceHeading + 540) % 360) - 180;
      state.deviceHeading = ns.Distance.normalizeDegrees(state.deviceHeading + delta * 0.25);
    }

    if (state.position) updateNavigation(state.position);
  }

  function evaluatePosition(position) {
    state.position = position;
    if (!state.route) return;

    const metrics = updatePoiIndicators(position);
    if (state.routeState !== 'running') return;

    const nearest = nearestPendingPoi(metrics);
    updateNavigation(position);

    $('#tracking-status').text(
      `GPS aktiv · Genauigkeit ${Math.round(position.accuracy)} m`
      + (nearest ? ` · nächster POI ${Math.round(nearest.distance)} m` : ' · alle POIs erreicht')
    );

    if (nearest && nearest.distance <= nearest.poi.triggerRadiusM) {
      state.triggered.add(nearest.poi.id);
      persistRouteProgress();
      ns.Vibration.start(ns.Vibration.Patterns.ACTIVE_POI);
      showPoi(nearest.poi);
      updateNavigation(position);
    }
  }

  async function startRoute() {
    if (!state.route || state.routeState === 'running') return;

    const resumed = state.routeState === 'paused';
    if (!resumed) {
      state.routeProgressIndex = null;
      state.triggered.clear();
    }

    state.routeState = 'running';
    state.deviceHeading = null;
    persistRouteProgress();
    updateRouteControls();
    $('#tracking-status')
      .prop('hidden', false)
      .text(resumed ? 'Route wird fortgesetzt.' : 'Route wird gestartet. GPS wird aktiviert.');
    showNavigation(resumed ? 'Route wird fortgesetzt' : 'Position wird ermittelt');

    ns.Geo.start(
      evaluatePosition,
      error => $('#tracking-status').text(`GPS-Fehler: ${error.message}`)
    );

    const orientationAvailable = await ns.Orientation.start(updateHeading);
    if (state.routeState !== 'running') {
      ns.Orientation.stop();
      return;
    }

    if (!orientationAvailable) {
      $('#navigation-mode').text('Norden oben · Navigation');
    }
  }

  function routesFromResponse(response) {
    if (!Array.isArray(response?.routes)) {
      throw new TypeError('Die Routen-API hat keine gültige Routenliste geliefert.');
    }
    return response.routes.filter(route => route?.id != null && route?.name != null);
  }

  function updateRouteProximity(position) {
    state.routes = state.routes.map(route => ({
      ...route,
      proximityM: route.start ? ns.Distance.meters(position, route.start) : null
    }));
  }

  async function initializeLocation(context) {
    try {
      const position = await ns.Geo.current();
      state.position = position;
      updateRouteProximity(position);
      $('#location-hint').text(
        `Standort ermittelt (Genauigkeit ${Math.round(position.accuracy)} m). `
        + `Nahe Routen werden bis ${ns.Config.routeRadiusKm} km empfohlen.`
      );
    } catch (error) {
      $('#location-hint').text(
        `Standort nicht verfügbar: ${error.message}. Alle Routen können weiterhin ausgewählt werden.`
      );
    } finally {
      context.markStep('location', 'done');
      renderRoutes();
    }
  }

  function restoreRouteLists() {
    if (state.routes.length) renderRoutes();
  }

  function bindEvents() {
    $(document).on('input', '#route-search', renderRoutes);

    activate('#clear-route-search', function () {
      $('#route-search').val('').trigger('input').trigger('focus');
    });

    activate('[data-route-id]', async function () {
      const button = $(this);
      button.prop('disabled', true);
      try {
        const route = await ns.Api.route(button.data('route-id'));
        renderRoute(route);
        navigate('#route-page');
      } catch (error) {
        alert(error.responseJSON?.message || error.statusText || error.message);
      } finally {
        button.prop('disabled', false);
      }
    });

    activate('[data-poi-id]', function () {
      const poi = state.route?.pois.find(item => item.id === Number($(this).data('poi-id')));
      if (poi) showPoi(poi);
    });

    activate('[data-page-target]', function () {
      const target = $(this).data('page-target');
      if (history.state?.previous === target) history.back();
      else navigate(target, { replace: true });
    });

    activate('#start-route', async function () {
      await startRoute();
    });

    activate('#pause-route', function () {
      pauseRoute();
    });

    activate('#stop-route', function () {
      endRoute({ returnToRoutes: true });
    });

    $('#poi-audio').on('play', function () {
      if (!state.route || state.activePoiId == null) return;
      state.playedAudio.add(state.activePoiId);
      persistRouteProgress();
    });

    document.addEventListener('wegwichtel:progress-storage-changed', event => {
      if (event.detail?.enabled) persistRouteProgress();
    });

    globalThis.addEventListener('pagehide', persistRouteProgress);
    globalThis.addEventListener('popstate', event => {
      const page = event.state?.page || '#routes-page';
      navigate(page, { updateHistory: false, instant: true });
    });

    globalThis.addEventListener('pageshow', restoreRouteLists);
    document.addEventListener('visibilitychange', () => {
      if (document.visibilityState === 'visible') restoreRouteLists();
      else persistRouteProgress();
    });
  }

  ns.App = {
    navigate,

    async initialize(context) {
      bindEvents();
      await ns.Api.health();
      context.markStep('server', 'done');

      const response = await ns.Api.routes(null);
      state.routes = routesFromResponse(response);
      context.markStep('routes', 'done');

      renderRoutes();
      initializeRouteSearch();
      updateRouteControls();
      hideNavigation();

      await initializeLocation(context);
    }
  };
}(globalThis.Wegwichtel, globalThis.jQuery));
