// @ts-nocheck
import exifr from "/vendor/exifr/full.esm.mjs";
import { createPhotoMarkerIcon } from "./map-view.js";
import { parseListing, resolveDavBaseUrl } from "./nextcloud-import.js";
import {
  buildTimeline,
  formatTimelineSummary,
  getPhotoTimestamp
} from "./timeline.js";

const state = {
  photos: [],
  visiblePhotos: [],
  objectUrls: [],
  processed: 0,
  total: 0,
  activePhotoId: null,
  timelineSelection: null,
  timelineViewport: null,
  timeline: {
    intervalMs: 15 * 60 * 1000,
    bins: [],
    start: null,
    end: null
  }
};
let activeImportController = null;
const IMPORT_RENDER_INTERVAL_MS = 180;
const IMPORT_CONCURRENCY = 2;
let scheduledRenderFrame = null;
let scheduledRenderTimer = null;
let scheduledRenderOptions = { fitMap: false };
let lastRenderAt = 0;
const SHARE_PARAM_NAME = "share";
const PHOTO_PARAM_NAME = "photo";
const OVERLAY_ZOOM_LEVEL = 16;
let pendingOverlayPhotoName = "";

const map = L.map("map", { zoomControl: true }).setView([52.5208, 13.4095], 13);
const lightMapLayer = L.tileLayer("https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png", {
  maxZoom: 20,
  subdomains: "abcd",
  attribution: "&copy; OpenStreetMap contributors &copy; CARTO"
});
const darkMapLayer = L.tileLayer("https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", {
  maxZoom: 20,
  subdomains: "abcd",
  attribution: "&copy; OpenStreetMap contributors &copy; CARTO"
});
const mapLayers = {
  light: lightMapLayer,
  dark: darkMapLayer
};
let activeMapLayer = lightMapLayer;
activeMapLayer.addTo(map);

const markers = L.layerGroup().addTo(map);
const route = L.polyline([], {
  color: "#2d6cdf",
  weight: 4,
  opacity: 0.85
});
const routeColors = {
  light: "#2d6cdf",
  dark: "#7dd3fc"
};
const ROUTE_VISIBLE_STORAGE_KEY = "routeVisible";
let routeVisible = localStorage.getItem(ROUTE_VISIBLE_STORAGE_KEY) !== "false";

const overlay = mustGet("overlay");
const overlayTitle = mustGet("overlay-title");
const overlayImage = mustGet("overlay-image");
const overlayLoading = mustGet("overlay-loading");
const overlayLoadingText = mustGet("overlay-loading-text");
const overlayMedia = mustGet("overlay-media");
const overlayPrev = mustGet("overlay-prev");
const overlayNext = mustGet("overlay-next");
const overlayDownload = mustGet("overlay-download");
const closeOverlay = mustGet("close-overlay");
const routeToggle = mustGet("route-toggle");
const routeToggleIcon = mustGet("route-toggle-icon");
const photoList = mustGet("photo-list");
const emptyState = mustGet("empty-state");
const photoCount = mustGet("photo-count");
const progressFill = mustGet("progress-fill");
const progressText = mustGet("progress-text");
const progressDetail = mustGet("progress-detail");
const activitySpinner = mustGet("activity-spinner");
const timelineSummary = mustGet("timeline-summary");
const timelineClearButton = mustGet("timeline-clear");
const timelineChart = mustGet("timeline-chart");
const timelineBars = mustGet("timeline-bars");
const timelineSelection = mustGet("timeline-selection");
const timelineBrush = mustGet("timeline-brush");
const themeToggle = mustGet("theme-toggle");
const themeToggleIcon = mustGet("theme-toggle-icon");
const panelLogo = mustGet("panel-logo");
const sidebarCollapseButton = mustGet("sidebar-collapse");
const sidebarCollapseIcon = mustGet("sidebar-collapse-icon");
const timelineCollapseButton = mustGet("timeline-collapse");
const timelineCollapseIcon = mustGet("timeline-collapse-icon");
const importUrlInput = mustGet("share-url");
const importStatus = mustGet("share-status");
const importButton = mustGet("load-share");
const stopImportButton = mustGet("cancel-share");
let overlayLoadToken = 0;

function mustGet(id) {
  const element = document.getElementById(id);
  if (!element) {
    throw new Error("Missing UI element: " + id);
  }
  return element;
}

function escapeHtml(value) {
  return String(value ?? "").replace(/[&<>"']/g, (character) => ({
    "&": "&amp;",
    "<": "&lt;",
    ">": "&gt;",
    '"': "&quot;",
    "'": "&#39;"
  })[character]);
}

function buildRemoteImageUrl(shareUrlValue, entryHref) {
  const params = new URLSearchParams({
    share: shareUrlValue.trim(),
    url: entryHref
  });
  return "/api/nextcloud/blob?" + params.toString();
}

async function loadImageElement(blob) {
  const objectUrl = URL.createObjectURL(blob);
  const image = new Image();

  try {
    await new Promise((resolve, reject) => {
      image.onload = () => resolve();
      image.onerror = () => reject(new Error("Could not decode image."));
      image.src = objectUrl;
    });
    return image;
  } finally {
    URL.revokeObjectURL(objectUrl);
  }
}

