#!/usr/bin/env python3
"""
Build a relocation-safe justified media gallery with directory-scoped pages.

The generated bundle contains:
  <output>/index.html
  <output>/site.webmanifest
  <output>/assets/*
  <output>/assets/gallery.<hash>.css
  <output>/assets/theme.<hash>.js
  <output>/assets/password.<hash>.js
  <output>/assets/gallery.<hash>.js
  <output>/assets/layout-worker.<hash>.js
  <output>/assets/app-icons/*
  <output>/data/pages/*.<hash>.js
  <output>/data/items/*.<hash>.bin
  <output>/data/items/*.<hash>.js
  <output>/data/exif/*.<hash>.js
  <output>/data/geometry/*.<hash>.bin
  <output>/data/geometry/*.<hash>.js  (file:// and compatibility fallback)
  <output>/data/order/*.<hash>.bin
  <output>/data/order/*.<hash>.js  (file:// and compatibility fallback)
  <output>/data/ids/*.<hash>.js
  <output>/data/chunk-revisions.<hash>.js
  <output>/data/build.json
  <output>/data/deployment.json
  <output>/data/gallery.py
  <output>/albums/*/index.html

Immutable, content-addressed CSS and JavaScript are written to assets/ so live
rebuilds cannot mix old HTML with new client code. Canonical gallery data is
written to data/, and directory-scoped pages live below albums/. Large row
layout calculations can run in the generated layout worker, while stable
image-link lookups use sharded data/ids/ files only when needed. All generated
URLs are relative, so the output directory can be moved as a unit.

Static raster images are converted to responsive derivatives in the selected
--format: JPEG XL, WebP, PNG, JPEG, or AVIF. Animated images, SVGs, and videos
are copied through unchanged so their animation/vector/playback behavior is
preserved. When ffmpeg and ffprobe are available, video poster images, exact
display aspect ratios, codec details, duration, frame rate, and creation time
are generated without modifying the original video. The lightbox reports
likely browser-portability issues and always offers the original video if
playback fails. --transcode-incompatible-videos can optionally create H.264/AAC
MP4 playback derivatives for those videos while preserving the exact originals;
detected HDR inputs are tone-mapped to broadly compatible BT.709 SDR.
With --include-originals, static source files are also copied byte-for-byte into
assets/ and exposed through a lightbox download control.

--password adds a browser-based unlock control and defers gallery data loading
until the supplied password is verified. Because the generated bundle remains
static, this is an access gate rather than encryption: direct asset URLs remain
available to anyone who can inspect or fetch the bundle. Password entry is
verified locally and has no native form or network-submission path. Passwords
can be read from an interactive prompt, a file, an environment variable, or the
backward-compatible --password VALUE form. Protected galleries include an
explicit lock control and can optionally relock after a configured timeout.

data/deployment.json documents MIME types, cache rules, byte-range behavior,
and Cloudflare/AWS S3/Backblaze B2 publishing guidance for each generated
bundle.

Requires:
  Python 3.10+
  Pillow

JPEG XL decoding uses, in order:
  1. pillow-jxl-plugin (imported as pillow_jxl), if installed
  2. djxl from libjxl-tools / jpeg-xl, as a fallback

JPEG XL encoding uses:
  1. cjxl from libjxl-tools / jpeg-xl

Optional: install the Python JXL plugin for JXL input decoding with:
  python3 -m pip install pillow-jxl-plugin

Optional video helpers:
  ffprobe reads video dimensions and duration.
  ffmpeg extracts a still-image poster; original video bytes are never changed.
  --transcode-incompatible-videos also requires ffmpeg with libx264 and creates
  H.264/AAC MP4 playback derivatives only for videos with portability warnings.

Example:
  python3 justified_gallery.py ~/Pictures/Set1 ~/Pictures/Set2 \
      --recursive --output ./gallery

  python3 justified_gallery.py image001.png image002.png \
      --output ./gallery --gap 1 --target-row-height 260

  python3 justified_gallery.py --output ./gallery --doctor

  python3 justified_gallery.py ~/Pictures --recursive --output ./gallery \
      --dry-run --report-json rebuild-plan.json

  python3 justified_gallery.py ~/Pictures --recursive --output ./gallery \
      --header-html '<p><strong>2026 collection</strong></p>' \
      --footer-html '<p><a href="/">More galleries</a></p>'

  python3 justified_gallery.py ~/Pictures --recursive --output ./gallery \
      --password 'correct horse battery staple'

  python3 justified_gallery.py ~/Pictures --recursive --output ./gallery \
      --password --password-session-timeout 30

  python3 justified_gallery.py ~/Pictures --recursive --output ./gallery \
      --transcode-incompatible-videos

  python3 justified_gallery.py ~/Pictures --recursive --output ./gallery --watch

  python3 justified_gallery.py --client-regression-test

  python3 justified_gallery.py --self-test
"""

from __future__ import annotations

import argparse
import ast
import concurrent.futures
from contextlib import contextmanager
import ctypes
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
import errno
import getpass
import http.server
import io
import hashlib
import html
import json
import math
import os
import queue
import re
import secrets
import select
import shutil
import signal
import sqlite3
import stat as stat_module
import struct
import subprocess
import sys
import sysconfig
import tempfile
import threading
import time
import urllib.error
import urllib.request
import warnings
from pathlib import Path, PurePosixPath
from typing import Any, Callable, Iterable, Literal, Mapping, Sequence, TypedDict

try:
    from PIL import Image, ImageOps, UnidentifiedImageError, __version__ as PILLOW_VERSION
except ImportError:
    print(
        "error: Pillow is required. Install it with: python3 -m pip install Pillow",
        file=sys.stderr,
    )
    raise SystemExit(2)

try:
    import fcntl  # type: ignore[import-not-found]
except ImportError:  # pragma: no cover - exercised only on non-POSIX hosts
    fcntl = None  # type: ignore[assignment]

# Pillow does not ship JPEG XL support itself. pillow-jxl-plugin registers JXL
# with Pillow simply by being imported. In this script it is used only for JXL
# input decoding; JXL output encoding always uses cjxl.
try:
    import pillow_jxl  # type: ignore[import-not-found]  # noqa: F401

    PILLOW_JXL_AVAILABLE = True
    PILLOW_JXL_IMPORT_ERROR: str | None = None
except Exception as exc:
    PILLOW_JXL_AVAILABLE = False
    PILLOW_JXL_IMPORT_ERROR = str(exc)

# Third-party Pillow codecs do not consistently use Pillow's documented
# OSError/UnidentifiedImageError failure types. In particular,
# pillow-jxl-plugin can surface libjxl decode failures as RuntimeError. Keep
# this tuple scoped to calls into external image codecs so ordinary internal
# RuntimeError exceptions are never mistaken for damaged media.
PIL_EXTERNAL_DECODER_ERRORS = (
    OSError,
    RuntimeError,
    ValueError,
    UnidentifiedImageError,
)


def usable_cpu_count() -> int:
    """Return the logical CPUs available to this process, with old-Python fallback.

    Python 3.13 added os.process_cpu_count(), which respects CPU affinity and
    interpreter CPU-count overrides. Older Python versions only expose the
    host-wide os.cpu_count(), so retain that as a compatibility fallback.
    """
    process_cpu_count = getattr(os, "process_cpu_count", None)
    if callable(process_cpu_count):
        try:
            count = process_cpu_count()
        except (OSError, TypeError, ValueError):
            count = None
        if count is not None:
            return max(1, int(count))

    try:
        count = os.cpu_count()
    except (OSError, TypeError, ValueError):
        count = None
    return max(1, int(count or 1))


def free_threaded_python_build() -> bool:
    """Return whether this interpreter was built with optional free-threading."""
    try:
        configured = sysconfig.get_config_var("Py_GIL_DISABLED")
    except (AttributeError, TypeError, ValueError):
        return False
    if isinstance(configured, str):
        return configured.strip().casefold() in {"1", "true", "yes"}
    return bool(configured)


def gil_enabled() -> bool:
    """Return the effective GIL state, conservatively on older interpreters.

    The check is intentionally performed at the point of use rather than at
    import time. A native extension imported by Pillow can cause a
    free-threaded interpreter to re-enable the GIL for the process.
    """
    is_enabled = getattr(sys, "_is_gil_enabled", None)
    if not callable(is_enabled):
        return True
    try:
        return bool(is_enabled())
    except (RuntimeError, TypeError, ValueError):
        return True


def python_threading_mode() -> str:
    """Return a concise description of the interpreter's effective GIL mode."""
    if not gil_enabled():
        return "free-threaded, GIL disabled"
    if free_threaded_python_build():
        return "free-threaded build, GIL enabled"
    return "standard build, GIL enabled"


CACHE_DB_SCHEMA_VERSION = 8

# ASSET_SCHEMA_VERSION describes where generated files live. It must not, by
# itself, force a JXL re-encode. Older caches may contain render signatures
# created while the exact same encoded bytes used an earlier asset layout.
ASSET_SCHEMA_VERSION = 3
ENCODING_COMPATIBLE_ASSET_SCHEMA_VERSIONS = (2, 3)

JXL_SUPPORT_VERSION = 3
WEBP_ANIMATION_VERSION = 1
JPEG_DRAFT_VERSION = 1
EXIF_METADATA_VERSION = 7
AVERAGE_COLOR_VERSION = 1
VIDEO_METADATA_VERSION = 4
VIDEO_POSTER_VERSION = 1
VIDEO_TRANSCODE_VERSION = 1
VIDEO_TRANSCODE_CRF = 20
VIDEO_TRANSCODE_PRESET = "medium"
VIDEO_TRANSCODE_AUDIO_BITRATE = "160k"
DEFAULT_CACHE = ".gallery-cache.sqlite3"
LEGACY_SQLITE_CACHE = ".justified-gallery-cache.sqlite3"
DEFAULT_LEGACY_CACHE = ".justified-gallery-cache.json"
DEFAULT_OUTPUT = "gallery-bundle"
ASSET_DIRNAME = "assets"
LEGACY_ASSET_DIRNAME = "gallery-assets"
ALBUMS_DIRNAME = "albums"
ASSET_SHARD_LEVELS = 2
ASSET_SHARD_WIDTH = 2
DEFAULT_JOBS = usable_cpu_count()
DEFAULT_CACHE_SYNC_INTERVAL = 60.0
MAX_PENDING_CACHE_ENTRIES = 1024
DIRECTORY_SORT_MEMORY_LIMIT = 10_000
CATALOG_SQL_BATCH_SIZE = 1024

GALLERY_DATA_FORMAT_VERSION = 16
GALLERY_DATA_DIR = "data"
LEGACY_GALLERY_DATA_DIR = "gallery-data"
GALLERY_DATA_PAGE_DIR = f"{GALLERY_DATA_DIR}/pages"
GALLERY_DATA_CATALOG_DIR = f"{GALLERY_DATA_DIR}/items"
GALLERY_DATA_EXIF_DIR = f"{GALLERY_DATA_DIR}/exif"
GALLERY_DATA_GEOMETRY_DIR = f"{GALLERY_DATA_DIR}/geometry"
GALLERY_DATA_ORDER_DIR = f"{GALLERY_DATA_DIR}/order"
GALLERY_DATA_ID_DIR = f"{GALLERY_DATA_DIR}/ids"
GALLERY_BUILD_INFO = f"{GALLERY_DATA_DIR}/build.json"
GALLERY_DEPLOYMENT_INFO = f"{GALLERY_DATA_DIR}/deployment.json"
DEFAULT_CATALOG_CHUNK_SIZE = 1024
DEFAULT_GEOMETRY_CHUNK_SIZE = 16384
DEFAULT_LAYOUT_SEGMENT_SIZE = 2048
PAGE_BOOTSTRAP_CATALOG_ITEMS = 96
PAGE_BOOTSTRAP_GEOMETRY_ITEMS = DEFAULT_LAYOUT_SEGMENT_SIZE
# Switch ordinary large galleries to summary-first geometry early enough that
# a 50k-item page does not have to download and decode every ratio before its
# first paint. Geometry chunks remain independently content-addressed and are
# hydrated only around the viewport.
SEGMENTED_GEOMETRY_THRESHOLD = 32_768
RETAINED_DATA_GENERATIONS = 2
CATALOG_CHUNK_FILENAME_WIDTH = 8
GEOMETRY_CHUNK_FILENAME_WIDTH = 5
GEOMETRY_RATIO_SCALE = 1_000_000
GEOMETRY_BINARY_MAGIC = b"JGG1"
GEOMETRY_BINARY_HEADER = struct.Struct("<4sI")
GEOMETRY_BINARY_VALUE = struct.Struct("<I")
ORDER_BINARY_MAGIC = b"JGO1"
ORDER_BINARY_VERSION = 1
ORDER_BINARY_HEADER = struct.Struct("<4sIII")
ORDER_BINARY_VALUE = struct.Struct("<I")
CATALOG_BINARY_MAGIC = b"JGC1"
CATALOG_BINARY_VERSION = 1
CATALOG_NULL_STRING = 0xFFFFFFFF
GEOMETRY_COMPATIBILITY_EAGER_LIMIT = 250_000
DEFAULT_WATCH_RECONCILE_INTERVAL = 60.0

STABLE_IMAGE_ID_BITS = 96
MIN_IMAGE_ID_BITS = 64
DEFAULT_IMAGE_ID_BITS = 80
DEFAULT_LIGHTBOX_OPACITY = 0.86
DEFAULT_TOOLTIP_DELAY_MS = 200
PASSWORD_PBKDF2_ITERATIONS = 210_000
PASSWORD_SALT_METADATA_KEY = "password_salt_hex"
PASSWORD_PROMPT_SENTINEL = object()
DEFAULT_RANDOM_GROUP_SUFFIX_REGEX = r"(?:\s*\(\d+\)|\d+)$"
HTML_TEMPLATE_VERSION = 6
HTML_BUILD_DATE_METADATA_KEY = "html_build_date"
HTML_BUILD_SIGNATURE_METADATA_KEY = "html_build_signature"


@dataclass(frozen=True, slots=True)
class BinaryField:
    """One named field in a portable little-endian binary record."""

    name: str
    format: str


@dataclass(frozen=True, slots=True)
class BinarySchema:
    """Single source of truth for Python packing and browser field offsets."""

    fields: tuple[BinaryField, ...]

    @property
    def struct(self) -> struct.Struct:
        return struct.Struct("<" + "".join(field.format for field in self.fields))

    @property
    def offsets(self) -> dict[str, int]:
        offsets: dict[str, int] = {}
        offset = 0
        for field in self.fields:
            offsets[field.name] = offset
            offset += struct.calcsize("<" + field.format)
        return offsets


@dataclass(frozen=True, slots=True)
class PositionalSchema:
    """Named contract for a compact JSON array shared with the browser."""

    fields: tuple[str, ...]

    @property
    def indexes(self) -> dict[str, int]:
        return {name: index for index, name in enumerate(self.fields)}

    def pack(self, values: dict[str, Any]) -> list[Any]:
        return [values.get(name) for name in self.fields]


CATALOG_HEADER_SCHEMA = BinarySchema((
    BinaryField("magic", "4s"),
    BinaryField("version", "I"),
    BinaryField("count", "I"),
    BinaryField("string_count", "I"),
    BinaryField("string_offsets", "I"),
    BinaryField("record_offsets", "I"),
    BinaryField("records", "I"),
    BinaryField("strings", "I"),
))
CATALOG_RECORD_SCHEMA = BinarySchema((
    BinaryField("kind", "B"),
    BinaryField("flags", "B"),
    BinaryField("reserved", "H"),
    BinaryField("name", "I"),
    BinaryField("digest", "I"),
    BinaryField("suffix", "I"),
    BinaryField("stable_id", "I"),
    BinaryField("color", "I"),
    BinaryField("width", "I"),
    BinaryField("height", "I"),
))
CATALOG_VIDEO_SCHEMA = BinarySchema((
    BinaryField("duration", "d"),
    BinaryField("file_size", "Q"),
    BinaryField("video_codec", "I"),
    BinaryField("video_profile", "I"),
    BinaryField("audio_codec", "I"),
    BinaryField("frame_rate", "d"),
    BinaryField("creation_time", "I"),
    BinaryField("compatibility_note", "I"),
    BinaryField("compatibility_filename", "I"),
))
CATALOG_BINARY_HEADER = CATALOG_HEADER_SCHEMA.struct
CATALOG_BINARY_RECORD = CATALOG_RECORD_SCHEMA.struct
CATALOG_BINARY_VIDEO = CATALOG_VIDEO_SCHEMA.struct

STATIC_CATALOG_SCHEMA = PositionalSchema((
    "name", "digest", "kind", "width", "height", "suffix", "stable_id",
    "average_color",
))
PASSTHROUGH_CATALOG_SCHEMA = PositionalSchema((
    "name", "digest", "kind", "suffix", "width", "height", "stable_id",
    "average_color",
))
VIDEO_CATALOG_SCHEMA = PositionalSchema((
    "name", "digest", "kind", "suffix", "width", "height", "stable_id",
    "has_poster", "duration", "file_size", "video_codec", "video_profile",
    "audio_codec", "frame_rate", "creation_time", "compatibility_note",
    "compatibility_filename", "average_color",
))
EXIF_RAW_SCHEMA = PositionalSchema((
    "album", "artist", "camera_make", "camera_model", "lens",
    "focal_length", "max_aperture", "exposure_time", "iso",
    "creation_time",
))
EXIF_DISPLAY_FIELDS = (
    ("album", "Album"),
    ("artist", "Artist"),
    ("camera_make", "Camera Make"),
    ("camera_model", "Camera Model Name"),
    ("lens", "Lens"),
    ("focal_length", "Focal Length"),
    ("max_aperture", "Max Aperture Value"),
    ("exposure_time", "Exposure Time"),
    ("iso", "ISO"),
    ("format", "Format"),
    ("creation_time", "Creation Date/Time"),
)
EXIF_OMIT_WHEN_EMPTY_FIELDS = frozenset(
    key for key, _label in EXIF_DISPLAY_FIELDS
)


def catalog_binary_browser_schema() -> dict[str, Any]:
    """Return the exact schema injected into the JavaScript binary decoder."""
    return {
        "magic": list(CATALOG_BINARY_MAGIC),
        "version": CATALOG_BINARY_VERSION,
        "nullString": CATALOG_NULL_STRING,
        "headerSize": CATALOG_BINARY_HEADER.size,
        "recordSize": CATALOG_BINARY_RECORD.size,
        "videoSize": CATALOG_BINARY_VIDEO.size,
        "header": CATALOG_HEADER_SCHEMA.offsets,
        "record": CATALOG_RECORD_SCHEMA.offsets,
        "video": CATALOG_VIDEO_SCHEMA.offsets,
    }


def compact_browser_schema() -> dict[str, Any]:
    """Return every compact fallback/EXIF index consumed by JavaScript."""
    return {
        "catalog": {
            "static": STATIC_CATALOG_SCHEMA.indexes,
            "passthrough": PASSTHROUGH_CATALOG_SCHEMA.indexes,
            "video": VIDEO_CATALOG_SCHEMA.indexes,
        },
        "exif": {
            "raw": EXIF_RAW_SCHEMA.indexes,
            "display": [
                {
                    "key": key,
                    "label": label,
                    "omitEmpty": key in EXIF_OMIT_WHEN_EMPTY_FIELDS,
                }
                for key, label in EXIF_DISPLAY_FIELDS
            ],
        },
    }


MediaKind = Literal["static", "animated", "svg", "video"]


class VideoDetails(TypedDict, total=False):
    album: str | None
    artist: str | None
    duration: float | None
    bitrate: int | None
    video_codec: str | None
    video_profile: str | None
    pixel_format: str | None
    frame_rate: float | None
    color_primaries: str | None
    color_transfer: str | None
    color_space: str | None
    creation_time: str | None
    hdr: bool
    compatibility_note: str | None


class StaticVariant(TypedDict):
    path: str
    width: int


class CommonMediaEntry(TypedDict, total=False):
    """Fields shared by every normalized cache entry."""

    source_path: str
    size: int
    mtime_ns: int
    sha256: str
    width: int
    height: int
    animated: bool
    source_suffix: str
    render_signature: str
    jxl_support_version: int | None
    jxl_decoder: str | None
    webp_animation_version: int
    original_asset: str | None
    exif_version: int
    exif: list[str | None]
    average_color: str | None
    average_color_version: int
    source_device: int
    source_inode: int
    source_ctime_ns: int
    source_birthtime_ns: int
    quick_hash: str


class StaticMediaEntry(CommonMediaEntry):
    """Responsive derivative metadata for a decoded still image."""

    kind: Literal["static"]
    variants: list[StaticVariant]
    static_encoder: str | None
    jxl_encoder: str | None
    jpeg_draft: int


class PassthroughMediaEntry(CommonMediaEntry):
    """Copied animated-raster or vector metadata."""

    kind: Literal["animated", "svg"]
    asset: str


class VideoMediaEntry(CommonMediaEntry):
    """Copied video plus optional poster/transcode metadata."""

    kind: Literal["video"]
    asset: str
    video_details: VideoDetails
    poster_asset: str
    video_transcode_asset: str
    video_transcode_status: str
    video_transcode_version: int
    video_compatibility_note: str
    video_poster_status: str
    video_poster_version: int
    duration: float | None
    bitrate: int | None
    video_codec: str | None
    video_profile: str | None
    audio_codec: str | None
    frame_rate: float | None
    video_creation_time: str | None
    video_pixel_format: str | None
    video_bits_per_raw_sample: int | None
    video_color_transfer: str | None
    video_color_primaries: str | None
    video_color_space: str | None
    video_color_range: str | None


MediaEntry = StaticMediaEntry | PassthroughMediaEntry | VideoMediaEntry


class CatalogRecord(TypedDict):
    catalog_id: int
    path: Path
    stable_id: str
    display_name: str
    source_root: str | None
    root_relative: str | None
    entry: MediaEntry


@dataclass(frozen=True, slots=True)
class CatalogInsertRecord:
    catalog_id: int
    path: str
    stable_id: str
    link_prefix: str
    display_name: str
    source_root: str | None
    root_relative: str | None
    catalog_item_json: str
    geometry_ratio: int


@dataclass(frozen=True, slots=True)
class ArtifactValidationRecord:
    path: str
    revision: str
    size: int
    mtime_ns: int
    ctime_ns: int
    device: int
    inode: int
    validated_ns: int

    def matches(self, revision: str, status: os.stat_result) -> bool:
        return (
            self.revision == revision
            and self.size == int(status.st_size)
            and self.mtime_ns == int(status.st_mtime_ns)
            and self.ctime_ns == int(status.st_ctime_ns)
            and self.device == int(status.st_dev)
            and self.inode == int(status.st_ino)
        )


class RandomOrderConfig(TypedDict, total=False):
    enabled: bool
    available: bool
    count: int
    groupCount: int
    binaryPath: str
    binaryRevision: str
    fallbackPath: str
    fallbackRevision: str


class DataChunkMetadata(TypedDict, total=False):
    path: str
    output_signature: str
    revision: str
    binaryRevision: str
    fallbackPath: str
    binaryPath: str
    count: int
    segments: list[list[int]]


class PasswordConfig(TypedDict):
    salt_hex: str
    verifier_hex: str
    iterations: int
    session_timeout_seconds: int


class RebuildPlan(TypedDict):
    counts: dict[str, int]
    reasons: dict[str, int]
    estimated_sources: int
    storage: dict[str, int]


class ContractValidation(TypedDict, total=False):
    pages: int
    data_files: int
    chunks: int
    client_assets: int


class StoragePreflightResult(TypedDict):
    estimated_new_files: int
    estimated_output_bytes: int
    estimated_temp_peak_bytes: int
    output_free_bytes: int
    temp_free_bytes: int
    output_free_inodes: int | None
    same_output_and_temp_filesystem: bool
    passed: bool
    errors: list[str]


class DoctorReport(TypedDict, total=False):
    mode: str
    healthy: bool
    issues: list[str]
    repairs: int
    cache: dict[str, object]
    generated: dict[str, object]
    permissions: dict[str, object]
    sample: dict[str, object]
    encoder: dict[str, object]


class MachineReport(TypedDict, total=False):
    mode: str
    status: str
    format: str
    images: int
    pages: int
    healthy: bool
    issues: list[str]
    error: str
    counts: dict[str, object]
    timing_seconds: dict[str, float]
    contract_validation: ContractValidation


class GalleryPageConfig(TypedDict, total=False):
    dataFormatVersion: int
    count: int
    dataRevision: str
    revisionIndexUrl: str
    revisionIndexRevision: str
    layoutWorkerUrl: str
    randomOrder: RandomOrderConfig
    bootstrap: dict[str, Any]


@dataclass(frozen=True, slots=True)
class SourceOptions:
    paths: tuple[Path, ...]
    recursive: bool


@dataclass(frozen=True, slots=True)
class OutputOptions:
    output: Path
    cache: Path | None
    title: str
    header_html: str
    footer_html: str
    no_credit: bool
    include_originals: bool
    transcode_incompatible_videos: bool


@dataclass(frozen=True, slots=True)
class PasswordOptions:
    password_session_timeout: float
    password_protected: bool
    password_source: str | None


@dataclass(frozen=True, slots=True)
class RenderOptions:
    target_row_height: int
    gap: int
    widths: tuple[int, ...]
    output_format: str
    zoom_width: int
    quality: int
    effort: int
    jobs: int
    encoder_threads: int
    max_working_memory_mib: int
    max_image_pixels: int | None
    jpeg_draft: bool


@dataclass(frozen=True, slots=True)
class ClientOptions:
    image_id_bits: int
    max_active_tile_loads: int | None
    image_load_margin: int
    lightbox_opacity: float
    tooltip_delay: int
    client_diagnostics: bool
    catalog_chunk_size: int
    geometry_chunk_size: int
    max_hot_catalog_chunks: int
    max_client_metadata_mib: int
    layout_worker: bool
    allow_unsorted: bool
    random_group_suffix_regex: str


@dataclass(frozen=True, slots=True)
class CacheOptions:
    cache_sync_interval: float
    lock_timeout: float
    durable_writes: bool
    hash_all: bool
    storage_preflight: bool
    verify_assets: bool
    no_clean: bool
    repair_permissions: bool
    skip_unchanged_html: bool


@dataclass(frozen=True, slots=True)
class WatchOptions:
    progress_files: bool
    watch: bool
    watch_interval: float
    watch_debounce: float
    watch_reconcile_interval: float


@dataclass(frozen=True, slots=True)
class CommandOptions:
    dry_run: bool
    report_json: Path | None
    doctor: bool
    doctor_sample_size: int
    doctor_repair: bool


@dataclass(frozen=True, slots=True)
class BuildOptions:
    """Validated immutable configuration grouped by responsibility."""

    source: SourceOptions
    output_settings: OutputOptions
    password_settings: PasswordOptions
    render: RenderOptions
    client: ClientOptions
    cache_settings: CacheOptions
    watch_settings: WatchOptions
    command: CommandOptions

    @classmethod
    def from_namespace(cls, namespace: argparse.Namespace) -> "BuildOptions":
        widths = tuple(
            sorted(
                set(
                    [
                        *namespace.widths,
                        *([namespace.zoom_width] if namespace.zoom_width else []),
                    ]
                )
            )
        )
        return cls(
            source=SourceOptions(
                paths=tuple(namespace.paths),
                recursive=bool(namespace.recursive),
            ),
            output_settings=OutputOptions(
                output=namespace.output,
                cache=namespace.cache,
                title=str(namespace.title),
                header_html=str(namespace.header_html),
                footer_html=str(namespace.footer_html),
                no_credit=bool(namespace.no_credit),
                include_originals=bool(namespace.include_originals),
                transcode_incompatible_videos=bool(
                    namespace.transcode_incompatible_videos
                ),
            ),
            password_settings=PasswordOptions(
                password_session_timeout=float(
                    namespace.password_session_timeout
                ),
                password_protected=bool(namespace.password_protected),
                password_source=namespace.password_source,
            ),
            render=RenderOptions(
                target_row_height=int(namespace.target_row_height),
                gap=int(namespace.gap),
                widths=widths,
                output_format=str(namespace.output_format),
                zoom_width=int(namespace.zoom_width),
                quality=int(namespace.quality),
                effort=int(namespace.effort),
                jobs=int(namespace.jobs),
                encoder_threads=int(namespace.encoder_threads),
                max_working_memory_mib=int(namespace.max_working_memory_mib),
                max_image_pixels=namespace.max_image_pixels,
                jpeg_draft=bool(namespace.jpeg_draft),
            ),
            client=ClientOptions(
                image_id_bits=int(namespace.image_id_bits),
                max_active_tile_loads=namespace.max_active_tile_loads,
                image_load_margin=int(namespace.image_load_margin),
                lightbox_opacity=float(namespace.lightbox_opacity),
                tooltip_delay=int(namespace.tooltip_delay),
                client_diagnostics=bool(namespace.client_diagnostics),
                catalog_chunk_size=int(namespace.catalog_chunk_size),
                geometry_chunk_size=int(namespace.geometry_chunk_size),
                max_hot_catalog_chunks=int(namespace.max_hot_catalog_chunks),
                max_client_metadata_mib=int(namespace.max_client_metadata_mib),
                layout_worker=bool(namespace.layout_worker),
                allow_unsorted=bool(namespace.allow_unsorted),
                random_group_suffix_regex=str(
                    namespace.random_group_suffix_regex
                ),
            ),
            cache_settings=CacheOptions(
                cache_sync_interval=float(namespace.cache_sync_interval),
                lock_timeout=float(namespace.lock_timeout),
                durable_writes=bool(namespace.durable_writes),
                hash_all=bool(namespace.hash_all),
                storage_preflight=bool(namespace.storage_preflight),
                verify_assets=bool(namespace.verify_assets),
                no_clean=bool(namespace.no_clean),
                repair_permissions=bool(namespace.repair_permissions),
                skip_unchanged_html=bool(namespace.skip_unchanged_html),
            ),
            watch_settings=WatchOptions(
                progress_files=bool(namespace.progress_files),
                watch=bool(namespace.watch),
                watch_interval=float(namespace.watch_interval),
                watch_debounce=float(namespace.watch_debounce),
                watch_reconcile_interval=float(
                    namespace.watch_reconcile_interval
                ),
            ),
            command=CommandOptions(
                dry_run=bool(namespace.dry_run),
                report_json=namespace.report_json,
                doctor=bool(namespace.doctor),
                doctor_sample_size=int(namespace.doctor_sample_size),
                doctor_repair=bool(namespace.doctor_repair),
            ),
        )


@dataclass(frozen=True, slots=True)
class ToolchainCapabilities:
    """Immutable external-tool and execution capabilities for one build."""

    cjxl_path: str | None
    djxl_path: str | None
    jxlinfo_path: str | None
    ffmpeg_path: str | None
    ffprobe_path: str | None
    encoder_threads: int
    working_memory_budget_bytes: int
    python_threading_mode: str


@dataclass(slots=True)
class WorkingMemoryGovernor:
    """Build-local accounting for decoded and resized image memory."""

    budget_bytes: int
    bytes_in_use: int = 0
    peak_reserved_bytes: int = 0
    condition: threading.Condition = field(default_factory=threading.Condition)

    @contextmanager
    def reserve(self, size_bytes: int):
        reservation = min(max(1, int(size_bytes)), self.budget_bytes)
        with self.condition:
            while (
                self.bytes_in_use > 0
                and self.bytes_in_use + reservation > self.budget_bytes
            ):
                self.condition.wait(timeout=0.25)
                check_cancelled()
            self.bytes_in_use += reservation
            self.peak_reserved_bytes = max(
                self.peak_reserved_bytes,
                self.bytes_in_use,
            )
        try:
            yield
        finally:
            with self.condition:
                self.bytes_in_use = max(0, self.bytes_in_use - reservation)
                self.condition.notify_all()

    def bounded_workers(
        self,
        base_bytes: int,
        copy_bytes: int,
        desired_workers: int,
    ) -> int:
        desired = max(1, int(desired_workers))
        base = max(1, int(base_bytes))
        copy_size = max(1, int(copy_bytes))
        if base >= self.budget_bytes:
            return 1
        capacity = max(0, (self.budget_bytes - base) // copy_size)
        return min(desired, 1 + int(capacity))


@dataclass(slots=True)
class AssetLockPool:
    """Build-local locks for content-addressed publication targets."""

    guard: threading.Lock = field(default_factory=threading.Lock)
    locks: dict[str, threading.Lock] = field(default_factory=dict)

    def for_key(self, key: str) -> threading.Lock:
        with self.guard:
            lock = self.locks.get(key)
            if lock is None:
                lock = threading.Lock()
                self.locks[key] = lock
            return lock


@dataclass(slots=True)
class BuildExecutionResources:
    """Mutable resources owned by exactly one build session."""

    toolchain: ToolchainCapabilities
    durable_writes: bool
    encoder_semaphore: threading.Semaphore
    memory: WorkingMemoryGovernor
    asset_locks: AssetLockPool = field(default_factory=AssetLockPool)
    content_cache_local: threading.local = field(default_factory=threading.local)

    @classmethod
    def create(
        cls,
        toolchain: ToolchainCapabilities,
        *,
        jobs: int,
        durable_writes: bool,
    ) -> "BuildExecutionResources":
        cpu_count = usable_cpu_count()
        max_encoders = max(
            1,
            cpu_count // max(1, toolchain.encoder_threads),
        )
        return cls(
            toolchain=toolchain,
            durable_writes=durable_writes,
            encoder_semaphore=threading.Semaphore(
                min(max(1, jobs), max_encoders)
            ),
            memory=WorkingMemoryGovernor(
                toolchain.working_memory_budget_bytes
            ),
        )

    @contextmanager
    def encoder_slot(self):
        with self.encoder_semaphore:
            yield


@dataclass(frozen=True)
class ProcessingOptions:
    """Immutable settings shared by every per-source processing task."""

    output_dir: Path
    requested_widths: tuple[int, ...]
    quality: int
    effort: int
    output_format: str
    render_signature: str
    compatible_render_signatures: frozenset[str]
    widths_only_settings_change: bool
    previous_widths: tuple[int, ...] | None
    trust_cached_assets: bool
    verify_assets: bool
    jpeg_draft: bool
    hash_all: bool
    include_originals: bool
    transcode_incompatible_videos: bool
    cache_path: Path | None
    toolchain: ToolchainCapabilities
    resources: BuildExecutionResources


@dataclass(frozen=True, slots=True)
class SourceProbe:
    """Cheap filesystem identity gathered before media decoding."""

    path: Path
    stat: os.stat_result
    suffix: str
    source_device: int
    source_inode: int
    source_ctime_ns: int
    source_birthtime_ns: int
    quick_hash: str


@dataclass(frozen=True, slots=True)
class CacheReuseDecision:
    """Resolved cache entry and the reasons its metadata may be reused."""

    old_entry: MediaEntry | None
    content_reused: bool
    precomputed_digest: str | None
    stat_unchanged: bool
    unchanged: bool
    identity_metadata_changed: bool


@dataclass(frozen=True)
class MediaProcessingContext:
    """Per-source state passed to the media-type-specific asset handlers."""

    path: Path
    old_entry: MediaEntry | None
    entry: MediaEntry
    stat_unchanged: bool
    unchanged: bool
    content_reused: bool
    exif_changed: bool
    identity_metadata_changed: bool
    average_color_changed: bool
    digest: str
    suffix: str
    width: int
    height: int
    kind: str
    video_details: VideoDetails
    old_signature: Any
    desired_jpeg_draft: int
    jpeg_draft_changed: bool
    jpeg_draft_metadata_changed: bool
    options: ProcessingOptions


@dataclass(frozen=True)
class ClientSettings:
    """Client behavior common to root and album page descriptors."""

    target_row_height: int
    gap: int
    requested_widths: tuple[int, ...]
    catalog_chunk_size: int
    geometry_chunk_size: int
    data_revision: str
    revision_index_rel: str
    revision_index_revision: str
    static_output_format: str
    max_active_tile_loads: int | None
    image_load_margin: int
    tooltip_delay: int
    client_diagnostics: bool
    image_id_bits: int
    include_originals: bool
    transcode_incompatible_videos: bool
    max_hot_catalog_chunks: int
    max_client_metadata_mib: int
    layout_worker: bool
    layout_worker_asset: str
    random_order: RandomOrderConfig


@dataclass(frozen=True)
class HtmlPage:
    """Presentation-only inputs for one generated HTML page."""

    title: str
    page_descriptor_href: str
    heading: str
    image_count: int
    home_href: str | None
    parent_href: str | None
    parent_label: str | None
    child_links: tuple[tuple[str, str], ...]
    page_dir: Path
    app_title: str
    app_assets_enabled: bool
    client_assets: dict[str, str]
    show_credit: bool
    include_originals: bool
    header_html: str
    footer_html: str
    password_config: PasswordConfig | None
    build_date: str


@dataclass(frozen=True)
class BuildSummary:
    """Final build counters, validation results, and phase timings."""

    index_path: Path
    catalog_count: int
    generated_page_count: int
    rewritten_page_count: int
    client_assets_rewritten: int
    credit_enabled: bool
    builder_script_rewritten: bool
    data_file_count: int
    rewritten_data_count: int
    validated_data_count: int
    contract_validation: ContractValidation
    retained_publish_generations: int
    retained_publish_paths: int
    build_info_rewritten: bool
    deployment_info_rewritten: bool
    changed_count: int
    reused_count: int
    skipped_count: int
    removed_asset_count: int
    removed_page_count: int
    removed_data_count: int
    cache_path: Path
    migrated_sqlite_cache: bool
    cache_rows: int
    pruned_cache_entries: int
    video_count: int
    video_compatibility_warning_count: int
    video_transcoded_count: int
    video_transcode_failed_count: int
    password_config: PasswordConfig | None
    preflight_result: StoragePreflightResult | None
    bootstrapped_generated_files: int
    inventory_sync_skipped: bool
    migrated_cache_entries: int
    cache_sync_count: int
    rebuild_plan: RebuildPlan | None
    scan_cache_elapsed: float
    processing_elapsed: float
    gallery_elapsed: float
    cleanup_elapsed: float
    total_elapsed: float
    toolchain: ToolchainCapabilities
    working_memory_peak_bytes: int
    lock_wait_seconds: float
    orphan_temp_dirs_removed: int


@dataclass
class ProcessingCounters:
    """Mutable counters shared with the cancellable source-processing phase."""

    changed: int = 0
    reused: int = 0
    skipped: int = 0


class ProcessingOutcome(str, Enum):
    UPDATED = "updated"
    CACHED = "cached "
    SKIPPED = "skipped"


@dataclass(frozen=True, slots=True)
class ProcessingResult:
    """One source-processing result with a stable progress classification."""

    entry: MediaEntry | None
    changed: bool

    @property
    def outcome(self) -> ProcessingOutcome:
        if self.entry is None:
            return ProcessingOutcome.SKIPPED
        if self.changed:
            return ProcessingOutcome.UPDATED
        return ProcessingOutcome.CACHED


@dataclass(frozen=True, slots=True)
class MediaAssetResult:
    """Normalized entry plus whether its cache row/output changed."""

    entry: MediaEntry
    changed: bool


REBUILD_REASON_LABELS = {
    "stat_failed": "source cannot be statted",
    "unsupported": "source header is unreadable or unsupported",
    "new_source": "new source",
    "source_changed": "source size or modification time changed",
    "jxl_metadata": "JPEG XL metadata support changed",
    "webp_animation": "WebP animation detection changed",
    "hash_all": "--hash-all requested",
    "video_metadata": "video metadata support changed",
    "exif_metadata": "EXIF cache version changed",
    "static_changed": "new or changed static source",
    "render_settings": "render settings changed",
    "jpeg_draft": "JPEG reduced-decode setting changed",
    "jxl_encoder": "JPEG XL encoder identity changed",
    "derivative_missing": "derivative is missing or empty",
    "original_missing": "original copy is missing or source changed",
    "video_missing": "video copy is missing or source changed",
    "poster_missing": "video poster is missing or source changed",
    "video_transcode": (
        "browser-compatible video derivative is missing or stale"
    ),
    "passthrough_missing": "passthrough asset is missing or source changed",
}


@dataclass
class RebuildPlanAccumulator:
    """Mutable, table-driven counters for a dry-run rebuild explanation."""

    counts: dict[str, int] = field(
        default_factory=lambda: {
            "scanned": 0,
            "cached": 0,
            "encode": 0,
            "poster": 0,
            "transcode": 0,
            "copy": 0,
            "rehash": 0,
            "metadata_refresh": 0,
            "skipped": 0,
        }
    )
    reasons: dict[str, int] = field(default_factory=dict)
    estimated_sources: int = 0
    estimated_new_files: int = 0
    estimated_output_bytes: int = 0
    estimated_temp_peak_bytes: int = 0

    def increment(self, name: str, amount: int = 1) -> None:
        self.counts[name] += amount

    def explain(self, reason: str) -> None:
        label = REBUILD_REASON_LABELS[reason]
        self.reasons[label] = self.reasons.get(label, 0) + 1

    def add_storage(
        self,
        *,
        files: int = 0,
        output_bytes: int = 0,
        temp_peak_bytes: int = 0,
    ) -> None:
        self.estimated_new_files += files
        self.estimated_output_bytes += output_bytes
        self.estimated_temp_peak_bytes = max(
            self.estimated_temp_peak_bytes,
            temp_peak_bytes,
        )

    def payload(self) -> RebuildPlan:
        return {
            "counts": self.counts,
            "reasons": self.reasons,
            "estimated_sources": self.estimated_sources,
            "storage": {
                "estimated_new_files": self.estimated_new_files,
                "estimated_output_bytes": self.estimated_output_bytes,
                "estimated_temp_peak_bytes": self.estimated_temp_peak_bytes,
            },
        }


@dataclass(frozen=True)
class CatalogAssembly:
    """Streamed catalog state consumed by the gallery-publication phase."""

    referenced_assets: Any
    route_roots: dict[Path, Path]
    catalog_count: int
    video_count: int
    video_compatibility_warning_count: int
    video_transcoded_count: int
    video_transcode_failed_count: int
    page_record_ranges: dict[Path, list[tuple[int, int]]]
    child_pages: dict[Path, list[tuple[str, Path]]]
    page_meta: dict[Path, tuple[Path, Path]]
    catalog_dependency_signatures: dict[Any, str]
    exif_dependency_signatures: dict[Any, str]
    geometry_dependency_signatures: dict[Any, str]
    id_dependency_signatures: dict[Any, str]


@dataclass(frozen=True)
class PublishedGallery:
    """Validated files and revisions emitted by the publication phase."""

    index_path: Path
    referenced_assets: Any
    referenced_pages: Any
    referenced_gallery_data: Any
    generated_page_count: int
    rewritten_page_count: int
    client_assets_rewritten: int
    credit_enabled: bool
    builder_script_rewritten: bool
    rewritten_data_count: int
    validated_data_count: int
    contract_validation: ContractValidation
    retained_publish_generations: int
    retained_publish_paths: int
    build_info_rewritten: bool
    deployment_info_rewritten: bool
    data_revision: str
    build_info_revision: str
    deployment_info_revision: str
    revision_index_revision: str
    gallery_css_version: str
    client_assets: dict[str, str]
    app_assets_enabled: bool


@dataclass(frozen=True)
class PagePublicationContext:
    args: BuildOptions
    output_dir: Path
    roots: tuple[Path, ...]
    cache_db: "SQLiteCache"
    catalog: CatalogAssembly
    data_revision: str
    revision_index_rel: str
    revision_index_revision: str
    revision_payload: dict[str, Any]
    random_order: RandomOrderConfig
    client_assets: dict[str, str]
    app_assets_enabled: bool
    credit_enabled: bool
    password_config: PasswordConfig | None
    build_date: str
    trust_generated_outputs: bool
    referenced_pages: Any
    referenced_gallery_data: Any
    progress: FinalizationReporter | None = None


@dataclass(frozen=True, slots=True)
class PagePublicationResult:
    index_path: Path
    generated_page_count: int
    rewritten_page_count: int
    rewritten_data_count: int


@dataclass(frozen=True, slots=True)
class PageDescriptorPublication:
    relative_path: str
    referenced_paths: set[str]
    rewritten_count: int


@dataclass(frozen=True, slots=True)
class DirectoryPageIndex:
    record_ranges: dict[Path, list[tuple[int, int]]]
    child_pages: dict[Path, list[tuple[str, Path]]]
    page_meta: dict[Path, tuple[Path, Path]]


@dataclass(frozen=True, slots=True)
class ClientAssetPublication:
    rewritten_count: int
    css_version: str
    assets: dict[str, str]


@dataclass(frozen=True)
class GeneratedCleanup:
    """Results from generated-file inventory reconciliation and cleanup."""

    data_file_count: int
    removed_asset_count: int
    removed_page_count: int
    removed_data_count: int
    inventory_sync_skipped: bool


@dataclass(frozen=True)
class CacheFinalization:
    """Final cache row counts after a complete build generation."""

    cache_rows: int
    pruned_cache_entries: int


@dataclass(frozen=True, slots=True)
class ChunkPublication:
    """Referenced files, rewrites, and revisions from one chunk family."""

    referenced_paths: set[str]
    rewritten_count: int
    revisions: dict[Any, Any]


@dataclass(frozen=True, slots=True)
class RandomOrderPublication:
    config: RandomOrderConfig
    referenced_paths: set[str]
    rewritten_count: int


@dataclass(frozen=True, slots=True)
class GeneratedFilePublication:
    relative_path: str
    rewritten: bool
    revision: str


@dataclass(frozen=True, slots=True)
class ChunkArtifact:
    relative_path: Path
    payload: str | bytes
    signature: str


@dataclass(frozen=True, slots=True)
class GalleryPublicationResult:
    catalog: CatalogAssembly
    published: PublishedGallery


@dataclass(frozen=True, slots=True)
class GalleryShellPublication:
    client_assets_rewritten: int
    gallery_css_version: str
    client_assets: dict[str, str]
    credit_enabled: bool
    builder_script_rewritten: bool
    app_assets_enabled: bool


@dataclass(frozen=True, slots=True)
class GalleryDataPublication:
    referenced_paths: set[str]
    rewritten_count: int
    random_order: RandomOrderConfig
    revision_index: GeneratedFilePublication
    data_revision: str
    revision_payload: dict[str, Any]

# Pre-v12 pages use these stable client paths. Existing copies are retained as
# compatibility assets while newly generated pages reference immutable,
# content-addressed client files.
LEGACY_GALLERY_CSS_ASSET = f"{ASSET_DIRNAME}/gallery.css"
LEGACY_THEME_JS_ASSET = f"{ASSET_DIRNAME}/theme.js"
LEGACY_GALLERY_JS_ASSET = f"{ASSET_DIRNAME}/gallery.js"
LEGACY_LAYOUT_WORKER_ASSET = f"{ASSET_DIRNAME}/layout-worker.js"
BUILDER_SCRIPT_COPY = f"{GALLERY_DATA_DIR}/gallery.py"
LEGACY_BUILDER_SCRIPT_COPY = "justified_gallery.py"

APP_ICON_SOURCE_URL = (
    "https://thumb.wikimedia.org/wikipedia/commons/thumb/f/f7/"
    "Picture_-_The_Noun_Project.svg/"
    "1280px-Picture_-_The_Noun_Project.svg.png"
)
APP_ICON_DIR = f"{ASSET_DIRNAME}/app-icons"
APP_ICON_MASTER = f"{APP_ICON_DIR}/source-512.png"
APP_FAVICON_16 = f"{APP_ICON_DIR}/favicon-16x16.png"
APP_FAVICON_32 = f"{APP_ICON_DIR}/favicon-32x32.png"
APP_FAVICON_48 = f"{APP_ICON_DIR}/favicon-48x48.png"
APP_FAVICON_ICO = f"{APP_ICON_DIR}/favicon.ico"
APPLE_TOUCH_ICON = f"{APP_ICON_DIR}/apple-touch-icon.png"
ANDROID_ICON_192 = f"{APP_ICON_DIR}/android-chrome-192x192.png"
ANDROID_ICON_512 = f"{APP_ICON_DIR}/android-chrome-512x512.png"
ANDROID_MASKABLE_192 = f"{APP_ICON_DIR}/android-maskable-192x192.png"
ANDROID_MASKABLE_512 = f"{APP_ICON_DIR}/android-maskable-512x512.png"
APP_MANIFEST = "site.webmanifest"


class ProcessingCancelled(Exception):
    """Internal cooperative-cancellation signal for worker threads."""


class GalleryError(RuntimeError):
    """Expected gallery failure with a stable diagnostic category."""

    code = "gallery"


class CacheError(GalleryError):
    code = "cache"


class ContractError(GalleryError):
    code = "contract"


class MediaError(GalleryError):
    code = "media"


class CodecError(GalleryError, ValueError):
    code = "codec"


class OutputError(GalleryError):
    code = "output"


@dataclass(slots=True)
class RuntimeState:
    """Process-lifetime cancellation, subprocess, signal, and temp state."""

    cancel_event: threading.Event = field(default_factory=threading.Event)
    active_processes: set[subprocess.Popen[Any]] = field(default_factory=set)
    active_processes_lock: threading.Lock = field(default_factory=threading.Lock)
    run_temp_dir: Path | None = None
    termination_signal: int | None = None
    orphan_temp_dirs_removed: int = 0


@dataclass(slots=True)
class BuildLock:
    """One build session's exclusive output-directory lock."""

    handle: Any | None = None
    path: Path | None = None
    fallback_created: bool = False
    wait_seconds: float = 0.0


# Only process-owned resources remain global. Build resources are constructed
# by BuildSession and passed to processing code explicitly.
_RUNTIME = RuntimeState()


def _linux_process_identity(pid: int) -> str | None:
    """Return a boot-specific Linux process start identity, when available."""
    try:
        boot_id = Path("/proc/sys/kernel/random/boot_id").read_text(
            encoding="ascii"
        ).strip()
        stat_text = Path(f"/proc/{int(pid)}/stat").read_text(
            encoding="utf-8"
        )
        # comm is parenthesized and may contain spaces. Field 22 (starttime)
        # is item 19 after the closing parenthesis because that tail starts at
        # field 3 (state).
        tail = stat_text[stat_text.rfind(")") + 1 :].split()
        start_ticks = tail[19]
        if not boot_id or not start_ticks:
            return None
        return f"{boot_id}:{start_ticks}"
    except (OSError, ValueError, IndexError):
        return None


def _process_definitely_gone(pid: int, identity: str) -> bool:
    """Conservatively identify an exited or PID-reused temp-workspace owner."""
    current_identity = _linux_process_identity(pid)
    if current_identity is not None:
        return current_identity != identity

    # On Linux, a missing /proc entry is definitive. Elsewhere, only ESRCH is
    # treated as proof; permission failures deliberately leave the directory.
    if sys.platform.startswith("linux"):
        return not Path(f"/proc/{int(pid)}").exists()
    try:
        os.kill(int(pid), 0)
    except ProcessLookupError:
        return True
    except (OSError, PermissionError):
        return False
    return False


def cleanup_orphaned_temp_workspaces(temp_root: Path) -> int:
    """Remove only marked gallery temp directories whose owner is gone."""
    removed = 0
    try:
        candidates = list(temp_root.glob("gallery-*-*"))
    except OSError:
        return 0

    current_uid = os.getuid() if hasattr(os, "getuid") else None
    for candidate in candidates:
        try:
            candidate_stat = candidate.lstat()
            if candidate.is_symlink() or not candidate.is_dir():
                continue
            if current_uid is not None and candidate_stat.st_uid != current_uid:
                continue
            marker = candidate / ".gallery-run.json"
            marker_stat = marker.lstat()
            if marker.is_symlink() or not marker.is_file():
                continue
            if current_uid is not None and marker_stat.st_uid != current_uid:
                continue
            payload = json.loads(marker.read_text(encoding="utf-8"))
            pid = int(payload.get("pid", 0))
            identity = str(payload.get("process_identity") or "")
            if pid <= 0 or not identity:
                continue
            if not candidate.name.startswith(f"gallery-{pid}-"):
                continue
            if not _process_definitely_gone(pid, identity):
                continue
            shutil.rmtree(candidate)
            removed += 1
        except (FileNotFoundError, NotADirectoryError):
            continue
        except (OSError, TypeError, ValueError, json.JSONDecodeError):
            # Unknown/untrusted directories are never removed.
            continue
    return removed


def release_build_lock(lock: BuildLock) -> None:
    """Release the current output-directory lock, if one is held."""
    handle = lock.handle
    path = lock.path
    fallback_created = lock.fallback_created
    lock.handle = None
    lock.path = None
    lock.fallback_created = False
    if handle is None:
        return
    try:
        if fcntl is not None:
            fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
    except OSError:
        pass
    try:
        handle.close()
    except OSError:
        pass
    if fallback_created and path is not None:
        try:
            path.unlink()
        except FileNotFoundError:
            pass


def _build_lock_owner_summary(lock_path: Path) -> str:
    try:
        payload = json.loads(lock_path.read_text(encoding="utf-8"))
        pid = int(payload.get("pid", 0))
        started_ns = int(payload.get("started_ns", 0))
    except (OSError, TypeError, ValueError, json.JSONDecodeError):
        return "owner details unavailable"
    details = [f"PID {pid}"] if pid > 0 else []
    if started_ns > 0:
        details.append(
            time.strftime(
                "%Y-%m-%d %H:%M:%S %Z",
                time.localtime(started_ns / 1_000_000_000),
            )
        )
    return ", started ".join(details) if details else "owner details unavailable"


def acquire_build_lock(
    lock: BuildLock,
    output_dir: Path,
    timeout: float = 0.0,
) -> None:
    """Acquire an exclusive, process-owned lock for one output directory."""
    release_build_lock(lock)
    ensure_output_directory(output_dir)
    lock_path = output_dir / ".gallery-build.lock"
    timeout = max(0.0, float(timeout))
    wait_started = time.monotonic()
    wait_announced = False

    if fcntl is not None:
        handle = lock_path.open("a+", encoding="utf-8")
        while True:
            try:
                fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
                break
            except OSError as exc:
                if exc.errno not in {errno.EACCES, errno.EAGAIN}:
                    handle.close()
                    raise
                elapsed = time.monotonic() - wait_started
                owner = _build_lock_owner_summary(lock_path)
                if elapsed >= timeout:
                    handle.close()
                    raise RuntimeError(
                        f"another gallery build is already using {output_dir} "
                        f"({owner})"
                    ) from exc
                if not wait_announced:
                    eprint(
                        f"Waiting up to {timeout:g}s for gallery build lock "
                        f"({owner})..."
                    )
                    wait_announced = True
                check_cancelled()
                time.sleep(min(0.25, max(0.01, timeout - elapsed)))
        lock.handle = handle
        lock.path = lock_path
        lock.fallback_created = False
    else:  # Conservative cross-platform fallback using exclusive creation.
        while True:  # pragma: no cover - non-POSIX fallback
            try:
                fd = os.open(
                    lock_path,
                    os.O_CREAT | os.O_EXCL | os.O_RDWR,
                    0o644,
                )
                break
            except FileExistsError as exc:
                elapsed = time.monotonic() - wait_started
                if elapsed >= timeout:
                    raise RuntimeError(
                        f"another gallery build may already be using "
                        f"{output_dir}; remove stale lock {lock_path} only "
                        "after verifying it is idle"
                    ) from exc
                check_cancelled()
                time.sleep(min(0.25, max(0.01, timeout - elapsed)))
        lock.handle = os.fdopen(fd, "w+", encoding="utf-8")
        lock.path = lock_path
        lock.fallback_created = True

    lock.wait_seconds = time.monotonic() - wait_started

    identity = _linux_process_identity(os.getpid())
    handle = lock.handle
    if handle is not None:
        handle.seek(0)
        handle.truncate()
        json.dump(
            {
                "pid": os.getpid(),
                "process_identity": identity,
                "started_ns": time.time_ns(),
            },
            handle,
            sort_keys=True,
        )
        handle.write("\n")
        handle.flush()


def _termination_handler(signum: int, _frame: Any) -> None:
    """Translate service-manager termination into normal cancellation."""
    _RUNTIME.termination_signal = int(signum)
    _RUNTIME.cancel_event.set()
    raise ProcessingCancelled()


def cancellation_exit_code() -> int:
    if _RUNTIME.termination_signal is None:
        return 130
    return 128 + int(_RUNTIME.termination_signal)


def cancellation_label() -> str:
    if _RUNTIME.termination_signal is None:
        return "Interrupted"
    try:
        name = signal.Signals(_RUNTIME.termination_signal).name
    except (ValueError, AttributeError):
        name = str(_RUNTIME.termination_signal)
    return f"Terminated by {name}"


@contextmanager
def termination_signal_handlers():
    """Temporarily install graceful SIGTERM/SIGHUP handlers."""
    installed: dict[int, Any] = {}
    if threading.current_thread() is threading.main_thread():
        for signum in (getattr(signal, "SIGTERM", None), getattr(signal, "SIGHUP", None)):
            if signum is None:
                continue
            installed[int(signum)] = signal.getsignal(signum)
            signal.signal(signum, _termination_handler)
    try:
        yield
    finally:
        for signum, previous in installed.items():
            signal.signal(signum, previous)


@contextmanager
def gallery_temp_workspace():
    """Confine all run-owned temporary files to one removable directory.

    Some native image codecs create their own intermediates (including PAM
    files) beneath the process temporary directory. Point Python, Pillow/native
    libraries, and child codec processes at a private directory so cleanup is
    complete without deleting unrelated files from the system temp directory.
    """
    previous_tempdir = tempfile.tempdir
    temp_keys = ("TMPDIR", "TMP", "TEMP")
    previous_environment = {key: os.environ.get(key) for key in temp_keys}
    system_temp_root = Path(tempfile.gettempdir()).resolve()
    _RUNTIME.orphan_temp_dirs_removed = cleanup_orphaned_temp_workspaces(
        system_temp_root
    )
    work_dir = Path(
        tempfile.mkdtemp(
            prefix=f"gallery-{os.getpid()}-",
            dir=str(system_temp_root),
        )
    )
    marker = work_dir / ".gallery-run.json"
    try:
        marker.write_text(
            json.dumps(
                {
                    "pid": os.getpid(),
                    "process_identity": _linux_process_identity(os.getpid()),
                    "created_ns": time.time_ns(),
                },
                sort_keys=True,
            )
            + "\n",
            encoding="utf-8",
        )
    except OSError:
        shutil.rmtree(work_dir, ignore_errors=True)
        raise
    _RUNTIME.run_temp_dir = work_dir

    for key in temp_keys:
        os.environ[key] = str(work_dir)
    # tempfile caches its selected directory, so update the cache explicitly
    # after creating the run root.
    tempfile.tempdir = str(work_dir)

    try:
        yield work_dir
    finally:
        # An exception outside the main processing loop can still leave a
        # codec running. Stop it before removing the directory it may be using.
        terminate_active_processes()
        tempfile.tempdir = previous_tempdir
        for key, value in previous_environment.items():
            if value is None:
                os.environ.pop(key, None)
            else:
                os.environ[key] = value
        _RUNTIME.run_temp_dir = None
        try:
            shutil.rmtree(work_dir)
        except FileNotFoundError:
            pass
        except OSError as exc:
            DIAGNOSTICS.warning(
                f"could not remove temporary directory: {exc}",
                code="temp-cleanup",
                source=work_dir,
            )


def check_cancelled() -> None:
    if _RUNTIME.cancel_event.is_set():
        raise ProcessingCancelled()


def terminate_active_processes() -> None:
    """Terminate external codec/probe processes currently owned by workers."""
    with _RUNTIME.active_processes_lock:
        processes = list(_RUNTIME.active_processes)

    def terminate(process: subprocess.Popen[Any]) -> None:
        if process.poll() is not None:
            return
        try:
            if os.name == "posix":
                os.killpg(process.pid, signal.SIGTERM)
            else:
                process.terminate()
        except (OSError, ProcessLookupError):
            pass

    def kill(process: subprocess.Popen[Any]) -> None:
        if process.poll() is not None:
            return
        try:
            if os.name == "posix":
                os.killpg(process.pid, signal.SIGKILL)
            else:
                process.kill()
        except (OSError, ProcessLookupError):
            pass

    for process in processes:
        terminate(process)

    deadline = time.monotonic() + 1.0
    for process in processes:
        if process.poll() is not None:
            continue
        remaining = deadline - time.monotonic()
        if remaining > 0:
            try:
                process.wait(timeout=remaining)
            except subprocess.TimeoutExpired:
                pass
        if process.poll() is None:
            kill(process)


def run_external(command: list[str]) -> subprocess.CompletedProcess[str]:
    """Run one external tool while participating in cooperative cancellation."""
    check_cancelled()
    process = subprocess.Popen(
        command,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        text=True,
        cwd=str(_RUNTIME.run_temp_dir) if _RUNTIME.run_temp_dir is not None else None,
        start_new_session=(os.name == "posix"),
    )
    with _RUNTIME.active_processes_lock:
        _RUNTIME.active_processes.add(process)

    try:
        stdout, stderr = process.communicate()
    except (KeyboardInterrupt, ProcessingCancelled):
        try:
            if process.poll() is None:
                if os.name == "posix":
                    os.killpg(process.pid, signal.SIGTERM)
                else:
                    process.terminate()
                try:
                    process.wait(timeout=1.0)
                except subprocess.TimeoutExpired:
                    if os.name == "posix":
                        os.killpg(process.pid, signal.SIGKILL)
                    else:
                        process.kill()
        except (OSError, ProcessLookupError):
            pass
        raise
    finally:
        with _RUNTIME.active_processes_lock:
            _RUNTIME.active_processes.discard(process)

    if _RUNTIME.cancel_event.is_set():
        raise ProcessingCancelled()

    return subprocess.CompletedProcess(
        command, process.returncode, stdout, stderr
    )


def _resolved_encoder_threads(jobs: int, requested: int) -> int:
    """Resolve the per-encoder thread count without mutating process state."""
    cpu_count = usable_cpu_count()
    return requested if requested > 0 else max(1, cpu_count // max(1, jobs))


def _physical_memory_bytes() -> int | None:
    """Return host/cgroup memory capacity without an optional package."""
    limits: list[int] = []
    try:
        page_size = int(os.sysconf("SC_PAGE_SIZE"))
        page_count = int(os.sysconf("SC_PHYS_PAGES"))
        if page_size > 0 and page_count > 0:
            limits.append(page_size * page_count)
    except (AttributeError, OSError, TypeError, ValueError):
        pass
    for candidate in (
        Path("/sys/fs/cgroup/memory.max"),
        Path("/sys/fs/cgroup/memory/memory.limit_in_bytes"),
    ):
        try:
            raw = candidate.read_text(encoding="ascii").strip()
            if raw and raw != "max":
                value = int(raw)
                # Some cgroup v1 hosts expose a sentinel near 2**63.
                if 0 < value < (1 << 60):
                    limits.append(value)
        except (OSError, ValueError):
            continue
    return min(limits) if limits else None


def resolved_working_memory_budget(requested_mib: int) -> int:
    """Resolve the build memory budget without mutating process state."""
    if requested_mib > 0:
        budget = requested_mib * 1024 * 1024
    else:
        physical = _physical_memory_bytes()
        if physical is None:
            budget = 512 * 1024 * 1024
        else:
            # One eighth of RAM leaves room for encoders, the OS cache, and
            # other processes. Bound the automatic value on both small and
            # very large hosts so --jobs cannot make decoded rasters unbounded.
            budget = min(
                8 * 1024 * 1024 * 1024,
                max(256 * 1024 * 1024, physical // 8),
            )
    return max(1, int(budget))


def resolved_toolchain_capabilities(
    jobs: int = 1,
    requested_encoder_threads: int = 0,
    requested_memory_mib: int = 0,
) -> ToolchainCapabilities:
    """Discover and freeze external-tool capabilities for one build."""
    return ToolchainCapabilities(
        cjxl_path=shutil.which("cjxl"),
        djxl_path=shutil.which("djxl"),
        jxlinfo_path=shutil.which("jxlinfo"),
        ffmpeg_path=shutil.which("ffmpeg"),
        ffprobe_path=shutil.which("ffprobe"),
        encoder_threads=_resolved_encoder_threads(
            jobs,
            requested_encoder_threads,
        ),
        working_memory_budget_bytes=resolved_working_memory_budget(
            requested_memory_mib
        ),
        python_threading_mode=python_threading_mode(),
    )


@contextmanager
def encoder_slot(resources: BuildExecutionResources | None = None):
    """Reserve an encoder slot when called from an active build."""
    if resources is None:
        yield
    else:
        with resources.encoder_slot():
            yield

APP_ICON_ASSETS = {
    APP_ICON_MASTER,
    APP_FAVICON_16,
    APP_FAVICON_32,
    APP_FAVICON_48,
    APP_FAVICON_ICO,
    APPLE_TOUCH_ICON,
    ANDROID_ICON_192,
    ANDROID_ICON_512,
    ANDROID_MASKABLE_192,
    ANDROID_MASKABLE_512,
}

# Generated bundle permissions are deliberately independent of the caller's
# umask. This is especially important because tempfile.mkstemp() creates files
# as 0600, and os.replace() otherwise preserves that private mode.
OUTPUT_DIR_MODE = 0o755
OUTPUT_FILE_MODE = 0o644

# Pillow may support more formats than these, but limiting the initial candidate
# set avoids trying to decode unrelated files while crawling large trees.
RASTER_EXTENSIONS = {
    ".jpg",
    ".jpeg",
    ".jpe",
    ".png",
    ".gif",
    ".webp",
    ".avif",
    ".bmp",
    ".dib",
    ".tif",
    ".tiff",
    ".jxl",
    ".ico",
}
VECTOR_EXTENSIONS = {".svg", ".svgz"}
VIDEO_EXTENSIONS = {
    ".3gp",
    ".avi",
    ".m2ts",
    ".m4v",
    ".mkv",
    ".mov",
    ".mp4",
    ".mpeg",
    ".mpg",
    ".mts",
    ".ogv",
    ".ts",
    ".webm",
}
IMAGE_EXTENSIONS = RASTER_EXTENSIONS | VECTOR_EXTENSIONS
CANDIDATE_EXTENSIONS = IMAGE_EXTENSIONS | VIDEO_EXTENSIONS

# These formats can contain browser-playable animation and are kept as
# pass-through assets when Pillow reports multiple animated frames. Other
# multi-image formats (for example JPEG/MPO or multi-page TIFF) are static for
# gallery purposes. JPEG XL animation is detected separately with jxlinfo.
BROWSER_ANIMATED_EXTENSIONS = {".gif", ".webp", ".png", ".avif"}

_DIGIT_RE = re.compile(r"(\d+)")
_SVG_LENGTH_RE = re.compile(
    r"^\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+))(?:px)?\s*$", re.IGNORECASE
)
_JXL_INFO_RE = re.compile(
    r"JPEG XL\s+(image|animation),\s*(\d+)x(\d+)", re.IGNORECASE
)


@dataclass(frozen=True, slots=True)
class Diagnostic:
    level: Literal["info", "warning", "error"]
    message: str
    code: str | None = None
    source: Path | None = None

    def render(self) -> str:
        prefix = "" if self.level == "info" else f"{self.level}: "
        code = f"[{self.code}] " if self.code else ""
        source = f"{self.source}: " if self.source is not None else ""
        return f"{prefix}{code}{source}{self.message}"


class DiagnosticReporter:
    """Serialize all diagnostics around the live progress-line lifecycle."""

    def __init__(self) -> None:
        self.output_lock = threading.Lock()
        self.active_progress: Any | None = None

    def bind_progress(self, reporter: Any) -> None:
        with self.output_lock:
            self.active_progress = reporter

    def clear_progress(self, reporter: Any) -> None:
        if self.active_progress is reporter:
            self.active_progress = None

    def emit(self, diagnostic: Diagnostic) -> None:
        with self.output_lock:
            reporter = self.active_progress
            if reporter is not None:
                reporter.break_line()
            print(diagnostic.render(), file=sys.stderr, flush=True)

    def raw(self, *parts: object) -> None:
        text = " ".join(str(part) for part in parts)
        level: Literal["info", "warning", "error"] = "info"
        for candidate in ("warning", "error"):
            marker = candidate + ": "
            if text.startswith(marker):
                level = candidate  # type: ignore[assignment]
                text = text[len(marker):]
                break
        self.emit(Diagnostic(level, text))

    def warning(
        self,
        message: str,
        *,
        code: str | None = None,
        source: Path | None = None,
    ) -> None:
        self.emit(Diagnostic("warning", message, code, source))

    def error(
        self,
        message: str,
        *,
        code: str | None = None,
        source: Path | None = None,
    ) -> None:
        self.emit(Diagnostic("error", message, code, source))


DIAGNOSTICS = DiagnosticReporter()


def eprint(*args: object) -> None:
    """Print a diagnostic without appending it to an active TTY status line."""
    DIAGNOSTICS.raw(*args)


def natural_key(value: str) -> list[object]:
    """Case-insensitive natural sort key: image2 sorts before image10."""
    parts = _DIGIT_RE.split(value.casefold())
    return [int(part) if part.isdigit() else part for part in parts]


def natural_compare(left: str, right: str) -> int:
    """SQLite-compatible comparison matching natural_key()."""
    left_key = natural_key(left)
    right_key = natural_key(right)
    if left_key < right_key:
        return -1
    if left_key > right_key:
        return 1
    left_folded = left.casefold()
    right_folded = right.casefold()
    if left_folded < right_folded:
        return -1
    if left_folded > right_folded:
        return 1
    return (left > right) - (left < right)


def is_relative_to(path: Path, parent: Path) -> bool:
    try:
        path.relative_to(parent)
        return True
    except ValueError:
        return False


def ensure_output_directory(path: Path) -> None:
    """Create generated output directories and chmod only newly-created ones."""
    missing: list[Path] = []
    current = path
    while not current.exists():
        missing.append(current)
        parent = current.parent
        if parent == current:
            break
        current = parent

    path.mkdir(parents=True, exist_ok=True)
    for directory in reversed(missing):
        try:
            if directory.is_dir() and not directory.is_symlink():
                os.chmod(directory, OUTPUT_DIR_MODE)
        except OSError as exc:
            eprint(f"warning: could not chmod output directory {directory}: {exc}")


def set_output_file_mode(path: Path) -> None:
    os.chmod(path, OUTPUT_FILE_MODE)


class ArtifactWriter:
    """The only low-level path for atomic generated-file publication."""

    def __init__(self) -> None:
        self.durable_writes = False

    def configure(self, *, durable_writes: bool) -> None:
        self.durable_writes = bool(durable_writes)

    @staticmethod
    def _temporary_path(path: Path) -> tuple[int, Path]:
        ensure_output_directory(path.parent)
        fd, name = tempfile.mkstemp(
            prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent)
        )
        return fd, Path(name)

    def write(self, path: Path, payload: str | bytes) -> None:
        fd, temporary = self._temporary_path(path)
        try:
            if isinstance(payload, bytes):
                handle = os.fdopen(fd, "wb")
            else:
                handle = os.fdopen(fd, "w", encoding="utf-8", newline="\n")
            with handle:
                handle.write(payload)
                handle.flush()
                if self.durable_writes:
                    os.fsync(handle.fileno())
            os.replace(temporary, path)
            set_output_file_mode(path)
        finally:
            try:
                temporary.unlink()
            except FileNotFoundError:
                pass

    def write_if_changed(self, path: Path, payload: str | bytes) -> bool:
        try:
            unchanged = (
                path.read_bytes() == payload
                if isinstance(payload, bytes)
                else path.read_text(encoding="utf-8") == payload
            )
            if unchanged:
                return False
        except (FileNotFoundError, OSError, UnicodeError):
            pass
        self.write(path, payload)
        return True

    def copy(self, source: Path, destination: Path) -> None:
        fd, temporary = self._temporary_path(destination)
        os.close(fd)
        try:
            shutil.copyfile(source, temporary)
            os.replace(temporary, destination)
            set_output_file_mode(destination)
        finally:
            try:
                temporary.unlink()
            except FileNotFoundError:
                pass


ARTIFACT_WRITER = ArtifactWriter()


def atomic_write_text(path: Path, text: str) -> None:
    ARTIFACT_WRITER.write(path, text)


def write_bytes_if_changed(path: Path, data: bytes) -> bool:
    return ARTIFACT_WRITER.write_if_changed(path, data)


def png_bytes(image: Image.Image) -> bytes:
    out = io.BytesIO()
    image.save(
        out,
        format="PNG",
        optimize=True,
        compress_level=9,
    )
    return out.getvalue()


def favicon_ico_bytes(image: Image.Image) -> bytes:
    out = io.BytesIO()
    image.save(
        out,
        format="ICO",
        sizes=[(16, 16), (32, 32), (48, 48)],
    )
    return out.getvalue()


def square_icon(
    source: Image.Image,
    size: int,
    *,
    opaque: bool,
    padding_fraction: float = 0.0,
) -> Image.Image:
    """Render a square application icon while preserving source aspect ratio."""
    if size < 1:
        raise ValueError("icon size must be positive")

    source_rgba = source.convert("RGBA")

    padding = max(0, round(size * padding_fraction))
    content_size = max(1, size - (padding * 2))

    fitted = ImageOps.contain(
        source_rgba,
        (content_size, content_size),
        Image.Resampling.LANCZOS,
    )

    if opaque:
        canvas = Image.new("RGBA", (size, size), (255, 255, 255, 255))
    else:
        canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0))

    left = (size - fitted.width) // 2
    top = (size - fitted.height) // 2
    canvas.alpha_composite(fitted, (left, top))

    if opaque:
        rgb = Image.new("RGB", (size, size), "white")
        rgb.paste(canvas, mask=canvas.getchannel("A"))
        canvas.close()
        fitted.close()
        source_rgba.close()
        return rgb

    fitted.close()
    source_rgba.close()
    return canvas


def download_app_icon_source(url: str = APP_ICON_SOURCE_URL) -> bytes:
    request = urllib.request.Request(
        url,
        headers={
            "User-Agent": (
                "justified-gallery-builder/1.0 "
                "(static gallery favicon fetch)"
            )
        },
    )

    # The requested source is small. Bound the response so a changed remote URL
    # cannot unexpectedly consume large amounts of memory.
    max_bytes = 16 * 1024 * 1024
    with urllib.request.urlopen(request, timeout=30) as response:
        data = response.read(max_bytes + 1)

    if len(data) > max_bytes:
        raise ValueError("favicon source exceeds 16 MiB")

    return data


def load_or_create_app_icon_master(output_dir: Path) -> Image.Image:
    master_path = output_dir / APP_ICON_MASTER

    if master_path.is_file():
        with Image.open(master_path) as opened:
            opened.load()
            return opened.convert("RGBA")

    source_data = download_app_icon_source()
    with Image.open(io.BytesIO(source_data)) as opened:
        opened.load()
        normalized = ImageOps.exif_transpose(opened).convert("RGBA")

    master = ImageOps.contain(
        normalized,
        (512, 512),
        Image.Resampling.LANCZOS,
    )

    # Keep a local, optimized 512px master so subsequent builds do not need
    # network access even if one of the derived icon files is later removed.
    write_bytes_if_changed(master_path, png_bytes(master))
    normalized.close()

    return master


def ensure_app_assets(output_dir: Path, title: str) -> bool:
    """
    Create all favicon/home-screen assets.

    Returns False only when the remote source is needed and cannot be obtained
    or decoded. Existing local master files make subsequent builds offline.
    """
    try:
        master = load_or_create_app_icon_master(output_dir)
    except (
        OSError,
        ValueError,
        urllib.error.URLError,
        UnidentifiedImageError,
    ) as exc:
        eprint(f"warning: could not prepare application icons: {exc}")
        return False

    try:
        # Browser favicons preserve transparency from the supplied artwork.
        for size, relative in (
            (16, APP_FAVICON_16),
            (32, APP_FAVICON_32),
            (48, APP_FAVICON_48),
        ):
            icon = square_icon(master, size, opaque=False)
            try:
                write_bytes_if_changed(output_dir / relative, png_bytes(icon))
            finally:
                icon.close()

        ico_source = square_icon(master, 48, opaque=False)
        try:
            write_bytes_if_changed(
                output_dir / APP_FAVICON_ICO,
                favicon_ico_bytes(ico_source),
            )
        finally:
            ico_source.close()

        # Apple touch icons should be opaque. Keep the artwork nearly full-size
        # while allowing a small visual margin around the supplied symbol.
        apple = square_icon(
            master,
            180,
            opaque=True,
            padding_fraction=0.06,
        )
        try:
            write_bytes_if_changed(
                output_dir / APPLE_TOUCH_ICON,
                png_bytes(apple),
            )
        finally:
            apple.close()

        # Standard Android/PWA icons are opaque. Maskable variants reserve a
        # larger safe area so launchers can crop them into circles/squircles.
        for size, regular_relative, maskable_relative in (
            (192, ANDROID_ICON_192, ANDROID_MASKABLE_192),
            (512, ANDROID_ICON_512, ANDROID_MASKABLE_512),
        ):
            regular = square_icon(
                master,
                size,
                opaque=True,
                padding_fraction=0.06,
            )
            try:
                write_bytes_if_changed(
                    output_dir / regular_relative,
                    png_bytes(regular),
                )
            finally:
                regular.close()

            maskable = square_icon(
                master,
                size,
                opaque=True,
                padding_fraction=0.20,
            )
            try:
                write_bytes_if_changed(
                    output_dir / maskable_relative,
                    png_bytes(maskable),
                )
            finally:
                maskable.close()

        manifest = {
            "name": title,
            "short_name": title[:32],
            "id": "./",
            "start_url": "./",
            "scope": "./",
            "display": "standalone",
            "background_color": "#111111",
            "theme_color": "#111111",
            "icons": [
                {
                    "src": ANDROID_ICON_192,
                    "sizes": "192x192",
                    "type": "image/png",
                    "purpose": "any",
                },
                {
                    "src": ANDROID_ICON_512,
                    "sizes": "512x512",
                    "type": "image/png",
                    "purpose": "any",
                },
                {
                    "src": ANDROID_MASKABLE_192,
                    "sizes": "192x192",
                    "type": "image/png",
                    "purpose": "maskable",
                },
                {
                    "src": ANDROID_MASKABLE_512,
                    "sizes": "512x512",
                    "type": "image/png",
                    "purpose": "maskable",
                },
            ],
        }
        atomic_write_text(
            output_dir / APP_MANIFEST,
            json.dumps(
                manifest,
                ensure_ascii=False,
                indent=2,
            )
            + "\n",
        )
    finally:
        master.close()

    return True


def app_head_html(page_dir: Path, app_title: str, enabled: bool) -> str:
    if not enabled:
        return ""

    def href(relative: str) -> str:
        return html.escape(
            relative_output_url(page_dir, Path(relative)),
            quote=True,
        )

    app_title_html = html.escape(app_title, quote=True)
    return f"""
<link rel="icon" href="{href(APP_FAVICON_ICO)}" sizes="any">
<link rel="icon" type="image/png" sizes="16x16" href="{href(APP_FAVICON_16)}">
<link rel="icon" type="image/png" sizes="32x32" href="{href(APP_FAVICON_32)}">
<link rel="icon" type="image/png" sizes="48x48" href="{href(APP_FAVICON_48)}">
<link rel="apple-touch-icon" sizes="180x180" href="{href(APPLE_TOUCH_ICON)}">
<link rel="manifest" href="{href(APP_MANIFEST)}">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="{app_title_html}">"""


def write_text_if_changed(path: Path, text: str) -> bool:
    """Atomically write generated text only when its bytes changed."""
    return ARTIFACT_WRITER.write_if_changed(path, text)


def external_tool_version(path: str | None) -> str | None:
    """Return a short, path-scrubbed external-tool version string."""
    if not path:
        return None
    try:
        completed = run_external([path, "--version"])
    except (OSError, ValueError):
        return "available (version unavailable)"
    if completed.returncode != 0:
        return "available (version unavailable)"
    output = completed.stdout.strip() or completed.stderr.strip()
    if not output:
        return "available (version unavailable)"
    first_line = output.splitlines()[0].strip()
    first_line = first_line.replace(path, Path(path).name)
    first_line = re.sub(r"(?:[A-Za-z]:\\|/)[^\s]+", "<path>", first_line)
    return first_line[:200]


def build_provenance_payload(
    args: BuildOptions,
    toolchain: ToolchainCapabilities,
) -> dict[str, Any]:
    """Return deterministic build provenance containing no source paths."""
    try:
        script_sha256 = sha256_file(Path(__file__).resolve())
    except OSError:
        script_sha256 = None
    pillow_jxl_version = None
    if PILLOW_JXL_AVAILABLE:
        pillow_jxl_version = str(
            getattr(pillow_jxl, "__version__", "installed")
        )
    return {
        "schema_version": 1,
        "completed": True,
        "builder_sha256": script_sha256,
        "runtime": {
            "python": ".".join(str(item) for item in sys.version_info[:3]),
            "python_implementation": sys.implementation.name,
            "free_threaded_build": free_threaded_python_build(),
            "gil_enabled": gil_enabled(),
            "usable_cpu_count": usable_cpu_count(),
            "pillow": PILLOW_VERSION,
            "pillow_jxl": pillow_jxl_version,
        },
        "encoders": {
            "static": static_encoder_name(args.render.output_format),
            "cjxl": external_tool_version(toolchain.cjxl_path),
            "djxl": external_tool_version(toolchain.djxl_path),
            "jxlinfo": external_tool_version(toolchain.jxlinfo_path),
            "ffmpeg": external_tool_version(toolchain.ffmpeg_path),
            "ffprobe": external_tool_version(toolchain.ffprobe_path),
            "threads_per_image": toolchain.encoder_threads,
        },
        "options": {
            "format": args.render.output_format,
            "widths": args.render.widths,
            "quality": args.render.quality,
            "effort": args.render.effort,
            "jobs": args.render.jobs,
            "target_row_height": args.render.target_row_height,
            "gap": args.render.gap,
            "recursive": bool(args.source.recursive),
            "include_originals": bool(args.output_settings.include_originals),
            "transcode_incompatible_videos": bool(
                args.output_settings.transcode_incompatible_videos
            ),
            "video_transcode_crf": VIDEO_TRANSCODE_CRF,
            "video_transcode_preset": VIDEO_TRANSCODE_PRESET,
            "video_transcode_audio_bitrate": VIDEO_TRANSCODE_AUDIO_BITRATE,
            "jpeg_draft": bool(args.render.jpeg_draft),
            "max_image_pixels": args.render.max_image_pixels,
            "zoom_width": args.render.zoom_width,
            "image_id_bits": args.client.image_id_bits,
            "manifest_chunk_size": args.client.catalog_chunk_size,
            "geometry_chunk_size": args.client.geometry_chunk_size,
            "page_bootstrap_catalog_items": PAGE_BOOTSTRAP_CATALOG_ITEMS,
            "page_bootstrap_geometry_items": PAGE_BOOTSTRAP_GEOMETRY_ITEMS,
            "layout_worker": bool(args.client.layout_worker),
            "max_active_tile_loads": args.client.max_active_tile_loads,
            "image_load_margin": args.client.image_load_margin,
            "allow_unsorted": bool(args.client.allow_unsorted),
            "random_group_suffix_regex": (
                args.client.random_group_suffix_regex
            ),
            "max_hot_catalog_chunks": args.client.max_hot_catalog_chunks,
            "max_client_metadata_mib": args.client.max_client_metadata_mib,
            "working_memory_budget_mib": (
                toolchain.working_memory_budget_bytes // (1024 * 1024)
            ),
            "working_memory_budget_adaptive": (
                args.render.max_working_memory_mib == 0
            ),
            "lock_timeout_seconds": args.cache_settings.lock_timeout,
            "storage_preflight": bool(args.cache_settings.storage_preflight),
            "header_html_sha256": (
                hashlib.sha256(args.output_settings.header_html.encode("utf-8")).hexdigest()
                if args.output_settings.header_html
                else None
            ),
            "footer_html_sha256": (
                hashlib.sha256(args.output_settings.footer_html.encode("utf-8")).hexdigest()
                if args.output_settings.footer_html
                else None
            ),
            "password_protected": bool(args.password_settings.password_protected),
            "password_session_timeout_minutes": args.password_settings.password_session_timeout,
        },
        "formats": {
            "asset_schema": ASSET_SCHEMA_VERSION,
            "cache_schema": CACHE_DB_SCHEMA_VERSION,
            "gallery_data": GALLERY_DATA_FORMAT_VERSION,
            "random_order": ORDER_BINARY_VERSION,
            "exif_metadata": EXIF_METADATA_VERSION,
            "webp_animation": WEBP_ANIMATION_VERSION,
            "video_metadata": VIDEO_METADATA_VERSION,
            "video_poster": VIDEO_POSTER_VERSION,
            "video_transcode": VIDEO_TRANSCODE_VERSION,
        },
    }


def write_build_provenance(
    output_dir: Path,
    args: BuildOptions,
    toolchain: ToolchainCapabilities,
) -> GeneratedFilePublication:
    payload = build_provenance_payload(args, toolchain)
    text = json.dumps(
        payload,
        ensure_ascii=False,
        sort_keys=True,
        indent=2,
    ) + "\n"
    revision = hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]
    rewritten = write_text_if_changed(output_dir / GALLERY_BUILD_INFO, text)
    return GeneratedFilePublication(GALLERY_BUILD_INFO, rewritten, revision)


def write_deployment_manifest(
    output_dir: Path,
    transcode_incompatible_videos: bool,
    data_revision: str,
    revision_index_rel: str,
) -> GeneratedFilePublication:
    """Write portable MIME, caching, and byte-range deployment guidance."""
    payload = {
        "schema_version": 2,
        "generation": data_revision,
        "purpose": (
            "Publishing metadata for static hosts, object storage, and CDNs. "
            "Apply the most specific matching rule."
        ),
        "mime_types": {
            ".html": "text/html; charset=utf-8",
            ".css": "text/css; charset=utf-8",
            ".js": "text/javascript; charset=utf-8",
            ".bin": "application/octet-stream",
            ".json": "application/json; charset=utf-8",
            ".webmanifest": "application/manifest+json; charset=utf-8",
            ".py": "text/x-python; charset=utf-8",
            ".avif": "image/avif",
            ".bmp": "image/bmp",
            ".gif": "image/gif",
            ".ico": "image/x-icon",
            ".jfif": "image/jpeg",
            ".jpe": "image/jpeg",
            ".jpeg": "image/jpeg",
            ".jpg": "image/jpeg",
            ".jxl": "image/jxl",
            ".png": "image/png",
            ".svg": "image/svg+xml",
            ".svgz": "image/svg+xml",
            ".tif": "image/tiff",
            ".tiff": "image/tiff",
            ".webp": "image/webp",
            ".3gp": "video/3gpp",
            ".avi": "video/x-msvideo",
            ".m2ts": "video/mp2t",
            ".m4v": "video/x-m4v",
            ".mkv": "video/x-matroska",
            ".mov": "video/quicktime",
            ".mp4": "video/mp4",
            ".mpeg": "video/mpeg",
            ".mpg": "video/mpeg",
            ".mts": "video/mp2t",
            ".ogv": "video/ogg",
            ".ts": "video/mp2t",
            ".webm": "video/webm",
        },
        "content_encoding": {
            ".svgz": "gzip",
        },
        "compression": {
            "recommended_encodings": ["br", "gzip"],
            "compressible_extensions": [
                ".html", ".css", ".js", ".json", ".svg"
            ],
            "vary_header": "Accept-Encoding",
            "verification": (
                "Confirm public text responses include Content-Encoding: br "
                "or gzip; never label uncompressed object bytes with a "
                "Content-Encoding header. Binary metadata, images, and videos "
                "should not be recompressed."
            ),
        },
        "cache_rules": [
            {
                "paths": ["index.html", "albums/**/index.html"],
                "cache_control": "public, max-age=0, must-revalidate",
            },
            {
                "paths": [
                    "data/build.json",
                    "data/deployment.json",
                    "data/gallery.py",
                    "site.webmanifest",
                    "assets/app-icons/**",
                ],
                "cache_control": "public, max-age=3600, must-revalidate",
            },
            {
                "paths": ["assets/**", "data/**/*.js", "data/**/*.bin"],
                "cache_control": "public, max-age=31536000, immutable",
            },
        ],
        "video_delivery": {
            "request_headers_to_preserve": ["Range", "If-Range"],
            "range_response_status": 206,
            "response_headers_to_preserve": [
                "Accept-Ranges",
                "Content-Range",
                "Content-Length",
                "Content-Type",
            ],
            "originals": "video originals are always exact source copies",
            "compatibility_transcoding_enabled": bool(
                transcode_incompatible_videos
            ),
            "compatibility_derivative": (
                "H.264 8-bit 4:2:0 video and AAC audio in MP4; detected HDR "
                "is tone-mapped to BT.709 SDR; the original remains the "
                "download and playback-failure fallback"
            ),
        },
        "publish": {
            "preserve_relative_paths": True,
            "index_document": "index.html",
            "directory_index_required_for_album_urls": True,
            "exclude": [
                ".gallery-cache.sqlite3",
                ".gallery-cache.sqlite3-wal",
                ".gallery-cache.sqlite3-shm",
                ".gallery-build.lock",
                "*.tmp",
            ],
            "upload_phases": [
                {
                    "order": 1,
                    "name": "immutable_content",
                    "paths": [
                        "assets/**",
                        "data/items/**",
                        "data/exif/**",
                        "data/geometry/**",
                        "data/order/**",
                        "data/ids/**",
                    ],
                    "cache_control": "public, max-age=31536000, immutable",
                    "parallel_safe": True,
                },
                {
                    "order": 2,
                    "name": "generation_manifests",
                    "paths": [
                        revision_index_rel,
                        "data/pages/**",
                        "data/build.json",
                        "data/deployment.json",
                        "data/gallery.py",
                        "site.webmanifest",
                    ],
                    "parallel_safe": True,
                },
                {
                    "order": 3,
                    "name": "html_commit_barrier",
                    "paths": ["albums/**/index.html", "index.html"],
                    "root_index_last": True,
                    "cache_control": "public, max-age=0, must-revalidate",
                },
            ],
            "failure_rule": (
                "Do not upload phase 3 unless phases 1 and 2 completed and "
                "verification passed; an existing HTML generation then remains "
                "the visible commit point."
            ),
            "post_upload_checks": [
                "HEAD one object from each phase and verify Content-Type, "
                "Cache-Control, and Content-Length",
                "fetch index.html without a cached response",
                "fetch its data/pages descriptor and chunk revision index",
                "fetch and hash one catalog .bin and one geometry .bin",
                "verify video Range requests return 206 when videos exist",
            ],
        },
        "provider_notes": {
            "Cloudflare": (
                "Respect origin Content-Type and Cache-Control metadata, avoid "
                "cache rules that ignore Range, allow 206 responses, and keep "
                "Brotli or gzip compression enabled for HTML/CSS/JS/JSON."
            ),
            "AWS S3": (
                "Set Content-Type and Cache-Control as object metadata at upload; "
                "metadata changes require copying/replacing the object."
            ),
            "Backblaze B2": (
                "Set b2-content-type and cache-control file info at upload; "
                "preserve byte-range requests through any CDN."
            ),
        },
        "password_gate": (
            "Client-side access gate only; it does not encrypt assets or replace "
            "origin/CDN authorization."
        ),
    }
    text = json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2) + "\n"
    revision = hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]
    rewritten = write_text_if_changed(output_dir / GALLERY_DEPLOYMENT_INFO, text)
    return GeneratedFilePublication(
        GALLERY_DEPLOYMENT_INFO,
        rewritten,
        revision,
    )



def ensure_builder_script_copy(output_dir: Path) -> bool:
    """Copy the exact script currently building the gallery into the bundle."""
    source = Path(__file__).resolve()
    try:
        data = source.read_bytes()
    except OSError as exc:
        raise RuntimeError(
            f"could not read builder script for credit link: {source}"
        ) from exc

    return write_bytes_if_changed(output_dir / BUILDER_SCRIPT_COPY, data)


def write_html(path: Path, text: str, skip_unchanged: bool = True) -> bool:
    """Write one generated HTML page, preserving identical files by default."""
    if skip_unchanged:
        try:
            if path.read_text(encoding="utf-8") == text:
                return False
        except (FileNotFoundError, OSError, UnicodeError):
            pass

    atomic_write_text(path, text)
    return True


def atomic_copy(src: Path, dst: Path) -> None:
    ARTIFACT_WRITER.copy(src, dst)


def sha256_file(path: Path, chunk_size: int = 4 * 1024 * 1024) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as handle:
        while True:
            chunk = handle.read(chunk_size)
            if not chunk:
                break
            digest.update(chunk)
    return digest.hexdigest()


def quick_file_fingerprint(path: Path, size: int, sample_size: int = 4096) -> str:
    """Hash bounded edge samples for validating filesystem identity hints."""
    digest = hashlib.sha256()
    digest.update(struct.pack("<Q", max(0, int(size))))
    with path.open("rb") as handle:
        digest.update(handle.read(sample_size))
        if size > sample_size:
            handle.seek(max(0, int(size) - sample_size))
            digest.update(handle.read(sample_size))
    return digest.hexdigest()[:24]


def parse_widths(value: str) -> list[int]:
    widths: list[int] = []
    for raw in value.split(","):
        raw = raw.strip()
        if not raw:
            continue
        try:
            width = int(raw)
        except ValueError as exc:
            raise argparse.ArgumentTypeError(f"invalid width: {raw!r}") from exc
        if width < 64:
            raise argparse.ArgumentTypeError("derivative widths must be >= 64")
        widths.append(width)

    widths = sorted(set(widths))
    if not widths:
        raise argparse.ArgumentTypeError("at least one derivative width is required")
    return widths


def positive_int(value: str) -> int:
    try:
        parsed = int(value)
    except ValueError as exc:
        raise argparse.ArgumentTypeError(f"invalid integer: {value!r}") from exc
    if parsed < 1:
        raise argparse.ArgumentTypeError("value must be >= 1")
    return parsed


def nonnegative_int(value: str) -> int:
    try:
        parsed = int(value)
    except ValueError as exc:
        raise argparse.ArgumentTypeError(f"invalid integer: {value!r}") from exc
    if parsed < 0:
        raise argparse.ArgumentTypeError("value must be >= 0")
    return parsed


def image_id_bits(value: str) -> int:
    try:
        parsed = int(value)
    except ValueError as exc:
        raise argparse.ArgumentTypeError(f"invalid integer: {value!r}") from exc

    if parsed < MIN_IMAGE_ID_BITS or parsed > STABLE_IMAGE_ID_BITS:
        raise argparse.ArgumentTypeError(
            f"image ID bits must be between {MIN_IMAGE_ID_BITS} "
            f"and {STABLE_IMAGE_ID_BITS}"
        )
    if parsed % 4 != 0:
        raise argparse.ArgumentTypeError(
            "image ID bits must be a multiple of 4 because IDs are hexadecimal"
        )
    return parsed


def nonnegative_float(value: str) -> float:
    try:
        parsed = float(value)
    except ValueError as exc:
        raise argparse.ArgumentTypeError(f"invalid number: {value!r}") from exc
    if parsed < 0:
        raise argparse.ArgumentTypeError("value must be >= 0")
    return parsed


def unit_interval_float(value: str) -> float:
    try:
        parsed = float(value)
    except ValueError as exc:
        raise argparse.ArgumentTypeError(f"invalid number: {value!r}") from exc
    if not (0.0 <= parsed <= 1.0):
        raise argparse.ArgumentTypeError("value must be between 0 and 1")
    return parsed


def _json_dumps(value: Any) -> str:
    return json.dumps(
        value,
        ensure_ascii=False,
        sort_keys=True,
        separators=(",", ":"),
    )


_NORMALIZED_CACHE_ENTRY_KEYS = {
    "source_path",
    "source_suffix",
    "size",
    "mtime_ns",
    "sha256",
    "width",
    "height",
    "animated",
    "kind",
    "render_signature",
    "static_encoder",
    "jxl_encoder",
    "jxl_support_version",
    "jxl_decoder",
    "original_asset",
    "asset",
    "jpeg_draft",
    "exif_version",
    "exif",
    "variants",
}


def _cache_entry_json(entry: MediaEntry) -> str:
    """Serialize only uncommon fields not already in normalized columns."""
    extras = {
        key: value
        for key, value in entry.items()
        if key not in _NORMALIZED_CACHE_ENTRY_KEYS
    }
    return _json_dumps(extras)


def open_cache_connection(path: Path) -> sqlite3.Connection:
    ensure_output_directory(path.parent)
    connection = sqlite3.connect(
        path,
        timeout=60.0,
        isolation_level=None,
    )
    connection.execute("PRAGMA busy_timeout = 60000")
    connection.execute("PRAGMA journal_mode = WAL")
    connection.execute("PRAGMA synchronous = NORMAL")
    connection.execute("PRAGMA temp_store = MEMORY")
    return connection


@contextmanager
def immediate_transaction(
    connection: sqlite3.Connection,
) -> Iterable[sqlite3.Connection]:
    """Run one explicit SQLite write transaction with reliable rollback."""
    connection.execute("BEGIN IMMEDIATE")
    try:
        yield connection
        connection.execute("COMMIT")
    except BaseException:
        if connection.in_transaction:
            try:
                connection.execute("ROLLBACK")
            except sqlite3.Error:
                pass
        raise


def _normalized_entry_values(entry: MediaEntry) -> tuple[Any, ...]:
    """Return hot cache fields stored in dedicated SQLite columns."""
    exif_json = None
    if entry.get("exif_version") is not None:
        exif_json = _json_dumps(entry.get("exif", []))
    return (
        entry.get("size"),
        entry.get("mtime_ns"),
        entry.get("sha256"),
        entry.get("width"),
        entry.get("height"),
        int(bool(entry.get("animated", False))),
        entry.get("kind"),
        entry.get("source_suffix"),
        entry.get("render_signature"),
        entry.get("static_encoder"),
        entry.get("jxl_encoder"),
        entry.get("jxl_support_version"),
        entry.get("jxl_decoder"),
        entry.get("original_asset"),
        entry.get("asset"),
        (
            int(entry.get("jpeg_draft") or 0)
            if "jpeg_draft" in entry
            else None
        ),
        entry.get("exif_version"),
        exif_json,
    )


def _source_identity_values(entry: MediaEntry) -> tuple[Any, ...] | None:
    """Return cheap filesystem identity hints for rename detection.

    Device/inode identity is never used alone: size, mtime, suffix, and a
    bounded first/last-byte fingerprint must also agree before cached content
    is reused without rereading the entire source.
    """
    required = (
        entry.get("source_device"),
        entry.get("source_inode"),
        entry.get("size"),
        entry.get("mtime_ns"),
        entry.get("quick_hash"),
    )
    if any(value is None for value in required):
        return None
    return (
        int(required[0]),
        int(required[1]),
        int(required[2]),
        int(required[3]),
        int(entry.get("source_ctime_ns") or 0),
        int(entry.get("source_birthtime_ns") or 0),
        str(required[4]),
    )


def _add_column_if_missing(
    connection: sqlite3.Connection,
    table: str,
    column: str,
    declaration: str,
) -> None:
    existing = {
        str(row[1])
        for row in connection.execute(f"PRAGMA table_info({table})")
    }
    if column not in existing:
        connection.execute(
            f"ALTER TABLE {table} ADD COLUMN {column} {declaration}"
        )


def _backfill_normalized_cache_columns(connection: sqlite3.Connection) -> None:
    """One-time v1 -> v2 migration of JSON cache fields into hot columns."""
    rows = connection.execute(
        "SELECT path, entry_json FROM files "
        "WHERE size IS NULL OR mtime_ns IS NULL OR sha256 IS NULL "
        "OR width IS NULL OR height IS NULL OR kind IS NULL"
    )
    batch: list[tuple[Any, ...]] = []
    for source_path, entry_json in rows:
        try:
            entry = json.loads(entry_json)
        except (TypeError, json.JSONDecodeError):
            continue
        if not isinstance(entry, dict):
            continue
        batch.append((*_normalized_entry_values(entry), str(source_path)))
        if len(batch) >= 1000:
            connection.executemany(
                """
                UPDATE files SET
                    size=?, mtime_ns=?, sha256=?, width=?, height=?,
                    animated=?, kind=?, source_suffix=?, render_signature=?,
                    static_encoder=?, jxl_encoder=?, jxl_support_version=?,
                    jxl_decoder=?, original_asset=?, asset=?, jpeg_draft=?,
                    exif_version=?, exif_json=?
                WHERE path=?
                """,
                batch,
            )
            batch.clear()
    if batch:
        connection.executemany(
            """
            UPDATE files SET
                size=?, mtime_ns=?, sha256=?, width=?, height=?,
                animated=?, kind=?, source_suffix=?, render_signature=?,
                static_encoder=?, jxl_encoder=?, jxl_support_version=?,
                jxl_decoder=?, original_asset=?, asset=?, jpeg_draft=?,
                exif_version=?, exif_json=?
            WHERE path=?
            """,
            batch,
        )


def _compact_cache_entry_json(connection: sqlite3.Connection) -> int:
    """Remove normalized duplicate values from legacy JSON cache payloads."""
    changed = 0
    batch: list[tuple[str, str]] = []
    rows = connection.execute("SELECT path, entry_json FROM files")
    for source_path, entry_json in rows:
        try:
            entry = json.loads(entry_json)
        except (TypeError, json.JSONDecodeError):
            continue
        if not isinstance(entry, dict):
            continue
        compact = _cache_entry_json(entry)
        if compact == entry_json:
            continue
        batch.append((compact, str(source_path)))
        changed += 1
        if len(batch) >= 2000:
            connection.executemany(
                "UPDATE files SET entry_json = ? WHERE path = ?",
                batch,
            )
            batch.clear()
    if batch:
        connection.executemany(
            "UPDATE files SET entry_json = ? WHERE path = ?",
            batch,
        )
    return changed


def _create_cache_schema_objects(connection: sqlite3.Connection) -> None:
    connection.execute(
        """
        CREATE TABLE IF NOT EXISTS files (
            path TEXT PRIMARY KEY,
            entry_json TEXT NOT NULL,
            seen_generation INTEGER NOT NULL DEFAULT 0,
            updated_ns INTEGER NOT NULL,
            size INTEGER,
            mtime_ns INTEGER,
            sha256 TEXT,
            width INTEGER,
            height INTEGER,
            animated INTEGER,
            kind TEXT,
            source_suffix TEXT,
            render_signature TEXT,
            static_encoder TEXT,
            jxl_encoder TEXT,
            jxl_support_version INTEGER,
            jxl_decoder TEXT,
            original_asset TEXT,
            asset TEXT,
            jpeg_draft INTEGER,
            exif_version INTEGER,
            exif_json TEXT
        ) WITHOUT ROWID
        """
    )
    connection.execute(
        """
        CREATE TABLE IF NOT EXISTS file_identity (
            path TEXT PRIMARY KEY,
            device INTEGER NOT NULL,
            inode INTEGER NOT NULL,
            size INTEGER NOT NULL,
            mtime_ns INTEGER NOT NULL,
            ctime_ns INTEGER NOT NULL,
            birthtime_ns INTEGER NOT NULL,
            quick_hash TEXT NOT NULL,
            updated_ns INTEGER NOT NULL
        ) WITHOUT ROWID
        """
    )
    connection.execute(
        """
        CREATE INDEX IF NOT EXISTS file_identity_lookup
        ON file_identity(device, inode, size, mtime_ns)
        """
    )
    connection.execute(
        """
        CREATE TABLE IF NOT EXISTS metadata (
            key TEXT PRIMARY KEY,
            value TEXT NOT NULL
        ) WITHOUT ROWID
        """
    )
    connection.execute(
        """
        CREATE TABLE IF NOT EXISTS generated_files (
            path TEXT PRIMARY KEY,
            kind TEXT NOT NULL,
            seen_generation INTEGER NOT NULL DEFAULT 0,
            input_signature TEXT
        ) WITHOUT ROWID
        """
    )
    connection.execute(
        """
        CREATE TABLE IF NOT EXISTS data_chunk_state (
            kind TEXT NOT NULL,
            chunk_key TEXT NOT NULL,
            dependency_signature TEXT NOT NULL,
            output_path TEXT NOT NULL,
            output_signature TEXT NOT NULL,
            metadata_json TEXT NOT NULL,
            PRIMARY KEY(kind, chunk_key)
        ) WITHOUT ROWID
        """
    )
    connection.execute(
        """
        CREATE TABLE IF NOT EXISTS data_generations (
            build_generation INTEGER NOT NULL,
            path TEXT NOT NULL,
            kind TEXT NOT NULL,
            PRIMARY KEY(build_generation, path)
        ) WITHOUT ROWID
        """
    )
    connection.execute(
        """
        CREATE INDEX IF NOT EXISTS data_generations_path
        ON data_generations(path)
        """
    )
    connection.execute(
        """
        CREATE TABLE IF NOT EXISTS data_generation_snapshots (
            build_generation INTEGER NOT NULL,
            kind TEXT NOT NULL,
            snapshot_generation INTEGER NOT NULL,
            signature TEXT NOT NULL,
            path_count INTEGER NOT NULL,
            PRIMARY KEY(build_generation, kind)
        ) WITHOUT ROWID
        """
    )
    connection.execute(
        """
        CREATE INDEX IF NOT EXISTS data_generation_snapshot_signature
        ON data_generation_snapshots(kind, signature, build_generation)
        """
    )
    connection.execute(
        """
        CREATE INDEX IF NOT EXISTS data_generation_snapshot_reference
        ON data_generation_snapshots(snapshot_generation, kind)
        """
    )
    connection.execute(
        """
        CREATE TABLE IF NOT EXISTS artifact_validations (
            path TEXT PRIMARY KEY,
            revision TEXT NOT NULL,
            size INTEGER NOT NULL,
            mtime_ns INTEGER NOT NULL,
            ctime_ns INTEGER NOT NULL,
            device INTEGER NOT NULL,
            inode INTEGER NOT NULL,
            validated_ns INTEGER NOT NULL
        ) WITHOUT ROWID
        """
    )
    _add_column_if_missing(
        connection,
        "data_generations",
        "kind",
        "TEXT NOT NULL DEFAULT 'data'",
    )

def _ensure_current_cache_columns(connection: sqlite3.Connection) -> None:
    """Add every nullable column required by the current cache schema."""
    for column, declaration in (
        ("size", "INTEGER"),
        ("mtime_ns", "INTEGER"),
        ("sha256", "TEXT"),
        ("width", "INTEGER"),
        ("height", "INTEGER"),
        ("animated", "INTEGER"),
        ("kind", "TEXT"),
        ("source_suffix", "TEXT"),
        ("render_signature", "TEXT"),
        ("static_encoder", "TEXT"),
        ("jxl_encoder", "TEXT"),
        ("jxl_support_version", "INTEGER"),
        ("jxl_decoder", "TEXT"),
        ("original_asset", "TEXT"),
        ("asset", "TEXT"),
        ("jpeg_draft", "INTEGER"),
        ("exif_version", "INTEGER"),
        ("exif_json", "TEXT"),
    ):
        _add_column_if_missing(connection, "files", column, declaration)
    _add_column_if_missing(
        connection,
        "generated_files",
        "input_signature",
        "TEXT",
    )


def _migrate_cache_1_to_2(connection: sqlite3.Connection) -> None:
    _ensure_current_cache_columns(connection)
    _backfill_normalized_cache_columns(connection)


def _migrate_cache_2_to_3(connection: sqlite3.Connection) -> None:
    _ensure_current_cache_columns(connection)


def _migrate_cache_3_to_4(connection: sqlite3.Connection) -> None:
    _ensure_current_cache_columns(connection)


def _migrate_cache_4_to_5(connection: sqlite3.Connection) -> None:
    _ensure_current_cache_columns(connection)


def _migrate_cache_5_to_6(connection: sqlite3.Connection) -> None:
    _ensure_current_cache_columns(connection)


def _migrate_cache_6_to_7(connection: sqlite3.Connection) -> None:
    _ensure_current_cache_columns(connection)
    _compact_cache_entry_json(connection)


def _migrate_cache_7_to_8(connection: sqlite3.Connection) -> None:
    """Reference legacy retained generations without duplicating their paths."""
    _ensure_current_cache_columns(connection)
    connection.execute(
        """
        INSERT OR IGNORE INTO data_generation_snapshots(
            build_generation, kind, snapshot_generation, signature, path_count
        )
        SELECT
            build_generation,
            kind,
            build_generation,
            'legacy:' || kind || ':' || build_generation,
            COUNT(*)
        FROM data_generations
        GROUP BY build_generation, kind
        """
    )


@dataclass(frozen=True, slots=True)
class CacheMigration:
    """One automatic, ordered, and independently testable cache upgrade."""

    source: int
    target: int
    apply: Callable[[sqlite3.Connection], None]


CACHE_SCHEMA_MIGRATION_STEPS = (
    CacheMigration(1, 2, _migrate_cache_1_to_2),
    CacheMigration(2, 3, _migrate_cache_2_to_3),
    CacheMigration(3, 4, _migrate_cache_3_to_4),
    CacheMigration(4, 5, _migrate_cache_4_to_5),
    CacheMigration(5, 6, _migrate_cache_5_to_6),
    CacheMigration(6, 7, _migrate_cache_6_to_7),
    CacheMigration(7, 8, _migrate_cache_7_to_8),
)
CACHE_SCHEMA_MIGRATIONS: dict[
    int,
    CacheMigration | tuple[int, Callable[[sqlite3.Connection], None]],
] = {
    migration.source: migration for migration in CACHE_SCHEMA_MIGRATION_STEPS
}


def initialize_cache_schema(connection: sqlite3.Connection) -> None:
    """Create or automatically upgrade the cache in one transaction."""
    current = int(connection.execute("PRAGMA user_version").fetchone()[0])
    if current < 0 or current > CACHE_DB_SCHEMA_VERSION:
        raise CacheError(
            f"unsupported cache database schema version {current}; "
            f"expected <= {CACHE_DB_SCHEMA_VERSION}"
        )

    with immediate_transaction(connection):
        _create_cache_schema_objects(connection)
        if current == 0:
            connection.execute(
                f"PRAGMA user_version = {CACHE_DB_SCHEMA_VERSION}"
            )
        else:
            while current < CACHE_DB_SCHEMA_VERSION:
                migration = CACHE_SCHEMA_MIGRATIONS.get(current)
                if migration is None:
                    raise CacheError(
                        f"no automatic cache migration is available from "
                        f"schema version {current}"
                    )
                if isinstance(migration, CacheMigration):
                    migration_target = migration.target
                    migration_apply = migration.apply
                else:
                    # Retain compatibility with the former internal registry
                    # shape so upgrade-failure injectors can test rollback.
                    migration_target, migration_apply = migration
                if migration_target != current + 1:
                    raise CacheError(
                        "cache migration registry is not contiguous at "
                        f"schema version {current}"
                    )
                migration_apply(connection)
                connection.execute(
                    f"PRAGMA user_version = {migration_target}"
                )
                current = migration_target

        connection.execute(
            """
            CREATE INDEX IF NOT EXISTS files_content_lookup
            ON files(sha256, size, source_suffix)
            """
        )


def _set_metadata(
    connection: sqlite3.Connection,
    key: str,
    value: Any,
) -> None:
    connection.execute(
        """
        INSERT INTO metadata(key, value)
        VALUES(?, ?)
        ON CONFLICT(key) DO UPDATE SET value = excluded.value
        """,
        (key, _json_dumps(value)),
    )


def _get_metadata(
    connection: sqlite3.Connection,
    key: str,
    default: Any = None,
) -> Any:
    row = connection.execute(
        "SELECT value FROM metadata WHERE key = ?",
        (key,),
    ).fetchone()
    if row is None:
        return default

    try:
        return json.loads(row[0])
    except json.JSONDecodeError:
        return default


_ISO_8601_UTC_SECONDS_RE = re.compile(
    r"\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\Z"
)


def resolve_html_build_date(
    cache_db: "SQLiteCache",
    build_signature: str,
    previous_run_complete: bool,
) -> str:
    """Return one UTC publication timestamp, retaining it for a no-op build.

    A fresh timestamp on every invocation would make every HTML page differ
    even when the generated gallery is unchanged, defeating
    --skip-unchanged-html. Reuse the previous timestamp only when the prior run
    completed and every input that can affect generated HTML has the same
    signature.
    """
    previous_signature = cache_db.metadata.get(
        HTML_BUILD_SIGNATURE_METADATA_KEY,
    )
    previous_build_date = cache_db.metadata.get(
        HTML_BUILD_DATE_METADATA_KEY,
    )
    if (
        previous_run_complete
        and previous_signature == build_signature
        and isinstance(previous_build_date, str)
        and _ISO_8601_UTC_SECONDS_RE.fullmatch(previous_build_date) is not None
    ):
        return previous_build_date

    build_date = (
        datetime.now(timezone.utc)
        .replace(microsecond=0)
        .isoformat()
        .replace("+00:00", "Z")
    )
    cache_db.metadata.set(
        HTML_BUILD_SIGNATURE_METADATA_KEY,
        build_signature,
    )
    cache_db.metadata.set(
        HTML_BUILD_DATE_METADATA_KEY,
        build_date,
    )
    return build_date


def password_gate_config(
    cache_db: "SQLiteCache",
    password: str | None,
    session_timeout_seconds: int = 0,
) -> PasswordConfig | None:
    """Return a stable salted verifier without persisting the password."""
    if password is None:
        return None

    salt_hex = cache_db.metadata.get(
        PASSWORD_SALT_METADATA_KEY,
        None,
    )
    if not isinstance(salt_hex, str) or re.fullmatch(
        r"[0-9a-f]{32}", salt_hex
    ) is None:
        salt_hex = secrets.token_hex(16)
        cache_db.metadata.set(
            PASSWORD_SALT_METADATA_KEY,
            salt_hex,
        )

    verifier_hex = hashlib.pbkdf2_hmac(
        "sha256",
        password.encode("utf-8"),
        bytes.fromhex(salt_hex),
        PASSWORD_PBKDF2_ITERATIONS,
    ).hex()
    return {
        "salt_hex": salt_hex,
        "verifier_hex": verifier_hex,
        "iterations": PASSWORD_PBKDF2_ITERATIONS,
        "session_timeout_seconds": max(0, int(session_timeout_seconds)),
    }


def resolve_gallery_password(
    args: argparse.Namespace,
    parser: argparse.ArgumentParser,
) -> tuple[str | None, str | None]:
    """Resolve an explicitly selected password source without persisting it."""
    literal = args.password
    password_file = args.password_file
    password_env = args.password_env

    if literal is PASSWORD_PROMPT_SENTINEL:
        try:
            first = getpass.getpass("Gallery password: ")
            second = getpass.getpass("Confirm gallery password: ")
        except (EOFError, KeyboardInterrupt):
            parser.error("could not read an interactive gallery password")
        if first != second:
            parser.error("interactive gallery passwords did not match")
        password = first
        source = "interactive prompt"
    elif literal is not None:
        password = str(literal)
        source = "command line"
        eprint(
            "warning: --password VALUE may be visible in shell history or "
            "process listings; prefer --password, --password-file, or "
            "--password-env"
        )
    elif password_file is not None:
        path = password_file.expanduser()
        try:
            raw = path.read_text(encoding="utf-8")
        except (OSError, UnicodeError) as exc:
            parser.error(f"could not read --password-file {path}: {exc}")
        password = (
            raw[:-2]
            if raw.endswith("\r\n")
            else (raw[:-1] if raw.endswith("\n") else raw)
        )
        if "\n" in password or "\r" in password:
            parser.error("--password-file must contain exactly one line")
        source = "password file"
        try:
            mode = stat_module.S_IMODE(path.stat().st_mode)
            if mode & 0o077:
                eprint(
                    f"warning: password file {path} is readable by group or "
                    "others; consider chmod 600"
                )
        except OSError:
            pass
    elif password_env is not None:
        value = os.environ.get(password_env)
        if value is None:
            parser.error(
                f"environment variable selected by --password-env is not set: "
                f"{password_env}"
            )
        password = value
        source = "environment variable"
        if "\n" in password or "\r" in password:
            parser.error("the password environment variable must be one line")
    else:
        return None, None

    if password == "":
        parser.error("gallery password cannot be empty")
    return password, source


def normalize_cache_permissions(path: Path) -> None:
    for candidate in (
        path,
        Path(f"{path}-wal"),
        Path(f"{path}-shm"),
    ):
        try:
            if candidate.is_file() and not candidate.is_symlink():
                os.chmod(candidate, OUTPUT_FILE_MODE)
        except OSError as exc:
            eprint(f"warning: could not chmod cache file {candidate}: {exc}")


def _legacy_migrated_path(path: Path) -> Path:
    candidate = path.with_name(path.name + ".migrated")
    if not candidate.exists():
        return candidate

    stamp = time.strftime("%Y%m%d-%H%M%S")
    return path.with_name(path.name + f".migrated-{stamp}")


def migrate_legacy_json_cache(
    sqlite_path: Path,
    legacy_path: Path,
) -> int:
    """
    Import the legacy JSON cache exactly once.

    Migration only runs when the SQLite database does not yet exist. The
    database is built in a temporary file and atomically installed. After a
    successful import, the JSON cache is renamed with a .migrated suffix as a
    backup.
    """
    if sqlite_path.exists() or not legacy_path.is_file():
        return 0

    try:
        with legacy_path.open("r", encoding="utf-8") as handle:
            legacy = json.load(handle)
    except (OSError, json.JSONDecodeError) as exc:
        eprint(f"warning: could not migrate legacy cache {legacy_path}: {exc}")
        return 0

    files = legacy.get("files")
    if not isinstance(files, dict):
        eprint(
            f"warning: legacy cache {legacy_path} has no usable files mapping; "
            "starting with a new SQLite cache"
        )
        return 0

    ensure_output_directory(sqlite_path.parent)

    fd, tmp_name = tempfile.mkstemp(
        prefix=f".{sqlite_path.name}.migrating.",
        suffix=".sqlite3",
        dir=str(sqlite_path.parent),
    )
    os.close(fd)
    tmp_path = Path(tmp_name)
    try:
        # mkstemp creates an empty file; sqlite can open and initialize it.
        connection = sqlite3.connect(
            tmp_path,
            timeout=60.0,
            isolation_level=None,
        )
        try:
            connection.execute("PRAGMA journal_mode = DELETE")
            connection.execute("PRAGMA synchronous = NORMAL")
            initialize_cache_schema(connection)

            with immediate_transaction(connection):
                now_ns = time.time_ns()
                connection.executemany(
                    """
                    INSERT INTO files(
                        path, entry_json, seen_generation, updated_ns,
                        size, mtime_ns, sha256, width, height, animated, kind,
                        source_suffix, render_signature, static_encoder,
                        jxl_encoder, jxl_support_version, jxl_decoder,
                        original_asset, asset, jpeg_draft,
                        exif_version, exif_json
                    )
                    VALUES(
                        ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
                    )
                    ON CONFLICT(path) DO UPDATE SET
                        entry_json = excluded.entry_json,
                        updated_ns = excluded.updated_ns,
                        size = excluded.size,
                        mtime_ns = excluded.mtime_ns,
                        sha256 = excluded.sha256,
                        width = excluded.width,
                        height = excluded.height,
                        animated = excluded.animated,
                        kind = excluded.kind,
                        source_suffix = excluded.source_suffix,
                        render_signature = excluded.render_signature,
                        static_encoder = excluded.static_encoder,
                        jxl_encoder = excluded.jxl_encoder,
                        jxl_support_version = excluded.jxl_support_version,
                        jxl_decoder = excluded.jxl_decoder,
                        original_asset = excluded.original_asset,
                        asset = excluded.asset,
                        jpeg_draft = excluded.jpeg_draft,
                        exif_version = excluded.exif_version,
                        exif_json = excluded.exif_json
                    """,
                    (
                        (
                            str(source_path),
                            _cache_entry_json(entry),
                            now_ns,
                            *_normalized_entry_values(entry),
                        )
                        for source_path, entry in files.items()
                        if isinstance(source_path, str) and isinstance(entry, dict)
                    ),
                )

                _set_metadata(connection, "legacy_cache_version", legacy.get("version"))
                _set_metadata(connection, "legacy_inputs", legacy.get("inputs", []))
                _set_metadata(
                    connection,
                    "legacy_render_signature",
                    legacy.get("render_signature"),
                )
                _set_metadata(
                    connection,
                    "legacy_render_settings",
                    legacy.get("render_settings", {}),
                )
                _set_metadata(
                    connection,
                    "legacy_run_complete",
                    legacy.get("run_complete"),
                )
                _set_metadata(
                    connection,
                    "legacy_progress",
                    legacy.get("progress", {}),
                )

            imported = int(
                connection.execute("SELECT COUNT(*) FROM files").fetchone()[0]
            )
        finally:
            connection.close()

        os.replace(tmp_path, sqlite_path)
        set_output_file_mode(sqlite_path)

        backup_path = _legacy_migrated_path(legacy_path)
        try:
            os.replace(legacy_path, backup_path)
            set_output_file_mode(backup_path)
            print(
                f"CACHE: migrated {imported} legacy JSON entr"
                f"{'y' if imported == 1 else 'ies'} to SQLite; "
                f"backup: {backup_path}"
            )
        except OSError as exc:
            eprint(
                f"warning: SQLite migration succeeded, but the legacy JSON "
                f"cache could not be renamed: {exc}"
            )
            print(f"CACHE: migrated {imported} legacy JSON entries to SQLite")

        return imported
    except (
        OSError,
        sqlite3.Error,
        TypeError,
        ValueError,
        KeyError,
        json.JSONDecodeError,
    ) as exc:
        eprint(f"warning: legacy JSON cache migration failed: {exc}")
        try:
            tmp_path.unlink()
        except FileNotFoundError:
            pass
        return 0
    finally:
        try:
            tmp_path.unlink()
        except FileNotFoundError:
            pass


CACHE_ENTRY_COLUMNS = (
    "path",
    "entry_json",
    "size",
    "mtime_ns",
    "sha256",
    "width",
    "height",
    "animated",
    "kind",
    "source_suffix",
    "render_signature",
    "static_encoder",
    "jxl_encoder",
    "jxl_support_version",
    "jxl_decoder",
    "original_asset",
    "asset",
    "jpeg_draft",
    "exif_version",
    "exif_json",
)


def cache_entry_select(alias: str | None = None) -> str:
    """Return the canonical ordered SELECT list for one cached entry."""
    prefix = f"{alias}." if alias else ""
    return ", ".join(f"{prefix}{column}" for column in CACHE_ENTRY_COLUMNS)


def cache_entry_from_row(row: Sequence[Any]) -> MediaEntry | None:
    """Decode one canonical cache row, including legacy JSON-only rows."""
    (
        source_path, entry_json, size, mtime_ns, digest, width,
        height, animated, kind, source_suffix, render_sig,
        static_encoder, jxl_encoder, jxl_support_version,
        jxl_decoder, original_asset, asset, jpeg_draft,
        exif_version, exif_json,
    ) = row
    if all(
        value is not None
        for value in (
            size, mtime_ns, digest, width, height, animated, kind,
            source_suffix,
        )
    ):
        try:
            extras = json.loads(entry_json or "{}")
        except (TypeError, json.JSONDecodeError):
            extras = {}
        entry: MediaEntry = (
            dict(extras) if isinstance(extras, dict) else {}
        )
        entry.update({
            "source_path": str(source_path),
            "source_suffix": str(source_suffix),
            "size": int(size),
            "mtime_ns": int(mtime_ns),
            "sha256": str(digest),
            "width": int(width),
            "height": int(height),
            "animated": bool(animated),
            "kind": str(kind),
        })
        for key, value in {
            "render_signature": render_sig,
            "static_encoder": static_encoder,
            "jxl_encoder": jxl_encoder,
            "jxl_support_version": jxl_support_version,
            "jxl_decoder": jxl_decoder,
            "original_asset": original_asset,
            "asset": asset,
        }.items():
            if value is not None:
                entry[key] = value
        if jpeg_draft is not None:
            entry["jpeg_draft"] = int(jpeg_draft)
        if exif_version is not None:
            try:
                exif = json.loads(exif_json or "[]")
            except (TypeError, json.JSONDecodeError):
                exif = None
            if isinstance(exif, list):
                entry["exif_version"] = int(exif_version)
                entry["exif"] = exif
        return entry
    try:
        value = json.loads(entry_json)
    except (TypeError, json.JSONDecodeError):
        return None
    return value if isinstance(value, dict) else None


class SQLiteStoreComponent:
    """Shared state view for one focused part of the SQLite cache facade."""

    def __init__(self, cache: "SQLiteCache") -> None:
        self._cache = cache

    @property
    def path(self) -> Path:
        return self._cache.path

    @property
    def connection(self) -> sqlite3.Connection:
        return self._cache.connection

    @property
    def _pending_generated_signatures(self) -> dict[str, tuple[str, str]]:
        return self._cache._pending_generated_signatures

    @property
    def _pending_data_chunk_states(
        self,
    ) -> dict[tuple[str, str], tuple[str, str, str, str]]:
        return self._cache._pending_data_chunk_states

    @property
    def _normalize_on_close(self) -> bool:
        return self._cache._normalize_on_close


class SQLiteMetadataStore(SQLiteStoreComponent):
    """Typed access to cache metadata and schema diagnostics."""

    def get(self, key: str, default: Any = None) -> Any:
        return _get_metadata(self.connection, key, default)

    def set(self, key: str, value: Any) -> None:
        _set_metadata(self.connection, key, value)

    def schema_version(self) -> int:
        return int(self.connection.execute("PRAGMA user_version").fetchone()[0])

    def quick_check(self) -> list[str]:
        return [
            str(row[0])
            for row in self.connection.execute("PRAGMA quick_check")
        ]

    def table_columns(self, table: str) -> set[str]:
        if table not in {"files", "generated_files", "metadata"}:
            raise CacheError(f"unsupported cache table inspection: {table}")
        return {
            str(row[1])
            for row in self.connection.execute(f"PRAGMA table_info({table})")
        }


class SQLiteScanCatalogStore(SQLiteStoreComponent):
    """Source-scan and streamed-catalog operations over one cache connection."""

    def prepare_transient_build_tables(self) -> None:
        """Create connection-local tables used to stream large builds."""
        self.connection.executescript(
            """
            DROP TABLE IF EXISTS temp.scan_paths;
            CREATE TEMP TABLE scan_paths (
                ordinal INTEGER PRIMARY KEY,
                path TEXT NOT NULL UNIQUE,
                successful INTEGER NOT NULL DEFAULT 0
            );
            DROP TABLE IF EXISTS temp.catalog_records;
            CREATE TEMP TABLE catalog_records (
                catalog_id INTEGER PRIMARY KEY,
                path TEXT NOT NULL UNIQUE,
                stable_id TEXT NOT NULL,
                link_prefix TEXT NOT NULL UNIQUE,
                id_shard TEXT NOT NULL,
                display_name TEXT NOT NULL,
                source_root TEXT,
                root_relative TEXT,
                catalog_item_json TEXT NOT NULL,
                geometry_ratio INTEGER NOT NULL
            );
            CREATE INDEX catalog_records_id_shard
                ON catalog_records(id_shard, stable_id);
            DROP TABLE IF EXISTS temp.current_generated;
            CREATE TEMP TABLE current_generated (
                path TEXT PRIMARY KEY,
                kind TEXT NOT NULL
            ) WITHOUT ROWID;
            """
        )

    def add_scan_paths(self, paths: Iterable[Path]) -> int:
        """Append unseen scanned paths in deterministic order."""
        next_ordinal = int(
            self.connection.execute(
                "SELECT COALESCE(MAX(ordinal) + 1, 0) FROM scan_paths"
            ).fetchone()[0]
        )
        added = 0
        for path in paths:
            cursor = self.connection.execute(
                "INSERT OR IGNORE INTO scan_paths(ordinal, path) VALUES(?, ?)",
                (next_ordinal, str(path)),
            )
            if cursor.rowcount:
                next_ordinal += 1
                added += 1
        return added

    def scan_count(self) -> int:
        return int(
            self.connection.execute("SELECT COUNT(*) FROM scan_paths").fetchone()[0]
        )

    def iter_scan_entries(
        self,
    ) -> Iterable[tuple[int, Path, MediaEntry | None]]:
        rows = self.connection.execute(
            f"""
            SELECT
                s.ordinal, s.path,
                {cache_entry_select("f")}
            FROM scan_paths AS s
            LEFT JOIN files AS f ON f.path = s.path
            ORDER BY s.ordinal
            """
        )
        for row in rows:
            ordinal = int(row[0])
            scan_path = Path(str(row[1]))
            entry = None
            if row[2] is not None:
                entry = cache_entry_from_row(row[2:])
            yield ordinal, scan_path, entry

    def mark_scan_success(self, ordinal: int) -> None:
        self.connection.execute(
            "UPDATE scan_paths SET successful = 1 WHERE ordinal = ?",
            (int(ordinal),),
        )

    def iter_success_entries(
        self,
    ) -> Iterable[tuple[int, Path, MediaEntry]]:
        rows = self.connection.execute(
            f"""
            SELECT
                s.ordinal, s.path,
                {cache_entry_select("f")}
            FROM scan_paths AS s
            JOIN files AS f ON f.path = s.path
            WHERE s.successful = 1
            ORDER BY s.ordinal
            """
        )
        for row in rows:
            entry = cache_entry_from_row(row[2:])
            if entry is None:
                continue
            yield int(row[0]), Path(str(row[1])), entry

    def finalize_run_from_scan(self, total: int) -> int:
        """Prune stale cache rows directly against the streamed scan table."""
        stale = int(
            self.connection.execute(
                """
                SELECT COUNT(*) FROM files AS f
                WHERE NOT EXISTS (
                    SELECT 1 FROM scan_paths AS s
                    WHERE s.path = f.path AND s.successful = 1
                )
                """
            ).fetchone()[0]
        )
        with immediate_transaction(self.connection):
            self.connection.execute(
                """
                DELETE FROM files
                WHERE NOT EXISTS (
                    SELECT 1 FROM scan_paths AS s
                    WHERE s.path = files.path AND s.successful = 1
                )
                """
            )
            self.connection.execute(
                """
                DELETE FROM file_identity
                WHERE NOT EXISTS (
                    SELECT 1 FROM files AS f
                    WHERE f.path = file_identity.path
                )
                """
            )
            _set_metadata(self.connection, "run_complete", True)
            _set_metadata(
                self.connection,
                "progress",
                {"completed": total, "total": total},
            )
        try:
            self.connection.execute("PRAGMA wal_checkpoint(TRUNCATE)")
        except sqlite3.Error:
            pass
        normalize_cache_permissions(self.path)
        return stale

    def reset_catalog_records(self) -> None:
        self.connection.execute("DELETE FROM catalog_records")

    @contextmanager
    def catalog_assembly_transaction(self) -> Iterable[None]:
        """Make transient catalog and generated-path staging one transaction."""
        with immediate_transaction(self.connection):
            yield

    def add_catalog_records(
        self,
        records: Sequence[CatalogInsertRecord],
    ) -> None:
        """Insert one bounded catalog batch inside the assembly transaction."""
        self.connection.executemany(
            """
            INSERT INTO catalog_records(
                catalog_id, path, stable_id, link_prefix, id_shard,
                display_name, source_root, root_relative, catalog_item_json,
                geometry_ratio
            ) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            """,
            (
                (
                    record.catalog_id,
                    record.path,
                    record.stable_id,
                    record.link_prefix,
                    record.stable_id[:2],
                    record.display_name,
                    record.source_root,
                    record.root_relative,
                    record.catalog_item_json,
                    record.geometry_ratio,
                )
                for record in records
            ),
        )

    def catalog_count(self) -> int:
        return int(
            self.connection.execute(
                "SELECT COUNT(*) FROM catalog_records"
            ).fetchone()[0]
        )

    def iter_catalog_records(
        self,
        start: int = 0,
        limit: int | None = None,
    ) -> Iterable[CatalogRecord]:
        sql = f"""
            SELECT
                c.catalog_id, c.path, c.stable_id, c.display_name,
                c.source_root, c.root_relative,
                {cache_entry_select("f")}
            FROM catalog_records AS c
            JOIN files AS f ON f.path = c.path
            WHERE c.catalog_id >= ?
            ORDER BY c.catalog_id
        """
        params: list[Any] = [int(start)]
        if limit is not None:
            sql += " LIMIT ?"
            params.append(int(limit))
        for row in self.connection.execute(sql, params):
            entry = cache_entry_from_row(row[6:])
            if entry is None:
                raise RuntimeError(
                    f"cache entry missing/corrupt for catalog path {row[1]}"
                )
            yield {
                "catalog_id": int(row[0]),
                "path": Path(str(row[1])),
                "stable_id": str(row[2]),
                "display_name": str(row[3]),
                "source_root": row[4],
                "root_relative": row[5],
                "entry": entry,
            }

    def catalog_bootstrap_for_ranges(
        self,
        ranges: Sequence[tuple[int, int]],
        limit: int,
    ) -> list[tuple[int, list[Any], int]]:
        """Return prepacked first-viewport values for page ranges."""
        remaining = max(0, int(limit))
        values: list[tuple[int, list[Any], int]] = []
        for start, end in ranges:
            count = min(remaining, max(0, int(end) - int(start)))
            if count <= 0:
                continue
            rows = self.connection.execute(
                """
                SELECT catalog_id, catalog_item_json, geometry_ratio
                FROM catalog_records
                WHERE catalog_id >= ? AND catalog_id < ?
                ORDER BY catalog_id
                LIMIT ?
                """,
                (int(start), int(end), count),
            )
            for catalog_id, item_json, ratio in rows:
                item = json.loads(str(item_json))
                if not isinstance(item, list):
                    raise RuntimeError(
                        f"invalid bootstrap catalog record {catalog_id}"
                    )
                values.append((int(catalog_id), item, int(ratio)))
            remaining = max(0, int(limit) - len(values))
            if remaining <= 0:
                break
        return values

    def id_shards(self) -> list[str]:
        return [
            str(row[0])
            for row in self.connection.execute(
                "SELECT DISTINCT id_shard FROM catalog_records "
                "ORDER BY id_shard"
            )
        ]

    def stable_id_rows(self, shard: str) -> list[list[str | int]]:
        return [
            [str(stable_id), int(catalog_id)]
            for stable_id, catalog_id in self.connection.execute(
                """
                SELECT stable_id, catalog_id
                FROM catalog_records
                WHERE id_shard = ?
                ORDER BY stable_id
                """,
                (shard,),
            )
        ]

class SQLiteChunkStateStore(SQLiteStoreComponent):
    """Content-addressed chunk signatures and publish-generation retention."""

    def generated_signature_matches(self, path: str, signature: str) -> bool:
        pending = self._pending_generated_signatures.get(path)
        if pending is not None:
            return pending[1] == signature
        row = self.connection.execute(
            "SELECT input_signature FROM generated_files WHERE path = ?",
            (path,),
        ).fetchone()
        return bool(row and row[0] == signature)

    def reusable_data_chunk(
        self,
        output_dir: Path,
        kind: str,
        chunk_key: str | int,
        dependency_signature: str,
        trust_generated: bool,
    ) -> DataChunkMetadata | None:
        """Return cached chunk metadata without rereading canonical records."""
        if not trust_generated:
            return None
        row = self.connection.execute(
            """
            SELECT output_path, output_signature, metadata_json
            FROM data_chunk_state
            WHERE kind = ? AND chunk_key = ? AND dependency_signature = ?
            """,
            (str(kind), str(chunk_key), str(dependency_signature)),
        ).fetchone()
        if row is None:
            return None
        output_path, output_signature, metadata_json = map(str, row)
        path = output_dir / output_path
        try:
            if not path.is_file() or path.stat().st_size <= 0:
                return None
            metadata = json.loads(metadata_json)
        except (OSError, TypeError, json.JSONDecodeError):
            return None
        if not isinstance(metadata, dict):
            return None
        metadata = dict(metadata)
        metadata["path"] = output_path
        metadata["output_signature"] = output_signature
        return metadata  # type: ignore[return-value]

    def record_data_chunk_state(
        self,
        kind: str,
        chunk_key: str | int,
        dependency_signature: str,
        output_path: str,
        output_signature: str,
        metadata: dict[str, Any],
    ) -> None:
        self._pending_data_chunk_states[(str(kind), str(chunk_key))] = (
            str(dependency_signature),
            str(output_path),
            str(output_signature),
            _json_dumps(metadata),
        )

    def record_generated_signature(
        self,
        path: str,
        kind: str,
        signature: str,
    ) -> None:
        self._pending_generated_signatures[path] = (kind, signature)

    def flush_generated_signatures(self, generation: int) -> None:
        if not self._pending_generated_signatures and not self._pending_data_chunk_states:
            return
        with immediate_transaction(self.connection):
            if self._pending_generated_signatures:
                self.connection.executemany(
                    """
                    INSERT INTO generated_files(
                        path, kind, seen_generation, input_signature
                    )
                    VALUES(?, ?, ?, ?)
                    ON CONFLICT(path) DO UPDATE SET
                        kind = excluded.kind,
                        seen_generation = excluded.seen_generation,
                        input_signature = excluded.input_signature
                    """,
                    (
                        (path, kind, generation, signature)
                        for path, (kind, signature)
                        in self._pending_generated_signatures.items()
                    ),
                )
            if self._pending_data_chunk_states:
                self.connection.executemany(
                    """
                    INSERT INTO data_chunk_state(
                        kind, chunk_key, dependency_signature, output_path,
                        output_signature, metadata_json
                    ) VALUES(?, ?, ?, ?, ?, ?)
                    ON CONFLICT(kind, chunk_key) DO UPDATE SET
                        dependency_signature = excluded.dependency_signature,
                        output_path = excluded.output_path,
                        output_signature = excluded.output_signature,
                        metadata_json = excluded.metadata_json
                    """,
                    (
                        (kind, chunk_key, *state)
                        for (kind, chunk_key), state
                        in self._pending_data_chunk_states.items()
                    ),
                )
        self._pending_generated_signatures.clear()
        self._pending_data_chunk_states.clear()

    def retain_data_generations(
        self,
        generation: int,
        keep: int = RETAINED_DATA_GENERATIONS,
    ) -> tuple[int, int]:
        """Retain current/prior builds while reusing identical path snapshots."""
        keep = max(1, int(keep))
        with immediate_transaction(self.connection):
            for kind in ("asset", "data"):
                signature, path_count = self._current_path_snapshot(kind)
                row = self.connection.execute(
                    """
                    SELECT snapshot_generation, path_count
                    FROM data_generation_snapshots
                    WHERE kind = ? AND signature = ?
                    ORDER BY build_generation DESC
                    LIMIT 1
                    """,
                    (kind, signature),
                ).fetchone()
                snapshot_generation = int(generation)
                if row is not None and int(row[1]) == path_count:
                    snapshot_generation = int(row[0])
                else:
                    self.connection.execute(
                        """
                        INSERT OR IGNORE INTO data_generations(
                            build_generation, path, kind
                        )
                        SELECT ?, path, kind FROM current_generated
                        WHERE kind = ?
                        """,
                        (int(generation), kind),
                    )
                self.connection.execute(
                    """
                    INSERT OR REPLACE INTO data_generation_snapshots(
                        build_generation, kind, snapshot_generation,
                        signature, path_count
                    ) VALUES(?, ?, ?, ?, ?)
                    """,
                    (
                        int(generation), kind, snapshot_generation,
                        signature, path_count,
                    ),
                )

            retained_generations = self._retained_snapshot_generations(keep)
            placeholders = ",".join("?" for _ in retained_generations)
            self.connection.execute(
                f"DELETE FROM data_generation_snapshots "
                f"WHERE build_generation NOT IN ({placeholders})",
                retained_generations,
            )
            self.connection.execute(
                """
                DELETE FROM data_generations AS retained
                WHERE NOT EXISTS (
                    SELECT 1 FROM data_generation_snapshots AS snapshot
                    WHERE snapshot.snapshot_generation = retained.build_generation
                      AND snapshot.kind = retained.kind
                )
                """
            )
            self.connection.execute(
                """
                INSERT INTO current_generated(path, kind)
                SELECT retained.path, MAX(retained.kind)
                FROM data_generation_snapshots AS snapshot
                JOIN data_generations AS retained
                  ON retained.build_generation = snapshot.snapshot_generation
                 AND retained.kind = snapshot.kind
                GROUP BY retained.path
                ON CONFLICT(path) DO NOTHING
                """
            )
            retained_paths = int(
                self.connection.execute(
                    "SELECT COUNT(DISTINCT path) FROM data_generations"
                ).fetchone()[0]
            )
        return len(retained_generations), retained_paths

    def _current_path_snapshot(self, kind: str) -> tuple[str, int]:
        hasher = hashlib.sha256(f"publish-paths-v1\0{kind}\0".encode("ascii"))
        count = 0
        for (path,) in self.connection.execute(
            "SELECT path FROM current_generated WHERE kind = ? ORDER BY path",
            (kind,),
        ):
            _signature_field(hasher, str(path))
            count += 1
        _signature_field(hasher, count)
        return hasher.hexdigest(), count

    def _retained_snapshot_generations(self, keep: int) -> list[int]:
        generations = [
            int(row[0])
            for row in self.connection.execute(
                """
                SELECT build_generation
                FROM data_generation_snapshots
                GROUP BY build_generation
                HAVING COUNT(DISTINCT kind) = 2
                ORDER BY build_generation DESC
                LIMIT ?
                """,
                (max(1, int(keep)),),
            )
        ]
        if not generations:
            raise CacheError("publish generation has no complete path snapshot")
        return generations

class SQLiteEntryRunStore(SQLiteStoreComponent):
    """Media entries and successful build-generation lifecycle operations."""

    def close(self) -> None:
        try:
            self.connection.execute("PRAGMA wal_checkpoint(TRUNCATE)")
        except sqlite3.Error:
            pass
        self.connection.close()
        if self._normalize_on_close:
            normalize_cache_permissions(self.path)

    def begin_run(
        self,
        base_payload: dict[str, Any],
        total: int,
    ) -> int:
        previous_generation = int(
            _get_metadata(self.connection, "generation", 0) or 0
        )
        generation = previous_generation + 1

        with immediate_transaction(self.connection):
            _set_metadata(self.connection, "generation", generation)
            _set_metadata(self.connection, "run_complete", False)
            _set_metadata(
                self.connection,
                "progress",
                {"completed": 0, "total": total},
            )
            for key, value in base_payload.items():
                _set_metadata(self.connection, key, value)

        normalize_cache_permissions(self.path)
        return generation

class SQLiteGeneratedInventoryStore(SQLiteStoreComponent):
    """Managed-output inventory, reconciliation, and stale-path operations."""

    def bootstrap_generated_inventory(self, output_dir: Path) -> int:
        """Import an existing generated tree once, then track it in SQLite.

        Older script versions had no generated-file inventory, so the first
        run after this upgrade performs one legacy tree scan. Subsequent runs
        clean stale output directly from this table without walking the full
        asset/data/page trees.
        """
        if bool(_get_metadata(self.connection, "generated_inventory_initialized", False)):
            return 0

        imported = 0
        with immediate_transaction(self.connection):
            def import_tree(relative_root: str, kind: str) -> None:
                nonlocal imported
                root = output_dir / relative_root
                if not root.is_dir():
                    return
                batch: list[tuple[str, str, int]] = []
                for path in root.rglob("*"):
                    if not path.is_file() or path.is_symlink():
                        continue
                    batch.append(
                        (path.relative_to(output_dir).as_posix(), kind, 0)
                    )
                    if len(batch) >= 1000:
                        self.connection.executemany(
                            "INSERT INTO generated_files(path, kind, seen_generation) "
                            "VALUES(?, ?, ?) ON CONFLICT(path) DO NOTHING",
                            batch,
                        )
                        imported += len(batch)
                        batch.clear()
                if batch:
                    self.connection.executemany(
                        "INSERT INTO generated_files(path, kind, seen_generation) "
                        "VALUES(?, ?, ?) ON CONFLICT(path) DO NOTHING",
                        batch,
                    )
                    imported += len(batch)

            import_tree(ASSET_DIRNAME, "asset")
            import_tree(GALLERY_DATA_DIR, "data")
            if LEGACY_ASSET_DIRNAME != ASSET_DIRNAME:
                import_tree(LEGACY_ASSET_DIRNAME, "asset")
            if LEGACY_GALLERY_DATA_DIR != GALLERY_DATA_DIR:
                import_tree(LEGACY_GALLERY_DATA_DIR, "data")

            page_batch: list[tuple[str, str, int]] = []
            for current, dirnames, filenames in os.walk(output_dir, topdown=True):
                if Path(current) == output_dir:
                    dirnames[:] = [
                        name
                        for name in dirnames
                        if name
                        not in {
                            ASSET_DIRNAME,
                            GALLERY_DATA_DIR,
                            LEGACY_ASSET_DIRNAME,
                            LEGACY_GALLERY_DATA_DIR,
                        }
                    ]
                if "index.html" not in filenames:
                    continue
                path = Path(current) / "index.html"
                if path.is_symlink():
                    continue
                page_batch.append(
                    (path.relative_to(output_dir).as_posix(), "page", 0)
                )
                if len(page_batch) >= 1000:
                    self.connection.executemany(
                        "INSERT INTO generated_files(path, kind, seen_generation) "
                        "VALUES(?, ?, ?) ON CONFLICT(path) DO NOTHING",
                        page_batch,
                    )
                    imported += len(page_batch)
                    page_batch.clear()
            if page_batch:
                self.connection.executemany(
                    "INSERT INTO generated_files(path, kind, seen_generation) "
                    "VALUES(?, ?, ?) ON CONFLICT(path) DO NOTHING",
                    page_batch,
                )
                imported += len(page_batch)

            legacy_builder = output_dir / LEGACY_BUILDER_SCRIPT_COPY
            if (
                LEGACY_BUILDER_SCRIPT_COPY != BUILDER_SCRIPT_COPY
                and legacy_builder.is_file()
                and not legacy_builder.is_symlink()
            ):
                self.connection.execute(
                    "INSERT INTO generated_files(path, kind, seen_generation) "
                    "VALUES(?, ?, ?) ON CONFLICT(path) DO NOTHING",
                    (LEGACY_BUILDER_SCRIPT_COPY, "asset", 0),
                )
                imported += 1

            manifest = output_dir / APP_MANIFEST
            if manifest.is_file() and not manifest.is_symlink():
                self.connection.execute(
                    "INSERT INTO generated_files(path, kind, seen_generation) "
                    "VALUES(?, ?, 0) ON CONFLICT(path) DO NOTHING",
                    (APP_MANIFEST, "page"),
                )
                imported += 1

            _set_metadata(
                self.connection,
                "generated_inventory_initialized",
                True,
            )

        return imported

    def generated_inventory_state(self) -> tuple[str | None, bool]:
        revision = _get_metadata(
            self.connection,
            "generated_inventory_revision",
            None,
        )
        has_stale = bool(
            _get_metadata(
                self.connection,
                "generated_inventory_has_stale",
                False,
            )
        )
        return (str(revision) if revision else None, has_stale)

    def set_generated_inventory_state(
        self,
        revision: str,
        has_stale: bool,
    ) -> None:
        with immediate_transaction(self.connection):
            _set_metadata(
                self.connection,
                "generated_inventory_revision",
                revision,
            )
            _set_metadata(
                self.connection,
                "generated_inventory_has_stale",
                bool(has_stale),
            )

    def sync_current_generated_inventory(
        self,
        generation: int,
    ) -> list[tuple[str, str]]:
        """Sync streamed TEMP generated paths into the persistent inventory."""
        with immediate_transaction(self.connection):
            cursor = self.connection.execute(
                "SELECT path, kind FROM current_generated ORDER BY path"
            )
            batch: list[tuple[str, str, int]] = []
            for path, kind in cursor:
                batch.append((str(path), str(kind), int(generation)))
                if len(batch) >= 2000:
                    self.connection.executemany(
                        """
                        INSERT INTO generated_files(path, kind, seen_generation)
                        VALUES(?, ?, ?)
                        ON CONFLICT(path) DO UPDATE SET
                            kind = excluded.kind,
                            seen_generation = excluded.seen_generation
                        """,
                        batch,
                    )
                    batch.clear()
            if batch:
                self.connection.executemany(
                    """
                    INSERT INTO generated_files(path, kind, seen_generation)
                    VALUES(?, ?, ?)
                    ON CONFLICT(path) DO UPDATE SET
                        kind = excluded.kind,
                        seen_generation = excluded.seen_generation
                    """,
                    batch,
                )

            stale = [
                (str(path), str(kind))
                for path, kind in self.connection.execute(
                    "SELECT path, kind FROM generated_files "
                    "WHERE seen_generation <> ?",
                    (generation,),
                )
            ]
            self.connection.execute(
                """
                DELETE FROM artifact_validations AS validation
                WHERE NOT EXISTS (
                    SELECT 1 FROM current_generated AS current
                    WHERE current.path = validation.path
                )
                """
            )
        return stale

    def forget_generated_paths(self, paths: Iterable[str]) -> None:
        values = [(path,) for path in paths]
        if not values:
            return
        self.connection.executemany(
            "DELETE FROM generated_files WHERE path = ?",
            values,
        )
        self.connection.executemany(
            "DELETE FROM artifact_validations WHERE path = ?",
            values,
        )

    def add_current_path(self, path: str, kind: str) -> None:
        self.connection.execute(
            """
            INSERT INTO current_generated(path, kind)
            VALUES(?, ?)
            ON CONFLICT(path) DO UPDATE SET kind = excluded.kind
            """,
            (str(path), str(kind)),
        )

    def add_current_paths(self, paths: Iterable[str], kind: str) -> None:
        self.connection.executemany(
            """
            INSERT INTO current_generated(path, kind)
            VALUES(?, ?)
            ON CONFLICT(path) DO UPDATE SET kind = excluded.kind
            """,
            ((str(path), str(kind)) for path in paths),
        )

    def iter_current_paths(self, kind: str) -> Iterable[str]:
        for (path,) in self.connection.execute(
            "SELECT path FROM current_generated WHERE kind = ? ORDER BY path",
            (str(kind),),
        ):
            yield str(path)

    def iter_tracked_paths(self, kind: str) -> Iterable[str]:
        for (path,) in self.connection.execute(
            "SELECT path FROM generated_files WHERE kind = ? ORDER BY path",
            (str(kind),),
        ):
            yield str(path)

    def current_path_count(self, kind: str) -> int:
        return int(
            self.connection.execute(
                "SELECT COUNT(*) FROM current_generated WHERE kind = ?",
                (str(kind),),
            ).fetchone()[0]
        )

    def row_count(self) -> int:
        return int(
            self.connection.execute("SELECT COUNT(*) FROM files").fetchone()[0]
        )

    def optimize(self) -> None:
        """Refresh SQLite planner statistics after a completed large build."""
        self.connection.execute("PRAGMA optimize")


class SQLiteArtifactValidationStore(SQLiteStoreComponent):
    """Persistent identities for previously verified immutable data files."""

    def snapshot(self) -> dict[str, ArtifactValidationRecord]:
        return {
            str(row[0]): ArtifactValidationRecord(
                path=str(row[0]),
                revision=str(row[1]),
                size=int(row[2]),
                mtime_ns=int(row[3]),
                ctime_ns=int(row[4]),
                device=int(row[5]),
                inode=int(row[6]),
                validated_ns=int(row[7]),
            )
            for row in self.connection.execute(
                """
                SELECT path, revision, size, mtime_ns, ctime_ns,
                       device, inode, validated_ns
                FROM artifact_validations
                """
            )
        }

    def record(self, records: Sequence[ArtifactValidationRecord]) -> None:
        if not records:
            return
        with immediate_transaction(self.connection):
            self.connection.executemany(
                """
                INSERT INTO artifact_validations(
                    path, revision, size, mtime_ns, ctime_ns,
                    device, inode, validated_ns
                ) VALUES(?, ?, ?, ?, ?, ?, ?, ?)
                ON CONFLICT(path) DO UPDATE SET
                    revision = excluded.revision,
                    size = excluded.size,
                    mtime_ns = excluded.mtime_ns,
                    ctime_ns = excluded.ctime_ns,
                    device = excluded.device,
                    inode = excluded.inode,
                    validated_ns = excluded.validated_ns
                """,
                (
                    (
                        record.path, record.revision, record.size,
                        record.mtime_ns, record.ctime_ns, record.device,
                        record.inode, record.validated_ns,
                    )
                    for record in records
                ),
            )

    def count(self) -> int:
        return int(
            self.connection.execute(
                "SELECT COUNT(*) FROM artifact_validations"
            ).fetchone()[0]
        )


@dataclass(frozen=True, slots=True)
class DoctorInventoryCounts:
    tracked_files: int
    missing_files: int
    empty_files: int
    untracked_files: int
    unsafe_tracked_paths: int


class SQLiteDoctorStore(SQLiteStoreComponent):
    """Database operations used only by Gallery Doctor."""

    def reset_seen(self) -> None:
        self.connection.executescript(
            """
            DROP TABLE IF EXISTS temp.doctor_seen;
            CREATE TEMP TABLE doctor_seen (
                path TEXT PRIMARY KEY,
                kind TEXT NOT NULL,
                size INTEGER NOT NULL,
                mode INTEGER NOT NULL
            ) WITHOUT ROWID;
            """
        )

    def record_seen(self, rows: Sequence[tuple[str, str, int, int]]) -> None:
        self.connection.executemany(
            "INSERT OR REPLACE INTO doctor_seen VALUES(?, ?, ?, ?)",
            rows,
        )

    def inventory_counts(self) -> DoctorInventoryCounts:
        tracked = int(
            self.connection.execute(
                "SELECT COUNT(*) FROM generated_files"
            ).fetchone()[0]
        )
        missing = int(self.connection.execute(
            """
            SELECT COUNT(*) FROM generated_files AS g
            LEFT JOIN doctor_seen AS s ON s.path = g.path AND s.kind = 'file'
            WHERE s.path IS NULL
            """
        ).fetchone()[0])
        empty = int(self.connection.execute(
            """
            SELECT COUNT(*) FROM generated_files AS g
            JOIN doctor_seen AS s ON s.path = g.path AND s.kind = 'file'
            WHERE s.size <= 0
            """
        ).fetchone()[0])
        untracked = int(self.connection.execute(
            """
            SELECT COUNT(*) FROM doctor_seen AS s
            LEFT JOIN generated_files AS g ON g.path = s.path
            WHERE s.kind = 'file' AND g.path IS NULL
            """
        ).fetchone()[0])
        unsafe = sum(
            1
            for (path_text,) in self.connection.execute(
                "SELECT path FROM generated_files"
            )
            if Path(str(path_text)).is_absolute()
            or ".." in Path(str(path_text)).parts
        )
        return DoctorInventoryCounts(
            tracked,
            missing,
            empty,
            untracked,
            unsafe,
        )

    def sample_asset_paths(self, limit: int) -> list[str]:
        return [
            str(row[0])
            for row in self.connection.execute(
                """
                SELECT g.path FROM generated_files AS g
                JOIN doctor_seen AS s
                  ON s.path = g.path AND s.kind = 'file'
                WHERE g.kind = 'asset' AND s.size > 0
                ORDER BY substr(g.path, -16), g.path
                LIMIT ?
                """,
                (max(0, int(limit)),),
            )
        ]


class SQLiteCache:
    """Unified facade composed from focused SQLite responsibility stores."""

    def __init__(
        self,
        path: Path,
        *,
        connection: sqlite3.Connection | None = None,
        normalize_on_close: bool = True,
    ) -> None:
        self.path = path
        self.connection = connection or open_cache_connection(path)
        try:
            initialize_cache_schema(self.connection)
        except (sqlite3.Error, RuntimeError):
            self.connection.close()
            raise
        self._pending_generated_signatures: dict[str, tuple[str, str]] = {}
        self._pending_data_chunk_states: dict[
            tuple[str, str], tuple[str, str, str, str]
        ] = {}
        self._normalize_on_close = bool(normalize_on_close)
        self.scan = SQLiteScanCatalogStore(self)
        self.chunks = SQLiteChunkStateStore(self)
        self.entries = SQLiteEntryRunStore(self)
        self.inventory = SQLiteGeneratedInventoryStore(self)
        self.validations = SQLiteArtifactValidationStore(self)
        self.metadata = SQLiteMetadataStore(self)
        self.doctor = SQLiteDoctorStore(self)
        if normalize_on_close:
            normalize_cache_permissions(path)

    def close(self) -> None:
        """Close the shared connection through its lifecycle component."""
        self.entries.close()


def open_dry_run_cache(path: Path) -> SQLiteCache:
    """Open a disposable cache clone so planning never mutates the real DB."""
    if _RUNTIME.run_temp_dir is None:
        raise RuntimeError("dry-run cache requires an active temp workspace")
    clone_path = _RUNTIME.run_temp_dir / "dry-run-cache.sqlite3"
    connection = sqlite3.connect(clone_path, isolation_level=None)
    connection.execute("PRAGMA temp_store = MEMORY")
    if path.is_file():
        source = sqlite3.connect(
            f"{path.resolve().as_uri()}?mode=ro",
            uri=True,
            isolation_level=None,
        )
        try:
            source.backup(connection)
        finally:
            source.close()
    return SQLiteCache(
        path,
        connection=connection,
        normalize_on_close=False,
    )


class SQLitePathSet:
    """Set-like view backed by the TEMP current_generated table."""

    def __init__(self, cache: SQLiteCache, kind: str) -> None:
        self.cache = cache
        self.kind = kind

    def add(self, path: str) -> None:
        self.cache.inventory.add_current_path(path, self.kind)

    def update(self, paths: Iterable[str]) -> None:
        self.cache.inventory.add_current_paths(paths, self.kind)

    def __iter__(self):
        yield from self.cache.inventory.iter_current_paths(self.kind)

    def __len__(self) -> int:
        return self.cache.inventory.current_path_count(self.kind)

    def __or__(self, other):
        # Used only by the explicit --repair-permissions path.
        return set(self) | set(other)


def checkpoint_sqlite_cache(
    path: Path,
    generation: int,
    entries: dict[str, MediaEntry],
    completed: int,
    total: int,
) -> None:
    connection = open_cache_connection(path)
    try:
        initialize_cache_schema(connection)
        with immediate_transaction(connection):
            now_ns = time.time_ns()

            if entries:
                connection.executemany(
                    """
                    INSERT INTO files(
                        path, entry_json, seen_generation, updated_ns,
                        size, mtime_ns, sha256, width, height, animated, kind,
                        source_suffix, render_signature, static_encoder,
                        jxl_encoder, jxl_support_version, jxl_decoder,
                        original_asset, asset, jpeg_draft,
                        exif_version, exif_json
                    )
                    VALUES(
                        ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
                    )
                    ON CONFLICT(path) DO UPDATE SET
                        entry_json = excluded.entry_json,
                        seen_generation = excluded.seen_generation,
                        updated_ns = excluded.updated_ns,
                        size = excluded.size,
                        mtime_ns = excluded.mtime_ns,
                        sha256 = excluded.sha256,
                        width = excluded.width,
                        height = excluded.height,
                        animated = excluded.animated,
                        kind = excluded.kind,
                        source_suffix = excluded.source_suffix,
                        render_signature = excluded.render_signature,
                        static_encoder = excluded.static_encoder,
                        jxl_encoder = excluded.jxl_encoder,
                        jxl_support_version = excluded.jxl_support_version,
                        jxl_decoder = excluded.jxl_decoder,
                        original_asset = excluded.original_asset,
                        asset = excluded.asset,
                        jpeg_draft = excluded.jpeg_draft,
                        exif_version = excluded.exif_version,
                        exif_json = excluded.exif_json
                    """,
                    (
                        (
                            source_path,
                            _cache_entry_json(entry),
                            generation,
                            now_ns,
                            *_normalized_entry_values(entry),
                        )
                        for source_path, entry in entries.items()
                    ),
                )
                identity_rows = []
                for source_path, entry in entries.items():
                    identity = _source_identity_values(entry)
                    if identity is not None:
                        identity_rows.append(
                            (source_path, *identity, now_ns)
                        )
                if identity_rows:
                    connection.executemany(
                        """
                        INSERT INTO file_identity(
                            path, device, inode, size, mtime_ns, ctime_ns,
                            birthtime_ns, quick_hash, updated_ns
                        ) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)
                        ON CONFLICT(path) DO UPDATE SET
                            device = excluded.device,
                            inode = excluded.inode,
                            size = excluded.size,
                            mtime_ns = excluded.mtime_ns,
                            ctime_ns = excluded.ctime_ns,
                            birthtime_ns = excluded.birthtime_ns,
                            quick_hash = excluded.quick_hash,
                            updated_ns = excluded.updated_ns
                        """,
                        identity_rows,
                    )

            _set_metadata(connection, "run_complete", False)
            _set_metadata(
                connection,
                "progress",
                {"completed": completed, "total": total},
            )
    finally:
        connection.close()

    normalize_cache_permissions(path)


class ProgressReporter:
    """Low-overhead progress output with optional per-file detail."""

    def __init__(
        self,
        total: int,
        per_file: bool = False,
        cache_sync_remaining: Callable[[], float | None] | None = None,
    ) -> None:
        self.total = total
        self.per_file = per_file
        self.cache_sync_remaining = cache_sync_remaining
        self.last_emit = 0.0
        self.is_tty = sys.stdout.isatty()
        self.line_active = False

    def cache_sync_status(self) -> str:
        if self.cache_sync_remaining is None:
            return "sync disabled"

        remaining = self.cache_sync_remaining()
        if remaining is None:
            return "sync disabled"

        seconds = max(0, int(remaining + 0.999))
        if seconds == 0:
            return "sync now"
        return f"sync in {seconds}s"

    def update(
        self,
        completed: int,
        changed: int,
        reused: int,
        skipped: int,
        status: str,
        path_label: str,
    ) -> None:
        with DIAGNOSTICS.output_lock:
            self._update_unlocked(
                completed,
                changed,
                reused,
                skipped,
                status,
                path_label,
            )

    def _update_unlocked(
        self,
        completed: int,
        changed: int,
        reused: int,
        skipped: int,
        status: str,
        path_label: str,
    ) -> None:
        sync_status = self.cache_sync_status()

        if self.per_file:
            print(
                f"[{completed}/{self.total}] {status:7}  {path_label} | "
                f"{sync_status}"
            )
            return

        now = time.monotonic()
        if completed < self.total and now - self.last_emit < 1.0:
            return
        self.last_emit = now
        percent = (completed * 100.0 / self.total) if self.total else 100.0
        line = (
            f"[{completed}/{self.total}] {percent:5.1f}% | "
            f"updated {changed} | cached {reused} | skipped {skipped} | "
            f"{sync_status}"
        )
        if self.is_tty:
            print("\r" + line.ljust(112), end="", flush=True)
            self.line_active = True
        else:
            print(line)

    def break_line(self) -> None:
        """End an in-place TTY status line before a diagnostic is printed."""
        if self.line_active:
            print(file=sys.stdout, flush=True)
            self.line_active = False

    def finish(self) -> None:
        with DIAGNOSTICS.output_lock:
            self.break_line()
            DIAGNOSTICS.clear_progress(self)


class FinalizationReporter:
    """Expose normally hidden post-processing phases without noisy logs."""

    def __init__(self, phase_count: int) -> None:
        self.phase_count = max(1, int(phase_count))
        self.phase = 0
        self.label = ""
        self.item_total = 0
        self.started = 0.0
        self.last_emit = 0.0
        self.is_tty = sys.stdout.isatty()
        self.line_active = False

    def begin(self, label: str, item_total: int = 0) -> None:
        self.complete()
        self.phase += 1
        self.label = str(label)
        self.item_total = max(0, int(item_total))
        self.started = time.monotonic()
        self.last_emit = self.started
        self._emit(0, finished=False)

    def update(self, completed: int) -> None:
        if not self.label:
            return
        now = time.monotonic()
        interval = 1.0 if self.is_tty else 30.0
        if now - self.last_emit < interval:
            return
        self.last_emit = now
        self._emit(completed, finished=False)

    def complete(self) -> None:
        if not self.label:
            return
        self._emit(self.item_total, finished=True)
        self.label = ""
        self.item_total = 0
        self.line_active = False

    def abort(self) -> None:
        if self.is_tty and self.line_active:
            print(file=sys.stdout, flush=True)
        self.label = ""
        self.item_total = 0
        self.line_active = False

    def _emit(self, completed: int, *, finished: bool) -> None:
        elapsed = max(0.0, time.monotonic() - self.started)
        count = ""
        if self.item_total and (completed or finished):
            count = f" {min(max(0, completed), self.item_total)}/{self.item_total}"
        elif completed and not finished:
            count = f" {max(0, completed)}"
        suffix = f" — done in {elapsed:.1f}s" if finished else ""
        line = (
            f"Finalizing [{self.phase}/{self.phase_count}] "
            f"{self.label}{count}{suffix}"
        )
        if self.is_tty:
            print("\r" + line.ljust(112), end="\n" if finished else "", flush=True)
            self.line_active = not finished
        elif not finished or elapsed >= 1.0:
            print(line, flush=True)


class PeriodicCacheWriter:
    """Periodically batch completed worker results into SQLite."""

    def __init__(
        self,
        path: Path,
        interval: float,
        generation: int,
        total: int,
        verbose: bool = False,
    ) -> None:
        self.path = path
        self.interval = interval
        self.generation = generation
        self.total = total
        self.verbose = verbose
        self.completed = 0
        self.sync_count = 0

        self.pending_entries: dict[str, MediaEntry] = {}
        self.dirty = False

        self._lock = threading.RLock()
        self._stop = threading.Event()
        self._thread: threading.Thread | None = None
        self._next_sync_monotonic: float | None = (
            time.monotonic() + interval if interval > 0 else None
        )

        if interval > 0:
            self._thread = threading.Thread(
                target=self._run,
                name="gallery-cache-writer",
                daemon=True,
            )
            self._thread.start()

    def update(
        self,
        source_path: Path,
        entry: MediaEntry | None,
        changed: bool,
        completed: int,
    ) -> None:
        source_key = str(source_path)

        with self._lock:
            if entry is not None and changed:
                self.pending_entries[source_key] = entry

            # Progress metadata is checkpointed periodically even when every
            # source was reused from cache, but unchanged source rows themselves
            # are never rewritten. Keep changed-entry memory bounded even when
            # timed checkpoints are disabled or image processing is very fast.
            self.completed = completed
            self.dirty = True
            should_flush = (
                len(self.pending_entries) >= MAX_PENDING_CACHE_ENTRIES
            )

        if should_flush:
            self.flush()

    def flush(self, force: bool = False) -> bool:
        with self._lock:
            if not force and not self.dirty:
                return False

            entries = self.pending_entries
            completed = self.completed

            self.pending_entries = {}
            self.dirty = False

        try:
            checkpoint_sqlite_cache(
                path=self.path,
                generation=self.generation,
                entries=entries,
                completed=completed,
                total=self.total,
            )
        except (OSError, sqlite3.Error) as exc:
            with self._lock:
                for source_path, entry in entries.items():
                    self.pending_entries[source_path] = entry
                self.dirty = True

            eprint(f"warning: periodic SQLite cache checkpoint failed: {exc}")
            return False

        with self._lock:
            self.sync_count += 1

        if self.verbose:
            print(
                f"CACHE: checkpointed {completed}/{self.total} processed file(s) "
                f"({len(entries)} updated)"
            )
        return True

    def seconds_until_sync(self) -> float | None:
        """Return seconds until the next scheduled periodic cache checkpoint."""
        with self._lock:
            deadline = self._next_sync_monotonic

        if deadline is None:
            return None
        return max(0.0, deadline - time.monotonic())

    def _run(self) -> None:
        while not self._stop.wait(self.interval):
            self.flush()
            with self._lock:
                if not self._stop.is_set():
                    self._next_sync_monotonic = (
                        time.monotonic() + self.interval
                    )

    def stop(self, flush: bool = True) -> bool:
        self._stop.set()
        with self._lock:
            self._next_sync_monotonic = None
        if self._thread is not None:
            self._thread.join()

        if flush:
            return self.flush(force=True)
        return True


def scan_input_files(
    cache_db: SQLiteCache,
    inputs: Iterable[Path],
    recursive: bool,
    output_dir: Path,
    cache_path: Path,
) -> int:
    """Scan deterministically while spooling path order into SQLite.

    No all-files Python list or deduplication set is retained. The TEMP
    scan_paths table owns ordering, deduplication, and success state.
    """
    output_dir = output_dir.resolve()
    cache_path = cache_path.resolve()
    pending: list[Path] = []

    def flush() -> None:
        nonlocal pending
        if pending:
            cache_db.scan.add_scan_paths(pending)
            pending = []

    def add_file(path: Path, *, known_regular: bool = False) -> None:
        candidate = path
        if not known_regular:
            try:
                if path.is_symlink():
                    candidate = path.resolve(strict=True)
                elif not path.is_file():
                    return
            except OSError as exc:
                eprint(f"warning: cannot inspect {path}: {exc}")
                return

        if candidate == cache_path or is_relative_to(candidate, output_dir):
            return
        if candidate.suffix.casefold() not in CANDIDATE_EXTENSIONS:
            return
        pending.append(candidate)
        if len(pending) >= 1000:
            flush()

    def sorted_directory_entries(root: Path) -> Iterable[tuple[Path, int]]:
        """Yield one directory naturally sorted with bounded Python memory.

        Ordinary directories stay entirely in memory. Very large directories
        spill names and file kinds into a short-lived on-disk SQLite sorter
        beside the gallery cache, avoiding a list proportional to the largest
        input directory.
        """
        buffered: list[tuple[str, str, int]] = []
        spill_pending: list[tuple[str, str, int]] = []
        sorter: sqlite3.Connection | None = None
        sorter_path: Path | None = None

        def start_sorter() -> sqlite3.Connection:
            nonlocal sorter_path
            ensure_output_directory(cache_path.parent)
            fd, tmp_name = tempfile.mkstemp(
                prefix=".gallery-directory-sort.",
                suffix=".sqlite3",
                dir=str(cache_path.parent),
            )
            os.close(fd)
            sorter_path = Path(tmp_name)
            connection = sqlite3.connect(sorter_path)
            connection.execute("PRAGMA journal_mode = OFF")
            connection.execute("PRAGMA synchronous = OFF")
            connection.execute("PRAGMA temp_store = FILE")
            connection.create_collation("GALLERY_NATURAL", natural_compare)
            connection.execute(
                "CREATE TABLE entries(name TEXT, path TEXT, kind INTEGER)"
            )
            return connection

        try:
            with os.scandir(root) as iterator:
                for entry in iterator:
                    try:
                        if entry.is_dir(follow_symlinks=False):
                            kind = 1
                        elif entry.is_file(follow_symlinks=False):
                            kind = 0
                        elif entry.is_symlink():
                            kind = 2
                        else:
                            continue
                    except OSError as exc:
                        eprint(f"warning: cannot inspect {entry.path}: {exc}")
                        continue

                    row = (entry.name, entry.path, kind)
                    if sorter is None and len(buffered) < DIRECTORY_SORT_MEMORY_LIMIT:
                        buffered.append(row)
                        continue
                    if sorter is None:
                        sorter = start_sorter()
                        sorter.executemany(
                            "INSERT INTO entries VALUES(?, ?, ?)",
                            buffered,
                        )
                        buffered.clear()
                    spill_pending.append(row)
                    if len(spill_pending) >= 1000:
                        sorter.executemany(
                            "INSERT INTO entries VALUES(?, ?, ?)",
                            spill_pending,
                        )
                        spill_pending.clear()

            if sorter is None:
                buffered.sort(
                    key=lambda row: (natural_key(row[0]), row[0])
                )
                for _name, path_text, kind in buffered:
                    yield Path(path_text), kind
                return

            if spill_pending:
                sorter.executemany(
                    "INSERT INTO entries VALUES(?, ?, ?)",
                    spill_pending,
                )
                spill_pending.clear()
            sorter.commit()
            for path_text, kind in sorter.execute(
                "SELECT path, kind FROM entries "
                "ORDER BY name COLLATE GALLERY_NATURAL, name"
            ):
                yield Path(str(path_text)), int(kind)
        except OSError as exc:
            eprint(f"warning: cannot list {root}: {exc}")
        finally:
            if sorter is not None:
                sorter.close()
            if sorter_path is not None:
                try:
                    sorter_path.unlink()
                except FileNotFoundError:
                    pass
                except OSError as exc:
                    eprint(
                        f"warning: could not remove scan sorter {sorter_path}: {exc}"
                    )

    def scan_directory(root: Path) -> None:
        directories: list[Path] = []
        for path, kind in sorted_directory_entries(root):
            try:
                if kind == 1:
                    if path == output_dir or is_relative_to(path, output_dir):
                        continue
                    directories.append(path)
                    continue
                if kind == 0:
                    add_file(path, known_regular=True)
                    continue
                if kind == 2:
                    add_file(path)
            except OSError as exc:
                eprint(f"warning: cannot inspect {path}: {exc}")

        if recursive:
            for directory in directories:
                scan_directory(directory)

    for raw in inputs:
        try:
            root = raw.expanduser().resolve(strict=True)
        except OSError as exc:
            eprint(f"warning: input does not exist or cannot be read: {raw}: {exc}")
            continue

        if root.is_file():
            add_file(root, known_regular=True)
        elif root.is_dir():
            scan_directory(root)
        else:
            eprint(f"warning: skipping non-file, non-directory input: {root}")

    flush()
    return cache_db.scan.scan_count()


def svg_dimensions(path: Path) -> tuple[int, int]:
    if path.suffix.casefold() == ".svgz":
        raise ValueError("SVGZ dimension parsing is not supported")

    import xml.etree.ElementTree as ET

    root = ET.parse(path).getroot()

    def parse_length(value: str | None) -> float | None:
        if not value:
            return None
        match = _SVG_LENGTH_RE.match(value)
        return float(match.group(1)) if match else None

    width = parse_length(root.get("width"))
    height = parse_length(root.get("height"))

    if width and height and width > 0 and height > 0:
        return max(1, round(width)), max(1, round(height))

    view_box = root.get("viewBox") or root.get("viewbox")
    if view_box:
        parts = re.split(r"[\s,]+", view_box.strip())
        if len(parts) == 4:
            vb_width = float(parts[2])
            vb_height = float(parts[3])
            if vb_width > 0 and vb_height > 0:
                return max(1, round(vb_width)), max(1, round(vb_height))

    raise ValueError("SVG has no usable width/height or viewBox")


def jxl_decoder_name(
    toolchain: ToolchainCapabilities | None = None,
) -> str | None:
    if PILLOW_JXL_AVAILABLE:
        return "pillow-jxl-plugin"
    djxl_path = (toolchain or resolved_toolchain_capabilities()).djxl_path
    if djxl_path:
        return "djxl"
    return None


def jxl_encoder_name(
    toolchain: ToolchainCapabilities | None = None,
) -> str | None:
    cjxl_path = (toolchain or resolved_toolchain_capabilities()).cjxl_path
    return "cjxl" if cjxl_path else None


def jxl_metadata(
    path: Path,
    toolchain: ToolchainCapabilities | None = None,
) -> tuple[int, int, bool]:
    """Read JXL dimensions and animation state without decoding pixels."""
    jxlinfo_path = (
        toolchain or resolved_toolchain_capabilities()
    ).jxlinfo_path
    if jxlinfo_path:
        completed = run_external([jxlinfo_path, str(path)])
        output = "\n".join(
            part for part in (completed.stdout, completed.stderr) if part
        )
        match = _JXL_INFO_RE.search(output)
        if match:
            kind, width_text, height_text = match.groups()
            width = int(width_text)
            height = int(height_text)
            if width <= 0 or height <= 0:
                raise ValueError("JPEG XL has invalid dimensions")
            return width, height, kind.casefold() == "animation"

        message = output.strip()
        if len(message) > 800:
            message = message[-800:]
        if completed.returncode != 0:
            raise ValueError(
                f"jxlinfo failed with exit status {completed.returncode}"
                + (f": {message}" if message else "")
            )
        raise ValueError(
            "jxlinfo succeeded but its JPEG XL dimensions could not be parsed"
        )

    # If jxlinfo is not installed, fall back to the normal decode path.
    return raster_metadata(path)


def decode_jxl_with_djxl(
    path: Path,
    output_path: Path,
    toolchain: ToolchainCapabilities | None = None,
) -> None:
    djxl_path = (toolchain or resolved_toolchain_capabilities()).djxl_path
    if not djxl_path:
        detail = f" ({PILLOW_JXL_IMPORT_ERROR})" if PILLOW_JXL_IMPORT_ERROR else ""
        raise ValueError(
            "JPEG XL decoding requires either pillow-jxl-plugin "
            "(`python3 -m pip install pillow-jxl-plugin`) or `djxl` "
            f"from libjxl-tools/jpeg-xl{detail}"
        )

    completed = run_external([djxl_path, str(path), str(output_path)])
    if completed.returncode != 0:
        message = completed.stderr.strip() or completed.stdout.strip()
        if len(message) > 800:
            message = message[-800:]
        raise ValueError(
            f"djxl failed with exit status {completed.returncode}"
            + (f": {message}" if message else "")
        )


def encode_jxl_with_cjxl(
    input_path: Path,
    output_path: Path,
    quality: int,
    effort: int,
    toolchain: ToolchainCapabilities | None = None,
) -> None:
    """Encode a named PNG using cjxl only.

    Avoid cjxl's stdin path: some builds or wrappers spool a non-seekable input
    to loose PAM files. The caller owns a seekable PNG beneath the per-run
    temporary directory, so Pillow can write there directly without building a
    second full PNG copy in a BytesIO object.
    """
    capabilities = toolchain or resolved_toolchain_capabilities()
    if not capabilities.cjxl_path:
        raise ValueError(
            "JPEG XL encoding requires `cjxl` from libjxl-tools/jpeg-xl"
        )

    command = [
        capabilities.cjxl_path,
        str(input_path),
        str(output_path),
        "-q",
        str(quality),
        "-e",
        str(effort),
        f"--num_threads={capabilities.encoder_threads}",
    ]
    completed = run_external(command)

    if completed.returncode != 0:
        message = completed.stderr.strip() or completed.stdout.strip()
        if len(message) > 800:
            message = message[-800:]
        raise ValueError(
            f"cjxl failed with exit status {completed.returncode}"
            + (f": {message}" if message else "")
        )


@dataclass(frozen=True, slots=True)
class StaticEncoderBackend:
    """One registered derivative encoder and its capability metadata."""

    name: str
    extension: str
    pillow_format: str | None
    writer: Callable[..., tuple[int, int]]


def static_extension(output_format: str) -> str:
    try:
        return STATIC_ENCODER_BACKENDS[output_format].extension
    except KeyError as exc:
        raise CodecError(
            f"unsupported static output format: {output_format}"
        ) from exc


def ensure_output_encoder_available(
    output_format: str,
    toolchain: ToolchainCapabilities | None = None,
) -> None:
    """Fail early when the requested static-image encoder is unavailable."""
    backend = STATIC_ENCODER_BACKENDS.get(output_format)
    if backend is None:
        raise CodecError(f"unsupported static output format: {output_format}")
    if backend.name == "jxl":
        capabilities = toolchain or resolved_toolchain_capabilities()
        if not capabilities.cjxl_path:
            raise CodecError(
                "JPEG XL output requires `cjxl` from libjxl-tools/jpeg-xl"
            )
        return

    pillow_format = backend.pillow_format
    if pillow_format is None:
        raise CodecError(f"encoder {output_format} has no Pillow format")

    # Force Pillow to register all installed save plugins before checking.
    Image.init()
    if pillow_format not in Image.SAVE:
        raise CodecError(
            f"{pillow_format} output is not supported by this Pillow build"
        )


def webp_method_from_effort(effort: int) -> int:
    """Map the script's 1..9 effort range onto Pillow WebP's 0..6 method."""
    clamped = min(9, max(1, int(effort)))
    return min(6, max(0, round((clamped - 1) * 6 / 8)))


def avif_speed_from_effort(effort: int) -> int:
    """Map 1..9 effort to Pillow AVIF speed 10..0 (higher effort = slower)."""
    clamped = min(9, max(1, int(effort)))
    return min(10, max(0, round((9 - clamped) * 10 / 8)))


def static_encoder_name(output_format: str) -> str | None:
    backend = STATIC_ENCODER_BACKENDS.get(output_format)
    if backend is None:
        return None
    if backend.name == "jxl":
        return jxl_encoder_name()
    return f"pillow-{backend.name}"


JPEG_INPUT_EXTENSIONS = {".jpg", ".jpeg", ".jpe", ".jfif"}


@contextmanager
def open_raster_image(
    path: Path,
    jpeg_draft_max_width: int | None = None,
    toolchain: ToolchainCapabilities | None = None,
):
    """
    Open a raster image with Pillow and close all resources on scope exit.

    For JXL input files, use pillow-jxl-plugin when it is available, but force
    pixel loading immediately so a decoder failure can be caught here. If that
    fails and djxl is available, decode through libjxl's command-line decoder
    as a fallback.

    The djxl fallback owns its TemporaryDirectory directly in this context
    manager. This guarantees cleanup when normal processing finishes, when a
    worker raises, or when cooperative Ctrl-C cancellation unwinds the worker.
    """
    if path.suffix.casefold() != ".jxl":
        image = Image.open(path)
        try:
            if (
                jpeg_draft_max_width
                and jpeg_draft_max_width > 0
                and path.suffix.casefold() in JPEG_INPUT_EXTENSIONS
            ):
                # libjpeg can decode JPEGs at 1/2, 1/4, or 1/8 resolution.
                # Only use a reduction that remains at least as large as the
                # largest requested derivative. EXIF orientations that swap
                # axes are left at full decode to avoid under-sizing width.
                try:
                    orientation = int(image.getexif().get(274, 1) or 1)
                except (OSError, TypeError, ValueError):
                    orientation = 1
                if orientation not in {5, 6, 7, 8}:
                    src_width, src_height = image.size
                    target_width = min(src_width, int(jpeg_draft_max_width))
                    if target_width < src_width:
                        target_height = max(
                            1,
                            round(src_height * target_width / src_width),
                        )
                        image.draft(
                            image.mode,
                            (target_width, target_height),
                        )
            yield image
        finally:
            image.close()
        return

    pillow_error: Exception | None = None

    if PILLOW_JXL_AVAILABLE:
        image: Image.Image | None = None
        try:
            image = Image.open(path)
            # pillow-jxl can defer the actual libjxl pixel decode until
            # load(), so force decoding here while we can still fall back to
            # djxl if needed.
            image.load()
        except Exception as exc:
            pillow_error = exc
            if image is not None:
                try:
                    image.close()
                except Exception:
                    pass
        else:
            try:
                yield image
            finally:
                image.close()
            return

    djxl_path = (toolchain or resolved_toolchain_capabilities()).djxl_path
    if not djxl_path:
        if pillow_error is not None:
            raise ValueError(
                "pillow-jxl-plugin failed to decode JPEG XL and djxl is not "
                f"available as a fallback: {pillow_error}"
            ) from pillow_error

        detail = (
            f" ({PILLOW_JXL_IMPORT_ERROR})"
            if PILLOW_JXL_IMPORT_ERROR
            else ""
        )
        raise ValueError(
            "JPEG XL decoding requires pillow-jxl-plugin or djxl"
            f"{detail}"
        )

    with tempfile.TemporaryDirectory(
        prefix="gallery-jxl-"
    ) as tmp_name:
        decoded = Path(tmp_name) / "decoded.apng"
        image: Image.Image | None = None

        try:
            decode_jxl_with_djxl(path, decoded, toolchain)
            image = Image.open(decoded)
            image.load()
        except (KeyboardInterrupt, ProcessingCancelled):
            if image is not None:
                try:
                    image.close()
                except Exception:
                    pass
            raise
        except Exception as exc:
            if image is not None:
                try:
                    image.close()
                except Exception:
                    pass
            if pillow_error is not None:
                raise ValueError(
                    "both pillow-jxl-plugin and djxl failed to decode JPEG XL; "
                    f"pillow-jxl-plugin: {pillow_error}; djxl: {exc}"
                ) from exc
            raise

        try:
            yield image
        finally:
            image.close()


def _webp_container_is_animated(path: Path) -> bool:
    """Detect WebP animation from RIFF chunks without decoding image pixels."""
    try:
        file_size = path.stat().st_size
        if file_size < 12:
            return False
        with path.open("rb") as source:
            header = source.read(12)
            if header[:4] != b"RIFF" or header[8:] != b"WEBP":
                return False
            declared_size = int.from_bytes(header[4:8], "little") + 8
            container_end = min(file_size, declared_size)
            offset = 12
            while offset + 8 <= container_end:
                source.seek(offset)
                chunk_header = source.read(8)
                if len(chunk_header) != 8:
                    return False
                chunk_type = chunk_header[:4]
                chunk_size = int.from_bytes(chunk_header[4:], "little")
                if chunk_type in {b"ANIM", b"ANMF"}:
                    return True
                if chunk_type == b"VP8X" and chunk_size >= 1:
                    flags = source.read(1)
                    if flags and flags[0] & 0x02:
                        return True
                next_offset = offset + 8 + chunk_size + (chunk_size & 1)
                if next_offset <= offset or next_offset > container_end:
                    return False
                offset = next_offset
    except (OSError, OverflowError):
        return False
    return False


def webp_animation_upgrade_needed(
    suffix: str,
    entry: MediaEntry | None,
) -> bool:
    """Return whether a cached WebP needs current animation classification."""
    return bool(
        suffix == ".webp"
        and entry is not None
        and entry.get("webp_animation_version") != WEBP_ANIMATION_VERSION
    )


def raster_metadata(path: Path) -> tuple[int, int, bool]:
    with open_raster_image(path) as image:
        # Pillow exposes is_animated/n_frames for some multi-image containers
        # that are not animations. JPEG files containing MPO/MPF auxiliary
        # images and multi-page TIFFs are common examples. Only formats that
        # can actually represent browser-playable animation should be treated
        # as animated here. JPEG XL is handled separately by jxl_metadata().
        animation_capable = (
            path.suffix.casefold() in BROWSER_ANIMATED_EXTENSIONS
        )
        animated = animation_capable and (
            (
                path.suffix.casefold() == ".webp"
                and _webp_container_is_animated(path)
            )
            or (
                bool(getattr(image, "is_animated", False))
                and int(getattr(image, "n_frames", 1)) > 1
            )
        )

        if not animated:
            # Read only header metadata here. ImageOps.exif_transpose() calls
            # load() and copies pixels even when orientation is normal, which
            # duplicates the full decode later used for derivative encoding.
            try:
                image.seek(0)
            except EOFError:
                pass
            width, height = image.size
            try:
                orientation = int(image.getexif().get(274, 1) or 1)
            except (AttributeError, OSError, TypeError, ValueError):
                orientation = 1
            if orientation in {5, 6, 7, 8}:
                width, height = height, width
        else:
            width, height = image.size

    if width <= 0 or height <= 0:
        raise ValueError("image has invalid dimensions")

    return int(width), int(height), animated


def _positive_float(value: Any) -> float | None:
    """Return a finite positive float from optional external-tool metadata."""
    try:
        parsed = float(value)
    except (TypeError, ValueError):
        return None
    return parsed if parsed > 0 and math.isfinite(parsed) else None


def _rational_float(value: Any) -> float | None:
    """Return a finite positive float from an ffprobe rational value."""
    text = str(value or "").strip()
    match = re.fullmatch(r"(-?\d+)(?:/(\d+))?", text)
    if not match:
        return _positive_float(value)
    numerator = int(match.group(1))
    denominator = int(match.group(2) or 1)
    if denominator == 0:
        return None
    result = numerator / denominator
    return result if result > 0 and math.isfinite(result) else None


def _optional_positive_int(value: Any) -> int | None:
    try:
        parsed = int(value)
    except (TypeError, ValueError):
        return None
    return parsed if parsed > 0 else None


def _metadata_tag_text(
    containers: Sequence[Any],
    *names: str,
) -> str | None:
    """Return the first non-empty tag value using case-insensitive names."""
    wanted = {name.casefold() for name in names}
    for container in containers:
        if not isinstance(container, Mapping):
            continue
        for key, value in container.items():
            if str(key).strip().casefold() not in wanted:
                continue
            text = _metadata_text(value)
            if text:
                return text
    return None


def video_is_hdr(details: dict[str, Any]) -> bool:
    """Return True for HDR/wide-gamut metadata needing SDR tone mapping."""
    transfer = str(details.get("color_transfer") or "").casefold()
    primaries = str(details.get("color_primaries") or "").casefold()
    color_space = str(details.get("color_space") or "").casefold()
    return bool(
        transfer in {"smpte2084", "arib-std-b67"}
        or primaries in {"bt2020", "smpte428", "smpte431", "smpte432"}
        or color_space in {"bt2020nc", "bt2020c"}
    )


def video_compatibility_note(
    suffix: str,
    video_codec: str | None,
    audio_codec: str | None,
    video_profile: str | None = None,
    pixel_format: str | None = None,
    bits_per_raw_sample: int | None = None,
    color_transfer: str | None = None,
    color_primaries: str | None = None,
    color_space: str | None = None,
) -> str | None:
    """Return a conservative browser-portability warning for one source."""
    extension = suffix.casefold()
    video = (video_codec or "").casefold()
    audio = (audio_codec or "").casefold()
    profile = (video_profile or "").casefold()
    pixel = (pixel_format or "").casefold()
    hdr = video_is_hdr(
        {
            "color_transfer": color_transfer,
            "color_primaries": color_primaries,
            "color_space": color_space,
        }
    )
    high_bit_depth = bool(
        (bits_per_raw_sample or 0) > 8
        or re.search(r"p(?:9|10|12|14|16)(?:le|be)?$", pixel)
        or re.search(r"(?:^|\D)(?:10|12|14|16)(?:-?bit|\b)", profile)
    )
    unusual_chroma = bool(
        pixel
        and pixel not in {"yuv420p", "yuvj420p", "nv12"}
        and not high_bit_depth
    )
    if not video:
        return "Codecs could not be checked; browser playback may vary."

    if hdr:
        return (
            "HDR or wide-gamut video is not consistently rendered across common "
            "browsers; an optional compatibility transcode can tone-map it to SDR."
        )
    if high_bit_depth:
        return (
            "High-bit-depth video is not consistently decoded across common "
            "browsers; an optional 8-bit compatibility transcode is recommended."
        )
    if unusual_chroma:
        return (
            "This pixel format is not consistently decoded across common browsers; "
            "an optional 4:2:0 compatibility transcode is recommended."
        )

    if extension in {".mp4", ".m4v", ".mov", ".3gp"}:
        video_ok = video in {"h264", "avc1"}
        audio_ok = not audio or audio in {"aac", "mp3"}
        if video_ok and audio_ok:
            return None
        return (
            "This container or codec combination is not consistently playable "
            "across common browsers; an optional compatibility transcode is "
            "recommended and the original remains available as a fallback."
        )

    if extension == ".webm":
        video_ok = video in {"vp8", "vp9", "av1"}
        audio_ok = not audio or audio in {"opus", "vorbis"}
        if video_ok and audio_ok:
            return None
        return (
            "This WebM codec combination is not consistently playable across "
            "common browsers; an optional compatibility transcode is recommended "
            "and the original remains available as a fallback."
        )

    if extension == ".ogv" and video in {"theora", "vp8", "vp9"}:
        return (
            "Ogg video support varies by browser; an optional compatibility "
            "transcode is recommended and the original remains available."
        )

    return (
        f"{extension.lstrip('.').upper() or 'This'} video is not consistently "
        "playable across common browsers; an optional compatibility transcode is "
        "recommended and the original remains available as a fallback."
    )


def video_metadata(
    path: Path,
    toolchain: ToolchainCapabilities | None = None,
) -> tuple[int, int, dict[str, Any]]:
    """Return display geometry and browser-relevant ffprobe metadata."""
    ffprobe_path = (
        toolchain or resolved_toolchain_capabilities()
    ).ffprobe_path
    if not ffprobe_path:
        return 16, 9, {
            "album": None,
            "artist": None,
            "duration": None,
            "video_codec": None,
            "video_profile": None,
            "audio_codec": None,
            "frame_rate": None,
            "creation_time": None,
            "pixel_format": None,
            "bits_per_raw_sample": None,
            "color_transfer": None,
            "color_primaries": None,
            "color_space": None,
            "color_range": None,
            "compatibility_note": video_compatibility_note(
                path.suffix, None, None
            ),
        }

    completed = run_external(
        [
            ffprobe_path,
            "-v",
            "error",
            "-show_entries",
            (
                "stream=codec_type,codec_name,profile,width,height,"
                "sample_aspect_ratio,avg_frame_rate,r_frame_rate,duration,"
                "pix_fmt,bits_per_raw_sample,color_transfer,color_primaries,"
                "color_space,color_range:"
                "stream_tags=rotate,creation_time,album,artist,author,"
                "album_artist:stream_side_data=rotation:format=duration:"
                "format_tags=creation_time,album,artist,author,album_artist"
            ),
            "-of",
            "json",
            str(path),
        ]
    )
    if completed.returncode != 0:
        message = completed.stderr.strip() or completed.stdout.strip()
        if len(message) > 800:
            message = message[-800:]
        raise ValueError(
            f"ffprobe failed with exit status {completed.returncode}"
            + (f": {message}" if message else "")
        )

    try:
        payload = json.loads(completed.stdout)
    except (TypeError, json.JSONDecodeError) as exc:
        raise ValueError("ffprobe returned invalid JSON") from exc

    streams = payload.get("streams") if isinstance(payload, dict) else None
    if not isinstance(streams, list) or not streams:
        raise ValueError("video has no readable video stream")
    stream = next(
        (
            item
            for item in streams
            if isinstance(item, dict) and item.get("codec_type") == "video"
        ),
        None,
    )
    if not isinstance(stream, dict):
        raise ValueError("video has no readable video stream")

    try:
        width = int(stream.get("width") or 0)
        height = int(stream.get("height") or 0)
    except (TypeError, ValueError) as exc:
        raise ValueError("video has invalid dimensions") from exc
    if width <= 0 or height <= 0:
        raise ValueError("video has invalid dimensions")

    sample_aspect_ratio = str(stream.get("sample_aspect_ratio") or "")
    ratio_match = re.fullmatch(r"(\d+):(\d+)", sample_aspect_ratio)
    if ratio_match:
        numerator = int(ratio_match.group(1))
        denominator = int(ratio_match.group(2))
        if numerator > 0 and denominator > 0:
            width = max(1, round(width * numerator / denominator))

    rotation = 0.0
    tags = stream.get("tags")
    if isinstance(tags, dict):
        try:
            rotation = float(tags.get("rotate") or 0)
        except (TypeError, ValueError):
            rotation = 0.0
    side_data = stream.get("side_data_list")
    if isinstance(side_data, list):
        for item in side_data:
            if not isinstance(item, dict) or "rotation" not in item:
                continue
            try:
                rotation = float(item["rotation"])
            except (TypeError, ValueError):
                pass
            break
    if round(rotation) % 180:
        width, height = height, width

    format_metadata = payload.get("format")
    if not isinstance(format_metadata, dict):
        format_metadata = {}
    duration = _positive_float(format_metadata.get("duration"))
    if duration is None:
        duration = _positive_float(stream.get("duration"))

    audio_stream = next(
        (
            item
            for item in streams
            if isinstance(item, dict) and item.get("codec_type") == "audio"
        ),
        None,
    )
    video_codec = str(stream.get("codec_name") or "").casefold() or None
    video_profile = str(stream.get("profile") or "").strip() or None
    audio_codec = (
        str(audio_stream.get("codec_name") or "").casefold() or None
        if isinstance(audio_stream, dict)
        else None
    )
    frame_rate = _rational_float(stream.get("avg_frame_rate"))
    if frame_rate is None:
        frame_rate = _rational_float(stream.get("r_frame_rate"))

    creation_time = None
    tag_containers = (
        format_metadata.get("tags"),
        stream.get("tags"),
        audio_stream.get("tags") if isinstance(audio_stream, dict) else None,
    )
    for container in tag_containers:
        if not isinstance(container, dict):
            continue
        candidate = _metadata_tag_text((container,), "creation_time")
        if candidate:
            creation_time = candidate[:128]
            break

    album = _metadata_tag_text(tag_containers, "album")
    artist = _metadata_tag_text(
        tag_containers,
        "artist",
        "album_artist",
        "author",
    )

    pixel_format = str(stream.get("pix_fmt") or "").casefold() or None
    bits_per_raw_sample = _optional_positive_int(
        stream.get("bits_per_raw_sample")
    )
    color_transfer = str(stream.get("color_transfer") or "").casefold() or None
    color_primaries = str(stream.get("color_primaries") or "").casefold() or None
    color_space = str(stream.get("color_space") or "").casefold() or None
    color_range = str(stream.get("color_range") or "").casefold() or None

    details = {
        "album": album,
        "artist": artist,
        "duration": duration,
        "video_codec": video_codec,
        "video_profile": video_profile,
        "audio_codec": audio_codec,
        "frame_rate": frame_rate,
        "creation_time": creation_time,
        "pixel_format": pixel_format,
        "bits_per_raw_sample": bits_per_raw_sample,
        "color_transfer": color_transfer,
        "color_primaries": color_primaries,
        "color_space": color_space,
        "color_range": color_range,
        "compatibility_note": video_compatibility_note(
            path.suffix,
            video_codec,
            audio_codec,
            video_profile,
            pixel_format,
            bits_per_raw_sample,
            color_transfer,
            color_primaries,
            color_space,
        ),
    }
    return width, height, details


def expected_video_poster(digest: str) -> str:
    filename = f"{digest[:24]}-poster.jpg"
    return sharded_asset_relative_path(filename, digest)


def expected_video_transcode_asset(digest: str) -> str:
    filename = f"{digest[:24]}-compat-v{VIDEO_TRANSCODE_VERSION}.mp4"
    return sharded_asset_relative_path(filename, digest)


def ensure_video_transcoder_available(
    toolchain: ToolchainCapabilities | None = None,
) -> None:
    """Require the probe and software H.264 encoder used by the opt-in path."""
    capabilities = toolchain or resolved_toolchain_capabilities()
    if not capabilities.ffmpeg_path:
        raise ValueError(
            "--transcode-incompatible-videos requires ffmpeg on PATH"
        )
    if not capabilities.ffprobe_path:
        raise ValueError(
            "--transcode-incompatible-videos requires ffprobe on PATH"
        )
    completed = run_external(
        [capabilities.ffmpeg_path, "-hide_banner", "-encoders"]
    )
    output = completed.stdout + "\n" + completed.stderr
    if completed.returncode != 0 or re.search(
        r"(?m)^\s*V\S*\s+libx264\s", output
    ) is None:
        raise ValueError(
            "--transcode-incompatible-videos requires an ffmpeg build with "
            "the libx264 encoder"
        )


def generate_browser_compatible_video(
    source: Path,
    destination: Path,
    details: dict[str, Any],
    toolchain: ToolchainCapabilities | None = None,
    resources: BuildExecutionResources | None = None,
) -> dict[str, Any]:
    """Create an atomic H.264/AAC MP4 derivative, tone-mapping HDR to SDR."""
    capabilities = toolchain or resolved_toolchain_capabilities()
    if not capabilities.ffmpeg_path or not capabilities.ffprobe_path:
        raise ValueError("ffmpeg and ffprobe are required for video transcoding")

    ensure_output_directory(destination.parent)
    fd, tmp_name = tempfile.mkstemp(
        prefix=f".{destination.name}.",
        suffix=".mp4",
        dir=str(destination.parent),
    )
    os.close(fd)
    temporary = Path(tmp_name)

    even_scale = "scale=w=trunc(iw/2)*2:h=trunc(ih/2)*2"
    if video_is_hdr(details):
        video_filter = ",".join(
            (
                "zscale=t=linear:npl=100",
                "format=gbrpf32le",
                "tonemap=tonemap=hable:desat=0",
                "zscale=p=bt709:t=bt709:m=bt709:r=tv",
                even_scale,
                "format=yuv420p",
            )
        )
    else:
        video_filter = f"{even_scale},format=yuv420p"

    command = [
        capabilities.ffmpeg_path,
        "-v",
        "error",
        "-nostdin",
        "-y",
        "-i",
        str(source),
        "-map",
        "0:v:0",
        "-map",
        "0:a:0?",
        "-map_metadata",
        "0",
        "-map_chapters",
        "-1",
        "-sn",
        "-dn",
        "-vf",
        video_filter,
        "-c:v",
        "libx264",
        "-tag:v",
        "avc1",
        "-preset",
        VIDEO_TRANSCODE_PRESET,
        "-crf",
        str(VIDEO_TRANSCODE_CRF),
        "-pix_fmt",
        "yuv420p",
        "-threads",
        str(max(1, capabilities.encoder_threads)),
        "-c:a",
        "aac",
        "-b:a",
        VIDEO_TRANSCODE_AUDIO_BITRATE,
        "-ac",
        "2",
        "-ar",
        "48000",
        "-metadata:s:v:0",
        "rotate=0",
        "-movflags",
        "+faststart",
        "-max_muxing_queue_size",
        "1024",
    ]
    if video_is_hdr(details):
        command.extend(
            [
                "-color_primaries",
                "bt709",
                "-color_trc",
                "bt709",
                "-colorspace",
                "bt709",
                "-color_range",
                "tv",
            ]
        )
    command.append(str(temporary))

    try:
        with encoder_slot(resources):
            completed = run_external(command)
        if completed.returncode != 0 or not generated_file_usable(temporary):
            message = completed.stderr.strip() or completed.stdout.strip()
            if len(message) > 1200:
                message = message[-1200:]
            raise ValueError(
                "ffmpeg compatibility transcode failed with exit status "
                f"{completed.returncode}"
                + (f": {message}" if message else "")
            )

        _width, _height, output_details = video_metadata(
            temporary,
            capabilities,
        )
        if output_details.get("video_codec") != "h264":
            raise ValueError("compatibility transcode did not produce H.264 video")
        if output_details.get("audio_codec") not in {None, "aac"}:
            raise ValueError("compatibility transcode did not produce AAC audio")
        if output_details.get("compatibility_note"):
            raise ValueError(
                "compatibility transcode still has a browser-portability warning: "
                f"{output_details['compatibility_note']}"
            )

        os.replace(temporary, destination)
        set_output_file_mode(destination)
        return output_details
    finally:
        try:
            temporary.unlink()
        except FileNotFoundError:
            pass


def generate_video_poster(
    source: Path,
    destination: Path,
    duration: float | None,
    toolchain: ToolchainCapabilities | None = None,
    resources: BuildExecutionResources | None = None,
) -> tuple[int, int]:
    """Extract one bounded JPEG poster without modifying the source video."""
    ffmpeg_path = (
        toolchain or resolved_toolchain_capabilities()
    ).ffmpeg_path
    if not ffmpeg_path:
        raise ValueError("ffmpeg is unavailable")

    ensure_output_directory(destination.parent)
    fd, tmp_name = tempfile.mkstemp(
        prefix=f".{destination.name}.",
        suffix=".jpg",
        dir=str(destination.parent),
    )
    os.close(fd)
    temporary = Path(tmp_name)
    seek_seconds = (
        min(10.0, max(0.0, duration * 0.1))
        if duration is not None
        else 0.0
    )
    command = [
        ffmpeg_path,
        "-v",
        "error",
        "-nostdin",
        "-y",
        "-ss",
        f"{seek_seconds:.3f}",
        "-i",
        str(source),
        "-map",
        "0:v:0",
        "-frames:v",
        "1",
        "-vf",
        "scale=w='min(960,iw)':h=-2",
        "-q:v",
        "3",
        "-threads",
        "1",
        str(temporary),
    ]
    try:
        with encoder_slot(resources):
            completed = run_external(command)
        if completed.returncode != 0 or not generated_file_usable(temporary):
            message = completed.stderr.strip() or completed.stdout.strip()
            if len(message) > 800:
                message = message[-800:]
            raise ValueError(
                "ffmpeg poster extraction failed with exit status "
                f"{completed.returncode}"
                + (f": {message}" if message else "")
            )
        with Image.open(temporary) as poster:
            poster.verify()
        with Image.open(temporary) as poster:
            width, height = poster.size
        if width <= 0 or height <= 0:
            raise ValueError("generated video poster has invalid dimensions")
        os.replace(temporary, destination)
        set_output_file_mode(destination)
        return int(width), int(height)
    finally:
        try:
            temporary.unlink()
        except FileNotFoundError:
            pass


def _exif_text(value: Any) -> str | None:
    """Return a short, display-safe EXIF string or None."""
    if value is None:
        return None
    if isinstance(value, bytes):
        value = value.decode("utf-8", "replace")
    text = str(value).replace("\x00", "").strip()
    return text[:512] if text else None


def _exif_number(value: Any) -> float | None:
    try:
        if isinstance(value, (tuple, list)) and len(value) == 2:
            denominator = float(value[1])
            return float(value[0]) / denominator if denominator else None
        return float(value)
    except (TypeError, ValueError, ZeroDivisionError, OverflowError):
        return None


def _format_exif_number(value: float, digits: int = 2) -> str:
    text = f"{value:.{digits}f}"
    return text.rstrip("0").rstrip(".") if "." in text else text


def _format_focal_length(value: Any) -> str | None:
    number = _exif_number(value)
    if number is None or number <= 0:
        return None
    return f"{_format_exif_number(number, 1)} mm"


def _format_max_aperture(value: Any) -> str | None:
    apex = _exif_number(value)
    if apex is None:
        return None
    try:
        f_number = 2 ** (apex / 2)
    except OverflowError:
        return None
    if not (0 < f_number < 1024):
        return None
    return f"f/{_format_exif_number(f_number, 1)}"


def _format_exposure_time(value: Any) -> str | None:
    seconds = _exif_number(value)
    if seconds is None or seconds <= 0:
        return None
    if seconds >= 1:
        return f"{_format_exif_number(seconds, 2)} s"
    reciprocal = 1 / seconds
    rounded = round(reciprocal)
    if rounded >= 1 and abs(reciprocal - rounded) <= max(0.02, reciprocal * 0.002):
        return f"1/{rounded} s"
    return f"{_format_exif_number(seconds, 4)} s"


def _format_iso(value: Any) -> str | None:
    values = value if isinstance(value, (tuple, list)) else (value,)
    formatted: list[str] = []
    for item in values:
        number = _exif_number(item)
        if number is not None and number > 0:
            formatted.append(_format_exif_number(number, 0))
    return ", ".join(formatted) or None


def _format_lens_specification(value: Any) -> str | None:
    if not isinstance(value, (tuple, list)) or len(value) < 4:
        return None
    numbers = [_exif_number(item) for item in value[:4]]
    if any(number is None or number <= 0 for number in numbers):
        return None
    focal_min, focal_max, aperture_min, aperture_max = numbers
    focal = _format_exif_number(float(focal_min), 1)
    if abs(float(focal_max) - float(focal_min)) > 0.05:
        focal += f"–{_format_exif_number(float(focal_max), 1)}"
    aperture = _format_exif_number(float(aperture_min), 1)
    if abs(float(aperture_max) - float(aperture_min)) > 0.05:
        aperture += f"–{_format_exif_number(float(aperture_max), 1)}"
    return f"{focal} mm f/{aperture}"


def _format_creation_datetime(values: dict[int, Any]) -> str | None:
    raw = (
        _exif_text(values.get(36867))
        or _exif_text(values.get(36868))
        or _exif_text(values.get(306))
    )
    if not raw:
        return None
    match = re.fullmatch(r"(\d{4}):(\d{2}):(\d{2})(.*)", raw)
    if match:
        raw = f"{match.group(1)}-{match.group(2)}-{match.group(3)}{match.group(4)}"
    offset = _exif_text(values.get(36881))
    if offset and not raw.endswith(offset):
        raw = f"{raw} {offset}"
    return raw


def _metadata_text(value: Any) -> str | None:
    """Normalize one or more metadata strings without exposing raw objects."""
    if isinstance(value, (tuple, list)):
        parts = [text for item in value if (text := _exif_text(item))]
        return ", ".join(dict.fromkeys(parts)) or None
    return _exif_text(value)


def _container_text_value(
    image: Image.Image,
    *names: str,
) -> str | None:
    """Read a case-insensitive PNG/container textual metadata value."""
    wanted = {name.casefold() for name in names}
    mappings: list[Any] = [image.info]
    try:
        text_mapping = getattr(image, "text", None)
    except (OSError, TypeError, ValueError):
        text_mapping = None
    if isinstance(text_mapping, dict) and text_mapping is not image.info:
        mappings.append(text_mapping)

    for mapping in mappings:
        if not isinstance(mapping, dict):
            continue
        for key, value in mapping.items():
            if str(key).strip().casefold() not in wanted:
                continue
            text = _metadata_text(value)
            if text:
                return text
    return None


def _iptc_artist(image: Image.Image) -> str | None:
    """Return the IPTC By-line as an Artist fallback when available."""
    try:
        from PIL import IptcImagePlugin

        metadata = IptcImagePlugin.getiptcinfo(image)
    except (OSError, SyntaxError, TypeError, ValueError):
        return None
    if not isinstance(metadata, dict):
        return None
    return _metadata_text(metadata.get((2, 80)))


def _xmp_packet_album_artist(
    packet: Any,
) -> tuple[str | None, str | None]:
    """Return Album and Artist from a bounded, non-executable XMP packet.

    Album is normally XMP Dynamic Media metadata rather than a TIFF/EXIF tag.
    Artist may also be present as xmpDM:artist, dc:creator, or xmp:author.
    Parsing is deliberately limited to a small packet and rejects DTD/entity
    declarations so this remains safe without adding an XML dependency.
    """
    if isinstance(packet, str):
        raw = packet.encode("utf-8", "replace")
    elif isinstance(packet, (bytes, bytearray, memoryview)):
        raw = bytes(packet)
    else:
        return None, None

    raw = raw.rstrip(b"\x00 \t\r\n")
    if not raw or len(raw) > 2 * 1024 * 1024:
        return None, None
    declaration_probe = raw.upper()
    if b"<!DOCTYPE" in declaration_probe or b"<!ENTITY" in declaration_probe:
        return None, None

    import xml.etree.ElementTree as ET

    try:
        root = ET.fromstring(raw)
    except (ET.ParseError, RecursionError, TypeError, ValueError):
        return None, None

    def local_name(name: Any) -> str:
        text = str(name)
        if "}" in text:
            text = text.rsplit("}", 1)[1]
        return text.rsplit(":", 1)[-1].casefold()

    def append_value(target: list[str], value: Any) -> None:
        text = _exif_text(value)
        if text and text not in target:
            target.append(text)

    def element_values(element: Any) -> list[str]:
        found: list[str] = []
        append_value(found, element.text)
        for descendant in element.iter():
            if descendant is element:
                continue
            append_value(found, descendant.text)
        return found

    albums: list[str] = []
    artists: list[str] = []
    creators: list[str] = []
    authors: list[str] = []
    for element in root.iter():
        for key, value in element.attrib.items():
            name = local_name(key)
            if name == "album":
                append_value(albums, value)
            elif name == "artist":
                append_value(artists, value)
            elif name == "creator":
                append_value(creators, value)
            elif name == "author":
                append_value(authors, value)

        name = local_name(element.tag)
        if name == "album":
            albums.extend(
                value for value in element_values(element) if value not in albums
            )
        elif name == "artist":
            artists.extend(
                value for value in element_values(element) if value not in artists
            )
        elif name == "creator":
            creators.extend(
                value for value in element_values(element) if value not in creators
            )
        elif name == "author":
            authors.extend(
                value for value in element_values(element) if value not in authors
            )

    album = albums[0] if albums else None
    preferred_artists = artists or creators or authors
    artist = ", ".join(preferred_artists) if preferred_artists else None
    return album, artist


def _xmp_album_artist(image: Image.Image) -> tuple[str | None, str | None]:
    """Return Album and Artist from XMP exposed by a Pillow decoder."""
    packet = image.info.get("xmp") or image.info.get("XML:com.adobe.xmp")
    return _xmp_packet_album_artist(packet)


_JXL_CONTAINER_SIGNATURE = b"\x00\x00\x00\x0cJXL \r\n\x87\n"
_MAX_JXL_METADATA_BOX_BYTES = 16 * 1024 * 1024
_brotli_decoder_lock = threading.Lock()
_brotli_decoder_function: Any = None
_brotli_decoder_checked = False
_python_brotli_decoder: Callable[[bytes], bytes] | None = None
_python_brotli_decoder_checked = False


def _load_python_brotli_decoder() -> Callable[[bytes], bytes] | None:
    """Return a Python Brotli decoder from either common optional package."""
    global _python_brotli_decoder, _python_brotli_decoder_checked
    if _python_brotli_decoder_checked:
        return _python_brotli_decoder
    with _brotli_decoder_lock:
        if _python_brotli_decoder_checked:
            return _python_brotli_decoder
        for module_name in ("brotli", "brotlicffi"):
            try:
                module = __import__(module_name)
                decoder = getattr(module, "decompress")
            except (AttributeError, ImportError, OSError):
                continue
            if callable(decoder):
                _python_brotli_decoder = decoder
                break
        _python_brotli_decoder_checked = True
        return _python_brotli_decoder


def _brotli_decoder() -> Any:
    """Return the system Brotli one-shot decoder when it is available."""
    global _brotli_decoder_checked, _brotli_decoder_function
    if _brotli_decoder_checked:
        return _brotli_decoder_function
    with _brotli_decoder_lock:
        if _brotli_decoder_checked:
            return _brotli_decoder_function
        import ctypes.util

        discovered = ctypes.util.find_library("brotlidec")
        candidates = [
            discovered,
            "libbrotlidec.so.1",
            "libbrotlidec.so",
            "libbrotlidec.1.dylib",
            "libbrotlidec.dylib",
            "brotlidec.dll",
        ]
        for library_name in dict.fromkeys(
            candidate for candidate in candidates if candidate
        ):
            try:
                library = ctypes.CDLL(library_name)
                function = library.BrotliDecoderDecompress
                function.argtypes = (
                    ctypes.c_size_t,
                    ctypes.c_void_p,
                    ctypes.POINTER(ctypes.c_size_t),
                    ctypes.c_void_p,
                )
                function.restype = ctypes.c_int
                # Keep the CDLL reachable through the function object.
                function._gallery_library = library
                _brotli_decoder_function = function
                break
            except (AttributeError, OSError):
                continue
        _brotli_decoder_checked = True
        return _brotli_decoder_function


def _decompress_brotli_metadata(encoded: bytes) -> bytes | None:
    """Decode one brob payload with a strict decompressed-size ceiling."""
    python_decoder = _load_python_brotli_decoder()
    if python_decoder is not None and encoded:
        try:
            decoded = python_decoder(encoded)
        except Exception:
            decoded = None
        if isinstance(decoded, (bytes, bytearray, memoryview)):
            bounded = bytes(decoded)
            if len(bounded) <= _MAX_JXL_METADATA_BOX_BYTES:
                return bounded

    function = _brotli_decoder()
    if function is None or not encoded:
        return None
    source = ctypes.create_string_buffer(encoded)
    capacity = min(
        _MAX_JXL_METADATA_BOX_BYTES,
        max(64 * 1024, len(encoded) * 4),
    )
    while capacity <= _MAX_JXL_METADATA_BOX_BYTES:
        destination = ctypes.create_string_buffer(capacity)
        decoded_size = ctypes.c_size_t(capacity)
        result = int(
            function(
                len(encoded),
                source,
                ctypes.byref(decoded_size),
                destination,
            )
        )
        if result == 1:  # BROTLI_DECODER_RESULT_SUCCESS
            return destination.raw[:decoded_size.value]
        if result != 3 or capacity == _MAX_JXL_METADATA_BOX_BYTES:
            return None
        capacity = min(_MAX_JXL_METADATA_BOX_BYTES, capacity * 2)
    return None


def _jxl_metadata_boxes(
    path: Path,
) -> tuple[bytes | None, bytes | None, bool]:
    """Return bounded JXL metadata and whether compressed data was unreadable."""
    try:
        file_size = path.stat().st_size
        if file_size < len(_JXL_CONTAINER_SIGNATURE):
            return None, None, False
        with path.open("rb") as source:
            if source.read(len(_JXL_CONTAINER_SIGNATURE)) != _JXL_CONTAINER_SIGNATURE:
                return None, None, False
            source.seek(0)
            exif_box: bytes | None = None
            xmp_box: bytes | None = None
            compressed_metadata_unreadable = False
            offset = 0
            while offset + 8 <= file_size and (exif_box is None or xmp_box is None):
                source.seek(offset)
                header = source.read(8)
                if len(header) != 8:
                    break
                size32, box_type = struct.unpack(">I4s", header)
                header_size = 8
                if size32 == 1:
                    extended = source.read(8)
                    if len(extended) != 8:
                        break
                    box_size = struct.unpack(">Q", extended)[0]
                    header_size = 16
                elif size32 == 0:
                    box_size = file_size - offset
                else:
                    box_size = size32
                if box_size < header_size or offset + box_size > file_size:
                    break
                payload_size = box_size - header_size
                wanted = box_type in {b"Exif", b"xml ", b"brob"}
                if wanted and payload_size <= _MAX_JXL_METADATA_BOX_BYTES + 4:
                    payload = source.read(payload_size)
                    if len(payload) != payload_size:
                        break
                    actual_type = box_type
                    if box_type == b"brob" and len(payload) >= 4:
                        actual_type = payload[:4]
                        # The JXL specification uses canonical ``Exif`` and
                        # ``xml `` identifiers, but deployed encoders have
                        # emitted mixed/lowercase variants inside brob boxes.
                        # ExifTool deliberately normalizes these too.
                        if actual_type.lower() == b"exif":
                            actual_type = b"Exif"
                        elif actual_type.lower() == b"xml ":
                            actual_type = b"xml "
                        if actual_type in {b"Exif", b"xml "}:
                            decoded = _decompress_brotli_metadata(payload[4:])
                            if decoded is None:
                                compressed_metadata_unreadable = True
                                payload = b""
                            else:
                                payload = decoded
                    if actual_type == b"Exif" and payload:
                        exif_box = payload
                    elif actual_type == b"xml " and payload:
                        xmp_box = payload
                offset += box_size
                if size32 == 0:
                    break
            return exif_box, xmp_box, compressed_metadata_unreadable
    except (OSError, OverflowError, struct.error):
        return None, None, False


def _jxl_exif_tiff_payload(exif_box: bytes) -> bytes | None:
    """Resolve a JXL Exif box's TIFF payload, tolerating legacy prefixes.

    JPEG XL stores a big-endian TIFF-header offset followed by an Exif
    payload.  Well-formed files either point directly at the TIFF byte-order
    marker or skip a conventional ``Exif\0\0`` prefix.  A few writers retained
    that prefix while leaving the offset at zero, so accept it as well.
    """
    if len(exif_box) < 12:
        return None
    tiff_offset = int.from_bytes(exif_box[:4], "big")
    payload = exif_box[4:]
    if tiff_offset > len(payload) - 8:
        return None
    candidate = payload[tiff_offset:]
    if candidate.startswith(b"Exif\x00\x00"):
        candidate = candidate[6:]
    if candidate[:4] not in {b"II*\x00", b"MM\x00*"}:
        # Some early encoders calculated the offset from the full box rather
        # than from the Exif payload.  Limit recovery to the small prefix area
        # where a TIFF header is allowed to occur; never scan arbitrary data.
        prefix = payload[: min(len(payload), 64)]
        positions = [
            position
            for marker in (b"II*\x00", b"MM\x00*")
            if (position := prefix.find(marker)) >= 0
        ]
        if not positions:
            return None
        candidate = payload[min(positions):]
    return candidate


def _tiff_ifd0_ascii(tiff: bytes, tag_id: int) -> str | None:
    """Read one bounded ASCII/BYTE value directly from a classic TIFF IFD0.

    Pillow remains the primary EXIF decoder.  This deliberately narrow reader
    covers identity strings such as Artist when a tolerant metadata tool can
    read an IFD that Pillow declines to expose.  It does not follow nested
    directories, execute maker-note offsets, or allocate from untrusted sizes.
    """
    if len(tiff) < 8 or len(tiff) > _MAX_JXL_METADATA_BOX_BYTES:
        return None
    if tiff[:2] == b"II":
        byte_order = "little"
    elif tiff[:2] == b"MM":
        byte_order = "big"
    else:
        return None

    def unsigned(offset: int, width: int) -> int | None:
        end = offset + width
        if offset < 0 or end > len(tiff):
            return None
        return int.from_bytes(tiff[offset:end], byte_order)

    if unsigned(2, 2) != 42:
        return None
    ifd_offset = unsigned(4, 4)
    if ifd_offset is None or ifd_offset > len(tiff) - 2:
        return None
    entry_count = unsigned(ifd_offset, 2)
    if entry_count is None:
        return None
    # Classic TIFF entries are 12 bytes.  Cap the walk independently of the
    # advertised count so malformed files cannot cause excessive work.
    available_entries = max(0, (len(tiff) - ifd_offset - 2) // 12)
    for index in range(min(entry_count, available_entries, 4096)):
        entry_offset = ifd_offset + 2 + index * 12
        entry_tag = unsigned(entry_offset, 2)
        if entry_tag != tag_id:
            continue
        value_type = unsigned(entry_offset + 2, 2)
        value_count = unsigned(entry_offset + 4, 4)
        if value_type not in {1, 2, 7} or value_count is None:
            return None
        if value_count <= 0 or value_count > 4096:
            return None
        if value_count <= 4:
            raw = tiff[entry_offset + 8:entry_offset + 8 + value_count]
        else:
            value_offset = unsigned(entry_offset + 8, 4)
            if value_offset is None or value_offset > len(tiff) - value_count:
                return None
            raw = tiff[value_offset:value_offset + value_count]
        return _exif_text(raw.rstrip(b"\x00"))
    return None


def _jxl_container_metadata(
    path: Path,
) -> tuple[
    dict[int, Any],
    dict[int, Any],
    str | None,
    str | None,
    bool,
]:
    """Decode TIFF/XMP metadata independently of the JXL pixel decoder."""
    exif_box, xmp_box, compressed_metadata_unreadable = _jxl_metadata_boxes(
        path
    )
    values: dict[int, Any] = {}
    thumbnail_values: dict[int, Any] = {}
    tiff = _jxl_exif_tiff_payload(exif_box) if exif_box is not None else None
    if tiff is not None:
        try:
            exif = Image.Exif()
            exif.load(tiff)
            values.update(exif.items())
            try:
                values.update(exif.get_ifd(34665))
            except (OSError, TypeError, ValueError, KeyError):
                pass
            try:
                thumbnail_values.update(exif.get_ifd(-1))
            except (OSError, TypeError, ValueError, KeyError):
                pass
        except (OSError, SyntaxError, TypeError, ValueError):
            pass
        # ExifTool reports tag 315 as [IFD0] Artist.  Read that one identity
        # field directly when Pillow omitted it, while retaining Pillow for all
        # normal EXIF decoding and formatting.
        direct_artist = _tiff_ifd0_ascii(tiff, 315)
        if direct_artist and not _metadata_text(values.get(315)):
            values[315] = direct_artist
    album, artist = _xmp_packet_album_artist(xmp_box)
    return (
        values,
        thumbnail_values,
        album,
        artist,
        compressed_metadata_unreadable,
    )


_EXIFTOOL_METADATA_TAGS = (
    "Album",
    "Artist",
    "Make",
    "Model",
    "LensModel",
    "LensID",
    "LensSpec",
    "LensMake",
    "FocalLength",
    "MaxApertureValue",
    "ExposureTime",
    "ISO",
    "DateTimeOriginal",
    "CreateDate",
    "ModifyDate",
    "OffsetTimeOriginal",
)
_exiftool_metadata_local = threading.local()
_exiftool_metadata_bridges_lock = threading.Lock()
_exiftool_metadata_bridges: set[Any] = set()


class _ExifToolMetadataBridge:
    """One persistent ExifTool process used by a metadata worker thread."""

    def __init__(self, executable: str) -> None:
        self._lock = threading.Lock()
        self._sequence = 0
        self._closed = False
        self._output: queue.Queue[str | None] = queue.Queue()
        self._process = subprocess.Popen(
            [executable, "-stay_open", "True", "-@", "-"],
            stdin=subprocess.PIPE,
            stdout=subprocess.PIPE,
            stderr=subprocess.DEVNULL,
            text=True,
            encoding="utf-8",
            errors="replace",
            bufsize=1,
            start_new_session=(os.name == "posix"),
        )
        with _RUNTIME.active_processes_lock:
            _RUNTIME.active_processes.add(self._process)
        self._reader = threading.Thread(
            target=self._read_output,
            name="gallery-exiftool-output",
            daemon=True,
        )
        self._reader.start()

    @property
    def usable(self) -> bool:
        return not self._closed and self._process.poll() is None

    def _read_output(self) -> None:
        stream = self._process.stdout
        if stream is None:
            self._output.put(None)
            return
        try:
            for line in stream:
                self._output.put(line)
        finally:
            self._output.put(None)

    def query(self, path: Path) -> dict[str, Any] | None:
        """Return selected numeric metadata for one file, with a timeout."""
        absolute = str(path.resolve())
        if "\n" in absolute or "\r" in absolute:
            return None
        with self._lock:
            if not self.usable or self._process.stdin is None:
                return None
            check_cancelled()
            self._sequence += 1
            request_id = self._sequence
            marker = f"{{ready{request_id}}}"
            arguments = [
                "-json",
                "-n",
                "-charset",
                "filename=UTF8",
                *[f"-{tag}" for tag in _EXIFTOOL_METADATA_TAGS],
                absolute,
                f"-execute{request_id}",
            ]
            try:
                self._process.stdin.write("\n".join(arguments) + "\n")
                self._process.stdin.flush()
            except (BrokenPipeError, OSError):
                self.close()
                return None

            chunks: list[str] = []
            deadline = time.monotonic() + 30.0
            while True:
                check_cancelled()
                remaining = deadline - time.monotonic()
                if remaining <= 0:
                    self.close()
                    return None
                try:
                    line = self._output.get(timeout=min(0.25, remaining))
                except queue.Empty:
                    continue
                if line is None:
                    self.close()
                    return None
                if marker in line:
                    prefix, _marker, _suffix = line.partition(marker)
                    chunks.append(prefix)
                    break
                chunks.append(line)

            payload = "".join(chunks)
            start = payload.find("[")
            end = payload.rfind("]")
            if start < 0 or end < start:
                return None
            try:
                decoded = json.loads(payload[start:end + 1])
            except (json.JSONDecodeError, TypeError, ValueError):
                return None
            if not isinstance(decoded, list) or not decoded:
                return None
            record = decoded[0]
            return record if isinstance(record, dict) else None

    def close(self) -> None:
        with self._lock:
            if self._closed:
                return
            self._closed = True
            process = self._process
            try:
                if process.poll() is None and process.stdin is not None:
                    process.stdin.write("-stay_open\nFalse\n")
                    process.stdin.flush()
                    process.stdin.close()
            except (BrokenPipeError, OSError):
                pass
            try:
                process.wait(timeout=2.0)
            except subprocess.TimeoutExpired:
                try:
                    if os.name == "posix":
                        os.killpg(process.pid, signal.SIGTERM)
                    else:
                        process.terminate()
                    process.wait(timeout=1.0)
                except (OSError, ProcessLookupError, subprocess.TimeoutExpired):
                    try:
                        process.kill()
                    except (OSError, ProcessLookupError):
                        pass
            finally:
                self._reader.join(timeout=1.0)
                for stream in (process.stdin, process.stdout):
                    if stream is not None and not stream.closed:
                        try:
                            stream.close()
                        except OSError:
                            pass
                with _RUNTIME.active_processes_lock:
                    _RUNTIME.active_processes.discard(process)


def _thread_exiftool_metadata_bridge() -> _ExifToolMetadataBridge | None:
    """Return a reusable ExifTool bridge local to the current worker."""
    bridge = getattr(_exiftool_metadata_local, "bridge", None)
    if isinstance(bridge, _ExifToolMetadataBridge) and bridge.usable:
        return bridge
    executable = shutil.which("exiftool")
    if not executable:
        return None
    try:
        bridge = _ExifToolMetadataBridge(executable)
    except OSError:
        return None
    _exiftool_metadata_local.bridge = bridge
    with _exiftool_metadata_bridges_lock:
        _exiftool_metadata_bridges.add(bridge)
    return bridge


def _close_exiftool_metadata_bridges() -> None:
    """Close every per-worker ExifTool process created by the current build."""
    with _exiftool_metadata_bridges_lock:
        bridges = tuple(_exiftool_metadata_bridges)
        _exiftool_metadata_bridges.clear()
    for bridge in bridges:
        bridge.close()


def _exiftool_display_metadata(record: dict[str, Any]) -> list[str | None]:
    """Convert one numeric ExifTool JSON object to the lightbox schema."""
    lens_model = (
        _metadata_text(record.get("LensModel"))
        or _metadata_text(record.get("LensID"))
    )
    lens_spec_value = record.get("LensSpec")
    lens_spec = _format_lens_specification(lens_spec_value)
    if lens_spec is None:
        lens_spec = _metadata_text(lens_spec_value)
    lens_make = _metadata_text(record.get("LensMake"))
    if lens_model:
        lens = lens_model
    elif lens_make and lens_spec:
        lens = f"{lens_make} {lens_spec}"
    else:
        lens = lens_make or lens_spec

    creation_values = {
        36867: record.get("DateTimeOriginal"),
        36868: record.get("CreateDate"),
        306: record.get("ModifyDate"),
        36881: record.get("OffsetTimeOriginal"),
    }
    return [
        _metadata_text(record.get("Album")),
        _metadata_text(record.get("Artist")),
        _metadata_text(record.get("Make")),
        _metadata_text(record.get("Model")),
        lens,
        _format_focal_length(record.get("FocalLength")),
        _format_max_aperture(record.get("MaxApertureValue")),
        _format_exposure_time(record.get("ExposureTime")),
        _format_iso(record.get("ISO")),
        _format_creation_datetime(creation_values),
    ]


def _exiftool_jxl_metadata(path: Path) -> list[str | None]:
    """Read compressed JXL metadata through ExifTool when Brotli is absent."""
    bridge = _thread_exiftool_metadata_bridge()
    if bridge is None:
        return []
    record = bridge.query(path)
    return _exiftool_display_metadata(record) if record is not None else []


def extract_video_identity_metadata(
    path: Path,
    details: Mapping[str, Any],
) -> list[str | None]:
    """Return Album and Artist for a video, with an ExifTool fallback."""
    result = [
        _metadata_text(details.get("album")),
        _metadata_text(details.get("artist")),
    ]
    if any(value is None for value in result):
        bridge = _thread_exiftool_metadata_bridge()
        record = bridge.query(path) if bridge is not None else None
        if record is not None:
            external = _exiftool_display_metadata(record)
            result = [
                value if value is not None else external[index]
                for index, value in enumerate(result)
            ]
    return result


def extract_display_exif(path: Path) -> list[str | None]:
    """Extract lightbox metadata without decoding source image pixels.

    Malformed or unsupported EXIF/XMP is non-fatal. Positions are album,
    artist, make, model, lens, focal length, maximum aperture, exposure time,
    ISO, and creation date/time. Trailing nulls are removed when gallery
    chunks are generated.
    """
    suffix = path.suffix.casefold()
    if suffix in VECTOR_EXTENSIONS:
        return []
    values: dict[int, Any] = {}
    thumbnail_values: dict[int, Any] = {}
    xmp_album: str | None = None
    xmp_artist: str | None = None
    container_album: str | None = None
    container_artist: str | None = None
    iptc_artist: str | None = None
    compressed_jxl_metadata_unreadable = False
    if suffix == ".jxl":
        (
            values,
            thumbnail_values,
            xmp_album,
            xmp_artist,
            compressed_jxl_metadata_unreadable,
        ) = _jxl_container_metadata(path)
    direct_jxl_metadata = bool(
        values or thumbnail_values or xmp_album or xmp_artist
    )
    if not (suffix == ".jxl" and direct_jxl_metadata):
        try:
            with Image.open(path) as image:
                exif = image.getexif()
                pillow_values: dict[int, Any] = dict(exif.items())
                pillow_xmp_album, pillow_xmp_artist = _xmp_album_artist(image)
                xmp_album = pillow_xmp_album or xmp_album
                xmp_artist = pillow_xmp_artist or xmp_artist
                container_album = _container_text_value(image, "album")
                container_artist = _container_text_value(
                    image,
                    "artist",
                    "author",
                )
                try:
                    pillow_values.update(exif.get_ifd(34665))
                except (OSError, TypeError, ValueError, KeyError):
                    pass
                values.update(pillow_values)
                try:
                    thumbnail_values.update(exif.get_ifd(-1))
                except (OSError, TypeError, ValueError, KeyError):
                    pass
                if not (
                    _metadata_text(values.get(315))
                    or _metadata_text(thumbnail_values.get(315))
                    or xmp_artist
                    or container_artist
                ):
                    iptc_artist = _iptc_artist(image)
        except (KeyboardInterrupt, ProcessingCancelled):
            raise
        except PIL_EXTERNAL_DECODER_ERRORS + (TypeError, SyntaxError):
            if (
                not compressed_jxl_metadata_unreadable
                and not (values or thumbnail_values or xmp_album or xmp_artist)
            ):
                return []

    lens_model = _exif_text(values.get(42036))
    lens_spec = _format_lens_specification(values.get(42034))
    lens_make = _exif_text(values.get(42035))
    if lens_model:
        lens = lens_model
    elif lens_make and lens_spec:
        lens = f"{lens_make} {lens_spec}"
    else:
        lens = lens_make or lens_spec

    iso_value = values.get(34855)
    if iso_value is None:
        iso_value = values.get(34867)

    result = [
        xmp_album or container_album,
        (
            _metadata_text(values.get(315))
            or _metadata_text(thumbnail_values.get(315))
            or xmp_artist
            or container_artist
            or iptc_artist
        ),
        _exif_text(values.get(271)),
        _exif_text(values.get(272)),
        lens,
        _format_focal_length(values.get(37386)),
        _format_max_aperture(values.get(37381)),
        _format_exposure_time(values.get(33434)),
        _format_iso(iso_value),
        _format_creation_datetime(values),
    ]
    if compressed_jxl_metadata_unreadable:
        external = _exiftool_jxl_metadata(path)
        if external:
            result = [
                value if value not in {None, ""} else external[index]
                for index, value in enumerate(result)
            ]
    return result


def safe_raster_for_output(
    image: Image.Image,
    output_format: str,
) -> Image.Image:
    """Normalize pixels to a mode suitable for the selected static encoder."""
    image = ImageOps.exif_transpose(image)

    if output_format == "jpeg":
        if image.mode in {"RGB", "L"}:
            return image

        has_alpha = "A" in image.getbands() or (
            image.mode == "P" and "transparency" in image.info
        )
        if has_alpha:
            rgba = image.convert("RGBA")
            try:
                background = Image.new("RGB", rgba.size, "white")
                background.paste(rgba, mask=rgba.getchannel("A"))
                return background
            finally:
                rgba.close()

        return image.convert("RGB")

    if image.mode in {"RGB", "RGBA", "L"}:
        return image
    if image.mode == "P":
        return image.convert("RGBA" if "transparency" in image.info else "RGB")
    if "A" in image.getbands():
        return image.convert("RGBA")
    return image.convert("RGB")


def compatible_icc_profile(
    image: Image.Image,
    icc_profile: bytes | bytearray | memoryview | None,
) -> bytes | None:
    """Return the ICC profile only when its data color space matches image.mode.

    ICC profiles declare their input/data color space in bytes 16..19 of the
    profile header. Keeping a GRAY/CMYK profile after converting pixels to RGB
    creates an invalid PNG/JXL combination; libpng/cjxl will reject it.

    For generated gallery derivatives we preserve compatible profiles and drop
    incompatible or malformed ones. The latter is preferable to emitting a
    formally invalid image, and avoids the common:

        iCCP: profile ... 'GRAY': Gray color space not permitted on RGB PNG
    """
    if not icc_profile:
        return None

    try:
        profile = bytes(icc_profile)
    except (TypeError, ValueError):
        return None

    if len(profile) < 20:
        return None

    color_space = profile[16:20]
    mode = image.mode

    if mode in {"RGB", "RGBA"}:
        return profile if color_space == b"RGB " else None
    if mode in {"L", "LA", "1"}:
        return profile if color_space == b"GRAY" else None
    if mode == "CMYK":
        return profile if color_space == b"CMYK" else None

    # Generated still derivatives should normally have been normalized to one
    # of the modes above. Be conservative for any unexpected Pillow mode.
    return None


def derivative_widths(source_width: int, requested: list[int]) -> list[int]:
    widths = [width for width in requested if width < source_width]
    widths.append(source_width if source_width <= requested[-1] else requested[-1])
    return sorted(set(widths))


def prepare_shared_resize_source(
    source: Image.Image,
    max_width: int,
) -> tuple[Image.Image, bool]:
    """Integer-reduce once before producing several final-width variants."""
    max_width = max(1, int(max_width))
    factor = max(1, source.width // max_width)
    while factor > 1 and source.width // factor < max_width:
        factor -= 1
    if factor <= 1:
        return source, False
    return source.reduce(factor), True


def _asset_lock(
    resources: BuildExecutionResources,
    key: str,
) -> threading.Lock:
    """Return one run-scoped lock for a content-addressed output path."""
    return resources.asset_locks.for_key(key)


def write_jxl_variant(
    source: Image.Image,
    dst: Path,
    width: int,
    quality: int,
    effort: int,
    icc_profile: bytes | None,
    toolchain: ToolchainCapabilities | None = None,
    resources: BuildExecutionResources | None = None,
) -> tuple[int, int]:
    src_width, src_height = source.size
    if width >= src_width:
        resized = source.copy()
    else:
        height = max(1, round(src_height * width / src_width))
        resized = source.resize(
            (width, height),
            resample=Image.Resampling.LANCZOS,
            reducing_gap=3.0,
        )

    out_width, out_height = resized.size
    ensure_output_directory(dst.parent)

    fd, tmp_name = tempfile.mkstemp(
        prefix=f".{dst.name}.", suffix=".tmp.jxl", dir=str(dst.parent)
    )
    os.close(fd)
    tmp = Path(tmp_name)
    input_fd, input_name = tempfile.mkstemp(
        prefix="cjxl-input-",
        suffix=".png",
        dir=str(_RUNTIME.run_temp_dir) if _RUNTIME.run_temp_dir is not None else None,
    )
    os.close(input_fd)
    input_path = Path(input_name)

    try:
        resized.info.pop("icc_profile", None)
        with encoder_slot(resources):
            resized.save(
                input_path,
                format="PNG",
                icc_profile=icc_profile,
                compress_level=0,
            )
            encode_jxl_with_cjxl(
                input_path,
                tmp,
                quality,
                effort,
                toolchain,
            )
        if not generated_file_usable(tmp):
            raise ValueError("cjxl produced an empty JPEG XL output")

        os.replace(tmp, dst)
        set_output_file_mode(dst)
    finally:
        resized.close()
        try:
            tmp.unlink()
        except FileNotFoundError:
            pass
        try:
            input_path.unlink()
        except FileNotFoundError:
            pass

    return out_width, out_height


def write_pillow_variant(
    source: Image.Image,
    dst: Path,
    width: int,
    *,
    temporary_suffix: str,
    format_label: str,
    save_kwargs: dict[str, Any],
    icc_profile: bytes | None,
    resources: BuildExecutionResources | None = None,
) -> tuple[int, int]:
    """Resize and atomically publish one Pillow-encoded derivative."""
    src_width, src_height = source.size
    if width >= src_width:
        resized = source.copy()
    else:
        height = max(1, round(src_height * width / src_width))
        resized = source.resize(
            (width, height),
            resample=Image.Resampling.LANCZOS,
            reducing_gap=3.0,
        )

    out_width, out_height = resized.size
    ensure_output_directory(dst.parent)

    fd, tmp_name = tempfile.mkstemp(
        prefix=f".{dst.name}.", suffix=temporary_suffix, dir=str(dst.parent)
    )
    os.close(fd)
    tmp = Path(tmp_name)

    try:
        resized.info.pop("icc_profile", None)
        if icc_profile:
            save_kwargs["icc_profile"] = icc_profile

        with encoder_slot(resources):
            resized.save(tmp, **save_kwargs)
        if not generated_file_usable(tmp):
            raise ValueError(f"Pillow produced an empty {format_label} output")

        os.replace(tmp, dst)
        set_output_file_mode(dst)
    finally:
        resized.close()
        try:
            tmp.unlink()
        except FileNotFoundError:
            pass

    return out_width, out_height


def write_webp_variant(
    source: Image.Image,
    dst: Path,
    width: int,
    quality: int,
    effort: int,
    icc_profile: bytes | None,
    toolchain: ToolchainCapabilities | None = None,
    resources: BuildExecutionResources | None = None,
) -> tuple[int, int]:
    del toolchain
    return write_pillow_variant(
        source,
        dst,
        width,
        temporary_suffix=".tmp.webp",
        format_label="WebP",
        save_kwargs={
            "format": "WEBP",
            "quality": int(quality),
            "method": webp_method_from_effort(effort),
        },
        icc_profile=icc_profile,
        resources=resources,
    )


def write_png_variant(
    source: Image.Image,
    dst: Path,
    width: int,
    quality: int,
    effort: int,
    icc_profile: bytes | None,
    toolchain: ToolchainCapabilities | None = None,
    resources: BuildExecutionResources | None = None,
) -> tuple[int, int]:
    del toolchain
    del quality  # PNG output is lossless; --quality does not alter PNG bytes.
    return write_pillow_variant(
        source,
        dst,
        width,
        temporary_suffix=".tmp.png",
        format_label="PNG",
        save_kwargs={
            "format": "PNG",
            "compress_level": min(9, max(1, int(effort))),
        },
        icc_profile=icc_profile,
        resources=resources,
    )


def write_jpeg_variant(
    source: Image.Image,
    dst: Path,
    width: int,
    quality: int,
    effort: int,
    icc_profile: bytes | None,
    toolchain: ToolchainCapabilities | None = None,
    resources: BuildExecutionResources | None = None,
) -> tuple[int, int]:
    del toolchain
    return write_pillow_variant(
        source,
        dst,
        width,
        temporary_suffix=".tmp.jpg",
        format_label="JPEG",
        save_kwargs={
            "format": "JPEG",
            "quality": int(quality),
            "optimize": int(effort) >= 5,
        },
        icc_profile=icc_profile,
        resources=resources,
    )


def write_avif_variant(
    source: Image.Image,
    dst: Path,
    width: int,
    quality: int,
    effort: int,
    icc_profile: bytes | None,
    toolchain: ToolchainCapabilities | None = None,
    resources: BuildExecutionResources | None = None,
) -> tuple[int, int]:
    capabilities = toolchain or resolved_toolchain_capabilities()
    return write_pillow_variant(
        source,
        dst,
        width,
        temporary_suffix=".tmp.avif",
        format_label="AVIF",
        save_kwargs={
            "format": "AVIF",
            "quality": int(quality),
            "speed": avif_speed_from_effort(effort),
            "max_threads": capabilities.encoder_threads,
        },
        icc_profile=icc_profile,
        resources=resources,
    )


STATIC_ENCODER_BACKENDS = {
    backend.name: backend
    for backend in (
        StaticEncoderBackend("jxl", ".jxl", None, write_jxl_variant),
        StaticEncoderBackend("webp", ".webp", "WEBP", write_webp_variant),
        StaticEncoderBackend("png", ".png", "PNG", write_png_variant),
        StaticEncoderBackend("jpeg", ".jpg", "JPEG", write_jpeg_variant),
        StaticEncoderBackend("avif", ".avif", "AVIF", write_avif_variant),
    )
}


def write_static_variant(
    source: Image.Image,
    dst: Path,
    width: int,
    quality: int,
    effort: int,
    icc_profile: bytes | None,
    output_format: str,
    toolchain: ToolchainCapabilities | None = None,
    resources: BuildExecutionResources | None = None,
) -> tuple[int, int]:
    try:
        backend = STATIC_ENCODER_BACKENDS[output_format]
    except KeyError as exc:
        raise ValueError(
            f"unsupported static output format: {output_format}"
        ) from exc
    return backend.writer(
        source,
        dst,
        width,
        quality,
        effort,
        icc_profile,
        toolchain,
        resources,
    )


def shard_parts(digest: str) -> list[str]:
    parts: list[str] = []
    offset = 0
    for _ in range(ASSET_SHARD_LEVELS):
        part = digest[offset : offset + ASSET_SHARD_WIDTH]
        if not part:
            break
        parts.append(part)
        offset += ASSET_SHARD_WIDTH
    return parts


def sharded_asset_relative_path(filename: str, digest: str) -> str:
    return "/".join([ASSET_DIRNAME, *shard_parts(digest), filename])


def legacy_flat_relative_path(filename: str) -> str:
    return f"{ASSET_DIRNAME}/{filename}"


def migrate_legacy_asset_if_present(output_dir: Path, rel: str) -> bool:
    """
    Migrate an older generated asset into the current assets/ layout.

    This handles both the former gallery-assets/ root and the still-older flat
    asset layout, allowing upgrades to reuse existing derivative bytes instead
    of re-encoding images solely because the public bundle path changed.

    Returns True when a legacy asset was moved into place.
    """
    dst = output_dir / rel
    if generated_file_usable(dst):
        return False

    rel_path = Path(rel)
    filename = rel_path.name
    candidates: list[Path] = []

    # Current-root flat layout from older sharding migrations.
    current_flat = output_dir / legacy_flat_relative_path(filename)
    if current_flat != dst:
        candidates.append(current_flat)

    # Exact sharded path under the former gallery-assets/ root.
    try:
        asset_relative = rel_path.relative_to(ASSET_DIRNAME)
    except ValueError:
        asset_relative = None
    if asset_relative is not None:
        candidates.append(
            output_dir / LEGACY_ASSET_DIRNAME / asset_relative
        )

    # Still-older flat file under gallery-assets/.
    candidates.append(
        output_dir / LEGACY_ASSET_DIRNAME / filename
    )

    seen: set[Path] = set()
    for src in candidates:
        if src in seen:
            continue
        seen.add(src)
        if not generated_file_usable(src):
            continue

        ensure_output_directory(dst.parent)
        os.replace(src, dst)
        set_output_file_mode(dst)
        return True

    return False


def expected_static_variants(
    digest: str,
    source_width: int,
    widths: Sequence[int],
    output_format: str,
) -> list[StaticVariant]:
    extension = static_extension(output_format)
    variants: list[StaticVariant] = []
    for width in derivative_widths(source_width, widths):
        filename = f"{digest[:24]}-w{width}{extension}"
        rel = sharded_asset_relative_path(filename, digest)
        variants.append({"width": width, "path": rel})
    return variants


def expected_passthrough_asset(digest: str, suffix: str) -> str:
    suffix = suffix.casefold()
    if not suffix.startswith("."):
        suffix = "." + suffix
    filename = f"{digest[:24]}{suffix}"
    return sharded_asset_relative_path(filename, digest)


def expected_original_asset(digest: str, suffix: str) -> str:
    """Return the content-addressed path used for an original static source."""
    suffix = suffix.casefold()
    if suffix and not suffix.startswith("."):
        suffix = "." + suffix
    filename = f"{digest[:24]}-original{suffix}"
    return sharded_asset_relative_path(filename, digest)


def ensure_original_asset(
    source: Path,
    output_dir: Path,
    digest: str,
    suffix: str,
    resources: BuildExecutionResources,
    trust_existing: bool = False,
) -> tuple[str, bool]:
    """Ensure an exact byte-for-byte copy of a static source exists."""
    rel = expected_original_asset(digest, suffix)
    dst = output_dir / rel
    lock = _asset_lock(resources, str(dst))
    with lock:
        needs_copy = False if trust_existing else not generated_file_usable(dst)
        if needs_copy:
            atomic_copy(source, dst)
    return rel, needs_copy


def generated_file_usable(path: Path) -> bool:
    """Return True only for a non-empty generated regular file.

    Atomic writes make truncated output uncommon, but a zero-length derivative
    can still be left behind by disk/full-filesystem failures or manual damage.
    Treat those files as missing so the normal generation path repairs them.
    """
    try:
        return path.is_file() and path.stat().st_size > 0
    except OSError:
        return False


def image_pixel_data(image: Image.Image) -> Iterable[Any]:
    """Use Pillow's current pixel API with a quiet compatibility fallback."""
    flattened = getattr(image, "get_flattened_data", None)
    if callable(flattened):
        return flattened()
    with warnings.catch_warnings():
        warnings.simplefilter("ignore", DeprecationWarning)
        return image.getdata()


def average_color_from_image(image: Image.Image) -> str | None:
    """Return an alpha-weighted representative RGB color for a raster."""
    oriented: Image.Image | None = None
    rgba: Image.Image | None = None
    try:
        oriented = ImageOps.exif_transpose(image)
        rgba = oriented.convert("RGBA")
        rgba.thumbnail((16, 16), Image.Resampling.BOX)
        red = green = blue = alpha_total = 0
        for r, g, b, alpha in image_pixel_data(rgba):
            if alpha <= 0:
                continue
            red += int(r) * int(alpha)
            green += int(g) * int(alpha)
            blue += int(b) * int(alpha)
            alpha_total += int(alpha)
        if alpha_total <= 0:
            return None
        return "#{:02x}{:02x}{:02x}".format(
            round(red / alpha_total),
            round(green / alpha_total),
            round(blue / alpha_total),
        )
    except PIL_EXTERNAL_DECODER_ERRORS:
        return None
    finally:
        if rgba is not None:
            rgba.close()
        if oriented is not None and oriented is not image:
            oriented.close()


def average_color_from_file(path: Path) -> str | None:
    try:
        with Image.open(path) as image:
            if bool(getattr(image, "is_animated", False)):
                try:
                    image.seek(0)
                except EOFError:
                    pass
            return average_color_from_image(image)
    except PIL_EXTERNAL_DECODER_ERRORS:
        return None


def _content_cache_connection(
    resources: BuildExecutionResources,
    cache_path: Path,
) -> sqlite3.Connection:
    """Return one read-only cache connection per processing thread."""
    local = resources.content_cache_local
    connection = getattr(local, "connection", None)
    connection_path = getattr(local, "path", None)
    normalized_path = str(cache_path.resolve())
    if connection is None or connection_path != normalized_path:
        if connection is not None:
            try:
                connection.close()
            except sqlite3.Error:
                pass
        connection = sqlite3.connect(
            normalized_path,
            timeout=60,
            check_same_thread=True,
        )
        connection.execute("PRAGMA busy_timeout = 60000")
        connection.execute("PRAGMA query_only = ON")
        local.connection = connection
        local.path = normalized_path
    return connection


def cached_entry_by_identity(
    resources: BuildExecutionResources,
    cache_path: Path,
    stat_result: os.stat_result,
    suffix: str,
) -> tuple[MediaEntry, str] | None:
    """Return a likely rename match plus its bounded validation fingerprint."""
    device = int(getattr(stat_result, "st_dev", 0) or 0)
    inode = int(getattr(stat_result, "st_ino", 0) or 0)
    if device <= 0 or inode <= 0:
        return None
    connection = _content_cache_connection(resources, cache_path)
    row = connection.execute(
        f"""
        SELECT
            {cache_entry_select("f")},
            i.quick_hash
        FROM file_identity AS i
        JOIN files AS f ON f.path = i.path
        WHERE i.device = ? AND i.inode = ? AND i.size = ?
          AND i.mtime_ns = ? AND f.source_suffix = ?
        ORDER BY i.updated_ns DESC
        LIMIT 1
        """,
        (
            device,
            inode,
            int(stat_result.st_size),
            int(stat_result.st_mtime_ns),
            suffix,
        ),
    ).fetchone()
    if row is None:
        return None
    entry = cache_entry_from_row(row[:len(CACHE_ENTRY_COLUMNS)])
    if entry is None:
        return None
    return entry, str(row[len(CACHE_ENTRY_COLUMNS)])


def cached_entry_by_content(
    resources: BuildExecutionResources,
    cache_path: Path,
    size: int,
    digest: str,
    suffix: str,
) -> MediaEntry | None:
    """Return reusable metadata/assets for identical bytes at another path."""
    connection = _content_cache_connection(resources, cache_path)

    row = connection.execute(
        f"""
        SELECT {cache_entry_select()}
        FROM files
        WHERE sha256 = ? AND size = ? AND source_suffix = ?
        ORDER BY updated_ns DESC
        LIMIT 1
        """,
        (digest, int(size), suffix),
    ).fetchone()
    if row is None:
        return None
    return cache_entry_from_row(row)


def process_passthrough_assets(
    context: MediaProcessingContext,
) -> MediaAssetResult:
    """Copy one animated/vector source and update its display metadata."""
    path = context.path
    old_entry = context.old_entry
    entry = context.entry
    options = context.options
    rel = expected_passthrough_asset(context.digest, context.suffix)
    dst = options.output_dir / rel
    lock = _asset_lock(options.resources, str(dst))
    trusted_existing = (
        options.trust_cached_assets
        and not options.verify_assets
        and context.unchanged
        and str((old_entry or {}).get("asset") or "") == rel
    )
    with lock:
        if trusted_existing:
            migrated = False
            needs_copy = False
        else:
            migrated = migrate_legacy_asset_if_present(options.output_dir, rel)
            needs_copy = not generated_file_usable(dst)
            if needs_copy:
                atomic_copy(path, dst)
    entry["asset"] = rel
    if options.include_originals:
        original_metadata_changed = entry.get("original_asset") != rel
        entry["original_asset"] = rel
    else:
        original_metadata_changed = "original_asset" in entry
        entry.pop("original_asset", None)

    if context.average_color_changed:
        entry["average_color"] = (
            average_color_from_file(path)
            if context.kind == "animated"
            else None
        )
        entry["average_color_version"] = AVERAGE_COLOR_VERSION

    entry["render_signature"] = options.render_signature
    return MediaAssetResult(
        entry=entry,
        changed=(
        context.content_reused
        or (not context.unchanged)
        or context.exif_changed
        or context.identity_metadata_changed
        or context.average_color_changed
        or migrated
        or needs_copy
        or original_metadata_changed
        ),
    )


def process_video_assets(
    context: MediaProcessingContext,
) -> MediaAssetResult:
    """Copy one video and maintain its optional poster/transcode assets."""
    path = context.path
    old_entry = context.old_entry
    entry = context.entry
    options = context.options
    toolchain = options.toolchain
    width = context.width
    height = context.height

    rel = expected_passthrough_asset(context.digest, context.suffix)
    dst = options.output_dir / rel
    lock = _asset_lock(options.resources, str(dst))
    trusted_existing = (
        options.trust_cached_assets
        and not options.verify_assets
        and context.unchanged
        and str((old_entry or {}).get("asset") or "") == rel
    )
    with lock:
        if trusted_existing:
            migrated = False
            needs_copy = False
        else:
            migrated = migrate_legacy_asset_if_present(options.output_dir, rel)
            needs_copy = not generated_file_usable(dst)
            if needs_copy:
                atomic_copy(path, dst)
    entry["asset"] = rel

    transcode_generated = False
    transcode_required = bool(entry.get("video_compatibility_note"))
    if options.transcode_incompatible_videos and transcode_required:
        transcode_rel = expected_video_transcode_asset(context.digest)
        transcode_dst = options.output_dir / transcode_rel
        transcode_ready = generated_file_usable(transcode_dst)
        transcode_invalid = False
        if transcode_ready and options.verify_assets:
            try:
                _tw, _th, transcoded_details = video_metadata(
                    transcode_dst,
                    toolchain,
                )
                if transcoded_details.get("compatibility_note"):
                    raise ValueError(
                        "compatibility derivative is not browser-portable"
                    )
            except (OSError, ValueError) as exc:
                eprint(
                    "warning: invalid video compatibility derivative "
                    f"{transcode_dst}: {exc}"
                )
                transcode_ready = False
                transcode_invalid = True

        old_transcode_status = str(
            (old_entry or {}).get("video_transcode_status") or ""
        )
        should_attempt_transcode = bool(
            not transcode_ready
            and (
                transcode_invalid
                or options.verify_assets
                or not context.stat_unchanged
                or int(
                    (old_entry or {}).get("video_transcode_version") or 0
                )
                != VIDEO_TRANSCODE_VERSION
                or old_transcode_status != "failed"
            )
        )
        if should_attempt_transcode:
            transcode_lock = _asset_lock(
                options.resources,
                str(transcode_dst),
            )
            with transcode_lock:
                if not transcode_invalid:
                    transcode_ready = generated_file_usable(transcode_dst)
                if not transcode_ready:
                    try:
                        generate_browser_compatible_video(
                            path,
                            transcode_dst,
                            context.video_details,
                            toolchain,
                            options.resources,
                        )
                        transcode_ready = True
                        transcode_generated = True
                    except (OSError, ValueError) as exc:
                        eprint(
                            "warning: could not create browser-compatible "
                            f"video for {path}: {exc}"
                        )

        entry["video_transcode_version"] = VIDEO_TRANSCODE_VERSION
        if transcode_ready:
            entry["video_transcode_asset"] = transcode_rel
            entry["video_transcode_status"] = "ready"
        else:
            entry.pop("video_transcode_asset", None)
            entry["video_transcode_status"] = "failed"
    elif options.transcode_incompatible_videos:
        entry.pop("video_transcode_asset", None)
        entry.pop("video_transcode_status", None)
        entry.pop("video_transcode_version", None)

    poster_rel = expected_video_poster(context.digest)
    poster_dst = options.output_dir / poster_rel
    poster_ready = generated_file_usable(poster_dst)
    poster_invalid = False
    if poster_ready and options.verify_assets:
        try:
            with Image.open(poster_dst) as poster:
                poster.verify()
        except (OSError, UnidentifiedImageError):
            poster_ready = False
            poster_invalid = True
    poster_generated = False
    old_poster_status = str(
        (old_entry or {}).get("video_poster_status") or ""
    )
    should_attempt_poster = bool(
        toolchain.ffmpeg_path
        and not poster_ready
        and (
            not context.stat_unchanged
            or int((old_entry or {}).get("video_poster_version") or 0)
            != VIDEO_POSTER_VERSION
            or old_poster_status != "failed"
        )
    )
    if should_attempt_poster:
        poster_lock = _asset_lock(options.resources, str(poster_dst))
        with poster_lock:
            if not poster_invalid:
                poster_ready = generated_file_usable(poster_dst)
            if not poster_ready:
                try:
                    poster_width, poster_height = generate_video_poster(
                        path,
                        poster_dst,
                        _positive_float(entry.get("duration")),
                        toolchain,
                        options.resources,
                    )
                    poster_ready = True
                    poster_generated = True
                    if not toolchain.ffprobe_path:
                        width, height = poster_width, poster_height
                except (OSError, ValueError, UnidentifiedImageError) as exc:
                    eprint(
                        f"warning: could not create video poster for {path}: {exc}"
                    )

    if poster_ready and not toolchain.ffprobe_path:
        try:
            with Image.open(poster_dst) as poster:
                poster_width, poster_height = poster.size
            if poster_width > 0 and poster_height > 0:
                width, height = int(poster_width), int(poster_height)
        except (OSError, UnidentifiedImageError):
            pass

    entry["width"] = width
    entry["height"] = height
    entry["video_poster_version"] = VIDEO_POSTER_VERSION
    if poster_ready:
        entry["poster_asset"] = poster_rel
        entry["video_poster_status"] = "ready"
    else:
        entry.pop("poster_asset", None)
        entry["video_poster_status"] = (
            "failed" if toolchain.ffmpeg_path else "unavailable"
        )

    if context.average_color_changed:
        entry["average_color"] = (
            average_color_from_file(poster_dst) if poster_ready else None
        )
        entry["average_color_version"] = AVERAGE_COLOR_VERSION

    if options.include_originals:
        original_metadata_changed = entry.get("original_asset") != rel
        entry["original_asset"] = rel
    else:
        original_metadata_changed = "original_asset" in entry
        entry.pop("original_asset", None)

    poster_metadata_changed = any(
        (old_entry or {}).get(key) != entry.get(key)
        for key in (
            "poster_asset",
            "video_poster_status",
            "video_poster_version",
            "width",
            "height",
            "duration",
            "video_codec",
            "video_profile",
            "audio_codec",
            "frame_rate",
            "video_creation_time",
            "video_pixel_format",
            "video_bits_per_raw_sample",
            "video_color_transfer",
            "video_color_primaries",
            "video_color_space",
            "video_color_range",
            "video_compatibility_note",
            "video_transcode_asset",
            "video_transcode_status",
            "video_transcode_version",
        )
    )
    entry["render_signature"] = options.render_signature
    return MediaAssetResult(
        entry=entry,
        changed=(
        context.content_reused
        or (not context.unchanged)
        or context.exif_changed
        or context.identity_metadata_changed
        or context.average_color_changed
        or migrated
        or needs_copy
        or poster_generated
        or transcode_generated
        or poster_metadata_changed
        or original_metadata_changed
        ),
    )


@dataclass(frozen=True, slots=True)
class StaticVariantPlan:
    variants: list[StaticVariant]
    encode_items: list[StaticVariant]
    migrated_any: bool
    needs_encode: bool
    settings_changed: bool


def plan_static_variant_work(
    context: MediaProcessingContext,
) -> StaticVariantPlan:
    """Resolve reusable, migrated, missing, and stale static derivatives."""
    options = context.options
    variants = expected_static_variants(
        context.digest,
        context.width,
        options.requested_widths,
        options.output_format,
    )
    previous_encoder = str(
        (context.old_entry or {}).get("static_encoder")
        or (context.old_entry or {}).get("jxl_encoder")
        or ""
    )
    encoder_changed = (
        options.output_format == "jxl" and previous_encoder != "cjxl"
    )
    settings_changed = (
        context.old_signature not in options.compatible_render_signatures
    )
    force_reencode = (
        settings_changed and not options.widths_only_settings_change
    ) or context.jpeg_draft_changed

    trusted_paths: set[str] = set()
    if (
        options.trust_cached_assets
        and not options.verify_assets
        and context.unchanged
        and not force_reencode
        and not encoder_changed
    ):
        if not settings_changed:
            trusted_paths.update(str(item["path"]) for item in variants)
        elif options.widths_only_settings_change and options.previous_widths:
            trusted_paths.update(
                str(item["path"])
                for item in expected_static_variants(
                    context.digest,
                    context.width,
                    options.previous_widths,
                    options.output_format,
                )
            )

    migrated_any = False
    missing: list[StaticVariant] = []
    if not force_reencode and not encoder_changed:
        for item in variants:
            if str(item["path"]) in trusted_paths:
                continue
            destination = options.output_dir / item["path"]
            with _asset_lock(options.resources, str(destination)):
                migrated_any = (
                    migrate_legacy_asset_if_present(
                        options.output_dir,
                        item["path"],
                    )
                    or migrated_any
                )
                if not generated_file_usable(destination):
                    missing.append(item)

    missing_paths = {str(item["path"]) for item in missing}
    encode_items = [
        item
        for item in variants
        if str(item["path"]) in missing_paths
        or force_reencode
        or encoder_changed
    ]
    return StaticVariantPlan(
        variants,
        encode_items,
        migrated_any,
        bool(encode_items),
        settings_changed,
    )


def encode_static_variants(
    context: MediaProcessingContext,
    plan: StaticVariantPlan,
) -> None:
    """Decode once and publish every derivative selected by the plan."""
    if not plan.needs_encode:
        return
    options = context.options
    max_variant_width = max(int(item["width"]) for item in plan.variants)
    cpu_parallelism = max(
        1,
        usable_cpu_count() // max(1, options.toolchain.encoder_threads),
    )
    desired_workers = min(len(plan.encode_items), 4, cpu_parallelism)
    base_bytes = max(1, context.width) * max(1, context.height) * 8
    copy_bytes = max(1, context.width) * max(1, context.height) * 4
    workers = options.resources.memory.bounded_workers(
        base_bytes,
        copy_bytes,
        desired_workers,
    )
    peak_bytes = base_bytes + (copy_bytes * workers if workers > 1 else 0)

    with options.resources.memory.reserve(peak_bytes), open_raster_image(
        context.path,
        jpeg_draft_max_width=(
            max_variant_width if context.desired_jpeg_draft else None
        ),
        toolchain=options.toolchain,
    ) as opened:
        if bool(getattr(opened, "is_animated", False)):
            try:
                opened.seek(0)
            except EOFError:
                pass
        output_image = safe_raster_for_output(opened, options.output_format)
        if context.average_color_changed:
            context.entry["average_color"] = average_color_from_image(
                output_image
            )
            context.entry["average_color_version"] = AVERAGE_COLOR_VERSION
        icc_profile = compatible_icc_profile(
            output_image,
            opened.info.get("icc_profile"),
        )
        resize_source, owns_resize_source = prepare_shared_resize_source(
            output_image,
            max_variant_width,
        )

        def encode_one(item: StaticVariant) -> None:
            check_cancelled()
            destination = options.output_dir / item["path"]
            with _asset_lock(options.resources, str(destination)):
                local_source = (
                    resize_source.copy() if workers > 1 else resize_source
                )
                try:
                    write_static_variant(
                        local_source,
                        destination,
                        int(item["width"]),
                        options.quality,
                        options.effort,
                        icc_profile,
                        options.output_format,
                        options.toolchain,
                        options.resources,
                    )
                finally:
                    if local_source is not resize_source:
                        local_source.close()

        try:
            if workers <= 1:
                for item in plan.encode_items:
                    encode_one(item)
            else:
                with concurrent.futures.ThreadPoolExecutor(
                    max_workers=workers
                ) as executor:
                    futures = [
                        executor.submit(encode_one, item)
                        for item in plan.encode_items
                    ]
                    for future in futures:
                        future.result()
        finally:
            if owns_resize_source:
                resize_source.close()
            if output_image is not opened:
                output_image.close()


def process_static_assets(
    context: MediaProcessingContext,
) -> MediaAssetResult:
    """Generate and publish responsive derivatives for one static image."""
    path = context.path
    old_entry = context.old_entry
    entry = context.entry
    options = context.options
    output_dir = options.output_dir
    output_format = options.output_format
    render_signature = options.render_signature
    include_originals = options.include_originals
    digest = context.digest
    suffix = context.suffix
    average_color_changed = context.average_color_changed
    unchanged = context.unchanged
    content_reused = context.content_reused
    exif_changed = context.exif_changed
    identity_metadata_changed = context.identity_metadata_changed

    variant_plan = plan_static_variant_work(context)
    encode_static_variants(context, variant_plan)
    variants = variant_plan.variants
    if average_color_changed and int(
        entry.get("average_color_version") or 0
    ) != AVERAGE_COLOR_VERSION:
        smallest_variant = min(
            variants,
            key=lambda item: int(item["width"]),
        )
        entry["average_color"] = average_color_from_file(
            output_dir / str(smallest_variant["path"])
        )
        entry["average_color_version"] = AVERAGE_COLOR_VERSION

    original_changed = False
    original_metadata_changed = False
    if include_originals:
        original_rel, original_changed = ensure_original_asset(
            path,
            output_dir,
            digest,
            suffix,
            options.resources,
            trust_existing=(
                options.trust_cached_assets
                and not options.verify_assets
                and unchanged
                and str((old_entry or {}).get("original_asset") or "")
                == expected_original_asset(digest, suffix)
            ),
        )
        original_metadata_changed = (
            entry.get("original_asset") != original_rel
        )
        entry["original_asset"] = original_rel
    else:
        original_metadata_changed = "original_asset" in entry
        entry.pop("original_asset", None)

    entry["variants"] = variants
    entry["render_signature"] = render_signature
    entry["static_output_format"] = output_format
    entry["static_encoder"] = static_encoder_name(output_format)
    if output_format == "jxl":
        entry["jxl_encoder"] = jxl_encoder_name(options.toolchain)
    else:
        entry.pop("jxl_encoder", None)
    return MediaAssetResult(
        entry=entry,
        changed=(
            content_reused
            or (not unchanged)
            or exif_changed
            or identity_metadata_changed
            or average_color_changed
            or variant_plan.migrated_any
            or variant_plan.needs_encode
            or variant_plan.settings_changed
            or context.jpeg_draft_changed
            or context.jpeg_draft_metadata_changed
            or original_changed
            or original_metadata_changed
        ),
    )



def probe_source_file(
    path: Path,
    old_entry: MediaEntry | None,
) -> SourceProbe | None:
    """Read cheap filesystem identity and a bounded rename fingerprint."""
    try:
        stat = path.stat()
    except OSError as exc:
        eprint(f"warning: cannot stat {path}: {exc}")
        return None

    suffix = path.suffix.casefold()
    source_device = int(getattr(stat, "st_dev", 0) or 0)
    source_inode = int(getattr(stat, "st_ino", 0) or 0)
    source_ctime_ns = int(getattr(stat, "st_ctime_ns", 0) or 0)
    source_birthtime_ns = int(
        getattr(stat, "st_birthtime_ns", 0)
        or round(float(getattr(stat, "st_birthtime", 0.0) or 0.0) * 1e9)
    )
    quick_hash = str((old_entry or {}).get("quick_hash") or "")
    same_cached_identity = bool(
        old_entry is not None
        and int(old_entry.get("source_device") or 0) == source_device
        and int(old_entry.get("source_inode") or 0) == source_inode
        and int(old_entry.get("size") or -1) == int(stat.st_size)
        and int(old_entry.get("mtime_ns") or -1) == int(stat.st_mtime_ns)
        and quick_hash
    )
    if not same_cached_identity:
        try:
            quick_hash = quick_file_fingerprint(path, stat.st_size)
        except OSError as exc:
            eprint(f"warning: cannot fingerprint {path}: {exc}")
            return None

    return SourceProbe(
        path=path,
        stat=stat,
        suffix=suffix,
        source_device=source_device,
        source_inode=source_inode,
        source_ctime_ns=source_ctime_ns,
        source_birthtime_ns=source_birthtime_ns,
        quick_hash=quick_hash,
    )


def resolve_source_cache_reuse(
    probe: SourceProbe,
    old_entry: MediaEntry | None,
    options: ProcessingOptions,
) -> CacheReuseDecision | None:
    """Resolve rename/content reuse separately from media inspection."""
    path = probe.path
    stat = probe.stat
    suffix = probe.suffix
    quick_hash = probe.quick_hash
    content_reused = False
    precomputed_digest: str | None = None

    if old_entry is None and options.cache_path is not None:
        try:
            identity_match = cached_entry_by_identity(
                options.resources,
                options.cache_path,
                stat,
                suffix,
            )
            matching_entry = None
            if identity_match is not None:
                candidate, cached_quick_hash = identity_match
                if secrets.compare_digest(quick_hash, cached_quick_hash):
                    matching_entry = candidate
                    precomputed_digest = str(candidate.get("sha256") or "")
            if matching_entry is None:
                precomputed_digest = sha256_file(path)
                matching_entry = cached_entry_by_content(
                    options.resources,
                    options.cache_path,
                    stat.st_size,
                    precomputed_digest,
                    suffix,
                )
        except (OSError, sqlite3.Error) as exc:
            eprint(f"warning: content-cache lookup failed for {path}: {exc}")
            matching_entry = None
        if matching_entry is not None:
            old_entry = matching_entry
            content_reused = True

    jxl_cache_upgrade_needed = (
        suffix == ".jxl"
        and old_entry is not None
        and old_entry.get("jxl_support_version") != JXL_SUPPORT_VERSION
    )
    video_cache_upgrade_needed = (
        suffix in VIDEO_EXTENSIONS
        and old_entry is not None
        and old_entry.get("video_metadata_version") != VIDEO_METADATA_VERSION
    )
    webp_animation_cache_upgrade_needed = webp_animation_upgrade_needed(
        suffix,
        old_entry,
    )

    stat_unchanged = (
        old_entry is not None
        and (
            content_reused
            or (
                old_entry.get("size") == stat.st_size
                and old_entry.get("mtime_ns") == stat.st_mtime_ns
                and not options.hash_all
            )
        )
    )
    unchanged = (
        stat_unchanged
        and not jxl_cache_upgrade_needed
        and not video_cache_upgrade_needed
        and not webp_animation_cache_upgrade_needed
    )
    identity_metadata_changed = any(
        (old_entry or {}).get(key) != value
        for key, value in {
            "source_device": probe.source_device,
            "source_inode": probe.source_inode,
            "source_ctime_ns": probe.source_ctime_ns,
            "source_birthtime_ns": probe.source_birthtime_ns,
            "quick_hash": quick_hash,
        }.items()
    )

    return CacheReuseDecision(
        old_entry=old_entry,
        content_reused=content_reused,
        precomputed_digest=precomputed_digest,
        stat_unchanged=stat_unchanged,
        unchanged=unchanged,
        identity_metadata_changed=identity_metadata_changed,
    )


def prepare_media_processing_context(
    probe: SourceProbe,
    decision: CacheReuseDecision,
    options: ProcessingOptions,
) -> MediaProcessingContext | None:
    """Decode only the metadata needed to dispatch one media handler."""
    path = probe.path
    stat = probe.stat
    suffix = probe.suffix
    old_entry = decision.old_entry
    stat_unchanged = decision.stat_unchanged
    unchanged = decision.unchanged
    content_reused = decision.content_reused
    webp_animation_cache_upgrade_needed = webp_animation_upgrade_needed(
        suffix,
        old_entry,
    )

    if unchanged:
        assert old_entry is not None
        entry = dict(old_entry)
        entry["source_path"] = str(path)
        entry["source_suffix"] = suffix
        digest = str(entry["sha256"])
        width = int(entry["width"])
        height = int(entry["height"])
        animated = bool(entry.get("animated", False))
        kind = str(entry["kind"])
        video_details = {
            "duration": _positive_float(entry.get("duration")),
            "video_codec": entry.get("video_codec"),
            "video_profile": entry.get("video_profile"),
            "audio_codec": entry.get("audio_codec"),
            "frame_rate": _positive_float(entry.get("frame_rate")),
            "creation_time": entry.get("video_creation_time"),
            "pixel_format": entry.get("video_pixel_format"),
            "bits_per_raw_sample": _optional_positive_int(
                entry.get("video_bits_per_raw_sample")
            ),
            "color_transfer": entry.get("video_color_transfer"),
            "color_primaries": entry.get("video_color_primaries"),
            "color_space": entry.get("video_color_space"),
            "color_range": entry.get("video_color_range"),
            "compatibility_note": entry.get("video_compatibility_note"),
        }
    else:
        try:
            if suffix in VIDEO_EXTENSIONS:
                width, height, video_details = video_metadata(
                    path,
                    options.toolchain,
                )
                if (
                    not options.toolchain.ffprobe_path
                    and stat_unchanged
                    and old_entry is not None
                ):
                    width = int(old_entry.get("width") or width)
                    height = int(old_entry.get("height") or height)
                    video_details["duration"] = _positive_float(
                        old_entry.get("duration")
                    )
                animated = False
                kind = "video"
                note = video_details.get("compatibility_note")
                if note and not options.transcode_incompatible_videos:
                    eprint(f"warning: {path}: {note}")
            elif suffix in VECTOR_EXTENSIONS:
                width, height = svg_dimensions(path)
                video_details = {}
                animated = False
                kind = "svg"
            elif suffix == ".jxl":
                # Avoid decoding JXL pixels merely to discover dimensions or
                # whether the file is animated. This bypasses Pillow-based JXL
                # decoding for animated JXL files that are passed through
                # unchanged.
                width, height, animated = jxl_metadata(path, options.toolchain)
                video_details = {}
                kind = "animated" if animated else "static"
            else:
                width, height, animated = raster_metadata(path)
                video_details = {}
                if animated:
                    kind = "animated"
                else:
                    kind = "static"

                if animated and suffix not in BROWSER_ANIMATED_EXTENSIONS:
                    eprint(
                        f"warning: {path}: animation may not be browser-portable in "
                        f"{suffix}; copying it through unchanged"
                    )
        except (OSError, ValueError, UnidentifiedImageError) as exc:
            eprint(f"warning: skipping unreadable/unsupported media {path}: {exc}")
            return None

        video_cache_upgrade_needed = (
            suffix in VIDEO_EXTENSIONS
            and old_entry is not None
            and old_entry.get("video_metadata_version")
            != VIDEO_METADATA_VERSION
        )
        if stat_unchanged and (
            video_cache_upgrade_needed
            or webp_animation_cache_upgrade_needed
        ):
            digest = str((old_entry or {})["sha256"])
        else:
            try:
                digest = decision.precomputed_digest or sha256_file(path)
            except OSError as exc:
                eprint(f"warning: cannot hash {path}: {exc}")
                return None

        entry = {
            "source_path": str(path),
            "source_suffix": suffix,
            "size": stat.st_size,
            "mtime_ns": stat.st_mtime_ns,
            "sha256": digest,
            "width": width,
            "height": height,
            "animated": bool(animated),
            "kind": kind,
        }
        if suffix == ".jxl":
            entry["jxl_support_version"] = JXL_SUPPORT_VERSION
            entry["jxl_decoder"] = jxl_decoder_name(options.toolchain)
        if suffix == ".webp":
            entry["webp_animation_version"] = WEBP_ANIMATION_VERSION
        if kind == "video":
            entry["video_metadata_version"] = VIDEO_METADATA_VERSION
            entry["duration"] = video_details.get("duration")

    entry.update(
        {
            "source_path": str(path),
            "source_suffix": suffix,
            "size": stat.st_size,
            "mtime_ns": stat.st_mtime_ns,
            "sha256": digest,
            "width": width,
            "height": height,
            "animated": bool(animated),
            "kind": kind,
            "source_device": probe.source_device,
            "source_inode": probe.source_inode,
            "source_ctime_ns": probe.source_ctime_ns,
            "source_birthtime_ns": probe.source_birthtime_ns,
            "quick_hash": probe.quick_hash,
        }
    )
    if suffix == ".jxl":
        entry["jxl_support_version"] = JXL_SUPPORT_VERSION
        entry["jxl_decoder"] = jxl_decoder_name(options.toolchain)
    if suffix == ".webp":
        entry["webp_animation_version"] = WEBP_ANIMATION_VERSION
    if kind == "video":
        entry["video_metadata_version"] = VIDEO_METADATA_VERSION
        entry["duration"] = video_details.get("duration")
        entry["video_codec"] = video_details.get("video_codec")
        entry["video_profile"] = video_details.get("video_profile")
        entry["audio_codec"] = video_details.get("audio_codec")
        entry["frame_rate"] = video_details.get("frame_rate")
        entry["video_creation_time"] = video_details.get("creation_time")
        entry["video_pixel_format"] = video_details.get("pixel_format")
        entry["video_bits_per_raw_sample"] = video_details.get(
            "bits_per_raw_sample"
        )
        entry["video_color_transfer"] = video_details.get("color_transfer")
        entry["video_color_primaries"] = video_details.get("color_primaries")
        entry["video_color_space"] = video_details.get("color_space")
        entry["video_color_range"] = video_details.get("color_range")
        entry["video_compatibility_note"] = video_details.get(
            "compatibility_note"
        )

    exif_changed = (
        not unchanged
        or int(entry.get("exif_version") or 0) != EXIF_METADATA_VERSION
    )
    if exif_changed:
        entry["exif"] = (
            extract_video_identity_metadata(path, video_details)
            if kind == "video"
            else extract_display_exif(path)
        )
        entry["exif_version"] = EXIF_METADATA_VERSION

    average_color_changed = (
        int(entry.get("average_color_version") or 0)
        != AVERAGE_COLOR_VERSION
    )

    old_signature = entry.get("render_signature")
    jpeg_source = suffix in JPEG_INPUT_EXTENSIONS and kind == "static"
    desired_jpeg_draft = (
        JPEG_DRAFT_VERSION if options.jpeg_draft and jpeg_source else 0
    )
    jpeg_draft_known = (
        old_entry is not None and "jpeg_draft" in old_entry
    )
    previous_jpeg_draft = int((old_entry or {}).get("jpeg_draft") or 0)
    jpeg_draft_changed = (
        jpeg_source
        and jpeg_draft_known
        and previous_jpeg_draft != desired_jpeg_draft
    )
    jpeg_draft_metadata_changed = (
        jpeg_source
        and old_entry is not None
        and not jpeg_draft_known
    )
    if jpeg_source:
        entry["jpeg_draft"] = desired_jpeg_draft
    else:
        entry.pop("jpeg_draft", None)

    return MediaProcessingContext(
        path=path,
        old_entry=old_entry,
        entry=entry,
        stat_unchanged=stat_unchanged,
        unchanged=unchanged,
        content_reused=content_reused,
        exif_changed=exif_changed,
        identity_metadata_changed=decision.identity_metadata_changed,
        average_color_changed=average_color_changed,
        digest=digest,
        suffix=suffix,
        width=width,
        height=height,
        kind=kind,
        video_details=video_details,
        old_signature=old_signature,
        desired_jpeg_draft=desired_jpeg_draft,
        jpeg_draft_changed=jpeg_draft_changed,
        jpeg_draft_metadata_changed=jpeg_draft_metadata_changed,
        options=options,
    )


@dataclass(frozen=True, slots=True)
class MediaHandler:
    """Registered processor for one or more probed media kinds."""

    kinds: frozenset[MediaKind]
    process: Callable[[MediaProcessingContext], MediaAssetResult]


MEDIA_HANDLERS: tuple[MediaHandler, ...] = (
    MediaHandler(frozenset({"video"}), process_video_assets),
    MediaHandler(frozenset({"animated", "svg"}), process_passthrough_assets),
    MediaHandler(frozenset({"static"}), process_static_assets),
)
MEDIA_HANDLER_BY_KIND: dict[MediaKind, MediaHandler] = {
    kind: handler
    for handler in MEDIA_HANDLERS
    for kind in handler.kinds
}


def process_one(
    path: Path,
    old_entry: MediaEntry | None,
    options: ProcessingOptions,
) -> ProcessingResult:
    """Probe, resolve cache reuse, then dispatch one media source."""
    check_cancelled()
    probe = probe_source_file(path, old_entry)
    if probe is None:
        return ProcessingResult(None, False)
    decision = resolve_source_cache_reuse(probe, old_entry, options)
    if decision is None:
        return ProcessingResult(None, False)
    context = prepare_media_processing_context(probe, decision, options)
    if context is None:
        return ProcessingResult(None, False)

    try:
        handler = MEDIA_HANDLER_BY_KIND.get(context.kind)
        if handler is None:
            raise ValueError(f"unsupported probed media kind: {context.kind}")
        result = handler.process(context)
        return ProcessingResult(result.entry, result.changed)

    except ProcessingCancelled:
        raise
    except (OSError, ValueError, UnidentifiedImageError) as exc:
        failure = MediaError(f"failed to generate assets: {exc}")
        DIAGNOSTICS.warning(
            str(failure),
            code=failure.code,
            source=path,
        )
        return ProcessingResult(None, False)


def estimated_variant_bytes(
    source_size: int,
    source_width: int,
    source_height: int,
    variant_width: int,
    output_format: str,
) -> int:
    scale = min(1.0, variant_width / max(1, source_width))
    scaled_pixels = max(
        1,
        int(source_width * source_height * scale * scale),
    )
    if output_format == "png":
        estimate = max(source_size * scale * scale, scaled_pixels * 2)
    else:
        estimate = max(16 * 1024, source_size * scale * scale)
    return max(1, int(estimate))


def plan_static_rebuild(
    plan: RebuildPlanAccumulator,
    *,
    output_dir: Path,
    stat: os.stat_result,
    suffix: str,
    old_entry: MediaEntry | None,
    is_new: bool,
    stat_changed: bool,
    probed_width: int | None,
    probed_height: int | None,
    requested_widths: Sequence[int],
    output_format: str,
    compatible_render_signatures: set[str],
    widths_only_settings_change: bool,
    jpeg_draft: bool,
    include_originals: bool,
) -> bool:
    needs_encode = is_new or stat_changed
    plan_width = int(probed_width or (old_entry or {}).get("width") or 1)
    plan_height = int(probed_height or (old_entry or {}).get("height") or 1)
    planned_variants = derivative_widths(plan_width, requested_widths)
    new_variant_widths: list[int] = []
    if needs_encode:
        plan.explain("static_changed")
        new_variant_widths = list(planned_variants)
    elif old_entry is not None:
        digest = str(old_entry.get("sha256") or "")
        width = int(old_entry.get("width") or 0)
        settings_changed = (
            old_entry.get("render_signature")
            not in compatible_render_signatures
        )
        jpeg_source = suffix in JPEG_INPUT_EXTENSIONS
        desired_jpeg_draft = (
            JPEG_DRAFT_VERSION if jpeg_draft and jpeg_source else 0
        )
        draft_known = "jpeg_draft" in old_entry
        draft_changed = bool(
            jpeg_source
            and draft_known
            and int(old_entry.get("jpeg_draft") or 0)
            != desired_jpeg_draft
        )
        previous_encoder = str(
            old_entry.get("static_encoder")
            or old_entry.get("jxl_encoder")
            or ""
        )
        encoder_changed = bool(
            output_format == "jxl" and previous_encoder != "cjxl"
        )
        force_reencode = bool(
            (settings_changed and not widths_only_settings_change)
            or draft_changed
            or encoder_changed
        )
        if force_reencode:
            needs_encode = True
            if (
                str(old_entry.get("static_output_format") or output_format)
                != output_format
            ):
                new_variant_widths = list(planned_variants)
            if settings_changed and not widths_only_settings_change:
                plan.explain("render_settings")
            if draft_changed:
                plan.explain("jpeg_draft")
            if encoder_changed:
                plan.explain("jxl_encoder")
        elif digest and width > 0:
            missing_variants = [
                item
                for item in expected_static_variants(
                    digest,
                    width,
                    requested_widths,
                    output_format,
                )
                if not generated_file_usable(output_dir / item["path"])
            ]
            if missing_variants:
                needs_encode = True
                new_variant_widths = [
                    int(item["width"]) for item in missing_variants
                ]
                plan.explain("derivative_missing")

    any_work = False
    if needs_encode:
        plan.increment("encode")
        any_work = True
        output_bytes = sum(
            estimated_variant_bytes(
                stat.st_size,
                plan_width,
                plan_height,
                variant_width,
                output_format,
            )
            for variant_width in new_variant_widths
        )
        largest_variant = max(planned_variants, default=plan_width)
        plan.add_storage(
            files=len(new_variant_widths),
            output_bytes=output_bytes,
            temp_peak_bytes=(
                plan_width * plan_height * 4
                + estimated_variant_bytes(
                    stat.st_size,
                    plan_width,
                    plan_height,
                    largest_variant,
                    output_format,
                )
            ),
        )

    if include_originals:
        needs_copy = is_new or stat_changed
        if not needs_copy and old_entry is not None:
            digest = str(old_entry.get("sha256") or "")
            rel = expected_original_asset(digest, suffix)
            needs_copy = not generated_file_usable(output_dir / rel)
        if needs_copy:
            plan.increment("copy")
            plan.explain("original_missing")
            plan.add_storage(files=1, output_bytes=stat.st_size)
            any_work = True
    return any_work


def plan_video_rebuild(
    plan: RebuildPlanAccumulator,
    *,
    output_dir: Path,
    stat: os.stat_result,
    suffix: str,
    old_entry: MediaEntry | None,
    is_new: bool,
    stat_changed: bool,
    probed_video_details: dict[str, Any] | None,
    transcode_incompatible_videos: bool,
    verify_assets: bool,
    toolchain: ToolchainCapabilities,
) -> bool:
    any_work = False
    needs_copy = is_new or stat_changed
    digest = str((old_entry or {}).get("sha256") or "")
    if not needs_copy and digest:
        rel = expected_passthrough_asset(digest, suffix)
        needs_copy = not generated_file_usable(output_dir / rel)
    if needs_copy:
        plan.increment("copy")
        plan.explain("video_missing")
        plan.add_storage(files=1, output_bytes=stat.st_size)
        any_work = True

    poster_missing = bool(
        toolchain.ffmpeg_path
        and (
            is_new
            or stat_changed
            or not digest
            or not generated_file_usable(
                output_dir / expected_video_poster(digest)
            )
        )
        and str((old_entry or {}).get("video_poster_status") or "")
        != "failed"
    )
    if poster_missing:
        plan.increment("poster")
        plan.explain("poster_missing")
        plan.add_storage(
            files=1,
            output_bytes=min(
                max(32 * 1024, stat.st_size // 100),
                1024 * 1024,
            ),
        )
        any_work = True

    compatibility_note = (
        probed_video_details.get("compatibility_note")
        if probed_video_details is not None
        else (old_entry or {}).get("video_compatibility_note")
    )
    if transcode_incompatible_videos and compatibility_note:
        old_transcode_status = str(
            (old_entry or {}).get("video_transcode_status") or ""
        )
        transcode_version_changed = (
            int((old_entry or {}).get("video_transcode_version") or 0)
            != VIDEO_TRANSCODE_VERSION
        )
        transcode_missing = bool(
            not digest
            or not generated_file_usable(
                output_dir / expected_video_transcode_asset(digest)
            )
        )
        retry_allowed = bool(
            old_transcode_status != "failed"
            or is_new
            or stat_changed
            or transcode_version_changed
            or verify_assets
        )
        if transcode_missing and retry_allowed:
            plan.increment("transcode")
            plan.explain("video_transcode")
            # Source size is a conservative estimate for the H.264/AAC copy.
            plan.add_storage(files=1, output_bytes=stat.st_size)
            any_work = True
    return any_work


def plan_passthrough_rebuild(
    plan: RebuildPlanAccumulator,
    *,
    output_dir: Path,
    stat: os.stat_result,
    suffix: str,
    old_entry: MediaEntry | None,
    is_new: bool,
    stat_changed: bool,
) -> bool:
    needs_copy = is_new or stat_changed
    if not needs_copy and old_entry is not None:
        digest = str(old_entry.get("sha256") or "")
        rel = expected_passthrough_asset(digest, suffix)
        needs_copy = not generated_file_usable(output_dir / rel)
    if not needs_copy:
        return False
    plan.increment("copy")
    plan.explain("passthrough_missing")
    plan.add_storage(files=1, output_bytes=stat.st_size)
    return True


def explain_rebuild_plan(
    cache_db: SQLiteCache,
    output_dir: Path,
    requested_widths: Sequence[int],
    output_format: str,
    compatible_render_signatures: set[str],
    widths_only_settings_change: bool,
    jpeg_draft: bool,
    hash_all: bool,
    include_originals: bool,
    transcode_incompatible_videos: bool,
    verify_assets: bool,
    toolchain: ToolchainCapabilities,
) -> RebuildPlan:
    """Build a conservative source-level plan without decoding or hashing."""
    plan = RebuildPlanAccumulator()
    for _ordinal, path, old_entry in cache_db.scan.iter_scan_entries():
        check_cancelled()
        plan.increment("scanned")
        try:
            stat = path.stat()
        except OSError:
            plan.increment("skipped")
            plan.explain("stat_failed")
            continue

        suffix = path.suffix.casefold()
        is_new = old_entry is None
        stat_changed = bool(
            old_entry is not None
            and (
                old_entry.get("size") != stat.st_size
                or old_entry.get("mtime_ns") != stat.st_mtime_ns
            )
        )
        jxl_upgrade = bool(
            suffix == ".jxl"
            and old_entry is not None
            and old_entry.get("jxl_support_version") != JXL_SUPPORT_VERSION
        )
        video_upgrade = bool(
            suffix in VIDEO_EXTENSIONS
            and old_entry is not None
            and old_entry.get("video_metadata_version")
            != VIDEO_METADATA_VERSION
        )
        webp_animation_upgrade = webp_animation_upgrade_needed(
            suffix,
            old_entry,
        )
        probed_kind: str | None = None
        probed_width: int | None = None
        probed_height: int | None = None
        probed_video_details: dict[str, Any] | None = None
        if is_new or stat_changed or video_upgrade or webp_animation_upgrade:
            try:
                if suffix in VIDEO_EXTENSIONS:
                    (
                        probed_width,
                        probed_height,
                        probed_video_details,
                    ) = video_metadata(path, toolchain)
                    probed_kind = "video"
                elif suffix in VECTOR_EXTENSIONS:
                    probed_width, probed_height = svg_dimensions(path)
                    probed_kind = "svg"
                elif suffix == ".jxl":
                    probed_width, probed_height, animated = jxl_metadata(
                        path,
                        toolchain,
                    )
                    probed_kind = "animated" if animated else "static"
                else:
                    probed_width, probed_height, animated = raster_metadata(path)
                    probed_kind = "animated" if animated else "static"
            except (OSError, ValueError, UnidentifiedImageError):
                plan.increment("skipped")
                plan.explain("unsupported")
                continue

        needs_rehash = is_new or stat_changed or hash_all or jxl_upgrade
        needs_metadata = bool(
            needs_rehash
            or video_upgrade
            or webp_animation_upgrade
            or int((old_entry or {}).get("exif_version") or 0)
            != EXIF_METADATA_VERSION
        )
        any_work = False
        if needs_rehash:
            plan.increment("rehash")
            any_work = True
            if is_new:
                plan.explain("new_source")
            elif stat_changed:
                plan.explain("source_changed")
                plan.estimated_sources += 1
            elif jxl_upgrade:
                plan.explain("jxl_metadata")
            else:
                plan.explain("hash_all")

        if needs_metadata:
            plan.increment("metadata_refresh")
            any_work = True
            if not needs_rehash:
                if video_upgrade:
                    plan.explain("video_metadata")
                elif webp_animation_upgrade:
                    plan.explain("webp_animation")
                else:
                    plan.explain("exif_metadata")

        kind = probed_kind or str((old_entry or {}).get("kind") or "static")
        if kind == "static":
            media_work = plan_static_rebuild(
                plan,
                output_dir=output_dir,
                stat=stat,
                suffix=suffix,
                old_entry=old_entry,
                is_new=is_new,
                stat_changed=stat_changed,
                probed_width=probed_width,
                probed_height=probed_height,
                requested_widths=requested_widths,
                output_format=output_format,
                compatible_render_signatures=compatible_render_signatures,
                widths_only_settings_change=widths_only_settings_change,
                jpeg_draft=jpeg_draft,
                include_originals=include_originals,
            )
        elif kind == "video":
            media_work = plan_video_rebuild(
                plan,
                output_dir=output_dir,
                stat=stat,
                suffix=suffix,
                old_entry=old_entry,
                is_new=is_new,
                stat_changed=stat_changed,
                probed_video_details=probed_video_details,
                transcode_incompatible_videos=transcode_incompatible_videos,
                verify_assets=verify_assets,
                toolchain=toolchain,
            )
        else:
            media_work = plan_passthrough_rebuild(
                plan,
                output_dir=output_dir,
                stat=stat,
                suffix=suffix,
                old_entry=old_entry,
                is_new=is_new,
                stat_changed=stat_changed,
            )
        if not (any_work or media_work):
            plan.increment("cached")

    return plan.payload()


def print_rebuild_plan(plan: RebuildPlan, output_format: str) -> None:
    """Print the dry-run plan in a compact, grep-friendly form."""
    counts = plan["counts"]
    print("Dry run: no gallery assets, metadata, or cache rows were changed.")
    print(f"Scanned:             {counts['scanned']}")
    print(f"Fully cached:        {counts['cached']}")
    print(f"Sources to encode:   {counts['encode']} ({output_format})")
    print(f"Video posters:       {counts['poster']}")
    print(f"Video transcodes:    {counts['transcode']}")
    print(f"Sources to copy:     {counts['copy']}")
    print(f"Sources to rehash:   {counts['rehash']}")
    print(f"Metadata refreshes:  {counts['metadata_refresh']}")
    print(f"Unreadable/skipped:  {counts['skipped']}")
    if int(plan.get("estimated_sources") or 0):
        print(
            "Note: encode/copy counts are conservative for sources whose "
            "size or modification time changed; content is not hashed in "
            "dry-run mode."
        )
    reasons = plan.get("reasons") or {}
    if reasons:
        print("Reasons:")
        for name, count in sorted(reasons.items()):
            print(f"  {count:>7}  {name}")


def human_bytes(value: int) -> str:
    amount = float(max(0, int(value)))
    for suffix in ("B", "KiB", "MiB", "GiB", "TiB"):
        if amount < 1024.0 or suffix == "TiB":
            return f"{amount:.1f} {suffix}" if suffix != "B" else f"{int(amount)} B"
        amount /= 1024.0
    return f"{int(value)} B"


def storage_preflight(
    output_dir: Path,
    plan: RebuildPlan,
) -> StoragePreflightResult:
    """Check projected output/temp bytes and inode demand before encoding."""
    storage = dict(plan.get("storage") or {})
    estimated_files = max(0, int(storage.get("estimated_new_files") or 0))
    estimated_output = max(0, int(storage.get("estimated_output_bytes") or 0))
    estimated_temp = max(0, int(storage.get("estimated_temp_peak_bytes") or 0))

    output_usage = shutil.disk_usage(output_dir)
    output_vfs = os.statvfs(output_dir)
    output_free_inodes = int(output_vfs.f_favail)
    inode_reporting = int(output_vfs.f_files) > 0

    temp_root = _RUNTIME.run_temp_dir or Path(tempfile.gettempdir())
    temp_usage = shutil.disk_usage(temp_root)
    same_device = output_dir.stat().st_dev == temp_root.stat().st_dev

    # Content-addressed files accumulate before stale-output cleanup. Include
    # the largest encoder staging requirement and a small fixed safety margin.
    has_planned_writes = bool(estimated_files or estimated_output or estimated_temp)
    safety_margin = 64 * 1024 * 1024 if has_planned_writes else 0
    required_output = estimated_output + safety_margin
    required_temp = estimated_temp + safety_margin
    if same_device:
        required_output += estimated_temp

    # Each new sharded asset may require directory entries too. The 10%
    # allowance is deliberately modest; existing shard directories dominate
    # established galleries.
    required_inodes = estimated_files + max(32, estimated_files // 10)
    errors: list[str] = []
    if required_output > int(output_usage.free * 0.95):
        errors.append(
            "output filesystem needs approximately "
            f"{human_bytes(required_output)} but only "
            f"{human_bytes(output_usage.free)} is free"
        )
    if not same_device and required_temp > int(temp_usage.free * 0.95):
        errors.append(
            "temporary filesystem needs approximately "
            f"{human_bytes(required_temp)} but only "
            f"{human_bytes(temp_usage.free)} is free"
        )
    if inode_reporting and required_inodes > output_free_inodes:
        errors.append(
            f"output filesystem needs approximately {required_inodes} new "
            f"inodes but only {output_free_inodes} are available"
        )

    result = {
        "estimated_new_files": estimated_files,
        "estimated_output_bytes": estimated_output,
        "estimated_temp_peak_bytes": estimated_temp,
        "output_free_bytes": int(output_usage.free),
        "temp_free_bytes": int(temp_usage.free),
        "output_free_inodes": output_free_inodes if inode_reporting else None,
        "same_output_and_temp_filesystem": same_device,
        "passed": not errors,
        "errors": errors,
    }
    if errors:
        raise OutputError("storage preflight failed: " + "; ".join(errors))
    return result


def emit_machine_report(path: Path | None, payload: dict[str, Any]) -> bool:
    """Write or print a stable JSON report without affecting gallery inventory."""
    if path is None:
        return False
    report = dict(payload)
    report.setdefault("schema_version", 1)
    report.setdefault(
        "finished_at",
        time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
    )
    text = json.dumps(report, ensure_ascii=False, sort_keys=True, indent=2) + "\n"
    if str(path) == "-":
        print(text, end="")
        return True
    destination = path.expanduser().resolve()
    write_text_if_changed(destination, text)
    return True


@dataclass(frozen=True, slots=True)
class DoctorFilesystemScan:
    repairs: int
    permission_file_issues: int
    permission_dir_issues: int
    permission_repair_failures: int
    symlink_issues: int


@dataclass(frozen=True, slots=True)
class DoctorSampleResult:
    requested: int
    checked: int
    failures: int


def scan_doctor_filesystem(
    cache_db: SQLiteCache,
    output_dir: Path,
    repair: bool,
) -> DoctorFilesystemScan:
    """Inventory managed output and optionally normalize permissions."""
    cache_db.doctor.reset_seen()
    seen_batch: list[tuple[str, str, int, int]] = []
    repairs = 0
    file_issues = 0
    directory_issues = 0
    repair_failures = 0
    symlink_issues = 0

    def record(path: Path, kind: str) -> None:
        nonlocal repairs, file_issues, directory_issues, repair_failures
        try:
            details = path.lstat()
            relative = path.relative_to(output_dir).as_posix()
        except (OSError, ValueError):
            return
        mode = stat_module.S_IMODE(details.st_mode)
        expected = OUTPUT_DIR_MODE if kind == "dir" else OUTPUT_FILE_MODE
        if mode != expected:
            if kind == "dir":
                directory_issues += 1
            else:
                file_issues += 1
            if repair and not path.is_symlink():
                try:
                    os.chmod(path, expected)
                    mode = expected
                    repairs += 1
                except OSError:
                    repair_failures += 1
        seen_batch.append((relative, kind, int(details.st_size), mode))
        if len(seen_batch) >= 2000:
            cache_db.doctor.record_seen(seen_batch)
            seen_batch.clear()

    record(output_dir, "dir")
    managed_roots = {
        ASSET_DIRNAME,
        GALLERY_DATA_DIR,
        ALBUMS_DIRNAME,
        LEGACY_ASSET_DIRNAME,
        LEGACY_GALLERY_DATA_DIR,
    }
    for root_name in sorted(managed_roots):
        root = output_dir / root_name
        if not root.is_dir() or root.is_symlink():
            continue
        for current, dirnames, filenames in os.walk(root, topdown=True):
            current_path = Path(current)
            record(current_path, "dir")
            safe_dirnames: list[str] = []
            for dirname in dirnames:
                candidate = current_path / dirname
                if candidate.is_symlink():
                    symlink_issues += 1
                else:
                    safe_dirnames.append(dirname)
            dirnames[:] = safe_dirnames
            for filename in filenames:
                candidate = current_path / filename
                if candidate.is_symlink():
                    symlink_issues += 1
                elif candidate.is_file():
                    record(candidate, "file")
    for root_file in ("index.html", APP_MANIFEST):
        candidate = output_dir / root_file
        if candidate.is_file() and not candidate.is_symlink():
            record(candidate, "file")
    if seen_batch:
        cache_db.doctor.record_seen(seen_batch)
    return DoctorFilesystemScan(
        repairs,
        file_issues,
        directory_issues,
        repair_failures,
        symlink_issues,
    )


def verify_doctor_sample(
    cache_db: SQLiteCache,
    output_dir: Path,
    sample_size: int,
    toolchain: ToolchainCapabilities,
) -> DoctorSampleResult:
    """Verify a bounded, representative sample of generated media."""
    requested = max(0, int(sample_size))
    checked = 0
    failures = 0
    sample_paths = cache_db.doctor.sample_asset_paths(max(requested, requested * 20))
    for relative_text in sample_paths:
        candidate = output_dir / relative_text
        suffix = candidate.suffix.casefold()
        if suffix not in CANDIDATE_EXTENSIONS:
            continue
        if checked >= requested:
            break
        checked += 1
        try:
            if suffix in VIDEO_EXTENSIONS:
                if toolchain.ffprobe_path:
                    video_metadata(candidate, toolchain)
            elif suffix in VECTOR_EXTENSIONS:
                svg_dimensions(candidate)
            elif suffix == ".jxl":
                jxl_metadata(candidate, toolchain)
            else:
                with Image.open(candidate) as image:
                    image.verify()
        except (OSError, ValueError, UnidentifiedImageError):
            failures += 1
    return DoctorSampleResult(requested, checked, failures)


def verify_doctor_data(
    cache_db: SQLiteCache,
    output_dir: Path,
    issues: list[str],
) -> int:
    """Fully verify immutable generated data during an explicit doctor run."""
    try:
        checked = validate_generated_data_files(
            output_dir,
            cache_db.inventory.iter_tracked_paths("data"),
            verify_content=True,
            reuse_cached_validations=False,
        )
    except ContractError as exc:
        issues.append("generated data verification failed: " + str(exc))
        return 0
    return checked


def run_doctor(
    cache_db: SQLiteCache,
    output_dir: Path,
    sample_size: int,
    repair: bool,
    toolchain: ToolchainCapabilities,
) -> DoctorReport:
    """Inspect a generated bundle without rebuilding source derivatives."""
    issues: list[str] = []
    repairs = 0
    quick_check_rows = cache_db.metadata.quick_check()
    if quick_check_rows != ["ok"]:
        issues.append("SQLite quick_check failed: " + "; ".join(quick_check_rows[:8]))

    schema_version = cache_db.metadata.schema_version()
    if schema_version != CACHE_DB_SCHEMA_VERSION:
        issues.append(
            f"cache schema is {schema_version}, expected {CACHE_DB_SCHEMA_VERSION}"
        )
    run_complete = bool(cache_db.metadata.get("run_complete", False))
    if not run_complete:
        issues.append("the previous gallery run did not complete")

    filesystem = scan_doctor_filesystem(cache_db, output_dir, repair)
    repairs = filesystem.repairs
    permission_file_issues = filesystem.permission_file_issues
    permission_dir_issues = filesystem.permission_dir_issues
    permission_repair_failures = filesystem.permission_repair_failures
    symlink_issues = filesystem.symlink_issues
    inventory = cache_db.doctor.inventory_counts()
    tracked_files = inventory.tracked_files
    missing_files = inventory.missing_files
    empty_files = inventory.empty_files
    untracked_files = inventory.untracked_files
    unsafe_tracked_paths = inventory.unsafe_tracked_paths

    if missing_files:
        issues.append(f"{missing_files} tracked generated file(s) are missing")
    if empty_files:
        issues.append(f"{empty_files} tracked generated file(s) are empty")
    if untracked_files:
        issues.append(f"{untracked_files} untracked file(s) exist in managed roots")
    if unsafe_tracked_paths:
        issues.append(f"{unsafe_tracked_paths} unsafe tracked path(s) were found")
    if symlink_issues:
        issues.append(f"{symlink_issues} symlink(s) exist in managed roots")
    if (
        (permission_file_issues or permission_dir_issues)
        and (not repair or permission_repair_failures)
    ):
        label = "could not fully repair" if repair else "found"
        issues.append(
            f"{label} {permission_file_issues} file and "
            f"{permission_dir_issues} directory permission mismatch(es)"
        )

    sample = verify_doctor_sample(cache_db, output_dir, sample_size, toolchain)
    if sample.failures:
        issues.append(
            f"{sample.failures} of {sample.checked} sampled media asset(s) "
            "failed verification"
        )
    data_files_checked = verify_doctor_data(cache_db, output_dir, issues)
    render_settings = cache_db.metadata.get("render_settings", {})
    cached_format = (
        str(render_settings.get("format") or "jxl")
        if isinstance(render_settings, dict)
        else "jxl"
    )
    encoder_available = True
    try:
        ensure_output_encoder_available(cached_format, toolchain)
    except ValueError as exc:
        encoder_available = False
        issues.append(str(exc))
    if not (output_dir / "index.html").is_file():
        issues.append("gallery index.html is missing")

    if repair:
        cache_db.inventory.optimize()

    return {
        "mode": "doctor",
        "healthy": not issues,
        "issues": issues,
        "repairs": repairs,
        "cache": {
            "schema_version": schema_version,
            "quick_check": quick_check_rows,
            "run_complete": run_complete,
            "rows": cache_db.inventory.row_count(),
        },
        "generated": {
            "tracked_files": tracked_files,
            "missing_files": missing_files,
            "empty_files": empty_files,
            "untracked_files": untracked_files,
            "unsafe_tracked_paths": unsafe_tracked_paths,
            "managed_symlinks": symlink_issues,
            "data_files_verified": data_files_checked,
        },
        "permissions": {
            "file_mismatches": permission_file_issues,
            "directory_mismatches": permission_dir_issues,
        },
        "sample": {
            "requested": sample.requested,
            "checked": sample.checked,
            "failures": sample.failures,
        },
        "encoder": {
            "format": cached_format,
            "available": encoder_available,
        },
    }


def print_doctor_report(report: DoctorReport) -> None:
    generated = report["generated"]
    permissions = report["permissions"]
    sample = report["sample"]
    print("Gallery doctor: " + ("healthy" if report["healthy"] else "issues found"))
    print(f"Cache rows:        {report['cache']['rows']}")
    print(f"Tracked files:     {generated['tracked_files']}")
    print(f"Missing/empty:     {generated['missing_files']}/{generated['empty_files']}")
    print(f"Untracked files:   {generated['untracked_files']}")
    print(
        "Permissions:       "
        f"{permissions['file_mismatches']} file, "
        f"{permissions['directory_mismatches']} directory mismatch(es)"
    )
    print(
        f"Media sample:      {sample['checked']} checked, "
        f"{sample['failures']} failed"
    )
    print(f"Generated data:    {generated.get('data_files_verified', 0)} verified")
    print(
        f"Encoder ({report['encoder']['format']}): "
        + ("available" if report["encoder"]["available"] else "missing")
    )
    if report["repairs"]:
        print(f"Repairs:           {report['repairs']}")
    for issue in report["issues"]:
        print(f"ISSUE: {issue}")



def matched_root_directory(
    path: Path,
    roots: list[Path],
) -> tuple[Path | None, Path | None]:
    # Explicit file roots are equal to `path`; descendants can only belong to
    # directory roots, so avoid an os.stat(root) call for every image record.
    for root in roots:
        if root == path:
            continue
        try:
            return root, path.relative_to(root)
        except ValueError:
            pass
    return None, None


def display_name(path: Path, roots: list[Path]) -> str:
    root, relative = matched_root_directory(path, roots)
    if root is not None and relative is not None:
        return relative.as_posix()
    return path.name


def safe_slug(value: str) -> str:
    slug = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip())
    slug = slug.strip(".-")
    return slug or "root"


def make_route_roots(roots: list[Path]) -> dict[Path, Path]:
    directory_roots = [root for root in roots if root.is_dir()]
    mapping: dict[Path, Path] = {}

    # With one source directory, its immediate children become
    # /albums/<child>/ while /albums/index.html represents the source root.
    if len(directory_roots) == 1:
        mapping[directory_roots[0]] = Path(ALBUMS_DIRNAME)
        return mapping

    # Multiple source roots are namespaced one level deeper:
    # /albums/<source-root>/...
    used: set[str] = set()
    for root in directory_roots:
        base = safe_slug(root.name)
        candidate = base
        index = 2
        while candidate.casefold() in used:
            candidate = f"{base}-{index}"
            index += 1
        used.add(candidate.casefold())
        mapping[root] = Path(ALBUMS_DIRNAME) / candidate

    return mapping


def relative_output_url(from_page_dir: Path, target_rel: str | Path) -> str:
    start = "." if str(from_page_dir) in {"", "."} else str(from_page_dir)
    rel = os.path.relpath(str(target_rel), start=start)
    return rel.replace(os.sep, "/")


def stable_image_id(path: Path) -> str:
    """Return the full rebuild-stable identifier stored in shared geometry.

    URLs may expose a shorter prefix selected by --image-id-bits, but geometry
    deliberately keeps the full 96-bit value. This lets links created with a
    different supported prefix length continue to resolve after later rebuilds.
    The identifier is independent of catalog position and file content, so it
    survives rebuilds, insertions/removals, and content changes. Renaming or
    moving the source file intentionally gives it a new identifier.
    """
    raw = os.fspath(path).encode("utf-8", "surrogateescape")
    return hashlib.sha256(raw).hexdigest()[: STABLE_IMAGE_ID_BITS // 4]


def compact_catalog_item(record: CatalogRecord) -> list[Any]:
    """Canonical metadata stored once for the entire gallery.

    Static:
      [display_name, sha256_prefix24, 0, source_width, source_height,
       source_suffix, stable_id, average_color]

    Passthrough/animated/vector:
      [display_name, sha256_prefix24, 1, suffix, source_width, source_height,
       stable_id, average_color]

    Video:
      [display_name, sha256_prefix24, 2, suffix, source_width, source_height,
       stable_id, has_poster, duration_seconds, file_size, video_codec,
       video_profile, audio_codec, frame_rate, creation_time,
       compatibility_note, compatibility_derivative_filename, average_color]

    Width/height live here rather than in every directory page's geometry so
    recursive galleries do not duplicate those values for every ancestor.
    """
    entry = record["entry"]
    digest = str(entry["sha256"])[:24]
    name = str(record["display_name"])
    width = int(entry["width"])
    height = int(entry["height"])

    suffix = str(entry.get("source_suffix") or "").casefold()
    if suffix and not suffix.startswith("."):
        suffix = "." + suffix

    stable_id = str(record["stable_id"])
    average_color = entry.get("average_color")

    if entry["kind"] == "static":
        return STATIC_CATALOG_SCHEMA.pack({
            "name": name,
            "digest": digest,
            "kind": 0,
            "width": width,
            "height": height,
            "suffix": suffix,
            "stable_id": stable_id,
            "average_color": average_color,
        })

    if not suffix:
        suffix = Path(str(entry["asset"])).suffix.casefold()

    if entry["kind"] == "video":
        duration = _positive_float(entry.get("duration"))
        frame_rate = _positive_float(entry.get("frame_rate"))
        return VIDEO_CATALOG_SCHEMA.pack({
            "name": name,
            "digest": digest,
            "kind": 2,
            "suffix": suffix,
            "width": width,
            "height": height,
            "stable_id": stable_id,
            "has_poster": 1 if entry.get("poster_asset") else 0,
            "duration": round(duration, 3) if duration is not None else None,
            "file_size": int(entry.get("size") or 0),
            "video_codec": entry.get("video_codec"),
            "video_profile": entry.get("video_profile"),
            "audio_codec": entry.get("audio_codec"),
            "frame_rate": (
                round(frame_rate, 3) if frame_rate is not None else None
            ),
            "creation_time": entry.get("video_creation_time"),
            "compatibility_note": entry.get("video_compatibility_note"),
            "compatibility_filename": (
                Path(str(entry["video_transcode_asset"])).name
                if entry.get("video_transcode_asset")
                else None
            ),
            "average_color": average_color,
        })

    return PASSTHROUGH_CATALOG_SCHEMA.pack({
        "name": name,
        "digest": digest,
        "kind": 1,
        "suffix": suffix,
        "width": width,
        "height": height,
        "stable_id": stable_id,
        "average_color": average_color,
    })


def compact_exif_item(record: CatalogRecord) -> list[Any]:
    """Return sparse positional metadata used only by the lightbox panel."""
    raw = record["entry"].get("exif", [])
    values = list(raw[:len(EXIF_RAW_SCHEMA.fields)]) if isinstance(raw, list) else []
    while values and values[-1] is None:
        values.pop()
    return values


def _signature_field(hasher: Any, value: Any) -> None:
    if value is None:
        data = b""
    elif isinstance(value, bytes):
        data = value
    else:
        data = str(value).encode("utf-8", "surrogatepass")
    hasher.update(len(data).to_bytes(8, "big"))
    hasher.update(data)


def update_data_chunk_dependency(
    hashers: dict[Any, Any],
    key: Any,
    namespace: str,
    value: Any,
) -> None:
    """Add one canonical record to a lightweight per-chunk dependency hash."""
    hasher = hashers.get(key)
    if hasher is None:
        hasher = hashlib.sha256(
            f"{namespace}-data{GALLERY_DATA_FORMAT_VERSION}\0".encode("ascii")
        )
        hashers[key] = hasher
    _signature_field(hasher, js_json(value))


def finalized_data_chunk_dependencies(
    hashers: dict[Any, Any],
) -> dict[Any, str]:
    return {key: hasher.hexdigest() for key, hasher in hashers.items()}


def compact_global_geometry_chunk(
    records: Sequence[CatalogRecord],
) -> list[int]:
    """Fixed-point aspect ratios indexed directly by catalog ID."""
    geometry: list[int] = []
    for record in records:
        entry = record["entry"]
        width = max(1, int(entry["width"]))
        height = max(1, int(entry["height"]))
        geometry.append(
            max(1, round(width * GEOMETRY_RATIO_SCALE / height))
        )
    return geometry


def packed_global_geometry_chunk(values: Sequence[int]) -> bytes:
    """Return a portable little-endian geometry chunk with a small header."""
    output = bytearray(GEOMETRY_BINARY_HEADER.pack(
        GEOMETRY_BINARY_MAGIC,
        len(values),
    ))
    for value in values:
        integer = int(value)
        if integer < 1 or integer > 0xFFFFFFFF:
            raise ValueError(f"geometry ratio is outside uint32 range: {integer}")
        output.extend(GEOMETRY_BINARY_VALUE.pack(integer))
    return bytes(output)


def packed_catalog_chunk(items: Sequence[Sequence[Any]]) -> bytes:
    """Pack catalog records into an offset-indexed, deduplicated string table."""
    string_ids: dict[str, int] = {}
    strings: list[bytes] = []

    def string_id(value: Any) -> int:
        if value is None:
            return CATALOG_NULL_STRING
        text = str(value)
        existing = string_ids.get(text)
        if existing is not None:
            return existing
        identifier = len(strings)
        string_ids[text] = identifier
        strings.append(text.encode("utf-8"))
        return identifier

    records: list[bytes] = []
    for raw in items:
        item = list(raw)
        kind = int(item[STATIC_CATALOG_SCHEMA.indexes["kind"]])
        schema = (
            STATIC_CATALOG_SCHEMA
            if kind == 0
            else VIDEO_CATALOG_SCHEMA if kind == 2 else PASSTHROUGH_CATALOG_SCHEMA
        )
        indexes = schema.indexes
        width = int(item[indexes["width"]])
        height = int(item[indexes["height"]])
        suffix = item[indexes["suffix"]]
        color_index = indexes["average_color"]
        color = item[color_index] if len(item) > color_index else None
        flags = (
            1
            if kind == 2 and bool(item[indexes["has_poster"]])
            else 0
        )
        record = bytearray(
            CATALOG_BINARY_RECORD.pack(
                kind,
                flags,
                0,
                string_id(item[indexes["name"]]),
                string_id(item[indexes["digest"]]),
                string_id(suffix),
                string_id(item[indexes["stable_id"]]),
                string_id(color),
                max(1, width),
                max(1, height),
            )
        )
        if kind == 2:
            duration_value = item[indexes["duration"]]
            frame_rate_value = item[indexes["frame_rate"]]
            duration = (
                float(duration_value) if duration_value is not None else math.nan
            )
            frame_rate = (
                float(frame_rate_value)
                if frame_rate_value is not None
                else math.nan
            )
            record.extend(
                CATALOG_BINARY_VIDEO.pack(
                    duration,
                    max(0, int(item[indexes["file_size"]] or 0)),
                    string_id(item[indexes["video_codec"]]),
                    string_id(item[indexes["video_profile"]]),
                    string_id(item[indexes["audio_codec"]]),
                    frame_rate,
                    string_id(item[indexes["creation_time"]]),
                    string_id(item[indexes["compatibility_note"]]),
                    string_id(item[indexes["compatibility_filename"]]),
                )
            )
        records.append(bytes(record))

    string_offsets = [0]
    string_blob = bytearray()
    for value in strings:
        string_blob.extend(value)
        string_offsets.append(len(string_blob))

    record_offsets = [0]
    record_blob = bytearray()
    for record in records:
        record_blob.extend(record)
        record_offsets.append(len(record_blob))

    string_offsets_offset = CATALOG_BINARY_HEADER.size
    record_offsets_offset = string_offsets_offset + 4 * len(string_offsets)
    records_offset = record_offsets_offset + 4 * len(record_offsets)
    strings_offset = records_offset + len(record_blob)
    output = bytearray(
        CATALOG_BINARY_HEADER.pack(
            CATALOG_BINARY_MAGIC,
            CATALOG_BINARY_VERSION,
            len(records),
            len(strings),
            string_offsets_offset,
            record_offsets_offset,
            records_offset,
            strings_offset,
        )
    )
    output.extend(struct.pack(f"<{len(string_offsets)}I", *string_offsets))
    output.extend(struct.pack(f"<{len(record_offsets)}I", *record_offsets))
    output.extend(record_blob)
    output.extend(string_blob)
    return bytes(output)


def js_json(data: Any) -> str:
    """Compact JSON safe to embed as a JavaScript expression."""
    return (
        json.dumps(data, ensure_ascii=False, separators=(",", ":"))
        .replace("&", "\\u0026")
        .replace("<", "\\u003c")
        .replace(">", "\\u003e")
        .replace("\u2028", "\\u2028")
        .replace("\u2029", "\\u2029")
    )


def catalog_chunk_relative_path(chunk_index: int, revision: str) -> Path:
    return Path(GALLERY_DATA_CATALOG_DIR) / (
        f"{chunk_index:0{CATALOG_CHUNK_FILENAME_WIDTH}d}.{revision}.js"
    )


def catalog_binary_relative_path(chunk_index: int, revision: str) -> Path:
    return Path(GALLERY_DATA_CATALOG_DIR) / (
        f"{chunk_index:0{CATALOG_CHUNK_FILENAME_WIDTH}d}.{revision}.bin"
    )


def exif_chunk_relative_path(chunk_index: int, revision: str) -> Path:
    return Path(GALLERY_DATA_EXIF_DIR) / (
        f"{chunk_index:0{CATALOG_CHUNK_FILENAME_WIDTH}d}.{revision}.js"
    )


def page_descriptor_relative_path(route: Path, revision: str) -> Path:
    route_key = "." if str(route) in {"", "."} else route.as_posix()
    digest = hashlib.sha256(route_key.encode("utf-8")).hexdigest()[:24]
    return Path(GALLERY_DATA_PAGE_DIR) / f"{digest}.{revision}.js"


def random_order_relative_path(revision: str, suffix: str) -> Path:
    return Path(GALLERY_DATA_ORDER_DIR) / f"groups.{revision}.{suffix}"


def global_geometry_chunk_relative_path(
    chunk_index: int,
    revision: str,
) -> Path:
    return Path(GALLERY_DATA_GEOMETRY_DIR) / (
        f"{chunk_index:0{GEOMETRY_CHUNK_FILENAME_WIDTH}d}.{revision}.js"
    )


def global_geometry_binary_relative_path(
    chunk_index: int,
    revision: str,
) -> Path:
    return Path(GALLERY_DATA_GEOMETRY_DIR) / (
        f"{chunk_index:0{GEOMETRY_CHUNK_FILENAME_WIDTH}d}.{revision}.bin"
    )


def id_index_relative_path(shard: str, revision: str) -> Path:
    return Path(GALLERY_DATA_ID_DIR) / f"{shard}.{revision}.js"


def chunk_revision_index_relative_path(revision: str) -> Path:
    return Path(GALLERY_DATA_DIR) / f"chunk-revisions.{revision}.js"


def generated_js_assignment(
    target: str,
    payload: Any,
    *,
    prefix: str = "",
) -> str:
    """Render and validate one generated global JavaScript assignment."""
    javascript = f"{prefix}{target}={js_json(payload)};\n"
    if not javascript.endswith(";\n") or javascript.endswith(";\\n"):
        raise RuntimeError(f"invalid generated JavaScript terminator for {target}")
    if "\x00" in javascript:
        raise RuntimeError(f"NUL byte in generated JavaScript for {target}")
    return javascript


def random_group_identity(
    record: CatalogRecord,
    suffix_pattern: re.Pattern[str],
) -> tuple[tuple[str, str, str], str, str]:
    """Return a directory-scoped group key and deterministic sort labels."""
    root_relative_value = record.get("root_relative")
    if root_relative_value:
        relative = PurePosixPath(str(root_relative_value))
        namespace = str(record.get("source_root") or "")
    else:
        source_path = Path(record["path"])
        relative = PurePosixPath(source_path.name)
        namespace = str(source_path.parent)

    stem = relative.stem
    match = suffix_pattern.search(stem)
    if match is not None and match.end() == len(stem):
        grouped_stem = stem[: match.start()].rstrip(" -_") or stem
    else:
        grouped_stem = stem

    parent = relative.parent.as_posix()
    key = (
        namespace.casefold(),
        parent.casefold(),
        grouped_stem.casefold(),
    )
    group_label = f"{namespace}/{parent}/{grouped_stem}"
    member_label = relative.name
    return key, group_label, member_label


def packed_random_group_order(
    lengths: Sequence[int],
    members: Sequence[int],
) -> bytes:
    """Pack group lengths followed by their catalog IDs as little-endian u32."""
    if len(members) > 0xFFFFFFFF or len(lengths) > 0xFFFFFFFF:
        raise RuntimeError("random grouping exceeds the 32-bit data limit")
    output = bytearray(
        ORDER_BINARY_HEADER.pack(
            ORDER_BINARY_MAGIC,
            ORDER_BINARY_VERSION,
            len(members),
            len(lengths),
        )
    )
    output.extend(len(lengths) * ORDER_BINARY_VALUE.size * b"\0")
    output.extend(len(members) * ORDER_BINARY_VALUE.size * b"\0")
    offset = ORDER_BINARY_HEADER.size
    for value in lengths:
        ORDER_BINARY_VALUE.pack_into(output, offset, int(value))
        offset += ORDER_BINARY_VALUE.size
    for value in members:
        ORDER_BINARY_VALUE.pack_into(output, offset, int(value))
        offset += ORDER_BINARY_VALUE.size
    return bytes(output)


def write_random_group_order(
    output_dir: Path,
    cache_db: SQLiteCache,
    record_count: int,
    suffix_regex: str,
    trust_generated: bool,
    enabled: bool,
) -> RandomOrderPublication:
    """Publish the optional global grouping map used by Unsorted mode."""
    if not enabled:
        return RandomOrderPublication(
            {
                "enabled": False,
                "available": False,
                "count": record_count,
                "groupCount": 0,
            },
            set(),
            0,
        )
    if record_count < 2:
        return RandomOrderPublication(
            {
                "enabled": True,
                "available": False,
                "count": record_count,
                "groupCount": record_count,
            },
            set(),
            0,
        )

    suffix_pattern = re.compile(suffix_regex)
    groups: dict[
        tuple[str, str, str],
        tuple[str, list[tuple[str, int]]],
    ] = {}
    for record in cache_db.scan.iter_catalog_records():
        key, group_label, member_label = random_group_identity(
            record, suffix_pattern
        )
        grouped = groups.get(key)
        if grouped is None:
            grouped = (group_label, [])
            groups[key] = grouped
        grouped[1].append((member_label, int(record["catalog_id"])))

    ordered_groups = sorted(
        groups.values(),
        key=lambda group: natural_key(group[0]),
    )
    lengths: list[int] = []
    members: list[int] = []
    for _label, group_members in ordered_groups:
        group_members.sort(
            key=lambda item: (natural_key(item[0]), item[1])
        )
        lengths.append(len(group_members))
        members.extend(catalog_id for _name, catalog_id in group_members)

    if len(members) != record_count or sum(lengths) != record_count:
        raise RuntimeError("random grouping did not cover the complete catalog")
    seen = bytearray(record_count)
    for catalog_id in members:
        if catalog_id < 0 or catalog_id >= record_count or seen[catalog_id]:
            raise RuntimeError(
                "random grouping contains an invalid or duplicate catalog ID"
            )
        seen[catalog_id] = 1

    binary = packed_random_group_order(lengths, members)
    binary_signature = hashlib.sha256(binary).hexdigest()
    binary_rel = random_order_relative_path(
        binary_signature[:12], "bin"
    )
    javascript = generated_js_assignment(
        "window.__JG_RANDOM_GROUP_ORDER__",
        {"lengths": lengths, "members": members},
    )
    fallback_signature = hashlib.sha256(
        javascript.encode("utf-8")
    ).hexdigest()
    fallback_rel = random_order_relative_path(
        fallback_signature[:12], "js"
    )
    rewritten = publish_generated_payload(
        output_dir,
        binary_rel,
        binary,
        binary_signature,
        cache_db,
        trust_generated,
    )
    rewritten += publish_generated_payload(
        output_dir,
        fallback_rel,
        javascript,
        fallback_signature,
        cache_db,
        trust_generated,
    )
    info = {
        "enabled": True,
        "available": True,
        "count": record_count,
        "groupCount": len(lengths),
        "binaryPath": binary_rel.as_posix(),
        "binaryRevision": binary_signature[:12],
        "fallbackPath": fallback_rel.as_posix(),
        "fallbackRevision": fallback_signature[:12],
    }
    return RandomOrderPublication(
        info,
        {binary_rel.as_posix(), fallback_rel.as_posix()},
        rewritten,
    )


@dataclass(frozen=True, slots=True)
class GeneratedArtifactPublisher:
    """Atomically publish, inventory, and signature-track generated payloads."""

    output_dir: Path
    cache_db: SQLiteCache
    trust_generated: bool

    def publish(
        self,
        relative_path: Path | str,
        payload: str | bytes,
        input_signature: str,
        *,
        kind: str = "data",
    ) -> int:
        rel_text = Path(relative_path).as_posix()
        if self.trust_generated and self.cache_db.chunks.generated_signature_matches(
            rel_text,
            input_signature,
        ):
            return 0
        changed = ARTIFACT_WRITER.write_if_changed(
            self.output_dir / rel_text,
            payload,
        )
        self.cache_db.chunks.record_generated_signature(
            rel_text,
            kind,
            input_signature,
        )
        return int(changed)

    def reusable_chunk(
        self,
        kind: str,
        key: str | int,
        dependency_signature: str,
    ) -> DataChunkMetadata | None:
        return self.cache_db.chunks.reusable_data_chunk(
            self.output_dir,
            kind,
            key,
            dependency_signature,
            self.trust_generated,
        )

    def publish_chunk(
        self,
        *,
        kind: str,
        key: str | int,
        dependency_signature: str,
        artifacts: Sequence[ChunkArtifact],
        primary_path: str,
        primary_signature: str,
        metadata: DataChunkMetadata,
    ) -> int:
        rewritten = sum(
            self.publish(
                artifact.relative_path,
                artifact.payload,
                artifact.signature,
            )
            for artifact in artifacts
        )
        self.cache_db.chunks.record_data_chunk_state(
            kind,
            key,
            dependency_signature,
            primary_path,
            primary_signature,
            metadata,
        )
        return rewritten


def publish_generated_payload(
    output_dir: Path,
    relative_path: Path | str,
    payload: str | bytes,
    input_signature: str,
    cache_db: SQLiteCache,
    trust_generated: bool,
    *,
    kind: str = "data",
) -> int:
    """Compatibility wrapper around the unified artifact publisher."""
    return GeneratedArtifactPublisher(
        output_dir,
        cache_db,
        trust_generated,
    ).publish(relative_path, payload, input_signature, kind=kind)


def write_catalog_chunks(
    output_dir: Path,
    record_count: int,
    chunk_size: int,
    dependency_signatures: dict[int, str],
    cache_db: SQLiteCache,
    trust_generated: bool,
) -> ChunkPublication:
    """Stream binary catalog chunks plus JavaScript compatibility fallbacks."""
    referenced: set[str] = set()
    rewritten = 0
    revisions: dict[int, dict[str, Any]] = {}
    publisher = GeneratedArtifactPublisher(
        output_dir,
        cache_db,
        trust_generated,
    )

    for chunk_index, start_index in enumerate(
        range(0, record_count, chunk_size)
    ):
        dependency_signature = dependency_signatures.get(chunk_index)
        if not dependency_signature:
            raise RuntimeError(
                f"missing catalog dependency signature for chunk {chunk_index}"
            )
        cached = publisher.reusable_chunk(
            "catalog",
            chunk_index,
            dependency_signature,
        )
        if cached is not None:
            fallback_revision = str(cached.get("revision") or "")
            binary_revision = str(cached.get("binaryRevision") or "")
            fallback_path = str(cached.get("fallbackPath") or "")
            binary_path = str(cached.get("binaryPath") or cached.get("path") or "")
            count = int(cached.get("count") or 0)
            if (
                re.fullmatch(r"[0-9a-f]{12}", fallback_revision)
                and re.fullmatch(r"[0-9a-f]{12}", binary_revision)
                and fallback_path
                and binary_path
                and generated_file_usable(output_dir / fallback_path)
                and generated_file_usable(output_dir / binary_path)
                and count > 0
            ):
                revisions[chunk_index] = {
                    "revision": fallback_revision,
                    "binaryRevision": binary_revision,
                    "count": count,
                }
                referenced.update((fallback_path, binary_path))
                continue

        chunk_records = list(
            cache_db.scan.iter_catalog_records(start_index, chunk_size)
        )
        payload = [compact_catalog_item(record) for record in chunk_records]
        if len(payload) != len(chunk_records):
            raise RuntimeError(f"catalog chunk {chunk_index} count mismatch")
        javascript = generated_js_assignment(
            f"window.__JG_CATALOG_CHUNKS[{chunk_index}]",
            payload,
            prefix=(
                "window.__JG_CATALOG_CHUNKS="
                "window.__JG_CATALOG_CHUNKS||Object.create(null);"
            ),
        )
        fallback_signature = hashlib.sha256(
            javascript.encode("utf-8")
        ).hexdigest()
        binary = packed_catalog_chunk(payload)
        binary_signature = hashlib.sha256(binary).hexdigest()
        fallback_revision = fallback_signature[:12]
        binary_revision = binary_signature[:12]
        revisions[chunk_index] = {
            "revision": fallback_revision,
            "binaryRevision": binary_revision,
            "count": len(payload),
        }
        fallback_rel = catalog_chunk_relative_path(
            chunk_index, fallback_revision
        )
        binary_rel = catalog_binary_relative_path(chunk_index, binary_revision)
        fallback_text = fallback_rel.as_posix()
        binary_text = binary_rel.as_posix()
        referenced.update((fallback_text, binary_text))

        rewritten += publisher.publish_chunk(
            kind="catalog",
            key=chunk_index,
            dependency_signature=dependency_signature,
            artifacts=(
                ChunkArtifact(fallback_rel, javascript, fallback_signature),
                ChunkArtifact(binary_rel, binary, binary_signature),
            ),
            primary_path=binary_text,
            primary_signature=binary_signature,
            metadata={
                **revisions[chunk_index],
                "binaryPath": binary_text,
                "fallbackPath": fallback_text,
            },
        )

    return ChunkPublication(referenced, rewritten, revisions)


def write_exif_chunks(
    output_dir: Path,
    record_count: int,
    chunk_size: int,
    dependency_signatures: dict[int, str],
    cache_db: SQLiteCache,
    trust_generated: bool,
) -> ChunkPublication:
    """Write lightbox-only EXIF chunks parallel to catalog chunks."""
    referenced: set[str] = set()
    rewritten = 0
    revisions: dict[int, str] = {}
    publisher = GeneratedArtifactPublisher(
        output_dir,
        cache_db,
        trust_generated,
    )

    for chunk_index, start_index in enumerate(range(0, record_count, chunk_size)):
        dependency_signature = dependency_signatures.get(chunk_index)
        if not dependency_signature:
            raise RuntimeError(
                f"missing EXIF dependency signature for chunk {chunk_index}"
            )
        cached = publisher.reusable_chunk(
            "exif",
            chunk_index,
            dependency_signature,
        )
        if cached is not None:
            revision = str(cached.get("revision") or "")
            rel_text = str(cached.get("path") or "")
            if re.fullmatch(r"[0-9a-f]{12}", revision) and rel_text:
                revisions[chunk_index] = revision
                referenced.add(rel_text)
                continue

        chunk_records = list(
            cache_db.scan.iter_catalog_records(start_index, chunk_size)
        )
        payload = [compact_exif_item(record) for record in chunk_records]
        if len(payload) != len(chunk_records):
            raise RuntimeError(f"EXIF chunk {chunk_index} count mismatch")
        javascript = generated_js_assignment(
            f"window.__JG_EXIF_CHUNKS[{chunk_index}]",
            payload,
            prefix=(
                "window.__JG_EXIF_CHUNKS="
                "window.__JG_EXIF_CHUNKS||Object.create(null);"
            ),
        )
        input_signature = hashlib.sha256(
            javascript.encode("utf-8")
        ).hexdigest()
        short_revision = input_signature[:12]
        revisions[chunk_index] = short_revision
        rel = exif_chunk_relative_path(chunk_index, short_revision)
        rel_text = rel.as_posix()
        referenced.add(rel_text)

        rewritten += publisher.publish_chunk(
            kind="exif",
            key=chunk_index,
            dependency_signature=dependency_signature,
            artifacts=(ChunkArtifact(rel, javascript, input_signature),),
            primary_path=rel_text,
            primary_signature=input_signature,
            metadata={"revision": short_revision, "path": rel_text},
        )

    return ChunkPublication(referenced, rewritten, revisions)


def write_global_geometry_chunks(
    output_dir: Path,
    record_count: int,
    chunk_size: int,
    dependency_signatures: dict[int, str],
    cache_db: SQLiteCache,
    trust_generated: bool,
) -> ChunkPublication:
    """Stream binary geometry plus a JavaScript fallback for every page."""
    referenced: set[str] = set()
    rewritten = 0
    revisions: dict[int, dict[str, Any]] = {}
    publisher = GeneratedArtifactPublisher(
        output_dir,
        cache_db,
        trust_generated,
    )

    for chunk_index, start_index in enumerate(
        range(0, record_count, chunk_size)
    ):
        dependency_signature = dependency_signatures.get(chunk_index)
        if not dependency_signature:
            raise RuntimeError(
                f"missing geometry dependency signature for chunk {chunk_index}"
            )
        cached = publisher.reusable_chunk(
            "geometry",
            chunk_index,
            dependency_signature,
        )
        if cached is not None:
            fallback_revision = str(cached.get("revision") or "")
            binary_revision = str(cached.get("binaryRevision") or "")
            fallback_path = str(cached.get("fallbackPath") or "")
            binary_path = str(cached.get("binaryPath") or cached.get("path") or "")
            segments = cached.get("segments")
            count = int(cached.get("count") or 0)
            if (
                re.fullmatch(r"[0-9a-f]{12}", fallback_revision)
                and re.fullmatch(r"[0-9a-f]{12}", binary_revision)
                and fallback_path
                and binary_path
                and generated_file_usable(output_dir / fallback_path)
                and generated_file_usable(output_dir / binary_path)
                and isinstance(segments, list)
                and sum(int(value[0]) for value in segments) == count
            ):
                revisions[chunk_index] = {
                    "revision": fallback_revision,
                    "binaryRevision": binary_revision,
                    "count": count,
                    "segments": segments,
                }
                referenced.update((fallback_path, binary_path))
                continue

        chunk_records = list(
            cache_db.scan.iter_catalog_records(start_index, chunk_size)
        )
        payload = compact_global_geometry_chunk(chunk_records)
        if len(payload) != len(chunk_records):
            raise RuntimeError(f"geometry chunk {chunk_index} count mismatch")
        javascript = generated_js_assignment(
            f"window.__JG_GLOBAL_GEOMETRY_CHUNKS[{chunk_index}]",
            payload,
            prefix=(
                "window.__JG_GLOBAL_GEOMETRY_CHUNKS="
                "window.__JG_GLOBAL_GEOMETRY_CHUNKS||Object.create(null);"
            ),
        )
        fallback_signature = hashlib.sha256(
            javascript.encode("utf-8")
        ).hexdigest()
        binary = packed_global_geometry_chunk(payload)
        binary_signature = hashlib.sha256(binary).hexdigest()
        fallback_revision = fallback_signature[:12]
        binary_revision = binary_signature[:12]
        layout_segments = [
            [len(payload[offset:offset + DEFAULT_LAYOUT_SEGMENT_SIZE]),
             sum(payload[offset:offset + DEFAULT_LAYOUT_SEGMENT_SIZE])]
            for offset in range(0, len(payload), DEFAULT_LAYOUT_SEGMENT_SIZE)
        ]
        if sum(segment[0] for segment in layout_segments) != len(payload):
            raise RuntimeError(
                f"geometry chunk {chunk_index} segment count mismatch"
            )
        revisions[chunk_index] = {
            "revision": fallback_revision,
            "binaryRevision": binary_revision,
            "count": len(payload),
            "segments": layout_segments,
        }
        fallback_rel = global_geometry_chunk_relative_path(
            chunk_index,
            fallback_revision,
        )
        binary_rel = global_geometry_binary_relative_path(
            chunk_index,
            binary_revision,
        )
        fallback_text = fallback_rel.as_posix()
        binary_text = binary_rel.as_posix()
        referenced.update((fallback_text, binary_text))

        rewritten += publisher.publish_chunk(
            kind="geometry",
            key=chunk_index,
            dependency_signature=dependency_signature,
            artifacts=(
                ChunkArtifact(fallback_rel, javascript, fallback_signature),
                ChunkArtifact(binary_rel, binary, binary_signature),
            ),
            primary_path=binary_text,
            primary_signature=binary_signature,
            metadata={
                **revisions[chunk_index],
                "binaryPath": binary_text,
                "fallbackPath": fallback_text,
            },
        )

    return ChunkPublication(referenced, rewritten, revisions)


def write_stable_id_index(
    output_dir: Path,
    dependency_signatures: dict[str, str],
    cache_db: SQLiteCache,
    trust_generated: bool,
) -> ChunkPublication:
    """Write 256-way stable-ID lookup shards used only for #image URLs."""
    referenced: set[str] = set()
    rewritten = 0
    revisions: dict[str, str] = {}
    publisher = GeneratedArtifactPublisher(
        output_dir,
        cache_db,
        trust_generated,
    )

    shards = cache_db.scan.id_shards()
    for shard in shards:
        dependency_signature = dependency_signatures.get(shard)
        if not dependency_signature:
            raise RuntimeError(
                f"missing stable-ID dependency signature for shard {shard}"
            )
        cached = publisher.reusable_chunk(
            "ids",
            shard,
            dependency_signature,
        )
        if cached is not None:
            revision = str(cached.get("revision") or "")
            rel_text = str(cached.get("path") or "")
            if re.fullmatch(r"[0-9a-f]{12}", revision) and rel_text:
                revisions[shard] = revision
                referenced.add(rel_text)
                continue

        rows = cache_db.scan.stable_id_rows(shard)
        javascript = generated_js_assignment(
            f"window.__JG_ID_SHARDS[{json.dumps(shard)}]",
            rows,
            prefix=(
                "window.__JG_ID_SHARDS="
                "window.__JG_ID_SHARDS||Object.create(null);"
            ),
        )
        input_signature = hashlib.sha256(
            javascript.encode("utf-8")
        ).hexdigest()
        revisions[shard] = input_signature[:12]
        rel = id_index_relative_path(shard, input_signature[:12])
        rel_text = rel.as_posix()
        referenced.add(rel_text)

        rewritten += publisher.publish_chunk(
            kind="ids",
            key=shard,
            dependency_signature=dependency_signature,
            artifacts=(ChunkArtifact(rel, javascript, input_signature),),
            primary_path=rel_text,
            primary_signature=input_signature,
            metadata={"revision": input_signature[:12], "path": rel_text},
        )
    return ChunkPublication(referenced, rewritten, revisions)


def write_chunk_revision_index(
    output_dir: Path,
    catalog_revisions: dict[int, dict[str, Any]],
    exif_revisions: dict[int, str],
    geometry_revisions: dict[int, dict[str, Any]],
    id_revisions: dict[str, str],
    cache_db: SQLiteCache,
    trust_generated: bool,
) -> GeneratedFilePublication:
    """Write the small mapping from chunk IDs to content revisions."""
    payload = {
        "catalog": {str(k): v for k, v in catalog_revisions.items()},
        "exif": {str(k): v for k, v in exif_revisions.items()},
        "geometry": {str(k): v for k, v in geometry_revisions.items()},
        "ids": id_revisions,
    }
    if sorted(catalog_revisions) != list(range(len(catalog_revisions))):
        raise RuntimeError("catalog revision index is not contiguous")
    if sorted(exif_revisions) != list(range(len(exif_revisions))):
        raise RuntimeError("EXIF revision index is not contiguous")
    if set(exif_revisions) != set(catalog_revisions):
        raise RuntimeError("EXIF and catalog revision indexes do not align")
    if sorted(geometry_revisions) != list(range(len(geometry_revisions))):
        raise RuntimeError("geometry revision index is not contiguous")
    for chunk_index, info in catalog_revisions.items():
        revision = str(info.get("revision") or "")
        binary_revision = str(info.get("binaryRevision") or "")
        if re.fullmatch(r"[0-9a-f]{12}", revision) is None:
            raise RuntimeError(
                f"invalid catalog revision for chunk {chunk_index}: {revision!r}"
            )
        if re.fullmatch(r"[0-9a-f]{12}", binary_revision) is None:
            raise RuntimeError(
                "invalid binary catalog revision for chunk "
                f"{chunk_index}: {binary_revision!r}"
            )
        if int(info.get("count") or 0) <= 0:
            raise RuntimeError(
                f"invalid catalog count for chunk {chunk_index}"
            )
    for chunk_index, revision in exif_revisions.items():
        if re.fullmatch(r"[0-9a-f]{12}", revision) is None:
            raise RuntimeError(
                f"invalid EXIF revision for chunk {chunk_index}: {revision!r}"
            )
    for chunk_index, info in geometry_revisions.items():
        revision = str(info.get("revision") or "")
        binary_revision = str(info.get("binaryRevision") or "")
        if re.fullmatch(r"[0-9a-f]{12}", revision) is None:
            raise RuntimeError(
                f"invalid geometry revision for chunk {chunk_index}: {revision!r}"
            )
        if re.fullmatch(r"[0-9a-f]{12}", binary_revision) is None:
            raise RuntimeError(
                "invalid binary geometry revision for chunk "
                f"{chunk_index}: {binary_revision!r}"
            )
        if sum(int(segment[0]) for segment in info.get("segments", [])) != int(
            info.get("count") or 0
        ):
            raise RuntimeError(
                f"invalid geometry summaries for chunk {chunk_index}"
            )
    for shard, revision in id_revisions.items():
        if re.fullmatch(r"[0-9a-f]{2}", shard) is None:
            raise RuntimeError(f"invalid stable-ID shard name: {shard!r}")
        if re.fullmatch(r"[0-9a-f]{12}", revision) is None:
            raise RuntimeError(
                f"invalid stable-ID shard revision for {shard}: {revision!r}"
            )
    javascript = generated_js_assignment(
        "window.__JG_CHUNK_REVISIONS__",
        payload,
    )
    input_signature = hashlib.sha256(
        javascript.encode("utf-8")
    ).hexdigest()
    rel = chunk_revision_index_relative_path(input_signature[:12])
    rel_text = rel.as_posix()
    rewritten = publish_generated_payload(
        output_dir,
        rel,
        javascript,
        input_signature,
        cache_db,
        trust_generated,
    )
    return GeneratedFilePublication(
        rel_text,
        bool(rewritten),
        input_signature[:12],
    )


def catalog_range_count(ranges: Iterable[tuple[int, int]]) -> int:
    return sum(max(0, int(end) - int(start)) for start, end in ranges)


def gallery_page_config(
    page_dir: Path,
    image_count: int,
    name_prefix: str,
    settings: ClientSettings,
) -> GalleryPageConfig:
    """Return the page-specific client configuration in its descriptor."""
    order_info = settings.random_order
    random_order = {
        "enabled": bool(order_info.get("enabled")),
        "available": bool(order_info.get("available")),
        "count": int(order_info.get("count") or 0),
        "groupCount": int(order_info.get("groupCount") or 0),
    }
    if random_order["available"]:
        random_order.update({
            "binaryUrl": relative_output_url(
                page_dir, str(order_info["binaryPath"])
            ),
            "binaryRevision": str(order_info["binaryRevision"]),
            "fallbackUrl": relative_output_url(
                page_dir, str(order_info["fallbackPath"])
            ),
            "fallbackRevision": str(order_info["fallbackRevision"]),
        })
    return {
        "dataFormatVersion": GALLERY_DATA_FORMAT_VERSION,
        "targetRowHeight": settings.target_row_height,
        "gap": settings.gap,
        "count": image_count,
        "namePrefix": name_prefix,
        "chunkSize": settings.catalog_chunk_size,
        "widths": list(settings.requested_widths),
        "staticExt": static_extension(settings.static_output_format),
        "maxActiveTileLoads": settings.max_active_tile_loads,
        "imageLoadMargin": settings.image_load_margin,
        "tooltipDelay": settings.tooltip_delay,
        "clientDiagnostics": bool(settings.client_diagnostics),
        "includeOriginals": bool(settings.include_originals),
        "videoTranscodeEnabled": bool(settings.transcode_incompatible_videos),
        "imageIdHexLength": settings.image_id_bits // 4,
        "minImageIdHexLength": MIN_IMAGE_ID_BITS // 4,
        "stableImageIdHexLength": STABLE_IMAGE_ID_BITS // 4,
        "assetBase": relative_output_url(page_dir, ASSET_DIRNAME),
        "catalogBase": relative_output_url(page_dir, GALLERY_DATA_CATALOG_DIR),
        "catalogChunkFilenameWidth": CATALOG_CHUNK_FILENAME_WIDTH,
        "exifBase": relative_output_url(page_dir, GALLERY_DATA_EXIF_DIR),
        "geometryBase": relative_output_url(
            page_dir, GALLERY_DATA_GEOMETRY_DIR
        ),
        "geometryChunkSize": settings.geometry_chunk_size,
        "geometryChunkFilenameWidth": GEOMETRY_CHUNK_FILENAME_WIDTH,
        "layoutSegmentSize": DEFAULT_LAYOUT_SEGMENT_SIZE,
        "segmentedGeometryThreshold": SEGMENTED_GEOMETRY_THRESHOLD,
        "geometryCompatibilityEagerLimit": GEOMETRY_COMPATIBILITY_EAGER_LIMIT,
        "idBase": relative_output_url(page_dir, GALLERY_DATA_ID_DIR),
        "idShardWidth": 2,
        "revisionIndexUrl": relative_output_url(
            page_dir, settings.revision_index_rel
        ),
        "revisionIndexRevision": settings.revision_index_revision,
        "layoutWorkerUrl": (
            relative_output_url(page_dir, settings.layout_worker_asset)
            if settings.layout_worker
            else ""
        ),
        "layoutWorkerVersion": LAYOUT_WORKER_VERSION,
        "maxHotCatalogChunks": settings.max_hot_catalog_chunks,
        "maxClientMetadataMiB": settings.max_client_metadata_mib,
        "assetShardLevels": ASSET_SHARD_LEVELS,
        "assetShardWidth": ASSET_SHARD_WIDTH,
        "geometryRatioScale": GEOMETRY_RATIO_SCALE,
        "randomOrder": random_order,
        "dataRevision": settings.data_revision,
    }


def chunk_indexes_for_ranges(
    ranges: Sequence[tuple[int, int]],
    chunk_size: int,
) -> set[int]:
    """Return every fixed-size global chunk touched by page ranges."""
    size = max(1, int(chunk_size))
    indexes: set[int] = set()
    for start, end in ranges:
        first = max(0, int(start))
        stop = max(first, int(end))
        if stop > first:
            indexes.update(range(first // size, ((stop - 1) // size) + 1))
    return indexes


def build_page_bootstrap(
    cache_db: SQLiteCache,
    record_ranges: Sequence[tuple[int, int]],
    page_count: int,
    catalog_chunk_size: int,
    geometry_chunk_size: int,
    revision_payload: dict[str, Any],
) -> dict[str, Any]:
    """Build the compact data needed to render a first viewport immediately."""
    geometry_limit = 0
    if page_count <= PAGE_BOOTSTRAP_GEOMETRY_ITEMS:
        geometry_limit = page_count
    elif page_count >= SEGMENTED_GEOMETRY_THRESHOLD:
        geometry_limit = PAGE_BOOTSTRAP_GEOMETRY_ITEMS
    record_limit = max(
        min(page_count, PAGE_BOOTSTRAP_CATALOG_ITEMS),
        geometry_limit,
    )
    values = cache_db.scan.catalog_bootstrap_for_ranges(
        record_ranges,
        record_limit,
    )
    catalog_values = values[:min(page_count, PAGE_BOOTSTRAP_CATALOG_ITEMS)]
    geometry_values = values[:geometry_limit]
    catalog_items = [
        [catalog_id, item]
        for catalog_id, item, _ratio in catalog_values
    ]
    geometry_ratios = [ratio for _catalog_id, _item, ratio in geometry_values]

    all_catalog_revisions = revision_payload.get("catalog", {})
    all_geometry_revisions = revision_payload.get("geometry", {})
    catalog_indexes = {
        catalog_id // max(1, int(catalog_chunk_size))
        for catalog_id, _item, _ratio in catalog_values
    }
    geometry_indexes = chunk_indexes_for_ranges(
        record_ranges,
        geometry_chunk_size,
    )

    def selected_revisions(
        source: Any,
        indexes: set[int],
    ) -> dict[str, Any]:
        if not isinstance(source, dict):
            return {}
        return {
            str(index): source[str(index)]
            for index in sorted(indexes)
            if str(index) in source
        }

    return {
        "catalogItems": catalog_items,
        "geometryRatios": geometry_ratios,
        "revisions": {
            "catalog": selected_revisions(
                all_catalog_revisions,
                catalog_indexes,
            ),
            "geometry": selected_revisions(
                all_geometry_revisions,
                geometry_indexes,
            ),
        },
    }


def write_page_descriptor(
    output_dir: Path,
    route: Path,
    record_ranges: Sequence[tuple[int, int]],
    page_config: GalleryPageConfig,
    cache_db: SQLiteCache,
    trust_generated: bool,
    revision_payload: dict[str, Any],
) -> PageDescriptorPublication:
    """Write page membership, first-viewport data, and client configuration."""
    ranges = [
        [int(start), int(end)]
        for start, end in record_ranges
        if int(end) > int(start)
    ]
    described_count = sum(end - start for start, end in ranges)
    expected_count = int(page_config.get("count") or 0)
    if described_count != expected_count:
        raise RuntimeError(
            f"page range count mismatch for {route}: "
            f"expected {expected_count}, got {described_count}"
        )
    descriptor_config = dict(page_config)
    descriptor_config["bootstrap"] = build_page_bootstrap(
        cache_db=cache_db,
        record_ranges=[(start, end) for start, end in ranges],
        page_count=expected_count,
        catalog_chunk_size=int(page_config.get("chunkSize") or 1),
        geometry_chunk_size=int(page_config.get("geometryChunkSize") or 1),
        revision_payload=revision_payload,
    )
    javascript = (
        generated_js_assignment("window.__JG_CONFIG__", descriptor_config)
        + generated_js_assignment("window.__JG_PAGE_RANGES__", ranges)
    )
    input_signature = hashlib.sha256(
        javascript.encode("utf-8")
    ).hexdigest()
    rel = page_descriptor_relative_path(route, input_signature[:12])
    rel_text = rel.as_posix()

    rewritten = publish_generated_payload(
        output_dir,
        rel,
        javascript,
        input_signature,
        cache_db,
        trust_generated,
    )
    return PageDescriptorPublication(rel_text, {rel_text}, rewritten)


def build_directory_pages(
    records: Iterable[CatalogRecord],
    route_roots: dict[Path, Path],
) -> DirectoryPageIndex:
    """Build directory-page membership as half-open catalog-ID ranges.

    Catalog IDs are assigned in deterministic source order. When consecutive
    images belong to the same directory subtree, their IDs collapse into one
    [start, end) range. Deep galleries therefore avoid the previous
    images-times-directory-depth collection of individual Python integers.
    """
    page_record_ranges: dict[Path, list[tuple[int, int]]] = {}
    page_meta: dict[Path, tuple[Path, Path]] = {}
    local_directories_by_root: dict[Path, set[Path]] = {}

    def append_catalog_id(route: Path, catalog_id: int) -> None:
        ranges = page_record_ranges.setdefault(route, [])
        if ranges and ranges[-1][1] == catalog_id:
            start, _end = ranges[-1]
            ranges[-1] = (start, catalog_id + 1)
        else:
            ranges.append((catalog_id, catalog_id + 1))

    for record in records:
        source_root = record.get("source_root")
        root_relative = record.get("root_relative")
        if source_root is None or root_relative is None:
            continue

        source_root = Path(source_root)
        root_relative = Path(root_relative)
        catalog_id = int(record["catalog_id"])
        local_dir = root_relative.parent
        local_directories_by_root.setdefault(source_root, set())

        while True:
            route_base = route_roots[source_root]
            route = route_base / (local_dir if str(local_dir) != "." else Path())
            append_catalog_id(route, catalog_id)
            page_meta[route] = (source_root, local_dir)
            local_directories_by_root[source_root].add(local_dir)

            if str(local_dir) == ".":
                break
            local_dir = local_dir.parent

    child_links: dict[Path, list[tuple[str, Path]]] = {}
    for source_root, local_directories in local_directories_by_root.items():
        route_base = route_roots[source_root]
        children_by_parent: dict[Path, list[Path]] = {}
        for candidate in local_directories:
            if str(candidate) == ".":
                continue
            children_by_parent.setdefault(candidate.parent, []).append(candidate)

        for local_dir in local_directories:
            route = route_base / (local_dir if str(local_dir) != "." else Path())
            children = [
                (candidate.name, route_base / candidate)
                for candidate in children_by_parent.get(local_dir, [])
            ]
            children.sort(key=lambda item: natural_key(item[0]))
            child_links[route] = children

    return DirectoryPageIndex(page_record_ranges, child_links, page_meta)


LAYOUT_WORKER_JS = r'''"use strict";

self.onmessage = event => {
  const data = event.data || {};
  const requestId = Number(data.requestId) || 0;
  const ratios = new Float32Array(data.ratiosBuffer);
  const containerWidth = Math.max(1, Number(data.containerWidth) || 1);
  const targetRowHeight = Math.max(1, Number(data.targetRowHeight) || 1);
  const gap = Math.max(0, Number(data.gap) || 0);
  const startIndex = Math.max(0, Math.trunc(Number(data.startIndex) || 0));

  const rows = [];
  let rowStart = 0;
  let rowCount = 0;
  let ratioSum = 0;

  function heightFor(sum, count) {
    const gaps = gap * Math.max(0, count - 1);
    return (containerWidth - gaps) / Math.max(Number.EPSILON, sum);
  }

  function pushRow(localStartIndex, count, height, complete) {
    rows.push(startIndex + localStartIndex, count, height, complete ? 1 : 0);
  }

  for (let pageIndex = 0; pageIndex < ratios.length; pageIndex += 1) {
    const currentRatio = Math.max(Number.EPSILON, ratios[pageIndex] || 0);
    if (rowCount === 0) rowStart = pageIndex;
    rowCount += 1;
    ratioSum += currentRatio;

    const height = heightFor(ratioSum, rowCount);
    if (height <= targetRowHeight) {
      if (rowCount > 1) {
        const previousCount = rowCount - 1;
        const previousRatioSum = ratioSum - currentRatio;
        const previousHeight = heightFor(previousRatioSum, previousCount);
        if (
          Math.abs(previousHeight - targetRowHeight) <
            Math.abs(height - targetRowHeight) &&
          previousHeight <= targetRowHeight * 1.5
        ) {
          pushRow(rowStart, previousCount, previousHeight, true);
          rowStart = pageIndex;
          rowCount = 1;
          ratioSum = currentRatio;
          continue;
        }
      }
      pushRow(rowStart, rowCount, height, true);
      rowCount = 0;
      ratioSum = 0;
    }
  }

  if (rowCount) {
    pushRow(
      rowStart,
      rowCount,
      Math.min(targetRowHeight, heightFor(ratioSum, rowCount)),
      false
    );
  }

  const packed = new Float64Array(rows);
  self.postMessage(
    { requestId, rowsBuffer: packed.buffer },
    [packed.buffer]
  );
};
'''

GALLERY_CSS = r""":root {
  color-scheme: light dark;
  --bg: #111;
  --fg: #eee;
  --muted: #aaa;
  --panel: #181818;
  --panel-hover: #202020;
  --topbar-bg: #141414;
  --line: #2a2a2a;
  --tile-bg: #1a1a1a;
  --panel-shadow: rgba(0, 0, 0, .4);
  --tile-hover-brightness: 1.06;
}
@media (prefers-color-scheme: light) {
  :root:not([data-theme]) {
    color-scheme: light;
    --bg: #f4f4f4;
    --fg: #171717;
    --muted: #666;
    --panel: #fff;
    --panel-hover: #e9e9e9;
    --topbar-bg: #fafafa;
    --line: #d4d4d4;
    --tile-bg: #e5e5e5;
    --panel-shadow: rgba(0, 0, 0, .16);
    --tile-hover-brightness: .94;
  }
}
:root[data-theme="dark"] {
  color-scheme: dark;
  --bg: #111;
  --fg: #eee;
  --muted: #aaa;
  --panel: #181818;
  --panel-hover: #202020;
  --topbar-bg: #141414;
  --line: #2a2a2a;
  --tile-bg: #1a1a1a;
  --panel-shadow: rgba(0, 0, 0, .4);
  --tile-hover-brightness: 1.06;
}
:root[data-theme="light"] {
  color-scheme: light;
  --bg: #f4f4f4;
  --fg: #171717;
  --muted: #666;
  --panel: #fff;
  --panel-hover: #e9e9e9;
  --topbar-bg: #fafafa;
  --line: #d4d4d4;
  --tile-bg: #e5e5e5;
  --panel-shadow: rgba(0, 0, 0, .16);
  --tile-hover-brightness: .94;
}
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; background: var(--bg); color: var(--fg); }
body {
  font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  overflow-y: scroll;
}
#gallery-content { display: contents; }
#gallery-content[hidden],
#password-gate[hidden] { display: none; }
#password-gate {
  min-height: 100vh;
  min-height: 100dvh;
  display: grid;
  place-items: center;
  padding:
    max(1rem, env(safe-area-inset-top))
    max(1rem, env(safe-area-inset-right))
    max(1rem, env(safe-area-inset-bottom))
    max(1rem, env(safe-area-inset-left));
}
.password-card {
  width: min(100%, 24rem);
  padding: 1.25rem;
  border: 1px solid var(--line);
  border-radius: .7rem;
  background: var(--topbar-bg);
  box-shadow: 0 .75rem 2.5rem var(--panel-shadow);
  text-align: center;
}
.password-card h1 {
  margin: 0 0 .45rem;
  font-size: 1.25rem;
}
.password-card p {
  margin: 0 0 1rem;
  color: var(--muted);
  line-height: 1.45;
}
#password-form {
  display: grid;
  gap: .7rem;
}
.password-label {
  color: var(--fg);
  font-size: .9rem;
  font-weight: 600;
  text-align: left;
}
#gallery-password {
  width: 100%;
  min-height: 2.75rem;
  padding: .6rem .75rem;
  border: 1px solid var(--line);
  border-radius: .45rem;
  background: var(--panel);
  color: var(--fg);
  font: inherit;
  text-align: left;
}
#gallery-password:focus-visible {
  outline: 2px solid currentColor;
  outline-offset: 2px;
}
#password-submit {
  min-height: 2.75rem;
  padding: .6rem .9rem;
  border: 1px solid var(--line);
  border-radius: .45rem;
  background: var(--panel);
  color: var(--fg);
  cursor: pointer;
  font: inherit;
  font-weight: 650;
}
#password-submit:hover,
#password-submit:focus-visible { background: var(--panel-hover); }
#password-submit:disabled,
#gallery-password:disabled {
  cursor: wait;
  opacity: .7;
}
#password-status {
  min-height: 1.25rem;
  margin: 0;
  color: var(--muted);
  font-size: .88rem;
}
#password-status.password-error { color: #d94a4a; }
.password-noscript {
  margin: 2rem auto;
  padding: 1rem;
  max-width: 32rem;
  text-align: center;
}
#topbar {
  padding:
    calc(.85rem + env(safe-area-inset-top))
    max(1rem, env(safe-area-inset-right))
    .35rem
    max(1rem, env(safe-area-inset-left));
  background: var(--topbar-bg);
  position: sticky;
  top: 0;
  z-index: 10;
}
#topbar-main {
  display: flex;
  align-items: center;
  gap: .75rem;
  min-width: 0;
}
#topbar h1 {
  flex: 1 1 auto;
  min-width: 0;
  margin: 0;
  font-size: 1.1rem;
  line-height: 1.25;
}
.page-credit {
  margin-left: .55rem;
  font-size: .68rem;
  font-weight: 400;
  color: var(--muted);
  white-space: nowrap;
}
.page-credit a {
  color: inherit;
  text-decoration: none;
  opacity: .72;
}
.page-credit a:hover, .page-credit a:focus-visible {
  opacity: 1;
  text-decoration: underline;
}
#meta {
  position: relative;
  z-index: 9;
  display: flex;
  flex-wrap: wrap;
  gap: .5rem .9rem;
  align-items: center;
  width: 100%;
  padding:
    0
    max(1rem, env(safe-area-inset-right))
    .9rem
    max(1rem, env(safe-area-inset-left));
  border-bottom: 1px solid var(--line);
  background: var(--topbar-bg);
}
#count {
  color: var(--muted);
  font-size: .92rem;
}
.custom-html {
  width: 100%;
  padding:
    .9rem
    max(1rem, env(safe-area-inset-right))
    .9rem
    max(1rem, env(safe-area-inset-left));
  overflow-wrap: anywhere;
  background: var(--topbar-bg);
  color: var(--fg);
}
.custom-html-inner {
  width: 100%;
  max-width: 80rem;
  margin: 0 auto;
  text-align: center;
}
.custom-html-inner > :first-child { margin-top: 0; }
.custom-html-inner > :last-child { margin-bottom: 0; }
.custom-html a {
  color: inherit;
  text-underline-offset: .15em;
}
#custom-header-html { border-bottom: 1px solid var(--line); }
#custom-footer-html {
  border-top: 1px solid var(--line);
  padding-bottom: calc(.9rem + env(safe-area-inset-bottom));
}
#topnav,
#page-nav {
  display: flex;
  flex-wrap: wrap;
  gap: .5rem;
  align-items: center;
  justify-content: flex-end;
  margin-left: auto;
}
#topnav {
  flex: 0 0 auto;
  flex-wrap: nowrap;
}
.visually-hidden {
  position: absolute !important;
  width: 1px !important;
  height: 1px !important;
  padding: 0 !important;
  margin: -1px !important;
  overflow: hidden !important;
  clip: rect(0, 0, 0, 0) !important;
  white-space: nowrap !important;
  border: 0 !important;
}
#sort-controls {
  display: inline-flex;
  align-items: center;
  gap: .35rem;
}
#sort-controls[hidden],
#reshuffle[hidden] {
  display: none;
}
#sort-mode,
#reshuffle {
  min-height: 2.3rem;
  color: inherit;
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: .45rem;
  font: .88rem/1 system-ui, sans-serif;
}
#sort-mode {
  width: 11rem;
  max-width: calc(100vw - 2rem);
  padding: .35rem 1.75rem .35rem .55rem;
  cursor: pointer;
}
#reshuffle {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 2.3rem;
  padding: 0;
  cursor: pointer;
  font-size: 1.2rem;
}
#sort-mode:hover, #sort-mode:focus-visible,
#reshuffle:hover, #reshuffle:focus-visible {
  background: var(--panel-hover);
}
#sort-mode:disabled, #reshuffle:disabled {
  cursor: progress;
  opacity: .65;
}
.top-link {
  color: inherit;
  text-decoration: none;
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: .45rem;
  padding: .35rem .6rem;
  font-size: .92rem;
}
.top-link:hover, .top-link:focus-visible {
  background: var(--panel-hover);
}
#theme-toggle,
#gallery-lock {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 2.3rem;
  height: 2.3rem;
  padding: 0;
  color: inherit;
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: .45rem;
  cursor: pointer;
  user-select: none;
  font: 1.15rem/1 system-ui, sans-serif;
}
#theme-toggle:hover, #theme-toggle:focus-visible,
#gallery-lock:hover, #gallery-lock:focus-visible {
  background: var(--panel-hover);
}
#gallery-lock svg {
  width: 1.2rem;
  height: 1.2rem;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.8;
  stroke-linecap: round;
  stroke-linejoin: round;
}
#children-menu {
  position: relative;
}
#children-menu[open] {
  z-index: 30;
}
#children-menu[open]::before {
  content: "";
  position: fixed;
  inset: 0;
  z-index: 0;
  background: transparent;
}
#children-toggle {
  list-style: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 2.3rem;
  height: 2.3rem;
  padding: 0;
  color: inherit;
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: .45rem;
  cursor: pointer;
  user-select: none;
  font-size: 1.15rem;
  line-height: 1;
}
#children-toggle::-webkit-details-marker {
  display: none;
}
#children-toggle::marker {
  content: "";
}
#children-toggle:hover, #children-toggle:focus-visible {
  background: var(--panel-hover);
}
#children-menu[open] #children-toggle {
  position: relative;
  z-index: 1;
}
#children-panel {
  position: absolute;
  top: calc(100% + .45rem);
  right: 0;
  width: min(24rem, calc(100vw - 2rem));
  max-width: calc(100vw - 2rem);
  max-height: min(70vh, 36rem);
  overflow: auto;
  padding: .85rem;
  background: var(--topbar-bg);
  border: 1px solid var(--line);
  border-radius: .55rem;
  box-shadow: 0 .5rem 1.5rem var(--panel-shadow);
  z-index: 1;
}
#children-panel h2 {
  margin: 0 0 .6rem;
  font-size: .95rem;
  color: var(--muted);
  font-weight: 600;
}
#children-panel ul {
  list-style: none;
  display: flex;
  flex-direction: column;
  gap: .35rem;
  margin: 0;
  padding: 0;
}
#children-panel a {
  display: block;
  color: inherit;
  text-decoration: none;
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: .45rem;
  padding: .45rem .65rem;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
#children-panel a:hover, #children-panel a:focus-visible {
  background: var(--panel-hover);
}
#gallery {
  width: 100%;
  position: relative;
  min-height: 1px;
  overflow-anchor: none;
}
.gallery-row {
  position: absolute;
  left: env(safe-area-inset-left);
  right: env(safe-area-inset-right);
  width: auto;
  display: flex;
  gap: 0;
  overflow: hidden;
  contain: layout paint style;
}
.geometry-load-error {
  align-items: flex-start;
  justify-content: center;
  padding: 1rem;
  background: color-mix(in srgb, var(--panel) 92%, transparent);
  color: var(--fg);
  text-align: center;
}
.geometry-load-error-inner {
  position: sticky;
  top: calc(4.75rem + env(safe-area-inset-top));
  display: grid;
  justify-items: center;
  gap: .55rem;
  margin: 0 auto;
  max-width: 32rem;
}
.geometry-load-error p { margin: 0; }
.geometry-load-error-detail {
  color: var(--muted);
  font-size: .82rem;
  overflow-wrap: anywhere;
}
.geometry-load-error-actions {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: .5rem;
}
.geometry-load-error button {
  min-height: 2.35rem;
  padding: .45rem .75rem;
  border: 1px solid var(--line);
  border-radius: .45rem;
  background: var(--panel-hover);
  color: var(--fg);
  cursor: pointer;
  font: inherit;
}
.geometry-load-error button:hover,
.geometry-load-error button:focus-visible { filter: brightness(1.12); }
.tile {
  display: block;
  min-width: 0;
  height: 100%;
  padding: 0;
  margin: 0;
  border: 0;
  background: var(--tile-bg);
  overflow: hidden;
  text-decoration: none;
  position: relative;
}
.tile.image-loading::before {
  content: "Loading image\2026";
  position: absolute;
  inset: 0;
  z-index: 1;
  display: grid;
  place-items: center;
  padding: .75rem;
  background: transparent;
  color: #fff;
  font-size: .82rem;
  text-align: center;
  text-shadow:
    0 1px 3px rgba(0, 0, 0, .9),
    0 0 10px rgba(0, 0, 0, .65);
  pointer-events: none;
}
.tile.load-error::after {
  content: "Tap to retry";
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  padding: .75rem;
  background: rgba(0, 0, 0, .6);
  color: #fff;
  font-size: .85rem;
  text-align: center;
  pointer-events: none;
}
.tile img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: contain;
  background: var(--tile-bg);
  opacity: 0;
  transform: scale(1);
  filter: brightness(1);
  transition: transform 120ms ease, filter 120ms ease;
}
.tile.image-loaded img {
  opacity: 1;
}
.video-placeholder {
  position: absolute;
  inset: 0;
  display: none;
  place-items: center;
  background:
    radial-gradient(circle at 50% 40%, rgba(255,255,255,.12), transparent 42%),
    linear-gradient(145deg, #252525, #111);
  color: rgba(255, 255, 255, .72);
  font-size: .72rem;
  font-weight: 650;
  letter-spacing: .12em;
  text-transform: uppercase;
}
.tile.video-no-poster .video-placeholder { display: grid; }
.tile.video-no-poster img { display: none; }
.video-play-indicator {
  position: absolute;
  z-index: 3;
  left: 50%;
  top: 50%;
  width: clamp(2.3rem, 18%, 4.2rem);
  aspect-ratio: 1;
  display: none;
  place-items: center;
  border: 1px solid rgba(255, 255, 255, .5);
  border-radius: 50%;
  background: rgba(0, 0, 0, .56);
  color: #fff;
  box-shadow: 0 .2rem 1rem rgba(0, 0, 0, .4);
  transform: translate(-50%, -50%);
  pointer-events: none;
  backdrop-filter: blur(3px);
  -webkit-backdrop-filter: blur(3px);
}
.video-play-indicator svg {
  width: 46%;
  height: 46%;
  margin-left: 7%;
  fill: currentColor;
}
.tile.video-tile.image-loaded .video-play-indicator,
.tile.video-tile.video-no-poster .video-play-indicator { display: grid; }
.tile-tooltip {
  position: absolute;
  left: 50%;
  bottom: .45rem;
  z-index: 4;
  max-width: calc(100% - .7rem);
  padding: .28rem .45rem;
  border: 1px solid rgba(255, 255, 255, .16);
  border-radius: .35rem;
  background: rgba(0, 0, 0, .82);
  color: #eee;
  box-shadow: 0 .2rem .65rem rgba(0, 0, 0, .3);
  font-size: .76rem;
  line-height: 1.2;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  pointer-events: none;
  user-select: none;
  opacity: 0;
  visibility: hidden;
  transform: translate(-50%, .2rem);
  transition:
    opacity 80ms ease,
    transform 80ms ease,
    visibility 0s linear 80ms;
}
.tile.tooltip-visible .tile-tooltip {
  opacity: 1;
  visibility: visible;
  transform: translate(-50%, 0);
  transition-delay: 0s;
}
@media (hover: hover) {
  .tile.image-loaded:hover img,
  .tile.image-loaded:focus-visible img {
    transform: scale(1.025);
    filter: brightness(var(--tile-hover-brightness));
  }
}
@media (prefers-reduced-motion: reduce) {
  .tile img {
    transition: none;
  }
}
#empty {
  display: none;
  padding: 2rem 1rem;
  color: var(--muted);
}
#empty.gallery-load-failure {
  color: var(--fg);
}
.empty-error-detail {
  display: block;
  margin: 0.75rem 0;
  color: var(--muted);
  overflow-wrap: anywhere;
}
.empty-retry {
  border: 1px solid var(--line);
  border-radius: 0.4rem;
  padding: 0.45rem 0.8rem;
  color: var(--fg);
  background: var(--panel);
  cursor: pointer;
}
#client-diagnostics {
  position: fixed;
  left: max(.5rem, env(safe-area-inset-left));
  bottom: max(.5rem, env(safe-area-inset-bottom));
  z-index: 1000;
  margin: 0;
  padding: .5rem .6rem;
  max-width: calc(100vw - 1rem);
  border: 1px solid rgba(255, 255, 255, .18);
  border-radius: .4rem;
  background: rgba(0, 0, 0, .78);
  color: #eee;
  font: 11px/1.35 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  white-space: pre;
  pointer-events: none;
  user-select: text;
  -webkit-user-select: text;
  backdrop-filter: blur(4px);
  -webkit-backdrop-filter: blur(4px);
}
#lightbox {
  --exif-panel-width: clamp(17rem, 24vw, 23rem);
  --drawer-control-offset: 0px;
  --drawer-transition: 220ms cubic-bezier(.2, .75, .25, 1);
  --corner-control-size: clamp(2.75rem, 2.5vw, 4rem);
  --secondary-control-size: clamp(1.95rem, 1.75vw, 2.8rem);
  --secondary-control-inset: clamp(.4rem, .375vw, .6rem);
  --corner-control-gap: clamp(.5rem, .35vw, .75rem);
  position: fixed;
  inset: 0;
  border: 0;
  padding: 0;
  margin: 0;
  width: 100vw;
  width: 100dvw;
  max-width: none;
  height: 100vh;
  height: 100dvh;
  max-height: none;
  overflow: hidden;
  background: transparent;
  color: white;
  isolation: isolate;
  touch-action: auto;
  overscroll-behavior: none;
  -webkit-tap-highlight-color: transparent;
}
#lightbox::backdrop { background: rgba(0, 0, 0, 0.86); }
#lightbox-shell {
  position: relative;
  z-index: 0;
  width: 100%;
  height: 100%;
  min-width: 0;
  min-height: 0;
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  grid-template-rows: minmax(0, 1fr) auto;
  overflow: hidden;
  touch-action: auto;
  overscroll-behavior: none;
}
#lightbox-stage {
  grid-column: 1;
  grid-row: 1;
  position: relative;
  min-width: 0;
  min-height: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
  padding:
    max(.5rem, env(safe-area-inset-top))
    max(.5rem, env(safe-area-inset-right))
    max(.5rem, env(safe-area-inset-bottom))
    max(.5rem, env(safe-area-inset-left));
  cursor: default;
  touch-action: none;
  overscroll-behavior: contain;
}
#lightbox-image {
  position: relative;
  z-index: 0;
  display: block;
  max-width: 100%;
  max-height: 100%;
  width: auto;
  height: auto;
  object-fit: contain;
  transform: translate3d(0, 0, 0) scale(1);
  transform-origin: center center;
  will-change: transform;
  user-select: none;
  -webkit-user-select: none;
  -webkit-user-drag: none;
  touch-action: none;
  cursor: zoom-in;
}
#lightbox-image.animated {
  will-change: auto;
}
#lightbox-image.animation-proxy {
  opacity: 0;
}
.lightbox-animation {
  position: absolute;
  z-index: 1;
  inset: 0;
  display: block;
  margin: auto;
  max-width: none;
  max-height: none;
  object-fit: contain;
  transform: none;
  transform-origin: center center;
  pointer-events: none;
  user-select: none;
  -webkit-user-select: none;
  -webkit-user-drag: none;
}
#lightbox-video {
  position: relative;
  z-index: 0;
  display: block;
  max-width: 100%;
  max-height: 100%;
  width: auto;
  height: auto;
  object-fit: contain;
  background: #000;
  touch-action: auto;
}
#lightbox-image[hidden],
#lightbox-video[hidden] { display: none; }
#video-playback-error {
  position: absolute;
  z-index: 5;
  width: min(30rem, calc(100% - 2rem));
  padding: 1rem;
  border: 1px solid rgba(255, 255, 255, .24);
  border-radius: .65rem;
  background: rgba(18, 18, 18, .92);
  color: #f2f2f2;
  box-shadow: 0 .5rem 2rem rgba(0, 0, 0, .45);
  text-align: center;
}
#video-playback-error[hidden] { display: none; }
#video-playback-error p { margin: 0; line-height: 1.45; }
.video-fallback-actions {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: .6rem;
  margin-top: .85rem;
}
.video-fallback-actions a {
  display: inline-flex;
  align-items: center;
  min-height: 2.4rem;
  padding: .45rem .75rem;
  border: 1px solid rgba(255, 255, 255, .3);
  border-radius: .45rem;
  background: rgba(255, 255, 255, .1);
  color: #fff;
  text-decoration: none;
}
.video-fallback-actions a:hover,
.video-fallback-actions a:focus-visible {
  background: rgba(255, 255, 255, .2);
}
#lightbox.video-active #lightbox-stage { touch-action: auto; }
#lightbox-image.zoomed { cursor: grab; }
#lightbox-image.dragging { cursor: grabbing; }
#lightbox-caption {
  grid-column: 1;
  grid-row: 2;
  padding: .5rem .75rem calc(.5rem + env(safe-area-inset-bottom));
  min-height: 2rem;
  text-align: center;
  font-size: .875rem;
  color: #ddd;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
#lightbox-exif {
  position: absolute;
  z-index: 25;
  inset: 0 0 0 auto;
  width: var(--exif-panel-width);
  box-sizing: border-box;
  overflow: auto;
  padding:
    max(1rem, env(safe-area-inset-top))
    max(1rem, env(safe-area-inset-right))
    max(1rem, env(safe-area-inset-bottom))
    1rem;
  border-left: 1px solid rgba(255, 255, 255, .14);
  background: rgba(18, 18, 18, .96);
  color: #eee;
  box-shadow: -.75rem 0 2rem rgba(0, 0, 0, .35);
  overscroll-behavior: contain;
  touch-action: pan-y;
  -webkit-overflow-scrolling: touch;
  visibility: hidden;
  opacity: 0;
  pointer-events: none;
  transform: translate3d(100%, 0, 0);
  transition:
    transform var(--drawer-transition),
    opacity var(--drawer-transition),
    visibility 0s linear 220ms;
}
#lightbox.exif-open {
  --drawer-control-offset: var(--exif-panel-width);
}
#lightbox.exif-open #lightbox-exif {
  visibility: visible;
  opacity: 1;
  pointer-events: auto;
  transform: translate3d(0, 0, 0);
  transition-delay: 0s;
}
#lightbox-exif h2 {
  margin: 0 0 1rem;
  font-size: 1rem;
  font-weight: 650;
  letter-spacing: .04em;
  text-transform: uppercase;
}
#lightbox-exif-list {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: .2rem;
  margin: 0;
}
#lightbox-exif-list dt {
  margin-top: .65rem;
  color: #aaa;
  font-size: .75rem;
  font-weight: 600;
  letter-spacing: .025em;
}
#lightbox-exif-list dd {
  margin: 0;
  color: #f2f2f2;
  font-size: .9rem;
  line-height: 1.35;
  overflow-wrap: anywhere;
}
#lightbox-exif-status {
  margin: 1rem 0 0;
  color: #aaa;
  font-size: .8rem;
  line-height: 1.35;
}
.nav {
  position: absolute;
  top: 50%;
  translate: 0 -50%;
  z-index: 20;
  width: 3rem;
  height: 5rem;
  border: 0;
  border-radius: .35rem;
  background: rgba(0,0,0,.35);
  color: white;
  font: 2rem/1 system-ui, sans-serif;
  cursor: pointer;
}
.nav:hover, .nav:focus-visible { background: rgba(0,0,0,.65); }
#prev { left: .5rem; }
#next {
  right: calc(var(--drawer-control-offset) + .5rem);
  transition: right var(--drawer-transition), background-color 120ms ease;
}
#download-original,
#lightbox-info-toggle,
#close {
  position: absolute;
  z-index: 30;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  border-radius: 50%;
  color: white;
  cursor: pointer;
  transition:
    right var(--drawer-transition),
    background-color 120ms ease,
    border-color 120ms ease;
}
#download-original,
#lightbox-info-toggle {
  top: calc(
    max(.5rem, env(safe-area-inset-top))
    + var(--secondary-control-inset)
  );
  width: var(--secondary-control-size);
  height: var(--secondary-control-size);
  box-sizing: border-box;
  border: 1px solid rgba(255, 255, 255, .42);
  background: rgba(128, 128, 128, .68);
  box-shadow: 0 1px .4rem rgba(0, 0, 0, .45);
  backdrop-filter: blur(3px);
  -webkit-backdrop-filter: blur(3px);
}
#download-original:hover,
#download-original:focus-visible,
#lightbox-info-toggle:hover,
#lightbox-info-toggle:focus-visible {
  border-color: rgba(255, 255, 255, .68);
  background: rgba(156, 156, 156, .84);
}
#close:hover,
#close:focus-visible {
  background: rgba(0,0,0,.7);
}
#download-original {
  right: calc(
    var(--drawer-control-offset)
    + max(.5rem, env(safe-area-inset-right))
    + var(--corner-control-size)
    + var(--corner-control-gap)
    + var(--secondary-control-size)
    + var(--corner-control-gap)
  );
  display: flex;
  align-items: center;
  justify-content: center;
  text-decoration: none;
}
#download-original svg {
  display: block;
  width: 66%;
  height: 66%;
  fill: none;
  stroke: currentColor;
  stroke-width: 2.6;
  stroke-linecap: round;
  stroke-linejoin: round;
  pointer-events: none;
}
#lightbox-info-toggle {
  right: calc(
    var(--drawer-control-offset)
    +
    max(.5rem, env(safe-area-inset-right))
    + var(--corner-control-size)
    + var(--corner-control-gap)
  );
}
#lightbox-info-toggle svg {
  display: block;
  width: 72%;
  height: 72%;
  fill: none;
  stroke: currentColor;
  stroke-width: 3.2;
  stroke-linecap: round;
  pointer-events: none;
}
#lightbox-info-toggle[aria-expanded="true"] {
  border-color: rgba(255, 255, 255, .72);
  background: rgba(168, 168, 168, .84);
  color: white;
}
#close {
  top: max(.5rem, env(safe-area-inset-top));
  right: calc(
    var(--drawer-control-offset)
    + max(.5rem, env(safe-area-inset-right))
  );
  width: var(--corner-control-size);
  height: var(--corner-control-size);
  border: 0;
  background: rgba(0,0,0,.45);
  font: clamp(1.6rem, 1.35vw, 2.2rem)/1 system-ui, sans-serif;
}
@media (max-width: 52rem) {
  #lightbox.exif-open { --drawer-control-offset: 0px; }
  #lightbox-shell {
    grid-template-columns: minmax(0, 1fr);
    grid-template-rows: minmax(0, 1fr) auto;
  }
  #lightbox-stage {
    grid-column: 1;
    grid-row: 1;
  }
  #lightbox-caption {
    grid-column: 1;
    grid-row: 2;
  }
  #lightbox-exif {
    inset: auto 0 0;
    width: auto;
    height: min(55dvh, 30rem);
    max-height: calc(100dvh - 4.5rem);
    padding:
      .75rem
      max(1rem, env(safe-area-inset-right))
      max(.75rem, env(safe-area-inset-bottom))
      max(1rem, env(safe-area-inset-left));
    border-top: 1px solid rgba(255, 255, 255, .14);
    border-left: 0;
    box-shadow: 0 -.75rem 2rem rgba(0, 0, 0, .35);
    transform: translate3d(0, 100%, 0);
  }
  #lightbox.exif-open #lightbox-exif {
    transform: translate3d(0, 0, 0);
  }
  #lightbox-exif h2 { margin-bottom: .35rem; }
  #lightbox-exif-list {
    grid-template-columns: minmax(8rem, .8fr) minmax(0, 1.2fr);
    column-gap: .75rem;
  }
  #lightbox-exif-list dt { margin-top: .35rem; }
  #lightbox-exif-list dd { margin-top: .35rem; }
}
@media (max-width: 32rem) {
  #meta {
    flex-wrap: wrap;
  }
  #topnav {
    min-width: 0;
  }
  #children-panel {
    width: calc(100vw - 2rem);
  }
}
@media (prefers-reduced-motion: reduce) {
  * { scroll-behavior: auto !important; }
  #lightbox-exif,
  #next,
  #download-original,
  #lightbox-info-toggle,
  #close { transition: none !important; }
}"""


THEME_JS = r"""(() => {
  "use strict";

  const STORAGE_KEY = "justified-gallery-theme";
  const systemThemeQuery = window.matchMedia
    ? window.matchMedia("(prefers-color-scheme: dark)")
    : null;

  function storedTheme() {
    try {
      const value = localStorage.getItem(STORAGE_KEY);
      return value === "light" || value === "dark"
        ? value
        : null;
    } catch (_) {
      return null;
    }
  }

  function effectiveTheme() {
    const explicit = document.documentElement.dataset.theme;
    if (explicit === "light" || explicit === "dark") {
      return explicit;
    }
    return systemThemeQuery && systemThemeQuery.matches
      ? "dark"
      : "light";
  }

  function updateThemeColor() {
    const meta = document.getElementById("theme-color");
    if (meta) {
      meta.content =
        effectiveTheme() === "dark" ? "#111111" : "#f4f4f4";
    }
  }

  function updateToggle() {
    const toggle = document.getElementById("theme-toggle");
    if (!toggle) return;

    const next =
      effectiveTheme() === "dark" ? "light" : "dark";
    toggle.textContent = next === "dark" ? "☾" : "☀";
    toggle.setAttribute(
      "aria-label",
      `Switch to ${next} mode`
    );
    toggle.title = `Switch to ${next} mode`;
  }

  function updateThemeUi() {
    updateThemeColor();
    updateToggle();
  }

  function setExplicitTheme(theme) {
    document.documentElement.dataset.theme = theme;
    try {
      localStorage.setItem(STORAGE_KEY, theme);
    } catch (_) {
      // Storage may be unavailable for file:// or privacy-restricted pages.
    }
    updateThemeUi();
  }

  function bindChildrenMenuUi() {
    const menu = document.getElementById("children-menu");
    const panel = document.getElementById("children-panel");
    const toggle = document.getElementById("children-toggle");
    if (!menu || !panel || !toggle) return;

    const updateExpandedState = () => {
      toggle.setAttribute("aria-expanded", String(menu.open));
    };

    menu.addEventListener("toggle", updateExpandedState);
    updateExpandedState();

    // The transparent CSS backdrop makes an outside pointer gesture target
    // the menu rather than the tile or control behind it. This capture-phase
    // fallback also covers browsers that do not hit-test a fixed pseudo-
    // element consistently. Consume the same click that dismisses the menu so
    // it can never activate the underlying gallery item.
    document.addEventListener(
      "click",
      event => {
        if (!menu.open) return;
        const target = event.target;
        if (
          target &&
          (panel.contains(target) || toggle.contains(target))
        ) {
          return;
        }
        event.preventDefault();
        event.stopPropagation();
        menu.open = false;
        updateExpandedState();
      },
      true
    );

    document.addEventListener(
      "keydown",
      event => {
        if (event.key !== "Escape" || !menu.open) return;
        event.preventDefault();
        event.stopPropagation();
        menu.open = false;
        updateExpandedState();
        try {
          toggle.focus({ preventScroll: true });
        } catch (_) {
          toggle.focus();
        }
      },
      true
    );
  }

  const initialTheme = storedTheme();
  if (initialTheme) {
    document.documentElement.dataset.theme = initialTheme;
  }
  updateThemeColor();

  function bindThemeUi() {
    const toggle = document.getElementById("theme-toggle");
    if (toggle) {
      toggle.addEventListener("click", () => {
        setExplicitTheme(
          effectiveTheme() === "dark" ? "light" : "dark"
        );
      });
    }

    if (systemThemeQuery) {
      const handleSystemThemeChange = () => {
        if (!document.documentElement.dataset.theme) {
          updateThemeUi();
        }
      };
      if (typeof systemThemeQuery.addEventListener === "function") {
        systemThemeQuery.addEventListener(
          "change",
          handleSystemThemeChange
        );
      } else if (typeof systemThemeQuery.addListener === "function") {
        systemThemeQuery.addListener(handleSystemThemeChange);
      }
    }

    bindChildrenMenuUi();
    updateThemeUi();
  }

  if (document.readyState === "loading") {
    document.addEventListener(
      "DOMContentLoaded",
      bindThemeUi,
      { once: true }
    );
  } else {
    bindThemeUi();
  }
})();"""

PASSWORD_JS = r"""(() => {
  "use strict";

  const gate = document.getElementById("password-gate");
  const content = document.getElementById("gallery-content");
  const controls = document.getElementById("password-form");
  const input = document.getElementById("gallery-password");
  const submit = document.getElementById("password-submit");
  const status = document.getElementById("password-status");
  const lockButton = document.getElementById("gallery-lock");
  if (!gate || !content || !controls || !input || !submit || !status) return;

  // Older generated pages used a native GET form. If one of those URLs is
  // revisited, remove its leaked field from the visible/current history entry
  // before loading any gallery resources. New pages never create this query.
  try {
    const currentUrl = new URL(location.href);
    if (currentUrl.searchParams.has("gallery-password")) {
      currentUrl.searchParams.delete("gallery-password");
      history.replaceState(
        history.state,
        "",
        `${currentUrl.pathname}${currentUrl.search}${currentUrl.hash}`
      );
    }
  } catch (_) {
    // URL/history APIs can be restricted in unusual embedded environments.
  }

  const saltHex = String(gate.dataset.salt || "");
  const expectedVerifier = String(gate.dataset.verifier || "");
  const iterations = Number(gate.dataset.iterations || 0);
  const sessionTimeoutSeconds = Math.max(
    0,
    Number(gate.dataset.sessionTimeoutSeconds || 0)
  );
  const geometrySrc = String(gate.dataset.geometrySrc || "");
  const gallerySrc = String(gate.dataset.gallerySrc || "");
  const storageKey = `justified-gallery-unlocked:${expectedVerifier.slice(0, 24)}`;

  function hexToBytes(value) {
    if (!/^(?:[0-9a-f]{2})+$/i.test(value)) {
      throw new Error("invalid password verifier configuration");
    }
    const result = new Uint8Array(value.length / 2);
    for (let index = 0; index < result.length; index += 1) {
      result[index] = parseInt(value.slice(index * 2, index * 2 + 2), 16);
    }
    return result;
  }

  function bytesToHex(value) {
    return Array.from(new Uint8Array(value), byte =>
      byte.toString(16).padStart(2, "0")
    ).join("");
  }

  function constantTimeEqual(left, right) {
    let difference = left.length ^ right.length;
    const length = Math.max(left.length, right.length);
    for (let index = 0; index < length; index += 1) {
      difference |=
        (left.charCodeAt(index) || 0) ^ (right.charCodeAt(index) || 0);
    }
    return difference === 0;
  }

  async function deriveVerifier(password) {
    if (!window.crypto || !window.crypto.subtle || !window.TextEncoder) {
      throw new Error(
        "Password unlock requires HTTPS or another secure browser context."
      );
    }
    const material = await window.crypto.subtle.importKey(
      "raw",
      new TextEncoder().encode(password),
      "PBKDF2",
      false,
      ["deriveBits"]
    );
    const bits = await window.crypto.subtle.deriveBits(
      {
        name: "PBKDF2",
        hash: "SHA-256",
        salt: hexToBytes(saltHex),
        iterations,
      },
      material,
      256
    );
    return bytesToHex(bits);
  }

  let scriptUrlPolicy = null;
  if (window.trustedTypes) {
    try {
      scriptUrlPolicy = window.trustedTypes.createPolicy(
        "justified-gallery-auth",
        {
          createScriptURL(value) {
            const url = new URL(String(value), document.baseURI);
            if (url.origin !== location.origin) {
              throw new TypeError("cross-origin gallery script URL rejected");
            }
            return url.href;
          },
        }
      );
    } catch (_) {
      scriptUrlPolicy = null;
    }
  }

  function loadScript(source) {
    return new Promise((resolve, reject) => {
      const script = document.createElement("script");
      script.async = false;
      script.src = scriptUrlPolicy
        ? scriptUrlPolicy.createScriptURL(source)
        : new URL(source, document.baseURI).href;
      script.addEventListener("load", resolve, { once: true });
      script.addEventListener(
        "error",
        () => reject(new Error(`failed to load ${source}`)),
        { once: true }
      );
      document.body.append(script);
    });
  }

  let startPromise = null;
  function startGallery() {
    if (startPromise) return startPromise;
    gate.hidden = true;
    content.hidden = false;
    content.removeAttribute("inert");
    content.removeAttribute("aria-hidden");
    startPromise = (async () => {
      try {
        await loadScript(geometrySrc);
      } catch (error) {
        console.error(error);
      }
      await loadScript(gallerySrc);
    })().catch(error => {
      console.error(error);
      content.hidden = true;
      gate.hidden = false;
      status.classList.add("password-error");
      status.textContent = "The gallery could not be loaded.";
    });
    return startPromise;
  }

  let relockTimer = null;

  function forgetUnlock() {
    if (relockTimer !== null) {
      clearTimeout(relockTimer);
      relockTimer = null;
    }
    try {
      sessionStorage.removeItem(storageKey);
    } catch (_) {
      // Storage may be unavailable in privacy-restricted environments.
    }
  }

  function relockGallery() {
    forgetUnlock();
    location.reload();
  }

  function scheduleRelock(expiresAt) {
    if (!sessionTimeoutSeconds || !Number.isFinite(expiresAt)) return;
    if (relockTimer !== null) clearTimeout(relockTimer);
    const remaining = expiresAt - Date.now();
    if (remaining <= 0) {
      relockGallery();
      return;
    }
    relockTimer = setTimeout(
      () => scheduleRelock(expiresAt),
      Math.min(remaining, 2147483647)
    );
  }

  function sessionIsUnlocked() {
    try {
      const stored = sessionStorage.getItem(storageKey);
      if (stored === "1") return sessionTimeoutSeconds === 0;
      if (!stored) return false;
      const record = JSON.parse(stored);
      const expiresAt = Number(record && record.expiresAt);
      if (!sessionTimeoutSeconds || !Number.isFinite(expiresAt)) {
        forgetUnlock();
        return false;
      }
      if (expiresAt <= Date.now()) {
        forgetUnlock();
        return false;
      }
      scheduleRelock(expiresAt);
      return true;
    } catch (_) {
      forgetUnlock();
      return false;
    }
  }

  function rememberUnlock() {
    const expiresAt = sessionTimeoutSeconds > 0
      ? Date.now() + sessionTimeoutSeconds * 1000
      : null;
    try {
      if (expiresAt !== null) {
        sessionStorage.setItem(storageKey, JSON.stringify({ expiresAt }));
      } else {
        sessionStorage.setItem(storageKey, "1");
      }
    } catch (_) {
      // Private browsing modes may make session storage unavailable.
    }
    if (expiresAt !== null) scheduleRelock(expiresAt);
  }

  let unlockPending = false;
  async function attemptUnlock() {
    if (unlockPending) return;
    unlockPending = true;
    controls.setAttribute("aria-busy", "true");
    submit.disabled = true;
    input.disabled = true;
    status.classList.remove("password-error");
    status.textContent = "Checking password…";
    try {
      const password = input.value;
      input.value = "";
      const actualVerifier = await deriveVerifier(password);
      if (!constantTimeEqual(actualVerifier, expectedVerifier)) {
        status.classList.add("password-error");
        status.textContent = "Incorrect password.";
        return;
      }
      rememberUnlock();
      await startGallery();
    } catch (error) {
      console.error(error);
      status.classList.add("password-error");
      status.textContent = String(error && error.message || error);
    } finally {
      unlockPending = false;
      controls.removeAttribute("aria-busy");
      submit.disabled = false;
      input.disabled = false;
      if (!gate.hidden) input.focus();
    }
  }

  submit.addEventListener("click", attemptUnlock);
  if (lockButton) lockButton.addEventListener("click", relockGallery);
  input.addEventListener("keydown", event => {
    if (event.key !== "Enter" || event.isComposing) return;
    event.preventDefault();
    attemptUnlock();
  });

  if (sessionIsUnlocked()) {
    startGallery();
  } else {
    input.focus();
  }
})();"""

GALLERY_JS = r"""(async () => {
  "use strict";

  const config = window.__JG_CONFIG__;
  delete window.__JG_CONFIG__;
  if (!config || typeof config !== "object") {
    throw new Error("gallery configuration is missing");
  }
  const startupStarted = performance.now();
  const startupTimings = Object.create(null);

  function markStartupPhase(name) {
    const key = String(name || "");
    if (!key || Object.prototype.hasOwnProperty.call(startupTimings, key)) {
      return;
    }
    startupTimings[key] = performance.now() - startupStarted;
    try { performance.mark(`jg:${key}`); } catch (_error) {}
  }

  markStartupPhase("client-start");
  const SUPPORTED_DATA_FORMAT_VERSION = __JG_DATA_FORMAT_VERSION__;
  const CATALOG_SCHEMA = __JG_CATALOG_BINARY_SCHEMA__;
  const COMPACT_SCHEMA = __JG_COMPACT_SCHEMA__;
  let binaryCatalogLoads = 0;
  let catalogFallbackLoads = 0;
  let binaryIntegrityFailures = 0;
  const deploymentRecoveryKey = [
    "jg-deployment-recovery",
    location.pathname,
    String(config.dataRevision || "unknown")
  ].join(":");

  function deploymentRecoveryAlreadyTried() {
    try {
      return sessionStorage.getItem(deploymentRecoveryKey) === "1";
    } catch (_) {
      return false;
    }
  }

  function rememberDeploymentRecovery() {
    try {
      sessionStorage.setItem(deploymentRecoveryKey, "1");
    } catch (_) {
      // Recovery still works without storage, but cannot suppress a loop.
    }
  }

  function clearDeploymentRecovery() {
    try {
      sessionStorage.removeItem(deploymentRecoveryKey);
    } catch (_) {
      // Storage may be unavailable in privacy-restricted environments.
    }
  }

  function waitForOnline(timeoutMs = 15000) {
    if (navigator.onLine !== false) return Promise.resolve();
    return new Promise(resolve => {
      let timer = null;
      const finish = () => {
        window.removeEventListener("online", finish);
        if (timer !== null) clearTimeout(timer);
        resolve();
      };
      window.addEventListener("online", finish, { once: true });
      timer = setTimeout(finish, timeoutMs);
    });
  }

  function retryDelay(attempt) {
    const base = Math.min(4000, 250 * (2 ** Math.max(0, attempt - 1)));
    return base + Math.floor(Math.random() * Math.max(1, base * 0.2));
  }

  function yieldToMain(priority = "user-visible") {
    const scheduling = globalThis.scheduler;
    if (scheduling && typeof scheduling.yield === "function") {
      return scheduling.yield();
    }
    if (scheduling && typeof scheduling.postTask === "function") {
      return scheduling.postTask(() => {}, { priority });
    }
    return new Promise(resolve => {
      const channel = new MessageChannel();
      channel.port1.onmessage = () => {
        channel.port1.close();
        channel.port2.close();
        resolve();
      };
      channel.port2.postMessage(0);
    });
  }

  function scheduleBackgroundTask(callback, delay = 0) {
    const scheduling = globalThis.scheduler;
    if (scheduling && typeof scheduling.postTask === "function") {
      const controller = new AbortController();
      scheduling.postTask(callback, {
        priority: "background",
        delay: Math.max(0, delay),
        signal: controller.signal
      }).catch(error => {
        if (!error || error.name !== "AbortError") console.debug(error);
      });
      return { cancel: () => controller.abort() };
    }
    let handle = null;
    let idle = false;
    const run = () => callback();
    if ("requestIdleCallback" in window) {
      idle = true;
      handle = window.requestIdleCallback(run, {
        timeout: Math.max(900, delay)
      });
    } else {
      handle = setTimeout(run, Math.max(0, delay));
    }
    return {
      cancel() {
        if (handle === null) return;
        if (idle && "cancelIdleCallback" in window) {
          window.cancelIdleCallback(handle);
        } else {
          clearTimeout(handle);
        }
        handle = null;
      }
    };
  }

  async function requestFreshDeployment(reason) {
    if (deploymentRecoveryAlreadyTried()) return false;
    rememberDeploymentRecovery();
    console.warn("Refreshing stale gallery deployment", reason);
    await waitForOnline();
    if (location.protocol !== "file:" && typeof fetch === "function") {
      try {
        await fetch(location.href, {
          cache: "reload",
          credentials: "same-origin",
          redirect: "follow"
        });
      } catch (_) {
        // A normal reload below remains the final recovery attempt.
      }
    }
    location.reload();
    return true;
  }

  if (Number(config.dataFormatVersion) !== SUPPORTED_DATA_FORMAT_VERSION) {
    const error = new Error(
      `gallery data format mismatch: expected ${SUPPORTED_DATA_FORMAT_VERSION}, ` +
      `got ${config.dataFormatVersion}`
    );
    if (await requestFreshDeployment(error.message)) return;
    throw error;
  }

  const trustedTypesApi = window.trustedTypes || null;
  const scriptUrlPolicy = trustedTypesApi
    ? trustedTypesApi.createPolicy("justified-gallery", {
        createScriptURL(value) {
          const url = new URL(String(value), document.baseURI);
          const allowed =
            location.protocol === "file:"
              ? url.protocol === "file:"
              : url.origin === location.origin;
          if (!allowed) {
            throw new TypeError(
              `refusing cross-origin script URL: ${url.href}`
            );
          }
          return url.href;
        }
      })
    : null;

  function setScriptSource(script, value) {
    script.src = scriptUrlPolicy
      ? scriptUrlPolicy.createScriptURL(value)
      : value;
  }

  function loadScriptOnce(src) {
    return new Promise((resolve, reject) => {
        const script = document.createElement("script");
        script.async = true;
        setScriptSource(script, src);

        script.onload = () => {
          script.remove();
          resolve();
        };

        script.onerror = () => {
          script.remove();
          reject(new Error(`failed to load ${src}`));
        };

        document.head.appendChild(script);
    });
  }

  async function loadScriptWithRetry(
    src,
    description,
    attempts = 3,
    recoverDeployment = true
  ) {
    let lastError = null;
    for (let attempt = 0; attempt < attempts; attempt += 1) {
      if (attempt > 0) {
        await waitForOnline();
        await new Promise(resolve => setTimeout(resolve, retryDelay(attempt)));
      }
      const scriptSrc = attempt === 0
        ? src
        : `${src}${src.includes("?") ? "&" : "?"}retry=${attempt}`;
      try {
        await loadScriptOnce(scriptSrc);
        return;
      } catch (error) {
        lastError = error;
      }
    }
    const error = new Error(
      `failed to load ${description}: ${String(lastError && lastError.message || lastError)}`
    );
    if (recoverDeployment && await requestFreshDeployment(error.message)) {
      await new Promise(() => {});
    }
    throw error;
  }

  async function registeredArrayOrRecover(
    registry,
    key,
    description,
    recoverDeployment = true
  ) {
    const loaded = registry[key];
    delete registry[key];
    if (Array.isArray(loaded)) return loaded;
    const error = new Error(`${description} did not register`);
    if (recoverDeployment && await requestFreshDeployment(error.message)) {
      await new Promise(() => {});
    }
    throw error;
  }

  let chunkRevisions = {
    catalog: Object.create(null),
    exif: Object.create(null),
    geometry: Object.create(null),
    ids: Object.create(null)
  };

  function mergeChunkRevisions(payload) {
    if (!payload || typeof payload !== "object") return;
    for (const kind of ["catalog", "exif", "geometry", "ids"]) {
      const group = payload[kind];
      if (!group || typeof group !== "object") continue;
      Object.assign(chunkRevisions[kind], group);
    }
  }

  const bootstrapConfig = config.bootstrap && typeof config.bootstrap === "object"
    ? config.bootstrap
    : {};
  mergeChunkRevisions(bootstrapConfig.revisions);
  let fullChunkRevisionsLoaded = false;
  let chunkRevisionsLoadPromise = null;

  async function loadChunkRevisions(recoverDeployment = true) {
    if (fullChunkRevisionsLoaded) return;
    if (!chunkRevisionsLoadPromise) {
      chunkRevisionsLoadPromise = (async () => {
        let loaded = window.__JG_CHUNK_REVISIONS__;
        delete window.__JG_CHUNK_REVISIONS__;
        if (!loaded || typeof loaded !== "object") {
          if (!config.revisionIndexUrl) return;
          await loadScriptWithRetry(
            config.revisionIndexUrl,
            "chunk revision index",
            3,
            false
          );
          loaded = window.__JG_CHUNK_REVISIONS__;
          delete window.__JG_CHUNK_REVISIONS__;
        }
        if (!loaded || typeof loaded !== "object") {
          throw new Error("chunk revision index did not register");
        }
        mergeChunkRevisions(loaded);
        fullChunkRevisionsLoaded = true;
        markStartupPhase("revision-index-ready");
      })();
    }
    try {
      await chunkRevisionsLoadPromise;
    } catch (error) {
      chunkRevisionsLoadPromise = null;
      if (
        recoverDeployment &&
        await requestFreshDeployment(String(error && error.message || error))
      ) {
        await new Promise(() => {});
      }
      throw error;
    }
  }

  function chunkInfo(kind, key) {
    const group = chunkRevisions &&
      typeof chunkRevisions[kind] === "object"
      ? chunkRevisions[kind]
      : null;
    const value = group ? group[String(key)] : null;
    if (value && typeof value === "object") return value;
    return { revision: String(value || "") };
  }

  function chunkRevision(kind, key) {
    return String(chunkInfo(kind, key).revision || "");
  }

  function chunkRevisionAvailable(kind, key) {
    const info = chunkInfo(kind, key);
    if (!/^[0-9a-f]{12}$/i.test(String(info.revision || ""))) return false;
    if (kind === "catalog" || kind === "geometry") {
      if (!/^[0-9a-f]{12}$/i.test(String(info.binaryRevision || ""))) {
        return false;
      }
    }
    if (kind === "geometry" && !Array.isArray(info.segments)) return false;
    return true;
  }

  async function ensureChunkRevision(kind, key) {
    if (chunkRevisionAvailable(kind, key)) return;
    await loadChunkRevisions();
    if (!chunkRevisionAvailable(kind, key)) {
      throw new Error(`missing ${kind} revision for ${String(key)}`);
    }
  }

  function contentAddressedChunkFilename(stem, revision) {
    const value = String(revision || "");
    if (!/^[0-9a-f]{12}$/i.test(value)) {
      const error = new Error(`missing or invalid content revision for ${stem}`);
      void requestFreshDeployment(error.message);
      throw error;
    }
    return `${stem}.${value}.js`;
  }

  async function verifyBinaryRevision(buffer, revision, description) {
    const expected = String(revision || "").toLowerCase();
    if (!/^[0-9a-f]{12}$/.test(expected)) {
      throw new Error(`${description} has no valid content revision`);
    }
    const subtle = globalThis.crypto && globalThis.crypto.subtle;
    if (!subtle || typeof subtle.digest !== "function") {
      return false;
    }
    const digest = new Uint8Array(await subtle.digest("SHA-256", buffer));
    let actual = "";
    for (const value of digest) actual += value.toString(16).padStart(2, "0");
    if (!actual.startsWith(expected)) {
      throw new Error(
        `${description} content hash mismatch: expected ${expected}, ` +
        `got ${actual.slice(0, 12)}`
      );
    }
    return true;
  }

  function validateRandomGroupOrder(lengthsValue, membersValue) {
    const orderConfig = config.randomOrder || {};
    const expectedCount = Math.max(0, Number(orderConfig.count) || 0);
    const expectedGroups = Math.max(0, Number(orderConfig.groupCount) || 0);
    const lengths = lengthsValue instanceof Uint32Array
      ? lengthsValue
      : Uint32Array.from(lengthsValue || []);
    const members = membersValue instanceof Uint32Array
      ? membersValue
      : Uint32Array.from(membersValue || []);
    if (
      lengths.length !== expectedGroups ||
      members.length !== expectedCount
    ) {
      throw new Error("random group-order count mismatch");
    }
    let total = 0;
    for (const length of lengths) {
      if (length < 1) throw new Error("random group-order has an empty group");
      total += length;
    }
    if (total !== expectedCount) {
      throw new Error("random group-order lengths do not cover the catalog");
    }
    const seen = new Uint8Array(expectedCount);
    for (const catalogId of members) {
      if (catalogId >= expectedCount || seen[catalogId]) {
        throw new Error("random group-order is not a catalog permutation");
      }
      seen[catalogId] = 1;
    }
    return { lengths, members };
  }

  async function fetchRandomGroupOrderBinary(attempts = 3) {
    const orderConfig = config.randomOrder || {};
    let lastError = null;
    for (let attempt = 0; attempt < attempts; attempt += 1) {
      if (attempt > 0) {
        await waitForOnline();
        await new Promise(resolve => setTimeout(resolve, retryDelay(attempt)));
      }
      const baseUrl = String(orderConfig.binaryUrl || "");
      const url = attempt === 0
        ? baseUrl
        : `${baseUrl}${baseUrl.includes("?") ? "&" : "?"}retry=${attempt}`;
      try {
        const response = await fetch(url, {
          cache: attempt === 0 ? "force-cache" : "reload",
          credentials: "same-origin"
        });
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        const buffer = await response.arrayBuffer();
        await verifyBinaryRevision(
          buffer,
          orderConfig.binaryRevision,
          "random group-order data"
        );
        if (buffer.byteLength < 16) {
          throw new Error("random group-order header is truncated");
        }
        const view = new DataView(buffer);
        if (
          view.getUint8(0) !== 0x4a ||
          view.getUint8(1) !== 0x47 ||
          view.getUint8(2) !== 0x4f ||
          view.getUint8(3) !== 0x31
        ) {
          throw new Error("random group-order magic mismatch");
        }
        if (view.getUint32(4, true) !== 1) {
          throw new Error("random group-order version mismatch");
        }
        const count = view.getUint32(8, true);
        const groupCount = view.getUint32(12, true);
        const expectedSize = 16 + (count + groupCount) * 4;
        if (buffer.byteLength !== expectedSize) {
          throw new Error("random group-order payload length mismatch");
        }
        const lengths = new Uint32Array(groupCount);
        const members = new Uint32Array(count);
        let offset = 16;
        for (let index = 0; index < groupCount; index += 1) {
          lengths[index] = view.getUint32(offset, true);
          offset += 4;
        }
        for (let index = 0; index < count; index += 1) {
          members[index] = view.getUint32(offset, true);
          offset += 4;
        }
        return validateRandomGroupOrder(lengths, members);
      } catch (error) {
        if (/hash mismatch/i.test(String(error && error.message || error))) {
          binaryIntegrityFailures += 1;
        }
        lastError = error;
      }
    }
    throw new Error(
      "failed to load random group-order data: " +
      String(lastError && lastError.message || lastError)
    );
  }

  async function loadRandomGroupOrderFallback() {
    const orderConfig = config.randomOrder || {};
    delete window.__JG_RANDOM_GROUP_ORDER__;
    await loadScriptWithRetry(
      String(orderConfig.fallbackUrl || ""),
      "random group-order fallback",
      3,
      false
    );
    const loaded = window.__JG_RANDOM_GROUP_ORDER__;
    delete window.__JG_RANDOM_GROUP_ORDER__;
    if (!loaded || typeof loaded !== "object") {
      throw new Error("random group-order fallback did not register");
    }
    return validateRandomGroupOrder(loaded.lengths, loaded.members);
  }

  function loadRandomGroupOrder() {
    const orderConfig = config.randomOrder || {};
    if (!orderConfig.available) {
      return Promise.reject(new Error("random group ordering is unavailable"));
    }
    const canFetchBinary =
      location.protocol !== "file:" && typeof fetch === "function";
    if (!canFetchBinary) return loadRandomGroupOrderFallback();
    return fetchRandomGroupOrderBinary().catch(error => {
      console.debug(
        "binary random group-order unavailable; using JavaScript fallback",
        error
      );
      return loadRandomGroupOrderFallback();
    });
  }

  const rawGeometryChunks =
    window.__JG_GLOBAL_GEOMETRY_CHUNKS =
      window.__JG_GLOBAL_GEOMETRY_CHUNKS || Object.create(null);
  const geometryChunkLoads = new Map();

  function geometryChunkFilename(chunkIndex) {
    return contentAddressedChunkFilename(
      String(chunkIndex).padStart(
        Math.max(1, Number(config.geometryChunkFilenameWidth) || 1),
        "0"
      ),
      chunkRevision("geometry", chunkIndex)
    );
  }

  function geometryBinaryFilename(chunkIndex) {
    const stem = String(chunkIndex).padStart(
      Math.max(1, Number(config.geometryChunkFilenameWidth) || 1),
      "0"
    );
    const revision = String(
      chunkInfo("geometry", chunkIndex).binaryRevision || ""
    );
    if (!/^[0-9a-f]{12}$/i.test(revision)) {
      throw new Error(
        `missing or invalid binary content revision for ${stem}`
      );
    }
    return `${stem}.${revision}.bin`;
  }

  async function fetchGeometryBinary(chunkIndex, attempts = 3) {
    let lastError = null;
    const baseUrl =
      `${config.geometryBase}/${geometryBinaryFilename(chunkIndex)}`;
    for (let attempt = 0; attempt < attempts; attempt += 1) {
      if (attempt > 0) {
        await waitForOnline();
        await new Promise(resolve => setTimeout(resolve, retryDelay(attempt)));
      }
      const url = attempt === 0
        ? baseUrl
        : `${baseUrl}?retry=${attempt}`;
      try {
        const response = await fetch(url, {
          cache: attempt === 0 ? "force-cache" : "reload",
          credentials: "same-origin"
        });
        if (!response.ok) {
          throw new Error(`HTTP ${response.status}`);
        }
        const buffer = await response.arrayBuffer();
        await verifyBinaryRevision(
          buffer,
          chunkInfo("geometry", chunkIndex).binaryRevision,
          `binary geometry chunk ${chunkIndex}`
        );
        if (buffer.byteLength < 8) {
          throw new Error("binary geometry header is truncated");
        }
        const view = new DataView(buffer);
        if (
          view.getUint8(0) !== 0x4a ||
          view.getUint8(1) !== 0x47 ||
          view.getUint8(2) !== 0x47 ||
          view.getUint8(3) !== 0x31
        ) {
          throw new Error("binary geometry magic mismatch");
        }
        const count = view.getUint32(4, true);
        const expected = Number(chunkInfo("geometry", chunkIndex).count);
        if (Number.isFinite(expected) && expected >= 0 && count !== expected) {
          throw new Error(
            `binary geometry chunk ${chunkIndex} count mismatch: ` +
            `expected ${expected}, got ${count}`
          );
        }
        if (buffer.byteLength !== 8 + count * 4) {
          throw new Error("binary geometry payload length mismatch");
        }
        const result = new Uint32Array(count);
        for (let index = 0; index < count; index += 1) {
          result[index] = view.getUint32(8 + index * 4, true);
        }
        return result;
      } catch (error) {
        if (/hash mismatch/i.test(String(error && error.message || error))) {
          binaryIntegrityFailures += 1;
        }
        lastError = error;
      }
    }
    throw new Error(
      `failed to load binary geometry chunk ${chunkIndex}: ` +
      String(lastError && lastError.message || lastError)
    );
  }

  async function loadGeometryScriptFallback(chunkIndex) {
    await loadScriptWithRetry(
      `${config.geometryBase}/${geometryChunkFilename(chunkIndex)}`,
      `geometry chunk ${chunkIndex}`,
      3,
      false
    );
    const loaded = await registeredArrayOrRecover(
      rawGeometryChunks,
      chunkIndex,
      `geometry chunk ${chunkIndex}`,
      false
    );
    const expected = Number(chunkInfo("geometry", chunkIndex).count);
    if (Number.isFinite(expected) && expected >= 0 && loaded.length !== expected) {
      throw new Error(
        `geometry chunk ${chunkIndex} count mismatch: ` +
        `expected ${expected}, got ${loaded.length}`
      );
    }
    return loaded;
  }

  function loadGeometryChunkRaw(chunkIndex) {
    const existing = rawGeometryChunks[chunkIndex];
    if (Array.isArray(existing)) {
      delete rawGeometryChunks[chunkIndex];
      return Promise.resolve(existing);
    }
    const pending = geometryChunkLoads.get(chunkIndex);
    if (pending) return pending;

    const canFetchBinary =
      location.protocol !== "file:" && typeof fetch === "function";
    const promise = ensureChunkRevision("geometry", chunkIndex).then(() => (
      canFetchBinary
        ? fetchGeometryBinary(chunkIndex).catch(error => {
            console.debug(
              `binary geometry chunk ${chunkIndex} unavailable; ` +
              "using JavaScript fallback",
              error
            );
            return loadGeometryScriptFallback(chunkIndex);
          })
        : loadGeometryScriptFallback(chunkIndex)
    )).finally(() => {
      geometryChunkLoads.delete(chunkIndex);
    });
    geometryChunkLoads.set(chunkIndex, promise);
    return promise;
  }

  function estimatedRatioSumForRange(chunkIndex, start, end) {
    const info = chunkInfo("geometry", chunkIndex);
    const summaries = Array.isArray(info.segments) ? info.segments : [];
    const chunkSize = Math.max(1, Number(config.geometryChunkSize) || 1);
    const segmentSize = Math.max(1, Number(config.layoutSegmentSize) || 1);
    const chunkStart = chunkIndex * chunkSize;
    let cursor = start;
    let total = 0;

    while (cursor < end) {
      const local = cursor - chunkStart;
      const summaryIndex = Math.floor(local / segmentSize);
      const summary = summaries[summaryIndex];
      const summaryStart = chunkStart + summaryIndex * segmentSize;
      const summaryEnd = Math.min(
        chunkStart + chunkSize,
        summaryStart + segmentSize,
        end
      );
      const covered = Math.max(0, summaryEnd - cursor);
      const summaryCount = Math.max(1, Number(summary && summary[0]) || covered || 1);
      const summarySum = Math.max(1, Number(summary && summary[1]) || summaryCount);
      total += (summarySum / config.geometryRatioScale) * (covered / summaryCount);
      cursor = summaryEnd;
    }
    return Math.max(0.000001, total);
  }

  async function loadGeometry() {
    const ranges = Array.isArray(window.__JG_PAGE_RANGES__)
      ? window.__JG_PAGE_RANGES__
      : [];
    delete window.__JG_PAGE_RANGES__;

    const expectedCount = Math.max(0, Number(config.count) || 0);
    const pageRanges = [];
    if (!expectedCount) {
      return { mode: "eager", ratios: new Float32Array(0), pageRanges, segments: [] };
    }

    const chunkSize = Math.max(
      1,
      Number(config.geometryChunkSize) || 1
    );
    const requiredGeometryChunks = new Set();
    for (const range of ranges) {
      const start = Math.max(0, Number(range && range[0]) || 0);
      const end = Math.max(start, Number(range && range[1]) || start);
      if (end <= start) continue;
      for (
        let chunkIndex = Math.floor(start / chunkSize);
        chunkIndex <= Math.floor((end - 1) / chunkSize);
        chunkIndex += 1
      ) {
        requiredGeometryChunks.add(chunkIndex);
      }
    }
    if (
      Array.from(requiredGeometryChunks).some(
        chunkIndex => !chunkRevisionAvailable("geometry", chunkIndex)
      )
    ) {
      await loadChunkRevisions();
    } else {
      markStartupPhase("bootstrap-revisions-ready");
    }
    const layoutSegmentSize = Math.max(
      1,
      Number(config.layoutSegmentSize) || 2048
    );
    const neededSet = new Set();
    const chunkSegments = new Map();
    const segments = [];
    let describedCount = 0;

    for (const range of ranges) {
      const start = Math.max(0, Number(range[0]) || 0);
      const end = Math.max(start, Number(range[1]) || start);
      if (end <= start) continue;
      const pageStart = describedCount;
      const count = end - start;
      describedCount += count;
      pageRanges.push({
        catalogStart: start,
        catalogEnd: end,
        pageStart,
        pageEnd: pageStart + count
      });

      let catalogStart = start;
      while (catalogStart < end) {
        const chunkIndex = Math.floor(catalogStart / chunkSize);
        neededSet.add(chunkIndex);
        const chunkStart = chunkIndex * chunkSize;
        const layoutBoundary = chunkStart +
          (Math.floor((catalogStart - chunkStart) / layoutSegmentSize) + 1) *
          layoutSegmentSize;
        const catalogEnd = Math.min(
          end,
          chunkStart + chunkSize,
          layoutBoundary
        );
        const segment = {
          index: segments.length,
          chunkIndex,
          catalogStart,
          catalogEnd,
          pageStart: pageStart + (catalogStart - start),
          pageEnd: pageStart + (catalogEnd - start),
          estimatedRatioSum: estimatedRatioSumForRange(
            chunkIndex,
            catalogStart,
            catalogEnd
          ),
          ratios: null,
          rows: null,
          layoutWidth: -1,
          loadError: null,
          failureCount: 0
        };
        segments.push(segment);
        let owned = chunkSegments.get(chunkIndex);
        if (!owned) {
          owned = [];
          chunkSegments.set(chunkIndex, owned);
        }
        owned.push(segment.index);
        catalogStart = catalogEnd;
      }
    }

    if (describedCount !== expectedCount) {
      throw new Error(
        `page range count mismatch: expected ${expectedCount}, got ${describedCount}`
      );
    }

    const bootstrapRatioValues = Array.isArray(bootstrapConfig.geometryRatios)
      ? bootstrapConfig.geometryRatios
      : [];
    const bootstrapRatioCount = Math.min(
      expectedCount,
      bootstrapRatioValues.length
    );
    let bootstrapRatiosValid = bootstrapRatioCount > 0;
    for (let index = 0; index < bootstrapRatioCount; index += 1) {
      const value = Number(bootstrapRatioValues[index]);
      if (!Number.isFinite(value) || value < 1) {
        bootstrapRatiosValid = false;
        break;
      }
    }
    if (bootstrapRatiosValid && bootstrapRatioCount === expectedCount) {
      const ratios = new Float32Array(expectedCount);
      for (let index = 0; index < expectedCount; index += 1) {
        ratios[index] = Number(bootstrapRatioValues[index]) /
          config.geometryRatioScale;
      }
      markStartupPhase("bootstrap-geometry-ready");
      return { mode: "eager", ratios, pageRanges, segments: [] };
    }
    if (bootstrapRatiosValid) {
      for (const segment of segments) {
        if (segment.pageEnd > bootstrapRatioCount) continue;
        const values = new Float32Array(segment.pageEnd - segment.pageStart);
        for (let offset = 0; offset < values.length; offset += 1) {
          values[offset] = Number(
            bootstrapRatioValues[segment.pageStart + offset]
          ) / config.geometryRatioScale;
        }
        segment.ratios = values;
      }
      markStartupPhase("bootstrap-geometry-ready");
    }

    const threshold = Math.max(
      1,
      Number(config.segmentedGeometryThreshold) || 32768
    );
    let forceEagerGeometry = false;
    try {
      const forceKey = `jg-force-eager:${String(config.dataRevision || "")}`;
      forceEagerGeometry = sessionStorage.getItem(forceKey) === "1";
    } catch (_error) {
      forceEagerGeometry = false;
    }
    const eagerLimit = Math.max(
      threshold,
      Number(config.geometryCompatibilityEagerLimit) || 250000
    );
    if (
      expectedCount >= threshold &&
      !(forceEagerGeometry && expectedCount <= eagerLimit)
    ) {
      return {
        mode: "segmented",
        ratios: null,
        pageRanges,
        segments,
        chunkSegments
      };
    }

    const ratios = new Float32Array(expectedCount);
    const neededChunks = Array.from(neededSet).sort((a, b) => a - b);
    let nextNeeded = 0;
    let filledCount = 0;
    const workerCount = Math.min(8, neededChunks.length);

    function consumeChunk(chunkIndex, chunk) {
      const chunkStart = chunkIndex * chunkSize;
      const owned = chunkSegments.get(chunkIndex) || [];
      for (const segmentIndex of owned) {
        const segment = segments[segmentIndex];
        for (
          let catalogId = segment.catalogStart;
          catalogId < segment.catalogEnd;
          catalogId += 1
        ) {
          const pageIndex =
            segment.pageStart + (catalogId - segment.catalogStart);
          const localIndex = catalogId - chunkStart;
          if (localIndex >= chunk.length) {
            throw new Error(
              `geometry entry ${catalogId} is outside chunk ${chunkIndex}`
            );
          }
          ratios[pageIndex] =
            (Number(chunk[localIndex]) || 1) / config.geometryRatioScale;
          filledCount += 1;
        }
      }
    }

    async function worker() {
      while (true) {
        const neededIndex = nextNeeded;
        nextNeeded += 1;
        if (neededIndex >= neededChunks.length) return;
        const chunkIndex = neededChunks[neededIndex];

        const chunk = await loadGeometryChunkRaw(chunkIndex);
        consumeChunk(chunkIndex, chunk);
      }
    }

    await Promise.all(
      Array.from({ length: workerCount }, () => worker())
    );

    if (filledCount !== expectedCount) {
      throw new Error(
        `page geometry count mismatch: expected ${expectedCount}, got ${filledCount}`
      );
    }

    return { mode: "eager", ratios, pageRanges, segments: [] };
  }

  let naturalGeometry;
  let geometry;
  let galleryLoadError = null;
  try {
    naturalGeometry = await loadGeometry();
    geometry = naturalGeometry;
    markStartupPhase("geometry-ready");
  } catch (error) {
    console.error(error);
    if (await requestFreshDeployment(String(error && error.message || error))) {
      return;
    }
    galleryLoadError = error;
    naturalGeometry = {
      mode: "eager",
      ratios: new Float32Array(0),
      pageRanges: [],
      segments: []
    };
    geometry = naturalGeometry;
  }

  const gallery = document.getElementById("gallery");
  const empty = document.getElementById("empty");
  const lightbox = document.getElementById("lightbox");
  const lightboxImage = document.getElementById("lightbox-image");
  const lightboxVideo = document.getElementById("lightbox-video");
  const caption = document.getElementById("lightbox-caption");
  const exifPanel = document.getElementById("lightbox-exif");
  const infoHeading = document.getElementById("lightbox-info-heading");
  const exifList = document.getElementById("lightbox-exif-list");
  const exifStatus = document.getElementById("lightbox-exif-status");
  const previousButton = document.getElementById("prev");
  const nextButton = document.getElementById("next");
  const closeButton = document.getElementById("close");
  const downloadButton = document.getElementById("download-original");
  const infoButton = document.getElementById("lightbox-info-toggle");
  const stage = document.getElementById("lightbox-stage");
  const videoPlaybackError = document.getElementById("video-playback-error");
  const videoPlaybackErrorMessage = document.getElementById(
    "video-playback-error-message"
  );
  const videoOpenOriginal = document.getElementById("video-open-original");
  const videoDownloadOriginal = document.getElementById(
    "video-download-original"
  );
  const sortControls = document.getElementById("sort-controls");
  const sortMode = document.getElementById("sort-mode");
  const reshuffleButton = document.getElementById("reshuffle");
  const sortStatus = document.getElementById("sort-status");

  const itemCount = Math.max(0, Number(config.count) || 0);
  const rawCatalogChunks =
    window.__JG_CATALOG_CHUNKS =
      window.__JG_CATALOG_CHUNKS || Object.create(null);
  const rawExifChunks =
    window.__JG_EXIF_CHUNKS =
      window.__JG_EXIF_CHUNKS || Object.create(null);
  const rawIdShards =
    window.__JG_ID_SHARDS =
      window.__JG_ID_SHARDS || Object.create(null);
  const bootstrapCatalogItems = new Map();
  for (const pair of Array.isArray(bootstrapConfig.catalogItems)
    ? bootstrapConfig.catalogItems
    : []) {
    const catalogId = Number(pair && pair[0]);
    const metadata = pair && pair[1];
    if (Number.isInteger(catalogId) && catalogId >= 0 && Array.isArray(metadata)) {
      bootstrapCatalogItems.set(catalogId, metadata);
    }
  }
  const idShardLoads = new Map();

  let currentIndex = -1;
  let lightboxRequestId = 0;
  let lightboxZoom = 1;
  let lightboxPanX = 0;
  let lightboxPanY = 0;
  let lightboxNeedsInitialFit = false;
  let lightboxSavedViewport = null;
  let lightboxHistoryActive = false;
  let lightboxHistoryPushed = false;
  let currentLightboxItem = null;
  let currentExifMetadata = null;
  let currentExifState = "idle";
  let currentExifLoadRequestId = 0;
  let lightboxAnimatedObjectUrl = null;
  let lightboxAnimatedAbortController = null;
  let lightboxAnimatedImage = null;

  const lightboxPointers = new Map();
  let lightboxGestureMode = "none";
  let lightboxSinglePointerId = null;
  let lightboxSingleStartX = 0;
  let lightboxSingleStartY = 0;
  let lightboxSingleStartPanX = 0;
  let lightboxSingleStartPanY = 0;
  let lightboxSingleStartedZoomed = false;
  let lightboxSingleMoved = false;
  let lightboxPinchStartDistance = 1;
  let lightboxPinchStartZoom = 1;
  let lightboxPinchStartPanX = 0;
  let lightboxPinchStartPanY = 0;
  let lightboxPinchStartMidX = 0;
  let lightboxPinchStartMidY = 0;
  let lastWidth = -1;
  let scheduled = false;
  let renderDebounceTimer = null;
  let renderRequestSerial = 0;
  let geometryLayoutRevision = 0;
  let renderedGeometryLayoutRevision = -1;
  let virtualScheduled = false;
  let virtualDeferredTimer = null;
  let virtualDeferredUntil = 0;
  let lastObservedScrollY = window.scrollY;
  let lastObservedScrollTime = performance.now();
  let scrollDirection = 1;
  let observedScrollSpeed = 0;
  let iosFastScrollActive = false;
  let iosFastScrollTimer = null;
  let iosFastScrollEntries = 0;
  let rows = [];
  let totalGalleryHeight = 0;
  let layoutWorker = null;
  let layoutWorkerRequestId = 0;
  const layoutWorkerPending = new Map();

  function stopLayoutWorker(error) {
    for (const pending of layoutWorkerPending.values()) {
      pending.reject(error);
    }
    layoutWorkerPending.clear();
    if (layoutWorker) {
      try { layoutWorker.terminate(); } catch (_error) {}
      layoutWorker = null;
    }
  }

  function getLayoutWorker() {
    if (!config.layoutWorkerUrl || !("Worker" in window)) {
      return null;
    }
    if (layoutWorker) return layoutWorker;
    try {
      const url =
        `${config.layoutWorkerUrl}?v=` +
        encodeURIComponent(String(config.layoutWorkerVersion || ""));
      layoutWorker = new Worker(url);
      markStartupPhase("layout-worker-started");
      layoutWorker.onmessage = event => {
        const data = event.data || {};
        const requestId = Number(data.requestId) || 0;
        const pending = layoutWorkerPending.get(requestId);
        if (!pending) return;
        layoutWorkerPending.delete(requestId);
        pending.resolve(data.rowsBuffer);
      };
      layoutWorker.onerror = error => {
        stopLayoutWorker(error);
      };
      return layoutWorker;
    } catch (error) {
      console.debug("layout worker unavailable", error);
      return null;
    }
  }

  const mountedRows = new Map();
  const recycledRows = [];
  const recycledTiles = [];
  const catalogChunkCache = new Map();
  const catalogChunkLoads = new Map();
  const exifChunkCache = new Map();
  const exifChunkLoads = new Map();
  const hotGeometryChunks = new Map();
  const metadataCacheLru = new Map();
  const metadataCacheEstimates = new WeakMap();
  let metadataCacheBytes = 0;
  let metadataCacheBudgetBytes = 0;
  const geometryHydrationLoads = new Map();
  const pendingTileLoads = new Set();
  const activeTileLoads = new Set();
  let geometryPrefetchHandle = null;
  let geometryPrefetchToken = 0;

  let tileLoadPumpScheduled = false;
  let imageResidencyScheduled = false;
  let nextTileLoadToken = 1;
  let nextTileBindingToken = 1;
  let diagnosticsRenderTimer = null;
  let resolveFirstViewportReady;
  const firstViewportReady = new Promise(resolve => {
    resolveFirstViewportReady = resolve;
  });

  function noteFirstViewportReady() {
    if (!resolveFirstViewportReady) return;
    markStartupPhase("first-image-decoded");
    resolveFirstViewportReady();
    resolveFirstViewportReady = null;
  }

  const diagnosticsQuery = new URLSearchParams(
    window.location.search
  ).get("diagnostics");
  const CLIENT_REGRESSION = new URLSearchParams(
    window.location.search
  ).get("regression") === "1";
  const CLIENT_DIAGNOSTICS =
    diagnosticsQuery === "1" ||
    (
      diagnosticsQuery !== "0" &&
      config.clientDiagnostics === true
    );

  // Every browser on iOS uses WebKit, and WebKit does not expose
  // navigator.deviceMemory. Without an explicit platform check, modern
  // iPhones are incorrectly assigned the normal-memory profile even though
  // their tabs have a comparatively tight process-memory ceiling.
  const isIOSWebKit = (() => {
    const agent = String(navigator.userAgent || "");
    const platform = String(navigator.platform || "");
    return /iPad|iPhone|iPod/i.test(agent) ||
      (platform === "MacIntel" && Number(navigator.maxTouchPoints) > 1);
  })();

  const diagnostics = {
    activeDecodes: 0,
    peakActiveDecodes: 0,
    peakActiveLoads: 0,
    peakPendingLoads: 0,
    residentImages: 0,
    peakResidentImages: 0,
    peakMountedRows: 0,
    releases: 0,
    loadsStarted: 0,
    loadErrors: 0,
    retries: 0,
    decodeCount: 0,
    decodeTotalMs: 0,
    decodeMaxMs: 0
  };

  function publishRegressionState() {
    if (!CLIENT_REGRESSION) return;
    const segmented = geometry.mode === "segmented";
    window.__JG_REGRESSION__ = {
      geometryMode: geometry.mode,
      itemCount,
      rows: rows.length,
      mountedRows: mountedRows.size,
      mountedTiles: gallery.querySelectorAll(".tile").length,
      loadedSegments: segmented
        ? geometry.segments.filter(segment => Boolean(segment.ratios)).length
        : 0,
      failedSegments: segmented
        ? geometry.segments.filter(segment => Boolean(segment.loadError)).length
        : 0,
      geometryLayoutRevision,
      totalGalleryHeight,
      lastWidth,
      metadataCacheBytes,
      metadataCacheBudgetBytes,
      binaryCatalogLoads,
      catalogFallbackLoads,
      binaryIntegrityFailures,
      iosWebKit: isIOSWebKit,
      iosFastScrollActive,
      iosFastScrollEntries,
      maxActiveTileLoads: MAX_ACTIVE_TILE_LOADS,
      imageLoadMargin: IMAGE_LOAD_MARGIN,
      maxRecycledTiles: MAX_RECYCLED_TILES,
      displayMode,
      displayOrderSample: geometry.displayOrder
        ? Array.from(geometry.displayOrder.slice(0, 18))
        : []
    };
  }

  let diagnosticsElement = null;

  const deviceMemory = Number(navigator.deviceMemory) || 0;
  const networkConnection =
    navigator.connection || navigator.mozConnection || navigator.webkitConnection || null;
  const constrainedNetwork = Boolean(
    networkConnection && (
      networkConnection.saveData ||
      /(^|-)2g$/.test(String(networkConnection.effectiveType || ""))
    )
  );
  const memoryTier = isIOSWebKit
    ? "low"
    : (deviceMemory > 0 && deviceMemory <= 2
      ? "low"
      : (deviceMemory > 4 ? "high" : "medium"));
  const lowMemoryDevice = memoryTier === "low";
  const mediumMemoryDevice = memoryTier === "medium";
  const OVERSCAN_VIEWPORTS =
    isIOSWebKit ? 0.45
    : (lowMemoryDevice ? 0.75 : (mediumMemoryDevice ? 1.0 : 1.5));
  const MIN_OVERSCAN_PX =
    isIOSWebKit ? 320
    : (lowMemoryDevice ? 600 : (mediumMemoryDevice ? 900 : 1200));
  const configuredHotCatalogChunks =
    Number(config.maxHotCatalogChunks);
  const MAX_HOT_CATALOG_CHUNKS =
    Number.isFinite(configuredHotCatalogChunks) &&
    configuredHotCatalogChunks >= 1
      ? Math.floor(configuredHotCatalogChunks)
      : Number.POSITIVE_INFINITY;
  const configuredMetadataCacheMiB = Number(config.maxClientMetadataMiB);
  const defaultMetadataCacheMiB = isIOSWebKit
    ? 4
    : (lowMemoryDevice ? 8 : (mediumMemoryDevice ? 24 : 64));
  const selectedMetadataCacheMiB =
    Number.isFinite(configuredMetadataCacheMiB) &&
    configuredMetadataCacheMiB > 0
      ? configuredMetadataCacheMiB
      : defaultMetadataCacheMiB;
  metadataCacheBudgetBytes = Math.max(
    2 * 1024 * 1024,
    Math.floor(
      (isIOSWebKit
        ? Math.min(8, selectedMetadataCacheMiB)
        : selectedMetadataCacheMiB) * 1024 * 1024
    )
  );
  const MAX_LIGHTBOX_PRELOADS =
    isIOSWebKit || lowMemoryDevice ? 0 : (mediumMemoryDevice ? 1 : 2);
  const MAX_RECYCLED_ROWS =
    isIOSWebKit ? 8
    : (lowMemoryDevice ? 24 : (mediumMemoryDevice ? 48 : 72));
  const MAX_RECYCLED_TILES =
    isIOSWebKit ? 0
    : (lowMemoryDevice ? 160 : (mediumMemoryDevice ? 320 : 512));
  const BASE_GRID_DPR_CAP = isIOSWebKit || constrainedNetwork || lowMemoryDevice
    ? 1.25
    : (mediumMemoryDevice ? 1.75 : 2);
  const FAST_SCROLL_SETTLE_MS = isIOSWebKit ? 220 : 120;
  const FAST_SCROLL_JUMP_VIEWPORTS = isIOSWebKit ? 0.25 : 0.75;
  const FAST_SCROLL_SPEED_PX_PER_MS = isIOSWebKit ? 1.1 : 4;
  const configuredMaxActiveTileLoads =
    Number(config.maxActiveTileLoads);
  const defaultMaxActiveTileLoads =
    lowMemoryDevice ? 4 : (memoryTier === "high" ? 8 : 6);
  const selectedMaxActiveTileLoads =
    Number.isFinite(configuredMaxActiveTileLoads) &&
    configuredMaxActiveTileLoads >= 1
      ? Math.floor(configuredMaxActiveTileLoads)
      : defaultMaxActiveTileLoads;
  // Treat this as a hard platform safety ceiling. A command-line override is
  // still honored everywhere else, but allowing many simultaneous WebKit
  // image decodes can terminate an iOS tab before JavaScript receives a
  // recoverable memory-pressure signal.
  const MAX_ACTIVE_TILE_LOADS = isIOSWebKit
    ? Math.min(2, selectedMaxActiveTileLoads)
    : selectedMaxActiveTileLoads;

  const configuredImageLoadMargin =
    Number(config.imageLoadMargin);
  const selectedImageLoadMargin =
    Number.isFinite(configuredImageLoadMargin) &&
    configuredImageLoadMargin >= 0
      ? configuredImageLoadMargin
      : 400;
  const IMAGE_LOAD_MARGIN = isIOSWebKit
    ? Math.min(180, selectedImageLoadMargin)
    : selectedImageLoadMargin;

  const configuredTooltipDelay =
    Number(config.tooltipDelay);
  const TOOLTIP_DELAY =
    Number.isFinite(configuredTooltipDelay) &&
    configuredTooltipDelay >= 0
      ? Math.floor(configuredTooltipDelay)
      : 200;

  const tooltipTimers = new WeakMap();

  function setTileTooltip(tile, text) {
    const tooltip = tile.querySelector(".tile-tooltip");
    const value = String(text || "");
    tile.dataset.tooltip = value;
    if (tooltip) tooltip.textContent = value;
    tile.removeAttribute("title");
  }

  function hideTileTooltip(tile) {
    const timer = tooltipTimers.get(tile);
    if (timer !== undefined) {
      clearTimeout(timer);
      tooltipTimers.delete(tile);
    }
    tile.classList.remove("tooltip-visible");
  }

  function showTileTooltipAfterDelay(tile) {
    hideTileTooltip(tile);
    if (!tile.dataset.tooltip) return;

    const show = () => {
      tooltipTimers.delete(tile);
      if (!tile.isConnected || !tile.matches(":hover")) return;
      tile.classList.add("tooltip-visible");
    };

    if (TOOLTIP_DELAY === 0) {
      show();
      return;
    }

    tooltipTimers.set(
      tile,
      setTimeout(show, TOOLTIP_DELAY)
    );
  }

  function diagnosticsSnapshot() {
    const averageDecodeMs = diagnostics.decodeCount
      ? diagnostics.decodeTotalMs / diagnostics.decodeCount
      : 0;

    return {
      activeLoads: activeTileLoads.size,
      maxActiveLoads: MAX_ACTIVE_TILE_LOADS,
      peakActiveLoads: diagnostics.peakActiveLoads,
      activeDecodes: diagnostics.activeDecodes,
      peakActiveDecodes: diagnostics.peakActiveDecodes,
      pendingLoads: pendingTileLoads.size,
      peakPendingLoads: diagnostics.peakPendingLoads,
      residentImages: diagnostics.residentImages,
      peakResidentImages: diagnostics.peakResidentImages,
      mountedRows: mountedRows.size,
      peakMountedRows: diagnostics.peakMountedRows,
      releases: diagnostics.releases,
      loadsStarted: diagnostics.loadsStarted,
      loadErrors: diagnostics.loadErrors,
      retries: diagnostics.retries,
      decodeCount: diagnostics.decodeCount,
      averageDecodeMs,
      maxDecodeMs: diagnostics.decodeMaxMs,
      imageLoadMargin: IMAGE_LOAD_MARGIN,
      memoryTier,
      geometryMode: geometry.mode,
      hotCatalogChunks: catalogChunkCache.size,
      hotGeometryChunks: hotGeometryChunks.size,
      hotExifChunks: exifChunkCache.size,
      metadataCacheBytes,
      metadataCacheBudgetBytes,
      geometrySegments: geometry.segments.length,
      thumbnailDprCap: adaptiveGridDprCap(),
      recycledRows: recycledRows.length,
      recycledTiles: recycledTiles.length,
      itemCount,
      startup: { ...startupTimings }
    };
  }

  function updateDiagnosticsPeaks() {
    if (!CLIENT_DIAGNOSTICS) return;

    diagnostics.peakActiveLoads = Math.max(
      diagnostics.peakActiveLoads,
      activeTileLoads.size
    );
    diagnostics.peakPendingLoads = Math.max(
      diagnostics.peakPendingLoads,
      pendingTileLoads.size
    );
    diagnostics.peakResidentImages = Math.max(
      diagnostics.peakResidentImages,
      diagnostics.residentImages
    );
    diagnostics.peakMountedRows = Math.max(
      diagnostics.peakMountedRows,
      mountedRows.size
    );
  }

  function renderDiagnostics() {
    diagnosticsRenderTimer = null;
    if (!CLIENT_DIAGNOSTICS || !diagnosticsElement) return;

    updateDiagnosticsPeaks();
    const stats = diagnosticsSnapshot();
    diagnosticsElement.textContent = [
      "Gallery diagnostics",
      `loads     ${stats.activeLoads}/${stats.maxActiveLoads}  peak ${stats.peakActiveLoads}`,
      `decodes   ${stats.activeDecodes}  peak ${stats.peakActiveDecodes}`,
      `pending   ${stats.pendingLoads}  peak ${stats.peakPendingLoads}`,
      `resident  ${stats.residentImages}  peak ${stats.peakResidentImages}`,
      `rows      ${stats.mountedRows}  peak ${stats.peakMountedRows}`,
      `releases  ${stats.releases}`,
      `started   ${stats.loadsStarted}  errors ${stats.loadErrors}  retries ${stats.retries}`,
      `decode    ${stats.averageDecodeMs.toFixed(1)} ms avg  ${stats.maxDecodeMs.toFixed(1)} ms max  n=${stats.decodeCount}`,
      `metadata  ${(stats.metadataCacheBytes / 1048576).toFixed(1)}/${(stats.metadataCacheBudgetBytes / 1048576).toFixed(1)} MiB`,
      `margin    ${stats.imageLoadMargin}px  images ${stats.itemCount}`,
      `startup   geometry ${Number(stats.startup["geometry-ready"] || 0).toFixed(1)} ms  ` +
        `catalog ${Number(stats.startup["first-catalog-metadata"] || 0).toFixed(1)} ms  ` +
        `image ${Number(stats.startup["first-image-decoded"] || 0).toFixed(1)} ms`
    ].join("\n");
  }

  function scheduleDiagnosticsRender() {
    if (!CLIENT_DIAGNOSTICS || diagnosticsRenderTimer !== null) {
      return;
    }

    // A 250 ms display cadence keeps the overlay useful without turning the
    // diagnostics themselves into meaningful scroll-time work.
    diagnosticsRenderTimer = setTimeout(
      renderDiagnostics,
      250
    );
  }

  function noteDiagnosticsState() {
    if (!CLIENT_DIAGNOSTICS) return;
    updateDiagnosticsPeaks();
    scheduleDiagnosticsRender();
  }

  function resetDiagnostics() {
    diagnostics.peakActiveLoads = activeTileLoads.size;
    diagnostics.peakActiveDecodes = diagnostics.activeDecodes;
    diagnostics.peakPendingLoads = pendingTileLoads.size;
    diagnostics.peakResidentImages = diagnostics.residentImages;
    diagnostics.peakMountedRows = mountedRows.size;
    diagnostics.releases = 0;
    diagnostics.loadsStarted = 0;
    diagnostics.loadErrors = 0;
    diagnostics.retries = 0;
    diagnostics.decodeCount = 0;
    diagnostics.decodeTotalMs = 0;
    diagnostics.decodeMaxMs = 0;
    renderDiagnostics();
  }

  if (CLIENT_DIAGNOSTICS) {
    diagnosticsElement = document.createElement("pre");
    diagnosticsElement.id = "client-diagnostics";
    diagnosticsElement.setAttribute(
      "aria-label",
      "Gallery client diagnostics"
    );
    document.body.appendChild(diagnosticsElement);

    window.__JG_DIAGNOSTICS__ = {
      snapshot: diagnosticsSnapshot,
      reset: resetDiagnostics
    };
    renderDiagnostics();
  }

  window.addEventListener(
    "memorypressure",
    () => ratchetMetadataBudget("browser memory pressure")
  );
  if ("PerformanceObserver" in window) {
    try {
      let severeLongTasks = 0;
      const observer = new PerformanceObserver(list => {
        for (const entry of list.getEntries()) {
          if (entry.duration >= 250) severeLongTasks += 1;
        }
        if (severeLongTasks >= 3) {
          severeLongTasks = 0;
          ratchetMetadataBudget("repeated long main-thread tasks");
        }
      });
      observer.observe({ type: "longtask", buffered: false });
    } catch (_error) {
      // Long-task observation is optional and absent in several browsers.
    }
  }

  function showGalleryLoadFailure(error) {
    empty.classList.add("gallery-load-failure");
    empty.setAttribute("role", "alert");
    empty.textContent = "Gallery data failed to load.";
    const detail = document.createElement("code");
    detail.className = "empty-error-detail";
    detail.textContent = String(
      error && error.message || error
    );
    const retry = document.createElement("button");
    retry.type = "button";
    retry.className = "empty-retry";
    retry.textContent = "Reload gallery";
    retry.addEventListener("click", async () => {
      clearDeploymentRecovery();
      if (!await requestFreshDeployment("manual retry")) location.reload();
    });
    empty.append(detail, retry);
    empty.style.display = "block";
  }

  if (galleryLoadError) {
    showGalleryLoadFailure(galleryLoadError);
    return;
  }

  if (!itemCount) {
    empty.style.display = "block";
    return;
  }

  function naturalCatalogIdAt(pageIndex) {
    let low = 0;
    let high = naturalGeometry.pageRanges.length - 1;
    while (low <= high) {
      const mid = (low + high) >> 1;
      const range = naturalGeometry.pageRanges[mid];
      if (pageIndex < range.pageStart) {
        high = mid - 1;
      } else if (pageIndex >= range.pageEnd) {
        low = mid + 1;
      } else {
        return range.catalogStart + (pageIndex - range.pageStart);
      }
    }
    return -1;
  }

  function catalogIdAt(pageIndex) {
    const naturalPageIndex = geometry.displayOrder
      ? Number(geometry.displayOrder[pageIndex])
      : pageIndex;
    return naturalCatalogIdAt(naturalPageIndex);
  }

  function geometrySegmentForPageIndex(pageIndex) {
    if (geometry.mode !== "segmented") return null;
    let low = 0;
    let high = geometry.segments.length - 1;
    while (low <= high) {
      const mid = (low + high) >> 1;
      const segment = geometry.segments[mid];
      if (pageIndex < segment.pageStart) {
        high = mid - 1;
      } else if (pageIndex >= segment.pageEnd) {
        low = mid + 1;
      } else {
        return segment;
      }
    }
    return null;
  }

  function estimatedMetadataBytes(value) {
    if (!value) return 0;
    if (Number.isFinite(Number(value.byteLength))) {
      return Math.max(0, Number(value.byteLength));
    }
    if (typeof value === "object") {
      const cached = metadataCacheEstimates.get(value);
      if (cached !== undefined) return cached;
      let estimate = 1024;
      try {
        estimate = Math.max(1024, JSON.stringify(value).length * 2);
      } catch (_error) {
        estimate = 64 * 1024;
      }
      metadataCacheEstimates.set(value, estimate);
      return estimate;
    }
    return 0;
  }

  function metadataCacheKey(kind, index) {
    return `${kind}:${index}`;
  }

  function dropMetadataCacheEntry(key, entry) {
    metadataCacheLru.delete(key);
    metadataCacheBytes = Math.max(0, metadataCacheBytes - entry.bytes);
    if (entry.kind === "catalog") {
      catalogChunkCache.delete(entry.index);
      return false;
    }
    if (entry.kind === "exif") {
      exifChunkCache.delete(entry.index);
      return false;
    }
    if (entry.kind !== "geometry") return false;
    hotGeometryChunks.delete(entry.index);
    let evictedLayout = false;
    for (
      const segmentIndex of
        naturalGeometry.chunkSegments.get(entry.index) || []
    ) {
      const segment = naturalGeometry.segments[segmentIndex];
      evictedLayout = evictedLayout || Array.isArray(segment.rows);
      segment.ratios = null;
      segment.rows = null;
      segment.layoutWidth = -1;
    }
    return evictedLayout;
  }

  function evictMetadataCache(
    protectedKey = "",
    targetBytes = metadataCacheBudgetBytes
  ) {
    let evictedLayout = false;
    while (metadataCacheBytes > targetBytes && metadataCacheLru.size > 1) {
      let candidate = null;
      for (const pair of metadataCacheLru.entries()) {
        if (pair[0] !== protectedKey) {
          candidate = pair;
          break;
        }
      }
      if (!candidate) break;
      evictedLayout = dropMetadataCacheEntry(candidate[0], candidate[1]) || evictedLayout;
    }
    if (evictedLayout) {
      geometryLayoutRevision += 1;
      scheduleRender();
    }
    noteDiagnosticsState();
  }

  function touchMetadataCache(kind, index, value, bytes = null) {
    const key = metadataCacheKey(kind, index);
    const previous = metadataCacheLru.get(key);
    if (previous) metadataCacheBytes = Math.max(0, metadataCacheBytes - previous.bytes);
    const estimate = Math.max(
      0,
      Number.isFinite(Number(bytes)) ? Number(bytes) : estimatedMetadataBytes(value)
    );
    metadataCacheLru.delete(key);
    metadataCacheLru.set(key, { kind, index, bytes: estimate });
    metadataCacheBytes += estimate;
    evictMetadataCache(key);
  }

  function ratchetMetadataBudget(reason) {
    const minimum = 2 * 1024 * 1024;
    const next = Math.max(minimum, Math.floor(metadataCacheBudgetBytes * 0.7));
    if (next >= metadataCacheBudgetBytes) return;
    metadataCacheBudgetBytes = next;
    console.debug("reduced gallery metadata cache budget", reason, next);
    evictMetadataCache();
  }

  function touchGeometryChunk(chunkIndex) {
    hotGeometryChunks.delete(chunkIndex);
    hotGeometryChunks.set(chunkIndex, true);
    let bytes = 0;
    for (
      const segmentIndex of
        naturalGeometry.chunkSegments.get(chunkIndex) || []
    ) {
      const segment = naturalGeometry.segments[segmentIndex];
      if (segment.ratios) bytes += segment.ratios.byteLength;
      if (Array.isArray(segment.rows)) bytes += segment.rows.length * 80;
    }
    touchMetadataCache("geometry", chunkIndex, true, bytes);
  }

  function ensureGeometrySegment(
    segmentIndex,
    { prefetch = false, force = false } = {}
  ) {
    if (geometry.mode !== "segmented") return Promise.resolve();
    const segment = geometry.segments[segmentIndex];
    if (!segment) return Promise.reject(new Error("invalid geometry segment"));
    if (segment.ratios) {
      touchGeometryChunk(segment.chunkIndex);
      return Promise.resolve();
    }
    if (segment.loadError && !force) {
      return Promise.reject(segment.loadError);
    }

    const existing = geometryHydrationLoads.get(segment.chunkIndex);
    if (existing) return existing;
    if (force) {
      for (const ownedIndex of geometry.chunkSegments.get(segment.chunkIndex) || []) {
        geometry.segments[ownedIndex].loadError = null;
      }
    }
    const promise = loadGeometryChunkRaw(segment.chunkIndex).then(chunk => {
      const chunkStart = segment.chunkIndex * config.geometryChunkSize;
      for (
        const ownedIndex of
          geometry.chunkSegments.get(segment.chunkIndex) || []
      ) {
        const owned = geometry.segments[ownedIndex];
        const count = owned.pageEnd - owned.pageStart;
        const values = new Float32Array(count);
        const localStart = owned.catalogStart - chunkStart;
        if (localStart < 0 || localStart + count > chunk.length) {
          throw new Error(
            `geometry segment ${ownedIndex} is outside chunk ${segment.chunkIndex}`
          );
        }
        for (let offset = 0; offset < count; offset += 1) {
          values[offset] =
            (Number(chunk[localStart + offset]) || 1) /
            config.geometryRatioScale;
        }
        owned.ratios = values;
        owned.loadError = null;
      }
      touchGeometryChunk(segment.chunkIndex);
      // Hydration changes placeholders into exact rows even though the
      // container width is unchanged. Track that independently from resize
      // invalidation so the next render cannot take the same-width shortcut.
      geometryLayoutRevision += 1;
    }).catch(error => {
      for (const ownedIndex of geometry.chunkSegments.get(segment.chunkIndex) || []) {
        const owned = geometry.segments[ownedIndex];
        if (!prefetch) {
          owned.loadError = error;
          owned.failureCount += 1;
        }
      }
      if (!prefetch) geometryLayoutRevision += 1;
      throw error;
    }).finally(() => {
      geometryHydrationLoads.delete(segment.chunkIndex);
    });
    geometryHydrationLoads.set(segment.chunkIndex, promise);
    return promise;
  }

  function ratioAt(pageIndex) {
    if (geometry.mode !== "segmented") {
      return geometry.ratios[pageIndex];
    }
    const segment = geometrySegmentForPageIndex(pageIndex);
    if (!segment || !segment.ratios) {
      throw new Error(`geometry is not resident for page index ${pageIndex}`);
    }
    return segment.ratios[pageIndex - segment.pageStart];
  }

  function touchChunk(chunkIndex, chunk) {
    catalogChunkCache.delete(chunkIndex);
    catalogChunkCache.set(chunkIndex, chunk);
    touchMetadataCache("catalog", chunkIndex, chunk);

    while (catalogChunkCache.size > MAX_HOT_CATALOG_CHUNKS) {
      const oldest = catalogChunkCache.keys().next().value;
      catalogChunkCache.delete(oldest);
      const key = metadataCacheKey("catalog", oldest);
      const entry = metadataCacheLru.get(key);
      if (entry) dropMetadataCacheEntry(key, entry);
    }
    publishRegressionState();
  }

  function chunkFilename(chunkIndex) {
    return contentAddressedChunkFilename(
      String(chunkIndex).padStart(
        config.catalogChunkFilenameWidth,
        "0"
      ),
      chunkRevision("catalog", chunkIndex)
    );
  }

  function catalogBinaryFilename(chunkIndex) {
    const stem = String(chunkIndex).padStart(
      config.catalogChunkFilenameWidth,
      "0"
    );
    const revision = String(
      chunkInfo("catalog", chunkIndex).binaryRevision || ""
    );
    if (!/^[0-9a-f]{12}$/i.test(revision)) {
      throw new Error(
        `missing or invalid binary catalog revision for ${stem}`
      );
    }
    return `${stem}.${revision}.bin`;
  }

  function decodeCatalogBinary(buffer, chunkIndex) {
    const header = CATALOG_SCHEMA.header;
    const record = CATALOG_SCHEMA.record;
    const video = CATALOG_SCHEMA.video;
    if (buffer.byteLength < CATALOG_SCHEMA.headerSize) {
      throw new Error("binary catalog header is truncated");
    }
    const view = new DataView(buffer);
    if (CATALOG_SCHEMA.magic.some((value, index) => (
      view.getUint8(index) !== value
    ))) {
      throw new Error("binary catalog magic mismatch");
    }
    const version = view.getUint32(header.version, true);
    const count = view.getUint32(header.count, true);
    const stringCount = view.getUint32(header.string_count, true);
    const stringOffsetsOffset = view.getUint32(header.string_offsets, true);
    const recordOffsetsOffset = view.getUint32(header.record_offsets, true);
    const recordsOffset = view.getUint32(header.records, true);
    const stringsOffset = view.getUint32(header.strings, true);
    const expected = Number(chunkInfo("catalog", chunkIndex).count);
    const stringTableEnd = stringOffsetsOffset + (stringCount + 1) * 4;
    const recordTableEnd = recordOffsetsOffset + (count + 1) * 4;
    if (
      version !== CATALOG_SCHEMA.version ||
      (Number.isFinite(expected) && expected >= 0 && count !== expected) ||
      stringOffsetsOffset !== CATALOG_SCHEMA.headerSize ||
      stringTableEnd > recordOffsetsOffset ||
      recordTableEnd > recordsOffset ||
      recordsOffset > stringsOffset ||
      stringsOffset > buffer.byteLength
    ) {
      throw new Error("binary catalog envelope is invalid");
    }
    const finalRecordOffset = view.getUint32(
      recordOffsetsOffset + count * 4,
      true
    );
    const finalStringOffset = view.getUint32(
      stringOffsetsOffset + stringCount * 4,
      true
    );
    if (
      finalRecordOffset !== stringsOffset - recordsOffset ||
      finalStringOffset !== buffer.byteLength - stringsOffset
    ) {
      throw new Error("binary catalog offset table is invalid");
    }

    const bytes = new Uint8Array(buffer);
    const decoder = new TextDecoder("utf-8", { fatal: true });
    const NULL_STRING = CATALOG_SCHEMA.nullString;

    function stringAt(identifier) {
      if (identifier === NULL_STRING) return null;
      if (identifier < 0 || identifier >= stringCount) {
        throw new Error("binary catalog string index is invalid");
      }
      const start = view.getUint32(
        stringOffsetsOffset + identifier * 4,
        true
      );
      const end = view.getUint32(
        stringOffsetsOffset + (identifier + 1) * 4,
        true
      );
      if (end < start || stringsOffset + end > buffer.byteLength) {
        throw new Error("binary catalog string bounds are invalid");
      }
      return decoder.decode(bytes.subarray(stringsOffset + start, stringsOffset + end));
    }

    function recordAt(index) {
      if (!Number.isInteger(index) || index < 0 || index >= count) return null;
      const relativeStart = view.getUint32(
        recordOffsetsOffset + index * 4,
        true
      );
      const relativeEnd = view.getUint32(
        recordOffsetsOffset + (index + 1) * 4,
        true
      );
      const start = recordsOffset + relativeStart;
      const end = recordsOffset + relativeEnd;
      if (end < start + CATALOG_SCHEMA.recordSize || end > stringsOffset) {
        throw new Error(`binary catalog record ${index} is truncated`);
      }
      const kind = view.getUint8(start + record.kind);
      const flags = view.getUint8(start + record.flags);
      const name = stringAt(view.getUint32(start + record.name, true));
      const digest = stringAt(view.getUint32(start + record.digest, true));
      const suffix = stringAt(view.getUint32(start + record.suffix, true));
      const stableId = stringAt(view.getUint32(start + record.stable_id, true));
      const color = stringAt(view.getUint32(start + record.color, true));
      const width = view.getUint32(start + record.width, true);
      const height = view.getUint32(start + record.height, true);
      if (kind === 0) {
        if (end !== start + CATALOG_SCHEMA.recordSize) {
          throw new Error("invalid static catalog record");
        }
        return [name, digest, 0, width, height, suffix, stableId, color];
      }
      if (kind === 1) {
        if (end !== start + CATALOG_SCHEMA.recordSize) {
          throw new Error("invalid passthrough catalog record");
        }
        return [name, digest, 1, suffix, width, height, stableId, color];
      }
      const videoStart = start + CATALOG_SCHEMA.recordSize;
      if (
        kind !== 2 ||
        end !== videoStart + CATALOG_SCHEMA.videoSize
      ) {
        throw new Error(`invalid catalog record kind ${kind}`);
      }
      const duration = view.getFloat64(videoStart + video.duration, true);
      const fileSize = Number(view.getBigUint64(videoStart + video.file_size, true));
      const videoCodec = stringAt(view.getUint32(videoStart + video.video_codec, true));
      const videoProfile = stringAt(view.getUint32(videoStart + video.video_profile, true));
      const audioCodec = stringAt(view.getUint32(videoStart + video.audio_codec, true));
      const frameRate = view.getFloat64(videoStart + video.frame_rate, true);
      const creationTime = stringAt(view.getUint32(videoStart + video.creation_time, true));
      const compatibilityNote = stringAt(view.getUint32(videoStart + video.compatibility_note, true));
      const compatibilityFilename = stringAt(view.getUint32(videoStart + video.compatibility_filename, true));
      return [
        name, digest, 2, suffix, width, height, stableId,
        flags & 1 ? 1 : 0,
        Number.isNaN(duration) ? null : duration,
        fileSize,
        videoCodec,
        videoProfile,
        audioCodec,
        Number.isNaN(frameRate) ? null : frameRate,
        creationTime,
        compatibilityNote,
        compatibilityFilename,
        color
      ];
    }

    return {
      binary: true,
      length: count,
      byteLength: buffer.byteLength,
      recordAt
    };
  }

  async function fetchCatalogBinary(chunkIndex, attempts = 3) {
    let lastError = null;
    const revision = chunkInfo("catalog", chunkIndex).binaryRevision;
    const baseUrl = `${config.catalogBase}/${catalogBinaryFilename(chunkIndex)}`;
    for (let attempt = 0; attempt < attempts; attempt += 1) {
      if (attempt > 0) {
        await waitForOnline();
        await new Promise(resolve => setTimeout(resolve, retryDelay(attempt)));
      }
      const url = attempt === 0
        ? baseUrl
        : `${baseUrl}?retry=${attempt}`;
      try {
        const response = await fetch(url, {
          cache: attempt === 0 ? "force-cache" : "reload",
          credentials: "same-origin"
        });
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        const buffer = await response.arrayBuffer();
        await verifyBinaryRevision(
          buffer,
          revision,
          `binary catalog chunk ${chunkIndex}`
        );
        const decoded = decodeCatalogBinary(buffer, chunkIndex);
        binaryCatalogLoads += 1;
        return decoded;
      } catch (error) {
        if (/hash mismatch/i.test(String(error && error.message || error))) {
          binaryIntegrityFailures += 1;
        }
        lastError = error;
      }
    }
    throw new Error(
      `failed to load binary catalog chunk ${chunkIndex}: ` +
      String(lastError && lastError.message || lastError)
    );
  }

  async function loadCatalogScriptFallback(chunkIndex, prefetch) {
    const baseSrc = `${config.catalogBase}/${chunkFilename(chunkIndex)}`;
    await loadScriptWithRetry(
      baseSrc,
      `catalog chunk ${chunkIndex}`,
      3,
      !prefetch
    );
    const loaded = await registeredArrayOrRecover(
      rawCatalogChunks,
      chunkIndex,
      `catalog chunk ${chunkIndex}`,
      !prefetch
    );
    catalogFallbackLoads += 1;
    return loaded;
  }

  function loadCatalogChunk(chunkIndex, { prefetch = false } = {}) {
    const cached = catalogChunkCache.get(chunkIndex);
    if (cached) {
      touchChunk(chunkIndex, cached);
      return Promise.resolve(cached);
    }

    const existingRaw = rawCatalogChunks[chunkIndex];
    if (existingRaw) {
      delete rawCatalogChunks[chunkIndex];
      touchChunk(chunkIndex, existingRaw);
      return Promise.resolve(existingRaw);
    }

    const pending = catalogChunkLoads.get(chunkIndex);
    if (pending) return pending;

    const canFetchBinary =
      location.protocol !== "file:" && typeof fetch === "function";
    const promise = ensureChunkRevision("catalog", chunkIndex).then(() => (
      canFetchBinary
        ? fetchCatalogBinary(chunkIndex).catch(error => {
            console.debug(
              `binary catalog chunk ${chunkIndex} unavailable; ` +
              "using JavaScript fallback",
              error
            );
            return loadCatalogScriptFallback(chunkIndex, prefetch);
          })
        : loadCatalogScriptFallback(chunkIndex, prefetch)
    )).then(loaded => {
      touchChunk(chunkIndex, loaded);
      return loaded;
    }).finally(() => {
      catalogChunkLoads.delete(chunkIndex);
    });

    catalogChunkLoads.set(chunkIndex, promise);
    return promise;
  }

  function touchExifChunk(chunkIndex, chunk) {
    exifChunkCache.delete(chunkIndex);
    exifChunkCache.set(chunkIndex, chunk);
    touchMetadataCache("exif", chunkIndex, chunk);
  }

  function loadExifChunk(chunkIndex) {
    const cached = exifChunkCache.get(chunkIndex);
    if (cached) {
      touchExifChunk(chunkIndex, cached);
      return Promise.resolve(cached);
    }

    const existingRaw = rawExifChunks[chunkIndex];
    if (existingRaw) {
      delete rawExifChunks[chunkIndex];
      touchExifChunk(chunkIndex, existingRaw);
      return Promise.resolve(existingRaw);
    }

    const pending = exifChunkLoads.get(chunkIndex);
    if (pending) return pending;

    const promise = ensureChunkRevision("exif", chunkIndex).then(() => {
      const filename = contentAddressedChunkFilename(
        String(chunkIndex).padStart(
          config.catalogChunkFilenameWidth,
          "0"
        ),
        chunkRevision("exif", chunkIndex)
      );
      return loadScriptWithRetry(
        `${config.exifBase}/${filename}`,
        `EXIF chunk ${chunkIndex}`
      );
    }).then(async () => {
      const loaded = await registeredArrayOrRecover(
        rawExifChunks,
        chunkIndex,
        `EXIF chunk ${chunkIndex}`
      );
      touchExifChunk(chunkIndex, loaded);
      return loaded;
    }).finally(() => {
      exifChunkLoads.delete(chunkIndex);
    });
    exifChunkLoads.set(chunkIndex, promise);
    return promise;
  }


  function loadIdShard(shard) {
    const existing = rawIdShards[shard];
    if (Array.isArray(existing)) {
      delete rawIdShards[shard];
      return Promise.resolve(existing);
    }
    const pending = idShardLoads.get(shard);
    if (pending) return pending;

    const promise = ensureChunkRevision("ids", shard).then(() => {
      const filename = contentAddressedChunkFilename(
        shard,
        chunkRevision("ids", shard)
      );
      return loadScriptWithRetry(
        `${config.idBase}/${filename}`,
        `stable-ID shard ${shard}`
      );
    }).then(async () => registeredArrayOrRecover(
      rawIdShards,
      shard,
      `stable-ID shard ${shard}`
    )).finally(() => {
      idShardLoads.delete(shard);
    });
    idShardLoads.set(shard, promise);
    return promise;
  }


  function chunkIndexForCatalogId(catalogId) {
    return Math.floor(catalogId / config.chunkSize);
  }

  async function metadataAt(pageIndex) {
    const catalogId = catalogIdAt(pageIndex);
    const bootstrapped = bootstrapCatalogItems.get(catalogId);
    if (bootstrapped) {
      markStartupPhase("first-catalog-metadata");
      return bootstrapped;
    }
    const chunkIndex = chunkIndexForCatalogId(catalogId);
    const chunk = await loadCatalogChunk(chunkIndex);
    const offset = catalogId - (chunkIndex * config.chunkSize);
    const metadata = chunk && chunk.binary
      ? chunk.recordAt(offset)
      : chunk[offset];

    if (!Array.isArray(metadata)) {
      throw new Error(`missing catalog metadata for id ${catalogId}`);
    }

    markStartupPhase("first-catalog-metadata");
    return metadata;
  }

  async function exifAt(pageIndex) {
    const catalogId = catalogIdAt(pageIndex);
    const chunkIndex = chunkIndexForCatalogId(catalogId);
    const chunk = await loadExifChunk(chunkIndex);
    const offset = catalogId - (chunkIndex * config.chunkSize);
    const metadata = chunk[offset];
    if (!Array.isArray(metadata)) {
      throw new Error(`missing EXIF metadata for id ${catalogId}`);
    }
    return metadata;
  }

  function assetDirectoryForDigest(digest) {
    const parts = [config.assetBase];
    let offset = 0;

    for (
      let level = 0;
      level < config.assetShardLevels;
      level += 1
    ) {
      parts.push(
        digest.slice(offset, offset + config.assetShardWidth)
      );
      offset += config.assetShardWidth;
    }

    return parts.join("/");
  }

  function derivativeWidths(sourceWidth) {
    const widths = [];

    for (const width of config.widths) {
      if (width < sourceWidth) widths.push(width);
    }

    const largestConfigured =
      config.widths[config.widths.length - 1];

    widths.push(
      sourceWidth <= largestConfigured
        ? sourceWidth
        : largestConfigured
    );

    widths.sort((a, b) => a - b);

    const unique = [];
    for (const width of widths) {
      if (
        !unique.length ||
        unique[unique.length - 1] !== width
      ) {
        unique.push(width);
      }
    }

    return unique;
  }

  function pageRelativeName(value) {
    const normalized = String(value || "").replace(/\\/g, "/");
    const prefix = String(config.namePrefix || "")
      .replace(/\\/g, "/")
      .replace(/^\/+|\/+$/g, "");

    if (!prefix) return normalized;

    const prefixWithSlash = `${prefix}/`;
    return normalized.startsWith(prefixWithSlash)
      ? normalized.slice(prefixWithSlash.length)
      : normalized;
  }

  function sourceFormat(sourceSuffix) {
    const suffix = String(sourceSuffix || "").toLowerCase();
    const names = {
      ".jpg": "JPEG",
      ".jpeg": "JPEG",
      ".jpe": "JPEG",
      ".jfif": "JPEG",
      ".jxl": "JPEG XL",
      ".webp": "WebP",
      ".png": "PNG",
      ".avif": "AVIF",
      ".gif": "GIF",
      ".tif": "TIFF",
      ".tiff": "TIFF",
      ".bmp": "BMP",
      ".svg": "SVG",
      ".svgz": "SVG",
      ".3gp": "3GPP video",
      ".avi": "AVI video",
      ".m2ts": "MPEG-2 transport stream",
      ".m4v": "M4V video",
      ".mkv": "Matroska video",
      ".mov": "QuickTime video",
      ".mp4": "MP4 video",
      ".mpeg": "MPEG video",
      ".mpg": "MPEG video",
      ".mts": "MPEG-2 transport stream",
      ".ogv": "Ogg video",
      ".ts": "MPEG transport stream",
      ".webm": "WebM video"
    };
    return names[suffix] || suffix.replace(/^\./, "").toUpperCase() || "Unknown";
  }

  async function itemAt(pageIndex) {
    const metadata = await metadataAt(pageIndex);

    const catalogSchemas = COMPACT_SCHEMA.catalog;
    const kindCode = Number(metadata[catalogSchemas.static.kind]);
    const fields = kindCode === 0
      ? catalogSchemas.static
      : (kindCode === 2 ? catalogSchemas.video : catalogSchemas.passthrough);
    const canonicalName = metadata[fields.name];
    const name = pageRelativeName(canonicalName);
    const digest = metadata[fields.digest];
    const passthrough = kindCode === 1;
    const video = kindCode === 2;
    const width = Number(metadata[fields.width]);
    const height = Number(metadata[fields.height]);
    const sourceSuffix = String(metadata[fields.suffix] || "");
    const passthroughAnimated = passthrough &&
      [".gif", ".webp", ".png", ".avif"].includes(sourceSuffix);
    const stableId = String(metadata[fields.stable_id] || "");
    const placeholderColor = String(metadata[fields.average_color] || "");
    const ratio = width / height;
    const assetDirectory = assetDirectoryForDigest(digest);

    if (video) {
      const originalSrc = `${assetDirectory}/${digest}${sourceSuffix}`;
      const compatibilityFilename = String(
        metadata[fields.compatibility_filename] || ""
      );
      const playbackTranscoded = Boolean(
        config.videoTranscodeEnabled && compatibilityFilename
      );
      const src = playbackTranscoded
        ? `${assetDirectory}/${compatibilityFilename}`
        : originalSrc;
      return {
        name,
        width,
        height,
        ratio,
        src,
        srcset: [],
        stableId,
        format: sourceFormat(sourceSuffix),
        sourceSuffix,
        animated: false,
        video: true,
        posterSrc: metadata[fields.has_poster]
          ? `${assetDirectory}/${digest}-poster.jpg`
          : null,
        duration: Number(metadata[fields.duration]) || null,
        fileSize: Number(metadata[fields.file_size]) || null,
        videoCodec: String(metadata[fields.video_codec] || ""),
        videoProfile: String(metadata[fields.video_profile] || ""),
        audioCodec: String(metadata[fields.audio_codec] || ""),
        frameRate: Number(metadata[fields.frame_rate]) || null,
        creationTime: String(metadata[fields.creation_time] || ""),
        compatibilityNote: String(metadata[fields.compatibility_note] || ""),
        originalSrc,
        playbackTranscoded,
        placeholderColor
      };
    }

    if (passthrough) {
      const src = `${assetDirectory}/${digest}${sourceSuffix}`;
      return {
        name,
        width,
        height,
        ratio,
        src,
        srcset: [],
        stableId,
        format: sourceFormat(sourceSuffix),
        sourceSuffix,
        animated: passthroughAnimated,
        video: false,
        posterSrc: null,
        originalSrc: config.includeOriginals ? src : null,
        placeholderColor
      };
    }

    const widths = derivativeWidths(width);
    const srcset = widths.map(variantWidth => [
      variantWidth,
      `${assetDirectory}/${digest}-w${variantWidth}${config.staticExt}`
    ]);

    return {
      name,
      width,
      height,
      ratio,
      src: srcset[srcset.length - 1][1],
      srcset,
      stableId,
      format: sourceFormat(sourceSuffix),
      sourceSuffix,
      animated: false,
      video: false,
      posterSrc: null,
      originalSrc: config.includeOriginals
        ? `${assetDirectory}/${digest}-original${sourceSuffix}`
        : null,
      placeholderColor
    };
  }


  function srcsetString(item) {
    return item.srcset
      .map(([width, src]) => `${src} ${width}w`)
      .join(", ");
  }

  function adaptiveGridDprCap() {
    const averageDecodeMs = diagnostics.decodeCount
      ? diagnostics.decodeTotalMs / diagnostics.decodeCount
      : 0;
    if (
      constrainedNetwork ||
      diagnostics.loadErrors >= 3 ||
      averageDecodeMs >= 45
    ) {
      return Math.min(BASE_GRID_DPR_CAP, 1.25);
    }
    if (averageDecodeMs >= 25) {
      return Math.min(BASE_GRID_DPR_CAP, 1.5);
    }
    return BASE_GRID_DPR_CAP;
  }

  function gridResponsiveSize(cssWidth) {
    const browserDpr = Math.max(1, Number(window.devicePixelRatio) || 1);
    const cappedDpr = Math.min(browserDpr, adaptiveGridDprCap());
    return `${Math.max(1, Math.ceil(cssWidth * cappedDpr / browserDpr))}px`;
  }

  function galleryViewportBounds() {
    const rect = gallery.getBoundingClientRect();
    const top = -rect.top;
    const height = Math.max(
      1,
      window.innerHeight ||
        document.documentElement.clientHeight ||
        1
    );

    return {
      top,
      bottom: top + height
    };
  }

  function adaptiveOverscanViewports() {
    let value = OVERSCAN_VIEWPORTS;
    const averageDecodeMs = diagnostics.decodeCount
      ? diagnostics.decodeTotalMs / diagnostics.decodeCount
      : 0;
    if (constrainedNetwork) value *= 0.65;
    if (averageDecodeMs >= 40 || diagnostics.loadErrors >= 3) value *= 0.7;
    // More look-ahead is useful on desktop, but it increases decoded-image
    // residency at exactly the wrong time on iOS.
    if (
      !isIOSWebKit &&
      observedScrollSpeed >= 1.5 &&
      averageDecodeMs < 25
    ) value *= 1.25;
    return Math.max(0.45, Math.min(2, value));
  }

  function directionalMargins(base) {
    const speedBoost = isIOSWebKit
      ? 1
      : Math.min(1.7, 1 + observedScrollSpeed * 0.12);
    const ahead = base * 1.35 * speedBoost;
    const behind = base * 0.55;
    return scrollDirection >= 0
      ? { before: behind, after: ahead }
      : { before: ahead, after: behind };
  }

  function rowVisibilityState(row, viewport) {
    const margins = directionalMargins(IMAGE_LOAD_MARGIN);
    return {
      visible:
        row.bottom >= viewport.top &&
        row.top <= viewport.bottom,
      resident:
        row.bottom >=
          viewport.top - margins.before &&
        row.top <=
          viewport.bottom + margins.after
    };
  }

  function tileLoadDistance(img, viewport) {
    const rowElement = img.closest(".gallery-row");
    if (!rowElement) return Number.POSITIVE_INFINITY;

    const rowIndex = Number(rowElement.dataset.row);
    const row = rows[rowIndex];
    if (!row) return Number.POSITIVE_INFINITY;

    if (
      row.bottom >= viewport.top &&
      row.top <= viewport.bottom
    ) {
      return 0;
    }

    if (row.top > viewport.bottom) {
      const distance = row.top - viewport.bottom;
      return distance * (scrollDirection >= 0 ? 0.7 : 1.4);
    }

    const distance = viewport.top - row.bottom;
    return distance * (scrollDirection < 0 ? 0.7 : 1.4);
  }

  function scheduleTileLoadPump() {
    if (tileLoadPumpScheduled) return;
    tileLoadPumpScheduled = true;
    requestAnimationFrame(pumpTileLoads);
  }

  function scheduleImageResidencyUpdate() {
    if (imageResidencyScheduled) return;
    imageResidencyScheduled = true;
    requestAnimationFrame(updateMountedImageResidency);
  }

  function syncImageResidency(img, state) {
    if (!img.isConnected || !img.dataset.src) {
      return;
    }

    // During an iOS momentum scroll, keep an already-loaded image only while
    // it is still actually visible. Do not start work for newly encountered
    // tiles until scrolling settles. This prevents a succession of abandoned
    // WebKit decode jobs from outrunning the tab's memory reclamation.
    if (isIOSWebKit && iosFastScrollActive) {
      pendingTileLoads.delete(img);
      if (!state.visible) {
        releaseTileImage(img);
        return;
      }
      if (img.dataset.resident !== "1") {
        img.dataset.resident = "1";
        if (CLIENT_DIAGNOSTICS) {
          diagnostics.residentImages += 1;
          noteDiagnosticsState();
        }
      }
      img.dataset.wanted = img.dataset.loaded === "1" ? "1" : "0";
      return;
    }

    if (state.resident) {
      if (img.dataset.resident !== "1") {
        img.dataset.resident = "1";
        if (CLIENT_DIAGNOSTICS) {
          diagnostics.residentImages += 1;
          noteDiagnosticsState();
        }
      }

      img.dataset.wanted = "1";
      img.dataset.loadPriority =
        state.visible ? "0" : "1";

      if (
        img.dataset.loaded !== "1" ||
        img.dataset.forceReload === "1"
      ) {
        pendingTileLoads.add(img);
        noteDiagnosticsState();
        scheduleTileLoadPump();
      }
      return;
    }

    releaseTileImage(img);
  }

  function updateMountedImageResidency() {
    imageResidencyScheduled = false;

    const viewport = galleryViewportBounds();

    for (
      const [rowIndex, rowElement]
        of mountedRows.entries()
    ) {
      const row = rows[rowIndex];
      if (!row) continue;

      const state =
        rowVisibilityState(row, viewport);

      for (
        const img of rowElement.querySelectorAll("img")
      ) {
        syncImageResidency(img, state);
      }
    }
  }

  function startImageLoad(img, force = false) {
    if (!img.isConnected || img.dataset.wanted !== "1") {
      return false;
    }
    if (!img.dataset.src) {
      return false;
    }
    if (img.dataset.loaded === "1" && !force) {
      return false;
    }

    const set = img.dataset.srcset;
    const src = img.dataset.src;
    const tile = img.closest(".tile");

    if (tile) {
      tile.classList.remove("load-error");
      delete tile.dataset.retryReady;
    }

    if (force) {
      img.removeAttribute("srcset");
      img.removeAttribute("src");
    }

    const loadToken = String(nextTileLoadToken++);
    img.dataset.loadToken = loadToken;

    if (set) img.srcset = set;
    if (src) img.src = src;

    img.dataset.loaded = "1";
    activeTileLoads.add(img);
    if (CLIENT_DIAGNOSTICS) {
      diagnostics.loadsStarted += 1;
      noteDiagnosticsState();
    }
    return true;
  }

  function pumpTileLoads() {
    tileLoadPumpScheduled = false;

    const candidates = [];
    for (const img of Array.from(pendingTileLoads)) {
      if (!img.isConnected || img.dataset.wanted !== "1") {
        pendingTileLoads.delete(img);
        continue;
      }
      if (activeTileLoads.has(img)) {
        pendingTileLoads.delete(img);
        continue;
      }
      if (
        img.dataset.loaded === "1" &&
        img.dataset.forceReload !== "1"
      ) {
        pendingTileLoads.delete(img);
        continue;
      }
      candidates.push(img);
    }

    const viewport = galleryViewportBounds();

    candidates.sort((a, b) => {
      const priorityA = Number(
        a.dataset.loadPriority || "1"
      );
      const priorityB = Number(
        b.dataset.loadPriority || "1"
      );
      if (priorityA !== priorityB) {
        return priorityA - priorityB;
      }
      return (
        tileLoadDistance(a, viewport) -
        tileLoadDistance(b, viewport)
      );
    });

    for (const img of candidates) {
      if (
        activeTileLoads.size >=
        MAX_ACTIVE_TILE_LOADS
      ) {
        break;
      }

      pendingTileLoads.delete(img);
      const force =
        img.dataset.forceReload === "1";
      delete img.dataset.forceReload;
      startImageLoad(img, force);
    }

    noteDiagnosticsState();
  }

  function activateImage(img, force = false) {
    if (!img.dataset.src) return;

    img.dataset.wanted = "1";
    img.dataset.loadPriority = "0";

    if (force) {
      img.dataset.loaded = "0";
      img.dataset.forceReload = "1";

      if (activeTileLoads.delete(img)) {
        img.removeAttribute("srcset");
        img.removeAttribute("src");
      }
    } else if (img.dataset.loaded === "1") {
      return;
    }

    pendingTileLoads.add(img);
    noteDiagnosticsState();
    scheduleTileLoadPump();
  }

  function retryTileImage(tile) {
    const img = tile.querySelector("img");
    if (!img || !img.dataset.src) return;
    img.dataset.retryCount = "0";
    delete tile.dataset.retryReady;
    tile.classList.remove("image-loaded", "load-error");
    tile.classList.add("image-loading");
    if (CLIENT_DIAGNOSTICS) {
      diagnostics.retries += 1;
      noteDiagnosticsState();
    }
    activateImage(img, true);
  }

  function handleTileImageError(tile, img) {
    if (activeTileLoads.delete(img)) {
      scheduleTileLoadPump();
    }

    if (CLIENT_DIAGNOSTICS) {
      diagnostics.loadErrors += 1;
      noteDiagnosticsState();
    }

    const retryCount = Number(img.dataset.retryCount || "0");
    if (retryCount < 1) {
      img.dataset.retryCount = String(retryCount + 1);
      const bindingToken = img.dataset.bindingToken || "";
      if (CLIENT_DIAGNOSTICS) {
        diagnostics.retries += 1;
        noteDiagnosticsState();
      }
      setTimeout(() => {
        if (
          !img.isConnected ||
          img.dataset.bindingToken !== bindingToken ||
          tile.dataset.bindingToken !== bindingToken
        ) return;

        img.dataset.loaded = "0";
        img.dataset.forceReload = "1";
        scheduleImageResidencyUpdate();
      }, 350);
      return;
    }

    if (tile.classList.contains("video-tile")) {
      delete img.dataset.src;
      delete img.dataset.srcset;
      delete tile.dataset.retryReady;
      tile.classList.remove("image-loading", "load-error");
      tile.classList.add("image-loaded", "video-no-poster");
      setTileTooltip(tile, tile.getAttribute("aria-label") || "Video");
      return;
    }

    tile.classList.remove("image-loading", "image-loaded");
    tile.classList.add("load-error");
    tile.dataset.retryReady = "1";
    setTileTooltip(tile, "Image failed to load — tap to retry");
  }

  function measureSafeAreaInsets() {
    const probe = document.createElement("div");
    probe.style.cssText = [
      "position:absolute",
      "visibility:hidden",
      "pointer-events:none",
      "padding-left:env(safe-area-inset-left)",
      "padding-right:env(safe-area-inset-right)",
      "padding-bottom:env(safe-area-inset-bottom)"
    ].join(";");

    document.body.appendChild(probe);

    const style = getComputedStyle(probe);
    const insets = {
      left: parseFloat(style.paddingLeft) || 0,
      right: parseFloat(style.paddingRight) || 0,
      bottom: parseFloat(style.paddingBottom) || 0
    };

    probe.remove();
    return insets;
  }

  function rowHeightFromRatioSum(ratioSum, count, containerWidth) {
    const gaps = config.gap * Math.max(0, count - 1);
    return (containerWidth - gaps) / Math.max(ratioSum, 0.000001);
  }

  function makeRows(containerWidth) {
    const result = [];
    let rowStart = 0;
    let rowCount = 0;
    let ratioSum = 0;

    function pushRow(startIndex, count, height, complete) {
      result.push({
        startIndex,
        count,
        height,
        complete,
        top: 0,
        bottom: 0
      });
    }

    for (
      let pageIndex = 0;
      pageIndex < itemCount;
      pageIndex += 1
    ) {
      if (rowCount === 0) {
        rowStart = pageIndex;
        ratioSum = 0;
      }
      const currentRatio = ratioAt(pageIndex);
      rowCount += 1;
      ratioSum += currentRatio;

      const height = rowHeightFromRatioSum(
        ratioSum,
        rowCount,
        containerWidth
      );

      if (height <= config.targetRowHeight) {
        if (rowCount > 1) {
          const previousCount = rowCount - 1;
          const previousRatioSum = ratioSum - currentRatio;
          const previousHeight = rowHeightFromRatioSum(
            previousRatioSum,
            previousCount,
            containerWidth
          );

          if (
            Math.abs(
              previousHeight - config.targetRowHeight
            ) <
              Math.abs(
                height - config.targetRowHeight
              ) &&
            previousHeight <=
              config.targetRowHeight * 1.5
          ) {
            pushRow(
              rowStart,
              previousCount,
              previousHeight,
              true
            );
            rowStart = pageIndex;
            rowCount = 1;
            ratioSum = currentRatio;
            continue;
          }
        }

        pushRow(
          rowStart,
          rowCount,
          height,
          true
        );
        rowCount = 0;
        ratioSum = 0;
      }
    }

    if (rowCount) {
      pushRow(
        rowStart,
        rowCount,
        Math.min(
          config.targetRowHeight,
          rowHeightFromRatioSum(
            ratioSum,
            rowCount,
            containerWidth
          )
        ),
        false
      );
    }

    return result;
  }

  function makeExactSegmentRows(segment, containerWidth) {
    const result = [];
    let rowStart = segment.pageStart;
    let rowCount = 0;
    let ratioSum = 0;

    function pushRow(startIndex, count, height, complete) {
      result.push({
        startIndex,
        count,
        height,
        complete,
        segmentIndex: segment.index,
        placeholder: false,
        top: 0,
        bottom: 0
      });
    }

    for (
      let pageIndex = segment.pageStart;
      pageIndex < segment.pageEnd;
      pageIndex += 1
    ) {
      if (rowCount === 0) {
        rowStart = pageIndex;
        ratioSum = 0;
      }
      const currentRatio = ratioAt(pageIndex);
      rowCount += 1;
      ratioSum += currentRatio;
      const height = rowHeightFromRatioSum(
        ratioSum,
        rowCount,
        containerWidth
      );
      if (height <= config.targetRowHeight) {
        if (rowCount > 1) {
          const previousCount = rowCount - 1;
          const previousRatioSum = ratioSum - currentRatio;
          const previousHeight = rowHeightFromRatioSum(
            previousRatioSum,
            previousCount,
            containerWidth
          );
          if (
            Math.abs(previousHeight - config.targetRowHeight) <
              Math.abs(height - config.targetRowHeight) &&
            previousHeight <= config.targetRowHeight * 1.5
          ) {
            pushRow(rowStart, previousCount, previousHeight, true);
            rowStart = pageIndex;
            rowCount = 1;
            ratioSum = currentRatio;
            continue;
          }
        }
        pushRow(rowStart, rowCount, height, true);
        rowCount = 0;
        ratioSum = 0;
      }
    }

    if (rowCount) {
      pushRow(
        rowStart,
        rowCount,
        Math.min(
          config.targetRowHeight,
          rowHeightFromRatioSum(ratioSum, rowCount, containerWidth)
        ),
        false
      );
    }
    segment.rows = result;
    segment.layoutWidth = containerWidth;
    return result;
  }

  function estimatedSegmentHeight(segment, containerWidth) {
    const count = Math.max(1, segment.pageEnd - segment.pageStart);
    const averageRatio = Math.max(
      0.05,
      segment.estimatedRatioSum / count
    );
    const estimatedPerRow = Math.max(
      1,
      Math.floor(
        (containerWidth + config.gap) /
        (config.targetRowHeight * averageRatio + config.gap)
      )
    );
    const rowCount = Math.max(1, Math.ceil(count / estimatedPerRow));
    return rowCount * config.targetRowHeight +
      Math.max(0, rowCount - 1) * config.gap;
  }

  async function requestWorkerRows(
    ratios,
    containerWidth,
    startIndex = 0,
    segmentIndex = null
  ) {
    const worker = getLayoutWorker();
    if (!worker) return null;
    const requestId = ++layoutWorkerRequestId;
    const ratioCopy = ratios.slice();
    const response = new Promise((resolve, reject) => {
      layoutWorkerPending.set(requestId, { resolve, reject });
    });
    worker.postMessage(
      {
        requestId,
        ratiosBuffer: ratioCopy.buffer,
        containerWidth,
        targetRowHeight: config.targetRowHeight,
        gap: config.gap,
        startIndex
      },
      [ratioCopy.buffer]
    );
    const rowsBuffer = await response;
    const packed = new Float64Array(rowsBuffer);
    const result = [];
    for (let offset = 0; offset + 3 < packed.length; offset += 4) {
      result.push({
        startIndex: Math.trunc(packed[offset]),
        count: Math.trunc(packed[offset + 1]),
        height: packed[offset + 2],
        complete: packed[offset + 3] !== 0,
        segmentIndex,
        placeholder: false,
        top: 0,
        bottom: 0
      });
    }
    return result;
  }

  async function makeSegmentedRowsAsync(containerWidth) {
    if (layoutWorkerPending.size) {
      const superseded = new Error("layout request superseded by a newer width");
      superseded.layoutSuperseded = true;
      stopLayoutWorker(superseded);
    }

    const segmentRows = await Promise.all(
      geometry.segments.map(async segment => {
        if (
          segment.layoutWidth === containerWidth &&
          Array.isArray(segment.rows)
        ) {
          return segment.rows;
        }
        if (!segment.ratios) {
          return [{
            startIndex: segment.pageStart,
            count: segment.pageEnd - segment.pageStart,
            height: estimatedSegmentHeight(segment, containerWidth),
            complete: false,
            segmentIndex: segment.index,
            placeholder: true,
            failureVersion: segment.failureCount,
            top: 0,
            bottom: 0
          }];
        }

        try {
          const workerRows = await requestWorkerRows(
            segment.ratios,
            containerWidth,
            segment.pageStart,
            segment.index
          );
          if (workerRows) {
            segment.rows = workerRows;
            segment.layoutWidth = containerWidth;
            return workerRows;
          }
        } catch (error) {
          if (error && error.layoutSuperseded) throw error;
          console.debug(
            `layout worker failed for segment ${segment.index}; ` +
            "using main thread",
            error
          );
        }
        return makeExactSegmentRows(segment, containerWidth);
      })
    );
    return segmentRows.flat();
  }

  function makeSegmentedRows(containerWidth) {
    const result = [];
    for (const segment of geometry.segments) {
      if (segment.layoutWidth === containerWidth && Array.isArray(segment.rows)) {
        result.push(...segment.rows);
      } else if (segment.ratios) {
        result.push(...makeExactSegmentRows(segment, containerWidth));
      } else {
        result.push({
          startIndex: segment.pageStart,
          count: segment.pageEnd - segment.pageStart,
          height: estimatedSegmentHeight(segment, containerWidth),
          complete: false,
          segmentIndex: segment.index,
          placeholder: true,
          failureVersion: segment.failureCount,
          top: 0,
          bottom: 0
        });
      }
    }
    return result;
  }

  async function makeRowsAsync(containerWidth) {
    if (geometry.mode === "segmented") {
      try {
        return await makeSegmentedRowsAsync(containerWidth);
      } catch (error) {
        if (error && error.layoutSuperseded) return null;
        console.debug("segmented layout worker failed", error);
        return makeSegmentedRows(containerWidth);
      }
    }
    // Main-thread calculation is faster for ordinary galleries. For large
    // pages, send only a compact Float32 ratio copy to a dedicated worker.
    if (itemCount < 50000) {
      return makeRows(containerWidth);
    }

    if (layoutWorkerPending.size) {
      const superseded = new Error("layout request superseded by a newer width");
      superseded.layoutSuperseded = true;
      stopLayoutWorker(superseded);
    }

    try {
      const workerRows = await requestWorkerRows(
        geometry.ratios,
        containerWidth
      );
      return workerRows || makeRows(containerWidth);
    } catch (error) {
      if (error && error.layoutSuperseded) return null;
      console.debug("layout worker failed; using main thread", error);
      return makeRows(containerWidth);
    }
  }

  async function positionRows(rowList, bottomInset) {
    let top = config.gap;

    for (let index = 0; index < rowList.length; index += 1) {
      const row = rowList[index];
      row.top = top;
      row.bottom = top + Math.max(1, row.height);
      top = row.bottom + config.gap;
      if (index > 0 && index % 2048 === 0) {
        await yieldToMain("user-visible");
      }
    }

    return Math.max(1, top + bottomInset);
  }

  function rowIdentity(row) {
    return [
      row.placeholder ? "p" : "r",
      Number.isInteger(row.segmentIndex) ? row.segmentIndex : -1,
      row.startIndex,
      row.count,
      Math.round(Number(row.height) * 1000),
      row.complete ? 1 : 0,
      Number(row.failureVersion) || 0
    ].join(":");
  }

  function captureScrollAnchor(rowList) {
    if (!rowList.length) return null;
    const localY = Math.max(0, -gallery.getBoundingClientRect().top);
    let rowIndex = firstRowEndingAfter(localY);
    rowIndex = Math.max(0, Math.min(rowList.length - 1, rowIndex));
    const row = rowList[rowIndex];
    const height = Math.max(1, row.height);
    const fraction = Math.max(
      0,
      Math.min(0.999999, (localY - row.top) / height)
    );
    let pageOffset = 0;
    let pageFraction = fraction;
    if (row.placeholder && row.count > 1) {
      const scaled = fraction * row.count;
      pageOffset = Math.min(row.count - 1, Math.floor(scaled));
      pageFraction = scaled - pageOffset;
    }
    return {
      pageIndex: row.startIndex + pageOffset,
      pageFraction,
      localY
    };
  }

  function restoreScrollAnchor(anchor, rowList) {
    if (!anchor || !rowList.length) return;
    let low = 0;
    let high = rowList.length - 1;
    let match = null;
    while (low <= high) {
      const mid = (low + high) >> 1;
      const row = rowList[mid];
      if (anchor.pageIndex < row.startIndex) {
        high = mid - 1;
      } else if (anchor.pageIndex >= row.startIndex + row.count) {
        low = mid + 1;
      } else {
        match = row;
        break;
      }
    }
    if (!match) return;
    let fraction = anchor.pageFraction;
    if (match.placeholder && match.count > 1) {
      fraction = (
        anchor.pageIndex - match.startIndex + anchor.pageFraction
      ) / match.count;
    }
    const newLocalY = match.top + Math.max(0, Math.min(1, fraction)) * match.height;
    const delta = newLocalY - anchor.localY;
    if (Math.abs(delta) >= 0.5) window.scrollBy(0, delta);
  }

  function reconcileMountedRows(oldRows, newRows) {
    const newIndexes = new Map();
    for (let index = 0; index < newRows.length; index += 1) {
      const key = rowIdentity(newRows[index]);
      let indexes = newIndexes.get(key);
      if (!indexes) {
        indexes = [];
        newIndexes.set(key, indexes);
      }
      indexes.push(index);
    }

    const retained = [];
    for (const [oldIndex, rowElement] of Array.from(mountedRows.entries())) {
      const oldRow = oldRows[oldIndex];
      const candidates = oldRow ? newIndexes.get(rowIdentity(oldRow)) : null;
      const newIndex = candidates && candidates.length ? candidates.shift() : -1;
      if (newIndex < 0) {
        unmountRow(oldIndex);
        continue;
      }
      const newRow = newRows[newIndex];
      rowElement.dataset.row = String(newIndex);
      rowElement.style.top = `${newRow.top}px`;
      rowElement.style.height = `${Math.max(1, newRow.height)}px`;
      retained.push([newIndex, rowElement]);
    }
    mountedRows.clear();
    for (const [newIndex, rowElement] of retained) {
      mountedRows.set(newIndex, rowElement);
    }
  }

  async function hydrateTile(
    tile,
    pageIndex,
    highPriority,
    bindingToken
  ) {
    let item;
    try {
      item = await itemAt(pageIndex);
    } catch (error) {
      console.error(error);
      if (
        !tile.isConnected ||
        tile.dataset.bindingToken !== bindingToken
      ) return;
      tile.classList.remove("image-loading", "image-loaded");
      tile.classList.add("load-error");
      tile.dataset.metadataRetry = "1";
      setTileTooltip(tile, "Metadata failed to load — tap to retry");
      tile.removeAttribute("href");
      return;
    }

    if (
      !tile.isConnected ||
      tile.dataset.bindingToken !== bindingToken
    ) return;
    clearDeploymentRecovery();

    tile.href = item.src;
    setTileTooltip(tile, item.name);
    tile.setAttribute("aria-label", item.name);
    tile.style.backgroundColor = /^#[0-9a-f]{6}$/i.test(item.placeholderColor)
      ? item.placeholderColor
      : "";
    tile.classList.remove("load-error");
    tile.classList.toggle("video-tile", Boolean(item.video));
    tile.classList.remove("video-no-poster");
    delete tile.dataset.metadataRetry;

    const img = tile.querySelector("img");
    if (!img) return;

    img.alt = "";
    img.width = item.width;
    img.height = item.height;
    const tileSource = item.video ? item.posterSrc : item.src;
    if (!tileSource) {
      delete img.dataset.src;
      delete img.dataset.srcset;
      tile.classList.remove("image-loading", "load-error");
      tile.classList.add("image-loaded", "video-no-poster");
      noteFirstViewportReady();
      return;
    }
    img.dataset.src = tileSource;
    img.dataset.bindingToken = bindingToken;

    const set = srcsetString(item);
    if (set) {
      img.dataset.srcset = set;
    } else {
      delete img.dataset.srcset;
    }

    if (highPriority) img.fetchPriority = "high";
    scheduleImageResidencyUpdate();
  }

  function createReusableTileElement() {
    const tile = document.createElement("a");
    const img = document.createElement("img");
    img.alt = "";
    img.decoding = isIOSWebKit ? "auto" : "async";
    img.addEventListener("load", () => {
      const loadToken = img.dataset.loadToken || "";
      const bindingToken = img.dataset.bindingToken || "";

      // The bounded-loader slot remains occupied until decode() settles, not
      // merely until network loading finishes. Binding checks prevent a late
      // async completion from mutating a recycled tile's new contents.
      const finalizeLoadedImage = () => {
        if (
          img.dataset.loadToken !== loadToken ||
          img.dataset.bindingToken !== bindingToken ||
          tile.dataset.bindingToken !== bindingToken
        ) {
          return;
        }

        activeTileLoads.delete(img);
        scheduleTileLoadPump();
        img.dataset.retryCount = "0";

        if (img.dataset.wanted === "1") {
          tile.classList.remove("image-loading", "load-error");
          tile.classList.add("image-loaded");
          delete tile.dataset.retryReady;
          noteFirstViewportReady();
        }
      };

      // WebKit's explicit decode promises can survive src removal and retain
      // an obsolete backing store while a recycled tile moves on. The load
      // event is sufficient on iOS; avoiding decode() closes that retention
      // path and also keeps the bounded loader honest.
      if (!isIOSWebKit && typeof img.decode === "function") {
        const decodeStarted = performance.now();
        if (CLIENT_DIAGNOSTICS) {
          diagnostics.activeDecodes += 1;
          diagnostics.peakActiveDecodes = Math.max(
            diagnostics.peakActiveDecodes,
            diagnostics.activeDecodes
          );
          noteDiagnosticsState();
        }

        Promise.resolve()
          .then(() => img.decode())
          .catch(error => {
            if (
              error &&
              /memory|allocation|quota/i.test(String(error.message || error))
            ) {
              ratchetMetadataBudget("image decode pressure");
            }
          })
          .finally(() => {
            if (CLIENT_DIAGNOSTICS) {
              const elapsed = Math.max(0, performance.now() - decodeStarted);
              diagnostics.activeDecodes = Math.max(
                0,
                diagnostics.activeDecodes - 1
              );
              diagnostics.decodeCount += 1;
              diagnostics.decodeTotalMs += elapsed;
              diagnostics.decodeMaxMs = Math.max(
                diagnostics.decodeMaxMs,
                elapsed
              );
              noteDiagnosticsState();
            }
            finalizeLoadedImage();
          });
      } else {
        finalizeLoadedImage();
      }
    });
    img.addEventListener("error", () => {
      if (img.dataset.bindingToken !== tile.dataset.bindingToken) return;
      handleTileImageError(tile, img);
    });

    const videoPlaceholder = document.createElement("span");
    videoPlaceholder.className = "video-placeholder";
    videoPlaceholder.textContent = "Video";
    videoPlaceholder.setAttribute("aria-hidden", "true");

    const playIndicator = document.createElement("span");
    playIndicator.className = "video-play-indicator";
    playIndicator.setAttribute("aria-hidden", "true");
    playIndicator.innerHTML =
      '<svg viewBox="0 0 24 24" focusable="false"><path d="M8 5v14l11-7z"/></svg>';

    const tooltip = document.createElement("span");
    tooltip.className = "tile-tooltip";
    tooltip.setAttribute("aria-hidden", "true");

    tile.addEventListener("pointerenter", event => {
      if (event.pointerType === "touch") return;
      showTileTooltipAfterDelay(tile);
    });
    tile.addEventListener("pointerleave", () => hideTileTooltip(tile));
    tile.addEventListener("pointerdown", () => hideTileTooltip(tile));
    tile.append(img, videoPlaceholder, playIndicator, tooltip);
    return tile;
  }

  function createTile(pageIndex, height, complete, highPriority) {
    const tile = recycledTiles.pop() || createReusableTileElement();
    const img = tile.querySelector("img");
    const bindingToken = String(nextTileBindingToken++);
    const ratio = ratioAt(pageIndex);
    const width = Math.max(1, ratio * height);

    tile.className = "tile image-loading";
    tile.href = "#";
    tile.dataset.index = String(pageIndex);
    tile.dataset.bindingToken = bindingToken;
    tile.setAttribute("aria-busy", "true");
    tile.removeAttribute("aria-label");
    delete tile.dataset.metadataRetry;
    delete tile.dataset.retryReady;
    delete tile.dataset.tooltip;

    tile.style.flex = complete
      ? `${ratio} 1 0px`
      : `0 0 ${width}px`;
    tile.style.backgroundColor = "";

    img.alt = "";
    img.width = Math.max(1, Math.round(ratio * 1000));
    img.height = 1000;
    img.fetchPriority = highPriority ? "high" : "auto";
    img.sizes = gridResponsiveSize(width);
    img.dataset.retryCount = "0";
    img.dataset.bindingToken = bindingToken;
    img.dataset.loaded = "0";
    img.dataset.wanted = "0";
    img.dataset.resident = "0";
    delete img.dataset.src;
    delete img.dataset.srcset;
    delete img.dataset.forceReload;
    delete img.dataset.loadPriority;
    delete img.dataset.loadToken;

    const tooltip = tile.querySelector(".tile-tooltip");
    if (tooltip) tooltip.textContent = "";

    hydrateTile(tile, pageIndex, highPriority, bindingToken).finally(() => {
      if (
        tile.isConnected &&
        tile.dataset.bindingToken === bindingToken
      ) {
        tile.removeAttribute("aria-busy");
      }
    });

    return tile;
  }

  function createRowElement(
    row,
    rowIndex,
    viewportTop,
    viewportBottom,
    promoteLcpCandidate
  ) {
    const rowElement = recycledRows.pop() || document.createElement("div");
    rowElement.replaceChildren();
    rowElement.className = "gallery-row";
    rowElement.dataset.row = String(rowIndex);
    rowElement.style.top = `${row.top}px`;
    rowElement.style.height =
      `${Math.max(1, row.height)}px`;
    rowElement.style.gap = `${config.gap}px`;

    const visible =
      row.bottom >= viewportTop &&
      row.top <= viewportBottom;
    let lcpOffset = -1;
    if (visible && promoteLcpCandidate) {
      let largestRatio = -1;
      for (let offset = 0; offset < row.count; offset += 1) {
        const ratio = ratioAt(row.startIndex + offset);
        if (ratio > largestRatio) {
          largestRatio = ratio;
          lcpOffset = offset;
        }
      }
    }

    for (
      let offset = 0;
      offset < row.count;
      offset += 1
    ) {
      rowElement.appendChild(
        createTile(
          row.startIndex + offset,
          row.height,
          row.complete,
          offset === lcpOffset
        )
      );
    }

    return rowElement;
  }

  function createGeometryErrorElement(row, rowIndex, segment) {
    const rowElement = recycledRows.pop() || document.createElement("div");
    rowElement.replaceChildren();
    rowElement.className = "gallery-row geometry-load-error";
    rowElement.dataset.row = String(rowIndex);
    rowElement.style.top = `${row.top}px`;
    rowElement.style.height = `${Math.max(96, row.height)}px`;
    rowElement.style.gap = "0";

    const inner = document.createElement("div");
    inner.className = "geometry-load-error-inner";
    const message = document.createElement("p");
    message.textContent = "This section could not be loaded.";
    const detail = document.createElement("p");
    detail.className = "geometry-load-error-detail";
    detail.textContent = String(
      segment.loadError && segment.loadError.message || segment.loadError || ""
    );
    const actions = document.createElement("div");
    actions.className = "geometry-load-error-actions";
    const retry = document.createElement("button");
    retry.type = "button";
    retry.textContent = "Retry this section";
    retry.addEventListener("click", () => {
      retry.disabled = true;
      ensureGeometrySegment(segment.index, { force: true })
        .then(scheduleRender)
        .catch(error => {
          console.error(error);
          retry.disabled = false;
          scheduleRender();
        });
    });
    actions.appendChild(retry);

    const eagerLimit = Math.max(
      1,
      Number(config.geometryCompatibilityEagerLimit) || 250000
    );
    if (segment.failureCount >= 2 && itemCount <= eagerLimit) {
      const compatibility = document.createElement("button");
      compatibility.type = "button";
      compatibility.textContent = "Use compatibility mode";
      compatibility.addEventListener("click", () => {
        try {
          const key = `jg-force-eager:${String(config.dataRevision || "")}`;
          sessionStorage.setItem(key, "1");
        } catch (_error) {
          // Reload still provides a clean retry if storage is unavailable.
        }
        location.reload();
      });
      actions.appendChild(compatibility);
    }

    inner.append(message, detail, actions);
    rowElement.appendChild(inner);
    return rowElement;
  }

  function releaseTileImage(img) {
    pendingTileLoads.delete(img);
    const releasedActive =
      activeTileLoads.delete(img);
    const tile = img.closest(".tile");
    const wasResident = img.dataset.resident === "1";

    if (wasResident) {
      img.dataset.resident = "0";
      if (CLIENT_DIAGNOSTICS) {
        diagnostics.residentImages = Math.max(
          0,
          diagnostics.residentImages - 1
        );
        diagnostics.releases += 1;
      }
    }

    // Removing srcset/src before detaching the node gives the browser a chance
    // to cancel outstanding fetch/decode work and release decoded-image backing
    // stores. This matters when a scrollbar scrub crosses many virtual rows in
    // a short period of time.
    img.removeAttribute("srcset");
    img.removeAttribute("src");
    img.fetchPriority = "auto";
    img.dataset.loaded = "0";
    img.dataset.wanted = "0";
    delete img.dataset.forceReload;
    delete img.dataset.loadPriority;
    delete img.dataset.loadToken;

    if (tile) {
      tile.classList.remove("image-loaded", "load-error");
      if (tile.classList.contains("video-no-poster")) {
        tile.classList.remove("image-loading");
        tile.classList.add("image-loaded");
      } else {
        tile.classList.add("image-loading");
      }
      delete tile.dataset.retryReady;
    }

    if (releasedActive) {
      scheduleTileLoadPump();
    }

    if (wasResident || releasedActive) {
      noteDiagnosticsState();
    }
  }

  function unmountRow(rowIndex) {
    const rowElement = mountedRows.get(rowIndex);
    if (!rowElement) return;

    for (const tile of Array.from(rowElement.querySelectorAll(".tile"))) {
      const img = tile.querySelector("img");
      if (!img) continue;
      releaseTileImage(img);
      hideTileTooltip(tile);
      tile.dataset.bindingToken = String(nextTileBindingToken++);
      img.dataset.bindingToken = tile.dataset.bindingToken;
      tile.removeAttribute("href");
      tile.removeAttribute("aria-label");
      tile.removeAttribute("aria-busy");
      tile.remove();
      if (recycledTiles.length < MAX_RECYCLED_TILES) {
        recycledTiles.push(tile);
      }
    }

    rowElement.replaceChildren();
    rowElement.remove();
    mountedRows.delete(rowIndex);
    if (recycledRows.length < MAX_RECYCLED_ROWS) {
      recycledRows.push(rowElement);
    }
  }

  function firstRowEndingAfter(y) {
    let low = 0;
    let high = rows.length;

    while (low < high) {
      const mid = (low + high) >> 1;
      if (rows[mid].bottom < y) {
        low = mid + 1;
      } else {
        high = mid;
      }
    }

    return low;
  }

  function firstRowStartingAfter(y) {
    let low = 0;
    let high = rows.length;

    while (low < high) {
      const mid = (low + high) >> 1;
      if (rows[mid].top <= y) {
        low = mid + 1;
      } else {
        high = mid;
      }
    }

    return low;
  }

  function cancelGeometryPrefetch() {
    geometryPrefetchToken += 1;
    if (geometryPrefetchHandle === null) return;
    geometryPrefetchHandle.cancel();
    geometryPrefetchHandle = null;
  }

  function scheduleGeometryPrefetch(startRowIndex, endRowIndex) {
    cancelGeometryPrefetch();
    if (
      geometry.mode !== "segmented" ||
      document.hidden ||
      constrainedNetwork ||
      lowMemoryDevice
    ) return;

    const edgeRowIndex = scrollDirection >= 0 ? endRowIndex : startRowIndex;
    const edgeRow = rows[edgeRowIndex];
    if (!edgeRow) return;
    let segmentIndex = Number(edgeRow.segmentIndex);
    if (!Number.isInteger(segmentIndex)) {
      const segment = geometrySegmentForPageIndex(edgeRow.startIndex);
      segmentIndex = segment ? segment.index : -1;
    }
    if (!Number.isInteger(segmentIndex) || segmentIndex < 0) return;
    const edgeSegment = geometry.segments[segmentIndex];
    if (edgeSegment && edgeSegment.ratios) {
      segmentIndex += scrollDirection >= 0 ? 1 : -1;
    }
    const target = geometry.segments[segmentIndex];
    if (!target || target.ratios || target.loadError) return;

    const token = geometryPrefetchToken;
    const run = () => {
      geometryPrefetchHandle = null;
      if (token !== geometryPrefetchToken || document.hidden) return;
      const catalogChunkIndex = Math.floor(
        target.catalogStart / Math.max(1, Number(config.chunkSize) || 1)
      );
      void Promise.all([
        ensureGeometrySegment(target.index, { prefetch: true }),
        loadCatalogChunk(catalogChunkIndex, { prefetch: true })
      ]).then(() => {
        if (token === geometryPrefetchToken) scheduleRender();
      }).catch(error => {
        console.debug("predictive gallery prefetch skipped", error);
      });
    };

    geometryPrefetchHandle = scheduleBackgroundTask(run, 80);
  }

  function updateVirtualRows() {
    virtualScheduled = false;
    if (!rows.length) return;

    const rect = gallery.getBoundingClientRect();
    const viewportTop = -rect.top;
    const viewportBottom =
      viewportTop + window.innerHeight;
    const overscan = Math.max(
      MIN_OVERSCAN_PX,
      window.innerHeight * adaptiveOverscanViewports()
    );
    const margins = directionalMargins(overscan);

    const wantedTop =
      Math.max(0, viewportTop - margins.before);
    const wantedBottom = Math.min(
      totalGalleryHeight,
      viewportBottom + margins.after
    );

    let startIndex =
      firstRowEndingAfter(wantedTop);
    let endIndex =
      firstRowStartingAfter(wantedBottom) - 1;

    if (
      startIndex >= rows.length ||
      endIndex < startIndex
    ) {
      for (
        const rowIndex of
          Array.from(mountedRows.keys())
      ) {
        unmountRow(rowIndex);
      }
      imageResidencyScheduled = false;
      noteDiagnosticsState();
      return;
    }

    startIndex = Math.max(0, startIndex);
    endIndex = Math.min(
      rows.length - 1,
      endIndex
    );

    for (
      const rowIndex of
        Array.from(mountedRows.keys())
    ) {
      if (
        rowIndex < startIndex ||
        rowIndex > endIndex
      ) {
        unmountRow(rowIndex);
      }
    }

    let lcpPriorityAssigned = false;
    for (const [rowIndex, rowElement] of mountedRows.entries()) {
      const row = rows[rowIndex];
      if (
        row &&
        row.bottom >= viewportTop &&
        row.top <= viewportBottom &&
        Array.from(rowElement.querySelectorAll("img")).some(
          img => img.fetchPriority === "high"
        )
      ) {
        lcpPriorityAssigned = true;
        break;
      }
    }

    for (
      let rowIndex = startIndex;
      rowIndex <= endIndex;
      rowIndex += 1
    ) {
      const row = rows[rowIndex];
      if (
        geometry.mode === "segmented" &&
        Number.isInteger(row.segmentIndex)
      ) {
        const segment = geometry.segments[row.segmentIndex];
        if (row.placeholder || !segment || !segment.ratios) {
          if (segment && segment.loadError) {
            if (!mountedRows.has(rowIndex)) {
              const errorElement = createGeometryErrorElement(
                row,
                rowIndex,
                segment
              );
              mountedRows.set(rowIndex, errorElement);
              gallery.appendChild(errorElement);
            }
            continue;
          }
          ensureGeometrySegment(row.segmentIndex)
            .then(scheduleRender)
            .catch(error => {
              console.error(error);
              scheduleVirtualUpdate();
            });
          continue;
        }
      }

      if (mountedRows.has(rowIndex)) {
        continue;
      }

      const rowElement = createRowElement(
        row,
        rowIndex,
        viewportTop,
        viewportBottom,
        !lcpPriorityAssigned
      );
      if (
        !lcpPriorityAssigned &&
        row.bottom >= viewportTop &&
        row.top <= viewportBottom
      ) {
        lcpPriorityAssigned = true;
      }
      mountedRows.set(rowIndex, rowElement);

      // Rows are absolutely positioned, but keeping DOM order stable also
      // preserves predictable keyboard/accessibility traversal.
      let nextElement = null;
      for (
        let nextIndex = rowIndex + 1;
        nextIndex <= endIndex;
        nextIndex += 1
      ) {
        nextElement = mountedRows.get(nextIndex);
        if (nextElement) break;
      }

      if (nextElement) {
        gallery.insertBefore(rowElement, nextElement);
      } else {
        gallery.appendChild(rowElement);
      }
    }

    scheduleGeometryPrefetch(startIndex, endIndex);
    updateMountedImageResidency();
    noteDiagnosticsState();
    publishRegressionState();
  }

  function runDeferredVirtualUpdate() {
    virtualDeferredTimer = null;

    const remaining =
      virtualDeferredUntil - performance.now();
    if (remaining > 0) {
      virtualDeferredTimer = setTimeout(
        runDeferredVirtualUpdate,
        remaining
      );
      return;
    }

    scheduleVirtualUpdate();
  }

  function deferVirtualUpdate(milliseconds) {
    virtualDeferredUntil = Math.max(
      virtualDeferredUntil,
      performance.now() + milliseconds
    );

    if (virtualDeferredTimer === null) {
      virtualDeferredTimer = setTimeout(
        runDeferredVirtualUpdate,
        milliseconds
      );
    }
  }

  function scheduleVirtualUpdate() {
    const now = performance.now();
    if (now < virtualDeferredUntil) {
      if (virtualDeferredTimer === null) {
        virtualDeferredTimer = setTimeout(
          runDeferredVirtualUpdate,
          virtualDeferredUntil - now
        );
      }
      return;
    }

    if (virtualScheduled) return;
    virtualScheduled = true;
    requestAnimationFrame(updateVirtualRows);
  }

  function finishIOSFastScroll() {
    if (iosFastScrollTimer !== null) {
      clearTimeout(iosFastScrollTimer);
      iosFastScrollTimer = null;
    }
    if (!iosFastScrollActive) return;

    iosFastScrollActive = false;
    observedScrollSpeed = 0;
    virtualDeferredUntil = 0;
    if (virtualDeferredTimer !== null) {
      clearTimeout(virtualDeferredTimer);
      virtualDeferredTimer = null;
    }
    scheduleVirtualUpdate();
    scheduleImageResidencyUpdate();
    publishRegressionState();
  }

  function beginIOSFastScroll() {
    if (!iosFastScrollActive) {
      iosFastScrollActive = true;
      iosFastScrollEntries += 1;
      pendingTileLoads.clear();
      // A residency pass releases images left behind by the jump, while the
      // virtual DOM itself remains stable until momentum scrolling ends.
      scheduleImageResidencyUpdate();
      publishRegressionState();
    }
    if (iosFastScrollTimer !== null) {
      clearTimeout(iosFastScrollTimer);
    }
    iosFastScrollTimer = setTimeout(
      finishIOSFastScroll,
      FAST_SCROLL_SETTLE_MS
    );
  }

  function handleGalleryScroll() {
    cancelGeometryPrefetch();
    scheduleDiagnosticsRender();

    const now = performance.now();
    const currentY = window.scrollY;
    const elapsed = Math.max(
      1,
      now - lastObservedScrollTime
    );
    const delta = currentY - lastObservedScrollY;
    const distance = Math.abs(delta);
    const viewport = Math.max(
      1,
      window.innerHeight
    );
    const speed = distance / elapsed;
    if (Math.abs(delta) >= 1) scrollDirection = delta > 0 ? 1 : -1;
    observedScrollSpeed = observedScrollSpeed * 0.65 + speed * 0.35;

    lastObservedScrollY = currentY;
    lastObservedScrollTime = now;

    const fastScroll =
      distance >= viewport * FAST_SCROLL_JUMP_VIEWPORTS ||
      speed >= FAST_SCROLL_SPEED_PX_PER_MS;

    if (isIOSWebKit && (iosFastScrollActive || fastScroll)) {
      beginIOSFastScroll();
      deferVirtualUpdate(FAST_SCROLL_SETTLE_MS);
      return;
    }

    scheduleImageResidencyUpdate();

    // Large scrollbar jumps can otherwise rebuild and decode a succession of
    // intermediate viewports. Keep the previous rows briefly, then render the
    // final position once the scrub settles.
    if (fastScroll) {
      deferVirtualUpdate(
        FAST_SCROLL_SETTLE_MS
      );
      return;
    }

    scheduleVirtualUpdate();
  }

  async function render() {
    scheduled = false;
    renderDebounceTimer = null;
    const renderSerial = ++renderRequestSerial;
    const requestedGeometryLayoutRevision = geometryLayoutRevision;
    virtualDeferredUntil = 0;
    if (virtualDeferredTimer !== null) {
      clearTimeout(virtualDeferredTimer);
      virtualDeferredTimer = null;
    }

    const insets = measureSafeAreaInsets();
    const width = Math.max(
      1,
      Math.floor(
        gallery.clientWidth -
        insets.left -
        insets.right
      )
    );

    if (
      width === lastWidth &&
      rows.length &&
      (
        geometry.mode !== "segmented" ||
        renderedGeometryLayoutRevision === geometryLayoutRevision
      )
    ) {
      scheduleVirtualUpdate();
      return;
    }

    const requestedWidth = width;
    const newRows = await makeRowsAsync(width);
    if (!newRows || renderSerial !== renderRequestSerial) return;
    if (newRows.some(row => !row.placeholder)) {
      markStartupPhase("first-exact-layout");
    }

    if (
      geometry.mode === "segmented" &&
      requestedGeometryLayoutRevision !== geometryLayoutRevision
    ) {
      scheduleRender();
      return;
    }

    // A resize may have happened while the worker was calculating. Discard
    // stale work and immediately schedule the current width instead.
    const currentInsets = measureSafeAreaInsets();
    const currentWidth = Math.max(
      1,
      Math.floor(
        gallery.clientWidth -
        currentInsets.left -
        currentInsets.right
      )
    );
    if (currentWidth !== requestedWidth) {
      scheduleRender();
      return;
    }

    const incrementalSegmentRelayout =
      geometry.mode === "segmented" &&
      width === lastWidth &&
      rows.length > 0;
    const oldRows = rows;
    const scrollAnchor = incrementalSegmentRelayout
      ? captureScrollAnchor(oldRows)
      : null;
    totalGalleryHeight =
      await positionRows(newRows, insets.bottom);
    if (renderSerial !== renderRequestSerial) return;

    if (incrementalSegmentRelayout) {
      reconcileMountedRows(oldRows, newRows);
    } else {
      for (
        const rowIndex of
          Array.from(mountedRows.keys())
      ) {
        unmountRow(rowIndex);
      }
      gallery.replaceChildren();
      mountedRows.clear();
    }

    rows = newRows;
    lastWidth = width;
    renderedGeometryLayoutRevision = requestedGeometryLayoutRevision;
    gallery.style.height =
      `${Math.ceil(totalGalleryHeight)}px`;
    if (incrementalSegmentRelayout) {
      restoreScrollAnchor(scrollAnchor, rows);
    }

    updateVirtualRows();
    noteDiagnosticsState();
    publishRegressionState();
  }

  function scheduleRender() {
    if (itemCount < 50000 && scheduled) return;
    if (renderDebounceTimer !== null) {
      clearTimeout(renderDebounceTimer);
      renderDebounceTimer = null;
    }
    scheduled = true;
    const queue = () => {
      renderDebounceTimer = null;
      requestAnimationFrame(render);
    };
    if (itemCount >= 50000) {
      renderDebounceTimer = setTimeout(queue, 80);
    } else {
      queue();
    }
  }

  const DISPLAY_MODE_STORAGE_KEY = "justified-gallery-display-mode";
  let displayMode = "natural";
  let randomGroupDataPromise = null;
  let pageGroupData = null;
  let allNaturalRatiosPromise = null;
  let randomSeed = null;
  let displayModeRequest = 0;

  function storedDisplayMode() {
    try {
      return sessionStorage.getItem(DISPLAY_MODE_STORAGE_KEY) === "random"
        ? "random"
        : "natural";
    } catch (_error) {
      return "natural";
    }
  }

  function rememberDisplayMode(mode) {
    try {
      sessionStorage.setItem(DISPLAY_MODE_STORAGE_KEY, mode);
    } catch (_error) {
      // The selected mode still works when session storage is unavailable.
    }
  }

  function freshRandomSeed() {
    try {
      const values = new Uint32Array(1);
      crypto.getRandomValues(values);
      return values[0] || 0x9e3779b9;
    } catch (_error) {
      return (
        (Date.now() ^ Math.floor(performance.now() * 1000)) >>> 0
      ) || 0x9e3779b9;
    }
  }

  function seededRandom(seed) {
    let state = seed >>> 0 || 0x9e3779b9;
    return () => {
      state ^= state << 13;
      state ^= state >>> 17;
      state ^= state << 5;
      return (state >>> 0) / 0x100000000;
    };
  }

  async function allNaturalRatios() {
    if (allNaturalRatiosPromise) return allNaturalRatiosPromise;
    allNaturalRatiosPromise = (async () => {
      if (naturalGeometry.mode !== "segmented") {
        if (naturalGeometry.ratios.length !== itemCount) {
          throw new Error("natural geometry count mismatch");
        }
        return naturalGeometry.ratios;
      }

      const ratios = new Float32Array(itemCount);
      const chunkSize = Math.max(1, Number(config.geometryChunkSize) || 1);
      const chunkIndexes = Array.from(
        naturalGeometry.chunkSegments.keys()
      ).sort((left, right) => left - right);
      let nextChunk = 0;
      let filled = 0;

      async function worker() {
        while (true) {
          const listIndex = nextChunk;
          nextChunk += 1;
          if (listIndex >= chunkIndexes.length) return;
          const chunkIndex = chunkIndexes[listIndex];
          const chunk = await loadGeometryChunkRaw(chunkIndex);
          const chunkStart = chunkIndex * chunkSize;
          for (
            const segmentIndex of
              naturalGeometry.chunkSegments.get(chunkIndex) || []
          ) {
            const segment = naturalGeometry.segments[segmentIndex];
            for (
              let catalogId = segment.catalogStart;
              catalogId < segment.catalogEnd;
              catalogId += 1
            ) {
              const localIndex = catalogId - chunkStart;
              if (localIndex < 0 || localIndex >= chunk.length) {
                throw new Error(
                  `geometry entry ${catalogId} is outside chunk ${chunkIndex}`
                );
              }
              const pageIndex =
                segment.pageStart + (catalogId - segment.catalogStart);
              ratios[pageIndex] =
                (Number(chunk[localIndex]) || 1) / config.geometryRatioScale;
              filled += 1;
            }
          }
          await yieldToMain("background");
        }
      }

      await Promise.all(
        Array.from(
          { length: Math.min(8, Math.max(1, chunkIndexes.length)) },
          () => worker()
        )
      );
      if (filled !== itemCount) {
        throw new Error(
          `random display geometry count mismatch: expected ${itemCount}, got ${filled}`
        );
      }
      return ratios;
    })().catch(error => {
      allNaturalRatiosPromise = null;
      throw error;
    });
    return allNaturalRatiosPromise;
  }

  async function currentPageGroups() {
    if (pageGroupData) return pageGroupData;
    if (!randomGroupDataPromise) {
      randomGroupDataPromise = loadRandomGroupOrder();
    }
    let globalOrder;
    try {
      globalOrder = await randomGroupDataPromise;
    } catch (error) {
      randomGroupDataPromise = null;
      throw error;
    }

    const pageMembers = new Uint32Array(itemCount);
    const seen = new Uint8Array(itemCount);
    const offsets = [0];
    let globalOffset = 0;
    let pageOffset = 0;
    for (const length of globalOrder.lengths) {
      const pageGroupStart = pageOffset;
      const groupEnd = globalOffset + length;
      for (; globalOffset < groupEnd; globalOffset += 1) {
        const naturalPageIndex = naturalPageIndexForCatalogId(
          globalOrder.members[globalOffset]
        );
        if (naturalPageIndex < 0) continue;
        if (seen[naturalPageIndex]) {
          throw new Error("random display contains a duplicate page item");
        }
        seen[naturalPageIndex] = 1;
        pageMembers[pageOffset] = naturalPageIndex;
        pageOffset += 1;
      }
      if (pageOffset > pageGroupStart) offsets.push(pageOffset);
    }
    if (globalOffset !== globalOrder.members.length || pageOffset !== itemCount) {
      throw new Error(
        `random display page coverage mismatch: expected ${itemCount}, got ${pageOffset}`
      );
    }
    pageGroupData = {
      members: pageMembers,
      offsets: Uint32Array.from(offsets)
    };
    randomGroupDataPromise = null;
    return pageGroupData;
  }

  async function randomDisplayGeometry(seed) {
    const [groups, naturalRatios] = await Promise.all([
      currentPageGroups(),
      allNaturalRatios()
    ]);
    const groupCount = Math.max(0, groups.offsets.length - 1);
    const groupOrder = new Uint32Array(groupCount);
    for (let index = 0; index < groupCount; index += 1) {
      groupOrder[index] = index;
    }
    const random = seededRandom(seed);
    for (let index = groupCount - 1; index > 0; index -= 1) {
      const swapIndex = Math.floor(random() * (index + 1));
      const value = groupOrder[index];
      groupOrder[index] = groupOrder[swapIndex];
      groupOrder[swapIndex] = value;
    }

    const displayOrder = new Uint32Array(itemCount);
    const ratios = new Float32Array(itemCount);
    let outputIndex = 0;
    for (const groupIndex of groupOrder) {
      const start = groups.offsets[groupIndex];
      const end = groups.offsets[groupIndex + 1];
      for (let index = start; index < end; index += 1) {
        const naturalPageIndex = groups.members[index];
        displayOrder[outputIndex] = naturalPageIndex;
        ratios[outputIndex] = naturalRatios[naturalPageIndex];
        outputIndex += 1;
      }
    }
    if (outputIndex !== itemCount) {
      throw new Error("random display did not emit every page item");
    }
    return {
      mode: "eager",
      ratios,
      pageRanges: naturalGeometry.pageRanges,
      segments: [],
      displayOrder
    };
  }

  async function applyDisplayGeometry(nextGeometry) {
    cancelGeometryPrefetch();
    renderRequestSerial += 1;
    pendingTileLoads.clear();
    for (const rowIndex of Array.from(mountedRows.keys())) {
      unmountRow(rowIndex);
    }
    gallery.replaceChildren();
    mountedRows.clear();
    rows = [];
    totalGalleryHeight = 0;
    gallery.style.height = "0px";
    geometry = nextGeometry;
    lastWidth = -1;
    renderedGeometryLayoutRevision = -1;
    geometryLayoutRevision += 1;
    window.scrollTo({ top: 0, left: 0, behavior: "auto" });
    await render();
  }

  function setSortBusy(busy) {
    if (sortControls) sortControls.setAttribute("aria-busy", String(busy));
    if (sortMode) sortMode.disabled = busy;
    if (reshuffleButton) reshuffleButton.disabled = busy;
  }

  async function setDisplayMode(mode, { reshuffle = false } = {}) {
    const request = ++displayModeRequest;
    const wanted = mode === "random" ? "random" : "natural";
    setSortBusy(true);
    try {
      if (wanted === "random") {
        if (randomSeed === null || reshuffle) randomSeed = freshRandomSeed();
        const nextGeometry = await randomDisplayGeometry(randomSeed);
        if (request !== displayModeRequest) return;
        await applyDisplayGeometry(nextGeometry);
        displayMode = "random";
        if (sortStatus) {
          sortStatus.textContent = reshuffle
            ? "Unsorted groups reshuffled."
            : "Unsorted order active.";
        }
      } else {
        if (geometry !== naturalGeometry) {
          await applyDisplayGeometry(naturalGeometry);
        }
        displayMode = "natural";
        if (sortStatus) sortStatus.textContent = "Sorted order active.";
      }
      if (sortMode) sortMode.value = displayMode;
      if (reshuffleButton) reshuffleButton.hidden = displayMode !== "random";
      rememberDisplayMode(displayMode);
    } catch (error) {
      console.error(error);
      displayMode = "natural";
      if (geometry !== naturalGeometry) await applyDisplayGeometry(naturalGeometry);
      if (sortMode) sortMode.value = "natural";
      if (reshuffleButton) reshuffleButton.hidden = true;
      if (sortStatus) {
        sortStatus.textContent =
          "Unsorted order could not be loaded; sorted order remains active.";
      }
      rememberDisplayMode("natural");
    } finally {
      if (request === displayModeRequest) setSortBusy(false);
    }
  }

  function initializeDisplayModeControls() {
    const available = Boolean(
      config.randomOrder &&
      config.randomOrder.enabled &&
      config.randomOrder.available &&
      itemCount > 1
    );
    if (!available || !sortControls || !sortMode || !reshuffleButton) {
      return "natural";
    }
    sortControls.hidden = false;
    sortMode.addEventListener("change", () => {
      void setDisplayMode(sortMode.value);
    });
    reshuffleButton.addEventListener("click", () => {
      void setDisplayMode("random", { reshuffle: true });
    });
    const initialMode = storedDisplayMode();
    sortMode.value = "natural";
    if (initialMode === "random" && sortStatus) {
      sortStatus.textContent = "Unsorted order will be restored after loading.";
    }
    return initialMode;
  }

  const LIGHTBOX_CLICK_ZOOM = 2;
  const LIGHTBOX_MAX_ZOOM = 8;

  function lockLightboxNativeZoom() {
    const viewport = document.querySelector(
      'meta[name="viewport"]'
    );
    if (!viewport || lightboxSavedViewport !== null) return;

    lightboxSavedViewport =
      viewport.getAttribute("content") || "";

    const retained = lightboxSavedViewport
      .split(",")
      .map(part => part.trim())
      .filter(part => {
        const lower = part.toLowerCase();
        return (
          !lower.startsWith("maximum-scale=") &&
          !lower.startsWith("user-scalable=")
        );
      });

    retained.push("maximum-scale=1", "user-scalable=no");
    viewport.setAttribute("content", retained.join(","));
  }

  function unlockLightboxNativeZoom() {
    if (lightboxSavedViewport === null) return;

    const viewport = document.querySelector(
      'meta[name="viewport"]'
    );
    if (viewport) {
      viewport.setAttribute(
        "content",
        lightboxSavedViewport
      );
    }
    lightboxSavedViewport = null;
  }

  function lightboxAvailableSize() {
    const style = getComputedStyle(stage);
    const horizontalPadding =
      (parseFloat(style.paddingLeft) || 0) +
      (parseFloat(style.paddingRight) || 0);
    const verticalPadding =
      (parseFloat(style.paddingTop) || 0) +
      (parseFloat(style.paddingBottom) || 0);

    return {
      width: Math.max(1, stage.clientWidth - horizontalPadding),
      height: Math.max(1, stage.clientHeight - verticalPadding),
    };
  }

  function fitLightboxImage(resetView = false) {
    if (
      !lightboxImage.naturalWidth ||
      !lightboxImage.naturalHeight
    ) {
      return;
    }

    const available = lightboxAvailableSize();
    const fitScale = Math.min(
      1,
      available.width / lightboxImage.naturalWidth,
      available.height / lightboxImage.naturalHeight
    );

    lightboxImage.style.width =
      `${Math.max(1, lightboxImage.naturalWidth * fitScale)}px`;
    lightboxImage.style.height =
      `${Math.max(1, lightboxImage.naturalHeight * fitScale)}px`;

    if (resetView) {
      lightboxZoom = 1;
      lightboxPanX = 0;
      lightboxPanY = 0;
    }

    applyLightboxTransform();
  }

  function clampLightboxPan() {
    const available = lightboxAvailableSize();
    const scaledWidth = lightboxImage.offsetWidth * lightboxZoom;
    const scaledHeight = lightboxImage.offsetHeight * lightboxZoom;
    const maxX = Math.max(0, (scaledWidth - available.width) / 2);
    const maxY = Math.max(0, (scaledHeight - available.height) / 2);

    lightboxPanX = Math.max(-maxX, Math.min(maxX, lightboxPanX));
    lightboxPanY = Math.max(-maxY, Math.min(maxY, lightboxPanY));
  }

  function applyLightboxTransform() {
    clampLightboxPan();
    const animatedAtRest = Boolean(
      currentLightboxItem &&
      currentLightboxItem.animated &&
      lightboxZoom <= 1.001 &&
      Math.abs(lightboxPanX) < 0.01 &&
      Math.abs(lightboxPanY) < 0.01
    );
    lightboxImage.style.transform = animatedAtRest
      ? "none"
      : (
          `translate3d(${lightboxPanX}px, ${lightboxPanY}px, 0) ` +
          `scale(${lightboxZoom})`
        );
    syncAnimatedLightboxImage();
    lightboxImage.classList.toggle(
      "zoomed",
      lightboxZoom > 1.001
    );

    if (lightboxZoom > 1.001) {
      lightboxImage.sizes =
        `${Math.ceil(stage.clientWidth * lightboxZoom)}px`;
    } else {
      lightboxImage.sizes = "100vw";
    }
  }

  function resetLightboxView() {
    lightboxZoom = 1;
    lightboxPanX = 0;
    lightboxPanY = 0;
    lightboxPointers.clear();
    lightboxGestureMode = "none";
    lightboxSinglePointerId = null;
    lightboxSingleMoved = false;
    lightboxSingleStartedZoomed = false;
    lightboxImage.classList.remove("dragging");
    lightboxImage.style.width = "";
    lightboxImage.style.height = "";
    lightboxImage.style.transform =
      "translate3d(0, 0, 0) scale(1)";
    lightboxImage.sizes = "100vw";
    syncAnimatedLightboxImage();
  }

  function setLightboxZoom(nextZoom, clientX = null, clientY = null) {
    const oldZoom = lightboxZoom;
    const newZoom = Math.max(
      1,
      Math.min(LIGHTBOX_MAX_ZOOM, nextZoom)
    );

    if (
      clientX !== null &&
      clientY !== null &&
      oldZoom > 0
    ) {
      const rect = stage.getBoundingClientRect();
      const anchorX = clientX - (rect.left + rect.width / 2);
      const anchorY = clientY - (rect.top + rect.height / 2);
      const imageX = (anchorX - lightboxPanX) / oldZoom;
      const imageY = (anchorY - lightboxPanY) / oldZoom;
      lightboxPanX = anchorX - imageX * newZoom;
      lightboxPanY = anchorY - imageY * newZoom;
    }

    lightboxZoom = newZoom;
    if (lightboxZoom <= 1.001) {
      lightboxZoom = 1;
      lightboxPanX = 0;
      lightboxPanY = 0;
    }

    applyLightboxTransform();
  }

  function refitOpenLightbox() {
    if (!lightbox.open || (currentLightboxItem && currentLightboxItem.video)) {
      return;
    }
    requestAnimationFrame(() => fitLightboxImage(false));
  }

  function isExifDrawerOpen() {
    return lightbox.classList.contains("exif-open");
  }

  function setExifDrawerOpen(open, focusButton = false) {
    const nextOpen = Boolean(open);
    lightbox.classList.toggle("exif-open", nextOpen);
    if (exifPanel) {
      exifPanel.setAttribute("aria-hidden", String(!nextOpen));
      exifPanel.toggleAttribute("inert", !nextOpen);
    }
    if (infoButton) {
      const action = nextOpen ? "Hide" : "Show";
      infoButton.setAttribute("aria-expanded", String(nextOpen));
      infoButton.setAttribute("aria-label", `${action} media information`);
      infoButton.title = `${action} media information`;
      if (focusButton) infoButton.focus({ preventScroll: true });
    }
    if (nextOpen) loadCurrentExif();
  }

  function toggleExifDrawer() {
    setExifDrawerOpen(!isExifDrawerOpen());
  }

  const VIDEO_FIELD_LABELS = [
    "Format",
    "Duration",
    "Dimensions",
    "Video Codec",
    "Audio Codec",
    "Frame Rate",
    "File Size",
    "Creation Date/Time",
    "Playback",
    "Original Compatibility"
  ];

  function formatDuration(seconds) {
    const total = Number(seconds);
    if (!Number.isFinite(total) || total <= 0) return null;
    const rounded = Math.round(total);
    const hours = Math.floor(rounded / 3600);
    const minutes = Math.floor((rounded % 3600) / 60);
    const remainder = rounded % 60;
    return hours
      ? `${hours}:${String(minutes).padStart(2, "0")}:${String(remainder).padStart(2, "0")}`
      : `${minutes}:${String(remainder).padStart(2, "0")}`;
  }

  function formatFileSize(bytes) {
    let value = Number(bytes);
    if (!Number.isFinite(value) || value <= 0) return null;
    const units = ["B", "KiB", "MiB", "GiB", "TiB"];
    let unit = 0;
    while (value >= 1024 && unit < units.length - 1) {
      value /= 1024;
      unit += 1;
    }
    const digits = unit === 0 || value >= 100 ? 0 : (value >= 10 ? 1 : 2);
    return `${value.toFixed(digits)} ${units[unit]}`;
  }

  function formatCodec(codec, profile = "") {
    const normalized = String(codec || "").toLowerCase();
    const names = {
      h264: "H.264 / AVC",
      hevc: "H.265 / HEVC",
      av1: "AV1",
      vp8: "VP8",
      vp9: "VP9",
      theora: "Theora",
      aac: "AAC",
      mp3: "MP3",
      opus: "Opus",
      vorbis: "Vorbis",
      ac3: "AC-3",
      eac3: "E-AC-3",
      flac: "FLAC",
      pcm_s16le: "PCM"
    };
    const label = names[normalized] || String(codec || "").toUpperCase();
    return label && profile ? `${label} (${profile})` : (label || null);
  }

  function renderExifPanel(item, metadata, state = "ready") {
    if (!exifList || !exifStatus) return;
    if (item && item.video) {
      if (infoHeading) infoHeading.textContent = "Info";
      const raw = Array.isArray(metadata) ? metadata : [];
      const exifFields = COMPACT_SCHEMA.exif.raw;
      const identityRows = [
        ["Album", raw[exifFields.album]],
        ["Artist", raw[exifFields.artist]]
      ];
      const values = [
        item.format,
        formatDuration(item.duration),
        item.width && item.height ? `${item.width} × ${item.height}` : null,
        formatCodec(item.videoCodec, item.videoProfile),
        formatCodec(item.audioCodec),
        item.frameRate ? `${Number(item.frameRate).toFixed(3).replace(/\.?0+$/, "")} fps` : null,
        formatFileSize(item.fileSize),
        item.creationTime || null,
        item.playbackTranscoded
          ? "Browser-compatible H.264/AAC derivative (original preserved)"
          : "Original video",
        item.compatibilityNote || "No common browser compatibility issues detected."
      ];
      const fragment = document.createDocumentFragment();
      for (const [label, value] of identityRows) {
        if (value == null || value === "") continue;
        const term = document.createElement("dt");
        const detail = document.createElement("dd");
        term.textContent = label;
        detail.textContent = String(value);
        fragment.append(term, detail);
      }
      for (let index = 0; index < VIDEO_FIELD_LABELS.length; index += 1) {
        const term = document.createElement("dt");
        const detail = document.createElement("dd");
        term.textContent = VIDEO_FIELD_LABELS[index];
        detail.textContent = values[index] == null || values[index] === ""
          ? "—"
          : String(values[index]);
        fragment.append(term, detail);
      }
      exifList.replaceChildren(fragment);
      exifStatus.textContent = state === "loading"
        ? "Loading metadata…"
        : (state === "error" ? "Embedded metadata is unavailable." : "");
      return;
    }
    if (infoHeading) infoHeading.textContent = "EXIF";
    const raw = Array.isArray(metadata) ? metadata : [];
    const exifSchema = COMPACT_SCHEMA.exif;
    const valuesByKey = { format: item && item.format };
    for (const [key, index] of Object.entries(exifSchema.raw)) {
      valuesByKey[key] = raw[index];
    }
    const fragment = document.createDocumentFragment();
    for (const field of exifSchema.display) {
      const value = valuesByKey[field.key];
      if (field.omitEmpty && (value == null || value === "")) {
        continue;
      }
      const term = document.createElement("dt");
      const detail = document.createElement("dd");
      term.textContent = field.label;
      detail.textContent = value == null || value === ""
        ? "—"
        : String(value);
      fragment.append(term, detail);
    }
    exifList.replaceChildren(fragment);
    if (state === "idle") {
      exifStatus.textContent = "";
    } else if (state === "loading") {
      exifStatus.textContent = "Loading metadata…";
    } else if (state === "error") {
      exifStatus.textContent = "Additional metadata is unavailable.";
    } else {
      exifStatus.textContent = raw.some(value => value != null && value !== "")
        ? ""
        : "No embedded EXIF metadata.";
    }
  }

  async function updateLightboxExif(index, item, requestId) {
    if (currentExifLoadRequestId === requestId) return;
    currentExifLoadRequestId = requestId;
    currentExifState = "loading";
    renderExifPanel(item, null, "loading");
    try {
      const metadata = await exifAt(index);
      if (requestId !== lightboxRequestId || !lightbox.open) return;
      currentExifMetadata = metadata;
      currentExifState = "ready";
      renderExifPanel(item, metadata, "ready");
    } catch (error) {
      if (requestId !== lightboxRequestId || !lightbox.open) return;
      console.debug("EXIF metadata load failed", error);
      currentExifMetadata = null;
      currentExifState = "error";
      renderExifPanel(item, null, "error");
    } finally {
      if (currentExifLoadRequestId === requestId) {
        currentExifLoadRequestId = 0;
      }
    }
  }

  function loadCurrentExif() {
    if (!currentLightboxItem || currentIndex < 0) return;
    if (currentExifState === "ready") {
      renderExifPanel(
        currentLightboxItem,
        currentExifMetadata,
        "ready"
      );
      return;
    }
    if (currentExifState === "loading") return;
    updateLightboxExif(
      currentIndex,
      currentLightboxItem,
      lightboxRequestId
    );
  }

  let videoFrameProbeTimer = null;

  function clearVideoPlaybackProbe() {
    if (videoFrameProbeTimer !== null) {
      clearTimeout(videoFrameProbeTimer);
      videoFrameProbeTimer = null;
    }
  }

  function hideVideoPlaybackError() {
    if (videoPlaybackError) videoPlaybackError.hidden = true;
  }

  function configureVideoFallback(item) {
    clearVideoPlaybackProbe();
    hideVideoPlaybackError();
    const filename = String(item && item.name || "video")
      .split(/[\\/]/)
      .pop() || "video";
    for (const link of [videoOpenOriginal, videoDownloadOriginal]) {
      if (link) link.href = item.originalSrc || item.src;
    }
    if (videoDownloadOriginal) videoDownloadOriginal.download = filename;
  }

  function showVideoPlaybackError() {
    const item = currentLightboxItem;
    if (!item || !item.video || !lightbox.open || !lightboxVideo.src) return;
    let message = "This video could not be played in this browser.";
    if (item.compatibilityNote) message += ` ${item.compatibilityNote}`;
    if (videoPlaybackErrorMessage) {
      videoPlaybackErrorMessage.textContent = message;
    }
    if (videoPlaybackError) videoPlaybackError.hidden = false;
  }

  lightboxVideo.addEventListener("error", showVideoPlaybackError);
  lightboxVideo.addEventListener("canplay", hideVideoPlaybackError);
  lightboxVideo.addEventListener("play", () => {
    clearVideoPlaybackProbe();
    if (
      !currentLightboxItem ||
      !currentLightboxItem.video ||
      typeof lightboxVideo.requestVideoFrameCallback !== "function"
    ) return;
    let frameDecoded = false;
    lightboxVideo.requestVideoFrameCallback(() => {
      frameDecoded = true;
      clearVideoPlaybackProbe();
      hideVideoPlaybackError();
    });
    videoFrameProbeTimer = setTimeout(() => {
      videoFrameProbeTimer = null;
      if (!frameDecoded && !lightboxVideo.paused && lightboxVideo.currentTime > 0) {
        showVideoPlaybackError();
      }
    }, 4000);
  });
  lightboxVideo.addEventListener("pause", clearVideoPlaybackProbe);

  function clearAnimatedLightboxSource() {
    if (lightboxAnimatedAbortController) {
      lightboxAnimatedAbortController.abort();
      lightboxAnimatedAbortController = null;
    }
    if (lightboxAnimatedImage) {
      lightboxAnimatedImage.removeAttribute("src");
      lightboxAnimatedImage.remove();
      lightboxAnimatedImage = null;
    }
    lightboxImage.classList.remove("animation-proxy");
    if (lightboxAnimatedObjectUrl) {
      URL.revokeObjectURL(lightboxAnimatedObjectUrl);
      lightboxAnimatedObjectUrl = null;
    }
  }

  function syncAnimatedLightboxImage() {
    const image = lightboxAnimatedImage;
    if (!image) return;

    let width = lightboxImage.style.width;
    let height = lightboxImage.style.height;
    if ((!width || !height) && image.naturalWidth && image.naturalHeight) {
      const available = lightboxAvailableSize();
      const fitScale = Math.min(
        1,
        available.width / image.naturalWidth,
        available.height / image.naturalHeight
      );
      width = `${Math.max(1, image.naturalWidth * fitScale)}px`;
      height = `${Math.max(1, image.naturalHeight * fitScale)}px`;
    }
    if (width) image.style.width = width;
    if (height) image.style.height = height;
    image.style.transform = lightboxImage.style.transform || "none";
  }

  function createAnimatedLightboxImage(item, src, requestId) {
    const image = document.createElement("img");
    image.className = "lightbox-animation";
    image.alt = "";
    image.setAttribute("aria-hidden", "true");
    image.draggable = false;
    image.decoding = "auto";
    image.loading = "eager";
    image.fetchPriority = "high";
    lightboxAnimatedImage = image;

    image.addEventListener("load", () => {
      if (
        image !== lightboxAnimatedImage ||
        requestId !== lightboxRequestId ||
        !lightbox.open
      ) {
        return;
      }
      syncAnimatedLightboxImage();
      requestAnimationFrame(() => {
        if (
          image === lightboxAnimatedImage &&
          requestId === lightboxRequestId &&
          lightbox.open
        ) {
          lightboxImage.classList.add("animation-proxy");
        }
      });
    }, { once: true });

    image.addEventListener("error", () => {
      if (image !== lightboxAnimatedImage) return;
      image.remove();
      lightboxAnimatedImage = null;
      lightboxImage.classList.remove("animation-proxy");
    }, { once: true });

    stage.appendChild(image);
    image.src = src;
  }

  async function isolatedAnimatedLightboxSource(item, requestId) {
    const controller = new AbortController();
    lightboxAnimatedAbortController = controller;
    try {
      const response = await fetch(item.src, {
        cache: "force-cache",
        credentials: "same-origin",
        signal: controller.signal
      });
      if (!response.ok) {
        throw new Error(`animated image fetch failed (${response.status})`);
      }
      const sourceBlob = await response.blob();
      if (requestId !== lightboxRequestId || !lightbox.open) return null;
      const mimeTypes = {
        ".gif": "image/gif",
        ".webp": "image/webp",
        ".png": "image/png",
        ".avif": "image/avif"
      };
      const expectedType = mimeTypes[item.sourceSuffix] || sourceBlob.type;
      const isolatedBlob = expectedType && sourceBlob.type !== expectedType
        ? sourceBlob.slice(0, sourceBlob.size, expectedType)
        : sourceBlob;
      lightboxAnimatedObjectUrl = URL.createObjectURL(isolatedBlob);
      return lightboxAnimatedObjectUrl;
    } catch (error) {
      if (error && error.name === "AbortError") return null;
      console.debug("animated lightbox isolation unavailable", error);
      return `${String(item.src).split("#", 1)[0]}#jg-animated-${requestId}`;
    } finally {
      if (lightboxAnimatedAbortController === controller) {
        lightboxAnimatedAbortController = null;
      }
    }
  }

  async function setLightboxImage(index) {
    cancelLightboxPreloads();
    clearAnimatedLightboxSource();
    const normalized =
      (index + itemCount) % itemCount;
    currentIndex = normalized;

    const requestId = ++lightboxRequestId;
    caption.textContent = "Loading…";
    currentLightboxItem = null;
    currentExifMetadata = null;
    currentExifState = "idle";
    currentExifLoadRequestId = 0;
    renderExifPanel(null, null, isExifDrawerOpen() ? "loading" : "idle");
    if (downloadButton) {
      downloadButton.removeAttribute("href");
      downloadButton.removeAttribute("download");
    }
    resetLightboxView();
    lightboxNeedsInitialFit = true;
    lightbox.classList.remove("video-active");
    clearVideoPlaybackProbe();
    hideVideoPlaybackError();
    lightboxVideo.pause();
    lightboxVideo.removeAttribute("src");
    lightboxVideo.removeAttribute("poster");
    lightboxVideo.hidden = true;
    lightboxImage.hidden = false;
    lightboxImage.classList.remove("animated", "animation-proxy");
    lightboxImage.removeAttribute("srcset");
    lightboxImage.removeAttribute("src");

    let item;
    try {
      item = await itemAt(normalized);
    } catch (error) {
      if (requestId === lightboxRequestId) {
        caption.textContent =
          "Unable to load media metadata";
      }
      console.error(error);
      return;
    }

    if (requestId !== lightboxRequestId) {
      return;
    }

    caption.textContent = item.name;
    currentLightboxItem = item;
    renderExifPanel(item, null, "idle");
    if (isExifDrawerOpen()) loadCurrentExif();

    if (downloadButton && item.originalSrc) {
      downloadButton.href = item.originalSrc;
      const pathParts = String(item.name).split(/[\\/]/);
      downloadButton.download =
        pathParts[pathParts.length - 1] || "original";
    }

    if (item.video) {
      lightboxNeedsInitialFit = false;
      lightboxImage.hidden = true;
      lightboxVideo.hidden = false;
      lightbox.classList.add("video-active");
      configureVideoFallback(item);
      if (item.posterSrc) lightboxVideo.poster = item.posterSrc;
      lightboxVideo.src = item.src;
      lightboxVideo.load();
      return item;
    }

    const set = item.animated ? "" : srcsetString(item);
    lightboxImage.classList.toggle("animated", item.animated);
    lightboxImage.sizes = "100vw";
    lightboxImage.dataset.src = item.src;
    lightboxImage.dataset.srcset = set;
    lightboxImage.dataset.retryCount = "0";
    delete lightboxImage.dataset.loadError;
    if (set) lightboxImage.srcset = set;
    lightboxImage.src = item.src;
    lightboxImage.alt = item.name;
    if (item.animated) {
      const displaySrc = await isolatedAnimatedLightboxSource(item, requestId);
      if (
        !displaySrc ||
        requestId !== lightboxRequestId ||
        !lightbox.open
      ) {
        return null;
      }
      createAnimatedLightboxImage(item, displaySrc, requestId);
    } else {
      scheduleLightboxPreload(normalized, requestId);
    }
    return item;
  }

  const lightboxPreloads = new Set();

  function cancelLightboxPreloads() {
    for (const preload of lightboxPreloads) {
      activeTileLoads.delete(preload);
      preload.removeAttribute("srcset");
      preload.removeAttribute("src");
    }
    lightboxPreloads.clear();
    scheduleTileLoadPump();
  }

  async function preloadLightboxIndex(index) {
    if (
      MAX_LIGHTBOX_PRELOADS < 1 ||
      lightboxPreloads.size >= MAX_LIGHTBOX_PRELOADS ||
      activeTileLoads.size >= MAX_ACTIVE_TILE_LOADS
    ) {
      return;
    }
    try {
      const item = await itemAt(index);
      if (item.video || item.animated) return;
      if (
        !lightbox.open ||
        lightboxPreloads.size >= MAX_LIGHTBOX_PRELOADS ||
        activeTileLoads.size >= MAX_ACTIVE_TILE_LOADS
      ) {
        return;
      }
      const preload = new Image();
      lightboxPreloads.add(preload);
      activeTileLoads.add(preload);
      let released = false;
      const release = () => {
        if (released) return;
        released = true;
        lightboxPreloads.delete(preload);
        activeTileLoads.delete(preload);
        scheduleTileLoadPump();
        noteDiagnosticsState();
      };
      preload.addEventListener("load", () => {
        if (typeof preload.decode === "function") {
          preload.decode().catch(() => {}).finally(release);
        } else {
          release();
        }
      }, { once: true });
      preload.addEventListener("error", release, { once: true });
      preload.sizes = "100vw";
      const set = srcsetString(item);
      if (set) preload.srcset = set;
      preload.src = item.src;
    } catch (error) {
      console.debug("adjacent image preload failed", error);
    }
  }

  async function scheduleLightboxPreload(index, requestId) {
    const connection =
      navigator.connection ||
      navigator.mozConnection ||
      navigator.webkitConnection;
    if (connection && connection.saveData) return;
    const effectiveType = String(
      connection && connection.effectiveType || ""
    ).toLowerCase();
    if (effectiveType === "slow-2g" || effectiveType === "2g") return;

    try {
      await lightboxImage.decode();
    } catch (_error) {
      // A successful load may still be useful even when decode() rejects.
    }
    if (requestId !== lightboxRequestId || !lightbox.open) return;

    await preloadLightboxIndex((index + 1) % itemCount);
    if (MAX_LIGHTBOX_PRELOADS < 2) return;
    const previous = () => {
      if (requestId !== lightboxRequestId || !lightbox.open) return;
      preloadLightboxIndex((index - 1 + itemCount) % itemCount);
    };
    scheduleBackgroundTask(previous, 250);
  }

  function naturalPageIndexForCatalogId(catalogId) {
    let low = 0;
    let high = naturalGeometry.pageRanges.length - 1;
    while (low <= high) {
      const mid = (low + high) >> 1;
      const range = naturalGeometry.pageRanges[mid];
      if (catalogId < range.catalogStart) {
        high = mid - 1;
      } else if (catalogId >= range.catalogEnd) {
        low = mid + 1;
      } else {
        return range.pageStart + (catalogId - range.catalogStart);
      }
    }
    return -1;
  }

  function pageIndexForCatalogId(catalogId) {
    const naturalPageIndex = naturalPageIndexForCatalogId(catalogId);
    if (naturalPageIndex < 0 || !geometry.displayOrder) {
      return naturalPageIndex;
    }
    for (let index = 0; index < geometry.displayOrder.length; index += 1) {
      if (geometry.displayOrder[index] === naturalPageIndex) return index;
    }
    return -1;
  }

  async function pageIndexForStableId(stableId) {
    const wanted = String(stableId).toLowerCase();
    const shardWidth = Math.max(1, Number(config.idShardWidth) || 2);
    const shard = wanted.slice(0, shardWidth);
    if (shard.length !== shardWidth) return -1;

    let rows;
    try {
      rows = await loadIdShard(shard);
    } catch (error) {
      console.error(error);
      return -1;
    }

    // Shards are sorted by full stable ID. Find the first prefix match, then
    // reject ambiguity rather than guessing.
    let low = 0;
    let high = rows.length;
    while (low < high) {
      const mid = (low + high) >> 1;
      const id = String(rows[mid][0] || "").toLowerCase();
      if (id < wanted) low = mid + 1;
      else high = mid;
    }

    let matchCatalogId = -1;
    for (let index = Math.max(0, low - 1); index < rows.length; index += 1) {
      const id = String(rows[index][0] || "").toLowerCase();
      if (!id.startsWith(wanted)) {
        if (id > wanted) break;
        continue;
      }
      if (matchCatalogId >= 0) return -1;
      matchCatalogId = Number(rows[index][1]);
    }
    return matchCatalogId >= 0
      ? pageIndexForCatalogId(matchCatalogId)
      : -1;
  }

  async function imageLocationFromHash() {
    const prefix = "#image=";
    if (location.hash.startsWith(prefix)) {
      const candidate = location.hash.slice(prefix.length);
      const minLength = Number(config.minImageIdHexLength);
      const maxLength = Number(config.stableImageIdHexLength);

      if (
        /^[0-9a-f]+$/i.test(candidate) &&
        candidate.length >= minLength &&
        candidate.length <= maxLength
      ) {
        return {
          pageIndex: await pageIndexForStableId(candidate),
          legacy: false
        };
      }
    }

    const legacyMatch = /^#image=(\\d+)$/.exec(location.hash);
    if (legacyMatch) {
      return {
        pageIndex: pageIndexForCatalogId(Number(legacyMatch[1])),
        legacy: true
      };
    }

    return null;
  }

  function lightboxHistoryState(markPushed) {
    const current = history.state;
    const state = current && typeof current === "object"
      ? { ...current }
      : {};
    if (markPushed) state.jgLightbox = true;
    else delete state.jgLightbox;
    return state;
  }

  async function setLightboxLocation(
    index,
    push,
    stableId = ""
  ) {
    let id = stableId;
    if (!id) {
      try {
        id = String((await itemAt(index)).stableId || "");
      } catch (error) {
        console.error(error);
        return;
      }
    }
    if (!id) return;

    const hash =
      `#image=${id.slice(0, config.imageIdHexLength)}`;
    if (push) lightboxHistoryPushed = true;
    const method = push ? "pushState" : "replaceState";
    history[method](
      lightboxHistoryState(lightboxHistoryPushed),
      "",
      hash
    );
    lightboxHistoryActive = true;
  }

  function clearLightboxLocation() {
    if (!/^#image=/.test(location.hash)) return;
    const url = new URL(location.href);
    url.hash = "";
    const state = history.state && typeof history.state === "object"
      ? { ...history.state }
      : null;
    if (state) delete state.jgLightbox;
    history.replaceState(state, "", url);
    lightboxHistoryActive = false;
    lightboxHistoryPushed = false;
  }

  async function openLightbox(index, fromHistory = false) {
    if (!lightbox.open) {
      lockLightboxNativeZoom();
      lightbox.showModal();
      try {
        lightbox.focus({ preventScroll: true });
      } catch (_error) {
        lightbox.focus();
      }
    }
    const item = await setLightboxImage(index);
    if (!item) return;
    if (fromHistory) {
      lightboxHistoryActive = true;
      lightboxHistoryPushed = Boolean(
        history.state && history.state.jgLightbox
      );
    } else {
      await setLightboxLocation(
        index,
        !lightboxHistoryActive,
        item.stableId
      );
    }
  }

  function closeLightboxWithHistory() {
    if (!lightbox.open) return;
    if (lightboxHistoryPushed && history.state && history.state.jgLightbox) {
      history.back();
      return;
    }
    lightbox.close();
    clearLightboxLocation();
  }

  async function move(delta) {
    if (currentIndex >= 0) {
      const nextIndex = (currentIndex + delta + itemCount) % itemCount;
      const item = await setLightboxImage(nextIndex);
      if (item) {
        await setLightboxLocation(nextIndex, false, item.stableId);
      }
    }
  }

  async function openLightboxFromLocation() {
    const locationImage = await imageLocationFromHash();
    if (
      !locationImage ||
      locationImage.pageIndex < 0
    ) {
      return false;
    }

    await openLightbox(locationImage.pageIndex, true);

    if (locationImage.legacy) {
      await setLightboxLocation(locationImage.pageIndex, false);
    }
    return true;
  }


  gallery.addEventListener("click", event => {
    const tile = event.target.closest(".tile");
    if (!tile || !gallery.contains(tile)) {
      return;
    }

    if (
      event.button !== 0 ||
      event.metaKey ||
      event.ctrlKey ||
      event.shiftKey ||
      event.altKey
    ) {
      return;
    }

    event.preventDefault();

    if (tile.dataset.metadataRetry === "1") {
      delete tile.dataset.metadataRetry;
      tile.classList.remove("load-error", "image-loaded");
      tile.classList.add("image-loading");
      tile.setAttribute("aria-busy", "true");
      const bindingToken = tile.dataset.bindingToken || "";
      hydrateTile(
        tile,
        Number(tile.dataset.index),
        true,
        bindingToken
      ).finally(() => {
        if (tile.dataset.bindingToken === bindingToken) {
          tile.removeAttribute("aria-busy");
        }
      });
      return;
    }

    if (tile.dataset.retryReady === "1") {
      retryTileImage(tile);
      return;
    }

    openLightbox(
      Number(tile.dataset.index)
    );
  });

  previousButton.addEventListener(
    "click",
    event => {
      event.stopPropagation();
      move(-1);
    }
  );

  nextButton.addEventListener(
    "click",
    event => {
      event.stopPropagation();
      move(1);
    }
  );

  closeButton.addEventListener(
    "click",
    closeLightboxWithHistory
  );

  infoButton.addEventListener(
    "click",
    event => {
      event.stopPropagation();
      toggleExifDrawer();
    }
  );

  lightboxImage.addEventListener(
    "load",
    () => {
      // Changing `sizes` while zooming can make the browser choose a larger
      // candidate from `srcset`, which fires another load event. Only the
      // first load for a newly selected image should reset to fitted view;
      // later responsive-candidate upgrades must preserve zoom and pan.
      const resetView = lightboxNeedsInitialFit;
      lightboxNeedsInitialFit = false;
      lightboxImage.dataset.retryCount = "0";
      delete lightboxImage.dataset.loadError;
      fitLightboxImage(resetView);
    }
  );

  function retryLightboxImage() {
    const src = lightboxImage.dataset.src;
    const set = lightboxImage.dataset.srcset;
    if (!src) return;
    delete lightboxImage.dataset.loadError;
    caption.textContent = lightboxImage.alt || "Loading…";
    lightboxImage.removeAttribute("srcset");
    lightboxImage.removeAttribute("src");
    requestAnimationFrame(() => {
      if (set) lightboxImage.srcset = set;
      lightboxImage.src = src;
    });
  }

  lightboxImage.addEventListener("error", () => {
    const count = Number(lightboxImage.dataset.retryCount || "0");
    if (count < 1) {
      lightboxImage.dataset.retryCount = String(count + 1);
      setTimeout(retryLightboxImage, 350);
      return;
    }
    lightboxImage.dataset.loadError = "1";
    caption.textContent = "Unable to load image — tap to retry";
  });

  function beginSinglePointerGesture(pointerId, point) {
    lightboxGestureMode = "single";
    lightboxSinglePointerId = pointerId;
    lightboxSingleStartX = point.x;
    lightboxSingleStartY = point.y;
    lightboxSingleStartPanX = lightboxPanX;
    lightboxSingleStartPanY = lightboxPanY;
    lightboxSingleStartedZoomed = lightboxZoom > 1.001;
    lightboxSingleMoved = false;
    if (lightboxSingleStartedZoomed) {
      lightboxImage.classList.add("dragging");
    }
  }

  function pinchPair() {
    const points = Array.from(lightboxPointers.values());
    if (points.length < 2) return null;
    const a = points[0];
    const b = points[1];
    const dx = b.x - a.x;
    const dy = b.y - a.y;
    return {
      distance: Math.max(1, Math.hypot(dx, dy)),
      midX: (a.x + b.x) / 2,
      midY: (a.y + b.y) / 2
    };
  }

  function beginPinchGesture() {
    const pair = pinchPair();
    if (!pair) return;
    lightboxGestureMode = "pinch";
    lightboxPinchStartDistance = pair.distance;
    lightboxPinchStartZoom = lightboxZoom;
    lightboxPinchStartPanX = lightboxPanX;
    lightboxPinchStartPanY = lightboxPanY;
    lightboxPinchStartMidX = pair.midX;
    lightboxPinchStartMidY = pair.midY;
    lightboxImage.classList.add("dragging");
  }

  lightboxImage.addEventListener(
    "pointerdown",
    event => {
      if (event.button !== 0 && event.pointerType === "mouse") return;
      if (event.cancelable) event.preventDefault();
      event.stopPropagation();

      lightboxPointers.set(event.pointerId, {
        x: event.clientX,
        y: event.clientY
      });

      try {
        lightboxImage.setPointerCapture(event.pointerId);
      } catch (_error) {
        // Older WebKit versions may reject pointer capture in edge cases.
      }

      if (lightboxPointers.size === 1) {
        beginSinglePointerGesture(
          event.pointerId,
          lightboxPointers.get(event.pointerId)
        );
      } else if (lightboxPointers.size === 2) {
        beginPinchGesture();
      }
    }
  );

  lightboxImage.addEventListener(
    "pointermove",
    event => {
      if (!lightboxPointers.has(event.pointerId)) return;
      if (event.cancelable) event.preventDefault();

      lightboxPointers.set(event.pointerId, {
        x: event.clientX,
        y: event.clientY
      });

      if (lightboxGestureMode === "pinch") {
        const pair = pinchPair();
        if (!pair) return;

        const nextZoom = Math.max(
          1,
          Math.min(
            LIGHTBOX_MAX_ZOOM,
            lightboxPinchStartZoom *
              (pair.distance / lightboxPinchStartDistance)
          )
        );
        const rect = stage.getBoundingClientRect();
        const centerX = rect.left + rect.width / 2;
        const centerY = rect.top + rect.height / 2;
        const startAnchorX = lightboxPinchStartMidX - centerX;
        const startAnchorY = lightboxPinchStartMidY - centerY;
        const currentAnchorX = pair.midX - centerX;
        const currentAnchorY = pair.midY - centerY;
        const imageX =
          (startAnchorX - lightboxPinchStartPanX) / lightboxPinchStartZoom;
        const imageY =
          (startAnchorY - lightboxPinchStartPanY) / lightboxPinchStartZoom;

        lightboxZoom = nextZoom;
        lightboxPanX = currentAnchorX - imageX * nextZoom;
        lightboxPanY = currentAnchorY - imageY * nextZoom;
        if (lightboxZoom <= 1.001) {
          lightboxZoom = 1;
          lightboxPanX = 0;
          lightboxPanY = 0;
        }
        applyLightboxTransform();
        return;
      }

      if (
        lightboxGestureMode !== "single" ||
        event.pointerId !== lightboxSinglePointerId
      ) {
        return;
      }

      const dx = event.clientX - lightboxSingleStartX;
      const dy = event.clientY - lightboxSingleStartY;
      if (Math.abs(dx) + Math.abs(dy) > 6) {
        lightboxSingleMoved = true;
      }

      if (lightboxSingleStartedZoomed) {
        lightboxPanX = lightboxSingleStartPanX + dx;
        lightboxPanY = lightboxSingleStartPanY + dy;
        applyLightboxTransform();
      }
    }
  );

  function finishLightboxPointer(event, cancelled = false) {
    if (!lightboxPointers.has(event.pointerId)) return;
    if (event.cancelable) event.preventDefault();

    const wasPinch = lightboxGestureMode === "pinch";
    const wasSingle =
      lightboxGestureMode === "single" &&
      event.pointerId === lightboxSinglePointerId;
    const dx = event.clientX - lightboxSingleStartX;
    const dy = event.clientY - lightboxSingleStartY;
    const moved = lightboxSingleMoved;
    const startedZoomed = lightboxSingleStartedZoomed;

    lightboxPointers.delete(event.pointerId);
    try {
      if (lightboxImage.hasPointerCapture(event.pointerId)) {
        lightboxImage.releasePointerCapture(event.pointerId);
      }
    } catch (_error) {
      // Pointer capture may already have been released by the browser.
    }

    if (wasPinch) {
      // Do not turn the final finger-lift after a pinch into a tap.
      lightboxGestureMode = lightboxPointers.size ? "ignore" : "none";
      if (!lightboxPointers.size) {
        lightboxImage.classList.remove("dragging");
      }
      return;
    }

    if (lightboxGestureMode === "ignore") {
      if (!lightboxPointers.size) {
        lightboxGestureMode = "none";
        lightboxImage.classList.remove("dragging");
      }
      return;
    }

    if (!wasSingle) return;
    lightboxGestureMode = "none";
    lightboxSinglePointerId = null;
    lightboxImage.classList.remove("dragging");
    if (cancelled) return;

    if (moved) {
      if (!startedZoomed && lightboxZoom <= 1.001) {
        if (Math.abs(dx) >= 60 && Math.abs(dx) > Math.abs(dy) * 1.25) {
          move(dx < 0 ? 1 : -1);
        } else if (dy >= 90 && Math.abs(dy) > Math.abs(dx) * 1.2) {
          if (isExifDrawerOpen()) {
            setExifDrawerOpen(false, true);
          } else {
            closeLightboxWithHistory();
          }
        }
      }
      return;
    }

    if (lightboxImage.dataset.loadError === "1") {
      lightboxImage.dataset.retryCount = "0";
      retryLightboxImage();
      return;
    }

    if (startedZoomed || lightboxZoom > 1.001) {
      setLightboxZoom(1);
    } else {
      setLightboxZoom(
        LIGHTBOX_CLICK_ZOOM,
        event.clientX,
        event.clientY
      );
    }
  }

  lightboxImage.addEventListener(
    "click",
    event => {
      event.preventDefault();
      event.stopPropagation();
    }
  );

  lightbox.addEventListener(
    "dblclick",
    event => {
      if (
        currentLightboxItem &&
        currentLightboxItem.video &&
        event.target === lightboxVideo
      ) {
        return;
      }
      event.preventDefault();
      event.stopPropagation();
    }
  );

  for (const gestureEvent of [
    "gesturestart",
    "gesturechange",
    "gestureend",
  ]) {
    lightbox.addEventListener(
      gestureEvent,
      event => {
        if (event.cancelable) event.preventDefault();
      },
      { passive: false }
    );
  }

  lightboxImage.addEventListener(
    "pointerup",
    event => finishLightboxPointer(event, false)
  );
  lightboxImage.addEventListener(
    "pointercancel",
    event => finishLightboxPointer(event, true)
  );
  lightboxImage.addEventListener(
    "lostpointercapture",
    event => {
      lightboxPointers.delete(event.pointerId);
      if (!lightboxPointers.size) {
        lightboxGestureMode = "none";
        lightboxSinglePointerId = null;
        lightboxImage.classList.remove("dragging");
      }
    }
  );


  stage.addEventListener(
    "wheel",
    event => {
      if (currentLightboxItem && currentLightboxItem.video) return;
      if (!lightboxImage.complete) return;
      event.preventDefault();
      const factor = Math.exp(-event.deltaY * 0.002);
      setLightboxZoom(
        lightboxZoom * factor,
        event.clientX,
        event.clientY
      );
    },
    { passive: false }
  );

  stage.addEventListener(
    "click",
    event => {
      if (event.target !== stage) return;
      if (isExifDrawerOpen()) {
        setExifDrawerOpen(false, true);
      } else {
        closeLightboxWithHistory();
      }
    }
  );

  lightbox.addEventListener(
    "cancel",
    event => {
      event.preventDefault();
      if (isExifDrawerOpen()) {
        setExifDrawerOpen(false, true);
      } else {
        closeLightboxWithHistory();
      }
    }
  );

  lightbox.addEventListener(
    "close",
    () => {
      cancelLightboxPreloads();
      clearAnimatedLightboxSource();
      lightboxImage.classList.remove("animated", "animation-proxy");
      lightboxImage.removeAttribute("srcset");
      lightboxImage.removeAttribute("src");
      lightboxVideo.pause();
      lightboxVideo.removeAttribute("src");
      lightboxVideo.removeAttribute("poster");
      lightboxVideo.load();
      lightboxVideo.hidden = true;
      clearVideoPlaybackProbe();
      hideVideoPlaybackError();
      lightbox.classList.remove("video-active");
      setExifDrawerOpen(false);
      resetLightboxView();
      unlockLightboxNativeZoom();
      if (!/^#image=/.test(location.hash)) {
        lightboxHistoryActive = false;
      }
    }
  );

  window.addEventListener("popstate", async () => {
    const locationImage = await imageLocationFromHash();
    if (
      locationImage &&
      locationImage.pageIndex >= 0
    ) {
      await openLightbox(
        locationImage.pageIndex,
        true
      );
      if (locationImage.legacy) {
        await setLightboxLocation(
          locationImage.pageIndex,
          false
        );
      }
      return;
    }
    lightboxHistoryActive = false;
    lightboxHistoryPushed = false;
    if (lightbox.open) lightbox.close();
  });

  document.addEventListener(
    "keydown",
    event => {
      if (!lightbox.open) return;
      const target = event.target;
      if (
        target instanceof HTMLElement &&
        (
          target.isContentEditable ||
          ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName)
        )
      ) {
        return;
      }
      if (
        currentLightboxItem &&
        currentLightboxItem.video &&
        target === lightboxVideo &&
        ["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)
      ) {
        return;
      }
      if (event.key === "ArrowLeft") {
        event.preventDefault();
        move(-1);
      } else if (
        event.key === "ArrowRight"
      ) {
        event.preventDefault();
        move(1);
      } else if (event.key === "Home") {
        event.preventDefault();
        setLightboxImage(0);
      } else if (event.key === "End") {
        event.preventDefault();
        setLightboxImage(itemCount - 1);
      } else if (
        event.key.toLowerCase() === "i" &&
        !event.ctrlKey &&
        !event.metaKey &&
        !event.altKey
      ) {
        event.preventDefault();
        toggleExifDrawer();
      }
    }
  );

  function suspendHiddenPageWork() {
    if (iosFastScrollTimer !== null) {
      clearTimeout(iosFastScrollTimer);
      iosFastScrollTimer = null;
    }
    iosFastScrollActive = false;
    virtualDeferredUntil = 0;
    if (virtualDeferredTimer !== null) {
      clearTimeout(virtualDeferredTimer);
      virtualDeferredTimer = null;
    }
    pendingTileLoads.clear();
    cancelGeometryPrefetch();
    cancelLightboxPreloads();
    if (!lightboxVideo.paused) lightboxVideo.pause();
    evictMetadataCache("", Math.floor(metadataCacheBudgetBytes * 0.35));

    const viewport = galleryViewportBounds();
    for (const [rowIndex, rowElement] of mountedRows.entries()) {
      const row = rows[rowIndex];
      if (!row || rowVisibilityState(row, viewport).visible) continue;
      for (const img of rowElement.querySelectorAll("img")) {
        releaseTileImage(img);
      }
    }
    noteDiagnosticsState();
  }

  function resumeVisiblePageWork() {
    lastObservedScrollY = window.scrollY;
    lastObservedScrollTime = performance.now();
    observedScrollSpeed = 0;
    scheduleVirtualUpdate();
    scheduleImageResidencyUpdate();
  }

  document.addEventListener("visibilitychange", () => {
    if (document.hidden) suspendHiddenPageWork();
    else resumeVisiblePageWork();
  });
  window.addEventListener("pagehide", suspendHiddenPageWork);
  window.addEventListener("pageshow", resumeVisiblePageWork);

  window.addEventListener(
    "scroll",
    handleGalleryScroll,
    { passive: true }
  );
  if (isIOSWebKit && "onscrollend" in window) {
    window.addEventListener(
      "scrollend",
      finishIOSFastScroll,
      { passive: true }
    );
  }

  if (window.visualViewport) {
    window.visualViewport.addEventListener(
      "resize",
      scheduleVirtualUpdate,
      { passive: true }
    );
    window.visualViewport.addEventListener(
      "resize",
      refitOpenLightbox,
      { passive: true }
    );
  }

  window.addEventListener(
    "resize",
    refitOpenLightbox,
    { passive: true }
  );
  if ("ResizeObserver" in window) {
    new ResizeObserver(
      scheduleRender
    ).observe(gallery);
  } else {
    window.addEventListener(
      "resize",
      scheduleRender,
      { passive: true }
    );
  }

  window.addEventListener(
    "orientationchange",
    scheduleRender,
    { passive: true }
  );

  function warmInitialViewportData() {
    const tasks = [];
    if (
      config.layoutWorkerUrl &&
      (geometry.mode === "segmented" || itemCount >= 50000)
    ) {
      getLayoutWorker();
    }
    if (geometry.mode === "segmented") {
      const segment = geometrySegmentForPageIndex(0);
      if (segment && !segment.ratios) {
        tasks.push(
          ensureGeometrySegment(segment.index, { prefetch: true })
            .then(scheduleRender)
        );
      }
    }
    const firstCatalogId = naturalCatalogIdAt(0);
    if (
      firstCatalogId >= 0 &&
      !bootstrapCatalogItems.has(firstCatalogId)
    ) {
      tasks.push(loadCatalogChunk(
        chunkIndexForCatalogId(firstCatalogId),
        { prefetch: true }
      ));
    }
    if (tasks.length) {
      void Promise.allSettled(tasks).then(() => {
        markStartupPhase("initial-metadata-warm");
      });
    } else {
      markStartupPhase("initial-metadata-warm");
    }
  }

  const deferredDisplayMode = initializeDisplayModeControls();
  warmInitialViewportData();
  markStartupPhase("initial-render-requested");
  render();
  void Promise.race([
    firstViewportReady,
    new Promise(resolve => setTimeout(resolve, 1000))
  ]).then(() => {
    scheduleBackgroundTask(() => {
      void loadChunkRevisions(false).catch(error => {
        console.debug("background chunk revision load deferred", error);
      });
    });
  });
  if (deferredDisplayMode === "random") {
    const timeout = new Promise(resolve => setTimeout(resolve, 2500));
    void Promise.race([firstViewportReady, timeout]).then(() => {
      scheduleBackgroundTask(() => {
        void setDisplayMode("random");
      });
    });
  }
  await openLightboxFromLocation();
})().catch(error => {
  console.error(error);
  const empty = document.getElementById("empty");
  if (empty) {
    empty.classList.add("gallery-load-failure");
    empty.setAttribute("role", "alert");
    empty.textContent = "Gallery data failed to load.";
    const detail = document.createElement("code");
    detail.className = "empty-error-detail";
    detail.textContent = String(error && error.message || error);
    const retry = document.createElement("button");
    retry.type = "button";
    retry.className = "empty-retry";
    retry.textContent = "Reload gallery";
    retry.addEventListener("click", () => location.reload());
    empty.append(detail, retry);
    empty.style.display = "block";
  }
});"""

_GALLERY_DATA_FORMAT_TOKEN = "__JG_DATA_FORMAT_VERSION__"
if GALLERY_JS.count(_GALLERY_DATA_FORMAT_TOKEN) != 1:
    raise RuntimeError("gallery client data-format token is missing or ambiguous")
GALLERY_JS = GALLERY_JS.replace(
    _GALLERY_DATA_FORMAT_TOKEN,
    str(GALLERY_DATA_FORMAT_VERSION),
)

_CATALOG_BINARY_SCHEMA_TOKEN = "__JG_CATALOG_BINARY_SCHEMA__"
if GALLERY_JS.count(_CATALOG_BINARY_SCHEMA_TOKEN) != 1:
    raise RuntimeError("gallery client catalog-schema token is missing or ambiguous")
GALLERY_JS = GALLERY_JS.replace(
    _CATALOG_BINARY_SCHEMA_TOKEN,
    js_json(catalog_binary_browser_schema()),
)

_COMPACT_SCHEMA_TOKEN = "__JG_COMPACT_SCHEMA__"
if GALLERY_JS.count(_COMPACT_SCHEMA_TOKEN) != 1:
    raise RuntimeError("gallery client compact-schema token is missing or ambiguous")
GALLERY_JS = GALLERY_JS.replace(
    _COMPACT_SCHEMA_TOKEN,
    js_json(compact_browser_schema()),
)



@dataclass(frozen=True, slots=True)
class ClientAssetSpec:
    """One generated browser asset and every policy attached to it."""

    key: str
    stem: str
    suffix: str
    payload: str | None
    legacy_path: str
    required_by_page: bool = False

    def version(self, css_payload: str) -> str:
        payload = css_payload if self.key == "css" else self.payload
        if payload is None:
            raise RuntimeError(f"client asset {self.key} has no payload")
        return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:12]

    def relative_path(self, version: str) -> str:
        return f"{ASSET_DIRNAME}/{self.stem}.{version}.{self.suffix}"

    def immutable_glob(self) -> str:
        return f"{self.stem}.*.{self.suffix}"


CLIENT_ASSET_SPECS = (
    ClientAssetSpec(
        "css", "gallery", "css", None, LEGACY_GALLERY_CSS_ASSET, True
    ),
    ClientAssetSpec(
        "theme", "theme", "js", THEME_JS, LEGACY_THEME_JS_ASSET, True
    ),
    ClientAssetSpec(
        "password", "password", "js", PASSWORD_JS, "", False
    ),
    ClientAssetSpec(
        "gallery", "gallery", "js", GALLERY_JS, LEGACY_GALLERY_JS_ASSET, True
    ),
    ClientAssetSpec(
        "layout_worker",
        "layout-worker",
        "js",
        LAYOUT_WORKER_JS,
        LEGACY_LAYOUT_WORKER_ASSET,
        False,
    ),
)
CLIENT_ASSET_BY_KEY = {spec.key: spec for spec in CLIENT_ASSET_SPECS}


def rendered_gallery_css(lightbox_opacity: float) -> str:
    opacity_text = format(float(lightbox_opacity), ".6g")
    return GALLERY_CSS.replace(
        "rgba(0, 0, 0, 0.86)",
        f"rgba(0, 0, 0, {opacity_text})",
        1,
    )


_DEFAULT_RENDERED_GALLERY_CSS = rendered_gallery_css(DEFAULT_LIGHTBOX_OPACITY)
CLIENT_ASSET_VERSIONS = {
    spec.key: spec.version(_DEFAULT_RENDERED_GALLERY_CSS)
    for spec in CLIENT_ASSET_SPECS
}
THEME_JS_VERSION = CLIENT_ASSET_VERSIONS["theme"]
PASSWORD_JS_VERSION = CLIENT_ASSET_VERSIONS["password"]
GALLERY_JS_VERSION = CLIENT_ASSET_VERSIONS["gallery"]
LAYOUT_WORKER_VERSION = CLIENT_ASSET_VERSIONS["layout_worker"]


def immutable_client_asset_paths(css_version: str) -> dict[str, str]:
    """Return content-addressed client paths used as one publish generation."""
    versions = dict(CLIENT_ASSET_VERSIONS)
    versions["css"] = css_version
    return {
        spec.key: spec.relative_path(versions[spec.key])
        for spec in CLIENT_ASSET_SPECS
    }


def ensure_gallery_client_assets(
    output_dir: Path,
    lightbox_opacity: float,
) -> ClientAssetPublication:
    """Write immutable CSS/JS before atomically publishing referencing pages."""
    rewritten = 0
    css = rendered_gallery_css(lightbox_opacity)
    css_version = hashlib.sha256(css.encode("utf-8")).hexdigest()[:12]
    assets = immutable_client_asset_paths(css_version)
    for spec in CLIENT_ASSET_SPECS:
        payload = css if spec.key == "css" else spec.payload
        if payload is None:
            raise RuntimeError(f"client asset {spec.key} has no payload")
        rewritten += int(
            write_text_if_changed(output_dir / assets[spec.key], payload)
        )
    return ClientAssetPublication(rewritten, css_version, assets)


@dataclass(frozen=True, slots=True)
class HtmlRenderFragments:
    title_html: str
    heading_html: str
    download_html: str
    credit_html: str
    app_head: str
    topnav_html: str
    page_nav_html: str
    custom_header_html: str
    custom_footer_html: str
    page_descriptor_href_html: str
    css_href_html: str
    theme_js_href_html: str
    js_href_html: str
    client_head_html: str
    password_meta_html: str
    password_gate_html: str
    gallery_content_open: str
    gallery_content_close: str
    bootstrap_html: str


def render_html_fragments(page: HtmlPage) -> HtmlRenderFragments:
    """Render conditional page fragments independently of the HTML shell."""
    title = page.title
    page_descriptor_href = page.page_descriptor_href
    heading = page.heading
    image_count = page.image_count
    home_href = page.home_href
    parent_href = page.parent_href
    parent_label = page.parent_label
    child_links = page.child_links
    page_dir = page.page_dir
    app_title = page.app_title
    app_assets_enabled = page.app_assets_enabled
    client_assets = page.client_assets
    show_credit = page.show_credit
    include_originals = page.include_originals
    header_html = page.header_html
    footer_html = page.footer_html
    password_config = page.password_config
    download_html = (
        '  <a id="download-original" href="#" download '
        'aria-label="Download original" title="Download original">'
        '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">'
        '<path d="M12 3.5v11"/>'
        '<path d="m7.75 10.25 4.25 4.25 4.25-4.25"/>'
        '<path d="M5 18v2h14v-2"/>'
        '</svg></a>\n'
        if include_originals
        else ""
    )

    title_html = html.escape(title, quote=False)
    heading_html = html.escape(heading, quote=False)

    if show_credit:
        credit_href_html = html.escape(
            relative_output_url(page_dir, BUILDER_SCRIPT_COPY),
            quote=True,
        )
        credit_html = (
            ' <span class="page-credit">'
            f'<a href="{credit_href_html}">powered by gallery.py</a>'
            '</span>'
        )
    else:
        credit_html = ""

    page_descriptor_href_html = html.escape(page_descriptor_href, quote=True)
    app_head = app_head_html(page_dir, app_title, app_assets_enabled)

    nav_parts: list[str] = []
    if home_href is not None:
        nav_parts.append(
            f'<a class="top-link" href="{html.escape(home_href, quote=True)}">All images</a>'
        )
    if parent_href is not None and parent_label is not None:
        nav_parts.append(
            f'<a class="top-link" href="{html.escape(parent_href, quote=True)}">'
            f'Up: {html.escape(parent_label, quote=False)}</a>'
        )
    nav_html = "\n".join(nav_parts)

    if child_links:
        child_items = "\n".join(
            "          "
            f'<li><a href="{html.escape(href, quote=True)}">'
            f'{html.escape(label, quote=False)}</a></li>'
            for label, href in child_links
        )
        children_html = (
            '      <details id="children-menu">\n'
            '        <summary id="children-toggle" '
            'aria-label="Albums" title="Albums">☰</summary>\n'
            '        <div id="children-panel">\n'
            '          <h2>Albums</h2>\n'
            '          <ul>\n'
            f'{child_items}\n'
            '          </ul>\n'
            '        </div>\n'
            '      </details>'
        )
    else:
        children_html = ""

    topnav_lines: list[str] = [
        '      <button id="theme-toggle" type="button" '
        'aria-label="Toggle color theme"></button>'
    ]
    if password_config is not None:
        topnav_lines.append(
            '      <button id="gallery-lock" type="button" '
            'aria-label="Lock gallery" title="Lock gallery">'
            '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">'
            '<rect x="5" y="10" width="14" height="10" rx="2"/>'
            '<path d="M8 10V7a4 4 0 0 1 8 0v3"/>'
            '</svg></button>'
        )
    if children_html:
        topnav_lines.extend(children_html.splitlines())
    topnav_html = "\n".join(topnav_lines)

    page_nav_html = ""
    if nav_html:
        page_nav_lines = [
            f"      {line}"
            for line in nav_html.splitlines()
        ]
        page_nav_html = (
            '    <nav id="page-nav" aria-label="Page navigation">\n'
            + "\n".join(page_nav_lines)
            + "\n    </nav>\n"
        )

    custom_header_html = ""
    if header_html.strip():
        custom_header_html = (
            '<section id="custom-header-html" class="custom-html" '
            'aria-label="Gallery introduction">\n'
            '  <div class="custom-html-inner">\n'
            f'{header_html}\n'
            '  </div>\n'
            '</section>\n'
        )

    custom_footer_html = ""
    if footer_html.strip():
        custom_footer_html = (
            '<footer id="custom-footer-html" class="custom-html">\n'
            '  <div class="custom-html-inner">\n'
            f'{footer_html}\n'
            '  </div>\n'
            '</footer>\n'
        )

    css_href_html = html.escape(
        relative_output_url(page_dir, client_assets["css"]),
        quote=True,
    )
    theme_js_href_html = html.escape(
        relative_output_url(page_dir, client_assets["theme"]),
        quote=True,
    )
    js_href_html = html.escape(
        relative_output_url(page_dir, client_assets["gallery"]),
        quote=True,
    )
    password_js_href_html = html.escape(
        relative_output_url(page_dir, client_assets["password"]),
        quote=True,
    )

    password_meta_html = ""
    client_head_html = ""
    password_gate_html = ""
    gallery_content_open = ""
    gallery_content_close = ""
    if password_config is not None:
        client_head_html = (
            f'\n<link rel="preload" href="{js_href_html}" as="script">'
        )
        password_meta_html = (
            '\n<meta name="robots" content="noindex,nofollow,noarchive">'
        )
        salt_html = html.escape(
            str(password_config["salt_hex"]),
            quote=True,
        )
        verifier_html = html.escape(
            str(password_config["verifier_hex"]),
            quote=True,
        )
        iterations_html = html.escape(
            str(password_config["iterations"]),
            quote=True,
        )
        timeout_html = html.escape(
            str(password_config["session_timeout_seconds"]),
            quote=True,
        )
        password_gate_html = f"""<div id="password-gate"
  data-salt="{salt_html}"
  data-verifier="{verifier_html}"
  data-iterations="{iterations_html}"
  data-session-timeout-seconds="{timeout_html}"
  data-geometry-src="{page_descriptor_href_html}"
  data-gallery-src="{js_href_html}">
  <div class="password-card">
    <h1>{heading_html}</h1>
    <p id="password-prompt">This gallery is password protected.</p>
    <div id="password-form" role="form"
      aria-describedby="password-prompt password-status">
      <label class="password-label" for="gallery-password">Password</label>
      <input id="gallery-password" type="password"
        autocomplete="current-password" autocapitalize="none" autocorrect="off"
        spellcheck="false" enterkeyhint="go" required>
      <button id="password-submit" type="button">View gallery</button>
      <p id="password-status" role="status" aria-live="polite"></p>
    </div>
  </div>
</div>
"""
        gallery_content_open = (
            '<div id="gallery-content" hidden inert aria-hidden="true">\n'
        )
        gallery_content_close = "</div>\n"
        bootstrap_html = (
            '<noscript><p class="password-noscript">This protected gallery '
            'requires JavaScript.</p></noscript>\n'
            f'<script src="{password_js_href_html}"></script>'
        )
    else:
        client_head_html = (
            f'\n<script src="{page_descriptor_href_html}" defer></script>'
            f'\n<script src="{js_href_html}" defer></script>'
        )
        bootstrap_html = (
            '<noscript>This gallery requires JavaScript for justified layout.'
            '</noscript>'
        )

    return HtmlRenderFragments(
        title_html=title_html,
        heading_html=heading_html,
        download_html=download_html,
        credit_html=credit_html,
        app_head=app_head,
        topnav_html=topnav_html,
        page_nav_html=page_nav_html,
        custom_header_html=custom_header_html,
        custom_footer_html=custom_footer_html,
        page_descriptor_href_html=page_descriptor_href_html,
        css_href_html=css_href_html,
        theme_js_href_html=theme_js_href_html,
        js_href_html=js_href_html,
        client_head_html=client_head_html,
        password_meta_html=password_meta_html,
        password_gate_html=password_gate_html,
        gallery_content_open=gallery_content_open,
        gallery_content_close=gallery_content_close,
        bootstrap_html=bootstrap_html,
    )


def build_html(page: HtmlPage) -> str:
    """Render one complete page from pre-rendered conditional fragments."""
    fragments = render_html_fragments(page)
    title_html = fragments.title_html
    heading_html = fragments.heading_html
    download_html = fragments.download_html
    credit_html = fragments.credit_html
    app_head = fragments.app_head
    topnav_html = fragments.topnav_html
    page_nav_html = fragments.page_nav_html
    custom_header_html = fragments.custom_header_html
    custom_footer_html = fragments.custom_footer_html
    password_meta_html = fragments.password_meta_html
    client_head_html = fragments.client_head_html
    password_gate_html = fragments.password_gate_html
    gallery_content_open = fragments.gallery_content_open
    gallery_content_close = fragments.gallery_content_close
    css_href_html = fragments.css_href_html
    theme_js_href_html = fragments.theme_js_href_html
    bootstrap_html = fragments.bootstrap_html
    image_count = page.image_count
    heading = page.heading
    build_date_html = html.escape(page.build_date, quote=True)

    return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="gallery-build-date" content="{build_date_html}">
<meta name="referrer" content="no-referrer">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="color-scheme" content="light dark">
<meta name="theme-color" id="theme-color" content="#111111">{password_meta_html}{client_head_html}
<script src="{theme_js_href_html}"></script>
<title>{title_html}</title>{app_head}
<link rel="stylesheet" href="{css_href_html}">
</head>
<body>
{password_gate_html}{gallery_content_open}<header id="topbar">
  <div id="topbar-main">
    <h1>{heading_html}{credit_html}</h1>
    <nav id="topnav" aria-label="Gallery controls">
{topnav_html}
    </nav>
  </div>
</header>
<div id="meta">
  <div id="count">{image_count} media item{'s' if image_count != 1 else ''}</div>
  <div id="sort-controls" hidden>
    <label class="visually-hidden" for="sort-mode">Display order</label>
    <select id="sort-mode" aria-label="Display order">
      <option value="natural">Sorted</option>
      <option value="random">Unsorted</option>
    </select>
    <button id="reshuffle" type="button" hidden
      aria-label="Reshuffle unsorted groups" title="Reshuffle unsorted groups">↻</button>
    <span id="sort-status" class="visually-hidden" aria-live="polite"></span>
  </div>
{page_nav_html}</div>
{custom_header_html}<main>
  <div id="gallery" aria-label="{html.escape(heading, quote=True)}"></div>
  <div id="empty">No supported images or videos were found.</div>
</main>
{custom_footer_html}
<dialog id="lightbox" tabindex="-1">
  <div id="lightbox-shell">
    <div id="lightbox-stage">
      <img id="lightbox-image" alt="" draggable="false">
      <video id="lightbox-video" controls playsinline preload="metadata" hidden></video>
      <div id="video-playback-error" role="alert" hidden>
        <p id="video-playback-error-message">This video could not be played in this browser.</p>
        <div class="video-fallback-actions">
          <a id="video-open-original" href="#" target="_blank" rel="noopener">Open original</a>
          <a id="video-download-original" href="#" download>Download original</a>
        </div>
      </div>
    </div>
    <div id="lightbox-caption"></div>
    <aside id="lightbox-exif" aria-label="Media metadata" aria-hidden="true" inert>
      <h2 id="lightbox-info-heading">EXIF</h2>
      <dl id="lightbox-exif-list"></dl>
      <p id="lightbox-exif-status" aria-live="polite"></p>
    </aside>
  </div>
  <button class="nav" id="prev" type="button" aria-label="Previous item">‹</button>
  <button class="nav" id="next" type="button" aria-label="Next item">›</button>
{download_html}  <button id="lightbox-info-toggle" type="button"
    aria-label="Show media information" aria-controls="lightbox-exif"
    aria-expanded="false" title="Show media information"><svg viewBox="0 0 20 20"
      aria-hidden="true" focusable="false"><path d="M10 4h.01"/>
      <path d="M10 8.5v7.5"/></svg></button>
  <button id="close" type="button" aria-label="Close">×</button>
</dialog>
{gallery_content_close}{bootstrap_html}
</body>
</html>
"""


def render_signature_for_asset_schema(
    widths: list[int],
    quality: int,
    effort: int,
    output_format: str,
    asset_schema_version: int,
) -> str:
    """Reproduce the historical render signature for one asset schema.

    Asset schema versions 2 and 3 used identical JPEG XL encoding parameters;
    v3 only changed generated-file placement to sharded directories. Other
    static output formats were introduced later and therefore only have
    current-schema compatibility.
    """
    payload: dict[str, Any] = {
        "asset_schema_version": asset_schema_version,
        "format": output_format,
        "widths": widths,
        "orientation": "exif-transpose",
    }

    # Preserve historical JXL/WebP signatures exactly so existing galleries do
    # not re-encode solely because newer output formats were added.
    if output_format in {"jxl", "webp"}:
        payload["quality"] = quality
        payload["effort"] = effort
    elif output_format == "png":
        # PNG is lossless, so --quality is intentionally irrelevant.
        payload["compress_level"] = min(9, max(1, int(effort)))
    elif output_format == "jpeg":
        payload["quality"] = quality
        payload["optimize"] = int(effort) >= 5
    elif output_format == "avif":
        payload["quality"] = quality
        payload["speed"] = avif_speed_from_effort(effort)
    else:
        raise ValueError(f"unsupported static output format: {output_format}")
    return hashlib.sha256(
        json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
    ).hexdigest()


def render_signature(
    widths: list[int],
    quality: int,
    effort: int,
    output_format: str,
) -> str:
    return render_signature_for_asset_schema(
        widths,
        quality,
        effort,
        output_format,
        ASSET_SCHEMA_VERSION,
    )


def encoding_compatible_render_signatures(
    widths: list[int],
    quality: int,
    effort: int,
    output_format: str,
) -> set[str]:
    if output_format == "jxl":
        versions = ENCODING_COMPATIBLE_ASSET_SCHEMA_VERSIONS
    else:
        versions = {ASSET_SCHEMA_VERSION}

    return {
        render_signature_for_asset_schema(
            widths,
            quality,
            effort,
            output_format,
            version,
        )
        for version in versions
    }


def remove_legacy_bundle_roots(output_dir: Path) -> int:
    """Remove obsolete generated-only roots from pre-layout-change bundles."""
    removed = 0
    for dirname in (LEGACY_ASSET_DIRNAME, LEGACY_GALLERY_DATA_DIR):
        if dirname in {ASSET_DIRNAME, GALLERY_DATA_DIR}:
            continue
        path = output_dir / dirname
        if not path.exists() or path.is_symlink():
            continue
        try:
            if path.is_dir():
                shutil.rmtree(path)
            else:
                path.unlink()
            removed += 1
        except OSError as exc:
            eprint(f"warning: could not remove obsolete generated path {path}: {exc}")

    legacy_builder = output_dir / LEGACY_BUILDER_SCRIPT_COPY
    if (
        LEGACY_BUILDER_SCRIPT_COPY != BUILDER_SCRIPT_COPY
        and legacy_builder.is_file()
        and not legacy_builder.is_symlink()
    ):
        try:
            legacy_builder.unlink()
            removed += 1
        except OSError as exc:
            eprint(
                f"warning: could not remove obsolete generated file "
                f"{legacy_builder}: {exc}"
            )

    # Older strict-security builds emitted deployment-specific header templates.
    # They are no longer generated; clean them up when encountered.
    for filename in (
        "security-headers.txt",
        "security-headers.nginx.conf",
        "security-headers.traefik.yml",
        "_headers",
    ):
        path = output_dir / filename
        if not path.is_file() or path.is_symlink():
            continue
        try:
            path.unlink()
            removed += 1
        except OSError as exc:
            eprint(
                f"warning: could not remove obsolete generated file "
                f"{path}: {exc}"
            )

    return removed


def normalize_output_permissions(
    output_dir: Path,
    referenced_assets: Iterable[str],
    referenced_pages: Iterable[str],
) -> None:
    """Normalize permissions for every file/directory owned by this bundle.

    This also repairs cached assets produced by older versions that inherited
    mkstemp's 0600 mode.
    """
    ensure_output_directory(output_dir)

    generated_files = set(referenced_assets)
    generated_files.update(referenced_pages)
    directories: set[Path] = {
        output_dir,
        output_dir / ASSET_DIRNAME,
        output_dir / GALLERY_DATA_DIR,
        output_dir / ALBUMS_DIRNAME,
    }

    for relative in generated_files:
        generated = output_dir / relative
        if generated.is_file() and not generated.is_symlink():
            set_output_file_mode(generated)

        parent = generated.parent
        while parent == output_dir or is_relative_to(parent, output_dir):
            directories.add(parent)
            if parent == output_dir:
                break
            parent = parent.parent

    # Parents first is not strictly necessary for chmod, but makes the intended
    # final tree state easy to reason about.
    for directory in sorted(directories, key=lambda p: len(p.parts)):
        if directory.is_dir() and not directory.is_symlink():
            os.chmod(directory, OUTPUT_DIR_MODE)



def remove_stale_generated_files(
    output_dir: Path,
    stale: Iterable[tuple[str, str]],
) -> tuple[dict[str, int], list[str]]:
    """Delete stale generated files using the SQLite inventory, not tree scans."""
    removed = {"asset": 0, "page": 0, "data": 0}
    cleared: list[str] = []
    protected_roots = {
        output_dir,
        output_dir / ASSET_DIRNAME,
        output_dir / GALLERY_DATA_DIR,
        output_dir / ALBUMS_DIRNAME,
    }

    for relative, kind in stale:
        path = output_dir / relative
        try:
            if path.is_file() and not path.is_symlink():
                path.unlink()
                removed[kind] = removed.get(kind, 0) + 1
            cleared.append(relative)
        except OSError as exc:
            eprint(f"warning: could not remove stale generated file {path}: {exc}")
            continue

        parent = path.parent
        while parent not in protected_roots and is_relative_to(parent, output_dir):
            try:
                parent.rmdir()
            except OSError:
                break
            parent = parent.parent

    return removed, cleared


def validate_generated_data_files(
    output_dir: Path,
    relative_paths: Iterable[str],
    *,
    verify_content: bool,
    cache_db: SQLiteCache | None = None,
    reuse_cached_validations: bool = False,
) -> int:
    """Verify referenced JavaScript/binary data and their content hashes."""
    checked = 0
    cached = (
        cache_db.validations.snapshot()
        if cache_db is not None and reuse_cached_validations
        else {}
    )
    validated: list[ArtifactValidationRecord] = []
    for relative in relative_paths:
        suffix = Path(str(relative)).suffix.casefold()
        if suffix not in {".js", ".bin"}:
            continue
        path = output_dir / str(relative)
        try:
            stat_result = path.stat()
        except OSError as exc:
            raise ContractError(
                f"generated data file is unavailable: {path}: {exc}"
            ) from exc
        if stat_result.st_size <= 0:
            raise ContractError(f"generated data file is empty: {path}")
        match = re.search(r"\.([0-9a-f]{12})\.(?:js|bin)$", path.name)
        if match is None:
            raise ContractError(
                f"generated data filename is not content-addressed: {path}"
            )
        revision = match.group(1)
        if not verify_content:
            checked += 1
            continue
        prior = cached.get(str(relative))
        if prior is not None and prior.matches(revision, stat_result):
            checked += 1
            continue
        try:
            data = path.read_bytes()
        except OSError as exc:
            raise ContractError(
                f"generated data file cannot be verified: {path}: {exc}"
            ) from exc
        if suffix == ".js":
            if not data.endswith(b";\n") or data.endswith(b";\\n"):
                raise ContractError(
                    f"generated data file has an invalid terminator: {path}"
                )
            if b"\x00" in data or b"window.__JG_" not in data:
                raise ContractError(
                    f"generated data file has an invalid JavaScript envelope: {path}"
                )
        else:
            magic = data[:4]
            if magic == GEOMETRY_BINARY_MAGIC:
                if len(data) < GEOMETRY_BINARY_HEADER.size:
                    raise ContractError(
                        f"generated geometry binary is truncated: {path}"
                    )
                _magic, count = GEOMETRY_BINARY_HEADER.unpack_from(data)
                expected_size = GEOMETRY_BINARY_HEADER.size + (
                    int(count) * GEOMETRY_BINARY_VALUE.size
                )
                if len(data) != expected_size:
                    raise ContractError(
                        "generated geometry binary has an invalid envelope: "
                        f"{path}"
                    )
            elif magic == ORDER_BINARY_MAGIC:
                if len(data) < ORDER_BINARY_HEADER.size:
                    raise ContractError(
                        f"generated random-order binary is truncated: {path}"
                    )
                _magic, version, count, group_count = (
                    ORDER_BINARY_HEADER.unpack_from(data)
                )
                expected_size = ORDER_BINARY_HEADER.size + (
                    (int(group_count) + int(count)) * ORDER_BINARY_VALUE.size
                )
                valid = (
                    int(version) == ORDER_BINARY_VERSION
                    and int(group_count) > 0
                    and int(group_count) <= int(count)
                    and len(data) == expected_size
                )
                if valid:
                    offset = ORDER_BINARY_HEADER.size
                    length_total = 0
                    for _index in range(int(group_count)):
                        length_total += ORDER_BINARY_VALUE.unpack_from(
                            data, offset
                        )[0]
                        offset += ORDER_BINARY_VALUE.size
                    valid = length_total == int(count)
                if valid:
                    seen = bytearray(int(count))
                    for _index in range(int(count)):
                        catalog_id = ORDER_BINARY_VALUE.unpack_from(
                            data, offset
                        )[0]
                        offset += ORDER_BINARY_VALUE.size
                        if catalog_id >= int(count) or seen[catalog_id]:
                            valid = False
                            break
                        seen[catalog_id] = 1
                if not valid:
                    raise ContractError(
                        "generated random-order binary has an invalid envelope: "
                        f"{path}"
                    )
            elif magic == CATALOG_BINARY_MAGIC:
                if len(data) < CATALOG_BINARY_HEADER.size:
                    raise ContractError(
                        f"generated catalog binary is truncated: {path}"
                    )
                (
                    _magic,
                    version,
                    count,
                    string_count,
                    string_offsets_offset,
                    record_offsets_offset,
                    records_offset,
                    strings_offset,
                ) = CATALOG_BINARY_HEADER.unpack_from(data)
                string_table_end = string_offsets_offset + 4 * (
                    int(string_count) + 1
                )
                record_table_end = record_offsets_offset + 4 * (
                    int(count) + 1
                )
                valid = (
                    int(version) == CATALOG_BINARY_VERSION
                    and string_offsets_offset == CATALOG_BINARY_HEADER.size
                    and string_table_end <= record_offsets_offset
                    and record_table_end <= records_offset
                    and records_offset <= strings_offset <= len(data)
                )
                if valid:
                    last_record = struct.unpack_from(
                        "<I", data, record_offsets_offset + 4 * int(count)
                    )[0]
                    last_string = struct.unpack_from(
                        "<I", data, string_offsets_offset + 4 * int(string_count)
                    )[0]
                    valid = (
                        last_record == strings_offset - records_offset
                        and last_string == len(data) - strings_offset
                    )
                if not valid:
                    raise ContractError(
                        "generated catalog binary has an invalid envelope: "
                        f"{path}"
                    )
            else:
                raise ContractError(
                    f"generated binary has an unknown envelope: {path}"
                )
        actual = hashlib.sha256(data).hexdigest()[:12]
        if actual != revision:
            raise ContractError(
                f"generated data hash mismatch for {path}: "
                f"filename has {revision}, content has {actual}"
            )
        validated.append(ArtifactValidationRecord(
            path=str(relative),
            revision=revision,
            size=int(stat_result.st_size),
            mtime_ns=int(stat_result.st_mtime_ns),
            ctime_ns=int(stat_result.st_ctime_ns),
            device=int(stat_result.st_dev),
            inode=int(stat_result.st_ino),
            validated_ns=time.time_ns(),
        ))
        checked += 1
    if cache_db is not None:
        cache_db.validations.record(validated)
    return checked


def _parse_generated_assignment(text: str, target: str, source: Path) -> Any:
    marker = f"{target}="
    start = text.find(marker)
    if start < 0:
        raise ContractError(f"{source} does not define {target}")
    start += len(marker)
    end = text.find(";\n", start)
    if end < 0:
        raise ContractError(f"{source} has an unterminated {target} assignment")
    try:
        return json.loads(text[start:end])
    except json.JSONDecodeError as exc:
        raise ContractError(f"{source} has invalid JSON for {target}: {exc}") from exc


def _resolved_generated_reference(
    output_dir: Path,
    page_relative: str,
    reference: str,
) -> tuple[Path, str]:
    decoded = html.unescape(str(reference)).split("#", 1)[0].split("?", 1)[0]
    if not decoded or decoded.startswith(("/", "\\")) or "://" in decoded:
        raise ContractError(
            f"invalid generated reference in {page_relative}: {reference!r}"
        )
    target = (output_dir / page_relative).parent / decoded
    resolved = target.resolve(strict=False)
    root = output_dir.resolve(strict=False)
    if not is_relative_to(resolved, root):
        raise ContractError(
            f"generated reference escapes output directory: {reference!r}"
        )
    return resolved, resolved.relative_to(root).as_posix()


def _contract_expected_chunk_paths(
    revision_index_rel: str,
    revision_payload: dict[str, Any],
) -> set[str]:
    """Resolve every canonical chunk named by a revision index."""
    paths = {revision_index_rel}
    paths.update(
        catalog_chunk_relative_path(
            int(key), str(value.get("revision") or "")
        ).as_posix()
        for key, value in revision_payload.get("catalog", {}).items()
    )
    paths.update(
        catalog_binary_relative_path(
            int(key), str(value.get("binaryRevision") or "")
        ).as_posix()
        for key, value in revision_payload.get("catalog", {}).items()
    )
    paths.update(
        exif_chunk_relative_path(int(key), str(value)).as_posix()
        for key, value in revision_payload.get("exif", {}).items()
    )
    paths.update(
        global_geometry_chunk_relative_path(
            int(key), str(value.get("revision") or "")
        ).as_posix()
        for key, value in revision_payload.get("geometry", {}).items()
    )
    paths.update(
        global_geometry_binary_relative_path(
            int(key), str(value.get("binaryRevision") or "")
        ).as_posix()
        for key, value in revision_payload.get("geometry", {}).items()
    )
    paths.update(
        id_index_relative_path(str(key), str(value)).as_posix()
        for key, value in revision_payload.get("ids", {}).items()
    )
    return paths


def _validate_contract_chunk_inventory(
    output_dir: Path,
    data_paths: set[str],
    expected_paths: set[str],
) -> None:
    missing = expected_paths - data_paths
    if missing:
        raise ContractError(
            "generated data inventory is missing referenced chunks: "
            + ", ".join(sorted(missing)[:5])
        )
    for relative in expected_paths:
        path = output_dir / relative
        if not path.is_file() or path.stat().st_size <= 0:
            raise ContractError(
                f"referenced generated chunk is unavailable: {path}"
            )


def _validate_client_asset_contract(
    output_dir: Path,
    client_assets: dict[str, str],
) -> None:
    for kind, relative in client_assets.items():
        path = output_dir / relative
        data = path.read_bytes()
        match = re.search(r"\.([0-9a-f]{12})\.(?:css|js)$", path.name)
        if match is None:
            raise ContractError(f"client asset is not content-addressed: {path}")
        actual = hashlib.sha256(data).hexdigest()[:12]
        if actual != match.group(1):
            raise ContractError(
                f"client asset hash mismatch for {path}: "
                f"filename has {match.group(1)}, content has {actual}"
            )
        if kind == "gallery" and (
            f"SUPPORTED_DATA_FORMAT_VERSION = {GALLERY_DATA_FORMAT_VERSION};"
            not in data.decode("utf-8")
        ):
            raise ContractError(
                "gallery client and generated data format versions do not agree"
            )


def _validate_random_order_contract(
    *,
    output_dir: Path,
    page_relative: str,
    descriptor_path: Path,
    random_order: object,
    data_paths: set[str],
    global_catalog_count: int,
) -> set[str]:
    if not isinstance(random_order, dict):
        raise ContractError(
            f"{descriptor_path} has no random-order configuration"
        )
    if int(random_order.get("count") or 0) != global_catalog_count:
        raise ContractError(
            f"{descriptor_path} has an inconsistent random-order count"
        )
    enabled = bool(random_order.get("enabled"))
    available = bool(random_order.get("available"))
    paths: set[str] = set()
    if available:
        if not enabled:
            raise ContractError(
                f"{descriptor_path} exposes disabled random-order data"
            )
        group_count = int(random_order.get("groupCount") or 0)
        if group_count <= 0 or group_count > global_catalog_count:
            raise ContractError(
                f"{descriptor_path} has an invalid random-order group count"
            )
        for url_key, revision_key, suffix in (
            ("binaryUrl", "binaryRevision", "bin"),
            ("fallbackUrl", "fallbackRevision", "js"),
        ):
            _path, relative = _resolved_generated_reference(
                output_dir,
                page_relative,
                str(random_order.get(url_key) or ""),
            )
            revision = str(random_order.get(revision_key) or "")
            if relative not in data_paths or re.search(
                rf"\.{re.escape(revision)}\.{suffix}$",
                relative,
            ) is None:
                raise ContractError(
                    f"{descriptor_path} references invalid random-order data"
                )
            paths.add(relative)
    elif enabled and global_catalog_count >= 2:
        raise ContractError(
            f"{descriptor_path} unexpectedly disables random-order data"
        )
    elif not enabled:
        if int(random_order.get("groupCount") or 0) != 0:
            raise ContractError(
                f"{descriptor_path} has disabled random-order groups"
            )
        if any(
            random_order.get(key)
            for key in (
                "binaryUrl", "binaryRevision", "fallbackUrl", "fallbackRevision",
            )
        ):
            raise ContractError(
                f"{descriptor_path} references disabled random-order data"
            )
    return paths


def _validate_descriptor_contract(
    *,
    output_dir: Path,
    page_relative: str,
    descriptor_path: Path,
    data_paths: set[str],
    client_assets: dict[str, str],
    revision_index_rel: str,
    data_revision: str,
    global_catalog_count: int,
) -> set[str]:
    text = descriptor_path.read_text(encoding="utf-8")
    config = _parse_generated_assignment(
        text, "window.__JG_CONFIG__", descriptor_path
    )
    ranges = _parse_generated_assignment(
        text, "window.__JG_PAGE_RANGES__", descriptor_path
    )
    if int(config.get("dataFormatVersion") or -1) != GALLERY_DATA_FORMAT_VERSION:
        raise ContractError(f"{descriptor_path} has an incompatible data format")
    if str(config.get("dataRevision") or "") != data_revision:
        raise ContractError(f"{descriptor_path} has a stale data revision")
    revision_match = re.search(r"\.([0-9a-f]{12})\.js$", revision_index_rel)
    if (
        revision_match is None
        or str(config.get("revisionIndexRevision") or "")
        != revision_match.group(1)
    ):
        raise ContractError(
            f"{descriptor_path} has a stale revision-index version"
        )
    worker_url = str(config.get("layoutWorkerUrl") or "")
    if worker_url:
        _path, relative = _resolved_generated_reference(
            output_dir, page_relative, worker_url
        )
        if relative != client_assets["layout_worker"]:
            raise ContractError(
                f"{descriptor_path} references a stale layout worker"
            )
    described = sum(
        max(0, int(value[1]) - int(value[0])) for value in ranges
    )
    if described != int(config.get("count") or 0):
        raise ContractError(f"{descriptor_path} has inconsistent page ranges")
    order_paths = _validate_random_order_contract(
        output_dir=output_dir,
        page_relative=page_relative,
        descriptor_path=descriptor_path,
        random_order=config.get("randomOrder"),
        data_paths=data_paths,
        global_catalog_count=global_catalog_count,
    )
    _path, relative = _resolved_generated_reference(
        output_dir,
        page_relative,
        str(config.get("revisionIndexUrl") or ""),
    )
    if relative != revision_index_rel:
        raise ContractError(
            f"{descriptor_path} references a stale revision index"
        )
    return order_paths


def _validate_page_html_contract(
    *,
    output_dir: Path,
    page_relative: str,
    data_paths: set[str],
    client_assets: dict[str, str],
    revision_index_rel: str,
    data_revision: str,
    global_catalog_count: int,
) -> tuple[str, set[str]]:
    page_path = output_dir / page_relative
    page_text = page_path.read_text(encoding="utf-8")
    page_dir = Path(page_relative).parent
    matches = list(re.finditer(
        r'<meta name="gallery-build-date" content="([^"]+)">',
        page_text,
    ))
    if (
        len(matches) != 1
        or _ISO_8601_UTC_SECONDS_RE.fullmatch(matches[0].group(1)) is None
        or matches[0].start() > page_text.find("</head>")
    ):
        raise ContractError(f"{page_path} has invalid gallery build-date metadata")

    required = [spec.key for spec in CLIENT_ASSET_SPECS if spec.required_by_page]
    if 'id="password-gate"' in page_text:
        required.append("password")
    for kind in required:
        relative = client_assets[kind]
        expected = html.escape(relative_output_url(page_dir, relative), quote=True)
        if expected not in page_text:
            raise ContractError(
                f"{page_path} does not reference client asset {relative}"
            )

    descriptor_match = re.search(r'data-geometry-src="([^"]+)"', page_text)
    if descriptor_match is None:
        descriptor_match = re.search(
            r'<script src="([^"]*data/pages/[^"]+\.js)"[^>]*></script>',
            page_text,
        )
    if descriptor_match is None:
        raise ContractError(f"{page_path} has no page descriptor reference")
    descriptor_path, descriptor_relative = _resolved_generated_reference(
        output_dir,
        page_relative,
        descriptor_match.group(1),
    )
    if descriptor_relative not in data_paths:
        raise ContractError(
            f"{page_path} references untracked descriptor {descriptor_relative}"
        )
    order_paths = _validate_descriptor_contract(
        output_dir=output_dir,
        page_relative=page_relative,
        descriptor_path=descriptor_path,
        data_paths=data_paths,
        client_assets=client_assets,
        revision_index_rel=revision_index_rel,
        data_revision=data_revision,
        global_catalog_count=global_catalog_count,
    )
    return matches[0].group(1), order_paths


def validate_generated_gallery_contract(
    output_dir: Path,
    referenced_pages: Iterable[str],
    referenced_data: Iterable[str],
    client_assets: dict[str, str],
    revision_index_rel: str,
    revision_payload: dict[str, Any],
    data_revision: str,
) -> dict[str, int]:
    """Validate the published HTML/client/data contract before cleanup."""
    data_paths = {str(path) for path in referenced_data}
    html_pages = sorted(
        str(path) for path in referenced_pages if str(path).endswith(".html")
    )
    if not html_pages:
        raise ContractError("generated gallery has no HTML pages")

    revision_path = output_dir / revision_index_rel
    revision_text = revision_path.read_text(encoding="utf-8")
    actual_revision_payload = _parse_generated_assignment(
        revision_text,
        "window.__JG_CHUNK_REVISIONS__",
        revision_path,
    )
    if actual_revision_payload != revision_payload:
        raise ContractError("chunk revision index does not match generated chunks")

    expected_chunk_paths = _contract_expected_chunk_paths(
        revision_index_rel,
        revision_payload,
    )
    _validate_contract_chunk_inventory(
        output_dir,
        data_paths,
        expected_chunk_paths,
    )

    global_catalog_count = sum(
        int(value.get("count") or 0)
        for value in revision_payload.get("catalog", {}).values()
    )
    order_paths: set[str] = set()

    _validate_client_asset_contract(output_dir, client_assets)

    descriptor_count = 0
    common_build_date: str | None = None
    for page_relative in html_pages:
        page_build_date, page_order_paths = _validate_page_html_contract(
            output_dir=output_dir,
            page_relative=page_relative,
            data_paths=data_paths,
            client_assets=client_assets,
            revision_index_rel=revision_index_rel,
            data_revision=data_revision,
            global_catalog_count=global_catalog_count,
        )
        if common_build_date is None:
            common_build_date = page_build_date
        elif page_build_date != common_build_date:
            raise ContractError(
                "generated HTML pages have inconsistent gallery build dates"
            )
        order_paths.update(page_order_paths)
        descriptor_count += 1

    return {
        "pages": len(html_pages),
        "descriptors": descriptor_count,
        "chunks": len(expected_chunk_paths | order_paths),
        "client_assets": len(client_assets),
    }


def migrate_legacy_sqlite_cache(output_dir: Path, cache_path: Path) -> bool:
    """Rename the former default SQLite cache into the new default location."""
    legacy_path = output_dir / LEGACY_SQLITE_CACHE

    if cache_path.exists() or not legacy_path.is_file():
        return False

    os.replace(legacy_path, cache_path)

    # Preserve SQLite WAL state if a previous run left sidecar files behind.
    for suffix in ("-wal", "-shm"):
        legacy_sidecar = Path(str(legacy_path) + suffix)
        new_sidecar = Path(str(cache_path) + suffix)
        if legacy_sidecar.is_file() and not new_sidecar.exists():
            os.replace(legacy_sidecar, new_sidecar)

    return True


def _add_input_output_arguments(parser: argparse.ArgumentParser) -> None:
    parser.add_argument(
        "paths",
        nargs="*",
        type=Path,
        help=(
            "one or more input media files or directories "
            "(not needed with --doctor, --self-test, or "
            "--client-regression-test)"
        ),
    )
    parser.add_argument(
        "-r",
        "--recursive",
        action="store_true",
        help="recursively crawl directory inputs",
    )
    parser.add_argument(
        "-o",
        "--output",
        type=Path,
        default=Path(DEFAULT_OUTPUT),
        help=f"bundle output directory (default: ./{DEFAULT_OUTPUT})",
    )
    parser.add_argument(
        "--cache",
        type=Path,
        default=None,
        help=(
            "SQLite cache database path "
            f"(default: <output>/{DEFAULT_CACHE})"
        ),
    )
    parser.add_argument(
        "--cache-sync-interval",
        type=nonnegative_float,
        default=DEFAULT_CACHE_SYNC_INTERVAL,
        metavar="SECONDS",
        help=(
            "periodically commit completed work to SQLite; "
            f"default: {DEFAULT_CACHE_SYNC_INTERVAL:g} seconds, 0 disables"
        ),
    )
    parser.add_argument(
        "--lock-timeout",
        type=nonnegative_float,
        default=0.0,
        metavar="SECONDS",
        help=(
            "wait up to this long for another build using the same output; "
            "0 fails immediately (default: 0)"
        ),
    )
    parser.add_argument(
        "--title",
        default="Image Gallery",
        help="HTML page title (default: %(default)s)",
    )
    parser.add_argument(
        "--header-html",
        default="",
        metavar="HTML",
        help=(
            "insert trusted HTML, centered below the gallery header, on "
            "every generated page"
        ),
    )
    parser.add_argument(
        "--footer-html",
        default="",
        metavar="HTML",
        help=(
            "insert trusted HTML, centered after the gallery, on every "
            "generated page"
        ),
    )

def _add_password_arguments(parser: argparse.ArgumentParser) -> None:
    password_group = parser.add_mutually_exclusive_group()
    password_group.add_argument(
        "--password",
        nargs="?",
        const=PASSWORD_PROMPT_SENTINEL,
        default=None,
        metavar="PASSWORD",
        help=(
            "add a mobile-friendly client-side password gate; gallery data "
            "loading is deferred until unlock, but generated assets are not "
            "encrypted and direct URLs remain accessible; browser unlock "
            "requires HTTPS or another secure context; entered passwords are "
            "verified locally and are never submitted over the network; omit "
            "PASSWORD to prompt securely (a literal value remains supported)"
        ),
    )
    password_group.add_argument(
        "--password-file",
        type=Path,
        metavar="PATH",
        help="read the gallery password from a one-line UTF-8 file",
    )
    password_group.add_argument(
        "--password-env",
        nargs="?",
        const="GALLERY_PASSWORD",
        metavar="ENV_VAR",
        help=(
            "read the gallery password from ENV_VAR; if omitted, use "
            "GALLERY_PASSWORD"
        ),
    )
    parser.add_argument(
        "--password-session-timeout",
        type=nonnegative_float,
        default=0.0,
        metavar="MINUTES",
        help=(
            "automatically relock an unlocked protected gallery after this "
            "many minutes; 0 keeps it unlocked for the browser tab session "
            "(default: 0)"
        ),
    )

def _add_content_arguments(parser: argparse.ArgumentParser) -> None:
    parser.add_argument(
        "--no-credit",
        action="store_true",
        help=(
            "omit the 'powered by gallery.py' credit and bundled builder script"
        ),
    )
    parser.add_argument(
        "--include-originals",
        action="store_true",
        help=(
            "copy original source files into assets/ and add a lightbox "
            "download-original control"
        ),
    )
    parser.add_argument(
        "--transcode-incompatible-videos",
        action="store_true",
        help=(
            "create H.264/AAC MP4 playback derivatives only for videos "
            "likely to have browser compatibility problems; detected HDR "
            "is tone-mapped to BT.709 SDR, exact originals remain available, "
            "and the option is disabled by default"
        ),
    )

def _add_display_arguments(parser: argparse.ArgumentParser) -> None:
    parser.add_argument(
        "--target-row-height",
        type=int,
        default=240,
        help="target justified-row height in CSS pixels (default: %(default)s)",
    )
    parser.add_argument(
        "--gap",
        type=int,
        default=2,
        help="gap between images/rows in CSS pixels (default: %(default)s)",
    )
    parser.add_argument(
        "--widths",
        type=parse_widths,
        default=parse_widths("480,960,1600,2560"),
        help=(
            "comma-separated responsive derivative widths "
            "(default: 480,960,1600,2560)"
        ),
    )
    parser.add_argument(
        "--format",
        dest="output_format",
        choices=("jxl", "webp", "png", "jpeg", "avif"),
        default="jxl",
        help="static derivative output format (default: %(default)s)",
    )
    parser.add_argument(
        "--zoom-width",
        type=nonnegative_int,
        default=0,
        metavar="PX",
        help=(
            "optionally add one larger derivative used by the lightbox; "
            "0 disables it (example: --zoom-width 3840)"
        ),
    )
    parser.add_argument(
        "--image-id-bits",
        type=image_id_bits,
        default=DEFAULT_IMAGE_ID_BITS,
        metavar="BITS",
        help=(
            "bit length used in #image= links; hexadecimal IDs support "
            f"{MIN_IMAGE_ID_BITS}..{STABLE_IMAGE_ID_BITS} bits in steps of 4 "
            f"(default: {DEFAULT_IMAGE_ID_BITS})"
        ),
    )
    parser.add_argument(
        "--max-active-tile-loads",
        type=positive_int,
        default=None,
        metavar="N",
        help=(
            "maximum number of gallery tile image loads allowed in flight "
            "at once; overrides the adaptive client default of 4 on devices "
            "reporting <=2 GiB of memory, 6 on devices reporting <=4 GiB or "
            "an unknown amount, and 8 above 4 GiB"
        ),
    )
    parser.add_argument(
        "--image-load-margin",
        type=nonnegative_int,
        default=400,
        metavar="PX",
        help=(
            "keep tile images loaded only when they are within this many "
            "pixels of the viewport (default: %(default)s)"
        ),
    )
    parser.add_argument(
        "--lightbox-opacity",
        type=unit_interval_float,
        default=DEFAULT_LIGHTBOX_OPACITY,
        metavar="0..1",
        help=(
            "lightbox backdrop opacity, where 0 is fully transparent and "
            f"1 is fully opaque (default: {DEFAULT_LIGHTBOX_OPACITY:g})"
        ),
    )
    parser.add_argument(
        "--tooltip-delay",
        type=nonnegative_int,
        default=DEFAULT_TOOLTIP_DELAY_MS,
        metavar="MS",
        help=(
            "delay before image-name tooltips appear on hover, in "
            f"milliseconds (default: {DEFAULT_TOOLTIP_DELAY_MS})"
        ),
    )
    parser.add_argument(
        "--client-diagnostics",
        action="store_true",
        help=(
            "show a live client-side gallery diagnostics overlay; it can "
            "also be enabled per page with ?diagnostics=1"
        ),
    )
    parser.add_argument(
        "--allow-unsorted",
        action="store_true",
        help=(
            "allow viewers to switch between Sorted and Unsorted display "
            "modes; disabled by default"
        ),
    )
    parser.add_argument(
        "--random-group-suffix-regex",
        default=DEFAULT_RANDOM_GROUP_SUFFIX_REGEX,
        metavar="REGEX",
        help=(
            "regular expression removed from the end of a filename stem "
            "when --allow-unsorted forms groups; the default groups trailing "
            "digits whether attached or separated, including 'Name001', "
            "'Name-1', and 'Name (1)'"
        ),
    )
    parser.add_argument(
        "--manifest-chunk-size",
        dest="catalog_chunk_size",
        type=positive_int,
        default=DEFAULT_CATALOG_CHUNK_SIZE,
        metavar="N",
        help=(
            "number of canonical media metadata records per lazy-loaded "
            "binary chunk; JavaScript fallbacks are also emitted "
            f"(default: {DEFAULT_CATALOG_CHUNK_SIZE})"
        ),
    )
    parser.add_argument(
        "--geometry-chunk-size",
        type=positive_int,
        default=DEFAULT_GEOMETRY_CHUNK_SIZE,
        metavar="N",
        help=(
            "number of canonical shared geometry records per binary chunk; "
            "JavaScript fallbacks are also emitted "
            f"(default: {DEFAULT_GEOMETRY_CHUNK_SIZE})"
        ),
    )

def _add_processing_arguments(parser: argparse.ArgumentParser) -> None:
    parser.add_argument(
        "--quality",
        type=int,
        default=85,
        help=(
            "static-image quality, 1-100; ignored for lossless PNG "
            "(default: %(default)s)"
        ),
    )
    parser.add_argument(
        "--effort",
        type=int,
        default=7,
        help="static-image encode effort/method hint, 1-9 (default: %(default)s)",
    )
    parser.add_argument(
        "-j",
        "--jobs",
        type=positive_int,
        default=DEFAULT_JOBS,
        help=(
            "number of images to process concurrently "
            f"(default: {DEFAULT_JOBS})"
        ),
    )
    parser.add_argument(
        "--encoder-threads",
        type=nonnegative_int,
        default=0,
        metavar="N",
        help=(
            "per-image encoder thread budget; passed directly to JXL/AVIF "
            "and used to cap concurrent PNG/JPEG/WebP encoding; 0 "
            "automatically divides available CPUs across --jobs (default: 0)"
        ),
    )
    parser.add_argument(
        "--max-working-memory-mib",
        "--max-decoded-memory-mib",
        dest="max_working_memory_mib",
        type=nonnegative_int,
        default=0,
        metavar="MIB",
        help=(
            "shared budget for decoded images, resize copies, and encoder "
            "working buffers; 0 adapts to physical RAM (default: 0)"
        ),
    )
    parser.add_argument(
        "--cjxl-threads",
        dest="encoder_threads",
        type=nonnegative_int,
        default=argparse.SUPPRESS,
        metavar="N",
        help=argparse.SUPPRESS,
    )
    parser.add_argument(
        "--durable-writes",
        action="store_true",
        help=(
            "fsync generated text/binary files before atomic replacement; "
            "slower, but maximizes crash/power-loss durability"
        ),
    )

def _add_client_resources_arguments(parser: argparse.ArgumentParser) -> None:
    parser.add_argument(
        "--max-hot-catalog-chunks",
        type=nonnegative_int,
        default=0,
        metavar="N",
        help=(
            "client catalog metadata chunks retained in memory; 0 adapts to "
            "device memory (default: 0)"
        ),
    )
    parser.add_argument(
        "--max-client-metadata-mib",
        type=nonnegative_int,
        default=0,
        metavar="MIB",
        help=(
            "byte budget for resident catalog, EXIF, and geometry metadata; "
            "0 adapts to reported device memory (default: 0)"
        ),
    )
    parser.add_argument(
        "--no-layout-worker",
        dest="layout_worker",
        action="store_false",
        help="disable Web Worker row-layout calculation",
    )
    parser.set_defaults(layout_worker=True)
    parser.add_argument(
        "--progress-files",
        action="store_true",
        help="print one progress line per source file instead of a timed summary",
    )

def _add_watch_arguments(parser: argparse.ArgumentParser) -> None:
    parser.add_argument(
        "--watch",
        action="store_true",
        help=(
            "build once, then monitor the input paths and rebuild after "
            "stable changes"
        ),
    )
    parser.add_argument(
        "--watch-interval",
        type=nonnegative_float,
        default=2.0,
        metavar="SECONDS",
        help="source polling interval used by --watch (default: 2)",
    )
    parser.add_argument(
        "--watch-debounce",
        type=nonnegative_float,
        default=1.0,
        metavar="SECONDS",
        help="time a source change must remain stable before rebuild (default: 1)",
    )
    parser.add_argument(
        "--watch-reconcile-interval",
        type=nonnegative_float,
        default=DEFAULT_WATCH_RECONCILE_INTERVAL,
        metavar="SECONDS",
        help=(
            "full-tree polling interval while native watch events are active; "
            "covers queue overflow and remote filesystems "
            f"(default: {DEFAULT_WATCH_RECONCILE_INTERVAL:g})"
        ),
    )

def _add_maintenance_arguments(parser: argparse.ArgumentParser) -> None:
    parser.add_argument(
        "--max-image-pixels",
        type=nonnegative_int,
        default=None,
        metavar="N",
        help=(
            "override Pillow's decompression-bomb pixel threshold; "
            "0 disables the limit, omitted keeps Pillow's built-in default"
        ),
    )
    parser.add_argument(
        "--hash-all",
        action="store_true",
        help=(
            "rehash every source instead of trusting cached size+mtime_ns; "
            "slower but detects same-size/same-mtime content changes"
        ),
    )
    parser.add_argument(
        "--dry-run",
        "--explain-rebuild",
        dest="dry_run",
        action="store_true",
        help=(
            "scan sources and explain cached, encode, copy, rehash, and "
            "metadata-refresh work without changing the gallery or cache"
        ),
    )
    parser.add_argument(
        "--report-json",
        type=Path,
        default=None,
        metavar="PATH",
        help=(
            "write a machine-readable build, dry-run, or doctor report; "
            "use - to also print the JSON report to stdout"
        ),
    )
    parser.add_argument(
        "--storage-preflight",
        action=argparse.BooleanOptionalAction,
        default=True,
        help=(
            "estimate required output bytes/inodes and stop before encoding "
            "when the destination is clearly too small (default: enabled)"
        ),
    )
    parser.add_argument(
        "--doctor",
        action="store_true",
        help=(
            "check cache integrity, tracked and untracked generated files, "
            "permissions, representative derivatives, and codec availability"
        ),
    )
    parser.add_argument(
        "--client-regression-test",
        action="store_true",
        help=(
            "run the bundled Playwright large-gallery browser regression "
            "suite (requires Node.js, the playwright package, and Chromium; "
            "a system browser is auto-detected or may be selected with "
            "PLAYWRIGHT_CHROMIUM_EXECUTABLE)"
        ),
    )
    parser.add_argument(
        "--self-test",
        action="store_true",
        help=(
            "run fast offline schema, migration, publication, option, and "
            "deterministic rebuild checks (no input paths required)"
        ),
    )
    parser.add_argument(
        "--doctor-sample-size",
        type=nonnegative_int,
        default=32,
        metavar="N",
        help="number of representative media assets verified by --doctor (default: 32)",
    )
    parser.add_argument(
        "--doctor-repair",
        action="store_true",
        help=(
            "with --doctor, repair managed file/directory permissions and "
            "run safe SQLite optimization"
        ),
    )
    parser.add_argument(
        "--verify-assets",
        action="store_true",
        help=(
            "stat every expected derivative/data file even after a clean "
            "previous run and verify generated-data content hashes; slower, "
            "but repairs externally deleted or corrupted output"
        ),
    )
    parser.add_argument(
        "--jpeg-draft",
        action=argparse.BooleanOptionalAction,
        default=True,
        help=(
            "use libjpeg reduced-resolution decoding when safe for JPEG "
            "sources (default: enabled; use --no-jpeg-draft to disable)"
        ),
    )
    parser.add_argument(
        "--no-clean",
        action="store_true",
        help="do not delete generated files no longer referenced by this run",
    )
    parser.add_argument(
        "--repair-permissions",
        action="store_true",
        help=(
            "walk all referenced output and restore 0644/0755 permissions; "
            "normally permissions are set only when files/directories are created"
        ),
    )
    parser.set_defaults(skip_unchanged_html=True)
    parser.add_argument(
        "--rewrite-html",
        dest="skip_unchanged_html",
        action="store_false",
        help="rewrite generated HTML even when its contents are unchanged",
    )
    parser.add_argument(
        "--skip-unchanged-html",
        dest="skip_unchanged_html",
        action="store_true",
        help=argparse.SUPPRESS,
    )

def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description=(
            "Generate a relocation-safe, standalone justified media gallery "
            "with responsive image derivatives and original video playback."
        )
    )
    _add_input_output_arguments(parser)
    _add_password_arguments(parser)
    _add_content_arguments(parser)
    _add_display_arguments(parser)
    _add_processing_arguments(parser)
    _add_client_resources_arguments(parser)
    _add_watch_arguments(parser)
    _add_maintenance_arguments(parser)
    return parser


def validate_cli_namespace(
    namespace: argparse.Namespace,
    parser: argparse.ArgumentParser,
) -> None:
    """Validate mode-level CLI relationships before resolving any secrets."""
    maintenance_modes = sum(
        bool(value)
        for value in (
            namespace.self_test,
            namespace.client_regression_test,
            namespace.doctor,
            namespace.dry_run,
        )
    )
    if namespace.self_test and maintenance_modes != 1:
        parser.error(
            "--self-test cannot be combined with --client-regression-test, "
            "--doctor, or --dry-run"
        )
    if namespace.client_regression_test and (
        namespace.watch or namespace.doctor or namespace.dry_run
    ):
        parser.error(
            "--client-regression-test cannot be combined with "
            "--watch, --doctor, or --dry-run"
        )
    if namespace.self_test and namespace.watch:
        parser.error("--self-test cannot be combined with --watch")
    if namespace.watch_interval < 0.1:
        parser.error("--watch-interval must be >= 0.1 seconds")
    if namespace.watch_reconcile_interval < 1:
        parser.error("--watch-reconcile-interval must be >= 1 second")
    if namespace.watch and (namespace.doctor or namespace.dry_run):
        parser.error("--watch cannot be combined with --doctor or --dry-run")
    if namespace.password == "":
        parser.error("--password cannot be empty")


def validate_build_options(
    args: BuildOptions,
    parser: argparse.ArgumentParser,
) -> None:
    """Validate the complete immutable build configuration without side effects."""
    if args.render.target_row_height < 32:
        parser.error("--target-row-height must be >= 32")
    if args.render.gap < 0 or args.render.gap > 64:
        parser.error("--gap must be between 0 and 64")
    if args.render.quality < 1 or args.render.quality > 100:
        parser.error("--quality must be between 1 and 100")
    if args.render.effort < 1 or args.render.effort > 9:
        parser.error("--effort must be between 1 and 9")
    if args.render.zoom_width and args.render.zoom_width < 64:
        parser.error("--zoom-width must be 0 or >= 64")
    if args.command.doctor and args.command.dry_run:
        parser.error("--doctor and --dry-run cannot be used together")
    if args.command.doctor_repair and not args.command.doctor:
        parser.error("--doctor-repair requires --doctor")
    if not args.command.doctor and not args.source.paths:
        parser.error("at least one input path is required unless --doctor is used")
    try:
        re.compile(args.client.random_group_suffix_regex)
    except re.error as exc:
        parser.error(f"--random-group-suffix-regex is invalid: {exc}")


def prepare_build_environment(
    args: BuildOptions,
    parser: argparse.ArgumentParser,
) -> BuildExecutionResources:
    """Initialize external tools and limits after pure validation succeeds."""
    validate_build_options(args, parser)

    toolchain = resolved_toolchain_capabilities(
        args.render.jobs,
        args.render.encoder_threads,
        args.render.max_working_memory_mib,
    )
    resources = BuildExecutionResources.create(
        toolchain,
        jobs=args.render.jobs,
        durable_writes=args.cache_settings.durable_writes,
    )
    ARTIFACT_WRITER.configure(
        durable_writes=resources.durable_writes,
    )
    if args.output_settings.transcode_incompatible_videos and not args.command.doctor:
        try:
            ensure_video_transcoder_available(toolchain)
        except ValueError as exc:
            parser.error(str(exc))
    if not args.command.dry_run and not args.command.doctor:
        try:
            ensure_output_encoder_available(args.render.output_format, toolchain)
        except ValueError as exc:
            parser.error(str(exc))

    # Apply Pillow's decompression-bomb threshold before worker threads open
    # any source image.
    if args.render.max_image_pixels is not None:
        Image.MAX_IMAGE_PIXELS = (
            None if args.render.max_image_pixels == 0 else args.render.max_image_pixels
        )
    return resources


def build_cache_base_payload(
    args: BuildOptions,
    roots: Sequence[Path],
    output_dir: Path,
    signature: str,
    compatible_signatures: Iterable[str],
    toolchain: ToolchainCapabilities,
) -> dict[str, Any]:
    """Return deterministic cache metadata for the current build settings."""
    return {
        "cache_format": "sqlite",
        "cache_schema_version": CACHE_DB_SCHEMA_VERSION,
        "inputs": [str(root) for root in roots],
        "recursive": bool(args.source.recursive),
        "output_dir": str(output_dir),
        "render_signature": signature,
        "compatible_render_signatures": sorted(compatible_signatures),
        "render_settings": {
            "format": args.render.output_format,
            "widths": args.render.widths,
            "quality": args.render.quality,
            "effort": args.render.effort,
            "target_row_height": args.render.target_row_height,
            "gap": args.render.gap,
            "jobs": args.render.jobs,
            "encoder_threads": toolchain.encoder_threads,
            "max_working_memory_mib": (
                toolchain.working_memory_budget_bytes // (1024 * 1024)
            ),
            "max_image_pixels": args.render.max_image_pixels,
            "zoom_width": args.render.zoom_width,
            "directory_pages": True,
            "cache_sync_interval": args.cache_settings.cache_sync_interval,
            "asset_sharding": {
                "levels": ASSET_SHARD_LEVELS,
                "width": ASSET_SHARD_WIDTH,
            },
            "gallery_data_format_version": GALLERY_DATA_FORMAT_VERSION,
            "manifest_chunk_size": args.client.catalog_chunk_size,
            "geometry_chunk_size": args.client.geometry_chunk_size,
            "max_active_tile_loads": args.client.max_active_tile_loads,
            "image_load_margin": args.client.image_load_margin,
            "client_diagnostics": bool(args.client.client_diagnostics),
            "allow_unsorted": bool(args.client.allow_unsorted),
            "random_group_suffix_regex": (
                args.client.random_group_suffix_regex
            ),
            "max_hot_catalog_chunks": args.client.max_hot_catalog_chunks,
            "layout_worker": bool(args.client.layout_worker),
            "durable_writes": bool(args.cache_settings.durable_writes),
            "image_id_bits": args.client.image_id_bits,
            "include_originals": bool(args.output_settings.include_originals),
            "transcode_incompatible_videos": bool(
                args.output_settings.transcode_incompatible_videos
            ),
            "video_transcode_version": VIDEO_TRANSCODE_VERSION,
            "video_transcode_crf": VIDEO_TRANSCODE_CRF,
            "video_transcode_preset": VIDEO_TRANSCODE_PRESET,
            "video_transcode_audio_bitrate": VIDEO_TRANSCODE_AUDIO_BITRATE,
            "jpeg_draft": bool(args.render.jpeg_draft),
            "verify_assets": bool(args.cache_settings.verify_assets),
            "header_html_sha256": (
                hashlib.sha256(args.output_settings.header_html.encode("utf-8")).hexdigest()
                if args.output_settings.header_html
                else None
            ),
            "footer_html_sha256": (
                hashlib.sha256(args.output_settings.footer_html.encode("utf-8")).hexdigest()
                if args.output_settings.footer_html
                else None
            ),
            "password_protected": bool(args.password_settings.password_protected),
            "password_session_timeout_minutes": args.password_settings.password_session_timeout,
            "static_encoder": static_encoder_name(args.render.output_format),
            "jxl_encoder": "cjxl" if args.render.output_format == "jxl" else None,
        },
    }


def process_scanned_sources(
    *,
    cache_db: SQLiteCache,
    roots: Sequence[Path],
    total: int,
    jobs: int,
    options: ProcessingOptions,
    cache_writer: PeriodicCacheWriter,
    progress: ProgressReporter,
    counters: ProcessingCounters,
) -> None:
    """Process the streamed scan table with bounded worker/future counts."""
    executor: concurrent.futures.ThreadPoolExecutor | None = None
    future_map: dict[
        concurrent.futures.Future[ProcessingResult],
        tuple[int, int, Path],
    ] = {}

    def account_result(ordinal: int, result: ProcessingResult) -> str:
        if result.outcome is ProcessingOutcome.SKIPPED:
            counters.skipped += 1
        else:
            cache_db.scan.mark_scan_success(ordinal)
            if result.outcome is ProcessingOutcome.UPDATED:
                counters.changed += 1
            else:
                counters.reused += 1
        return result.outcome.value

    try:
        if jobs == 1 or total <= 1:
            for ordinal, path, old_entry in cache_db.scan.iter_scan_entries():
                index = ordinal + 1
                check_cancelled()
                result = process_one(path, old_entry, options)
                status = account_result(ordinal, result)

                cache_writer.update(
                    path,
                    result.entry,
                    result.changed,
                    index,
                )
                progress.update(
                    index,
                    counters.changed,
                    counters.reused,
                    counters.skipped,
                    status,
                    display_name(path, roots),
                )
            return

        # Keep only a small multiple of --jobs futures in flight so a very
        # large gallery does not allocate one Future per source.
        max_in_flight = max(jobs, jobs * 2)
        cached_iter = iter(cache_db.scan.iter_scan_entries())
        executor = concurrent.futures.ThreadPoolExecutor(max_workers=jobs)

        def submit_next() -> bool:
            check_cancelled()
            try:
                ordinal, path, old_entry = next(cached_iter)
            except StopIteration:
                return False
            index = ordinal + 1
            future = executor.submit(process_one, path, old_entry, options)
            future_map[future] = (ordinal, index, path)
            return True

        for _ in range(min(max_in_flight, total)):
            if not submit_next():
                break

        completed = 0
        while future_map:
            done, _pending = concurrent.futures.wait(
                future_map,
                timeout=0.25,
                return_when=concurrent.futures.FIRST_COMPLETED,
            )
            check_cancelled()
            if not done:
                continue

            for future in done:
                ordinal, index, path = future_map.pop(future)
                completed += 1
                try:
                    result = future.result()
                except ProcessingCancelled:
                    raise
                except Exception as exc:
                    DIAGNOSTICS.warning(
                        f"worker failure: {exc}",
                        code="worker-failure",
                        source=path,
                    )
                    result = ProcessingResult(None, False)

                status = account_result(ordinal, result)

                cache_writer.update(
                    path,
                    result.entry,
                    result.changed,
                    completed,
                )
                progress.update(
                    completed,
                    counters.changed,
                    counters.reused,
                    counters.skipped,
                    status,
                    display_name(path, roots),
                )
                submit_next()

        executor.shutdown(wait=True)
        executor = None
    except BaseException:
        for future in future_map:
            future.cancel()
        if executor is not None:
            executor.shutdown(wait=True, cancel_futures=True)
        raise
    finally:
        _close_exiftool_metadata_bridges()


def assemble_catalog(
    cache_db: SQLiteCache,
    roots: Sequence[Path],
    args: BuildOptions,
    progress: FinalizationReporter | None = None,
) -> CatalogAssembly:
    """Build a catalog atomically so SQLite can batch transient writes."""
    with cache_db.scan.catalog_assembly_transaction():
        return _assemble_catalog(cache_db, roots, args, progress)


def _assemble_catalog(
    cache_db: SQLiteCache,
    roots: Sequence[Path],
    args: BuildOptions,
    progress: FinalizationReporter | None = None,
) -> CatalogAssembly:
    """Rebuild the canonical streamed catalog and its dependency hashes."""
    referenced_assets = SQLitePathSet(cache_db, "asset")

    route_roots = make_route_roots(roots)
    image_id_hex_length = args.client.image_id_bits // 4
    cache_db.scan.reset_catalog_records()
    catalog_count = 0
    video_count = 0
    video_compatibility_warning_count = 0
    video_transcoded_count = 0
    video_transcode_failed_count = 0
    catalog_dependency_hashers: dict[int, Any] = {}
    exif_dependency_hashers: dict[int, Any] = {}
    geometry_dependency_hashers: dict[int, Any] = {}
    id_dependency_hashers: dict[str, Any] = {}
    catalog_batch: list[CatalogInsertRecord] = []
    asset_path_batch: list[str] = []
    link_prefix_paths: dict[str, Path] = {}

    def flush_staged_rows() -> None:
        if catalog_batch:
            cache_db.scan.add_catalog_records(catalog_batch)
            catalog_batch.clear()
        if asset_path_batch:
            referenced_assets.update(asset_path_batch)
            asset_path_batch.clear()

    for _ordinal, path, entry in cache_db.scan.iter_success_entries():
        # Reconstruct derivable fields without retaining a full gallery-sized
        # Python record list.
        if entry["kind"] == "static":
            entry["variants"] = expected_static_variants(
                str(entry["sha256"]),
                int(entry["width"]),
                args.render.widths,
                args.render.output_format,
            )
            if args.output_settings.include_originals and not entry.get("original_asset"):
                entry["original_asset"] = expected_original_asset(
                    str(entry["sha256"]),
                    str(entry.get("source_suffix") or path.suffix),
                )
        else:
            entry["asset"] = expected_passthrough_asset(
                str(entry["sha256"]),
                str(entry.get("source_suffix") or path.suffix),
            )
            if entry["kind"] == "video" and entry.get("poster_asset"):
                entry["poster_asset"] = expected_video_poster(
                    str(entry["sha256"])
                )
            if (
                entry["kind"] == "video"
                and args.output_settings.transcode_incompatible_videos
                and entry.get("video_transcode_status") == "ready"
                and entry.get("video_transcode_asset")
            ):
                entry["video_transcode_asset"] = (
                    expected_video_transcode_asset(str(entry["sha256"]))
                )
            if args.output_settings.include_originals:
                entry["original_asset"] = entry["asset"]

        source_root, root_relative = matched_root_directory(path, roots)
        stable_id = stable_image_id(path)
        stable_link_id = stable_id[:image_id_hex_length]
        record_display_name = display_name(path, roots)
        dependency_record = {
            "catalog_id": catalog_count,
            "path": path,
            "stable_id": stable_id,
            "display_name": record_display_name,
            "source_root": (
                str(source_root) if source_root is not None else None
            ),
            "root_relative": (
                str(root_relative) if root_relative is not None else None
            ),
            "entry": entry,
        }
        catalog_item = compact_catalog_item(dependency_record)
        exif_item = compact_exif_item(dependency_record)
        geometry_ratio = max(
            1,
            round(
                int(entry["width"])
                * GEOMETRY_RATIO_SCALE
                / max(1, int(entry["height"]))
            ),
        )
        update_data_chunk_dependency(
            catalog_dependency_hashers,
            catalog_count // args.client.catalog_chunk_size,
            "catalog-v3",
            catalog_item,
        )
        update_data_chunk_dependency(
            exif_dependency_hashers,
            catalog_count // args.client.catalog_chunk_size,
            "exif-v8",
            exif_item,
        )
        update_data_chunk_dependency(
            geometry_dependency_hashers,
            catalog_count // args.client.geometry_chunk_size,
            "geometry-v5-binary",
            geometry_ratio,
        )
        update_data_chunk_dependency(
            id_dependency_hashers,
            stable_id[:2],
            "ids-v2",
            [stable_id, catalog_count],
        )

        collision_path = link_prefix_paths.get(stable_link_id)
        if collision_path is not None:
            raise RuntimeError(
                "stable image-link collision at "
                f"{args.client.image_id_bits} bits ({stable_link_id}): "
                f"{collision_path} and {path}; rerun with a larger "
                "--image-id-bits value"
            )
        link_prefix_paths[stable_link_id] = path
        catalog_batch.append(CatalogInsertRecord(
            catalog_id=catalog_count,
            path=str(path),
            stable_id=stable_id,
            link_prefix=stable_link_id,
            display_name=record_display_name,
            source_root=(str(source_root) if source_root is not None else None),
            root_relative=(
                str(root_relative) if root_relative is not None else None
            ),
            catalog_item_json=_json_dumps(catalog_item),
            geometry_ratio=geometry_ratio,
        ))

        catalog_count += 1
        if progress is not None and catalog_count % 256 == 0:
            progress.update(catalog_count)

        if entry["kind"] == "video":
            video_count += 1
            if entry.get("video_compatibility_note"):
                video_compatibility_warning_count += 1
            if (
                args.output_settings.transcode_incompatible_videos
                and entry.get("video_transcode_status") == "ready"
                and entry.get("video_transcode_asset")
            ):
                video_transcoded_count += 1
            elif (
                args.output_settings.transcode_incompatible_videos
                and entry.get("video_compatibility_note")
                and entry.get("video_transcode_status") == "failed"
            ):
                video_transcode_failed_count += 1

        if entry["kind"] == "static":
            asset_path_batch.extend(
                str(item["path"]) for item in entry["variants"]
            )
        else:
            asset_path_batch.append(str(entry["asset"]))
            if entry["kind"] == "video" and entry.get("poster_asset"):
                asset_path_batch.append(str(entry["poster_asset"]))
            if (
                entry["kind"] == "video"
                and args.output_settings.transcode_incompatible_videos
                and entry.get("video_transcode_status") == "ready"
                and entry.get("video_transcode_asset")
            ):
                asset_path_batch.append(str(entry["video_transcode_asset"]))

        if args.output_settings.include_originals:
            original_asset = entry.get("original_asset")
            if not original_asset:
                raise RuntimeError(
                    f"original asset missing after successful processing: {path}"
                )
            asset_path_batch.append(str(original_asset))

        if (
            len(catalog_batch) >= CATALOG_SQL_BATCH_SIZE
            or len(asset_path_batch) >= CATALOG_SQL_BATCH_SIZE * 4
        ):
            flush_staged_rows()

    flush_staged_rows()
    directory_pages = build_directory_pages(
        cache_db.scan.iter_catalog_records(),
        route_roots,
    )
    catalog_dependency_signatures = finalized_data_chunk_dependencies(
        catalog_dependency_hashers
    )
    exif_dependency_signatures = finalized_data_chunk_dependencies(
        exif_dependency_hashers
    )
    geometry_dependency_signatures = finalized_data_chunk_dependencies(
        geometry_dependency_hashers
    )
    id_dependency_signatures = finalized_data_chunk_dependencies(
        id_dependency_hashers
    )
    if progress is not None:
        progress.update(catalog_count)

    return CatalogAssembly(
        referenced_assets=referenced_assets,
        route_roots=route_roots,
        catalog_count=catalog_count,
        video_count=video_count,
        video_compatibility_warning_count=video_compatibility_warning_count,
        video_transcoded_count=video_transcoded_count,
        video_transcode_failed_count=video_transcode_failed_count,
        page_record_ranges=directory_pages.record_ranges,
        child_pages=directory_pages.child_pages,
        page_meta=directory_pages.page_meta,
        catalog_dependency_signatures=catalog_dependency_signatures,
        exif_dependency_signatures=exif_dependency_signatures,
        geometry_dependency_signatures=geometry_dependency_signatures,
        id_dependency_signatures=id_dependency_signatures,
    )


def _summary_publication_lines(summary: BuildSummary) -> list[str]:
    contract = summary.contract_validation
    credit = (
        f"enabled ({BUILDER_SCRIPT_COPY}, "
        f"{'rewritten' if summary.builder_script_rewritten else 'unchanged'})"
        if summary.credit_enabled
        else "disabled"
    )
    lines = [
        "",
        f"Gallery:  {summary.index_path}",
        f"Media:    {summary.catalog_count}",
        f"Pages:    {summary.generated_page_count}",
        f"HTML:     {summary.rewritten_page_count} page(s) rewritten",
        f"Client:   {summary.client_assets_rewritten} CSS/JS file(s) rewritten",
        f"Credit:   {credit}",
        f"Data:     {summary.data_file_count} file(s), "
        f"{summary.rewritten_data_count} rewritten",
        f"Data check: {summary.validated_data_count} generated data file(s) "
        "(cached identities or content hashes)",
        "Contract: "
        f"{contract['pages']} page(s), {contract['chunks']} chunk reference(s), "
        f"{contract['client_assets']} client asset(s) verified",
        f"Publish retention: {summary.retained_publish_generations} complete "
        f"generation(s), {summary.retained_publish_paths} data/asset path(s) retained",
        f"Build info: {GALLERY_BUILD_INFO} "
        f"({'rewritten' if summary.build_info_rewritten else 'unchanged'})",
        f"Deploy info: {GALLERY_DEPLOYMENT_INFO} "
        f"({'rewritten' if summary.deployment_info_rewritten else 'unchanged'})",
        f"Updated:  {summary.changed_count}",
        f"Cached:   {summary.reused_count}",
        f"Skipped:  {summary.skipped_count}",
        f"Removed:  {summary.removed_asset_count} stale asset(s)",
        f"Removed:  {summary.removed_page_count} stale page(s)",
        f"Data GC:  {summary.removed_data_count} stale data file(s)",
        f"Cache:    {summary.cache_path}",
    ]
    if summary.migrated_sqlite_cache:
        lines.append(f"Cache:    renamed {LEGACY_SQLITE_CACHE} -> {DEFAULT_CACHE}")
    lines.extend((
        f"Cache rows: {summary.cache_rows}",
        f"Cache pruned: {summary.pruned_cache_entries}",
    ))
    return lines


def _summary_feature_lines(args: BuildOptions, summary: BuildSummary) -> list[str]:
    custom_html = [
        name
        for name, enabled in (
            ("header", bool(args.output_settings.header_html.strip())),
            ("footer", bool(args.output_settings.footer_html.strip())),
        )
        if enabled
    ]
    password_gate = "disabled"
    if summary.password_config is not None:
        timeout = args.password_settings.password_session_timeout
        duration = (
            f"relocks after {timeout:g} minute(s)"
            if timeout > 0
            else "unlocked for tab session"
        )
        password_gate = f"enabled (client-side; {duration})"
    transcodes = "disabled"
    if args.output_settings.transcode_incompatible_videos:
        transcodes = (
            f"enabled; {summary.video_transcoded_count} ready, "
            f"{summary.video_transcode_failed_count} failed"
        )
    return [
        f"Static format: {args.render.output_format}",
        "Durable writes: "
        + ("enabled" if args.cache_settings.durable_writes else "disabled"),
        "Layout worker: "
        + ("enabled" if args.client.layout_worker else "disabled"),
        "Catalog cache: "
        + (
            f"{args.client.max_hot_catalog_chunks} chunks"
            if args.client.max_hot_catalog_chunks
            else "adaptive"
        ),
        "Tile loads: "
        + (
            f"{args.client.max_active_tile_loads} concurrent"
            if args.client.max_active_tile_loads is not None
            else "adaptive (4/6/8 concurrent)"
        ),
        f"Image margin: {args.client.image_load_margin}px",
        f"Tooltip delay: {args.client.tooltip_delay}ms",
        f"Lightbox opacity: {args.client.lightbox_opacity:g}",
        "Static originals: "
        + (
            "included with lightbox download"
            if args.output_settings.include_originals
            else "not included"
        ),
        f"Videos:   {summary.video_count} original(s) copied unchanged; "
        f"posters optional; {summary.video_compatibility_warning_count} "
        "portability warning(s)",
        f"Video compatibility transcodes: {transcodes}",
        "Custom HTML: " + (", ".join(custom_html) if custom_html else "disabled"),
        f"Password gate: {password_gate}",
        f"Image link IDs: {args.client.image_id_bits} bits "
        f"({args.client.image_id_bits // 4} hex characters)",
        "Client diagnostics: "
        + ("enabled" if args.client.client_diagnostics else "disabled"),
    ]


def _summary_runtime_lines(args: BuildOptions, summary: BuildSummary) -> list[str]:
    lines = [
        f"Python:   {sys.version_info.major}.{sys.version_info.minor}."
        f"{sys.version_info.micro} ({python_threading_mode()}); "
        f"{usable_cpu_count()} usable CPU(s)",
        f"Encoder threads/image: {summary.toolchain.encoder_threads}",
        "Working memory: "
        f"{summary.toolchain.working_memory_budget_bytes // (1024 * 1024)} MiB "
        + ("(adaptive)" if args.render.max_working_memory_mib == 0 else "(explicit)"),
        "Working-memory peak: "
        f"{human_bytes(summary.working_memory_peak_bytes)} reserved",
    ]
    if summary.preflight_result is not None:
        preflight = summary.preflight_result
        lines.append(
            "Storage preflight: "
            f"{preflight['estimated_new_files']} new file(s), "
            f"{human_bytes(preflight['estimated_output_bytes'])} estimated"
        )
    if summary.lock_wait_seconds >= 0.01:
        lines.append(f"Build lock wait: {summary.lock_wait_seconds:.2f}s")
    lines.extend((
        "JPEG reduced decode: "
        + ("enabled" if args.render.jpeg_draft else "disabled"),
        "Asset verification: "
        + (
            "filesystem checks"
            if args.cache_settings.verify_assets
            else "trusted after clean runs"
        ),
    ))
    if summary.bootstrapped_generated_files:
        lines.append(
            f"Output inventory imported: {summary.bootstrapped_generated_files} file(s)"
        )
    if summary.orphan_temp_dirs_removed:
        lines.append(
            "Temporary cleanup: "
            f"{summary.orphan_temp_dirs_removed} abandoned workspace(s) removed"
        )
    if summary.inventory_sync_skipped:
        lines.append("Output inventory: unchanged (sync skipped)")
    if summary.migrated_cache_entries:
        suffix = "y" if summary.migrated_cache_entries == 1 else "ies"
        lines.append(
            f"Cache migrated: {summary.migrated_cache_entries} legacy JSON entr{suffix}"
        )
    interval = args.cache_settings.cache_sync_interval
    lines.append(
        f"Cache sync: every {interval:g}s ({summary.cache_sync_count} checkpoint(s))"
        if interval > 0
        else "Cache sync: disabled"
    )
    lines.append(
        "Timing:   "
        f"scan/cache {summary.scan_cache_elapsed:.2f}s | "
        f"process {summary.processing_elapsed:.2f}s | "
        f"gallery {summary.gallery_elapsed:.2f}s | "
        f"cleanup {summary.cleanup_elapsed:.2f}s | "
        f"total {summary.total_elapsed:.2f}s"
    )
    return lines


def build_human_summary_lines(
    args: BuildOptions,
    summary: BuildSummary,
) -> list[str]:
    """Render the human-readable result without writing to stdout."""
    return [
        *_summary_publication_lines(summary),
        *_summary_feature_lines(args, summary),
        *_summary_runtime_lines(args, summary),
    ]


def print_build_summary(
    args: BuildOptions,
    summary: BuildSummary,
) -> None:
    print("\n".join(build_human_summary_lines(args, summary)))


def build_machine_report(
    args: BuildOptions,
    summary: BuildSummary,
) -> MachineReport:
    """Return the machine report independently from console formatting."""
    return {
        "mode": "build",
        "status": "completed",
        "format": args.render.output_format,
        "images": summary.catalog_count,
        "videos": {
            "count": summary.video_count,
            "compatibility_warnings": (
                summary.video_compatibility_warning_count
            ),
            "transcoding_enabled": bool(
                args.output_settings.transcode_incompatible_videos
            ),
            "transcoded": summary.video_transcoded_count,
            "transcode_failed": summary.video_transcode_failed_count,
        },
        "pages": summary.generated_page_count,
        "counts": {
            "updated": summary.changed_count,
            "cached": summary.reused_count,
            "skipped": summary.skipped_count,
            "html_rewritten": summary.rewritten_page_count,
            "client_assets_rewritten": summary.client_assets_rewritten,
            "data_files": summary.data_file_count,
            "data_rewritten": summary.rewritten_data_count,
            "assets_removed": summary.removed_asset_count,
            "pages_removed": summary.removed_page_count,
            "data_removed": summary.removed_data_count,
            "retained_publish_generations": (
                summary.retained_publish_generations
            ),
            "retained_publish_paths": summary.retained_publish_paths,
            "cache_rows": summary.cache_rows,
            "cache_pruned": summary.pruned_cache_entries,
        },
        "plan": summary.rebuild_plan,
        "storage_preflight": summary.preflight_result,
        "working_memory": {
            "budget_bytes": summary.toolchain.working_memory_budget_bytes,
            "peak_reserved_bytes": summary.working_memory_peak_bytes,
        },
        "lock_wait_seconds": summary.lock_wait_seconds,
        "custom_html": {
            "header": bool(args.output_settings.header_html.strip()),
            "footer": bool(args.output_settings.footer_html.strip()),
        },
        "password_protected": summary.password_config is not None,
        "password_source": args.password_settings.password_source,
        "password_session_timeout_minutes": args.password_settings.password_session_timeout,
        "deployment_manifest": GALLERY_DEPLOYMENT_INFO,
        "contract_validation": summary.contract_validation,
        "timing_seconds": {
            "scan_cache": summary.scan_cache_elapsed,
            "process": summary.processing_elapsed,
            "gallery": summary.gallery_elapsed,
            "cleanup": summary.cleanup_elapsed,
            "total": summary.total_elapsed,
        },
    }


def emit_build_summary(
    args: BuildOptions,
    summary: BuildSummary,
) -> None:
    """Emit synchronized human and machine representations of one result."""
    print_build_summary(args, summary)
    emit_machine_report(args.command.report_json, build_machine_report(args, summary))



def publish_gallery_pages(context: PagePublicationContext) -> PagePublicationResult:
    """Publish root and album descriptors/pages from one validated context."""
    args = context.args
    output_dir = context.output_dir
    roots = context.roots
    cache_db = context.cache_db
    catalog = context.catalog
    catalog_count = catalog.catalog_count
    route_roots = catalog.route_roots
    child_pages = catalog.child_pages
    page_record_ranges = catalog.page_record_ranges
    page_meta = catalog.page_meta
    data_revision = context.data_revision
    revision_index_rel = context.revision_index_rel
    revision_index_revision = context.revision_index_revision
    revision_payload = context.revision_payload
    client_assets = context.client_assets
    app_assets_enabled = context.app_assets_enabled
    credit_enabled = context.credit_enabled
    password_config = context.password_config
    build_date = context.build_date
    trust_generated_outputs = context.trust_generated_outputs
    referenced_pages = context.referenced_pages
    referenced_gallery_data = context.referenced_gallery_data
    progress = context.progress
    generated_page_count = 0
    rewritten_page_count = 0
    rewritten_data_count = 0

    # Write each compact page descriptor before rewriting its HTML. Canonical
    # ratio/stable-ID geometry is shared globally across every page.
    client_settings = ClientSettings(
        target_row_height=args.render.target_row_height,
        gap=args.render.gap,
        requested_widths=tuple(args.render.widths),
        catalog_chunk_size=args.client.catalog_chunk_size,
        geometry_chunk_size=args.client.geometry_chunk_size,
        data_revision=data_revision,
        revision_index_rel=revision_index_rel,
        revision_index_revision=revision_index_revision,
        static_output_format=args.render.output_format,
        max_active_tile_loads=args.client.max_active_tile_loads,
        image_load_margin=args.client.image_load_margin,
        tooltip_delay=args.client.tooltip_delay,
        client_diagnostics=args.client.client_diagnostics,
        image_id_bits=args.client.image_id_bits,
        include_originals=args.output_settings.include_originals,
        transcode_incompatible_videos=args.output_settings.transcode_incompatible_videos,
        max_hot_catalog_chunks=args.client.max_hot_catalog_chunks,
        max_client_metadata_mib=args.client.max_client_metadata_mib,
        layout_worker=args.client.layout_worker,
        layout_worker_asset=client_assets["layout_worker"],
        random_order=context.random_order,
    )
    root_page_config = gallery_page_config(
        page_dir=Path("."),
        image_count=catalog_count,
        name_prefix="",
        settings=client_settings,
    )
    root_descriptor = write_page_descriptor(
        output_dir=output_dir,
        route=Path("."),
        record_ranges=[(0, catalog_count)] if catalog_count else [],
        page_config=root_page_config,
        cache_db=cache_db,
        trust_generated=trust_generated_outputs,
        revision_payload=revision_payload,
    )
    referenced_gallery_data.update(root_descriptor.referenced_paths)
    rewritten_data_count += root_descriptor.rewritten_count

    root_children: list[tuple[str, str]] = []
    directory_roots = [root for root in roots if root.is_dir()]
    if len(directory_roots) == 1:
        root_route = route_roots[directory_roots[0]]
        for label, route in child_pages.get(root_route, []):
            href = relative_output_url(Path("."), route / "index.html")
            root_children.append((label, href))
    else:
        for root in directory_roots:
            route = route_roots[root]
            href = relative_output_url(Path("."), route / "index.html")
            root_children.append((root.name, href))
        root_children.sort(key=lambda item: natural_key(item[0]))

    index_path = output_dir / "index.html"
    root_page = build_html(HtmlPage(
        title=args.output_settings.title,
        page_descriptor_href=relative_output_url(
            Path("."), root_descriptor.relative_path
        ),
        heading=args.output_settings.title,
        image_count=catalog_count,
        home_href=None,
        parent_href=None,
        parent_label=None,
        child_links=tuple(root_children),
        page_dir=Path("."),
        app_title=args.output_settings.title,
        app_assets_enabled=app_assets_enabled,
        client_assets=client_assets,
        show_credit=credit_enabled,
        include_originals=args.output_settings.include_originals,
        header_html=args.output_settings.header_html,
        footer_html=args.output_settings.footer_html,
        password_config=password_config,
        build_date=build_date,
    ))
    if write_html(
        index_path,
        root_page,
        skip_unchanged=args.cache_settings.skip_unchanged_html,
    ):
        rewritten_page_count += 1
    referenced_pages.add("index.html")
    generated_page_count += 1
    if progress is not None:
        progress.update(generated_page_count)

    # Album pages keep only compact catalog-ID ranges. Names and asset metadata
    # remain canonical in data/items/, while geometry is shared in data/geometry/.
    for route, scoped_ranges in sorted(
        page_record_ranges.items(),
        key=lambda item: natural_key(item[0].as_posix()),
    ):
        if str(route) in {"", "."}:
            continue

        page_dir = route
        source_root, local_dir = page_meta[route]

        if str(local_dir) == ".":
            heading = source_root.name
        else:
            heading = local_dir.as_posix()

        parent_href: str | None
        parent_label: str | None
        if str(local_dir) == ".":
            parent_href = relative_output_url(
                page_dir,
                Path("index.html"),
            )
            parent_label = "All images"
        else:
            parent_local = local_dir.parent
            parent_route = route_roots[source_root] / (
                parent_local if str(parent_local) != "." else Path()
            )
            parent_href = relative_output_url(
                page_dir,
                parent_route / "index.html",
            )
            parent_label = (
                "Albums"
                if str(parent_local) == "."
                else parent_local.name
            )

        child_links = [
            (
                label,
                relative_output_url(
                    page_dir,
                    child_route / "index.html",
                ),
            )
            for label, child_route in child_pages.get(route, [])
        ]

        child_image_count = catalog_range_count(scoped_ranges)
        page_config = gallery_page_config(
            page_dir=page_dir,
            image_count=child_image_count,
            name_prefix=(
                ""
                if str(local_dir) == "."
                else local_dir.as_posix()
            ),
            settings=client_settings,
        )
        descriptor = write_page_descriptor(
            output_dir=output_dir,
            route=route,
            record_ranges=scoped_ranges,
            page_config=page_config,
            cache_db=cache_db,
            trust_generated=trust_generated_outputs,
            revision_payload=revision_payload,
        )
        referenced_gallery_data.update(descriptor.referenced_paths)
        rewritten_data_count += descriptor.rewritten_count

        page = build_html(HtmlPage(
            title=f"{args.output_settings.title} — {heading}",
            page_descriptor_href=relative_output_url(
                page_dir, descriptor.relative_path
            ),
            heading=heading,
            image_count=child_image_count,
            home_href=relative_output_url(
                page_dir,
                Path("index.html"),
            ),
            parent_href=parent_href,
            parent_label=parent_label,
            child_links=tuple(child_links),
            page_dir=page_dir,
            app_title=args.output_settings.title,
            app_assets_enabled=app_assets_enabled,
            client_assets=client_assets,
            show_credit=credit_enabled,
            include_originals=args.output_settings.include_originals,
            header_html=args.output_settings.header_html,
            footer_html=args.output_settings.footer_html,
            password_config=password_config,
            build_date=build_date,
        ))

        page_output_path = output_dir / route / "index.html"
        ensure_output_directory(page_output_path.parent)
        if write_html(
            page_output_path,
            page,
            skip_unchanged=args.cache_settings.skip_unchanged_html,
        ):
            rewritten_page_count += 1

        referenced_pages.add((route / "index.html").as_posix())
        generated_page_count += 1
        if progress is not None:
            progress.update(generated_page_count)

    return PagePublicationResult(
        index_path=index_path,
        generated_page_count=generated_page_count,
        rewritten_page_count=rewritten_page_count,
        rewritten_data_count=rewritten_data_count,
    )


def html_build_signature(
    *,
    args: BuildOptions,
    catalog: CatalogAssembly,
    data_revision: str,
    client_assets: dict[str, str],
    app_assets_enabled: bool,
    credit_enabled: bool,
    password_config: PasswordConfig | None,
) -> str:
    """Hash every input that can alter the generated HTML page set."""
    page_topology: list[dict[str, Any]] = []
    for route, ranges in sorted(
        catalog.page_record_ranges.items(),
        key=lambda item: natural_key(item[0].as_posix()),
    ):
        page_meta = catalog.page_meta.get(route)
        page_topology.append(
            {
                "route": route.as_posix(),
                "ranges": [[int(start), int(count)] for start, count in ranges],
                "children": [
                    [label, child_route.as_posix()]
                    for label, child_route in catalog.child_pages.get(route, [])
                ],
                "heading": (
                    [page_meta[0].name, page_meta[1].as_posix()]
                    if page_meta is not None
                    else None
                ),
            }
        )

    payload = {
        "html_template_version": HTML_TEMPLATE_VERSION,
        "data_revision": data_revision,
        "title": args.output_settings.title,
        "header_html": args.output_settings.header_html,
        "footer_html": args.output_settings.footer_html,
        "include_originals": bool(args.output_settings.include_originals),
        "password_config": password_config,
        "client_assets": client_assets,
        "app_assets_enabled": app_assets_enabled,
        "credit_enabled": credit_enabled,
        "root_routes": sorted(
            [root.name, route.as_posix()]
            for root, route in catalog.route_roots.items()
        ),
        "page_topology": page_topology,
    }
    return hashlib.sha256(_json_dumps(payload).encode("utf-8")).hexdigest()


def publish_gallery_shell(
    *,
    args: BuildOptions,
    output_dir: Path,
    referenced_assets: Any,
    referenced_pages: SQLitePathSet,
) -> GalleryShellPublication:
    """Publish browser assets, optional credit source, and app icons."""
    client_publication = ensure_gallery_client_assets(
        output_dir,
        args.client.lightbox_opacity,
    )
    client_assets = client_publication.assets
    referenced_assets.update(client_assets.values())
    for legacy_client_asset in (
        spec.legacy_path for spec in CLIENT_ASSET_SPECS if spec.legacy_path
    ):
        if (output_dir / legacy_client_asset).is_file():
            referenced_assets.add(legacy_client_asset)
    for pattern in (spec.immutable_glob() for spec in CLIENT_ASSET_SPECS):
        for prior_client in (output_dir / ASSET_DIRNAME).glob(pattern):
            if prior_client.is_file() and not prior_client.is_symlink():
                referenced_assets.add(
                    prior_client.relative_to(output_dir).as_posix()
                )

    credit_enabled = not args.output_settings.no_credit
    builder_script_rewritten = False
    if credit_enabled:
        builder_script_rewritten = ensure_builder_script_copy(output_dir)
        referenced_assets.add(BUILDER_SCRIPT_COPY)

    app_assets_enabled = ensure_app_assets(
        output_dir,
        args.output_settings.title,
    )
    if app_assets_enabled:
        referenced_assets.update(APP_ICON_ASSETS)
        referenced_pages.add(APP_MANIFEST)

    return GalleryShellPublication(
        client_publication.rewritten_count,
        client_publication.css_version,
        client_assets,
        credit_enabled,
        builder_script_rewritten,
        app_assets_enabled,
    )


def publish_gallery_data(
    *,
    args: BuildOptions,
    output_dir: Path,
    cache_db: SQLiteCache,
    catalog: CatalogAssembly,
    trust_generated_outputs: bool,
) -> GalleryDataPublication:
    """Publish all chunk families and their generation index."""
    catalog_chunks = write_catalog_chunks(
        output_dir,
        catalog.catalog_count,
        args.client.catalog_chunk_size,
        catalog.catalog_dependency_signatures,
        cache_db,
        trust_generated_outputs,
    )
    exif_chunks = write_exif_chunks(
        output_dir,
        catalog.catalog_count,
        args.client.catalog_chunk_size,
        catalog.exif_dependency_signatures,
        cache_db,
        trust_generated_outputs,
    )
    geometry_chunks = write_global_geometry_chunks(
        output_dir,
        catalog.catalog_count,
        args.client.geometry_chunk_size,
        catalog.geometry_dependency_signatures,
        cache_db,
        trust_generated_outputs,
    )
    id_chunks = write_stable_id_index(
        output_dir,
        catalog.id_dependency_signatures,
        cache_db,
        trust_generated_outputs,
    )
    order = write_random_group_order(
        output_dir,
        cache_db,
        catalog.catalog_count,
        args.client.random_group_suffix_regex,
        trust_generated_outputs,
        args.client.allow_unsorted,
    )
    revision_index = write_chunk_revision_index(
        output_dir,
        catalog_chunks.revisions,
        exif_chunks.revisions,
        geometry_chunks.revisions,
        id_chunks.revisions,
        cache_db,
        trust_generated_outputs,
    )
    revision_settings = {
        "data_format_version": GALLERY_DATA_FORMAT_VERSION,
        "geometry_chunk_size": args.client.geometry_chunk_size,
        "page_bootstrap_catalog_items": PAGE_BOOTSTRAP_CATALOG_ITEMS,
        "page_bootstrap_geometry_items": PAGE_BOOTSTRAP_GEOMETRY_ITEMS,
        "layout_segment_size": DEFAULT_LAYOUT_SEGMENT_SIZE,
        "segmented_geometry_threshold": SEGMENTED_GEOMETRY_THRESHOLD,
        "geometry_compatibility_eager_limit": GEOMETRY_COMPATIBILITY_EAGER_LIMIT,
        "target_row_height": args.render.target_row_height,
        "gap": args.render.gap,
        "manifest_chunk_size": args.client.catalog_chunk_size,
        "widths": args.render.widths,
        "static_output_format": args.render.output_format,
        "max_active_tile_loads": args.client.max_active_tile_loads,
        "image_load_margin": args.client.image_load_margin,
        "tooltip_delay": args.client.tooltip_delay,
        "client_diagnostics": bool(args.client.client_diagnostics),
        "image_id_bits": args.client.image_id_bits,
        "include_originals": bool(args.output_settings.include_originals),
        "transcode_incompatible_videos": bool(
            args.output_settings.transcode_incompatible_videos
        ),
        "max_hot_catalog_chunks": args.client.max_hot_catalog_chunks,
        "max_client_metadata_mib": args.client.max_client_metadata_mib,
        "layout_worker": bool(args.client.layout_worker),
        "layout_worker_version": LAYOUT_WORKER_VERSION,
        "asset_shard_levels": ASSET_SHARD_LEVELS,
        "asset_shard_width": ASSET_SHARD_WIDTH,
        "geometry_ratio_scale": GEOMETRY_RATIO_SCALE,
        "allow_unsorted": bool(args.client.allow_unsorted),
        "random_group_suffix_regex": (
            args.client.random_group_suffix_regex
            if args.client.allow_unsorted
            else None
        ),
        "random_order_binary_revision": order.config.get("binaryRevision"),
        "random_order_fallback_revision": order.config.get("fallbackRevision"),
    }
    data_revision = hashlib.sha256(
        (
            f"{revision_index.revision};"
            + json.dumps(
                revision_settings,
                sort_keys=True,
                separators=(",", ":"),
            )
        ).encode("utf-8")
    ).hexdigest()[:12]
    referenced = set().union(
        catalog_chunks.referenced_paths,
        exif_chunks.referenced_paths,
        geometry_chunks.referenced_paths,
        id_chunks.referenced_paths,
        order.referenced_paths,
        {revision_index.relative_path},
    )
    revision_payload = {
        "catalog": {str(k): v for k, v in catalog_chunks.revisions.items()},
        "exif": {str(k): v for k, v in exif_chunks.revisions.items()},
        "geometry": {str(k): v for k, v in geometry_chunks.revisions.items()},
        "ids": id_chunks.revisions,
    }
    return GalleryDataPublication(
        referenced,
        catalog_chunks.rewritten_count
        + exif_chunks.rewritten_count
        + geometry_chunks.rewritten_count
        + id_chunks.rewritten_count
        + order.rewritten_count
        + int(revision_index.rewritten),
        order.config,
        revision_index,
        data_revision,
        revision_payload,
    )


def publish_gallery(
    *,
    args: BuildOptions,
    output_dir: Path,
    roots: Sequence[Path],
    cache_db: SQLiteCache,
    generation: int,
    password_config: PasswordConfig | None,
    previous_run_complete: bool,
    trust_generated_outputs: bool,
    toolchain: ToolchainCapabilities,
    progress: FinalizationReporter | None = None,
) -> GalleryPublicationResult:
    """Assemble, publish, and validate one immutable gallery generation."""
    # Phase 4: assemble canonical catalog data and publish one generation.
    if progress is not None:
        progress.begin("Assembling catalog")
    catalog = assemble_catalog(cache_db, roots, args, progress)
    referenced_assets = catalog.referenced_assets

    referenced_pages = SQLitePathSet(cache_db, "page")
    referenced_gallery_data = SQLitePathSet(cache_db, "data")
    rewritten_data_count = 0

    if progress is not None:
        progress.begin("Publishing client assets")
    shell = publish_gallery_shell(
        args=args,
        output_dir=output_dir,
        referenced_assets=referenced_assets,
        referenced_pages=referenced_pages,
    )

    if progress is not None:
        progress.begin("Publishing shared gallery data")
    data = publish_gallery_data(
        args=args,
        output_dir=output_dir,
        cache_db=cache_db,
        catalog=catalog,
        trust_generated_outputs=trust_generated_outputs,
    )
    referenced_gallery_data.update(data.referenced_paths)
    rewritten_data_count += data.rewritten_count
    data_revision = data.data_revision
    revision_index_rel = data.revision_index.relative_path
    revision_index_revision = data.revision_index.revision
    build_date = resolve_html_build_date(
        cache_db,
        html_build_signature(
            args=args,
            catalog=catalog,
            data_revision=data_revision,
            client_assets=shell.client_assets,
            app_assets_enabled=shell.app_assets_enabled,
            credit_enabled=shell.credit_enabled,
            password_config=password_config,
        ),
        previous_run_complete,
    )

    if progress is not None:
        album_page_count = sum(
            1
            for route in catalog.page_record_ranges
            if str(route) not in {"", "."}
        )
        progress.begin("Publishing gallery pages", album_page_count + 1)
    page_publication = publish_gallery_pages(PagePublicationContext(
        args=args,
        output_dir=output_dir,
        roots=tuple(roots),
        cache_db=cache_db,
        catalog=catalog,
        data_revision=data_revision,
        revision_index_rel=revision_index_rel,
        revision_index_revision=revision_index_revision,
        revision_payload=data.revision_payload,
        random_order=data.random_order,
        client_assets=shell.client_assets,
        app_assets_enabled=shell.app_assets_enabled,
        credit_enabled=shell.credit_enabled,
        password_config=password_config,
        build_date=build_date,
        trust_generated_outputs=trust_generated_outputs,
        referenced_pages=referenced_pages,
        referenced_gallery_data=referenced_gallery_data,
        progress=progress,
    ))
    index_path = page_publication.index_path
    generated_page_count = page_publication.generated_page_count
    rewritten_page_count = page_publication.rewritten_page_count
    rewritten_data_count += page_publication.rewritten_data_count

    if progress is not None:
        progress.begin("Validating generated data")
    validated_data_count = validate_generated_data_files(
        output_dir,
        referenced_gallery_data,
        verify_content=True,
        cache_db=cache_db,
        reuse_cached_validations=trust_generated_outputs,
    )
    build_info = write_build_provenance(output_dir, args, toolchain)
    referenced_gallery_data.add(build_info.relative_path)
    rewritten_data_count += int(build_info.rewritten)
    deployment_info = write_deployment_manifest(
        output_dir,
        args.output_settings.transcode_incompatible_videos,
        data_revision,
        revision_index_rel,
    )
    referenced_gallery_data.add(deployment_info.relative_path)
    rewritten_data_count += int(deployment_info.rewritten)
    if progress is not None:
        progress.begin("Validating gallery contract")
    contract_validation = validate_generated_gallery_contract(
        output_dir=output_dir,
        referenced_pages=referenced_pages,
        referenced_data=referenced_gallery_data,
        client_assets=shell.client_assets,
        revision_index_rel=revision_index_rel,
        revision_payload=data.revision_payload,
        data_revision=data_revision,
    )

    # Only a fully validated generation enters the deployment-retention set.
    # Keeping it in current_generated protects the immediately prior complete
    # generation from stale-file cleanup during rolling object-store publishes.
    if progress is not None:
        progress.begin("Retaining rollout generation")
    retained_publish_generations, retained_publish_paths = (
        cache_db.chunks.retain_data_generations(generation)
    )

    # Commit changed metadata input signatures in one SQLite transaction.
    # On the next clean no-op build, matching content-addressed chunks can
    # skip rewriting the already-generated JavaScript files.
    cache_db.chunks.flush_generated_signatures(generation)

    return GalleryPublicationResult(catalog, PublishedGallery(
        index_path=index_path,
        referenced_assets=referenced_assets,
        referenced_pages=referenced_pages,
        referenced_gallery_data=referenced_gallery_data,
        generated_page_count=generated_page_count,
        rewritten_page_count=rewritten_page_count,
        client_assets_rewritten=shell.client_assets_rewritten,
        credit_enabled=shell.credit_enabled,
        builder_script_rewritten=shell.builder_script_rewritten,
        rewritten_data_count=rewritten_data_count,
        validated_data_count=validated_data_count,
        contract_validation=contract_validation,
        retained_publish_generations=retained_publish_generations,
        retained_publish_paths=retained_publish_paths,
        build_info_rewritten=build_info.rewritten,
        deployment_info_rewritten=deployment_info.rewritten,
        data_revision=data_revision,
        build_info_revision=build_info.revision,
        deployment_info_revision=deployment_info.revision,
        revision_index_revision=revision_index_revision,
        gallery_css_version=shell.gallery_css_version,
        client_assets=shell.client_assets,
        app_assets_enabled=shell.app_assets_enabled,
    ))

def cleanup_generated_output(
    *,
    args: BuildOptions,
    output_dir: Path,
    cache_db: SQLiteCache,
    generation: int,
    catalog: CatalogAssembly,
    published: PublishedGallery,
) -> GeneratedCleanup:
    """Reconcile the generated inventory and remove only stale managed files."""
    inventory_revision = hashlib.sha256(
        _json_dumps(
            {
                "data_revision": published.data_revision,
                "build_provenance": published.build_info_revision,
                "deployment_manifest": published.deployment_info_revision,
                "chunk_revision_index": published.revision_index_revision,
                "asset_dir": ASSET_DIRNAME,
                "data_dir": GALLERY_DATA_DIR,
                "albums_dir": ALBUMS_DIRNAME,
                "builder_script": BUILDER_SCRIPT_COPY,
                "widths": args.render.widths,
                "pages": sorted(published.referenced_pages),
                "routes": sorted(
                    route.as_posix()
                    for route in catalog.page_record_ranges
                ),
                "app_assets": published.app_assets_enabled,
                "credit_enabled": published.credit_enabled,
                "include_originals": bool(args.output_settings.include_originals),
                "transcode_incompatible_videos": bool(
                    args.output_settings.transcode_incompatible_videos
                ),
                "video_transcode_version": VIDEO_TRANSCODE_VERSION,
                "client_css": published.gallery_css_version,
                "client_theme_js": THEME_JS_VERSION,
                "client_js": GALLERY_JS_VERSION,
                "layout_worker": LAYOUT_WORKER_VERSION,
                "client_assets": sorted(published.client_assets.values()),
            }
        ).encode("utf-8")
    ).hexdigest()[:16]

    previous_inventory_revision, inventory_has_stale = (
        cache_db.inventory.generated_inventory_state()
    )
    if (
        previous_inventory_revision == inventory_revision
        and not inventory_has_stale
    ):
        stale_generated: list[tuple[str, str]] = []
        inventory_sync_skipped = True
    else:
        stale_generated = cache_db.inventory.sync_current_generated_inventory(
            generation
        )
        inventory_sync_skipped = False

    removed_asset_count = 0
    removed_page_count = 0
    removed_data_count = 0
    if args.cache_settings.no_clean:
        cache_db.inventory.set_generated_inventory_state(
            inventory_revision,
            bool(stale_generated),
        )
    else:
        removed, cleared_paths = remove_stale_generated_files(
            output_dir,
            stale_generated,
        )
        cache_db.inventory.forget_generated_paths(cleared_paths)
        remaining_stale = len(stale_generated) - len(cleared_paths)
        cache_db.inventory.set_generated_inventory_state(
            inventory_revision,
            remaining_stale > 0,
        )
        removed_asset_count = removed.get("asset", 0)
        removed_page_count = removed.get("page", 0)
        removed_data_count = removed.get("data", 0)
        remove_legacy_bundle_roots(output_dir)

    if args.cache_settings.repair_permissions:
        normalize_output_permissions(
            output_dir,
            published.referenced_assets,
            (
                published.referenced_pages
                | published.referenced_gallery_data
            ),
        )

    return GeneratedCleanup(
        data_file_count=len(published.referenced_gallery_data),
        removed_asset_count=removed_asset_count,
        removed_page_count=removed_page_count,
        removed_data_count=removed_data_count,
        inventory_sync_skipped=inventory_sync_skipped,
    )


def finalize_build_cache(
    cache_db: SQLiteCache,
    total: int,
) -> CacheFinalization:
    """Commit final scan state, optimize, and close the build cache."""
    pruned_cache_entries = cache_db.scan.finalize_run_from_scan(total)
    cache_rows = cache_db.inventory.row_count()
    cache_db.inventory.optimize()
    cache_db.close()
    return CacheFinalization(
        cache_rows=cache_rows,
        pruned_cache_entries=pruned_cache_entries,
    )


@dataclass(slots=True)
class BuildSession:
    """Own one build's ordered phases, resources, state, and final reporting."""

    args: BuildOptions
    parser: argparse.ArgumentParser
    resolved_password: str | None
    run_started: float = field(init=False)
    scan_cache_started: float = field(init=False)
    output_dir: Path = field(init=False)
    cache_path: Path = field(init=False)
    legacy_cache_path: Path = field(init=False)
    roots: list[Path] = field(default_factory=list, init=False)
    cache_db: SQLiteCache | None = field(default=None, init=False)
    toolchain: ToolchainCapabilities = field(init=False)
    resources: BuildExecutionResources = field(init=False)
    build_lock: BuildLock = field(default_factory=BuildLock, init=False)
    total: int = field(default=0, init=False)
    signature: str = field(default="", init=False)
    compatible_signatures: set[str] = field(default_factory=set, init=False)
    previous_run_complete: bool = field(default=False, init=False)
    previous_widths: list[int] | None = field(default=None, init=False)
    widths_only_settings_change: bool = field(default=False, init=False)
    rebuild_plan: RebuildPlan | None = field(default=None, init=False)
    preflight_result: StoragePreflightResult | None = field(default=None, init=False)
    password_config: PasswordConfig | None = field(default=None, init=False)
    processing_options: ProcessingOptions = field(init=False)
    generation: int = field(default=0, init=False)
    migrated_sqlite_cache: bool = field(default=False, init=False)
    migrated_cache_entries: int = field(default=0, init=False)
    bootstrapped_generated_files: int = field(default=0, init=False)
    cache_writer: PeriodicCacheWriter | None = field(default=None, init=False)
    finalization_progress: FinalizationReporter | None = field(
        default=None,
        init=False,
    )
    processing_counters: ProcessingCounters = field(
        default_factory=ProcessingCounters,
        init=False,
    )
    scan_cache_elapsed: float = field(default=0.0, init=False)
    processing_elapsed: float = field(default=0.0, init=False)

    def _start_finalization_progress(self) -> FinalizationReporter:
        progress = FinalizationReporter(10)
        self.finalization_progress = progress
        return progress

    def _complete_finalization_progress(
        self,
        progress: FinalizationReporter,
    ) -> None:
        progress.complete()
        self.finalization_progress = None

    def _cache(self) -> SQLiteCache:
        if self.cache_db is None:
            raise CacheError("build cache is not open")
        return self.cache_db

    def _close_cache(self) -> None:
        if self.cache_db is None:
            return
        try:
            self.cache_db.close()
        finally:
            self.cache_db = None

    def prepare_runtime(self) -> None:
        _RUNTIME.cancel_event.clear()
        _RUNTIME.termination_signal = None
        self.run_started = time.monotonic()
        self.scan_cache_started = self.run_started
        self.resources = prepare_build_environment(self.args, self.parser)
        self.toolchain = self.resources.toolchain
        self.output_dir = self.args.output_settings.output.expanduser().resolve()

    def open_output_and_cache(self) -> int | None:
        args = self.args
        output_dir = self.output_dir
        if not args.command.dry_run:
            if args.command.doctor and not output_dir.is_dir():
                report: DoctorReport = {
                    "mode": "doctor",
                    "healthy": False,
                    "issues": [
                        f"gallery output directory does not exist: {output_dir}"
                    ],
                    "repairs": 0,
                    "cache": {"rows": 0},
                    "generated": {
                        "tracked_files": 0,
                        "missing_files": 0,
                        "empty_files": 0,
                        "untracked_files": 0,
                    },
                    "permissions": {
                        "file_mismatches": 0,
                        "directory_mismatches": 0,
                    },
                    "sample": {"checked": 0, "failures": 0},
                    "encoder": {
                        "format": args.render.output_format,
                        "available": False,
                    },
                }
                print_doctor_report(report)
                emit_machine_report(args.command.report_json, report)
                return 1
            ensure_output_directory(output_dir)
            try:
                acquire_build_lock(
                    self.build_lock,
                    output_dir,
                    args.cache_settings.lock_timeout,
                )
            except (OSError, RuntimeError) as exc:
                DIAGNOSTICS.error(str(exc), code="build-lock")
                emit_machine_report(args.command.report_json, {
                    "mode": "doctor" if args.command.doctor else "build",
                    "status": "lock-failed",
                    "lock_timeout_seconds": args.cache_settings.lock_timeout,
                    "error": str(exc),
                })
                return 2

        if args.output_settings.cache is None:
            self.cache_path = output_dir / DEFAULT_CACHE
            self.legacy_cache_path = output_dir / DEFAULT_LEGACY_CACHE
            self.migrated_sqlite_cache = (
                False
                if args.command.dry_run
                else migrate_legacy_sqlite_cache(output_dir, self.cache_path)
            )
        else:
            requested = args.output_settings.cache.expanduser().resolve()
            self.migrated_sqlite_cache = False
            if requested.suffix.casefold() == ".json":
                self.legacy_cache_path = requested
                self.cache_path = requested.with_suffix(".sqlite3")
            else:
                self.cache_path = requested
                self.legacy_cache_path = output_dir / DEFAULT_LEGACY_CACHE

        if not args.command.dry_run and not args.command.doctor:
            ensure_output_directory(output_dir / ASSET_DIRNAME)
        for raw in args.source.paths:
            try:
                self.roots.append(raw.expanduser().resolve(strict=True))
            except OSError:
                pass

        self.migrated_cache_entries = (
            0
            if args.command.dry_run
            else migrate_legacy_json_cache(
                self.cache_path,
                self.legacy_cache_path,
            )
        )
        dry_run_source = self.cache_path
        legacy_sqlite = output_dir / LEGACY_SQLITE_CACHE
        if (
            args.command.dry_run
            and args.output_settings.cache is None
            and not self.cache_path.is_file()
            and legacy_sqlite.is_file()
        ):
            dry_run_source = legacy_sqlite

        try:
            self.cache_db = (
                open_dry_run_cache(dry_run_source)
                if args.command.dry_run
                else SQLiteCache(self.cache_path)
            )
        except (sqlite3.DatabaseError, CacheError) as exc:
            if not args.command.doctor:
                if isinstance(exc, CacheError):
                    raise
                raise CacheError(f"cache database could not be opened: {exc}") from exc
            report = self._failed_cache_doctor_report(exc)
            print_doctor_report(report)
            emit_machine_report(args.command.report_json, report)
            return 1

        if args.command.doctor:
            report = run_doctor(
                self._cache(),
                output_dir,
                args.command.doctor_sample_size,
                args.command.doctor_repair,
                self.toolchain,
            )
            self._close_cache()
            print_doctor_report(report)
            emit_machine_report(args.command.report_json, report)
            return 0 if report["healthy"] else 1
        return None

    def _failed_cache_doctor_report(
        self,
        exc: sqlite3.DatabaseError | CacheError,
    ) -> DoctorReport:
        return {
            "mode": "doctor",
            "healthy": False,
            "issues": [f"cache database could not be opened: {exc}"],
            "repairs": 0,
            "cache": {
                "schema_version": None,
                "quick_check": [str(exc)],
                "run_complete": False,
                "rows": 0,
            },
            "generated": {
                "tracked_files": 0,
                "missing_files": 0,
                "empty_files": 0,
                "untracked_files": 0,
                "unsafe_tracked_paths": 0,
                "managed_symlinks": 0,
            },
            "permissions": {
                "file_mismatches": 0,
                "directory_mismatches": 0,
            },
            "sample": {
                "requested": int(self.args.command.doctor_sample_size),
                "checked": 0,
                "failures": 0,
            },
            "encoder": {
                "format": self.args.render.output_format,
                "available": False,
            },
        }

    def scan_and_plan(self) -> int | None:
        args = self.args
        cache = self._cache()
        cache.scan.prepare_transient_build_tables()
        self.total = scan_input_files(
            cache,
            args.source.paths,
            args.source.recursive,
            self.output_dir,
            (
                _RUNTIME.run_temp_dir / "dry-run-scan.sqlite3"
                if args.command.dry_run and _RUNTIME.run_temp_dir is not None
                else self.cache_path
            ),
        )
        self.signature = render_signature(
            args.render.widths,
            args.render.quality,
            args.render.effort,
            args.render.output_format,
        )
        self.compatible_signatures = encoding_compatible_render_signatures(
            args.render.widths,
            args.render.quality,
            args.render.effort,
            args.render.output_format,
        )
        cache_base_payload = build_cache_base_payload(
            args,
            self.roots,
            self.output_dir,
            self.signature,
            self.compatible_signatures,
            self.toolchain,
        )
        self.previous_run_complete = bool(
            cache.metadata.get("run_complete", False)
        )
        previous_settings = cache.metadata.get(
            "render_settings",
            {},
        )
        if isinstance(previous_settings, dict):
            raw_widths = previous_settings.get("widths")
            if isinstance(raw_widths, list):
                self.previous_widths = sorted(int(width) for width in raw_widths)
                self.widths_only_settings_change = (
                    self.previous_widths != args.render.widths
                    and previous_settings.get("quality") == args.render.quality
                    and previous_settings.get("effort") == args.render.effort
                    and previous_settings.get("format") == args.render.output_format
                )

        if (
            args.command.dry_run
            or args.cache_settings.storage_preflight
            or args.command.report_json is not None
        ):
            self.rebuild_plan = explain_rebuild_plan(
                cache_db=cache,
                output_dir=self.output_dir,
                requested_widths=args.render.widths,
                output_format=args.render.output_format,
                compatible_render_signatures=self.compatible_signatures,
                widths_only_settings_change=self.widths_only_settings_change,
                jpeg_draft=args.render.jpeg_draft,
                hash_all=args.cache_settings.hash_all,
                include_originals=args.output_settings.include_originals,
                transcode_incompatible_videos=(
                    args.output_settings.transcode_incompatible_videos
                ),
                verify_assets=args.cache_settings.verify_assets,
                toolchain=self.toolchain,
            )
        if args.command.dry_run:
            return self._finish_dry_run()

        if args.cache_settings.storage_preflight:
            assert self.rebuild_plan is not None
            try:
                self.preflight_result = storage_preflight(
                    self.output_dir,
                    self.rebuild_plan,
                )
            except OutputError as exc:
                DIAGNOSTICS.error(str(exc), code=exc.code)
                emit_machine_report(args.command.report_json, {
                    "mode": "build",
                    "status": "preflight-failed",
                    "format": args.render.output_format,
                    "plan": self.rebuild_plan,
                    "error": str(exc),
                })
                return 2

        self.password_config = password_gate_config(
            cache,
            self.resolved_password,
            round(args.password_settings.password_session_timeout * 60),
        )
        self.resolved_password = None
        self.bootstrapped_generated_files = (
            cache.inventory.bootstrap_generated_inventory(self.output_dir)
        )
        trust_outputs = (
            self.previous_run_complete and not args.cache_settings.verify_assets
        )
        self.processing_options = ProcessingOptions(
            output_dir=self.output_dir,
            requested_widths=tuple(args.render.widths),
            quality=args.render.quality,
            effort=args.render.effort,
            output_format=args.render.output_format,
            render_signature=self.signature,
            compatible_render_signatures=frozenset(self.compatible_signatures),
            widths_only_settings_change=self.widths_only_settings_change,
            previous_widths=(
                tuple(self.previous_widths)
                if self.previous_widths is not None
                else None
            ),
            trust_cached_assets=trust_outputs,
            verify_assets=args.cache_settings.verify_assets,
            jpeg_draft=args.render.jpeg_draft,
            hash_all=args.cache_settings.hash_all,
            include_originals=args.output_settings.include_originals,
            transcode_incompatible_videos=(
                args.output_settings.transcode_incompatible_videos
            ),
            cache_path=self.cache_path,
            toolchain=self.toolchain,
            resources=self.resources,
        )
        self.generation = cache.entries.begin_run(cache_base_payload, self.total)
        return None

    def _finish_dry_run(self) -> int:
        args = self.args
        self._close_cache()
        assert self.rebuild_plan is not None
        print_rebuild_plan(self.rebuild_plan, args.render.output_format)
        if args.render.output_format == "jxl" and not self.toolchain.cjxl_path:
            print("Encoder note: cjxl is not currently available on PATH.")
        print(
            "Working memory budget: "
            f"{self.toolchain.working_memory_budget_bytes // (1024 * 1024)} MiB "
            + (
                "(adaptive)"
                if args.render.max_working_memory_mib == 0
                else "(explicit)"
            )
        )
        emit_machine_report(args.command.report_json, {
            "mode": "dry-run",
            "status": "planned",
            "format": args.render.output_format,
            "plan": self.rebuild_plan,
            "password_protected": bool(args.password_settings.password_protected),
            "working_memory_budget_bytes": self.toolchain.working_memory_budget_bytes,
            "scan_seconds": time.monotonic() - self.scan_cache_started,
        })
        return 0

    def process_sources(self) -> int | None:
        args = self.args
        cache = self._cache()
        self.cache_writer = PeriodicCacheWriter(
            path=self.cache_path,
            interval=args.cache_settings.cache_sync_interval,
            generation=self.generation,
            total=self.total,
            verbose=args.watch_settings.progress_files,
        )
        progress = ProgressReporter(
            self.total,
            per_file=args.watch_settings.progress_files,
            cache_sync_remaining=self.cache_writer.seconds_until_sync,
        )
        DIAGNOSTICS.bind_progress(progress)
        self.scan_cache_elapsed = time.monotonic() - self.scan_cache_started
        started = time.monotonic()
        try:
            process_scanned_sources(
                cache_db=cache,
                roots=self.roots,
                total=self.total,
                jobs=args.render.jobs,
                options=self.processing_options,
                cache_writer=self.cache_writer,
                progress=progress,
                counters=self.processing_counters,
            )
        except (KeyboardInterrupt, ProcessingCancelled):
            _RUNTIME.cancel_event.set()
            terminate_active_processes()
            self.cache_writer.stop(flush=True)
            progress.finish()
            self._close_cache()
            eprint(
                f"\n{cancellation_label()}; completed work was flushed "
                "to the cache."
            )
            emit_machine_report(args.command.report_json, {
                "mode": "build",
                "status": "interrupted",
                "signal": _RUNTIME.termination_signal,
                "counts": {
                    "updated": self.processing_counters.changed,
                    "cached": self.processing_counters.reused,
                    "skipped": self.processing_counters.skipped,
                    "total": self.total,
                },
                "plan": self.rebuild_plan,
                "working_memory": {
                    "budget_bytes": self.toolchain.working_memory_budget_bytes,
                    "peak_reserved_bytes": (
                        self.resources.memory.peak_reserved_bytes
                    ),
                },
                "elapsed_seconds": time.monotonic() - self.run_started,
            })
            return cancellation_exit_code()

        progress.finish()
        finalization_progress = self._start_finalization_progress()
        finalization_progress.begin("Flushing media cache")
        checkpoint_succeeded = self.cache_writer.stop(flush=True)
        if not checkpoint_succeeded:
            raise CacheError("final SQLite cache checkpoint failed")
        self.processing_elapsed = time.monotonic() - started
        return None

    def publish_and_finalize(self) -> int:
        cache = self._cache()
        progress = self.finalization_progress or self._start_finalization_progress()
        gallery_started = time.monotonic()
        publication = publish_gallery(
            args=self.args,
            output_dir=self.output_dir,
            roots=self.roots,
            cache_db=cache,
            generation=self.generation,
            password_config=self.password_config,
            previous_run_complete=self.previous_run_complete,
            trust_generated_outputs=(
                self.previous_run_complete
                and not self.args.cache_settings.verify_assets
            ),
            toolchain=self.toolchain,
            progress=progress,
        )
        catalog = publication.catalog
        published = publication.published
        gallery_elapsed = time.monotonic() - gallery_started
        cleanup_started = time.monotonic()
        progress.begin("Reconciling generated output")
        cleanup = cleanup_generated_output(
            args=self.args,
            output_dir=self.output_dir,
            cache_db=cache,
            generation=self.generation,
            catalog=catalog,
            published=published,
        )
        progress.begin("Finalizing SQLite cache")
        finalization = finalize_build_cache(cache, self.total)
        self.cache_db = None
        self._complete_finalization_progress(progress)
        cleanup_elapsed = time.monotonic() - cleanup_started
        assert self.cache_writer is not None
        emit_build_summary(self.args, BuildSummary(
            index_path=published.index_path,
            catalog_count=catalog.catalog_count,
            generated_page_count=published.generated_page_count,
            rewritten_page_count=published.rewritten_page_count,
            client_assets_rewritten=published.client_assets_rewritten,
            credit_enabled=published.credit_enabled,
            builder_script_rewritten=published.builder_script_rewritten,
            data_file_count=cleanup.data_file_count,
            rewritten_data_count=published.rewritten_data_count,
            validated_data_count=published.validated_data_count,
            contract_validation=published.contract_validation,
            retained_publish_generations=published.retained_publish_generations,
            retained_publish_paths=published.retained_publish_paths,
            build_info_rewritten=published.build_info_rewritten,
            deployment_info_rewritten=published.deployment_info_rewritten,
            changed_count=self.processing_counters.changed,
            reused_count=self.processing_counters.reused,
            skipped_count=self.processing_counters.skipped,
            removed_asset_count=cleanup.removed_asset_count,
            removed_page_count=cleanup.removed_page_count,
            removed_data_count=cleanup.removed_data_count,
            cache_path=self.cache_path,
            migrated_sqlite_cache=self.migrated_sqlite_cache,
            cache_rows=finalization.cache_rows,
            pruned_cache_entries=finalization.pruned_cache_entries,
            video_count=catalog.video_count,
            video_compatibility_warning_count=(
                catalog.video_compatibility_warning_count
            ),
            video_transcoded_count=catalog.video_transcoded_count,
            video_transcode_failed_count=catalog.video_transcode_failed_count,
            password_config=self.password_config,
            preflight_result=self.preflight_result,
            bootstrapped_generated_files=self.bootstrapped_generated_files,
            inventory_sync_skipped=cleanup.inventory_sync_skipped,
            migrated_cache_entries=self.migrated_cache_entries,
            cache_sync_count=self.cache_writer.sync_count,
            rebuild_plan=self.rebuild_plan,
            scan_cache_elapsed=self.scan_cache_elapsed,
            processing_elapsed=self.processing_elapsed,
            gallery_elapsed=gallery_elapsed,
            cleanup_elapsed=cleanup_elapsed,
            total_elapsed=time.monotonic() - self.run_started,
            toolchain=self.toolchain,
            working_memory_peak_bytes=(
                self.resources.memory.peak_reserved_bytes
            ),
            lock_wait_seconds=self.build_lock.wait_seconds,
            orphan_temp_dirs_removed=_RUNTIME.orphan_temp_dirs_removed,
        ))
        return 0

    def execute(self) -> int:
        self.prepare_runtime()
        try:
            result = self.open_output_and_cache()
            if result is not None:
                return result
            result = self.scan_and_plan()
            if result is not None:
                return result
            result = self.process_sources()
            if result is not None:
                return result
            return self.publish_and_finalize()
        finally:
            if self.finalization_progress is not None:
                self.finalization_progress.abort()
                self.finalization_progress = None
            self._close_cache()
            release_build_lock(self.build_lock)


def run_build(
    args: BuildOptions,
    parser: argparse.ArgumentParser,
    resolved_password: str | None,
) -> int:
    """Execute one explicitly phased, resource-owning build session."""
    return BuildSession(args, parser, resolved_password).execute()


@dataclass(slots=True)
class SelfTestSuite:
    """Minimal assertion runner used by the dependency-free maintenance test."""

    checks: int = 0

    def check(self, condition: bool, message: str) -> None:
        if not condition:
            raise AssertionError(message)
        self.checks += 1

    def raises(
        self,
        expected: type[BaseException],
        operation: Callable[[], object],
        message: str,
    ) -> BaseException:
        try:
            operation()
        except expected as exc:
            self.checks += 1
            return exc
        except BaseException as exc:
            raise AssertionError(
                f"{message}: raised {type(exc).__name__}, expected {expected.__name__}"
            ) from exc
        raise AssertionError(f"{message}: no exception was raised")


@dataclass(frozen=True, slots=True)
class SelfTestBuildCase:
    """One declarative cached-build scenario in the maintenance test."""

    name: str
    extra_args: tuple[str, ...]
    expected_updated: int
    unsorted_enabled: bool
    compare_with_initial_snapshot: bool = False


STRUCTURAL_FUNCTION_LINE_BUDGETS = {
    "run_build": 12,
    "process_static_assets": 100,
    "plan_static_variant_work": 100,
    "encode_static_variants": 110,
    "run_doctor": 115,
    "verify_doctor_sample": 50,
    "build_human_summary_lines": 15,
    "chunk_indexes_for_ranges": 20,
    "build_page_bootstrap": 75,
    "write_page_descriptor": 55,
    "_summary_publication_lines": 65,
    "_summary_feature_lines": 90,
    "_summary_runtime_lines": 90,
    "publish_gallery": 180,
    "publish_gallery_shell": 65,
    "publish_gallery_data": 145,
    "validate_generated_gallery_contract": 90,
    "_contract_expected_chunk_paths": 50,
    "_validate_contract_chunk_inventory": 30,
    "_validate_client_asset_contract": 35,
    "_validate_random_order_contract": 80,
    "_validate_descriptor_contract": 80,
    "_validate_page_html_contract": 80,
    "BuildSession.open_output_and_cache": 130,
    "BuildSession.scan_and_plan": 150,
    "BuildSession.process_sources": 75,
    "BuildSession.publish_and_finalize": 90,
    "BuildSession.execute": 24,
}
STRUCTURAL_MAX_PRODUCTION_FUNCTION_LINES = 320
STRUCTURAL_DICT_ANY_BUDGET = 45


def _self_test_structural_budgets(suite: SelfTestSuite) -> None:
    """Enforce lightweight architecture limits without third-party tooling."""
    source = Path(__file__).read_text(encoding="utf-8")
    tree = ast.parse(source, filename=str(Path(__file__).resolve()))
    functions: dict[str, ast.FunctionDef | ast.AsyncFunctionDef] = {}

    def collect(
        body: Sequence[ast.stmt],
        prefix: str = "",
    ) -> None:
        for node in body:
            if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
                functions[prefix + node.name] = node
            elif isinstance(node, ast.ClassDef):
                collect(node.body, prefix + node.name + ".")

    collect(tree.body)
    for qualified_name, budget in STRUCTURAL_FUNCTION_LINE_BUDGETS.items():
        node = functions.get(qualified_name)
        suite.check(node is not None, f"missing structural target {qualified_name}")
        assert node is not None and node.end_lineno is not None
        line_count = node.end_lineno - node.lineno + 1
        suite.check(
            line_count <= budget,
            f"{qualified_name} grew to {line_count} lines (budget {budget})",
        )

    exempt_from_global_budget = {
        "run_client_regression_test",
        "_write_client_regression_fixture",
        "run_self_test",
    }
    oversized = sorted(
        (name, node.end_lineno - node.lineno + 1)
        for name, node in functions.items()
        if node.end_lineno is not None
        and name not in exempt_from_global_budget
        and node.end_lineno - node.lineno + 1
        > STRUCTURAL_MAX_PRODUCTION_FUNCTION_LINES
    )
    suite.check(
        not oversized,
        "production function line budget exceeded: " + repr(oversized),
    )

    loaded_names = {
        node.id
        for node in ast.walk(tree)
        if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load)
    }
    unused_top_level_helpers = sorted(
        node.name
        for node in tree.body
        if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
        and node.name not in loaded_names
    )
    suite.check(
        not unused_top_level_helpers,
        "unreferenced top-level helpers: " + repr(unused_top_level_helpers),
    )

    sqlite_cache = next(
        (
            node
            for node in tree.body
            if isinstance(node, ast.ClassDef) and node.name == "SQLiteCache"
        ),
        None,
    )
    suite.check(sqlite_cache is not None, "SQLiteCache class is missing")
    assert sqlite_cache is not None
    suite.check(
        not any(
            isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
            and node.name == "__getattr__"
            for node in sqlite_cache.body
        ),
        "SQLiteCache must not restore dynamic delegation",
    )
    suite.check(
        re.search(r"(?<!_)cache(?:_db)?\.connection", source) is None,
        "callers must use focused SQLite store methods instead of raw connections",
    )
    runtime_fields = set(RuntimeState.__dataclass_fields__)
    build_only_fields = {
        "cjxl_path",
        "djxl_path",
        "jxlinfo_path",
        "ffmpeg_path",
        "ffprobe_path",
        "encoder_threads",
        "working_memory_budget_bytes",
        "asset_locks",
        "content_cache_local",
        "durable_writes",
        "active_progress",
        "build_lock_handle",
        "build_lock_path",
        "build_lock_fallback_created",
        "build_lock_wait_seconds",
    }
    suite.check(
        runtime_fields.isdisjoint(build_only_fields),
        "process RuntimeState regained build-local resources",
    )

    dict_any_count = source.count("dict[str, Any]")
    suite.check(
        dict_any_count <= STRUCTURAL_DICT_ANY_BUDGET,
        "generic dict annotation budget exceeded: "
        f"{dict_any_count} > {STRUCTURAL_DICT_ANY_BUDGET}",
    )
    suite.check(
        re.search(r"metadata\[\d+\]", GALLERY_JS) is None,
        "browser code contains an unnamed compact metadata index",
    )


def _self_test_generated_snapshot(root: Path) -> dict[str, str]:
    """Hash deterministic generated output while excluding mutable SQLite state."""
    snapshot: dict[str, str] = {}
    for path in sorted(root.rglob("*")):
        if not path.is_file():
            continue
        relative = path.relative_to(root).as_posix()
        if relative == ".gallery-build.lock" or relative.startswith(
            ".gallery-cache.sqlite3"
        ):
            continue
        snapshot[relative] = sha256_file(path)
    return snapshot


def _self_test_current_page_config(output_dir: Path) -> GalleryPageConfig:
    index = output_dir / "index.html"
    source = index.read_text(encoding="utf-8")
    match = re.search(r'data-geometry-src="([^"]+)"', source)
    if match is None:
        match = re.search(
            r'<script src="([^"]*data/pages/[^"]+\.js)"[^>]*></script>',
            source,
        )
    if match is None:
        raise AssertionError("self-test gallery has no page descriptor")
    descriptor = (output_dir / html.unescape(match.group(1))).resolve()
    return _parse_generated_assignment(
        descriptor.read_text(encoding="utf-8"),
        "window.__JG_CONFIG__",
        descriptor,
    )


def _self_test_cache_migrations(root: Path, suite: SelfTestSuite) -> None:
    """Exercise a representative database through every supported upgrade."""
    ensure_output_directory(root)
    suite.check(
        tuple(migration.source for migration in CACHE_SCHEMA_MIGRATION_STEPS)
        == tuple(range(1, CACHE_DB_SCHEMA_VERSION)),
        "cache migration sources are not contiguous",
    )
    suite.check(
        tuple(migration.target for migration in CACHE_SCHEMA_MIGRATION_STEPS)
        == tuple(range(2, CACHE_DB_SCHEMA_VERSION + 1)),
        "cache migration targets are not contiguous",
    )

    for source_version in range(CACHE_DB_SCHEMA_VERSION + 1):
        path = root / f"cache-v{source_version}.sqlite3"
        connection = sqlite3.connect(path, isolation_level=None)
        if source_version == 0 or source_version == CACHE_DB_SCHEMA_VERSION:
            _create_cache_schema_objects(connection)
        else:
            connection.executescript(
                """
                CREATE TABLE files (
                    path TEXT PRIMARY KEY,
                    entry_json TEXT NOT NULL,
                    seen_generation INTEGER NOT NULL DEFAULT 0,
                    updated_ns INTEGER NOT NULL
                ) WITHOUT ROWID;
                CREATE TABLE generated_files (
                    path TEXT PRIMARY KEY,
                    kind TEXT NOT NULL,
                    seen_generation INTEGER NOT NULL DEFAULT 0
                ) WITHOUT ROWID;
                """
            )
            entry = {
                "size": 12,
                "mtime_ns": 34,
                "sha256": "a" * 64,
                "width": 2,
                "height": 3,
                "kind": "static",
                "source_suffix": ".jpg",
                "asset": "assets/example.jpg",
            }
            connection.execute(
                "INSERT INTO files(path, entry_json, updated_ns) VALUES(?, ?, ?)",
                ("example.jpg", _json_dumps(entry), 1),
            )
            if source_version == 7:
                connection.executescript(
                    """
                    CREATE TABLE data_generations (
                        build_generation INTEGER NOT NULL,
                        path TEXT NOT NULL,
                        kind TEXT NOT NULL,
                        PRIMARY KEY(build_generation, path)
                    ) WITHOUT ROWID;
                    INSERT INTO data_generations VALUES
                        (12, 'assets/legacy.bin', 'asset'),
                        (12, 'data/legacy.js', 'data');
                    """
                )
        connection.execute(f"PRAGMA user_version = {source_version}")
        connection.close()

        cache = SQLiteCache(path)
        try:
            if source_version == 0:
                suite.check(
                    isinstance(cache.scan, SQLiteScanCatalogStore)
                    and isinstance(cache.chunks, SQLiteChunkStateStore)
                    and isinstance(cache.entries, SQLiteEntryRunStore)
                    and isinstance(cache.inventory, SQLiteGeneratedInventoryStore),
                    "SQLiteCache did not expose its explicit store components",
                )
            version = cache.metadata.schema_version()
            columns = cache.metadata.table_columns("files")
            suite.check(
                version == CACHE_DB_SCHEMA_VERSION,
                f"cache v{source_version} did not upgrade automatically",
            )
            suite.check(
                {"sha256", "exif_json", "render_signature"} <= columns,
                f"cache v{source_version} is missing current columns",
            )
            suite.check(
                cache.metadata.quick_check() == ["ok"],
                f"cache v{source_version} failed quick_check",
            )
        finally:
            cache.close()
        if source_version == 7:
            migrated = sqlite3.connect(path)
            try:
                legacy_snapshots = int(migrated.execute(
                    "SELECT COUNT(*) FROM data_generation_snapshots "
                    "WHERE build_generation = 12"
                ).fetchone()[0])
            finally:
                migrated.close()
            suite.check(
                legacy_snapshots == 2,
                "cache v7 retained generations were not migrated automatically",
            )

    rollback_path = root / "cache-rollback.sqlite3"
    connection = sqlite3.connect(rollback_path, isolation_level=None)
    connection.executescript(
        """
        CREATE TABLE files (
            path TEXT PRIMARY KEY,
            entry_json TEXT NOT NULL,
            seen_generation INTEGER NOT NULL DEFAULT 0,
            updated_ns INTEGER NOT NULL
        ) WITHOUT ROWID;
        CREATE TABLE generated_files (
            path TEXT PRIMARY KEY,
            kind TEXT NOT NULL,
            seen_generation INTEGER NOT NULL DEFAULT 0
        ) WITHOUT ROWID;
        PRAGMA user_version = 1;
        """
    )
    before_schema = list(connection.execute(
        "SELECT type, name, sql FROM sqlite_schema "
        "WHERE name NOT LIKE 'sqlite_%' ORDER BY type, name"
    ))
    original_migration = CACHE_SCHEMA_MIGRATIONS[1]

    def fail_mid_migration(target: sqlite3.Connection) -> None:
        _ensure_current_cache_columns(target)
        target.execute("CREATE TABLE interrupted_migration(value INTEGER)")
        raise RuntimeError("injected migration interruption")

    CACHE_SCHEMA_MIGRATIONS[1] = (2, fail_mid_migration)
    try:
        try:
            initialize_cache_schema(connection)
        except RuntimeError as exc:
            suite.check(
                str(exc) == "injected migration interruption",
                "cache rollback test raised the wrong failure",
            )
        else:
            suite.check(False, "interrupted cache migration unexpectedly succeeded")
        suite.check(
            list(connection.execute(
                "SELECT type, name, sql FROM sqlite_schema "
                "WHERE name NOT LIKE 'sqlite_%' ORDER BY type, name"
            )) == before_schema
            and int(connection.execute("PRAGMA user_version").fetchone()[0]) == 1
            and not connection.in_transaction,
            "interrupted cache migration was not fully rolled back",
        )
    finally:
        CACHE_SCHEMA_MIGRATIONS[1] = original_migration
        connection.close()


def _self_test_binary_codecs(suite: SelfTestSuite) -> None:
    suite.check(
        set(MEDIA_HANDLER_BY_KIND) == {"static", "animated", "svg", "video"},
        "media-handler registry is incomplete",
    )
    suite.check(
        set(STATIC_ENCODER_BACKENDS) == {"jxl", "webp", "png", "jpeg", "avif"},
        "static-encoder registry is incomplete",
    )
    suite.check(
        ToolchainCapabilities.__dataclass_params__.frozen,
        "toolchain capabilities must remain immutable",
    )
    suite.check(
        {error.code for error in (
            CacheError,
            ContractError,
            MediaError,
            CodecError,
            OutputError,
        )} == {"cache", "contract", "media", "codec", "output"},
        "expected-failure diagnostic categories are not stable",
    )
    schema = catalog_binary_browser_schema()
    suite.check(schema["headerSize"] == 32, "catalog header size changed")
    suite.check(schema["recordSize"] == 32, "catalog record size changed")
    suite.check(schema["videoSize"] == 48, "catalog video size changed")
    suite.check(
        schema["record"]["width"] == 24
        and schema["video"]["compatibility_filename"] == 44,
        "catalog browser offsets disagree with the Python schema",
    )
    compact_schema = compact_browser_schema()
    suite.check(
        compact_schema["catalog"]["static"]
        == STATIC_CATALOG_SCHEMA.indexes
        and compact_schema["catalog"]["passthrough"]
        == PASSTHROUGH_CATALOG_SCHEMA.indexes
        and compact_schema["catalog"]["video"]
        == VIDEO_CATALOG_SCHEMA.indexes,
        "compact catalog schemas disagree with the browser contract",
    )
    suite.check(
        compact_schema["exif"]["raw"] == EXIF_RAW_SCHEMA.indexes
        and compact_schema["exif"]["display"]
        == [
            {
                "key": key,
                "label": label,
                "omitEmpty": key in EXIF_OMIT_WHEN_EMPTY_FIELDS,
            }
            for key, label in EXIF_DISPLAY_FIELDS
        ],
        "compact EXIF schema disagrees with the browser contract",
    )
    suite.check(
        all(
            field["omitEmpty"]
            for field in compact_schema["exif"]["display"]
        ),
        "empty EXIF display fields are not all configured to be omitted",
    )

    static_record: CatalogRecord = {
        "catalog_id": 0,
        "path": Path("Name001.jpg"),
        "stable_id": "id-one",
        "display_name": "One",
        "source_root": None,
        "root_relative": None,
        "entry": {
            "sha256": "a" * 64,
            "width": 640,
            "height": 480,
            "kind": "static",
            "source_suffix": ".jpg",
            "average_color": "#123456",
            "exif": [
                "Test Album", "Test Artist", "Camera", "Model", None,
                "50 mm",
            ],
        },
    }
    static_item = compact_catalog_item(static_record)
    suite.check(
        static_item[STATIC_CATALOG_SCHEMA.indexes["stable_id"]] == "id-one"
        and static_item[STATIC_CATALOG_SCHEMA.indexes["width"]] == 640,
        "named static catalog packing changed",
    )
    suite.check(
        compact_exif_item(static_record)
        == [
            "Test Album", "Test Artist", "Camera", "Model", None,
            "50 mm",
        ],
        "named EXIF packing changed",
    )

    items = [
        ["One", "a" * 24, 0, 640, 480, ".jpg", "id-one", "#123456"],
        ["Two", "b" * 24, 1, ".gif", 320, 200, "id-two", "#654321"],
        [
            "Three", "c" * 24, 2, ".mov", 1920, 1080, "id-three", 1,
            3.5, 12345, "hevc", "Main 10", "aac", 29.97,
            "2026-01-02T03:04:05Z", "compatibility note", "compat.mp4",
            "#abcdef",
        ],
    ]
    packed = packed_catalog_chunk(items)
    header = CATALOG_BINARY_HEADER.unpack_from(packed)
    suite.check(header[0] == CATALOG_BINARY_MAGIC, "catalog magic mismatch")
    suite.check(header[1] == CATALOG_BINARY_VERSION, "catalog version mismatch")
    suite.check(header[2] == len(items), "catalog count mismatch")
    record_offsets = header[5]
    records_start = header[6]
    lengths = []
    for index in range(len(items)):
        start = struct.unpack_from("<I", packed, record_offsets + index * 4)[0]
        end = struct.unpack_from("<I", packed, record_offsets + (index + 1) * 4)[0]
        lengths.append(end - start)
        suite.check(
            records_start + end <= header[7],
            "catalog record exceeds the record section",
        )
    suite.check(lengths == [32, 32, 80], "catalog record lengths changed")

    geometry = packed_global_geometry_chunk([1, GEOMETRY_RATIO_SCALE, 2**32 - 1])
    suite.check(
        GEOMETRY_BINARY_HEADER.unpack_from(geometry)
        == (GEOMETRY_BINARY_MAGIC, 3),
        "geometry binary envelope mismatch",
    )
    order = packed_random_group_order([2, 1], [1, 0, 2])
    suite.check(
        ORDER_BINARY_HEADER.unpack_from(order)
        == (ORDER_BINARY_MAGIC, ORDER_BINARY_VERSION, 3, 2),
        "group-order binary envelope mismatch",
    )


def _self_test_validation_ledger(root: Path, suite: SelfTestSuite) -> None:
    """Require identity reuse to fall back to hashing after any file change."""
    ensure_output_directory(root / GALLERY_DATA_DIR)
    cache = SQLiteCache(root / DEFAULT_CACHE)
    try:
        payload = generated_js_assignment("window.__JG_TEST__", {"value": 1})
        revision = hashlib.sha256(payload.encode("utf-8")).hexdigest()[:12]
        relative = f"{GALLERY_DATA_DIR}/test.{revision}.js"
        write_text_if_changed(root / relative, payload)
        validate_generated_data_files(
            root,
            [relative],
            verify_content=True,
            cache_db=cache,
            reuse_cached_validations=False,
        )
        first = cache.validations.snapshot()[relative]
        validate_generated_data_files(
            root,
            [relative],
            verify_content=True,
            cache_db=cache,
            reuse_cached_validations=True,
        )
        suite.check(
            cache.validations.snapshot()[relative] == first,
            "unchanged generated data was revalidated",
        )
        write_text_if_changed(
            root / relative,
            generated_js_assignment("window.__JG_TEST__", {"value": 2}),
        )
        suite.raises(
            ContractError,
            lambda: validate_generated_data_files(
                root,
                [relative],
                verify_content=True,
                cache_db=cache,
                reuse_cached_validations=True,
            ),
            "changed generated data bypassed full validation",
        )
    finally:
        cache.close()


def _self_test_failure_contracts(root: Path, suite: SelfTestSuite) -> None:
    """Exercise corrupt inputs and interrupted publication boundaries."""
    ensure_output_directory(root)

    invalid_payloads: tuple[tuple[str, bytes], ...] = (
        ("truncated-geometry", GEOMETRY_BINARY_MAGIC + b"\x00"),
        ("truncated-order", ORDER_BINARY_MAGIC + b"\x00"),
        ("truncated-catalog", CATALOG_BINARY_MAGIC + b"\x00"),
        ("unknown-envelope", b"BAD!\x00\x01\x02\x03"),
        (
            "invalid-order",
            ORDER_BINARY_HEADER.pack(ORDER_BINARY_MAGIC, ORDER_BINARY_VERSION, 2, 1)
            + ORDER_BINARY_VALUE.pack(2)
            + ORDER_BINARY_VALUE.pack(0)
            + ORDER_BINARY_VALUE.pack(0),
        ),
    )
    for stem, payload in invalid_payloads:
        revision = hashlib.sha256(payload).hexdigest()[:12]
        relative = f"data/{stem}.{revision}.bin"
        write_bytes_if_changed(root / relative, payload)
        suite.raises(
            ContractError,
            lambda relative=relative: validate_generated_data_files(
                root, [relative], verify_content=True
            ),
            f"{stem} binary was accepted",
        )

    malformed_js = b"window.__JG_CONFIG__={}\n"
    revision = hashlib.sha256(malformed_js).hexdigest()[:12]
    malformed_js_relative = f"data/malformed.{revision}.js"
    write_bytes_if_changed(root / malformed_js_relative, malformed_js)
    suite.raises(
        ContractError,
        lambda: validate_generated_data_files(
            root, [malformed_js_relative], verify_content=True
        ),
        "malformed generated JavaScript was accepted",
    )

    for unsafe_reference in ("../escape.js", "/absolute.js", "https://bad.invalid/x"):
        suite.raises(
            ContractError,
            lambda reference=unsafe_reference: _resolved_generated_reference(
                root, "index.html", reference
            ),
            f"unsafe generated reference {unsafe_reference!r} was accepted",
        )
    suite.raises(
        ContractError,
        lambda: _parse_generated_assignment(
            "window.__JG_CONFIG__={bad};\n",
            "window.__JG_CONFIG__",
            root / "malformed-descriptor.js",
        ),
        "malformed page descriptor was accepted",
    )

    atomic_target = root / "atomic" / "value.txt"
    ARTIFACT_WRITER.write(atomic_target, "original\n")
    original_replace = os.replace

    def interrupt_replace(source: str | bytes | Path, destination: str | bytes | Path) -> None:
        if Path(destination) == atomic_target:
            raise OSError("injected publication interruption")
        original_replace(source, destination)

    os.replace = interrupt_replace
    try:
        suite.raises(
            OSError,
            lambda: ARTIFACT_WRITER.write(atomic_target, "replacement\n"),
            "interrupted atomic publication unexpectedly succeeded",
        )
    finally:
        os.replace = original_replace
    suite.check(
        atomic_target.read_text(encoding="utf-8") == "original\n",
        "interrupted atomic publication replaced the previous artifact",
    )
    suite.check(
        not list(atomic_target.parent.glob(".value.txt.*.tmp")),
        "interrupted atomic publication left a temporary file",
    )


def _self_test_run_builder(
    source_dir: Path,
    output_dir: Path,
    report_path: Path,
    *extra: str,
) -> MachineReport:
    command = [
        sys.executable,
        str(Path(__file__).resolve()),
        str(source_dir),
        "--recursive",
        "--output",
        str(output_dir),
        "--format",
        "jpeg",
        "--quality",
        "80",
        "--jobs",
        "2",
        "--encoder-threads",
        "1",
        "--no-credit",
        "--report-json",
        str(report_path),
        *extra,
    ]
    completed = subprocess.run(
        command,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        text=True,
        timeout=180,
        check=False,
        env={**os.environ, "PYTHONHASHSEED": "0"},
    )
    if completed.returncode != 0:
        details = (completed.stderr or completed.stdout).strip()
        raise AssertionError(
            f"self-test gallery build failed ({completed.returncode}): {details}"
        )
    return json.loads(report_path.read_text(encoding="utf-8"))


def run_self_test() -> int:
    """Run fast offline invariants plus a deterministic cached rebuild."""
    if _RUNTIME.run_temp_dir is None:
        raise RuntimeError("self-test requires an active temp workspace")
    suite = SelfTestSuite()
    root = _RUNTIME.run_temp_dir / "self-test"
    source_dir = root / "input"
    output_dir = root / "output"
    ensure_output_directory(source_dir)

    _self_test_binary_codecs(suite)
    _self_test_structural_budgets(suite)
    _self_test_failure_contracts(root / "failure-contracts", suite)
    _self_test_validation_ledger(root / "validation-ledger", suite)
    suite.check(
        "__JG_CATALOG_BINARY_SCHEMA__" not in GALLERY_JS
        and "__JG_DATA_FORMAT_VERSION__" not in GALLERY_JS
        and "__JG_COMPACT_SCHEMA__" not in GALLERY_JS,
        "browser schema tokens were not resolved",
    )
    suite.check(
        all(
            marker in GALLERY_JS
            for marker in (
                "const isIOSWebKit",
                "iosFastScrollActive",
                "beginIOSFastScroll",
                "finishIOSFastScroll",
                "Math.min(2, selectedMaxActiveTileLoads)",
                "Math.min(180, selectedImageLoadMargin)",
                'img.decoding = isIOSWebKit ? "auto" : "async"',
                '!isIOSWebKit && typeof img.decode === "function"',
            )
        ),
        "iOS WebKit memory-safety profile is incomplete",
    )
    suite.check(
        all(
            marker in GALLERY_JS
            for marker in (
                "const passthroughAnimated",
                "scheduleLightboxPreload(normalized, requestId);",
                "if (item.video || item.animated) return;",
                "isolatedAnimatedLightboxSource",
                "createAnimatedLightboxImage",
                'document.createElement("img")',
                'const set = item.animated ? "" : srcsetString(item);',
                'lightboxImage.classList.add("animation-proxy")',
                "URL.createObjectURL",
                "URL.revokeObjectURL",
            )
        ),
        "animated passthrough lightbox isolation is incomplete",
    )

    parser = build_parser()
    default_namespace = parser.parse_args(["input.jpg", "--format", "jpeg"])
    enabled_namespace = parser.parse_args(
        ["input.jpg", "--format", "jpeg", "--allow-unsorted"]
    )
    suite.check(
        not default_namespace.allow_unsorted,
        "Unsorted mode is not disabled by default",
    )
    suite.check(
        enabled_namespace.allow_unsorted,
        "--allow-unsorted did not enable the option",
    )

    atomic_target = root / "atomic" / "value.txt"
    suite.check(
        ARTIFACT_WRITER.write_if_changed(atomic_target, "one\n"),
        "first artifact write was not reported",
    )
    suite.check(
        not ARTIFACT_WRITER.write_if_changed(atomic_target, "one\n"),
        "identical artifact was rewritten",
    )
    suite.check(
        not list(atomic_target.parent.glob(".value.txt.*.tmp")),
        "artifact writer left a temporary file",
    )

    _self_test_cache_migrations(root / "migrations", suite)

    first = Image.new("RGB", (96, 64), (170, 40, 30))
    second = Image.new("RGB", (64, 96), (30, 100, 180))
    animation_a = Image.new("RGB", (80, 60), (20, 160, 80))
    animation_b = Image.new("RGB", (80, 60), (180, 140, 20))
    try:
        from PIL import PngImagePlugin

        metadata_exif = Image.Exif()
        metadata_exif[315] = "Self-test Artist"
        metadata_xmp = (
            b'<?xpacket begin=""?>'
            b'<x:xmpmeta xmlns:x="adobe:ns:meta/">'
            b'<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">'
            b'<rdf:Description '
            b'xmlns:xmpDM="http://ns.adobe.com/xmp/1.0/DynamicMedia/" '
            b'xmpDM:album="Self-test Album"/>'
            b'</rdf:RDF></x:xmpmeta><?xpacket end="w"?>'
        )
        first.save(
            source_dir / "Name001.jpg",
            quality=90,
            exif=metadata_exif,
            xmp=metadata_xmp,
        )
        png_metadata = PngImagePlugin.PngInfo()
        png_metadata.add_itxt("Album", "PNG Album")
        png_metadata.add_text("Artist", "PNG Artist")
        second.save(source_dir / "Name002.png", pnginfo=png_metadata)
        animation_a.save(
            source_dir / "Other001.gif",
            save_all=True,
            append_images=[animation_b],
            duration=100,
            loop=0,
        )
    finally:
        first.close()
        second.close()
        animation_a.close()
        animation_b.close()
    extracted_metadata = extract_display_exif(source_dir / "Name001.jpg")
    suite.check(
        extracted_metadata[:2] == ["Self-test Album", "Self-test Artist"],
        "Album/Artist EXIF and XMP metadata were not extracted",
    )
    png_metadata_values = extract_display_exif(source_dir / "Name002.png")
    suite.check(
        png_metadata_values[:2] == ["PNG Album", "PNG Artist"],
        "PNG textual Album/Artist metadata were not extracted",
    )
    webp_probe_dir = root / "metadata-webp"
    ensure_output_directory(webp_probe_dir)
    animated_webp_probe = webp_probe_dir / "animated.webp"
    vp8x_payload = b"\x02" + (b"\x00" * 9)
    vp8x_chunk = (
        b"VP8X"
        + len(vp8x_payload).to_bytes(4, "little")
        + vp8x_payload
    )
    riff_payload = b"WEBP" + vp8x_chunk
    write_bytes_if_changed(
        animated_webp_probe,
        b"RIFF" + len(riff_payload).to_bytes(4, "little") + riff_payload,
    )
    suite.check(
        _webp_container_is_animated(animated_webp_probe),
        "WebP RIFF animation flag was not detected",
    )
    suite.check(
        webp_animation_upgrade_needed(".webp", {"kind": "static"})
        and not webp_animation_upgrade_needed(
            ".webp",
            {"webp_animation_version": WEBP_ANIMATION_VERSION},
        )
        and not webp_animation_upgrade_needed(".png", {"kind": "static"}),
        "WebP animation cache upgrade predicate is inconsistent",
    )
    suite.check(
        _metadata_tag_text(
            (
                {"ALBUM": "Video Album"},
                {"Album_Artist": "Video Artist"},
            ),
            "album",
        ) == "Video Album"
        and _metadata_tag_text(
            (
                {"ALBUM": "Video Album"},
                {"Album_Artist": "Video Artist"},
            ),
            "artist",
            "album_artist",
            "author",
        ) == "Video Artist",
        "case-insensitive video identity tags were not normalized",
    )
    suite.check(
        extract_video_identity_metadata(
            source_dir / "Video001.mov",
            {"album": "Native Album", "artist": "Native Artist"},
        ) == ["Native Album", "Native Artist"],
        "native video Album/Artist metadata was not normalized",
    )
    exiftool_values = _exiftool_display_metadata({
        "Album": "External Album",
        "Artist": "External Artist",
        "Make": "External Camera",
        "Model": "External Model",
        "FocalLength": 50,
        "MaxApertureValue": 2,
        "ExposureTime": 0.004,
        "ISO": 200,
        "DateTimeOriginal": "2026:10:29 09:30:00",
    })
    suite.check(
        exiftool_values[:4] == [
            "External Album",
            "External Artist",
            "External Camera",
            "External Model",
        ]
        and exiftool_values[5:] == [
            "50 mm",
            "f/2",
            "1/250 s",
            "200",
            "2026-10-29 09:30:00",
        ],
        "ExifTool numeric metadata was not normalized to the lightbox schema",
    )

    if os.name != "nt":
        fake_exiftool = root / "fake-exiftool"
        write_text_if_changed(
            fake_exiftool,
            """#!/usr/bin/env python3
import json
import sys

previous = ""
for raw in sys.stdin:
    argument = raw.rstrip("\\r\\n")
    if previous == "-stay_open" and argument == "False":
        break
    if argument.startswith("-execute"):
        request_id = argument[len("-execute"):]
        print(json.dumps([{"Album": "Bridge Album", "Artist": "Bridge Artist"}]))
        print("{ready" + request_id + "}", flush=True)
    previous = argument
""",
        )
        fake_exiftool.chmod(0o755)
        bridge = _ExifToolMetadataBridge(str(fake_exiftool))
        prior_bridge = getattr(_exiftool_metadata_local, "bridge", None)
        _exiftool_metadata_local.bridge = bridge
        try:
            bridge_record = bridge.query(source_dir / "Name001.jpg")
            video_fallback = extract_video_identity_metadata(
                source_dir / "Video001.mov",
                {"album": "Native Album", "artist": None},
            )
        finally:
            if prior_bridge is None:
                delattr(_exiftool_metadata_local, "bridge")
            else:
                _exiftool_metadata_local.bridge = prior_bridge
            bridge.close()
        suite.check(
            bridge_record is not None
            and bridge_record.get("Album") == "Bridge Album"
            and bridge_record.get("Artist") == "Bridge Artist",
            "persistent ExifTool metadata bridge did not return JSON metadata",
        )
        suite.check(
            video_fallback == ["Native Album", "Bridge Artist"],
            "video metadata did not merge the ExifTool identity fallback",
        )

    def jxl_box(box_type: bytes, payload: bytes) -> bytes:
        return struct.pack(">I4s", 8 + len(payload), box_type) + payload

    jxl_exif = Image.Exif()
    jxl_exif[315] = "JXL Artist"
    jxl_exif_payload = b"\x00\x00\x00\x00" + jxl_exif.tobytes()[6:]
    jxl_file_type = jxl_box(
        b"ftyp",
        b"jxl " + b"\x00\x00\x00\x00" + b"jxl ",
    )
    jxl_metadata_path = root / "metadata-jxl" / "plain.jxl"
    write_bytes_if_changed(
        jxl_metadata_path,
        _JXL_CONTAINER_SIGNATURE
        + jxl_file_type
        + jxl_box(b"Exif", jxl_exif_payload)
        + jxl_box(b"xml ", metadata_xmp),
    )
    jxl_metadata_values = extract_display_exif(jxl_metadata_path)
    suite.check(
        jxl_metadata_values[:2] == ["Self-test Album", "JXL Artist"],
        "JXL container Exif/XMP metadata were not extracted",
    )
    prefixed_jxl_metadata_path = root / "metadata-jxl" / "prefixed.jxl"
    write_bytes_if_changed(
        prefixed_jxl_metadata_path,
        _JXL_CONTAINER_SIGNATURE
        + jxl_file_type
        + jxl_box(b"Exif", b"\x00\x00\x00\x06" + jxl_exif.tobytes()),
    )
    prefixed_jxl_metadata_values = extract_display_exif(
        prefixed_jxl_metadata_path
    )
    suite.check(
        prefixed_jxl_metadata_values[1] == "JXL Artist",
        "JXL Exif-prefix IFD0 Artist metadata was not extracted",
    )
    suite.check(
        _tiff_ifd0_ascii(jxl_exif.tobytes()[6:], 315) == "JXL Artist",
        "bounded TIFF IFD0 Artist fallback did not decode the tag",
    )
    legacy_prefixed_jxl_path = root / "metadata-jxl" / "legacy-prefix.jxl"
    write_bytes_if_changed(
        legacy_prefixed_jxl_path,
        _JXL_CONTAINER_SIGNATURE
        + jxl_file_type
        + jxl_box(b"Exif", b"\x00\x00\x00\x00" + jxl_exif.tobytes()),
    )
    suite.check(
        extract_display_exif(legacy_prefixed_jxl_path)[1] == "JXL Artist",
        "legacy zero-offset JXL Exif prefix was not tolerated",
    )

    # A fixed Brotli stream for the Exif payload above exercises the common
    # brob representation used by lossless JPEG-to-JXL recompression. Systems
    # without a Brotli decoder still retain the uncompressed-box fallback.
    compressed_jxl_exif = bytes.fromhex(
        "1b29000084006f1dc926344916aaa41a5da8eabf4e2011812012c181bd352421"
        "749199d2e659757ceffe0f"
    )
    brob_metadata_path = root / "metadata-jxl" / "compressed.jxl"
    write_bytes_if_changed(
        brob_metadata_path,
        _JXL_CONTAINER_SIGNATURE
        + jxl_file_type
        + jxl_box(b"brob", b"Exif" + compressed_jxl_exif),
    )
    brotli_available = _brotli_decoder() is not None
    brob_metadata_values = extract_display_exif(brob_metadata_path)
    suite.check(
        not brotli_available
        or brob_metadata_values[1] == "JXL Artist",
        "Brotli-compressed JXL Exif metadata were not extracted",
    )
    mixed_case_brob_path = root / "metadata-jxl" / "compressed-mixed-case.jxl"
    write_bytes_if_changed(
        mixed_case_brob_path,
        _JXL_CONTAINER_SIGNATURE
        + jxl_file_type
        + jxl_box(b"brob", b"eXiF" + compressed_jxl_exif),
    )
    mixed_case_brob_values = extract_display_exif(mixed_case_brob_path)
    suite.check(
        not brotli_available
        or mixed_case_brob_values[1] == "JXL Artist",
        "mixed-case Brotli JXL Exif identifier was not normalized",
    )
    write_text_if_changed(
        source_dir / "Vector001.svg",
        '<svg xmlns="http://www.w3.org/2000/svg" width="120" height="60">'
        '<rect width="120" height="60" fill="#456789"/></svg>\n',
    )

    # Avoid a network favicon lookup while retaining app-asset coverage.
    icon = Image.new("RGBA", (512, 512), (50, 90, 130, 255))
    try:
        write_bytes_if_changed(
            output_dir / APP_ICON_MASTER,
            png_bytes(icon),
        )
    finally:
        icon.close()

    temp_root = Path(tempfile.gettempdir())
    before_temp_dirs = {path.name for path in temp_root.glob("gallery-*-*")}
    build_cases = (
        SelfTestBuildCase("initial", (), 4, False),
        SelfTestBuildCase("cached", (), 0, False, True),
        SelfTestBuildCase("unsorted", ("--allow-unsorted",), 0, True),
        SelfTestBuildCase("sorted", (), 0, False),
    )
    initial_snapshot: dict[str, str] | None = None
    for case in build_cases:
        report = _self_test_run_builder(
            source_dir,
            output_dir,
            root / f"{case.name}.json",
            *case.extra_args,
        )
        suite.check(
            report.get("counts", {}).get("updated") == case.expected_updated,
            f"{case.name} build updated an unexpected number of media files",
        )
        page_config = _self_test_current_page_config(output_dir)
        suite.check(
            page_config.get("randomOrder", {}).get("enabled")
            is case.unsorted_enabled,
            f"{case.name} build published the wrong sort-mode capability",
        )
        bootstrap = page_config.get("bootstrap", {})
        suite.check(
            page_config.get("dataFormatVersion") == GALLERY_DATA_FORMAT_VERSION
            and len(bootstrap.get("catalogItems", [])) == 4
            and len(bootstrap.get("geometryRatios", [])) == 4,
            f"{case.name} build published an invalid first-viewport bootstrap",
        )
        bootstrap_revisions = bootstrap.get("revisions", {})
        suite.check(
            bool(bootstrap_revisions.get("catalog"))
            and bool(bootstrap_revisions.get("geometry"))
            and "exif" not in bootstrap_revisions
            and "ids" not in bootstrap_revisions,
            f"{case.name} bootstrap did not isolate critical revisions",
        )
        snapshot = _self_test_generated_snapshot(output_dir)
        if initial_snapshot is None:
            initial_snapshot = snapshot
        if case.compare_with_initial_snapshot:
            suite.check(
                snapshot == initial_snapshot,
                f"{case.name} cached build changed generated bytes",
            )
        if case.unsorted_enabled:
            page_text = (output_dir / "index.html").read_text(encoding="utf-8")
            suite.check(
                ">Sorted<" in page_text and ">Unsorted<" in page_text,
                "Sorted/Unsorted labels are missing",
            )
    protected_report = _self_test_run_builder(
        source_dir,
        output_dir,
        root / "protected.json",
        "--password",
        "self-test-password",
    )
    protected_html = (output_dir / "index.html").read_text(encoding="utf-8")
    suite.check(
        protected_report.get("counts", {}).get("updated") == 0,
        "password-only rebuild unexpectedly reprocessed media",
    )
    suite.check(
        'rel="preload"' in protected_html
        and 'as="script"' in protected_html
        and "self-test-password" not in protected_html,
        "protected page did not safely preload the gallery client",
    )
    unprotected_report = _self_test_run_builder(
        source_dir,
        output_dir,
        root / "unprotected.json",
    )
    unprotected_html = (output_dir / "index.html").read_text(encoding="utf-8")
    suite.check(
        unprotected_report.get("counts", {}).get("updated") == 0,
        "removing password protection unexpectedly reprocessed media",
    )
    suite.check(
        'rel="preload"' not in unprotected_html,
        "unprotected page retained the password-only client preload",
    )
    head_end = unprotected_html.find("</head>")
    descriptor_script = re.search(
        r'<script src="[^"]*data/pages/[^"]+\.js" defer></script>',
        unprotected_html,
    )
    gallery_script = re.search(
        r'<script src="[^"]*assets/gallery\.[^"]+\.js" defer></script>',
        unprotected_html,
    )
    suite.check(
        descriptor_script is not None
        and gallery_script is not None
        and descriptor_script.start() < head_end
        and gallery_script.start() < head_end,
        "unprotected client scripts are not deferred from the document head",
    )

    cache_path = output_dir / DEFAULT_CACHE
    connection = sqlite3.connect(cache_path)
    try:
        snapshot_rows = list(connection.execute(
            """
            SELECT kind, COUNT(*), COUNT(DISTINCT snapshot_generation)
            FROM data_generation_snapshots
            GROUP BY kind ORDER BY kind
            """
        ))
        validation_before = dict(connection.execute(
            "SELECT path, validated_ns FROM artifact_validations ORDER BY path"
        ))
    finally:
        connection.close()
    suite.check(
        snapshot_rows == [("asset", 2, 1), ("data", 2, 1)],
        "unchanged rollout path snapshots were duplicated",
    )
    suite.check(
        bool(validation_before),
        "generated-data validation ledger was not populated",
    )
    validation_cached = _self_test_run_builder(
        source_dir,
        output_dir,
        root / "validation-cached.json",
    )
    connection = sqlite3.connect(cache_path)
    try:
        validation_after = dict(connection.execute(
            "SELECT path, validated_ns FROM artifact_validations ORDER BY path"
        ))
    finally:
        connection.close()
    suite.check(
        validation_cached.get("counts", {}).get("updated") == 0
        and validation_after == validation_before,
        "cached build did not reuse unchanged generated-data validations",
    )
    deployment = json.loads(
        (output_dir / GALLERY_DEPLOYMENT_INFO).read_text(encoding="utf-8")
    )
    suite.check(
        deployment.get("compression", {}).get("recommended_encodings")
        == ["br", "gzip"],
        "deployment manifest is missing text-compression guidance",
    )
    doctor_report_path = root / "doctor.json"
    doctor = subprocess.run(
        [
            sys.executable,
            str(Path(__file__).resolve()),
            "--output",
            str(output_dir),
            "--format",
            "jpeg",
            "--doctor",
            "--doctor-sample-size",
            "8",
            "--report-json",
            str(doctor_report_path),
        ],
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        text=True,
        timeout=60,
        check=False,
    )
    doctor_payload = (
        json.loads(doctor_report_path.read_text(encoding="utf-8"))
        if doctor_report_path.is_file()
        else {}
    )
    suite.check(
        doctor.returncode == 0
        and doctor_payload.get("healthy") is True
        and int(
            doctor_payload.get("generated", {}).get("data_files_verified") or 0
        ) > 0,
        "Gallery Doctor rejected the self-test fixture: "
        + (doctor.stderr or doctor.stdout).strip(),
    )
    after_temp_dirs = {path.name for path in temp_root.glob("gallery-*-*")}
    suite.check(
        not (after_temp_dirs - before_temp_dirs),
        "child builds left gallery temporary directories behind",
    )

    node = shutil.which("node")
    if node is not None:
        javascript = root / "gallery.js"
        write_text_if_changed(javascript, GALLERY_JS)
        syntax = subprocess.run(
            [node, "--check", str(javascript)],
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True,
            timeout=30,
            check=False,
        )
        suite.check(
            syntax.returncode == 0,
            f"generated gallery JavaScript is invalid: {syntax.stderr.strip()}",
        )

    print(f"Self-test passed: {suite.checks} checks.")
    return 0


def _write_client_regression_fixture(root: Path, item_count: int) -> None:
    """Create a compact synthetic gallery using the production client."""
    ensure_output_directory(root)
    client_publication = ensure_gallery_client_assets(
        root,
        DEFAULT_LIGHTBOX_OPACITY,
    )
    client_assets = client_publication.assets
    catalog_chunk_size = 4096
    geometry_chunk_size = 8192
    ratio_value = GEOMETRY_RATIO_SCALE * 4 // 3
    catalog_revisions: dict[str, dict[str, Any]] = {}
    exif_revisions: dict[str, str] = {}
    geometry_revisions: dict[str, dict[str, Any]] = {}
    shared_digest = hashlib.sha256(b"gallery regression fixture").hexdigest()
    shared_prefix = shared_digest[:24]
    asset_rel = expected_passthrough_asset(shared_digest, ".svg")
    write_text_if_changed(
        root / asset_rel,
        '<svg xmlns="http://www.w3.org/2000/svg" width="4" height="3" '
        'viewBox="0 0 4 3"><rect width="4" height="3" fill="#607d8b"/>'
        '<circle cx="2" cy="1.5" r=".7" fill="#cfd8dc"/></svg>\n',
    )

    catalog_chunks = math.ceil(item_count / catalog_chunk_size)
    for chunk_index in range(catalog_chunks):
        chunk_start = chunk_index * catalog_chunk_size
        chunk_count = min(catalog_chunk_size, item_count - chunk_start)
        javascript = f"""window.__JG_CATALOG_CHUNKS = window.__JG_CATALOG_CHUNKS || Object.create(null);
window.__JG_CATALOG_CHUNKS[{chunk_index}] = new Proxy(new Array({chunk_count}), {{
  get(target, property, receiver) {{
    if (property === "length") return {chunk_count};
    const local = Number(property);
    if (Number.isInteger(local) && local >= 0 && local < {chunk_count}) {{
      const index = {chunk_start} + local;
      return ["fixture-" + index + ".svg", "{shared_prefix}", 1, ".svg", 4, 3,
        index.toString(16).padStart(24, "0").slice(-24), "#607d8b"];
    }}
    return Reflect.get(target, property, receiver);
  }}
}});
"""
        revision = hashlib.sha256(javascript.encode("utf-8")).hexdigest()[:12]
        rel = (
            Path(GALLERY_DATA_CATALOG_DIR)
            / f"{chunk_index:0{CATALOG_CHUNK_FILENAME_WIDTH}d}.{revision}.js"
        )
        write_text_if_changed(root / rel, javascript)
        binary_payload = [
            [
                f"fixture-{index}.svg",
                shared_prefix,
                1,
                ".svg",
                4,
                3,
                f"{index:024x}"[-24:],
                "#607d8b",
            ]
            for index in range(chunk_start, chunk_start + chunk_count)
        ]
        binary = packed_catalog_chunk(binary_payload)
        binary_revision = hashlib.sha256(binary).hexdigest()[:12]
        binary_rel = catalog_binary_relative_path(chunk_index, binary_revision)
        write_bytes_if_changed(root / binary_rel, binary)
        catalog_revisions[str(chunk_index)] = {
            "revision": revision,
            "binaryRevision": binary_revision,
            "count": chunk_count,
        }
        exif_javascript = generated_js_assignment(
            f"window.__JG_EXIF_CHUNKS[{chunk_index}]",
            [
                ["Fixture Album", "Fixture Artist"]
                if chunk_start + offset == 0
                else []
                for offset in range(chunk_count)
            ],
            prefix=(
                "window.__JG_EXIF_CHUNKS="
                "window.__JG_EXIF_CHUNKS||Object.create(null);"
            ),
        )
        exif_revision = hashlib.sha256(
            exif_javascript.encode("utf-8")
        ).hexdigest()[:12]
        write_text_if_changed(
            root / exif_chunk_relative_path(chunk_index, exif_revision),
            exif_javascript,
        )
        exif_revisions[str(chunk_index)] = exif_revision

    geometry_chunks = math.ceil(item_count / geometry_chunk_size)
    for chunk_index in range(geometry_chunks):
        chunk_start = chunk_index * geometry_chunk_size
        chunk_count = min(geometry_chunk_size, item_count - chunk_start)
        fallback = (
            "window.__JG_GLOBAL_GEOMETRY_CHUNKS = "
            "window.__JG_GLOBAL_GEOMETRY_CHUNKS || Object.create(null);\n"
            f"window.__JG_GLOBAL_GEOMETRY_CHUNKS[{chunk_index}] = "
            f"new Array({chunk_count}).fill({ratio_value});\n"
        )
        fallback_revision = hashlib.sha256(
            fallback.encode("utf-8")
        ).hexdigest()[:12]
        binary = packed_global_geometry_chunk([ratio_value] * chunk_count)
        binary_revision = hashlib.sha256(binary).hexdigest()[:12]
        stem = f"{chunk_index:0{GEOMETRY_CHUNK_FILENAME_WIDTH}d}"
        write_text_if_changed(
            root / GALLERY_DATA_GEOMETRY_DIR / f"{stem}.{fallback_revision}.js",
            fallback,
        )
        write_bytes_if_changed(
            root / GALLERY_DATA_GEOMETRY_DIR / f"{stem}.{binary_revision}.bin",
            binary,
        )
        summaries: list[list[int]] = []
        remaining = chunk_count
        while remaining:
            segment_count = min(DEFAULT_LAYOUT_SEGMENT_SIZE, remaining)
            summaries.append([segment_count, segment_count * ratio_value])
            remaining -= segment_count
        geometry_revisions[str(chunk_index)] = {
            "revision": fallback_revision,
            "binaryRevision": binary_revision,
            "count": chunk_count,
            "segments": summaries,
        }

    revision_payload = {
        "catalog": catalog_revisions,
        "exif": exif_revisions,
        "geometry": geometry_revisions,
        "ids": {},
    }
    revision_javascript = generated_js_assignment(
        "window.__JG_CHUNK_REVISIONS__",
        revision_payload,
    )
    revision = hashlib.sha256(
        revision_javascript.encode("utf-8")
    ).hexdigest()[:12]
    revision_rel = chunk_revision_index_relative_path(revision)
    write_text_if_changed(root / revision_rel, revision_javascript)

    order_lengths = [
        min(3, item_count - start)
        for start in range(0, item_count, 3)
    ]
    order_members = list(range(item_count))
    order_binary = packed_random_group_order(
        order_lengths, order_members
    )
    order_binary_revision = hashlib.sha256(order_binary).hexdigest()[:12]
    order_binary_rel = random_order_relative_path(
        order_binary_revision, "bin"
    )
    write_bytes_if_changed(root / order_binary_rel, order_binary)
    order_fallback = generated_js_assignment(
        "window.__JG_RANDOM_GROUP_ORDER__",
        {"lengths": order_lengths, "members": order_members},
    )
    order_fallback_revision = hashlib.sha256(
        order_fallback.encode("utf-8")
    ).hexdigest()[:12]
    order_fallback_rel = random_order_relative_path(
        order_fallback_revision, "js"
    )
    write_text_if_changed(root / order_fallback_rel, order_fallback)

    regression_client_settings = ClientSettings(
        target_row_height=180,
        gap=2,
        requested_widths=(480,),
        catalog_chunk_size=catalog_chunk_size,
        geometry_chunk_size=geometry_chunk_size,
        data_revision=revision,
        revision_index_rel=revision_rel.as_posix(),
        revision_index_revision=revision,
        static_output_format="jpeg",
        max_active_tile_loads=6,
        image_load_margin=300,
        tooltip_delay=0,
        client_diagnostics=False,
        image_id_bits=DEFAULT_IMAGE_ID_BITS,
        include_originals=False,
        transcode_incompatible_videos=False,
        max_hot_catalog_chunks=4,
        max_client_metadata_mib=4,
        layout_worker=True,
        layout_worker_asset=client_assets["layout_worker"],
        random_order={
            "enabled": True,
            "available": item_count > 1,
            "count": item_count,
            "groupCount": len(order_lengths),
            "binaryPath": order_binary_rel.as_posix(),
            "binaryRevision": order_binary_revision,
            "fallbackPath": order_fallback_rel.as_posix(),
            "fallbackRevision": order_fallback_revision,
        },
    )
    config = gallery_page_config(
        page_dir=Path("."),
        image_count=item_count,
        name_prefix="",
        settings=regression_client_settings,
    )
    descriptor = (
        generated_js_assignment("window.__JG_CONFIG__", config)
        + generated_js_assignment("window.__JG_PAGE_RANGES__", [[0, item_count]])
    )
    descriptor_rel = Path(GALLERY_DATA_DIR) / "page.js"
    write_text_if_changed(root / descriptor_rel, descriptor)
    page = build_html(HtmlPage(
        title=f"Regression {item_count}",
        page_descriptor_href=descriptor_rel.as_posix(),
        heading=f"Regression {item_count}",
        image_count=item_count,
        home_href=None,
        parent_href=None,
        parent_label=None,
        child_links=tuple(
            (f"Fixture album {index}", f"#fixture-album-{index}")
            for index in range(40)
        ),
        page_dir=Path("."),
        app_title="Regression",
        app_assets_enabled=False,
        client_assets=client_assets,
        show_credit=False,
        include_originals=False,
        header_html="",
        footer_html="",
        password_config=None,
        build_date="2000-01-01T00:00:00Z",
    ))
    write_text_if_changed(root / "index.html", page)


def run_client_regression_test() -> int:
    """Exercise large-gallery hydration, virtualization, and resizing."""
    node = shutil.which("node")
    if node is None:
        eprint("error: --client-regression-test requires Node.js")
        return 2
    configured_browser = os.environ.get(
        "PLAYWRIGHT_CHROMIUM_EXECUTABLE", ""
    ).strip()
    browser_executable = ""
    if configured_browser:
        candidate = Path(configured_browser).expanduser()
        if not candidate.is_file() or not os.access(candidate, os.X_OK):
            eprint(
                "error: PLAYWRIGHT_CHROMIUM_EXECUTABLE is not an "
                f"executable file: {candidate}"
            )
            return 2
        browser_executable = str(candidate.resolve())
    else:
        for executable_name in (
            "chromium",
            "chromium-browser",
            "google-chrome-stable",
            "google-chrome",
        ):
            discovered = shutil.which(executable_name)
            if discovered:
                browser_executable = discovered
                break
    available = subprocess.run(
        [
            node,
            "-e",
            (
                "const{chromium}=require('playwright');"
                "const p=process.argv[1]||'';"
                "const o={headless:true};if(p)o.executablePath=p;"
                "chromium.launch(o)"
                ".then(browser=>browser.close())"
                ".catch(()=>process.exit(1))"
            ),
            browser_executable,
        ],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
        check=False,
    )
    if available.returncode != 0:
        eprint(
            "error: --client-regression-test requires the Node.js playwright "
            "package and an installed Chromium browser"
        )
        return 2

    if _RUNTIME.run_temp_dir is None:
        raise RuntimeError("client regression test requires a temp workspace")
    fixture_root = _RUNTIME.run_temp_dir / "client-regression"
    counts = (32767, 32768, 61489, 250000)
    for count in counts:
        _write_client_regression_fixture(fixture_root / str(count), count)

    runner = r'''"use strict";
const { chromium } = require("playwright");
const { pathToFileURL } = require("url");
const path = require("path");

async function waitReady(page, timeout = 30000) {
  await page.waitForFunction(() => {
    const state = window.__JG_REGRESSION__;
    return state && state.mountedRows > 0 && state.mountedTiles > 0;
  }, null, { timeout });
}

async function assertHealthy(page, label, { allowTileErrors = false } = {}) {
  const state = await page.evaluate(() => window.__JG_REGRESSION__);
  if (!state || state.mountedRows < 1 || state.mountedTiles < 1) {
    throw new Error(`${label}: no mounted gallery content`);
  }
  if (state.mountedRows > 250 || state.mountedTiles > 1200) {
    throw new Error(`${label}: virtual DOM bounds exceeded`);
  }
  if (state.metadataCacheBytes > state.metadataCacheBudgetBytes * 1.05) {
    throw new Error(`${label}: metadata byte budget exceeded`);
  }
  if (await page.locator("#empty.gallery-load-failure").isVisible()) {
    throw new Error(`${label}: whole-gallery failure UI is visible`);
  }
  if (!allowTileErrors && await page.locator(".tile.load-error").count()) {
    throw new Error(`${label}: unexpected tile-local errors`);
  }
  return state;
}

async function scrollToFraction(page, fraction) {
  await page.evaluate(value => {
    const maximum = Math.max(0, document.documentElement.scrollHeight - innerHeight);
    scrollTo(0, maximum * value);
  }, fraction);
  await page.waitForTimeout(900);
}

function assertGroupedSample(sample, count, label) {
  if (!Array.isArray(sample) || sample.length < 3) {
    throw new Error(`${label}: random display sample is missing`);
  }
  for (let index = 1; index < sample.length; index += 1) {
    const previous = sample[index - 1];
    const current = sample[index];
    if (Math.floor(previous / 3) === Math.floor(current / 3)) {
      if (current !== previous + 1) {
        throw new Error(`${label}: members are not naturally ordered`);
      }
    } else if (previous % 3 !== 2 && previous !== count - 1) {
      throw new Error(`${label}: a filename group was split`);
    }
  }
}

(async () => {
  const base = process.argv[2];
  const fixtureRoot = process.argv[3];
  const browserExecutable = process.argv[4] || "";
  const cases = [32767, 32768, 61489, 250000];
  const launchOptions = {
    headless: true,
    args: ["--allow-file-access-from-files"]
  };
  if (browserExecutable) launchOptions.executablePath = browserExecutable;
  const browser = await chromium.launch(launchOptions);
  try {
    for (const count of cases) {
      const page = await browser.newPage({ viewport: { width: 1920, height: 1080 } });
      const pageErrors = [];
      const catalogBinaries = [];
      const geometryBinaries = [];
      const orderBinaries = [];
      page.on("pageerror", error => pageErrors.push(String(error)));
      page.on("request", request => {
        const url = request.url();
        if (/\/data\/items\/.*\.bin(?:\?|$)/.test(url)) catalogBinaries.push(url);
        if (/\/data\/geometry\/.*\.bin(?:\?|$)/.test(url)) geometryBinaries.push(url);
        if (/\/data\/order\/.*\.bin(?:\?|$)/.test(url)) orderBinaries.push(url);
      });
      await page.goto(`${base}/${count}/?regression=1`, {
        waitUntil: "domcontentloaded",
        timeout: 30000
      });
      await waitReady(page);
      let state = await assertHealthy(page, String(count));
      if (orderBinaries.length) {
        throw new Error(`${count}: random order loaded before it was selected`);
      }
      if (count === 32767) {
        const menu = page.locator("#children-menu");
        const toggle = page.locator("#children-toggle");
        await toggle.click();
        if (!await menu.evaluate(element => element.open)) {
          throw new Error("album menu did not open");
        }
        const firstTileBounds = await page.locator(".tile").first().boundingBox();
        if (!firstTileBounds) throw new Error("album-menu test has no tile bounds");
        await page.mouse.click(
          firstTileBounds.x + Math.min(12, firstTileBounds.width / 2),
          firstTileBounds.y + Math.min(12, firstTileBounds.height / 2)
        );
        const outsideDismissal = await page.evaluate(() => ({
          menuOpen: document.getElementById("children-menu").open,
          lightboxOpen: document.getElementById("lightbox").open
        }));
        if (outsideDismissal.menuOpen || outsideDismissal.lightboxOpen) {
          throw new Error("outside album-menu click reached the underlying tile");
        }
        await toggle.click();
        await page.keyboard.press("Escape");
        const keyboardDismissal = await page.evaluate(() => ({
          menuOpen: document.getElementById("children-menu").open,
          focused: document.activeElement && document.activeElement.id
        }));
        if (keyboardDismissal.menuOpen || keyboardDismissal.focused !== "children-toggle") {
          throw new Error("Escape did not close and refocus the album menu");
        }
        process.stdout.write("PASS album-menu dismissal\n");

        const firstTile = page.locator(".tile").first();
        await firstTile.click();
        await page.waitForFunction(() => document.getElementById("lightbox").open);
        const initialLightboxState = await page.evaluate(() => ({
          hash: location.hash,
          focused: document.activeElement && document.activeElement.id
        }));
        if (initialLightboxState.focused !== "lightbox") {
          throw new Error("lightbox did not receive keyboard focus when opened");
        }
        await page.locator("#lightbox-info-toggle").click();
        await page.waitForFunction(() => {
          const terms = Array.from(
            document.querySelectorAll("#lightbox-exif-list dt")
          ).map(element => element.textContent);
          return terms[0] === "Album" && terms[1] === "Artist";
        });
        const identityMetadata = await page.evaluate(() => {
          const rows = Array.from(
            document.querySelectorAll("#lightbox-exif-list dt")
          ).map(term => [
            term.textContent,
            term.nextElementSibling && term.nextElementSibling.textContent
          ]);
          return rows.slice(0, 2);
        });
        if (JSON.stringify(identityMetadata) !== JSON.stringify([
          ["Album", "Fixture Album"],
          ["Artist", "Fixture Artist"]
        ])) {
          throw new Error("Album/Artist metadata is missing or out of order");
        }
        await page.keyboard.press("ArrowRight");
        await page.waitForFunction(
          previousHash => location.hash !== previousHash,
          initialLightboxState.hash
        );
        await page.waitForFunction(() => {
          const terms = Array.from(
            document.querySelectorAll("#lightbox-exif-list dt")
          ).map(element => element.textContent);
          return terms.length === 1 && terms[0] === "Format";
        });
        await page.keyboard.press("Escape");
        await page.waitForFunction(() => !document.getElementById("lightbox").open);
        process.stdout.write("PASS immediate lightbox keyboard navigation\n");
      }
      const expectedMode = count >= 32768 ? "segmented" : "eager";
      if (state.geometryMode !== expectedMode) {
        throw new Error(`${count}: expected ${expectedMode}, got ${state.geometryMode}`);
      }
      if (count === 32767 || count === 61489) {
        await page.locator("#sort-mode").selectOption("random");
        await page.waitForFunction(() => {
          const current = window.__JG_REGRESSION__;
          return current && current.displayMode === "random" &&
            current.displayOrderSample.length > 0;
        });
        state = await assertHealthy(page, `${count} random groups`);
        assertGroupedSample(state.displayOrderSample, count, String(count));
        if (!orderBinaries.length) {
          throw new Error(`${count}: random-order binary was not requested`);
        }
        const initialSample = state.displayOrderSample.join(",");
        await page.setViewportSize({ width: 1111, height: 777 });
        await page.waitForTimeout(500);
        state = await assertHealthy(page, `${count} random resize`);
        if (state.displayOrderSample.join(",") !== initialSample) {
          throw new Error(`${count}: random order changed during resize`);
        }
        await page.locator("#reshuffle").click();
        await page.waitForFunction(sample => {
          const current = window.__JG_REGRESSION__;
          return current && current.displayMode === "random" &&
            current.displayOrderSample.join(",") !== sample;
        }, initialSample);
        state = await assertHealthy(page, `${count} reshuffle`);
        assertGroupedSample(state.displayOrderSample, count, `${count} reshuffle`);

        if (count === 32767) {
          await page.reload({ waitUntil: "domcontentloaded" });
          await waitReady(page);
          await page.waitForFunction(() => {
            const current = window.__JG_REGRESSION__;
            return current && current.displayMode === "random";
          });
          await assertHealthy(page, `${count} session restore`);
        }

        await page.locator("#sort-mode").selectOption("natural");
        await page.waitForFunction(mode => {
          const current = window.__JG_REGRESSION__;
          return current && current.displayMode === "natural" &&
            current.geometryMode === mode;
        }, expectedMode);
        state = await assertHealthy(page, `${count} natural restore`);
      }
      for (const fraction of [0.5, 0.98]) {
        await scrollToFraction(page, fraction);
        state = await assertHealthy(page, `${count} scroll ${fraction}`);
        if (state.failedSegments) {
          throw new Error(`${count}: segment hydration failed`);
        }
      }
      for (const viewport of [
        { width: 390, height: 844 },
        { width: 1280, height: 720 },
        { width: 430, height: 932 }
      ]) {
        await page.setViewportSize(viewport);
      }
      await page.waitForTimeout(700);
      state = await assertHealthy(page, `${count} resize storm`);
      if (state.lastWidth > 430) throw new Error(`${count}: stale resize layout`);
      if (pageErrors.length) throw new Error(`${count}: ${pageErrors.join(" | ")}`);
      if (!catalogBinaries.length || !geometryBinaries.length) {
        throw new Error(`${count}: binary catalog/geometry paths were not exercised`);
      }
      await page.close();
      process.stdout.write(`PASS baseline ${count}\n`);
    }

    // iOS browsers all use WebKit and do not expose deviceMemory. Emulate the
    // relevant platform signals to verify the constrained profile and the
    // fast-scroll coast state independently of desktop behavior.
    {
      const context = await browser.newContext({
        viewport: { width: 430, height: 932 },
        deviceScaleFactor: 3,
        isMobile: true,
        hasTouch: true,
        userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) " +
          "AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/130.0.0.0 " +
          "Mobile/15E148 Safari/604.1"
      });
      const page = await context.newPage();
      const pageErrors = [];
      page.on("pageerror", error => pageErrors.push(String(error)));
      await page.goto(`${base}/61489/?regression=1`, {
        waitUntil: "domcontentloaded"
      });
      await waitReady(page);
      let state = await assertHealthy(page, "iOS safety profile");
      if (!state.iosWebKit || state.maxActiveTileLoads > 2 ||
          state.imageLoadMargin > 180 || state.maxRecycledTiles !== 0) {
        throw new Error("iOS WebKit safety limits were not applied");
      }
      await page.evaluate(async () => {
        const maximum = Math.max(
          0,
          document.documentElement.scrollHeight - innerHeight
        );
        for (const fraction of [0.12, 0.35, 0.61, 0.86, 0.97]) {
          scrollTo(0, maximum * fraction);
          await new Promise(resolve => requestAnimationFrame(resolve));
        }
      });
      await page.waitForFunction(() => {
        const current = window.__JG_REGRESSION__;
        return current && current.iosFastScrollEntries > 0 &&
          current.iosFastScrollActive === false;
      }, null, { timeout: 5000 });
      await page.waitForTimeout(500);
      state = await assertHealthy(page, "iOS fast-scroll recovery");
      if (state.iosFastScrollEntries < 1) {
        throw new Error("iOS fast-scroll coast mode was not exercised");
      }
      if (pageErrors.length) {
        throw new Error(`iOS fast-scroll: ${pageErrors.join(" | ")}`);
      }
      await context.close();
      process.stdout.write("PASS iOS fast-scroll memory profile\n");
    }

    // Truncated, hash-invalid catalog binaries must retry and fall back to JS.
    {
      const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
      await page.route("**/*", async route => {
        const pathname = new URL(route.request().url()).pathname;
        if (/\/data\/items\/.*\.bin$/.test(pathname)) {
          await route.fulfill({
            status: 200,
            contentType: "application/octet-stream",
            body: Buffer.from("JGC1-bad")
          });
        } else {
          await route.continue();
        }
      });
      await page.goto(`${base}/61489/?regression=1`, { waitUntil: "domcontentloaded" });
      await waitReady(page);
      await page.waitForFunction(() => {
        const state = window.__JG_REGRESSION__;
        return state && state.catalogFallbackLoads > 0 && state.binaryIntegrityFailures > 0;
      });
      await assertHealthy(page, "corrupt binary fallback");
      await page.close();
      process.stdout.write("PASS corrupt binary fallback\n");
    }

    // Delayed and out-of-order chunk responses must not attach stale metadata.
    {
      const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
      await page.route("**/*", async route => {
        const pathname = new URL(route.request().url()).pathname;
        const match = /\/data\/(?:items|geometry)\/(\d+)\..*\.bin$/.exec(pathname);
        if (match) await new Promise(resolve => setTimeout(resolve, 20 + (3 - Number(match[1]) % 4) * 35));
        await route.continue();
      });
      await page.goto(`${base}/61489/?regression=1`, { waitUntil: "domcontentloaded" });
      await waitReady(page);
      for (const value of [0.15, 0.55, 0.95]) {
        await scrollToFraction(page, value);
      }
      await page.waitForTimeout(1000);
      await assertHealthy(page, "out-of-order chunks");
      await page.close();
      process.stdout.write("PASS out-of-order chunks\n");
    }

    // A temporary offline transition during predictive hydration must recover.
    {
      const context = await browser.newContext({ viewport: { width: 1280, height: 720 } });
      const page = await context.newPage();
      await page.goto(`${base}/61489/?regression=1`, { waitUntil: "domcontentloaded" });
      await waitReady(page);
      await context.setOffline(true);
      await page.evaluate(() => scrollTo(0, document.documentElement.scrollHeight * 0.9));
      await page.waitForTimeout(350);
      await context.setOffline(false);
      await page.waitForTimeout(1800);
      await assertHealthy(page, "offline recovery");
      await context.close();
      process.stdout.write("PASS offline recovery\n");
    }

    // If both representations of a catalog segment are absent, contain the
    // failure to its tiles after the one deployment-refresh attempt.
    {
      const context = await browser.newContext({ viewport: { width: 1280, height: 720 } });
      const page = await context.newPage();
      await page.route("**/*", async route => {
        const pathname = new URL(route.request().url()).pathname;
        if (/\/data\/items\/00000000\..*\.(?:bin|js)$/.test(pathname)) {
          await route.fulfill({ status: 404, body: "missing" });
        } else {
          await route.continue();
        }
      });
      await page.goto(`${base}/61489/?regression=1`, { waitUntil: "domcontentloaded" });
      await waitReady(page);
      await page.locator(".tile.load-error").first().waitFor({ timeout: 30000 });
      await assertHealthy(page, "segment-local missing data", { allowTileErrors: true });
      await context.close();
      process.stdout.write("PASS segment-local missing data\n");
    }

    // file:// must use JavaScript fallbacks and remain fully functional.
    {
      const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
      const fileUrl = pathToFileURL(path.join(fixtureRoot, "32767", "index.html"));
      fileUrl.search = "?regression=1";
      await page.goto(fileUrl.href, { waitUntil: "load" });
      await waitReady(page);
      const state = await assertHealthy(page, "file protocol");
      if (state.binaryCatalogLoads !== 0 || state.catalogFallbackLoads < 1) {
        throw new Error("file protocol did not use catalog fallback");
      }
      await page.close();
      process.stdout.write("PASS file protocol fallback\n");
    }
  } finally {
    await browser.close();
  }
})().catch(error => {
  console.error(error && error.stack || error);
  process.exitCode = 1;
});
'''
    runner_path = fixture_root / "run.cjs"
    write_text_if_changed(runner_path, runner)

    class QuietHandler(http.server.SimpleHTTPRequestHandler):
        def log_message(self, _format: str, *args: Any) -> None:
            return

    handler = lambda *args, **kwargs: QuietHandler(  # noqa: E731
        *args,
        directory=str(fixture_root),
        **kwargs,
    )
    server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
    server_thread = threading.Thread(target=server.serve_forever, daemon=True)
    server_thread.start()
    port = int(server.server_address[1])
    try:
        completed = subprocess.run(
            [
                node,
                str(runner_path),
                f"http://127.0.0.1:{port}",
                str(fixture_root),
                browser_executable,
            ],
            cwd=fixture_root,
            check=False,
            timeout=420,
        )
    except subprocess.TimeoutExpired:
        eprint("error: client regression test timed out")
        return 1
    finally:
        server.shutdown()
        server.server_close()
        server_thread.join(timeout=5)
    if completed.returncode != 0:
        eprint("error: client regression test failed")
        return 1
    print("Client regression suite passed.")
    return 0


class LinuxInotifyMonitor:
    """Minimal recursive Linux inotify backend with polling reconciliation."""

    _EVENT = struct.Struct("iIII")
    _IN_ATTRIB = 0x00000004
    _IN_CLOSE_WRITE = 0x00000008
    _IN_MOVED_FROM = 0x00000040
    _IN_MOVED_TO = 0x00000080
    _IN_CREATE = 0x00000100
    _IN_DELETE = 0x00000200
    _IN_DELETE_SELF = 0x00000400
    _IN_MOVE_SELF = 0x00000800
    _IN_Q_OVERFLOW = 0x00004000
    _IN_IGNORED = 0x00008000
    _IN_ISDIR = 0x40000000
    _MASK = (
        _IN_ATTRIB
        | _IN_CLOSE_WRITE
        | _IN_MOVED_FROM
        | _IN_MOVED_TO
        | _IN_CREATE
        | _IN_DELETE
        | _IN_DELETE_SELF
        | _IN_MOVE_SELF
    )

    def __init__(self, args: BuildOptions) -> None:
        if not sys.platform.startswith("linux"):
            raise OSError(errno.ENOSYS, "inotify is available only on Linux")
        libc = ctypes.CDLL(None, use_errno=True)
        init = libc.inotify_init1
        init.argtypes = [ctypes.c_int]
        init.restype = ctypes.c_int
        add = libc.inotify_add_watch
        add.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_uint32]
        add.restype = ctypes.c_int
        self._add_watch = add
        self.fd = int(init(os.O_NONBLOCK | getattr(os, "O_CLOEXEC", 0)))
        if self.fd < 0:
            code = ctypes.get_errno()
            raise OSError(code, os.strerror(code))
        self.args = args
        self.output_dir = args.output_settings.output.expanduser().resolve()
        self.watches: dict[int, Path] = {}
        self.paths: dict[Path, int] = {}
        self.overflowed = False
        try:
            self.refresh()
        except OSError:
            self.close()
            raise
        if not self.watches:
            self.close()
            raise OSError(errno.ENOENT, "no watchable input directories")

    def _excluded(self, path: Path) -> bool:
        try:
            resolved = path.resolve()
        except OSError:
            return True
        return resolved == self.output_dir or is_relative_to(
            resolved, self.output_dir
        )

    def _root_directories(self) -> list[Path]:
        roots: set[Path] = set()
        for raw in self.args.source.paths:
            try:
                resolved = raw.expanduser().resolve(strict=True)
            except OSError:
                continue
            root = resolved if resolved.is_dir() else resolved.parent
            if not self._excluded(root):
                roots.add(root)
        return sorted(roots, key=lambda path: natural_key(str(path)))

    def _iter_directories(self) -> Iterable[Path]:
        for root in self._root_directories():
            yield root
            if not self.args.source.recursive:
                continue
            for directory, names, _files in os.walk(root, followlinks=False):
                base = Path(directory)
                names[:] = [
                    name
                    for name in names
                    if not self._excluded(base / name)
                ]
                for name in names:
                    yield base / name

    def refresh(self) -> None:
        for directory in self._iter_directories():
            if directory in self.paths:
                continue
            encoded = os.fsencode(directory)
            descriptor = int(self._add_watch(self.fd, encoded, self._MASK))
            if descriptor < 0:
                code = ctypes.get_errno()
                if code in {errno.ENOENT, errno.ENOTDIR, errno.EACCES}:
                    continue
                raise OSError(code, os.strerror(code), str(directory))
            old_path = self.watches.get(descriptor)
            if old_path is not None:
                self.paths.pop(old_path, None)
            self.watches[descriptor] = directory
            self.paths[directory] = descriptor

    def wait(self, timeout: float) -> bool:
        if self.fd < 0:
            return False
        readable, _writable, _errors = select.select(
            [self.fd], [], [], max(0.0, timeout)
        )
        if not readable:
            return False
        changed = False
        while True:
            try:
                data = os.read(self.fd, 256 * 1024)
            except BlockingIOError:
                break
            if not data:
                break
            offset = 0
            while offset + self._EVENT.size <= len(data):
                descriptor, mask, _cookie, name_length = self._EVENT.unpack_from(
                    data, offset
                )
                offset += self._EVENT.size + int(name_length)
                if mask & self._IN_Q_OVERFLOW:
                    self.overflowed = True
                if mask & self._IN_IGNORED:
                    path = self.watches.pop(descriptor, None)
                    if path is not None:
                        self.paths.pop(path, None)
                changed = True
        if changed:
            try:
                self.refresh()
            except OSError:
                self.overflowed = True
        return changed

    def close(self) -> None:
        if getattr(self, "fd", -1) >= 0:
            try:
                os.close(self.fd)
            except OSError:
                pass
            self.fd = -1


class HybridWatchMonitor:
    """Prefer native events while periodically reconciling the full tree."""

    def __init__(self, args: BuildOptions) -> None:
        self.args = args
        self.native: LinuxInotifyMonitor | None = None
        try:
            self.native = LinuxInotifyMonitor(args)
        except OSError as exc:
            eprint(f"WATCH: native events unavailable ({exc}); using polling")
        self.reconcile_interval = max(
            1.0, float(args.watch_settings.watch_reconcile_interval)
        )
        self.next_reconcile = time.monotonic() + self.reconcile_interval

    @property
    def backend(self) -> str:
        return "inotify + polling reconciliation" if self.native else "polling"

    def wait_for_scan(self, timeout: float) -> bool:
        now = time.monotonic()
        until_reconcile = max(0.0, self.next_reconcile - now)
        wait_for = min(max(0.0, timeout), until_reconcile)
        native_changed = False
        if self.native is not None:
            native_changed = self.native.wait(wait_for)
        else:
            if _RUNTIME.cancel_event.wait(wait_for):
                check_cancelled()
        now = time.monotonic()
        reconcile = now >= self.next_reconcile
        if reconcile:
            self.next_reconcile = now + self.reconcile_interval
        return native_changed or reconcile or self.native is None

    def refresh(self) -> None:
        if self.native is not None:
            try:
                self.native.refresh()
            except OSError as exc:
                eprint(
                    f"WATCH: native watch refresh failed ({exc}); "
                    "polling reconciliation remains active"
                )

    def close(self) -> None:
        if self.native is not None:
            self.native.close()


def watch_source_fingerprint(args: BuildOptions) -> tuple[str, int]:
    """Return a stable, bounded-memory fingerprint of watched media files."""
    digest = hashlib.sha256()
    count = 0
    output_dir = args.output_settings.output.expanduser().resolve()
    if args.output_settings.cache is None:
        cache_path = output_dir / DEFAULT_CACHE
    else:
        requested_cache = args.output_settings.cache.expanduser().resolve()
        cache_path = (
            requested_cache.with_suffix(".sqlite3")
            if requested_cache.suffix.casefold() == ".json"
            else requested_cache
        )

    def include(path: Path) -> None:
        nonlocal count
        try:
            candidate = path.resolve(strict=True) if path.is_symlink() else path
            if not candidate.is_file():
                return
            resolved = candidate.resolve()
            if resolved == cache_path or is_relative_to(resolved, output_dir):
                return
            if resolved.suffix.casefold() not in CANDIDATE_EXTENSIONS:
                return
            stat = resolved.stat()
        except OSError:
            return
        digest.update(os.fsencode(str(resolved)))
        digest.update(b"\0")
        digest.update(
            struct.pack(
                "<QQQ",
                max(0, int(stat.st_size)),
                max(0, int(stat.st_mtime_ns)),
                max(0, int(getattr(stat, "st_ctime_ns", 0))),
            )
        )
        count += 1

    def scan_directory(root: Path) -> None:
        try:
            with os.scandir(root) as iterator:
                entries = sorted(iterator, key=lambda item: natural_key(item.name))
        except OSError:
            return
        directories: list[Path] = []
        for entry in entries:
            path = Path(entry.path)
            try:
                if entry.is_dir(follow_symlinks=False):
                    resolved = path.resolve()
                    if resolved == output_dir or is_relative_to(resolved, output_dir):
                        continue
                    directories.append(path)
                elif entry.is_file(follow_symlinks=False) or entry.is_symlink():
                    include(path)
            except OSError:
                continue
        if args.source.recursive:
            for directory in directories:
                scan_directory(directory)

    for raw in args.source.paths:
        try:
            root = raw.expanduser().resolve(strict=True)
        except OSError:
            digest.update(os.fsencode(f"missing:{raw}"))
            digest.update(b"\0")
            continue
        if root.is_file():
            include(root)
        elif root.is_dir():
            scan_directory(root)
    return digest.hexdigest(), count


def main() -> int:
    parser = build_parser()
    namespace = parser.parse_args()
    validate_cli_namespace(namespace, parser)
    if namespace.self_test:
        return run_self_test()
    if namespace.client_regression_test:
        return run_client_regression_test()

    namespace.password_protected = bool(
        namespace.password is not None
        or namespace.password_file is not None
        or namespace.password_env is not None
    )
    namespace.password_source = (
        "interactive prompt"
        if namespace.password is PASSWORD_PROMPT_SENTINEL
        else (
            "command line"
            if namespace.password is not None
            else (
                "password file"
                if namespace.password_file is not None
                else (
                    "environment variable"
                    if namespace.password_env is not None
                    else None
                )
            )
        )
    )

    resolved_password: str | None = None
    if not namespace.doctor and not namespace.dry_run:
        resolved_password, password_source = resolve_gallery_password(
            namespace,
            parser,
        )
        namespace.password_source = password_source
        namespace.password = None

    args = BuildOptions.from_namespace(namespace)

    result = run_build(args, parser, resolved_password)
    if result != 0 or not args.watch_settings.watch:
        return result

    baseline, watched_count = watch_source_fingerprint(args)
    monitor = HybridWatchMonitor(args)
    print(
        f"WATCH: monitoring {watched_count} media file(s) via "
        f"{monitor.backend}; reconciling every "
        f"{args.watch_settings.watch_reconcile_interval:g}s"
    )
    pending: str | None = None
    pending_since = 0.0
    try:
        while True:
            now = time.monotonic()
            pending_due = (
                pending is not None
                and now - pending_since >= args.watch_settings.watch_debounce
            )
            if pending is None:
                wait_for = args.watch_settings.watch_interval
            else:
                wait_for = min(
                    args.watch_settings.watch_interval,
                    max(0.0, pending_since + args.watch_settings.watch_debounce - now),
                )
            should_scan = monitor.wait_for_scan(wait_for)
            check_cancelled()
            now = time.monotonic()
            pending_due = (
                pending is not None
                and now - pending_since >= args.watch_settings.watch_debounce
            )
            if not should_scan and not pending_due:
                continue

            current, watched_count = watch_source_fingerprint(args)
            monitor.refresh()
            now = time.monotonic()
            if current == baseline:
                pending = None
                continue
            if current != pending:
                pending = current
                pending_since = now
                if args.watch_settings.watch_debounce > 0:
                    continue
            if now - pending_since < args.watch_settings.watch_debounce:
                continue

            target = current
            print("WATCH: stable source change detected; rebuilding...")
            result = run_build(args, parser, resolved_password)
            after_build, watched_count = watch_source_fingerprint(args)
            monitor.refresh()
            if result != 0:
                eprint(
                    f"WATCH: rebuild exited with status {result}; "
                    "continuing to monitor sources"
                )
                baseline = after_build
                pending = None
                continue
            if after_build != target:
                print(
                    "WATCH: sources changed during the build; "
                    "coalescing one follow-up rebuild"
                )
                baseline = target
                pending = after_build
                pending_since = time.monotonic() - args.watch_settings.watch_debounce
                continue
            baseline = after_build
            pending = None
    finally:
        monitor.close()


if __name__ == "__main__":
    with gallery_temp_workspace(), termination_signal_handlers():
        try:
            exit_code = main()
        except GalleryError as exc:
            DIAGNOSTICS.error(str(exc), code=exc.code)
            exit_code = 2
        except (KeyboardInterrupt, ProcessingCancelled):
            _RUNTIME.cancel_event.set()
            terminate_active_processes()
            eprint(f"\n{cancellation_label()}; temporary files were cleaned up.")
            exit_code = cancellation_exit_code()
    raise SystemExit(exit_code)

