<script lang="ts">
  import { browser } from "$app/environment";
  import { goto } from "$app/navigation";
  import { page } from "$app/state";
  import {
    createInfiniteQuery,
    createQuery,
    useQueryClient,
  } from "@tanstack/svelte-query";
  import { toast } from "svelte-sonner";
  import {
    batchDelete,
    getPhoto,
    listHeaps,
    listPhotos,
    listPhotosAround,
    type PpAlbum,
  } from "$lib/services/photoprism";
  import {
    consumePendingFocus,
    filters,
    filtersToQ,
    filtersToUrlParams,
    parseUrlParams,
    setSearch,
    setSection,
    type PendingFocus,
  } from "$lib/stores/filters.svelte";
  import { isAuthenticated } from "$lib/stores/session.svelte";
  import { untrack } from "svelte";
  import {
    clearSelection,
    isSelected,
    selectRange,
    selection,
    setAnchor,
    setFocused,
    setOrder,
  } from "$lib/stores/selection.svelte";
  import { removedIds } from "$lib/stores/bulkAction.svelte";
  import {
    openPreview,
    setRightSidebarWidth,
    setThumbnailSize,
    THUMBNAIL_SIZE_LABELS,
    THUMBNAIL_SIZE_PRESETS,
    view,
  } from "$lib/stores/view.svelte";
  import { tick } from "svelte";
  import { resizable } from "$lib/actions/resizable";
  import { gridKeyNav, type ArrowKey } from "$lib/actions/gridKeyNav";
  import { nearBottom } from "$lib/actions/nearBottom";
  import {
    visibleRange,
    getVisibleRangeHandle,
  } from "$lib/actions/visibleRange";
  import BulkActionBar from "$lib/components/timeline/BulkActionBar.svelte";
  import BulkMetadataSidebar from "$lib/components/sidebar/BulkMetadataSidebar.svelte";
  import PhotoTile from "$lib/components/timeline/PhotoTile.svelte";
  import RightSidebar from "$lib/components/sidebar/RightSidebar.svelte";
  import SkeletonGrid from "$lib/components/timeline/SkeletonGrid.svelte";
  import Toolbar from "$lib/components/layout/Toolbar.svelte";
  import { EmptyState, InlineLoader } from "$lib/components/feedback";
  import {
    AlertCircle,
    Archive,
    EyeOff,
    ImageOff,
    Layers,
    MousePointerClick,
    Sparkles,
  } from "lucide-svelte";
  import { type PpPhoto } from "$lib/types/photoprism";

  // ── URL ↔ filter store sync ──────────────────────────────────────────────
  // On nav (back/forward, deep link), reflect the URL into the store.
  $effect(() => {
    if (!browser) return;
    const next = parseUrlParams(page.url.searchParams);
    if (next.section !== undefined) filters.section = next.section;
    if (next.heapUid !== undefined) filters.heapUid = next.heapUid;
    if (next.folderPath !== undefined) filters.folderPath = next.folderPath;
    if (next.search !== undefined) filters.search = next.search;
  });

  // When the store changes from in-app actions (left-sidebar nav, search
  // box, etc.), push the matching query string back so the URL is shareable.
  let lastWritten = $state("");
  $effect(() => {
    if (!browser) return;
    const qs = filtersToUrlParams().toString();
    const here = page.url.searchParams.toString();
    if (qs === here || qs === lastWritten) return;
    lastWritten = qs;
    void goto(`/${qs ? "?" + qs : ""}`, {
      replaceState: true,
      keepFocus: true,
      noScroll: true,
    });
  });

  // ── Section title ────────────────────────────────────────────────────────
  const heapsQuery = createQuery<PpAlbum[]>(() => ({
    queryKey: ["heaps"],
    queryFn: listHeaps,
    enabled: isAuthenticated(),
  }));

  const sectionLabel = $derived(buildSectionLabel());
  function buildSectionLabel(): string {
    switch (filters.section) {
      case "review":
        return "Review";
      case "archive":
        return "Archive";
      case "hidden":
        return "Hidden";
      case "heap": {
        const heap = (heapsQuery.data ?? []).find(
          (h) => h.UID === filters.heapUid,
        );
        return heap ? `Heap · ${heap.Title}` : "Heap";
      }
      default:
        // 'all-photos' is the internal "no section filter" state —
        // the visible context now comes from the folder filter
        // (root by default). Show the folder path so the title
        // reflects what's actually on screen; only the rare
        // `folderPath === null` case (e.g. right after deleting a
        // heap) still reads as "All photos".
        if (filters.folderPath === "/") return "Folder · /";
        if (filters.folderPath) return `Folder · ${filters.folderPath}`;
        return "All photos";
    }
  }

  // ── Photo list ───────────────────────────────────────────────────────────
  // Infinite scroll. PhotoPrism's `/photos?merged=true` returns a multi-row-
  // per-photo shape (HEIC + companion JPG + MOV each count toward `count`),
  // so the page size is generous: 120 photo entries per page lands ~250-360
  // SQL rows, well below PhotoPrism's 1000-row server cap. Pages flatten
  // downstream into a single `photos` array consumers iterate.
  const PHOTOS_PAGE_SIZE = 120;

  // Anchor mode: when an in-app deep link stashes a pending focus with a
  // TakenAt, the first page is fetched as a window around that date via
  // PhotoPrism's `before:`/`after:` DSL — so the target photo is in the
  // loaded page even when it would otherwise be hundreds of entries past
  // the newest-first cursor. Subsequent pages continue chronologically
  // with a `before:<oldest-loaded-TakenAt>` cursor instead of the
  // standard offset, so the listing stays in newest-first order without
  // jumping around the library. Cleared when the filter changes — a new
  // filter is a fresh listing, possibly with its own anchor.
  let anchor = $state<PendingFocus | null>(null);
  let lastFilterQ: string | null = null;
  // Watch every URL change so we catch pending-focus stashes even when the
  // filter didn't change (e.g. user clicks the open-folder icon for a photo
  // in the folder they're already on — the goto sets the same URL but the
  // user still expects to land on THAT photo). Pure filter changes with
  // no pending stash clear any stale anchor so a subsequent refetch
  // doesn't keep the old window.
  $effect(() => {
    if (!browser) return;
    void page.url.search;
    untrack(() => {
      const pending = consumePendingFocus();
      if (pending) {
        anchor = pending;
        lastFilterQ = filtersToQ(filters);
        return;
      }
      const q = filtersToQ(filters);
      if (q !== lastFilterQ) {
        anchor = null;
        lastFilterQ = q;
      }
    });
  });

  const photosQuery = createInfiniteQuery<PpPhoto[]>(() => ({
    queryKey: [
      "photos",
      "q",
      filtersToQ(filters),
      { count: PHOTOS_PAGE_SIZE, anchor: anchor?.takenAt ?? null },
    ],
    queryFn: ({ pageParam }) => {
      const offset = pageParam as number;
      const baseQ = filtersToQ(filters);
      // Page 0 + anchor → load a window around the anchor's date.
      // Subsequent pages aren't reachable in anchor mode (see
      // getNextPageParam).
      if (offset === 0 && anchor?.takenAt) {
        return listPhotosAround({
          q: baseQ,
          takenAt: anchor.takenAt,
          afterCount: 30,
          beforeCount: 90,
          merged: true,
        });
      }
      return listPhotos({
        q: baseQ,
        count: PHOTOS_PAGE_SIZE,
        offset,
        order: "newest",
        merged: true,
      });
    },
    initialPageParam: 0,
    // PhotoPrism's `count` limits SQL rows; with `merged=true` each
    // photo expands into its file rows, so a "full" page of count=120
    // typically returns ~60 photo entries. The only reliable end-of-
    // pagination signal is an empty page. Costs one extra fetch at the
    // tail (cheap; the empty response is small).
    getNextPageParam: (last, pages) => {
      if (last.length === 0) return undefined;
      // Anchor mode terminates after page 0 — the user sees the 120-
      // photo window around the deep-linked photo. PhotoPrism's
      // `before:` cursor is day-precision, so paginating further
      // chronologically risks dense-day infinite loops (same-day
      // photos exceeding the page size keep the cursor at the same
      // value). To "see more," the user clears the anchor by
      // navigating fresh.
      if (anchor?.takenAt) return undefined;
      return pages.length * PHOTOS_PAGE_SIZE;
    },
    enabled: isAuthenticated(),
  }));

  /** Flattened view of every loaded page, deduplicated by UID. Adjacent
   *  pages can repeat a photo when its file-row span straddles the offset
   *  boundary (a `merged=true` quirk); the Set keeps first occurrence and
   *  preserves order.
   *
   *  Split into two derived values so the heavy dedup pass only runs
   *  when the query's pages array changes (i.e. a fetch landed). The
   *  cheap folder-scope filter then runs whenever the user flips the
   *  filter store — search-as-you-type, section toggle, root vs.
   *  subfolder — without re-walking every page on each keystroke.
   *
   *  When the user picks the root entry in the folder tree we filter
   *  to `Path === ''` here — PhotoPrism's `path:` operator can't
   *  express that match, so the query fetches the whole library and
   *  we strip subfolder rows post-hoc. */
  const dedupedAll = $derived<PpPhoto[]>(
    dedupedPhotos(photosQuery.data?.pages),
  );
  // `removedIds` hides tiles the instant a removing action (archive / delete /
  // restore) succeeds, so the grid updates without waiting on the server-
  // reconcile refetch (see bulkAction store / BulkActionBar).
  const photos = $derived<PpPhoto[]>(
    applyFolderScope(dedupedAll, filters).filter((p) => !removedIds.has(p.UID)),
  );
  function dedupedPhotos(pages: PpPhoto[][] | undefined): PpPhoto[] {
    if (!pages) return [];
    const seen = new Set<string>();
    const out: PpPhoto[] = [];
    for (const page of pages) {
      for (const p of page) {
        if (seen.has(p.UID)) continue;
        seen.add(p.UID);
        out.push(p);
      }
    }
    return out;
  }
  // Root-folder scope is the only client-side filter we apply, and only
  // when the timeline is actually showing a folder view — never when the
  // user is in a heap, has a free-form search, or is on a non-default
  // section (archive / review / hidden). Those views are
  // scoped server-side via the q-DSL and must not be re-filtered here,
  // or labels / search will silently drop subfolder photos when the
  // store hasn't fully hydrated from the URL yet.
  //
  // Root (`/`) historically also restricted to `Path === ''` — i.e.
  // photos sitting directly at the originals root with no subfolder.
  // PhotoPrism's indexer however always nests photos under YYYY/MM, so
  // that view was always empty in practice. Treat root as "the whole
  // library" instead; subfolders still scope normally.
  function applyFolderScope(list: PpPhoto[], f: typeof filters): PpPhoto[] {
    return list;
  }
  const pageCount = $derived(photosQuery.data?.pages.length ?? 0);

  $effect(() => {
    setOrder(photos.map((p) => p.UID));
  });

  /**
   * Auto-focus the first photo on the FIRST page only. Subsequent pages
   * append silently — we never want the focus to jump back to the top of
   * the timeline mid-scroll. `untrack` keeps Escape (which clears focus)
   * from immediately re-triggering this effect.
   *
   * Always re-anchors to photos[0] when pageCount lands on 1 (which
   * only happens on initial load or after a filter change resets the
   * infinite-query) so switching views drops the user back at the top
   * with a fresh focus cursor. Mid-flow mutations (archive / restore /
   * etc.) advance focus themselves via `focusAfter` and don't flip
   * pageCount, so they don't get clobbered by this re-anchor.
   */
  $effect(() => {
    // Re-read pageCount so the effect bottoms out cleanly on filter
    // changes (which reset pageCount back to 0/1).
    const pages = pageCount;
    untrack(() => {
      if (photos.length === 0) {
        setFocused(null);
        return;
      }
      // Only re-anchor focus on the very first page; later pages
      // must not pull focus back to photo[0].
      if (pages !== 1) return;
      // Anchor (from an in-app deep link) takes precedence — its UID is
      // guaranteed in `photos` because page 0 was fetched as a window
      // around its TakenAt. Plain navigations leave anchor null and we
      // snap to photos[0] as before. `scrollToIndex` expands the
      // windowed render set + scrolls the tile into view (with sticky-
      // header peek) — same helper gridKeyNav uses for arrow nav.
      const targetIdx =
        anchor?.uid != null
          ? photos.findIndex((p) => p.UID === anchor!.uid)
          : -1;
      if (targetIdx >= 0) {
        setFocused(photos[targetIdx].UID);
        void scrollToIndex(targetIdx);
      } else {
        setFocused(photos[0].UID);
      }
    });
  });

  /**
   * Flatten photos + month headers into a single row list. PhotoPrism
   * returns photos pre-sorted by TakenAt; we emit a header row whenever
   * the month-key changes, and tile rows for every photo in order. The
   * tile's `tileIndex` matches its position in `photos`, which is what
   * the windowing observer keys off of (so the visible-range math stays
   * one-dimensional even though the template renders headers inline).
   */
  type Row =
    | { kind: "header"; key: string; label: string; count: number }
    | { kind: "tile"; photo: PpPhoto; tileIndex: number };
  const rows = $derived<Row[]>(buildRows(photos));

  function buildRows(list: PpPhoto[]): Row[] {
    const out: Row[] = [];
    const fmt = new Intl.DateTimeFormat(undefined, {
      month: "long",
      year: "numeric",
    });
    // First pass: count per month — used by the header chip. Sparse
    // Map: O(months) memory, O(photos) time, both trivial at 10k.
    const counts = new Map<string, number>();
    const labels = new Map<string, string>();
    for (const p of list) {
      const { key, label } = monthKey(p, fmt);
      counts.set(key, (counts.get(key) ?? 0) + 1);
      if (!labels.has(key)) labels.set(key, label);
    }
    // Second pass: emit rows in document order.
    let prev = "";
    for (let i = 0; i < list.length; i++) {
      const p = list[i];
      const { key } = monthKey(p, fmt);
      if (key !== prev) {
        out.push({
          kind: "header",
          key,
          label: labels.get(key) ?? "",
          count: counts.get(key) ?? 0,
        });
        prev = key;
      }
      out.push({ kind: "tile", photo: p, tileIndex: i });
    }
    return out;
  }

  function monthKey(
    p: PpPhoto,
    fmt: Intl.DateTimeFormat,
  ): { key: string; label: string } {
    const raw = p.TakenAtLocal ?? p.TakenAt ?? "";
    if (!raw) return { key: "no-date", label: "No date" };
    const d = new Date(raw);
    if (Number.isNaN(d.getTime())) return { key: "no-date", label: "No date" };
    const k = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`;
    return { key: k, label: fmt.format(d) };
  }

  // Visible-range window. The observer reports `first`/`last` based on
  // sampled tiles (every 5th, PhotoPrism's value). We render tiles in
  // [first - BUFFER, last + BUFFER] plus any `forcedExpand` index that
  // keyboard scrollToIndex has demanded (so a focused tile that's
  // currently windowed-out is mounted before scrollIntoView runs).
  const TILE_BUFFER = 4;
  const TILE_SAMPLE = 5;
  // Seed `visLast` generously so the first paint already shows a screen-
  // ful of tiles instead of a single buffer of 4. The IntersectionObserver
  // narrows it on the next layout pass.
  const INITIAL_VIS_LAST = 60;
  let visFirst = $state(0);
  let visLast = $state(INITIAL_VIS_LAST);
  let forcedExpand = $state<number | null>(null);
  const renderFirst = $derived(
    Math.max(0, Math.min(visFirst, forcedExpand ?? visFirst) - TILE_BUFFER),
  );
  const renderLast = $derived(
    Math.max(visLast, forcedExpand ?? visLast) + TILE_BUFFER,
  );

  // Hoist the selection reads out of the per-tile each-block. The same
  // `has` / `===` semantics as `isSelected(uid) || selection.focused === uid`,
  // just expressed at the loop level so the render reads as "given this
  // (selected, focused) snapshot, here's each tile's state". No reactivity
  // change — SvelteSet's `has` already subscribes per-key — but it removes
  // the function-call indirection and keeps the hot loop body terse.
  const selectedIds = $derived(selection.ids);
  const focusedUid = $derived(selection.focused);

  // Reset the window when the filter key changes — old indices from the
  // previous photo list otherwise pin renderFirst/renderLast outside the
  // new shorter list and the grid renders empty. Track `filtersToQ` as
  // the trigger; appending pages (which keeps the same filter key) does
  // NOT reset, so scroll position stays stable mid-pagination.
  $effect(() => {
    filtersToQ(filters);
    untrack(() => {
      visFirst = 0;
      visLast = INITIAL_VIS_LAST;
      forcedExpand = null;
    });
  });

  /** `use:tileRegister={i}` — stable-identity Svelte action that hooks
   *  the tile shell into the visibility observer when it mounts and
   *  un-hooks it when it unmounts (or when `i` changes because the
   *  photos array shifted). Using a `use:` action (not `{@attach}`)
   *  keeps the registration stable across re-renders; `{@attach}` would
   *  rebuild on every render because the inline arrow has fresh
   *  identity each time.
   *
   *  Resolve the handle lazily off the scroll root each call instead
   *  of stashing it in a `$state` populated by `$effect`. The effect
   *  runs *after* the DOM update flush, but on a remount with cached
   *  photo data the tiles render in the same pass as the scroll root,
   *  so a `$state`-backed handle is still `null` when tileRegister
   *  first fires — and the tile never enrols in the observer. Symptom
   *  was a blank grid on return-trip to the timeline (the bug this
   *  comment exists for). The visibleRange action sets
   *  `__visibleRange` on the scroll-root node during its setup phase,
   *  which runs before any child action, so a synchronous lookup
   *  always succeeds. */
  function tileRegister(node: HTMLElement, index: number) {
    let current = index;
    const handle = scrollRoot ? getVisibleRangeHandle(scrollRoot) : null;
    handle?.register(node, current);
    return {
      update(next: number) {
        if (next === current) return;
        handle?.unregister(node);
        current = next;
        handle?.register(node, current);
      },
      destroy() {
        handle?.unregister(node);
      },
    };
  }

  /** Scroll the given tile fully into view. The default `scrollIntoView`
   *  ignores the sticky month header overlaying the top of the grid, so
   *  arrow-up into the row immediately under a header would leave the
   *  tile occluded. We compute the visible region manually, subtract
   *  the sticky header height from the top, and add a 25% "peek" so
   *  the focused tile lands with breathing room rather than flush
   *  against the viewport edge — same heuristic mule-image's Timeline
   *  uses in its `scrollRowIntoView`. */
  function scrollTileIntoView(el: HTMLElement) {
    if (!scrollRoot) return;
    const stickyH =
      scrollRoot.querySelector<HTMLElement>("h2.sticky")?.offsetHeight ?? 0;
    const rootRect = scrollRoot.getBoundingClientRect();
    const elRect = el.getBoundingClientRect();
    const elTop = elRect.top - rootRect.top + scrollRoot.scrollTop;
    const elBot = elTop + elRect.height;
    const peek = Math.round(elRect.height * 0.25);
    const viewTop = scrollRoot.scrollTop + stickyH;
    const viewBot = scrollRoot.scrollTop + scrollRoot.clientHeight;
    if (elTop - peek < viewTop) {
      scrollRoot.scrollTo({ top: Math.max(0, elTop - peek - stickyH) });
    } else if (elBot + peek > viewBot) {
      scrollRoot.scrollTo({ top: elBot + peek - scrollRoot.clientHeight });
    }
  }

  /** Called when arrow-keying lands on a tile that isn't currently
   *  rendered. Forces the window to include the target, waits for the
   *  tile shell to mount, then scrolls it fully into view. */
  async function scrollToIndex(i: number) {
    forcedExpand = i;
    await tick();
    if (!scrollRoot) return;
    const el = scrollRoot.querySelector<HTMLElement>(
      `[data-uid-shell="${photos[i]?.UID ?? ""}"]`,
    );
    if (el) scrollTileIntoView(el);
  }

  // ── Visual rows for keyboard navigation ──────────────────────────────────
  // The CSS Grid lays each photo into a cell with column count derived from
  // `repeat(auto-fill, minmax(thumbnailSize, 1fr))`. Month headers span the
  // full row, so a new month forces a row break even if the previous month's
  // last row had empty slots. Linear "+/-cols" arrow math doesn't account
  // for that and crosses headers wrong; mule-image's `useGridKeyNav` solves
  // it by operating on an explicit `string[][]` visual-row map. We do the
  // same: build the row map from `photos` + `tilesPerRow` + month
  // boundaries, then translate arrow keys into (row, col) moves.

  /** Cached column count of the photo grid. Measured from
   *  `grid-template-columns` on the grid element itself (the only place
   *  with a reliable value) via a Svelte action that runs *after* the
   *  grid mounts. An earlier $effect-based version observed
   *  `scrollRoot` and ran before the {#if photos.length > 0} branch
   *  evaluated, so the grid element didn't exist and `cols` stayed at
   *  1 — making the arrow-keyboard nav feel like a 1-column list. */
  let cols = $state(1);
  let gridEl: HTMLElement | undefined = $state();

  function trackGridCols(node: HTMLElement) {
    gridEl = node;
    const measure = () => {
      const n = getComputedStyle(node)
        .gridTemplateColumns.split(" ")
        .filter(Boolean).length;
      cols = Math.max(1, n);
    };
    measure();
    // ResizeObserver fires on width changes (sidebar toggle, window
    // resize, container reflow). Thumbnail-size changes don't change
    // the grid's width but DO change its column count — handled by a
    // separate effect below that re-runs `measure` on the next tick.
    const ro = new ResizeObserver(measure);
    ro.observe(node);
    return {
      destroy() {
        ro.disconnect();
        if (gridEl === node) gridEl = undefined;
      },
    };
  }

  // Thumbnail-size changes alter column count without resizing the grid,
  // so the ResizeObserver above misses them. Re-measure on the next
  // microtask so the new computed style is in place.
  $effect(() => {
    void view.thumbnailSize;
    queueMicrotask(() => {
      if (!gridEl) return;
      const n = getComputedStyle(gridEl)
        .gridTemplateColumns.split(" ")
        .filter(Boolean).length;
      cols = Math.max(1, n);
    });
  });

  interface VisualRow {
    uids: string[];
    /** Index of the first tile in this row, in the flat `photos` array.
     *  Used to call `scrollToIndex` after a move. */
    firstTileIndex: number;
  }
  /** Pre-broken visual rows + a `uid → (row, col)` index. Whenever a new
   *  month begins, the prior row is flushed regardless of how full it was
   *  — this matches the CSS Grid where a full-span header forces the
   *  next tile onto a fresh row. */
  const visualGrid = $derived(buildVisualGrid(photos, cols));

  function buildVisualGrid(
    list: PpPhoto[],
    colsPerRow: number,
  ): { rows: VisualRow[]; pos: Map<string, [number, number]> } {
    const rows: VisualRow[] = [];
    const pos = new Map<string, [number, number]>();
    const fmt = new Intl.DateTimeFormat(undefined, {
      month: "long",
      year: "numeric",
    });
    let curMonth = "";
    let curRow: VisualRow | null = null;
    for (let i = 0; i < list.length; i++) {
      const p = list[i];
      const { key } = monthKey(p, fmt);
      const monthChanged = key !== curMonth;
      const rowFull = curRow !== null && curRow.uids.length >= colsPerRow;
      if (!curRow || monthChanged || rowFull) {
        curRow = { uids: [], firstTileIndex: i };
        rows.push(curRow);
        curMonth = key;
      }
      pos.set(p.UID, [rows.length - 1, curRow.uids.length]);
      curRow.uids.push(p.UID);
    }
    return { rows, pos };
  }

  // "Intended" column for sticky-column behaviour. Updated by horizontal
  // arrow presses; vertical presses look up the destination cell with
  // this column clamped to the destination row's width — so traversing
  // a partial row doesn't permanently drift your column. Reset whenever
  // focus is established by a non-arrow path (click, Escape).
  let intendedCol: number | null = $state(null);
  $effect(() => {
    // Auto-clear when focus is dropped (Escape, photos refetch, etc.).
    if (selection.focused === null) intendedCol = null;
  });

  function onArrow(key: ArrowKey, extending: boolean) {
    const { rows, pos } = visualGrid;
    if (rows.length === 0) return;

    // Resolve starting (row, col). Without a focused tile, default to
    // the top-left so the first ArrowDown/Right lands on the first
    // real photo instead of doing nothing. The explicit tuple type
    // keeps TS from widening `[number, number]` to `string | number`.
    const cur = selection.focused ? pos.get(selection.focused) : undefined;
    const start: [number, number] = cur ?? [0, -1];
    let r = start[0];
    let c = start[1];

    // Bootstrap intendedCol from the current column so the first
    // vertical move preserves whatever column the user is already on.
    if (intendedCol === null) intendedCol = c < 0 ? 0 : c;

    if (key === "ArrowLeft" || key === "ArrowRight") {
      c += key === "ArrowRight" ? 1 : -1;
      // Wrap across row boundaries (LeftArrow at col 0 → previous
      // row's last col, RightArrow at last col → next row's col 0).
      while (c < 0 && r > 0) {
        r -= 1;
        c = rows[r].uids.length - 1;
      }
      while (r < rows.length && c >= rows[r].uids.length) {
        if (r === rows.length - 1) {
          c = rows[r].uids.length - 1;
          break;
        }
        r += 1;
        c = 0;
      }
      if (c < 0) c = 0;
      // User explicitly chose this column → it's the new "home" for
      // subsequent vertical moves.
      intendedCol = c;
    } else {
      r += key === "ArrowDown" ? 1 : -1;
      if (r < 0) r = 0;
      if (r >= rows.length) r = rows.length - 1;
      // Vertical: prefer the intended column; clamp to destination
      // row width so a narrower row doesn't fall off the edge.
      // intendedCol is preserved so going down through narrow rows
      // then back up returns to the original column.
      const w = rows[r].uids.length;
      c = Math.min(intendedCol, w - 1);
      if (c < 0) c = 0;
    }

    const destUid = rows[r]?.uids[c];
    if (!destUid) return;

    // Plain arrow nav collapses any prior multi-selection to the cursor
    // (mirrors gridKeyNav.moveFocus). Shift-extend keeps `ids` growing
    // from the anchor below.
    if (!extending) clearSelection();
    setFocused(destUid);
    if (!extending) setAnchor(destUid);
    if (extending && selection.focused) selectRange(destUid);

    // Try the cheap path first: rendered tile, our scroll helper that
    // respects the sticky header. Fall back to scrollToIndex (windowing
    // expand + scroll) when the destination is currently unmounted.
    const tile = scrollRoot?.querySelector<HTMLElement>(
      `[data-uid="${destUid}"]`,
    );
    if (tile) {
      scrollTileIntoView(tile);
    } else {
      const flatIdx = rows[r].firstTileIndex + c;
      scrollToIndex(flatIdx);
    }
  }

  const focusedPhotoQuery = createQuery<PpPhoto | null>(() => ({
    queryKey: ["photo", selection.focused ?? ""],
    queryFn: () =>
      selection.focused ? getPhoto(selection.focused) : Promise.resolve(null),
    enabled: isAuthenticated() && Boolean(selection.focused),
    staleTime: 0,
  }));

  const qc = useQueryClient();
  let emptyingArchive = $state(false);

  /**
   * Walk every archived photo and delete it in pages. The timeline's
   * infinite query only holds what the user has scrolled; we re-query
   * `archived:true` directly so even unloaded pages get cleared.
   * PhotoPrism caps `count` at 1000 — pull the max each round so we
   * spend one HTTP call per chunk.
   */
  async function onEmptyArchive() {
    if (emptyingArchive) return;
    if (
      !confirm(
        "Permanently delete EVERY photo in the Archive? This cannot be undone.",
      )
    ) {
      return;
    }
    emptyingArchive = true;
    const tid = toast.loading("Emptying archive…");
    let total = 0;
    try {
      while (true) {
        const batch = await listPhotos({
          q: "archived:true",
          count: 1000,
          offset: 0,
          order: "newest",
          merged: false,
        });
        if (batch.length === 0) break;
        const uids = Array.from(new Set(batch.map((p) => p.UID)));
        await batchDelete(uids);
        total += uids.length;
      }
      toast.success(total === 0 ? "Archive already empty" : `Deleted ${total}`, { id: tid });
    } catch (err) {
      toast.error(err instanceof Error ? err.message : "Empty archive failed", { id: tid });
    } finally {
      emptyingArchive = false;
      void qc.invalidateQueries({ queryKey: ["photos"] });
    }
  }

  function onTileClick(e: MouseEvent, uid: string) {
    // Modifier clicks (shift / cmd / ctrl) are handled by gridKeyNav's
    // document-level click handler — let them bubble.
    if (e.shiftKey || e.metaKey || e.ctrlKey) return;
    // Plain click: select this tile only. Replaces the previous
    // "click opens preview" semantics — preview now lives on dblclick.
    // Reset both focus and anchor so a subsequent shift-click extends
    // the range from this tile, and clear sticky-column intent so
    // arrow nav re-anchors off this tile's actual column.
    selection.ids.clear();
    selection.ids.add(uid);
    setFocused(uid);
    setAnchor(uid);
    intendedCol = null;
  }

  function onTileDblclick(e: MouseEvent, uid: string) {
    if (e.shiftKey || e.metaKey || e.ctrlKey) return;
    e.preventDefault();
    selection.ids.clear();
    selection.ids.add(uid);
    setFocused(uid);
    setAnchor(uid);
    openPreview();
  }

  // Scroll root for the infinite-scroll IntersectionObserver. Bound by
  // the <main> element below; the sentinel's `root` references this so
  // the observer measures intersections relative to the timeline pane
  // (the page itself doesn't scroll).
  let scrollRoot: HTMLElement | undefined = $state();

  let searchDraft = $state(filters.search);
  $effect(() => {
    searchDraft = filters.search;
  });
  function onSearchSubmit(e: SubmitEvent) {
    e.preventDefault();
    setSearch(searchDraft.trim());
  }

  // PhotoPrism's q-DSL is non-obvious; surfacing 4 working examples on
  // focus turns the placeholder hint into a clickable cheat-sheet.
  const SEARCH_EXAMPLES = [
    "label:dog",
    "keyword:vacation",
    "taken:2024",
    '"exact phrase"',
  ];
  let searchFocused = $state(false);
  function onSearchFocus() {
    searchFocused = true;
  }
  function onSearchBlur() {
    // Defer so a click on an example fires before the popover unmounts.
    setTimeout(() => (searchFocused = false), 120);
  }
  function applySearchExample(ex: string) {
    searchDraft = ex;
    setSearch(ex);
    searchFocused = false;
  }
</script>

<Toolbar showRightToggle>
  <span
    class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground"
  >
    {sectionLabel}
  </span>
  {#if filters.section === "archive" && photos.length > 0}
    <button
      type="button"
      class="rounded border border-destructive/40 px-2 py-0.5 text-[11px] text-destructive hover:bg-destructive/10 disabled:opacity-50"
      disabled={emptyingArchive}
      onclick={onEmptyArchive}
      title="Permanently delete every archived photo"
    >
      {emptyingArchive ? "Emptying…" : "Empty Archive"}
    </button>
  {/if}
  <form class="relative flex items-center gap-1" onsubmit={onSearchSubmit}>
    <input
      type="search"
      placeholder={'Search · label:website / "vacation"'}
      class="w-56 rounded border border-input bg-background px-2 py-0.5 text-xs shadow-sm focus:outline-none focus:ring-2 focus:ring-ring"
      bind:value={searchDraft}
      onfocus={onSearchFocus}
      onblur={onSearchBlur}
    />
    <button
      type="submit"
      class="rounded border border-border px-2 py-0.5 text-xs hover:bg-accent"
    >
      Go
    </button>
    {#if filters.search}
      <button
        type="button"
        class="rounded border border-border px-1.5 py-0.5 text-xs hover:bg-accent"
        onclick={() => {
          searchDraft = "";
          setSearch("");
        }}
        title="Clear search"
      >
        ✕
      </button>
    {/if}
    <!--
			Cheat-sheet popover: opens on input focus, lists working q-DSL
			patterns. Clicking an example fills the input AND fires the
			search, so it doubles as a one-click "try it" affordance.
		-->
    {#if searchFocused}
      <div
        class="absolute left-0 top-full z-50 mt-1 w-56 rounded-md border border-border bg-popover p-1.5 text-popover-foreground shadow-md"
      >
        <div
          class="px-1 pb-1 text-[10px] uppercase tracking-wide text-muted-foreground"
        >
          Examples
        </div>
        {#each SEARCH_EXAMPLES as ex (ex)}
          <button
            type="button"
            class="block w-full rounded px-2 py-1 text-left font-mono text-[11px] hover:bg-accent"
            onmousedown={(e) => {
              e.preventDefault();
              applySearchExample(ex);
            }}
          >
            {ex}
          </button>
        {/each}
      </div>
    {/if}
  </form>

  {#snippet trailing()}
    <!-- Thumbnail size — five steps mirroring mule-image's XS/S/M/L/XL.
		     Persisted to localStorage via view.svelte.ts. -->
    <div
      class="flex items-center overflow-hidden rounded border border-border"
      role="group"
      aria-label="Thumbnail size"
    >
      {#each THUMBNAIL_SIZE_PRESETS as size, i (size)}
        <button
          type="button"
          class="px-1.5 py-0.5 text-[10px] font-medium hover:bg-accent"
          class:bg-accent={view.thumbnailSize === size}
          class:text-foreground={view.thumbnailSize === size}
          class:text-muted-foreground={view.thumbnailSize !== size}
          onclick={() => setThumbnailSize(size)}
          title={`${THUMBNAIL_SIZE_LABELS[i]} · ${size}px`}
        >
          {THUMBNAIL_SIZE_LABELS[i]}
        </button>
      {/each}
    </div>
  {/snippet}
</Toolbar>

<div class="flex min-h-0 flex-1">
  <!--
		Main column wraps the scrollable timeline and the action bar so
		the bar's width matches the timeline only — the right aside is a
		sibling at row level and stays full height when the bar appears.
	-->
  <div class="flex min-w-0 flex-1 flex-col overflow-hidden">
        <main
          bind:this={scrollRoot}
          class="flex-1 overflow-y-auto outline-none focus:outline-none"
          use:gridKeyNav={{ scrollToIndex, onArrow }}
          use:visibleRange={{
            onChange: (f, l) => {
              visFirst = f;
              visLast = l;
            },
            sampleEvery: TILE_SAMPLE,
          }}
        >
          <div class="pr-2 pl-2 pb-2 overflow-x-hidden">
            {#if photosQuery.isPending}
              <SkeletonGrid />
            {:else if photosQuery.isError}
              <EmptyState
                tone="destructive"
                icon={AlertCircle}
                title="Failed to load photos"
                description={photosQuery.error instanceof Error
                  ? photosQuery.error.message
                  : "unknown error"}
              />
            {:else if photos.length === 0}
              {#if filters.section === "archive"}
                <EmptyState icon={Archive} title="Archive is empty" />
              {:else if filters.section === "review"}
                <EmptyState
                  icon={Sparkles}
                  title="Nothing left to review"
                  description="Photos the indexer wasn't sure about land here — use Keep to accept them into the timeline or Archive to set them aside."
                />
              {:else if filters.section === "hidden"}
                <EmptyState
                  icon={EyeOff}
                  title="No hidden photos"
                  description="The indexer auto-hides files it can't read (broken files, very low quality); they only ever show up here."
                />
              {:else if filters.section === "heap"}
                <EmptyState
                  icon={Layers}
                  title="This heap has no photos yet"
                  description={'Select some photos and use the bulk bar’s “＋ Add to heap” button.'}
                />
              {:else}
                <EmptyState
                  icon={ImageOff}
                  title="No photos"
                  description="Index a folder from Settings → Index, or run a reindex from the server."
                />
              {/if}
            {:else}
              <div
                data-photo-grid
                use:trackGridCols
                class="grid gap-2"
                style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
              >
                {#each rows as row (row.kind === "header" ? `h:${row.key}` : `t:${row.photo.UID}`)}
                  {#if row.kind === "header"}
                    <!-- col-span-full + position:sticky pins the month label to
						     the top of the scrolling main as the user passes through.
						     -mx-6 stretches the bar past the wrapper padding so it
						     reads edge-to-edge in the viewport. -->
                    <h2
                      class="sticky top-0 z-10 -mx-6 border-b border-border bg-background/95 px-6 py-2 text-xs font-semibold uppercase tracking-wide text-foreground/80 backdrop-blur"
                      style="grid-column: 1 / -1;"
                    >
                      {row.label}
                      <span
                        class="ml-2 text-[10px] font-normal text-muted-foreground"
                      >
                        {row.count}
                      </span>
                    </h2>
                  {:else}
                    {@const photo = row.photo}
                    {@const i = row.tileIndex}
                    {@const inWindow = i >= renderFirst && i <= renderLast}
                    <!-- Shell: always rendered. Holds grid-cell space + the
						     stable `data-uid-shell` anchor that gridKeyNav.scrollToIndex
						     can query even when the inner button is windowed out. -->
                    <div
                      data-uid-shell={photo.UID}
                      class="aspect-square"
                      use:tileRegister={i}
                    >
                      {#if inWindow}
                        {@const sel =
                          selectedIds.has(photo.UID) ||
                          focusedUid === photo.UID}
                        <PhotoTile
                          {photo}
                          selected={sel}
                          onClick={(e) => onTileClick(e, photo.UID)}
                          onDblclick={(e) => onTileDblclick(e, photo.UID)}
                        />
                      {/if}
                    </div>
                  {/if}
                {/each}
              </div>

              <!-- Sentinel: when this div nears the viewport we kick the next
			     page. The 4-viewport rootMargin (handled by the action) is
			     PhotoPrism's preload distance from page/photos.vue. -->
              <div
                aria-hidden="true"
                class="h-4"
                use:nearBottom={{
                  onHit: () => photosQuery.fetchNextPage(),
                  enabled:
                    photosQuery.hasNextPage && !photosQuery.isFetchingNextPage,
                  root: scrollRoot,
                }}
              ></div>
              {#if photosQuery.isFetchingNextPage}
                <InlineLoader
                  size="sm"
                  align="center"
                  polite={false}
                  label="Loading more photos…"
                />
              {/if}
            {/if}
          </div>
        </main>
    <BulkActionBar />
  </div>

  {#if !view.rightSidebarCollapsed}
    <aside
      class="relative h-full shrink-0 border-l border-border bg-card/30"
      style="width: {view.rightSidebarWidth}px;"
    >
      <div class="h-full overflow-y-auto">
        {#if selection.ids.size >= 2}
          <!-- Multi-select swaps to a bulk-edit panel: edits fan out across
					     the whole selection (note / date / keyword). The single-photo
					     metadata view returns when the user drops back to one. -->
          <BulkMetadataSidebar ids={Array.from(selection.ids)} />
        {:else if focusedPhotoQuery.data}
          <RightSidebar photo={focusedPhotoQuery.data} />
        {:else if focusedPhotoQuery.isFetching}
          <InlineLoader size="sm" label="Loading metadata…" />
        {:else}
          <EmptyState icon={MousePointerClick} title="No photo selected">
            {#snippet descriptionSnippet()}
              <p>
                Use arrow keys or <kbd class="rounded bg-muted px-1">⌘</kbd
                >+click on a thumbnail to view its metadata here.
              </p>
            {/snippet}
          </EmptyState>
        {/if}
      </div>
      <!-- Resize handle on the left edge; mirrors the layout's left aside
			     hot-zone for symmetry. -->
      <div
        class="group absolute -left-1.5 top-0 z-20 h-full w-3 cursor-col-resize"
        use:resizable={{
          edge: "left",
          getWidth: () => view.rightSidebarWidth,
          setWidth: setRightSidebarWidth,
        }}
        role="separator"
        aria-orientation="vertical"
        aria-label="Resize info panel"
      >
        <div
          class="ml-1 h-full w-0.5 bg-transparent transition-colors group-hover:bg-primary/40"
        ></div>
      </div>
    </aside>
  {/if}
</div>