async function createThumbnailObjectUrl(blob) {
  const maxDimension = 320;
  const quality = 0.82;
  let width = 0;
  let height = 0;
  let drawSource = null;
  let closeBitmap = null;
  let sourceImage = null;

  if (typeof createImageBitmap === "function") {
    const bitmap = await createImageBitmap(blob);
    width = bitmap.width;
    height = bitmap.height;
    drawSource = bitmap;
    closeBitmap = () => bitmap.close();
  } else {
    sourceImage = await loadImageElement(blob);
    width = sourceImage.naturalWidth;
    height = sourceImage.naturalHeight;
    drawSource = sourceImage;
  }

  const scale = Math.min(1, maxDimension / Math.max(width, height));
  const targetWidth = Math.max(1, Math.round(width * scale));
  const targetHeight = Math.max(1, Math.round(height * scale));
  const canvas = document.createElement("canvas");
  const context = canvas.getContext("2d");

  if (!context) {
    if (closeBitmap) {
      closeBitmap();
    }
    throw new Error("Canvas 2D context unavailable.");
  }

  canvas.width = targetWidth;
  canvas.height = targetHeight;
  context.drawImage(drawSource, 0, 0, targetWidth, targetHeight);

  const thumbnailBlob = await new Promise((resolve, reject) => {
    canvas.toBlob(
      (result) => {
        if (result) {
          resolve(result);
          return;
        }
        reject(new Error("Could not create thumbnail."));
      },
      "image/jpeg",
      quality
    );
  });

  if (closeBitmap) {
    closeBitmap();
  }

  if (sourceImage) {
    sourceImage.removeAttribute("src");
  }

  return URL.createObjectURL(thumbnailBlob);
}

function setBrandLogo(theme) {
  panelLogo.src = theme === "dark" ? "/assets/logo-dark.png" : "/assets/logo-light.png";
}

function setTheme(theme, persist = true) {
  const resolvedTheme = theme === "light" ? "light" : "dark";
  document.body.dataset.theme = resolvedTheme;
  if (persist) {
    localStorage.setItem("theme", resolvedTheme);
  }
  setMapTheme(resolvedTheme);
  setBrandLogo(resolvedTheme);

  if (resolvedTheme === "dark") {
    themeToggle.setAttribute("aria-label", "Switch to light mode");
    themeToggleIcon.innerHTML =
      '<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">' +
      '<path d="M10.7 12.2a5.7 5.7 0 1 1 0-8.4 4.9 4.9 0 0 0 0 8.4Z" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/>' +
      "</svg>";
  } else {
    themeToggle.setAttribute("aria-label", "Switch to dark mode");
    themeToggleIcon.innerHTML =
      '<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">' +
      '<path d="M8 1.8V3.1M8 12.9v1.3M3.1 3.1l.9.9M12 12l.9.9M1.8 8H3.1M12.9 8h1.3M3.1 12.9l.9-.9M12 4l.9-.9" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>' +
      '<circle cx="8" cy="8" r="3.2" stroke="currentColor" stroke-width="1.4"/>' +
      "</svg>";
  }
}

function setMapTheme(theme) {
  const resolvedTheme = theme === "dark" ? "dark" : "light";
  const nextMapLayer = mapLayers[resolvedTheme];

  if (activeMapLayer !== nextMapLayer) {
    map.removeLayer(activeMapLayer);
    activeMapLayer = nextMapLayer;
    activeMapLayer.addTo(map);
  }

  route.setStyle({
    color: routeColors[resolvedTheme]
  });
}

function getPhotoNameFromLocation() {
  const value = new URL(window.location.href).searchParams.get(PHOTO_PARAM_NAME);
  return value ? value.trim() : "";
}

function syncPhotoNameInLocation(photoName) {
  const nextUrl = new URL(window.location.href);
  const trimmed = photoName.trim();

  if (trimmed) {
    nextUrl.searchParams.set(PHOTO_PARAM_NAME, trimmed);
  } else {
    nextUrl.searchParams.delete(PHOTO_PARAM_NAME);
  }

  if (nextUrl.toString() !== window.location.href) {
    window.history.replaceState({}, "", nextUrl);
  }
}

function syncOverlayDownload(photo) {
  if (!photo) {
    overlayDownload.setAttribute("href", "#");
    overlayDownload.setAttribute("aria-disabled", "true");
    overlayDownload.removeAttribute("download");
    return;
  }

  overlayDownload.href = photo.fullUrl;
  overlayDownload.download = photo.name;
  overlayDownload.setAttribute("aria-label", "Download photo");
  overlayDownload.setAttribute("aria-disabled", "false");
}

function getOverlayPhotos() {
  if (!state.visiblePhotos.length) {
    return state.photos;
  }

  if (state.activePhotoId && state.visiblePhotos.some((photo) => photo.id === state.activePhotoId)) {
    return state.visiblePhotos;
  }

  return state.photos.length ? state.photos : state.visiblePhotos;
}

function findPhotoByName(photoName, photos = state.photos) {
  return photos.find((photo) => photo.name === photoName) ?? null;
}

function zoomMapToPhoto(photo) {
  if (photo.latitude === null || photo.longitude === null) {
    return;
  }

  const targetZoom = Math.min(18, Math.max(map.getZoom(), OVERLAY_ZOOM_LEVEL));
  map.setView([photo.latitude, photo.longitude], targetZoom, { animate: false });
}

function updateOverlayNavigationState() {
  const photos = getOverlayPhotos();
  const hasNavigation = photos.length > 1;
  overlayPrev.disabled = !hasNavigation;
  overlayNext.disabled = !hasNavigation;
  overlayPrev.setAttribute("aria-hidden", hasNavigation ? "false" : "true");
  overlayNext.setAttribute("aria-hidden", hasNavigation ? "false" : "true");
}

function openPhotoOverlay(photo, options = {}) {
  const photoName = photo.name ?? "";
  pendingOverlayPhotoName = "";
  state.activePhotoId = photo.id;
  renderVisiblePhotos({ fitMap: false, preserveMapView: true });
  updateOverlayNavigationState();

  if (options.syncLocation !== false) {
    syncPhotoNameInLocation(photoName);
  }

  if (options.zoomMap !== false) {
    zoomMapToPhoto(photo);
  }

  openOverlay(photo);
}

function navigateOverlayPhoto(direction) {
  const photos = getOverlayPhotos();
  if (photos.length < 2 || !state.activePhotoId) {
    return;
  }

  const index = photos.findIndex((photo) => photo.id === state.activePhotoId);
  if (index < 0) {
    return;
  }

  const nextIndex = (index + direction + photos.length) % photos.length;
  openPhotoOverlay(photos[nextIndex]);
}

function tryOpenPendingPhoto() {
  if (!pendingOverlayPhotoName || overlay.classList.contains("open")) {
    return;
  }

  const targetPhoto = findPhotoByName(pendingOverlayPhotoName);
  if (!targetPhoto) {
    return;
  }

  openPhotoOverlay(targetPhoto);
}

function setRouteVisible(visible) {
  routeVisible = Boolean(visible);
  localStorage.setItem(ROUTE_VISIBLE_STORAGE_KEY, routeVisible ? "true" : "false");

  if (routeVisible) {
    if (!map.hasLayer(route)) {
      route.addTo(map);
    }
    routeToggle.setAttribute("aria-label", "Hide route line");
    routeToggle.setAttribute("aria-pressed", "true");
    routeToggleIcon.innerHTML =
      '<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">' +
      '<path d="M3 11.5 6.2 8.2c.4-.4 1-.5 1.5-.2l1.1.7c.5.3 1.1.2 1.5-.2L13 5.8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>' +
      '<circle cx="3" cy="11.5" r="1" fill="currentColor"/>' +
      '<circle cx="13" cy="5.8" r="1" fill="currentColor"/>' +
      "</svg>";
  } else {
    if (map.hasLayer(route)) {
      map.removeLayer(route);
    }
    routeToggle.setAttribute("aria-label", "Show route line");
    routeToggle.setAttribute("aria-pressed", "false");
    routeToggleIcon.innerHTML =
      '<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">' +
      '<path d="M3 11.5 6.2 8.2c.4-.4 1-.5 1.5-.2l1.1.7c.5.3 1.1.2 1.5-.2L13 5.8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>' +
      '<circle cx="3" cy="11.5" r="1" fill="currentColor"/>' +
      '<circle cx="13" cy="5.8" r="1" fill="currentColor"/>' +
      '<path d="M3 13 13 3" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>' +
      "</svg>";
  }
}

function scheduleMapResize() {
  window.clearTimeout(scheduleMapResize.timeoutId);
  scheduleMapResize.timeoutId = window.setTimeout(() => {
    map.invalidateSize();
  }, 220);
}
scheduleMapResize.timeoutId = 0;

function setSidebarCollapsed(collapsed) {
  const isCollapsed = Boolean(collapsed);
  document.querySelector("aside")?.classList.toggle("sidebar-collapsed", isCollapsed);
  localStorage.setItem("sidebarCollapsed", isCollapsed ? "true" : "false");
  sidebarCollapseButton.setAttribute("aria-label", isCollapsed ? "Expand left panel" : "Collapse left panel");
  sidebarCollapseIcon.innerHTML = isCollapsed
    ? '<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6.5 4 10.5 8l-4 4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>'
    : '<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M9.5 4 5.5 8l4 4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>';
  scheduleMapResize();
}

function setTimelineCollapsed(collapsed) {
  const isCollapsed = Boolean(collapsed);
  document.querySelector(".timeline")?.classList.toggle("timeline-collapsed", isCollapsed);
  localStorage.setItem("timelineCollapsed", isCollapsed ? "true" : "false");
  timelineCollapseButton.setAttribute("aria-label", isCollapsed ? "Expand timeline" : "Collapse timeline");
  timelineCollapseIcon.innerHTML = isCollapsed
    ? '<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M4 9.5 8 5.5l4 4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>'
    : '<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M4 6.5 8 10.5l4-4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>';
  scheduleMapResize();
}

function getShareUrlFromLocation() {
  const value = new URL(window.location.href).searchParams.get(SHARE_PARAM_NAME);
  return value ? value.trim() : "";
}

function syncShareUrlInLocation(shareUrlValue) {
  const nextUrl = new URL(window.location.href);
  const trimmed = shareUrlValue.trim();
  if (trimmed) {
    nextUrl.searchParams.set(SHARE_PARAM_NAME, trimmed);
  } else {
    nextUrl.searchParams.delete(SHARE_PARAM_NAME);
  }

  if (nextUrl.toString() !== window.location.href) {
    window.history.replaceState({}, "", nextUrl);
  }
}

const themeMediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const storedTheme = localStorage.getItem("theme");
const initialTheme = storedTheme === "dark" || (storedTheme !== "light" && themeMediaQuery.matches) ? "dark" : "light";
setTheme(initialTheme, false);
themeToggle.addEventListener("click", () => {
  setTheme(document.body.dataset.theme === "dark" ? "light" : "dark");
});
if (storedTheme === null) {
  themeMediaQuery.addEventListener("change", (event) => {
    setTheme(event.matches ? "dark" : "light", false);
  });
}
setRouteVisible(routeVisible);
routeToggle.addEventListener("click", () => {
  setRouteVisible(!routeVisible);
});

const storedSidebarCollapsed = localStorage.getItem("sidebarCollapsed") === "true";
setSidebarCollapsed(storedSidebarCollapsed);
sidebarCollapseButton.addEventListener("click", () => {
  setSidebarCollapsed(!document.querySelector("aside")?.classList.contains("sidebar-collapsed"));
});

setTimelineCollapsed(false);
timelineCollapseButton.addEventListener("click", () => {
  setTimelineCollapsed(!document.querySelector(".timeline")?.classList.contains("timeline-collapsed"));
});

function formatDate(value) {
  if (!value) {
    return "no timestamp";
  }
  const date = new Date(value);
  return Number.isNaN(date.getTime())
    ? value
    : date.toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short" });
}

function normalizeExifDate(value) {
  if (typeof value !== "string") {
    return null;
  }
  const match = value.match(/^(\d{4}):(\d{2}):(\d{2}) (\d{2}):(\d{2}):(\d{2})$/);
  if (!match) {
    return null;
  }
  const [, year, month, day, hour, minute, second] = match;
  const date = new Date(
    Number(year),
    Number(month) - 1,
    Number(day),
    Number(hour),
    Number(minute),
    Number(second)
  );
  return Number.isNaN(date.getTime()) ? null : date.toISOString();
}

function parseDateOrNull(value) {
  if (!value) {
    return null;
  }
  const date = new Date(value);
  return Number.isNaN(date.getTime()) ? null : date.toISOString();
}

function clamp(value, min, max) {
  return Math.min(max, Math.max(min, value));
}

function setImportStatus(message, tone = "info") {
  importStatus.textContent = message;
  importStatus.style.color = tone === "error" ? "#9d174d" : "var(--muted)";
}

function setProgress(processed, total, detail) {
  state.processed = processed;
  state.total = total;
  progressFill.style.width = total > 0 ? Math.round((processed / total) * 100) + "%" : "0%";
  progressText.textContent = processed + " / " + total;
  progressDetail.textContent = detail;
}

function setImporting(isImporting) {
  activitySpinner.classList.toggle("active", isImporting);
  importButton.disabled = isImporting;
  stopImportButton.disabled = !isImporting;
}

function clearObjectUrls() {
  for (const url of state.objectUrls) {
    URL.revokeObjectURL(url);
  }
  state.objectUrls = [];
}

function clearGallery() {
  cancelScheduledRender();
  closeOverlayView();
  state.photos = [];
  state.visiblePhotos = [];
  state.activePhotoId = null;
  state.timelineSelection = null;
  state.timelineViewport = null;
  state.timeline = {
    intervalMs: 15 * 60 * 1000,
    bins: [],
    start: null,
    end: null
  };
  markers.clearLayers();
  route.setLatLngs([]);
  photoList.replaceChildren(emptyState);
  emptyState.textContent = "No images imported yet.";
  photoCount.textContent = "0 photos";
  timelineSummary.textContent = "No photos imported yet.";
  timelineClearButton.disabled = true;
  timelineBars.replaceChildren();
  timelineSelection.classList.remove("visible");
  timelineSelection.style.left = "0";
  timelineSelection.style.width = "0";
  timelineBrush.classList.remove("visible");
  setProgress(0, 0, "idle");
}

function openOverlay(photo) {
  const requestToken = ++overlayLoadToken;
  overlay.classList.add("open");
  overlay.setAttribute("aria-hidden", "false");
  overlayTitle.textContent = photo.name;
  overlayImage.alt = photo.name;
  syncOverlayDownload(photo);
  overlay.classList.add("loading");
  overlayLoading.classList.add("active");
  overlayLoading.classList.remove("error");
  overlayLoadingText.textContent = "Loading full-size image...";
  overlayImage.onload = () => {
    if (requestToken !== overlayLoadToken) {
      return;
    }
    overlay.classList.remove("loading");
    overlayLoading.classList.remove("active");
    overlayLoading.classList.remove("error");
  };
  overlayImage.onerror = () => {
    if (requestToken !== overlayLoadToken) {
      return;
    }
    overlayLoadingText.textContent = "Could not load full-size image.";
    overlay.classList.remove("loading");
    overlayLoading.classList.add("error");
    overlayLoading.classList.add("active");
  };
  overlayImage.removeAttribute("src");
  overlayImage.src = photo.fullUrl;
}

function closeOverlayView() {
  overlayLoadToken += 1;
  overlay.classList.remove("open");
  overlay.setAttribute("aria-hidden", "true");
  overlay.classList.remove("loading");
  overlayLoading.classList.remove("active");
  overlayLoading.classList.remove("error");
  overlayLoadingText.textContent = "Loading full-size image...";
  overlayImage.removeAttribute("src");
  syncOverlayDownload(null);
  syncPhotoNameInLocation("");
  overlaySwipeState = null;
}

closeOverlay.addEventListener("click", closeOverlayView);
overlay.addEventListener("click", (event) => {
  if (event.target === overlay) {
    closeOverlayView();
  }
});

overlayPrev.addEventListener("click", () => {
  navigateOverlayPhoto(-1);
});

overlayNext.addEventListener("click", () => {
  navigateOverlayPhoto(1);
});

let overlaySwipeState = null;

overlayMedia.addEventListener("pointerdown", (event) => {
  if (!overlay.classList.contains("open") || event.button !== 0 || event.pointerType === "mouse") {
    return;
  }

  overlaySwipeState = {
    pointerId: event.pointerId,
    startX: event.clientX,
    startY: event.clientY,
    moved: false
  };

  overlayMedia.setPointerCapture(event.pointerId);
});

overlayMedia.addEventListener("pointermove", (event) => {
  if (!overlaySwipeState || overlaySwipeState.pointerId !== event.pointerId) {
    return;
  }

  const deltaX = event.clientX - overlaySwipeState.startX;
  const deltaY = event.clientY - overlaySwipeState.startY;
  overlaySwipeState.moved = overlaySwipeState.moved || Math.abs(deltaX) > 10 || Math.abs(deltaY) > 10;
});

function finishOverlaySwipe(event) {
  if (!overlaySwipeState || overlaySwipeState.pointerId !== event.pointerId) {
    return;
  }

  try {
    overlayMedia.releasePointerCapture(event.pointerId);
  } catch {
    // Pointer capture may already be released.
  }

  const deltaX = event.clientX - overlaySwipeState.startX;
  const deltaY = event.clientY - overlaySwipeState.startY;
  const isHorizontalSwipe = Math.abs(deltaX) > 60 && Math.abs(deltaX) > Math.abs(deltaY) * 1.2;

  if (isHorizontalSwipe) {
    navigateOverlayPhoto(deltaX < 0 ? 1 : -1);
  }

  overlaySwipeState = null;
}

overlayMedia.addEventListener("pointerup", finishOverlaySwipe);
overlayMedia.addEventListener("pointercancel", () => {
  if (overlaySwipeState) {
    try {
      overlayMedia.releasePointerCapture(overlaySwipeState.pointerId);
    } catch {
      // Pointer capture may already be released.
    }
  }
  overlaySwipeState = null;
});

function updateRouteView(photos, options = {}) {
  const routePoints = photos
    .filter((photo) => photo.latitude !== null && photo.longitude !== null && photo.capturedAt)
    .sort((a, b) => String(a.capturedAt).localeCompare(String(b.capturedAt)))
    .map((photo) => [photo.latitude, photo.longitude]);

  route.setLatLngs(routePoints);

  if (options.preserveMapView) {
    return;
  }

  if (routePoints.length === 1) {
    map.setView(routePoints[0], 13);
  } else if (routePoints.length > 1) {
    map.fitBounds(route.getBounds(), { padding: [40, 40] });
  }
}

function isPhotoInSelection(photo, selection = state.timelineSelection) {
  if (!selection) {
    return true;
  }

  const photoDate = getPhotoTimestamp(photo);
  if (!photoDate) {
    return false;
  }

  return photoDate.getTime() >= selection.start && photoDate.getTime() < selection.end;
}

function getVisiblePhotos() {
  return state.photos.filter((photo) => isPhotoInSelection(photo));
}

function syncTimelineSelectionOverlay() {
  const selection = state.timelineSelection;
  if (!selection || !state.timeline.start || !state.timeline.end) {
    timelineSelection.classList.remove("visible");
    timelineSelection.style.left = "0";
    timelineSelection.style.width = "0";
    return;
  }

  const total = state.timeline.end.getTime() - state.timeline.start.getTime();
  if (total <= 0) {
    timelineSelection.classList.remove("visible");
    return;
  }

  const left = clamp((selection.start - state.timeline.start.getTime()) / total, 0, 1);
  const right = clamp((selection.end - state.timeline.start.getTime()) / total, 0, 1);
  const startPct = Math.min(left, right) * 100;
  const widthPct = Math.max(1, (Math.max(left, right) - Math.min(left, right)) * 100);
  timelineSelection.classList.add("visible");
  timelineSelection.style.left = startPct + "%";
  timelineSelection.style.width = widthPct + "%";
}

function setTimelineSelection(start, end) {
  if (start === null || end === null) {
    state.timelineSelection = null;
    state.timelineViewport = null;
    timelineClearButton.disabled = true;
  } else {
    const normalizedStart = Math.min(start, end);
    const normalizedEnd = Math.max(start, end);
    state.timelineSelection = {
      start: normalizedStart,
      end: normalizedEnd
    };
    state.timelineViewport = {
      start: normalizedStart,
      end: normalizedEnd
    };
    timelineClearButton.disabled = false;
  }

  renderVisiblePhotos({ fitMap: true });
}

function clearTimelineSelection() {
  setTimelineSelection(null, null);
}

function renderTimeline() {
  const timelinePhotos = state.timelineSelection ? state.visiblePhotos : state.photos;
  const timeline = buildTimeline(timelinePhotos, state.timelineViewport);
  state.timeline = timeline;
  timelineSummary.textContent = formatTimelineSummary(state.photos);

  timelineBars.replaceChildren();

  if (!timeline.bins.length) {
    syncTimelineSelectionOverlay();
    return;
  }

  const maxCount = Math.max(...timeline.bins.map((bin) => bin.count), 1);
  const labelStep = Math.max(1, Math.ceil(timeline.bins.length / 6));

  timeline.bins.forEach((bin, index) => {
    const bar = document.createElement("button");
    const isSelected = state.timelineSelection
      ? bin.end.getTime() > state.timelineSelection.start && bin.start.getTime() < state.timelineSelection.end
      : false;
    const isActive = state.activePhotoId ? bin.photoIds.includes(state.activePhotoId) : false;

    bar.type = "button";
    bar.className = "timeline-bar" + (isSelected ? " selected" : "") + (isActive ? " active" : "");
    bar.title = bin.label + " - " + bin.count + (bin.count === 1 ? " photo" : " photos");
    bar.style.gridColumn = String(index + 1);

    const fill = document.createElement("div");
    fill.className = "timeline-bar-fill";
    fill.style.height = Math.max(4, Math.round((bin.count / maxCount) * 100)) + "%";
    bar.appendChild(fill);

    const meta = document.createElement("span");
    meta.className = "timeline-bar-meta";
    meta.textContent = index % labelStep === 0 || index === timeline.bins.length - 1 ? bin.label : "";
    bar.appendChild(meta);

    bar.addEventListener("click", () => {
      if (timelineClickSuppressed) {
        return;
      }
      setTimelineSelection(bin.start.getTime(), bin.end.getTime());
    });

    timelineBars.appendChild(bar);
  });

  syncTimelineSelectionOverlay();
}

function renderVisiblePhotos(options = {}) {
  const visiblePhotos = getVisiblePhotos();
  state.visiblePhotos = visiblePhotos;
  markers.clearLayers();
  route.setLatLngs([]);
  photoList.replaceChildren();

  if (!visiblePhotos.length) {
    emptyState.textContent = state.photos.length
      ? "No photos match the selected range."
      : "No images imported yet.";
    photoList.appendChild(emptyState);
    photoCount.textContent = "0 photos";
  } else {
    emptyState.textContent = "No images imported yet.";
    photoCount.textContent =
      visiblePhotos.length + (visiblePhotos.length === 1 ? " photo shown" : " photos shown");

    for (const photo of visiblePhotos) {
      if (photo.latitude !== null && photo.longitude !== null) {
          const marker = L.marker([photo.latitude, photo.longitude], {
            icon: createPhotoMarkerIcon(L, photo, state.activePhotoId, escapeHtml)
          }).addTo(markers);

        marker.bindPopup(
          '<div class="thumb">' +
            '<img src="' + escapeHtml(photo.thumbUrl) + '" alt="' + escapeHtml(photo.name) + '" />' +
            '<strong>' + escapeHtml(photo.name) + '</strong>' +
            '<small>' + escapeHtml(formatDate(photo.capturedAt)) + '</small>' +
          '</div>'
        );

        marker.on("mouseover", () => marker.openPopup());
        marker.on("click", () => {
          openPhotoOverlay(photo);
        });
      }

      const item = document.createElement("article");
      item.className = "photo" + (photo.id === state.activePhotoId ? " active" : "");
      const photoTitle = photo.name + " - " + formatDate(photo.capturedAt);
      item.title = photoTitle;
      item.setAttribute("aria-label", photoTitle);
      item.innerHTML =
        '<img src="' + escapeHtml(photo.thumbUrl) + '" alt="' + escapeHtml(photo.name) + '" />';
      item.addEventListener("click", () => {
        openPhotoOverlay(photo);
      });
      photoList.appendChild(item);
    }
  }

  updateRouteView(visiblePhotos, options);
  renderTimeline();
  updateOverlayNavigationState();

  if (options.fitMap && visiblePhotos.length) {
    const bounds = visiblePhotos.filter((photo) => photo.latitude !== null && photo.longitude !== null);
    if (bounds.length === 1) {
      map.setView([bounds[0].latitude, bounds[0].longitude], 13);
    } else if (bounds.length > 1) {
      map.fitBounds(bounds.map((photo) => [photo.latitude, photo.longitude]), { padding: [40, 40] });
    }
  }
}

function runScheduledRender() {
  scheduledRenderFrame = null;
  const options = scheduledRenderOptions;
  scheduledRenderOptions = { fitMap: false };
  lastRenderAt = performance.now();
  renderVisiblePhotos(options);
}

function scheduleVisiblePhotoRender(options = {}) {
  scheduledRenderOptions = {
    fitMap: Boolean(scheduledRenderOptions.fitMap || options.fitMap)
  };

  if (scheduledRenderFrame !== null || scheduledRenderTimer !== null) {
    return;
  }

  const elapsed = performance.now() - lastRenderAt;
  const delay = Math.max(0, IMPORT_RENDER_INTERVAL_MS - elapsed);
  const queueFrame = () => {
    scheduledRenderTimer = null;
    scheduledRenderFrame = requestAnimationFrame(runScheduledRender);
  };

  if (delay === 0) {
    queueFrame();
  } else {
    scheduledRenderTimer = setTimeout(queueFrame, delay);
  }
}

function cancelScheduledRender() {
  if (scheduledRenderFrame !== null) {
    cancelAnimationFrame(scheduledRenderFrame);
    scheduledRenderFrame = null;
  }

  if (scheduledRenderTimer !== null) {
    clearTimeout(scheduledRenderTimer);
    scheduledRenderTimer = null;
  }

  scheduledRenderOptions = { fitMap: false };
}

function flushVisiblePhotoRender(options = {}) {
  const renderOptions = {
    fitMap: Boolean(scheduledRenderOptions.fitMap || options.fitMap)
  };
  cancelScheduledRender();
  lastRenderAt = performance.now();
  renderVisiblePhotos(renderOptions);
}

let timelinePointerState = null;
let timelineClickSuppressed = false;

function updateTimelineBrushOverlay(startX, endX) {
  if (!state.timeline.start || !state.timeline.end) {
    timelineBrush.classList.remove("visible");
    return;
  }

  const width = timelineChart.getBoundingClientRect().width;
  if (width <= 0) {
    timelineBrush.classList.remove("visible");
    return;
  }

  const leftX = clamp(Math.min(startX, endX), 0, width);
  const rightX = clamp(Math.max(startX, endX), 0, width);
  timelineChart.classList.add("selecting");
  timelineBrush.classList.add("visible");
  timelineBrush.style.left = leftX + "px";
  timelineBrush.style.width = Math.max(1, rightX - leftX) + "px";
}

function clearTimelineBrushOverlay() {
  timelineChart.classList.remove("selecting");
  timelineBrush.classList.remove("visible");
  timelineBrush.style.left = "0";
  timelineBrush.style.width = "0";
}

function timeFromTimelineX(x) {
  if (!state.timeline.start || !state.timeline.end) {
    return null;
  }

  const width = timelineChart.getBoundingClientRect().width;
  if (width <= 0) {
    return null;
  }

  const ratio = clamp(x / width, 0, 1);
  const time = state.timeline.start.getTime() + ratio * (state.timeline.end.getTime() - state.timeline.start.getTime());
  return new Date(time);
}

function selectTimelineBinAtX(x) {
  if (!state.timeline.bins.length || !state.timeline.start || !state.timeline.end) {
    return;
  }

  const width = timelineChart.getBoundingClientRect().width;
  if (width <= 0) {
    return;
  }

  const ratio = clamp(x / width, 0, 1);
  const index = clamp(Math.floor(ratio * state.timeline.bins.length), 0, state.timeline.bins.length - 1);
  const bin = state.timeline.bins[index];
  setTimelineSelection(bin.start.getTime(), bin.end.getTime());
}

timelineChart.addEventListener("pointerdown", (event) => {
  if (!state.timeline.bins.length) {
    return;
  }

  const rect = timelineChart.getBoundingClientRect();
  const startX = event.clientX - rect.left;
  timelinePointerState = {
    pointerId: event.pointerId,
    startX,
    currentX: startX,
    moved: false
  };

  timelineChart.setPointerCapture(event.pointerId);
  updateTimelineBrushOverlay(startX, startX);
});

timelineChart.addEventListener("pointermove", (event) => {
  if (!timelinePointerState || timelinePointerState.pointerId !== event.pointerId) {
    return;
  }

  const rect = timelineChart.getBoundingClientRect();
  const currentX = event.clientX - rect.left;
  timelinePointerState.currentX = currentX;
  timelinePointerState.moved = timelinePointerState.moved || Math.abs(currentX - timelinePointerState.startX) > 4;
  updateTimelineBrushOverlay(timelinePointerState.startX, currentX);
});

function finishTimelinePointer(event) {
  if (!timelinePointerState || timelinePointerState.pointerId !== event.pointerId) {
    return;
  }

  try {
    timelineChart.releasePointerCapture(event.pointerId);
  } catch {
    // Pointer capture may already be released.
  }

  if (timelinePointerState.moved) {
    const startTime = timeFromTimelineX(timelinePointerState.startX);
    const endTime = timeFromTimelineX(timelinePointerState.currentX);
    if (startTime && endTime) {
      setTimelineSelection(startTime.getTime(), endTime.getTime());
    }
    timelineClickSuppressed = true;
    setTimeout(() => {
      timelineClickSuppressed = false;
    }, 0);
  } else {
    selectTimelineBinAtX(timelinePointerState.currentX);
  }

  clearTimelineBrushOverlay();
  timelinePointerState = null;
}

timelineChart.addEventListener("pointerup", finishTimelinePointer);
timelineChart.addEventListener("pointercancel", finishTimelinePointer);

timelineClearButton.addEventListener("click", clearTimelineSelection);

function appendPhoto(photo) {
  state.photos.push(photo);
  scheduleVisiblePhotoRender({ fitMap: state.photos.length === 1 });
  tryOpenPendingPhoto();
}

async function loadImportListing(shareUrlValue, signal) {
  const davBaseUrl = resolveDavBaseUrl(shareUrlValue);
  const response = await fetch(
    "/api/nextcloud/list?share=" + encodeURIComponent(shareUrlValue.trim()),
    {
      signal
    }
  );

  if (!response.ok) {
    throw new Error("Could not load the Nextcloud listing: " + response.status);
  }

  return parseListing(await response.text(), davBaseUrl);
}

async function readRemoteImage(entry, shareUrlValue, signal) {
  const fullUrl = buildRemoteImageUrl(shareUrlValue, entry.href);
  const response = await fetch(fullUrl, {
    signal
  });
  if (!response.ok) {
    throw new Error("Could not load image: " + entry.name);
  }

  const blob = await response.blob();
  const [gps, tags] = await Promise.all([exifr.gps(blob), exifr.parse(blob)]);
  const capturedAt =
    normalizeExifDate(tags?.DateTimeOriginal?.value ?? tags?.DateTimeOriginal) ||
    parseDateOrNull(entry.lastModified);

  if (gps?.latitude === undefined || gps?.longitude === undefined) {
    return null;
  }

  const thumbUrl = await createThumbnailObjectUrl(blob);
  state.objectUrls.push(thumbUrl);

  return {
    id: entry.href,
    name: entry.name,
    latitude: gps.latitude,
    longitude: gps.longitude,
    capturedAt,
    thumbUrl,
    fullUrl,
    source: "nextcloud"
  };
}

async function processImportEntries(listing, shareUrlValue, signal) {
  let loaded = 0;
  let skipped = 0;
  let processed = 0;
  let nextIndex = 0;

  async function importWorker() {
    while (nextIndex < listing.length) {
      if (signal.aborted) {
        throw signal.reason ?? new Error("Import canceled");
      }

      const entry = listing[nextIndex];
      nextIndex += 1;

      try {
        const photo = await readRemoteImage(entry, shareUrlValue, signal);
        if (photo) {
          appendPhoto(photo);
          loaded += 1;
        } else {
          skipped += 1;
        }
      } catch (error) {
        if (signal.aborted) {
          throw signal.reason ?? error;
        }
        skipped += 1;
        console.error(error);
      }

      processed += 1;
      setProgress(processed, listing.length, "processed " + processed + " / " + listing.length);
      setImportStatus("Importing: " + loaded + " shown, " + skipped + " skipped.");
      await new Promise((resolve) => setTimeout(resolve, 0));
    }
  }

  const workerCount = Math.min(IMPORT_CONCURRENCY, listing.length);
  await Promise.all(Array.from({ length: workerCount }, () => importWorker()));

  return { loaded, skipped };
}

async function importFromShare() {
  if (activeImportController) {
    return;
  }

  syncShareUrlInLocation(importUrlInput.value);
  const controller = new AbortController();
  activeImportController = controller;
  setImporting(true);

  try {
    clearObjectUrls();
    clearGallery();
    setImportStatus("Importing...");
    const listing = await loadImportListing(importUrlInput.value, controller.signal);

    if (!listing.length) {
      throw new Error("No images found in the share.");
    }

    setProgress(0, listing.length, "checking files");

    const { loaded, skipped } = await processImportEntries(listing, importUrlInput.value, controller.signal);

    if (!loaded) {
      throw new Error("No images with GPS data found.");
    }

    setImportStatus(
      "Import complete: " + loaded + " images imported" + (skipped ? ", " + skipped + " skipped" : "") + "."
    );
    setProgress(listing.length, listing.length, "complete");
    flushVisiblePhotoRender({ fitMap: true });
  } catch (error) {
    if (controller.signal.aborted) {
      flushVisiblePhotoRender();
      setImportStatus("Import stopped.");
      setProgress(state.processed, state.total, "canceled");
      return;
    }

    console.error(error);
    setImportStatus(
      "Import failed: " + (error instanceof Error ? error.message : "unknown error"),
      "error"
    );
    flushVisiblePhotoRender();
  } finally {
    activeImportController = null;
    setImporting(false);
  }
}

importButton.addEventListener("click", () => {
  void importFromShare();
});

stopImportButton.addEventListener("click", () => {
  if (activeImportController) {
    activeImportController.abort("Import canceled by user");
  }
});

const initialShareUrl = getShareUrlFromLocation();
pendingOverlayPhotoName = getPhotoNameFromLocation();
if (initialShareUrl) {
  importUrlInput.value = initialShareUrl;
  window.queueMicrotask(() => {
    void importFromShare();
  });
}

clearGallery();
setImportStatus("Ready to import.");
