from __future__ import annotations

import csv
import hashlib
import json
import math
import pickle
import tempfile
from dataclasses import dataclass, field
from datetime import datetime, timezone
from json import JSONDecodeError
from pathlib import Path
from typing import Any, Mapping, Sequence

import duckdb
import numpy as np

from ._version import __version__


PREPARED_SCHEMA_VERSION = "1.2.0"
SUPPORTED_OUTPUT_MODES = (
    "target_sensor",
    "vnir_spectrum",
    "swir_spectrum",
    "full_spectrum",
)
SUPPORTED_NEIGHBOR_ESTIMATORS = (
    "mean",
    "distance_weighted_mean",
    "simplex_mixture",
)
SUPPORTED_KNN_BACKENDS = (
    "numpy",
    "scipy_ckdtree",
    "faiss",
    "pynndescent",
    "scann",
)
SUPPORTED_PERSISTED_KNN_INDEX_BACKENDS = (
    "faiss",
    "pynndescent",
    "scann",
)
KNN_BACKEND_INSTALL_HINTS = {
    "scipy_ckdtree": 'pip install "spectral-library[knn]"',
    "faiss": 'pip install "spectral-library[knn-faiss]"',
    "pynndescent": 'pip install "spectral-library[knn-pynndescent]"',
    "scann": 'pip install "spectral-library[knn-scann]"',
}
SEGMENTS = ("vnir", "swir")
CANONICAL_START_NM = 400
CANONICAL_END_NM = 2500
VNIR_START_NM = 400
VNIR_END_NM = 1000
SWIR_START_NM = 800
SWIR_END_NM = 2500
FULL_BLEND_START_NM = 800
FULL_BLEND_END_NM = 1000
MAX_INTERNAL_INTERPOLATED_GAP_COUNT = 8
MAX_INTERNAL_INTERPOLATED_RUN_COUNT = 8

CANONICAL_WAVELENGTHS = np.arange(CANONICAL_START_NM, CANONICAL_END_NM + 1, dtype=np.int32)
VNIR_WAVELENGTHS = np.arange(VNIR_START_NM, VNIR_END_NM + 1, dtype=np.int32)
SWIR_WAVELENGTHS = np.arange(SWIR_START_NM, SWIR_END_NM + 1, dtype=np.int32)
SEGMENT_RANGES = {
    "vnir": (VNIR_START_NM, VNIR_END_NM),
    "swir": (SWIR_START_NM, SWIR_END_NM),
}
SEGMENT_WAVELENGTHS = {
    "vnir": VNIR_WAVELENGTHS,
    "swir": SWIR_WAVELENGTHS,
}
SEGMENT_FILE_NAMES = {
    "vnir": "hyperspectral_vnir.npy",
    "swir": "hyperspectral_swir.npy",
}
FULL_WAVELENGTH_COUNT = int(CANONICAL_WAVELENGTHS.size)


class SpectralLibraryError(Exception):
    def __init__(self, code: str, message: str, *, context: Mapping[str, object] | None = None) -> None:
        super().__init__(message)
        self.code = code
        self.message = message
        self.context = dict(context or {})

    def to_dict(self, *, command: str | None = None) -> dict[str, object]:
        payload: dict[str, object] = {
            "error_code": self.code,
            "message": self.message,
        }
        if command:
            payload["command"] = command
        if self.context:
            payload["context"] = self.context
        return payload

    def __str__(self) -> str:
        return self.message


class SensorSchemaError(SpectralLibraryError):
    def __init__(self, message: str, *, context: Mapping[str, object] | None = None) -> None:
        super().__init__("invalid_sensor_schema", message, context=context)


class PreparedLibraryBuildError(SpectralLibraryError):
    def __init__(self, message: str, *, context: Mapping[str, object] | None = None) -> None:
        super().__init__("prepare_failed", message, context=context)


class PreparedLibraryValidationError(SpectralLibraryError):
    def __init__(self, message: str, *, context: Mapping[str, object] | None = None) -> None:
        super().__init__("invalid_prepared_library", message, context=context)


class PreparedLibraryCompatibilityError(SpectralLibraryError):
    def __init__(self, message: str, *, context: Mapping[str, object] | None = None) -> None:
        super().__init__("prepared_library_incompatible", message, context=context)


class MappingInputError(SpectralLibraryError):
    def __init__(self, message: str, *, context: Mapping[str, object] | None = None) -> None:
        super().__init__("invalid_mapping_input", message, context=context)


@dataclass(frozen=True)
class SensorBandDefinition:
    band_id: str
    segment: str
    wavelength_nm: tuple[float, ...]
    rsr: tuple[float, ...]
    center_nm: float | None = None
    fwhm_nm: float | None = None
    support_min_nm: float | None = None
    support_max_nm: float | None = None

    def __post_init__(self) -> None:
        if not self.band_id:
            raise SensorSchemaError("Sensor band_id must be non-empty.")
        if self.segment not in SEGMENTS:
            raise SensorSchemaError(
                "Sensor segment must be either 'vnir' or 'swir'.",
                context={"band_id": self.band_id, "segment": self.segment},
            )
        if len(self.wavelength_nm) != len(self.rsr):
            raise SensorSchemaError(
                "Sensor wavelength_nm and rsr arrays must have the same length.",
                context={"band_id": self.band_id},
            )
        if len(self.wavelength_nm) == 0:
            raise SensorSchemaError("Sensor bands must define at least one SRF sample.", context={"band_id": self.band_id})

        wavelengths = np.asarray(self.wavelength_nm, dtype=np.float64)
        rsr = np.asarray(self.rsr, dtype=np.float64)
        if not np.all(np.isfinite(wavelengths)) or not np.all(np.isfinite(rsr)):
            raise SensorSchemaError("Sensor SRF values must be finite.", context={"band_id": self.band_id})
        if np.any(np.diff(wavelengths) <= 0):
            raise SensorSchemaError("Sensor wavelengths must be strictly increasing.", context={"band_id": self.band_id})
        if np.all(rsr <= 0):
            raise SensorSchemaError(
                "Sensor SRF values must include at least one positive support sample.",
                context={"band_id": self.band_id},
            )

        support_min = self.support_min_nm if self.support_min_nm is not None else float(wavelengths[rsr > 0].min())
        support_max = self.support_max_nm if self.support_max_nm is not None else float(wavelengths[rsr > 0].max())
        segment_min, segment_max = SEGMENT_RANGES[self.segment]
        tolerance = 1e-6
        if support_min < segment_min - tolerance or support_max > segment_max + tolerance:
            raise SensorSchemaError(
                "Sensor band support must stay inside its declared segment range.",
                context={
                    "band_id": self.band_id,
                    "segment": self.segment,
                    "support_min_nm": support_min,
                    "support_max_nm": support_max,
                },
            )

    @classmethod
    def from_dict(cls, payload: Mapping[str, object]) -> "SensorBandDefinition":
        if "band_id" not in payload:
            raise SensorSchemaError("Sensor band definition is missing band_id.")
        if "segment" not in payload:
            raise SensorSchemaError("Sensor band definition is missing segment.", context={"band_id": payload.get("band_id")})
        if "wavelength_nm" not in payload or "rsr" not in payload:
            raise SensorSchemaError(
                "Sensor band definition must include wavelength_nm and rsr arrays.",
                context={"band_id": payload.get("band_id")},
            )

        wavelengths = tuple(float(value) for value in payload["wavelength_nm"])  # type: ignore[index]
        rsr = tuple(float(value) for value in payload["rsr"])  # type: ignore[index]
        ordered_pairs = tuple(sorted(zip(wavelengths, rsr), key=lambda item: item[0]))
        ordered_wavelengths = tuple(pair[0] for pair in ordered_pairs)
        ordered_rsr = tuple(pair[1] for pair in ordered_pairs)
        positive_support = [wavelength for wavelength, weight in ordered_pairs if weight > 0]

        return cls(
            band_id=str(payload["band_id"]),
            segment=str(payload["segment"]),
            wavelength_nm=ordered_wavelengths,
            rsr=ordered_rsr,
            center_nm=_optional_float(payload.get("center_nm")),
            fwhm_nm=_optional_float(payload.get("fwhm_nm")),
            support_min_nm=(
                _optional_float(payload.get("support_min_nm"))
                if _optional_float(payload.get("support_min_nm")) is not None
                else (min(positive_support) if positive_support else None)
            ),
            support_max_nm=(
                _optional_float(payload.get("support_max_nm"))
                if _optional_float(payload.get("support_max_nm")) is not None
                else (max(positive_support) if positive_support else None)
            ),
        )

    def to_dict(self) -> dict[str, object]:
        payload: dict[str, object] = {
            "band_id": self.band_id,
            "segment": self.segment,
            "wavelength_nm": list(self.wavelength_nm),
            "rsr": list(self.rsr),
        }
        if self.center_nm is not None:
            payload["center_nm"] = self.center_nm
        if self.fwhm_nm is not None:
            payload["fwhm_nm"] = self.fwhm_nm
        if self.support_min_nm is not None:
            payload["support_min_nm"] = self.support_min_nm
        if self.support_max_nm is not None:
            payload["support_max_nm"] = self.support_max_nm
        return payload


@dataclass(frozen=True)
class SensorSRFSchema:
    sensor_id: str
    bands: tuple[SensorBandDefinition, ...]

    def __post_init__(self) -> None:
        if not self.sensor_id:
            raise SensorSchemaError("Sensor schema sensor_id must be non-empty.")
        if not self.bands:
            raise SensorSchemaError("Sensor schema must include at least one band.", context={"sensor_id": self.sensor_id})
        band_ids = [band.band_id for band in self.bands]
        if len(set(band_ids)) != len(band_ids):
            raise SensorSchemaError("Sensor band_id values must be unique.", context={"sensor_id": self.sensor_id})

    @classmethod
    def from_dict(cls, payload: Mapping[str, object]) -> "SensorSRFSchema":
        if "sensor_id" not in payload:
            raise SensorSchemaError("Sensor schema is missing sensor_id.")
        if "bands" not in payload:
            raise SensorSchemaError("Sensor schema is missing bands.", context={"sensor_id": payload.get("sensor_id")})
        bands = tuple(SensorBandDefinition.from_dict(band_payload) for band_payload in payload["bands"])  # type: ignore[index]
        return cls(sensor_id=str(payload["sensor_id"]), bands=bands)

    def to_dict(self) -> dict[str, object]:
        return {
            "sensor_id": self.sensor_id,
            "bands": [band.to_dict() for band in self.bands],
        }

    def band_ids(self) -> tuple[str, ...]:
        return tuple(band.band_id for band in self.bands)

    def bands_for_segment(self, segment: str) -> tuple[SensorBandDefinition, ...]:
        if segment not in SEGMENTS:
            raise SensorSchemaError("Unknown sensor segment.", context={"segment": segment, "sensor_id": self.sensor_id})
        return tuple(band for band in self.bands if band.segment == segment)


@dataclass(frozen=True)
class PreparedLibraryManifest:
    schema_version: str
    package_version: str
    source_siac_root: str
    source_siac_build_id: str
    prepared_at: str
    source_sensors: tuple[str, ...]
    supported_output_modes: tuple[str, ...]
    row_count: int
    vnir_wavelength_range_nm: tuple[int, int]
    swir_wavelength_range_nm: tuple[int, int]
    array_dtype: str
    file_checksums: dict[str, str] = field(default_factory=dict)
    knn_index_artifacts: dict[str, dict[str, dict[str, str]]] = field(default_factory=dict)
    interpolation_summary: dict[str, int] = field(default_factory=dict)

    @classmethod
    def from_dict(cls, payload: Mapping[str, object]) -> "PreparedLibraryManifest":
        return cls(
            schema_version=str(payload["schema_version"]),
            package_version=str(payload["package_version"]),
            source_siac_root=str(payload["source_siac_root"]),
            source_siac_build_id=str(payload["source_siac_build_id"]),
            prepared_at=str(payload["prepared_at"]),
            source_sensors=tuple(str(value) for value in payload["source_sensors"]),  # type: ignore[index]
            supported_output_modes=tuple(str(value) for value in payload["supported_output_modes"]),  # type: ignore[index]
            row_count=int(payload["row_count"]),
            vnir_wavelength_range_nm=tuple(int(value) for value in payload["vnir_wavelength_range_nm"]),  # type: ignore[index]
            swir_wavelength_range_nm=tuple(int(value) for value in payload["swir_wavelength_range_nm"]),  # type: ignore[index]
            array_dtype=str(payload["array_dtype"]),
            file_checksums={str(key): str(value) for key, value in dict(payload["file_checksums"]).items()},  # type: ignore[arg-type]
            knn_index_artifacts={
                str(backend): {
                    str(sensor_id): {
                        str(segment): str(path)
                        for segment, path in dict(sensor_payload).items()
                    }
                    for sensor_id, sensor_payload in dict(backend_payload).items()
                }
                for backend, backend_payload in dict(payload.get("knn_index_artifacts") or {}).items()  # type: ignore[arg-type]
            },
            interpolation_summary={
                str(key): int(value)
                for key, value in dict(payload.get("interpolation_summary") or {}).items()  # type: ignore[arg-type]
            },
        )

    @classmethod
    def from_json(cls, path: Path) -> "PreparedLibraryManifest":
        payload = _read_json_document(
            path,
            error_factory=PreparedLibraryValidationError,
            document_name=path.name,
        )
        if not isinstance(payload, dict):
            raise PreparedLibraryValidationError(
                f"{path.name} must contain a JSON object.",
                context={"path": str(path)},
            )
        try:
            return cls.from_dict(payload)
        except (KeyError, TypeError, ValueError) as exc:
            raise PreparedLibraryValidationError(
                f"{path.name} is missing required fields or contains invalid values.",
                context={"path": str(path)},
            ) from exc

    def to_dict(self) -> dict[str, object]:
        return {
            "schema_version": self.schema_version,
            "package_version": self.package_version,
            "source_siac_root": self.source_siac_root,
            "source_siac_build_id": self.source_siac_build_id,
            "prepared_at": self.prepared_at,
            "source_sensors": list(self.source_sensors),
            "supported_output_modes": list(self.supported_output_modes),
            "row_count": self.row_count,
            "vnir_wavelength_range_nm": list(self.vnir_wavelength_range_nm),
            "swir_wavelength_range_nm": list(self.swir_wavelength_range_nm),
            "array_dtype": self.array_dtype,
            "file_checksums": dict(self.file_checksums),
            "knn_index_artifacts": {
                backend: {
                    sensor_id: dict(sensor_payload)
                    for sensor_id, sensor_payload in backend_payload.items()
                }
                for backend, backend_payload in self.knn_index_artifacts.items()
            },
            **(
                {"interpolation_summary": dict(self.interpolation_summary)}
                if self.interpolation_summary
                else {}
            ),
        }


@dataclass
class MappingResult:
    target_reflectance: np.ndarray | None = None
    target_band_ids: tuple[str, ...] = ()
    reconstructed_vnir: np.ndarray | None = None
    reconstructed_swir: np.ndarray | None = None
    reconstructed_full_spectrum: np.ndarray | None = None
    reconstructed_wavelength_nm: np.ndarray | None = None
    neighbor_ids_by_segment: dict[str, tuple[str, ...]] = field(default_factory=dict)
    neighbor_distances_by_segment: dict[str, np.ndarray] = field(default_factory=dict)
    segment_outputs: dict[str, np.ndarray] = field(default_factory=dict)
    segment_valid_band_counts: dict[str, int] = field(default_factory=dict)
    diagnostics: dict[str, object] = field(default_factory=dict)

    def to_summary_dict(self) -> dict[str, object]:
        return {
            "target_band_ids": list(self.target_band_ids),
            "segment_valid_band_counts": dict(self.segment_valid_band_counts),
            "neighbor_ids_by_segment": {
                segment: list(neighbor_ids) for segment, neighbor_ids in self.neighbor_ids_by_segment.items()
            },
            "neighbor_distances_by_segment": {
                segment: [float(value) for value in distances]
                for segment, distances in self.neighbor_distances_by_segment.items()
            },
            "diagnostics": self.diagnostics,
        }


@dataclass
class BatchMappingResult:
    sample_ids: tuple[str, ...]
    results: tuple[MappingResult, ...]

    def to_summary_dict(self) -> dict[str, object]:
        return {
            "sample_ids": list(self.sample_ids),
            "results": [
                {"sample_id": sample_id, **result.to_summary_dict()}
                for sample_id, result in zip(self.sample_ids, self.results)
            ],
        }


@dataclass
class _SegmentRetrieval:
    segment: str
    valid_band_count: int
    query_band_ids: tuple[str, ...]
    success: bool
    query_band_values: np.ndarray = field(default_factory=lambda: np.empty(0, dtype=np.float64))
    query_valid_mask: np.ndarray = field(default_factory=lambda: np.empty(0, dtype=bool))
    reconstructed: np.ndarray | None = None
    neighbor_indices: np.ndarray = field(default_factory=lambda: np.empty(0, dtype=np.int64))
    neighbor_ids: tuple[str, ...] = ()
    neighbor_distances: np.ndarray = field(default_factory=lambda: np.empty(0, dtype=np.float64))
    neighbor_weights: np.ndarray = field(default_factory=lambda: np.empty(0, dtype=np.float64))
    neighbor_band_values: np.ndarray = field(default_factory=lambda: np.empty((0, 0), dtype=np.float64))
    source_fit_rmse: float | None = None
    confidence_score: float | None = None
    confidence_components: dict[str, float] = field(default_factory=dict)
    reason: str | None = None


def _optional_float(value: object) -> float | None:
    if value is None:
        return None
    if isinstance(value, str) and not value.strip():
        return None
    return float(value)


def _default_sample_id(index: int) -> str:
    return f"sample_{index + 1:06d}"


def _attach_sample_context(error: SpectralLibraryError, *, sample_id: str, sample_index: int) -> SpectralLibraryError:
    context = {"sample_id": sample_id, "sample_index": sample_index, **error.context}
    if type(error) is SpectralLibraryError:
        return SpectralLibraryError(error.code, error.message, context=context)
    return error.__class__(error.message, context=context)


def _normalized_sample_ids(sample_ids: Sequence[str] | None, *, sample_count: int) -> tuple[str, ...]:
    if sample_count < 1:
        raise MappingInputError("Batch mapping requires at least one reflectance sample.")
    if sample_ids is None:
        return tuple(_default_sample_id(index) for index in range(sample_count))

    if len(sample_ids) != sample_count:
        raise MappingInputError(
            "sample_ids must have the same length as the reflectance batch.",
            context={"sample_count": sample_count, "sample_id_count": len(sample_ids)},
        )

    normalized: list[str] = []
    for index, sample_id in enumerate(sample_ids):
        text = str(sample_id).strip()
        if not text:
            raise MappingInputError("sample_ids must be non-empty.", context={"sample_index": index})
        normalized.append(text)

    if len(set(normalized)) != len(normalized):
        raise MappingInputError("sample_ids must be unique within a batch.")
    return tuple(normalized)


def _segment_slice(segment: str) -> slice:
    start_nm, end_nm = SEGMENT_RANGES[segment]
    start_index = start_nm - CANONICAL_START_NM
    stop_index = end_nm - CANONICAL_START_NM + 1
    return slice(start_index, stop_index)


def _segment_wavelengths(segment: str) -> np.ndarray:
    return SEGMENT_WAVELENGTHS[segment]


def _blend_overlap(vnir: np.ndarray, swir: np.ndarray) -> np.ndarray:
    overlap_wavelengths = np.arange(FULL_BLEND_START_NM, FULL_BLEND_END_NM + 1, dtype=np.float64)
    weights = (FULL_BLEND_END_NM - overlap_wavelengths) / (FULL_BLEND_END_NM - FULL_BLEND_START_NM)
    vnir_overlap_start = FULL_BLEND_START_NM - VNIR_START_NM
    swir_overlap_start = FULL_BLEND_START_NM - SWIR_START_NM
    overlap_length = overlap_wavelengths.size
    return (
        weights * vnir[vnir_overlap_start : vnir_overlap_start + overlap_length]
        + (1.0 - weights) * swir[swir_overlap_start : swir_overlap_start + overlap_length]
    )


def _assemble_full_spectrum(vnir: np.ndarray, swir: np.ndarray) -> np.ndarray:
    full = np.empty(FULL_WAVELENGTH_COUNT, dtype=np.float64)
    blend_start = FULL_BLEND_START_NM - CANONICAL_START_NM
    blend_stop = FULL_BLEND_END_NM - CANONICAL_START_NM + 1
    swir_overlap_stop = FULL_BLEND_END_NM - SWIR_START_NM + 1
    full[:blend_start] = vnir[:blend_start]
    full[blend_start:blend_stop] = _blend_overlap(vnir, swir)
    full[blend_stop:] = swir[swir_overlap_stop:]
    return full


def _assemble_full_spectrum_batch(vnir: np.ndarray, swir: np.ndarray) -> np.ndarray:
    full = np.empty((vnir.shape[0], FULL_WAVELENGTH_COUNT), dtype=np.float64)
    blend_start = FULL_BLEND_START_NM - CANONICAL_START_NM
    blend_stop = FULL_BLEND_END_NM - CANONICAL_START_NM + 1
    vnir_overlap_start = FULL_BLEND_START_NM - VNIR_START_NM
    swir_overlap_start = FULL_BLEND_START_NM - SWIR_START_NM
    swir_overlap_stop = FULL_BLEND_END_NM - SWIR_START_NM + 1
    full[:, :blend_start] = vnir[:, :blend_start]
    overlap_wavelengths = np.arange(FULL_BLEND_START_NM, FULL_BLEND_END_NM + 1, dtype=np.float64)
    weights = (FULL_BLEND_END_NM - overlap_wavelengths) / (FULL_BLEND_END_NM - FULL_BLEND_START_NM)
    overlap_length = overlap_wavelengths.size
    full[:, blend_start:blend_stop] = (
        weights * vnir[:, vnir_overlap_start : vnir_overlap_start + overlap_length]
        + (1.0 - weights) * swir[:, swir_overlap_start : swir_overlap_start + overlap_length]
    )
    full[:, blend_stop:] = swir[:, swir_overlap_stop:]
    return full


def _ensure_supported_output_mode(output_mode: str) -> None:
    if output_mode not in SUPPORTED_OUTPUT_MODES:
        raise MappingInputError(
            "Unsupported output_mode.",
            context={"output_mode": output_mode, "supported_output_modes": list(SUPPORTED_OUTPUT_MODES)},
        )


def _validate_mapping_request(
    output_mode: str,
    *,
    k: int,
    min_valid_bands: int,
    neighbor_estimator: str = "mean",
    knn_backend: str = "numpy",
    knn_eps: float = 0.0,
) -> None:
    _ensure_supported_output_mode(output_mode)
    if k < 1:
        raise MappingInputError("k must be at least 1.", context={"k": k})
    if min_valid_bands < 1:
        raise MappingInputError("min_valid_bands must be at least 1.", context={"min_valid_bands": min_valid_bands})
    if neighbor_estimator not in SUPPORTED_NEIGHBOR_ESTIMATORS:
        raise MappingInputError(
            "neighbor_estimator is not supported.",
            context={
                "neighbor_estimator": neighbor_estimator,
                "supported_neighbor_estimators": list(SUPPORTED_NEIGHBOR_ESTIMATORS),
            },
        )
    if knn_backend not in SUPPORTED_KNN_BACKENDS:
        raise MappingInputError(
            "knn_backend is not supported.",
            context={
                "knn_backend": knn_backend,
                "supported_knn_backends": list(SUPPORTED_KNN_BACKENDS),
            },
        )
    if knn_eps < 0:
        raise MappingInputError("knn_eps must be non-negative.", context={"knn_eps": knn_eps})


def _normalized_source_sensors(source_sensors: Sequence[str]) -> list[str]:
    normalized: list[str] = []
    for sensor_id in source_sensors:
        text = str(sensor_id).strip()
        if text and text not in normalized:
            normalized.append(text)
    if not normalized:
        raise PreparedLibraryBuildError("At least one source sensor must be provided.")
    return normalized


def _normalized_knn_index_backends(knn_index_backends: Sequence[str] | None) -> list[str]:
    if not knn_index_backends:
        return []
    normalized: list[str] = []
    for backend in knn_index_backends:
        text = str(backend).strip()
        if not text:
            continue
        if text not in SUPPORTED_PERSISTED_KNN_INDEX_BACKENDS:
            raise PreparedLibraryBuildError(
                "Requested knn_index_backend is not supported for persisted indexes.",
                context={
                    "knn_index_backend": text,
                    "supported_knn_index_backends": list(SUPPORTED_PERSISTED_KNN_INDEX_BACKENDS),
                },
            )
        if text not in normalized:
            normalized.append(text)
    return normalized


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


def _sha256_runtime_path(path: Path) -> str:
    if path.is_file():
        return _sha256_file(path)
    if path.is_dir():
        digest = hashlib.sha256()
        for child in sorted(candidate for candidate in path.rglob("*") if candidate.is_file()):
            digest.update(str(child.relative_to(path)).encode("utf-8"))
            with child.open("rb") as handle:
                while True:
                    chunk = handle.read(1024 * 1024)
                    if not chunk:
                        break
                    digest.update(chunk)
        return digest.hexdigest()
    raise FileNotFoundError(path)


def _sha256_paths(paths: Sequence[Path]) -> str:
    digest = hashlib.sha256()
    for path in sorted(paths):
        digest.update(path.name.encode("utf-8"))
        with path.open("rb") as handle:
            while True:
                chunk = handle.read(1024 * 1024)
                if not chunk:
                    break
                digest.update(chunk)
    return digest.hexdigest()


def _longest_true_run(mask: np.ndarray) -> int:
    longest = 0
    current = 0
    for value in np.asarray(mask, dtype=bool):
        if bool(value):
            current += 1
            if current > longest:
                longest = current
        else:
            current = 0
    return int(longest)


def _summarize_missing_gap_counts(spectrum: np.ndarray) -> dict[str, int]:
    missing_mask = np.isnan(spectrum)
    if not missing_mask.any():
        return {
            "missing_count": 0,
            "leading_gap_count": 0,
            "trailing_gap_count": 0,
            "internal_gap_count": 0,
            "max_internal_gap_run_count": 0,
        }

    leading_gap_count = 0
    while leading_gap_count < missing_mask.size and bool(missing_mask[leading_gap_count]):
        leading_gap_count += 1

    trailing_gap_count = 0
    while trailing_gap_count < missing_mask.size and bool(missing_mask[missing_mask.size - 1 - trailing_gap_count]):
        trailing_gap_count += 1

    internal_start = leading_gap_count
    internal_end = missing_mask.size - trailing_gap_count
    internal_mask = np.asarray(missing_mask[internal_start:internal_end], dtype=bool)
    return {
        "missing_count": int(missing_mask.sum()),
        "leading_gap_count": int(leading_gap_count),
        "trailing_gap_count": int(trailing_gap_count),
        "internal_gap_count": int(internal_mask.sum()),
        "max_internal_gap_run_count": _longest_true_run(internal_mask),
    }


def _empty_interpolation_summary() -> dict[str, int]:
    return {
        "interpolated_row_count": 0,
        "rows_with_leading_gaps": 0,
        "rows_with_trailing_gaps": 0,
        "rows_with_internal_gaps": 0,
        "max_missing_count": 0,
        "max_leading_gap_count": 0,
        "max_trailing_gap_count": 0,
        "max_internal_gap_count": 0,
        "max_internal_gap_run_count": 0,
    }


def _update_interpolation_summary(summary: dict[str, int], gap_counts: Mapping[str, int]) -> None:
    summary["interpolated_row_count"] += 1
    if int(gap_counts["leading_gap_count"]) > 0:
        summary["rows_with_leading_gaps"] += 1
    if int(gap_counts["trailing_gap_count"]) > 0:
        summary["rows_with_trailing_gaps"] += 1
    if int(gap_counts["internal_gap_count"]) > 0:
        summary["rows_with_internal_gaps"] += 1
    summary["max_missing_count"] = max(summary["max_missing_count"], int(gap_counts["missing_count"]))
    summary["max_leading_gap_count"] = max(summary["max_leading_gap_count"], int(gap_counts["leading_gap_count"]))
    summary["max_trailing_gap_count"] = max(summary["max_trailing_gap_count"], int(gap_counts["trailing_gap_count"]))
    summary["max_internal_gap_count"] = max(summary["max_internal_gap_count"], int(gap_counts["internal_gap_count"]))
    summary["max_internal_gap_run_count"] = max(
        summary["max_internal_gap_run_count"],
        int(gap_counts["max_internal_gap_run_count"]),
    )


def _ordered_neighbor_rows(
    distances: np.ndarray,
    candidate_row_indices: np.ndarray,
    *,
    k: int,
) -> tuple[np.ndarray, np.ndarray]:
    neighbor_count = min(int(k), int(candidate_row_indices.size))
    if neighbor_count <= 0:
        raise MappingInputError("k must be at least 1.", context={"k": k})
    if neighbor_count == candidate_row_indices.size:
        local_top = np.arange(candidate_row_indices.size)
    else:
        local_top = np.argpartition(distances, neighbor_count - 1)[:neighbor_count]
    ordered_local = local_top[np.lexsort((candidate_row_indices[local_top], distances[local_top]))]
    return candidate_row_indices[ordered_local], np.asarray(distances[ordered_local], dtype=np.float64)


def _load_ckdtree_class() -> type[Any]:
    try:
        from scipy.spatial import cKDTree  # type: ignore[import-not-found]
    except ModuleNotFoundError as exc:
        raise MappingInputError(
            f'scipy_ckdtree backend requires scipy. Install it with `{KNN_BACKEND_INSTALL_HINTS["scipy_ckdtree"]}`.',
            context={"knn_backend": "scipy_ckdtree"},
        ) from exc
    return cKDTree


def _load_faiss_module() -> Any:
    try:
        import faiss  # type: ignore[import-not-found]
    except ModuleNotFoundError as exc:
        raise MappingInputError(
            f'faiss backend requires faiss-cpu. Install it with `{KNN_BACKEND_INSTALL_HINTS["faiss"]}`.',
            context={"knn_backend": "faiss"},
        ) from exc
    return faiss


def _load_pynndescent_class() -> type[Any]:
    try:
        from pynndescent import NNDescent  # type: ignore[import-not-found]
    except ModuleNotFoundError as exc:
        raise MappingInputError(
            f'pynndescent backend requires pynndescent. Install it with `{KNN_BACKEND_INSTALL_HINTS["pynndescent"]}`.',
            context={"knn_backend": "pynndescent"},
        ) from exc
    return NNDescent


def _load_scann_ops() -> Any:
    try:
        import scann  # type: ignore[import-not-found]
    except ModuleNotFoundError as exc:
        raise MappingInputError(
            f'scann backend requires ScaNN. Install it with `{KNN_BACKEND_INSTALL_HINTS["scann"]}`.',
            context={"knn_backend": "scann"},
        ) from exc
    return scann.scann_ops_pybind


def _persist_faiss_index(candidate_matrix: np.ndarray, output_path: Path) -> None:
    faiss = _load_faiss_module()
    output_path.parent.mkdir(parents=True, exist_ok=True)
    index = faiss.IndexHNSWFlat(int(candidate_matrix.shape[1]), 32)
    if hasattr(index, "hnsw"):
        index.hnsw.efConstruction = max(40, min(int(candidate_matrix.shape[0]), 320))
    index.add(np.asarray(candidate_matrix, dtype=np.float32))
    faiss.write_index(index, str(output_path))


def _load_persisted_faiss_index(path: Path) -> Any:
    faiss = _load_faiss_module()
    return faiss.read_index(str(path))


def _persist_pynndescent_index(candidate_matrix: np.ndarray, output_path: Path) -> None:
    NNDescent = _load_pynndescent_class()
    output_path.parent.mkdir(parents=True, exist_ok=True)
    index = NNDescent(np.asarray(candidate_matrix, dtype=np.float32), metric="euclidean")
    if hasattr(index, "prepare"):
        index.prepare()
    with output_path.open("wb") as handle:
        pickle.dump(index, handle)


def _load_persisted_pynndescent_index(path: Path) -> Any:
    with path.open("rb") as handle:
        return pickle.load(handle)


def _persist_scann_index(candidate_matrix: np.ndarray, output_path: Path) -> None:
    searcher = _build_scann_searcher(
        np.asarray(candidate_matrix, dtype=np.float32),
        neighbor_count=min(int(candidate_matrix.shape[0]), 64),
        knn_eps=0.0,
    )
    output_path.mkdir(parents=True, exist_ok=True)
    if hasattr(searcher, "serialize"):
        searcher.serialize(str(output_path))
        return
    raise PreparedLibraryBuildError(
        "scann searcher does not support serialization in this environment.",
        context={"knn_backend": "scann", "path": str(output_path)},
    )


def _load_persisted_scann_index(path: Path) -> Any:
    scann_ops = _load_scann_ops()
    if hasattr(scann_ops, "load_searcher"):
        return scann_ops.load_searcher(str(path))
    raise MappingInputError(
        "scann backend does not support persisted searcher loading in this environment.",
        context={"knn_backend": "scann", "path": str(path)},
    )


def _persist_knn_index(candidate_matrix: np.ndarray, *, backend: str, output_path: Path) -> None:
    if backend == "faiss":
        _persist_faiss_index(candidate_matrix, output_path)
        return
    if backend == "pynndescent":
        _persist_pynndescent_index(candidate_matrix, output_path)
        return
    if backend == "scann":
        _persist_scann_index(candidate_matrix, output_path)
        return
    raise PreparedLibraryBuildError(
        "KNN index persistence is not supported for the requested backend.",
        context={"knn_backend": backend, "supported_knn_index_backends": list(SUPPORTED_PERSISTED_KNN_INDEX_BACKENDS)},
    )


def _load_persisted_knn_index(path: Path, *, backend: str) -> Any:
    if backend == "faiss":
        return _load_persisted_faiss_index(path)
    if backend == "pynndescent":
        return _load_persisted_pynndescent_index(path)
    if backend == "scann":
        return _load_persisted_scann_index(path)
    raise MappingInputError(
        "KNN index persistence is not supported for the requested backend.",
        context={"knn_backend": backend, "supported_knn_index_backends": list(SUPPORTED_PERSISTED_KNN_INDEX_BACKENDS)},
    )


def _normalize_query_matrix(query_values: np.ndarray, *, dtype: np.dtype[Any]) -> np.ndarray:
    query_matrix = np.asarray(query_values, dtype=dtype)
    if query_matrix.ndim == 1:
        return query_matrix.reshape(1, -1)
    if query_matrix.ndim != 2:
        raise MappingInputError(
            "Query values must be one-dimensional or two-dimensional.",
            context={"query_shape": list(query_matrix.shape)},
        )
    return query_matrix


def _normalize_local_indices(local_indices: np.ndarray, *, query_count: int) -> np.ndarray:
    normalized = np.asarray(local_indices, dtype=np.int64)
    if normalized.ndim == 0:
        return normalized.reshape(1, 1)
    if normalized.ndim == 1:
        return normalized.reshape(query_count, -1)
    if normalized.ndim != 2:
        raise MappingInputError(
            "Neighbor search backend returned indices with an unsupported shape.",
            context={"local_index_shape": list(normalized.shape)},
        )
    return normalized


def _build_scann_searcher(
    candidate_matrix: np.ndarray,
    *,
    neighbor_count: int,
    knn_eps: float,
) -> Any:
    scann_ops = _load_scann_ops()
    candidate_count = int(candidate_matrix.shape[0])
    num_leaves = max(1, min(candidate_count, int(round(math.sqrt(candidate_count)))))
    base_leaves_to_search = max(1, min(num_leaves, int(round(math.sqrt(num_leaves))) or 1))
    leaves_to_search = max(
        1,
        min(
            num_leaves,
            int(math.ceil(base_leaves_to_search / (1.0 + max(float(knn_eps), 0.0) * 4.0))),
        ),
    )
    training_sample_size = min(candidate_count, max(32, num_leaves * 10))
    builder = scann_ops.builder(np.asarray(candidate_matrix, dtype=np.float32), neighbor_count, "squared_l2")
    builder = builder.tree(
        num_leaves=num_leaves,
        num_leaves_to_search=leaves_to_search,
        training_sample_size=training_sample_size,
    )
    if hasattr(builder, "score_ah"):
        builder = builder.score_ah(2, anisotropic_quantization_threshold=0.2)
    if hasattr(builder, "reorder"):
        builder = builder.reorder(neighbor_count)
    return builder.build()


def _search_local_neighbor_indices(
    candidate_matrix: np.ndarray,
    query_values: np.ndarray,
    *,
    k: int,
    knn_backend: str,
    knn_eps: float,
) -> np.ndarray | None:
    if knn_backend == "numpy":
        return None

    neighbor_count = min(int(k), int(candidate_matrix.shape[0]))
    if neighbor_count <= 0:
        raise MappingInputError("k must be at least 1.", context={"k": k})

    query_count = int(_normalize_query_matrix(query_values, dtype=np.float64).shape[0])

    if knn_backend == "scipy_ckdtree":
        cKDTree = _load_ckdtree_class()
        tree = cKDTree(np.asarray(candidate_matrix, dtype=np.float64))
        _, local_indices = tree.query(
            _normalize_query_matrix(query_values, dtype=np.float64),
            k=neighbor_count,
            eps=float(knn_eps),
            workers=1,
        )
        return _normalize_local_indices(local_indices, query_count=query_count)

    if knn_backend == "faiss":
        faiss = _load_faiss_module()
        vector_dim = int(candidate_matrix.shape[1])
        query_matrix = _normalize_query_matrix(query_values, dtype=np.float32)
        index = faiss.IndexHNSWFlat(vector_dim, 32)
        if hasattr(index, "hnsw"):
            index.hnsw.efConstruction = max(40, neighbor_count * 8)
            index.hnsw.efSearch = max(
                neighbor_count,
                int(math.ceil(max(32, neighbor_count * 8) / (1.0 + max(float(knn_eps), 0.0) * 4.0))),
            )
        index.add(np.asarray(candidate_matrix, dtype=np.float32))
        _, local_indices = index.search(query_matrix, neighbor_count)
        return _normalize_local_indices(local_indices, query_count=query_count)

    if knn_backend == "pynndescent":
        NNDescent = _load_pynndescent_class()
        query_matrix = _normalize_query_matrix(query_values, dtype=np.float32)
        index = NNDescent(np.asarray(candidate_matrix, dtype=np.float32), metric="euclidean")
        local_indices, _ = index.query(query_matrix, k=neighbor_count, epsilon=float(knn_eps))
        return _normalize_local_indices(local_indices, query_count=query_count)

    if knn_backend == "scann":
        query_matrix = _normalize_query_matrix(query_values, dtype=np.float32)
        searcher = _build_scann_searcher(candidate_matrix, neighbor_count=neighbor_count, knn_eps=knn_eps)
        try:
            search_result = searcher.search_batched(query_matrix, final_num_neighbors=neighbor_count)
        except TypeError:
            search_result = searcher.search_batched(query_matrix)
        local_indices = search_result[0] if isinstance(search_result, tuple) else search_result
        return _normalize_local_indices(local_indices, query_count=query_count)

    raise MappingInputError(
        "knn_backend is not supported.",
        context={
            "knn_backend": knn_backend,
            "supported_knn_backends": list(SUPPORTED_KNN_BACKENDS),
        },
    )


def _query_persisted_knn_index(
    index: Any,
    query_values: np.ndarray,
    *,
    k: int,
    knn_backend: str,
    knn_eps: float,
) -> np.ndarray:
    query_count = int(_normalize_query_matrix(query_values, dtype=np.float64).shape[0])

    if knn_backend == "faiss":
        _, local_indices = index.search(_normalize_query_matrix(query_values, dtype=np.float32), int(k))
        return _normalize_local_indices(local_indices, query_count=query_count)

    if knn_backend == "pynndescent":
        local_indices, _ = index.query(
            _normalize_query_matrix(query_values, dtype=np.float32),
            k=int(k),
            epsilon=float(knn_eps),
        )
        return _normalize_local_indices(local_indices, query_count=query_count)

    if knn_backend == "scann":
        try:
            search_result = index.search_batched(
                _normalize_query_matrix(query_values, dtype=np.float32),
                final_num_neighbors=int(k),
            )
        except TypeError:
            search_result = index.search_batched(_normalize_query_matrix(query_values, dtype=np.float32))
        local_indices = search_result[0] if isinstance(search_result, tuple) else search_result
        return _normalize_local_indices(local_indices, query_count=query_count)

    raise MappingInputError(
        "Persisted KNN querying is not supported for the requested backend.",
        context={"knn_backend": knn_backend},
    )


def _refine_neighbor_rows(
    candidate_matrix: np.ndarray,
    query_vector: np.ndarray,
    candidate_row_indices: np.ndarray,
    local_candidate_indices: np.ndarray,
    *,
    k: int,
) -> tuple[np.ndarray, np.ndarray]:
    local_indices = np.asarray(local_candidate_indices, dtype=np.int64)
    local_indices = local_indices[(local_indices >= 0) & (local_indices < int(candidate_row_indices.size))]
    local_indices = np.unique(local_indices)
    if local_indices.size == 0:
        raise PreparedLibraryValidationError("Neighbor search backend returned no candidate rows.")
    found_row_indices = candidate_row_indices[local_indices]
    exact_distances = np.sqrt(np.mean((candidate_matrix[local_indices] - query_vector) ** 2, axis=1))
    return _ordered_neighbor_rows(exact_distances, found_row_indices, k=k)


def _search_neighbor_rows(
    candidate_matrix: np.ndarray,
    query_vector: np.ndarray,
    candidate_row_indices: np.ndarray,
    *,
    k: int,
    knn_backend: str,
    knn_eps: float,
) -> tuple[np.ndarray, np.ndarray]:
    if knn_backend == "numpy":
        distances = np.sqrt(np.mean((candidate_matrix - query_vector) ** 2, axis=1))
        return _ordered_neighbor_rows(distances, candidate_row_indices, k=k)

    local_indices = _search_local_neighbor_indices(
        candidate_matrix,
        query_vector,
        k=k,
        knn_backend=knn_backend,
        knn_eps=knn_eps,
    )
    if local_indices is None:
        raise PreparedLibraryValidationError("Neighbor search backend returned no candidate rows.")
    return _refine_neighbor_rows(candidate_matrix, query_vector, candidate_row_indices, local_indices[0], k=k)


def _combine_neighbor_spectra(
    hyperspectral_rows: np.ndarray,
    neighbor_indices: np.ndarray,
    neighbor_distances: np.ndarray,
    neighbor_band_values: np.ndarray,
    query_vector: np.ndarray,
    *,
    neighbor_estimator: str,
) -> tuple[np.ndarray, np.ndarray, float]:
    neighbor_spectra = np.asarray(hyperspectral_rows[neighbor_indices], dtype=np.float64)
    if neighbor_estimator == "mean":
        weights = np.full(neighbor_indices.shape[0], 1.0 / float(neighbor_indices.shape[0]), dtype=np.float64)
    elif neighbor_estimator == "distance_weighted_mean":
        exact_match_mask = np.asarray(neighbor_distances <= 1e-12, dtype=bool)
        if np.any(exact_match_mask):
            weights = np.asarray(exact_match_mask, dtype=np.float64)
            weights /= float(np.sum(weights))
        else:
            weights = 1.0 / np.asarray(neighbor_distances, dtype=np.float64)
            weights /= float(np.sum(weights))
    elif neighbor_estimator == "simplex_mixture":
        weights = _fit_simplex_neighbor_weights(
            np.asarray(neighbor_band_values, dtype=np.float64),
            np.asarray(query_vector, dtype=np.float64),
            np.asarray(neighbor_distances, dtype=np.float64),
        )
    else:
        raise MappingInputError(
            "neighbor_estimator is not supported.",
            context={
                "neighbor_estimator": neighbor_estimator,
                "supported_neighbor_estimators": list(SUPPORTED_NEIGHBOR_ESTIMATORS),
            },
        )
    reconstructed = np.asarray(weights @ neighbor_spectra, dtype=np.float64)
    source_prediction = np.asarray(weights @ np.asarray(neighbor_band_values, dtype=np.float64), dtype=np.float64)
    source_fit_rmse = float(np.sqrt(np.mean((source_prediction - np.asarray(query_vector, dtype=np.float64)) ** 2)))
    return reconstructed, np.asarray(weights, dtype=np.float64), source_fit_rmse


def _segment_confidence_payload(
    *,
    query_vector: np.ndarray,
    valid_band_count: int,
    total_band_count: int,
    neighbor_distances: np.ndarray,
    neighbor_weights: np.ndarray,
    source_fit_rmse: float | None,
) -> tuple[float, dict[str, float]]:
    if valid_band_count <= 0 or total_band_count <= 0 or neighbor_distances.size == 0:
        return 0.0, {
            "coverage": 0.0,
            "distance": 0.0,
            "fit": 0.0,
            "weight_concentration": 0.0,
        }
    query_scale = float(np.mean(np.abs(np.asarray(query_vector, dtype=np.float64)))) if query_vector.size else 0.0
    if not np.isfinite(query_scale) or query_scale <= 1e-12:
        query_scale = 1.0
    coverage = float(valid_band_count) / float(total_band_count)
    distance_score = math.exp(-float(np.mean(np.asarray(neighbor_distances, dtype=np.float64))) / query_scale)
    fit_score = math.exp(-float(source_fit_rmse or 0.0) / query_scale)
    weight_concentration = float(np.max(np.asarray(neighbor_weights, dtype=np.float64))) if neighbor_weights.size else 0.0
    confidence_score = max(
        0.0,
        min(
            1.0,
            0.25 * coverage + 0.35 * distance_score + 0.25 * fit_score + 0.15 * weight_concentration,
        ),
    )
    return confidence_score, {
        "coverage": coverage,
        "distance": distance_score,
        "fit": fit_score,
        "weight_concentration": weight_concentration,
    }


def _project_simplex(values: np.ndarray) -> np.ndarray:
    if values.ndim != 1:
        raise MappingInputError("Simplex projection requires a one-dimensional vector.")
    if values.size == 0:
        raise MappingInputError("Simplex projection requires at least one value.")
    sorted_values = np.sort(np.asarray(values, dtype=np.float64))[::-1]
    cumulative = np.cumsum(sorted_values) - 1.0
    indices = np.arange(1, sorted_values.size + 1, dtype=np.float64)
    positive = sorted_values - cumulative / indices > 0.0
    if not np.any(positive):
        return np.full(values.shape, 1.0 / float(values.size), dtype=np.float64)
    rho = int(np.flatnonzero(positive)[-1])
    theta = cumulative[rho] / float(rho + 1)
    projected = np.maximum(np.asarray(values, dtype=np.float64) - theta, 0.0)
    projected_sum = float(np.sum(projected))
    if projected_sum <= 0.0:
        return np.full(values.shape, 1.0 / float(values.size), dtype=np.float64)
    return projected / projected_sum


def _fit_simplex_neighbor_weights(
    neighbor_band_values: np.ndarray,
    query_vector: np.ndarray,
    neighbor_distances: np.ndarray,
) -> np.ndarray:
    candidate_matrix = np.asarray(neighbor_band_values, dtype=np.float64)
    query = np.asarray(query_vector, dtype=np.float64)
    if candidate_matrix.ndim != 2:
        raise MappingInputError("Simplex mixture fitting requires a two-dimensional candidate matrix.")
    neighbor_count = int(candidate_matrix.shape[0])
    if neighbor_count < 1:
        raise MappingInputError("Simplex mixture fitting requires at least one neighbor candidate.")
    if neighbor_count == 1:
        return np.asarray([1.0], dtype=np.float64)

    exact_match_mask = np.asarray(neighbor_distances <= 1e-12, dtype=bool)
    if np.any(exact_match_mask):
        weights = np.asarray(exact_match_mask, dtype=np.float64)
        return weights / float(np.sum(weights))

    gram = candidate_matrix @ candidate_matrix.T
    linear = candidate_matrix @ query
    max_eigenvalue = float(np.max(np.linalg.eigvalsh(gram)))
    if not np.isfinite(max_eigenvalue) or max_eigenvalue <= 1e-12:
        return np.full(neighbor_count, 1.0 / float(neighbor_count), dtype=np.float64)

    step = 1.0 / max_eigenvalue
    weights = np.full(neighbor_count, 1.0 / float(neighbor_count), dtype=np.float64)
    momentum = np.asarray(weights, dtype=np.float64)
    t_prev = 1.0
    for _ in range(250):
        gradient = gram @ momentum - linear
        updated = _project_simplex(momentum - step * gradient)
        if np.linalg.norm(updated - weights, ord=1) <= 1e-10:
            weights = updated
            break
        t_next = 0.5 * (1.0 + math.sqrt(1.0 + 4.0 * t_prev * t_prev))
        momentum = updated + ((t_prev - 1.0) / t_next) * (updated - weights)
        weights = updated
        t_prev = t_next
    return np.asarray(weights / float(np.sum(weights)), dtype=np.float64)


def _source_retrieval_bands(schema: SensorSRFSchema, segment: str) -> tuple[SensorBandDefinition, ...]:
    if segment == "swir":
        return tuple(
            band
            for band in schema.bands
            if band.segment == "swir" or band.band_id == "nir"
        )
    return schema.bands_for_segment(segment)


def _source_retrieval_band_indices(schema: SensorSRFSchema, segment: str) -> tuple[int, ...]:
    return tuple(
        index
        for index, band in enumerate(schema.bands)
        if band.segment == segment or (segment == "swir" and band.band_id == "nir")
    )


def _resample_band_response(band: SensorBandDefinition, *, segment_only: bool) -> np.ndarray:
    canonical = np.interp(
        CANONICAL_WAVELENGTHS.astype(np.float64),
        np.asarray(band.wavelength_nm, dtype=np.float64),
        np.asarray(band.rsr, dtype=np.float64),
        left=0.0,
        right=0.0,
    )
    if segment_only:
        return canonical[_segment_slice(band.segment)]
    return canonical


def _simulate_segment_matrix(
    hyperspectral_segment: np.ndarray,
    bands: Sequence[SensorBandDefinition],
    *,
    dtype: np.dtype[Any],
) -> np.ndarray:
    matrix = np.empty((hyperspectral_segment.shape[0], len(bands)), dtype=dtype)
    for index, band in enumerate(bands):
        response = _resample_band_response(band, segment_only=True)
        denominator = float(response.sum())
        if denominator <= 0:
            raise SensorSchemaError(
                "Resampled SRF support must remain positive.",
                context={"band_id": band.band_id, "segment": band.segment},
            )
        matrix[:, index] = (hyperspectral_segment @ response / denominator).astype(dtype, copy=False)
    return matrix


def _simulate_source_retrieval_matrix(
    hyperspectral_full: np.ndarray,
    bands: Sequence[SensorBandDefinition],
    *,
    dtype: np.dtype[Any],
) -> np.ndarray:
    matrix = np.empty((hyperspectral_full.shape[0], len(bands)), dtype=dtype)
    for index, band in enumerate(bands):
        response = _resample_band_response(band, segment_only=False)
        denominator = float(response.sum())
        if denominator <= 0:
            raise SensorSchemaError(
                "Resampled SRF support must remain positive.",
                context={"band_id": band.band_id, "segment": band.segment},
            )
        matrix[:, index] = (hyperspectral_full @ response / denominator).astype(dtype, copy=False)
    return matrix


def _load_sensor_payloads(path: Path) -> list[Mapping[str, object]]:
    payload = _read_json_document(
        path,
        error_factory=SensorSchemaError,
        document_name=path.name,
    )
    if isinstance(payload, dict) and "sensors" in payload:
        sensors = payload["sensors"]
        if not isinstance(sensors, list):
            raise SensorSchemaError("sensor_schema.json sensors entry must be a list.", context={"path": str(path)})
        return [sensor_payload for sensor_payload in sensors if isinstance(sensor_payload, dict)]
    if isinstance(payload, dict):
        return [payload]
    raise SensorSchemaError("Sensor schema JSON must be an object.", context={"path": str(path)})


def load_sensor_schemas(srf_root: Path) -> dict[str, SensorSRFSchema]:
    if not srf_root.exists():
        raise SensorSchemaError("SRF root does not exist.", context={"srf_root": str(srf_root)})

    schemas: dict[str, SensorSRFSchema] = {}
    json_paths = sorted(path for path in srf_root.glob("*.json") if path.is_file())
    if not json_paths:
        raise SensorSchemaError("No sensor schema JSON files were found.", context={"srf_root": str(srf_root)})

    for path in json_paths:
        for payload in _load_sensor_payloads(path):
            schema = SensorSRFSchema.from_dict(payload)
            if schema.sensor_id in schemas:
                raise SensorSchemaError(
                    "Duplicate sensor_id encountered while loading SRF definitions.",
                    context={"sensor_id": schema.sensor_id, "path": str(path)},
                )
            schemas[schema.sensor_id] = schema
    return schemas


def _load_siac_rows(
    metadata_path: Path,
    spectra_path: Path,
    *,
    dtype: np.dtype[Any],
) -> tuple[list[str], list[dict[str, object]], np.ndarray, dict[str, int]]:
    if not metadata_path.exists() or not spectra_path.exists():
        raise PreparedLibraryBuildError(
            "SIAC tabular inputs are missing required files.",
            context={"metadata_path": str(metadata_path), "spectra_path": str(spectra_path)},
        )

    with metadata_path.open("r", encoding="utf-8", newline="") as handle:
        metadata_reader = csv.DictReader(handle)
        metadata_fieldnames = list(metadata_reader.fieldnames or [])
        metadata_rows = [dict(row) for row in metadata_reader]

    if not metadata_rows:
        raise PreparedLibraryBuildError("SIAC spectra metadata is empty.", context={"metadata_path": str(metadata_path)})

    required_keys = ("source_id", "spectrum_id", "sample_name")
    missing_keys = [key for key in required_keys if key not in metadata_fieldnames]
    if missing_keys:
        raise PreparedLibraryBuildError(
            "SIAC spectra metadata is missing required identifier columns.",
            context={"metadata_path": str(metadata_path), "missing_columns": missing_keys},
        )

    with spectra_path.open("r", encoding="utf-8", newline="") as handle:
        spectra_reader = csv.DictReader(handle)
        spectra_fieldnames = list(spectra_reader.fieldnames or [])
        nm_columns = [column for column in spectra_fieldnames if column.startswith("nm_")]
        if not nm_columns:
            raise PreparedLibraryBuildError(
                "SIAC normalized spectra is missing nm_* columns.",
                context={"spectra_path": str(spectra_path)},
            )

        wavelengths = [int(column.split("_", 1)[1]) for column in nm_columns]
        expected_wavelengths = list(range(CANONICAL_START_NM, CANONICAL_END_NM + 1))
        if wavelengths != expected_wavelengths:
            raise PreparedLibraryBuildError(
                "SIAC normalized spectra must be on the canonical 400-2500 nm grid.",
                context={
                    "spectra_path": str(spectra_path),
                    "expected_start_nm": CANONICAL_START_NM,
                    "expected_end_nm": CANONICAL_END_NM,
                },
            )

        spectra_by_key: dict[tuple[str, str, str], np.ndarray] = {}
        interpolation_summary = _empty_interpolation_summary()
        canonical_wavelengths = np.asarray(expected_wavelengths, dtype=np.float64)
        for row in spectra_reader:
            key = (row["source_id"], row["spectrum_id"], row["sample_name"])
            if key in spectra_by_key:
                raise PreparedLibraryBuildError(
                    "Duplicate spectra rows were found while preparing the mapping library.",
                    context={"source_id": key[0], "spectrum_id": key[1], "sample_name": key[2]},
                )
            values: list[float] = []
            for column in nm_columns:
                cell = row.get(column)
                if cell is None or str(cell).strip() == "":
                    values.append(float("nan"))
                    continue
                try:
                    values.append(float(cell))
                except (TypeError, ValueError) as exc:
                    raise PreparedLibraryBuildError(
                        "SIAC normalized spectra nm_* values must be numeric when present.",
                        context={
                            "spectra_path": str(spectra_path),
                            "source_id": key[0],
                            "spectrum_id": key[1],
                            "sample_name": key[2],
                            "column": column,
                            "value": cell,
                        },
                    ) from exc
            spectrum = np.asarray(values, dtype=np.float64)
            if np.isnan(spectrum).any():
                valid = np.isfinite(spectrum)
                gap_counts = _summarize_missing_gap_counts(spectrum)
                if int(valid.sum()) < 2:
                    raise PreparedLibraryBuildError(
                        "SIAC normalized spectra rows with missing nm_* cells must contain at least two numeric values.",
                        context={
                            "spectra_path": str(spectra_path),
                            "source_id": key[0],
                            "spectrum_id": key[1],
                            "sample_name": key[2],
                            "missing_value_count": int(gap_counts["missing_count"]),
                        },
                    )
                if int(gap_counts["internal_gap_count"]) > MAX_INTERNAL_INTERPOLATED_GAP_COUNT:
                    raise PreparedLibraryBuildError(
                        "SIAC normalized spectra rows may only interpolate a small number of internal nm_* gaps.",
                        context={
                            "spectra_path": str(spectra_path),
                            "source_id": key[0],
                            "spectrum_id": key[1],
                            "sample_name": key[2],
                            "internal_gap_count": int(gap_counts["internal_gap_count"]),
                            "max_allowed_internal_gap_count": MAX_INTERNAL_INTERPOLATED_GAP_COUNT,
                        },
                    )
                if int(gap_counts["max_internal_gap_run_count"]) > MAX_INTERNAL_INTERPOLATED_RUN_COUNT:
                    raise PreparedLibraryBuildError(
                        "SIAC normalized spectra rows may only interpolate short contiguous internal nm_* gaps.",
                        context={
                            "spectra_path": str(spectra_path),
                            "source_id": key[0],
                            "spectrum_id": key[1],
                            "sample_name": key[2],
                            "max_internal_gap_run_count": int(gap_counts["max_internal_gap_run_count"]),
                            "max_allowed_internal_gap_run_count": MAX_INTERNAL_INTERPOLATED_RUN_COUNT,
                        },
                    )
                spectrum = np.interp(canonical_wavelengths, canonical_wavelengths[valid], spectrum[valid])
                _update_interpolation_summary(interpolation_summary, gap_counts)
            spectra_by_key[key] = np.asarray(spectrum, dtype=dtype)

    aligned_metadata_rows: list[dict[str, object]] = []
    hyperspectral = np.empty((len(metadata_rows), FULL_WAVELENGTH_COUNT), dtype=dtype)
    metadata_keys: set[tuple[str, str, str]] = set()
    for row_index, row in enumerate(metadata_rows):
        key = (str(row["source_id"]), str(row["spectrum_id"]), str(row["sample_name"]))
        metadata_keys.add(key)
        if key not in spectra_by_key:
            raise PreparedLibraryBuildError(
                "SIAC spectra metadata and normalized spectra are not row-complete.",
                context={"source_id": key[0], "spectrum_id": key[1], "sample_name": key[2]},
            )
        hyperspectral[row_index] = spectra_by_key[key]
        aligned_metadata_rows.append({"row_index": row_index, **row})

    extra_keys = set(spectra_by_key) - metadata_keys
    if extra_keys:
        source_id, spectrum_id, sample_name = sorted(extra_keys)[0]
        raise PreparedLibraryBuildError(
            "SIAC normalized spectra contains rows that are missing from spectra metadata.",
            context={"source_id": source_id, "spectrum_id": spectrum_id, "sample_name": sample_name},
        )

    return ["row_index", *metadata_fieldnames], aligned_metadata_rows, hyperspectral, interpolation_summary


def _write_mapping_metadata_parquet(path: Path, fieldnames: Sequence[str], rows: Sequence[Mapping[str, object]]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    with tempfile.TemporaryDirectory() as tmpdir:
        temp_csv = Path(tmpdir) / "mapping_metadata.csv"
        with temp_csv.open("w", encoding="utf-8", newline="") as handle:
            writer = csv.DictWriter(handle, fieldnames=list(fieldnames))
            writer.writeheader()
            for row in rows:
                writer.writerow(row)

        connection = duckdb.connect()
        try:
            temp_csv_sql = str(temp_csv).replace("'", "''")
            output_sql = str(path).replace("'", "''")
            connection.execute(
                f"""
                COPY (
                  SELECT * FROM read_csv_auto('{temp_csv_sql}', HEADER=TRUE, SAMPLE_SIZE=-1)
                ) TO '{output_sql}' (FORMAT PARQUET)
                """
            )
        finally:
            connection.close()


def _write_json(path: Path, payload: Mapping[str, object]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")


def _relative_runtime_name(path: Path, *, root: Path) -> str:
    return path.relative_to(root).as_posix()


def _artifact_name_for_knn_index(backend: str, sensor_id: str, segment: str) -> str:
    suffix = {
        "faiss": ".faiss",
        "pynndescent": ".pkl",
        "scann": "",
    }.get(backend)
    if suffix is None:
        raise PreparedLibraryBuildError(
            "KNN index persistence is not supported for the requested backend.",
            context={"knn_backend": backend, "supported_knn_index_backends": list(SUPPORTED_PERSISTED_KNN_INDEX_BACKENDS)},
        )
    return f"knn_indexes/{backend}_{sensor_id}_{segment}{suffix}"


def _read_json_document(
    path: Path,
    *,
    error_factory: type[SpectralLibraryError],
    document_name: str,
) -> object:
    try:
        text = path.read_text(encoding="utf-8")
    except OSError as exc:
        raise error_factory(
            f"Could not read {document_name}.",
            context={"path": str(path)},
        ) from exc
    try:
        return json.loads(text)
    except JSONDecodeError as exc:
        raise error_factory(
            f"{document_name} is not valid JSON.",
            context={"path": str(path)},
        ) from exc


def _flatten_knn_index_artifact_names(knn_index_artifacts: Mapping[str, Mapping[str, Mapping[str, str]]]) -> tuple[str, ...]:
    file_names: list[str] = []
    for backend in sorted(knn_index_artifacts):
        backend_payload = knn_index_artifacts[backend]
        for sensor_id in sorted(backend_payload):
            for segment in sorted(backend_payload[sensor_id]):
                file_name = str(backend_payload[sensor_id][segment]).strip()
                if file_name:
                    file_names.append(file_name)
    return tuple(file_names)


def _required_runtime_file_names(manifest: PreparedLibraryManifest) -> tuple[str, ...]:
    stable_files = [
        "manifest.json",
        "mapping_metadata.parquet",
        SEGMENT_FILE_NAMES["vnir"],
        SEGMENT_FILE_NAMES["swir"],
        "sensor_schema.json",
        "checksums.json",
    ]
    stable_files.extend(f"source_{sensor_id}_{segment}.npy" for sensor_id in manifest.source_sensors for segment in SEGMENTS)
    stable_files.extend(_flatten_knn_index_artifact_names(manifest.knn_index_artifacts))
    return tuple(stable_files)


def _validate_manifest_compatibility(manifest: PreparedLibraryManifest) -> None:
    current_major = PREPARED_SCHEMA_VERSION.split(".", 1)[0]
    prepared_major = manifest.schema_version.split(".", 1)[0]
    if current_major != prepared_major:
        raise PreparedLibraryCompatibilityError(
            "Prepared mapping runtime schema major version is incompatible with this package.",
            context={
                "prepared_schema_version": manifest.schema_version,
                "expected_schema_version": PREPARED_SCHEMA_VERSION,
            },
        )


def _load_prepared_manifest(prepared_root: Path) -> PreparedLibraryManifest:
    manifest_path = prepared_root / "manifest.json"
    if not manifest_path.exists():
        raise PreparedLibraryValidationError(
            "Prepared mapping runtime is missing manifest.json.",
            context={"prepared_root": str(prepared_root)},
        )

    payload = _read_json_document(
        manifest_path,
        error_factory=PreparedLibraryValidationError,
        document_name="manifest.json",
    )
    if not isinstance(payload, dict):
        raise PreparedLibraryValidationError(
            "manifest.json must contain a JSON object.",
            context={"path": str(manifest_path)},
        )

    try:
        manifest = PreparedLibraryManifest.from_dict(payload)
    except (KeyError, TypeError, ValueError) as exc:
        raise PreparedLibraryValidationError(
            "manifest.json is missing required fields or contains invalid values.",
            context={"path": str(manifest_path)},
        ) from exc

    if not manifest.source_sensors:
        raise PreparedLibraryValidationError(
            "manifest.json must declare at least one source sensor.",
            context={"path": str(manifest_path)},
        )
    if len(set(manifest.source_sensors)) != len(manifest.source_sensors):
        raise PreparedLibraryValidationError(
            "manifest.json source_sensors must be unique.",
            context={"path": str(manifest_path)},
        )
    if manifest.row_count < 1:
        raise PreparedLibraryValidationError(
            "manifest.json row_count must be at least 1.",
            context={"path": str(manifest_path), "row_count": manifest.row_count},
        )
    unsupported_modes = [mode for mode in manifest.supported_output_modes if mode not in SUPPORTED_OUTPUT_MODES]
    if unsupported_modes:
        raise PreparedLibraryValidationError(
            "manifest.json contains unsupported output modes.",
            context={"path": str(manifest_path), "unsupported_output_modes": unsupported_modes},
        )
    if len(manifest.vnir_wavelength_range_nm) != 2 or len(manifest.swir_wavelength_range_nm) != 2:
        raise PreparedLibraryValidationError(
            "manifest.json wavelength ranges must contain exactly two values.",
            context={"path": str(manifest_path)},
        )
    try:
        dtype_np = np.dtype(manifest.array_dtype)
    except TypeError as exc:
        raise PreparedLibraryValidationError(
            "manifest.json array_dtype is not a valid NumPy dtype.",
            context={"path": str(manifest_path), "array_dtype": manifest.array_dtype},
        ) from exc
    if dtype_np.kind != "f":
        raise PreparedLibraryValidationError(
            "manifest.json array_dtype must be floating-point.",
            context={"path": str(manifest_path), "array_dtype": manifest.array_dtype},
        )
    unsupported_knn_backends = [
        backend for backend in manifest.knn_index_artifacts if backend not in SUPPORTED_PERSISTED_KNN_INDEX_BACKENDS
    ]
    if unsupported_knn_backends:
        raise PreparedLibraryValidationError(
            "manifest.json contains unsupported knn_index_artifacts backends.",
            context={"path": str(manifest_path), "unsupported_knn_index_backends": unsupported_knn_backends},
        )
    for backend, backend_payload in manifest.knn_index_artifacts.items():
        for sensor_id, sensor_payload in backend_payload.items():
            if sensor_id not in manifest.source_sensors:
                raise PreparedLibraryValidationError(
                    "manifest.json knn_index_artifacts references a sensor that is not in source_sensors.",
                    context={"path": str(manifest_path), "knn_backend": backend, "sensor_id": sensor_id},
                )
            invalid_segments = [segment for segment in sensor_payload if segment not in SEGMENTS]
            if invalid_segments:
                raise PreparedLibraryValidationError(
                    "manifest.json knn_index_artifacts contains invalid segment keys.",
                    context={
                        "path": str(manifest_path),
                        "knn_backend": backend,
                        "sensor_id": sensor_id,
                        "invalid_segments": invalid_segments,
                    },
                )

    required_checksum_files = {
        file_name for file_name in _required_runtime_file_names(manifest) if file_name not in {"manifest.json", "checksums.json"}
    }
    missing_checksum_files = sorted(required_checksum_files - set(manifest.file_checksums))
    if missing_checksum_files:
        raise PreparedLibraryValidationError(
            "manifest.json file_checksums is missing required runtime files.",
            context={"path": str(manifest_path), "missing_files": missing_checksum_files},
        )
    return manifest


def _load_checksums_payload(prepared_root: Path) -> dict[str, object]:
    checksums_path = prepared_root / "checksums.json"
    if not checksums_path.exists():
        raise PreparedLibraryValidationError(
            "Prepared mapping runtime is missing checksums.json.",
            context={"prepared_root": str(prepared_root)},
        )
    payload = _read_json_document(
        checksums_path,
        error_factory=PreparedLibraryValidationError,
        document_name="checksums.json",
    )
    if not isinstance(payload, dict):
        raise PreparedLibraryValidationError(
            "checksums.json must contain a JSON object.",
            context={"path": str(checksums_path)},
        )
    files_payload = payload.get("files")
    if not isinstance(files_payload, dict):
        raise PreparedLibraryValidationError(
            "checksums.json must contain a files object.",
            context={"path": str(checksums_path)},
        )
    return payload


def _expected_runtime_checksums(manifest: PreparedLibraryManifest, manifest_path: Path) -> dict[str, str]:
    return {
        **manifest.file_checksums,
        manifest_path.name: _sha256_runtime_path(manifest_path),
    }


def prepare_mapping_library(
    siac_root: Path,
    srf_root: Path,
    output_root: Path,
    source_sensors: Sequence[str],
    *,
    dtype: str = "float32",
    knn_index_backends: Sequence[str] | None = None,
) -> PreparedLibraryManifest:
    dtype_np = np.dtype(dtype)
    if dtype_np.kind != "f":
        raise PreparedLibraryBuildError("prepare_mapping_library only supports floating-point dtypes.", context={"dtype": dtype})

    source_sensor_ids = _normalized_source_sensors(source_sensors)
    persisted_knn_backends = _normalized_knn_index_backends(knn_index_backends)
    output_root = Path(output_root)
    output_root.mkdir(parents=True, exist_ok=True)

    metadata_path = Path(siac_root) / "tabular" / "siac_spectra_metadata.csv"
    spectra_path = Path(siac_root) / "tabular" / "siac_normalized_spectra.csv"
    metadata_fieldnames, metadata_rows, hyperspectral, interpolation_summary = _load_siac_rows(
        metadata_path,
        spectra_path,
        dtype=dtype_np,
    )

    sensors = load_sensor_schemas(Path(srf_root))
    missing_source_sensors = [sensor_id for sensor_id in source_sensor_ids if sensor_id not in sensors]
    if missing_source_sensors:
        raise SensorSchemaError(
            "Requested source sensors are missing from the SRF root.",
            context={"missing_source_sensors": missing_source_sensors, "srf_root": str(srf_root)},
        )

    hyperspectral_vnir = hyperspectral[:, _segment_slice("vnir")]
    hyperspectral_swir = hyperspectral[:, _segment_slice("swir")]
    np.save(output_root / SEGMENT_FILE_NAMES["vnir"], hyperspectral_vnir)
    np.save(output_root / SEGMENT_FILE_NAMES["swir"], hyperspectral_swir)

    for sensor_id in source_sensor_ids:
        schema = sensors[sensor_id]
        for segment in SEGMENTS:
            bands = _source_retrieval_bands(schema, segment)
            segment_matrix = _simulate_source_retrieval_matrix(
                hyperspectral,
                bands,
                dtype=dtype_np,
            )
            np.save(output_root / f"source_{sensor_id}_{segment}.npy", segment_matrix)

    knn_index_artifacts: dict[str, dict[str, dict[str, str]]] = {}
    for backend in persisted_knn_backends:
        backend_payload: dict[str, dict[str, str]] = {}
        for sensor_id in source_sensor_ids:
            sensor_payload: dict[str, str] = {}
            for segment in SEGMENTS:
                matrix_path = output_root / f"source_{sensor_id}_{segment}.npy"
                candidate_matrix = np.asarray(np.load(matrix_path), dtype=np.float32)
                artifact_relative_name = _artifact_name_for_knn_index(backend, sensor_id, segment)
                artifact_path = output_root / artifact_relative_name
                _persist_knn_index(candidate_matrix, backend=backend, output_path=artifact_path)
                sensor_payload[segment] = artifact_relative_name
            backend_payload[sensor_id] = sensor_payload
        knn_index_artifacts[backend] = backend_payload

    mapping_metadata_path = output_root / "mapping_metadata.parquet"
    _write_mapping_metadata_parquet(mapping_metadata_path, metadata_fieldnames, metadata_rows)

    sensor_schema_path = output_root / "sensor_schema.json"
    _write_json(
        sensor_schema_path,
        {
            "schema_version": PREPARED_SCHEMA_VERSION,
            "canonical_wavelength_grid": {
                "start_nm": CANONICAL_START_NM,
                "end_nm": CANONICAL_END_NM,
                "step_nm": 1,
            },
            "sensors": [schema.to_dict() for _, schema in sorted(sensors.items())],
        },
    )

    stable_files = [
        mapping_metadata_path,
        output_root / SEGMENT_FILE_NAMES["vnir"],
        output_root / SEGMENT_FILE_NAMES["swir"],
        sensor_schema_path,
    ]
    stable_files.extend(output_root / f"source_{sensor_id}_{segment}.npy" for sensor_id in source_sensor_ids for segment in SEGMENTS)
    stable_files.extend(output_root / file_name for file_name in _flatten_knn_index_artifact_names(knn_index_artifacts))
    file_checksums = {
        _relative_runtime_name(path, root=output_root): _sha256_runtime_path(path)
        for path in stable_files
    }

    manifest = PreparedLibraryManifest(
        schema_version=PREPARED_SCHEMA_VERSION,
        package_version=__version__,
        source_siac_root=str(Path(siac_root)),
        source_siac_build_id=_sha256_paths([metadata_path, spectra_path]),
        prepared_at=datetime.now(timezone.utc).isoformat(),
        source_sensors=tuple(source_sensor_ids),
        supported_output_modes=SUPPORTED_OUTPUT_MODES,
        row_count=int(hyperspectral.shape[0]),
        vnir_wavelength_range_nm=(VNIR_START_NM, VNIR_END_NM),
        swir_wavelength_range_nm=(SWIR_START_NM, SWIR_END_NM),
        array_dtype=dtype_np.name,
        file_checksums=file_checksums,
        knn_index_artifacts=knn_index_artifacts,
        interpolation_summary=interpolation_summary,
    )
    manifest_path = output_root / "manifest.json"
    _write_json(manifest_path, manifest.to_dict())

    checksums_payload = {
        "schema_version": PREPARED_SCHEMA_VERSION,
        "files": {
            **file_checksums,
            "manifest.json": _sha256_runtime_path(manifest_path),
        },
    }
    _write_json(output_root / "checksums.json", checksums_payload)
    return manifest


def validate_prepared_library(
    prepared_root: Path,
    *,
    verify_checksums: bool = True,
) -> PreparedLibraryManifest:
    prepared_root = Path(prepared_root)
    mapper = SpectralMapper(prepared_root, verify_checksums=verify_checksums)
    return mapper.manifest


class SpectralMapper:
    def __init__(self, prepared_root: Path, *, verify_checksums: bool = False) -> None:
        self.prepared_root = Path(prepared_root)
        self.manifest = _load_prepared_manifest(self.prepared_root)
        _validate_manifest_compatibility(self.manifest)
        self._validate_required_runtime_files()
        self._validate_checksums(verify_checksums=verify_checksums)

        self._sensor_schemas = self._load_prepared_sensor_schemas()
        self._row_ids, self._row_sample_names = self._load_row_metadata()
        self._row_index_by_id = {row_id: index for index, row_id in enumerate(self._row_ids)}
        row_indices_by_sample_name: dict[str, list[int]] = {}
        for row_index, sample_name in enumerate(self._row_sample_names):
            row_indices_by_sample_name.setdefault(sample_name, []).append(row_index)
        self._row_indices_by_sample_name = {
            sample_name: tuple(row_indices)
            for sample_name, row_indices in row_indices_by_sample_name.items()
        }
        self._all_row_indices = np.arange(self.manifest.row_count, dtype=np.int64)
        self._hyperspectral_cache: dict[str, np.ndarray] = {}
        self._source_matrix_cache: dict[tuple[str, str], np.ndarray] = {}
        self._response_cache: dict[tuple[str, str, bool], np.ndarray] = {}
        self._source_query_cache: dict[str, np.ndarray] = {}
        self._knn_index_cache: dict[tuple[str, str, str], Any] = {}

        self._validate_prepared_layout()

    def _validate_required_runtime_files(self) -> None:
        for file_name in _required_runtime_file_names(self.manifest):
            if not (self.prepared_root / file_name).exists():
                raise PreparedLibraryValidationError(
                    "Prepared mapping runtime is missing a required file.",
                    context={"prepared_root": str(self.prepared_root), "file_name": file_name},
                )

    def _validate_checksums(self, *, verify_checksums: bool) -> None:
        checksums_payload = _load_checksums_payload(self.prepared_root)
        recorded_checksums = checksums_payload["files"]
        manifest_path = self.prepared_root / "manifest.json"
        expected_checksums = _expected_runtime_checksums(self.manifest, manifest_path)
        for file_name, expected_checksum in expected_checksums.items():
            if recorded_checksums.get(file_name) != expected_checksum:
                raise PreparedLibraryValidationError(
                    "checksums.json does not match the manifest checksum records.",
                    context={"prepared_root": str(self.prepared_root), "file_name": file_name},
                )
            if verify_checksums:
                actual_checksum = _sha256_runtime_path(self.prepared_root / file_name)
                if actual_checksum != expected_checksum:
                    raise PreparedLibraryValidationError(
                        "Prepared mapping runtime checksum verification failed.",
                        context={"prepared_root": str(self.prepared_root), "file_name": file_name},
                    )

    def _load_prepared_sensor_schemas(self) -> dict[str, SensorSRFSchema]:
        sensor_schema_path = self.prepared_root / "sensor_schema.json"
        schemas: dict[str, SensorSRFSchema] = {}
        for payload in _load_sensor_payloads(sensor_schema_path):
            schema = SensorSRFSchema.from_dict(payload)
            if schema.sensor_id in schemas:
                raise PreparedLibraryValidationError(
                    "sensor_schema.json contains duplicate sensor_id values.",
                    context={"prepared_root": str(self.prepared_root), "sensor_id": schema.sensor_id},
                )
            schemas[schema.sensor_id] = schema
        return schemas

    def _load_row_metadata(self) -> tuple[tuple[str, ...], tuple[str, ...]]:
        metadata_path = self.prepared_root / "mapping_metadata.parquet"
        connection = duckdb.connect()
        try:
            try:
                rows = connection.execute(
                    """
                    SELECT row_index, source_id, spectrum_id, sample_name
                    FROM read_parquet(?)
                    ORDER BY row_index
                    """,
                    [str(metadata_path)],
                ).fetchall()
            except duckdb.Error as exc:
                raise PreparedLibraryValidationError(
                    "mapping_metadata.parquet is missing required row-alignment columns.",
                    context={"prepared_root": str(self.prepared_root), "path": str(metadata_path)},
                ) from exc
        finally:
            connection.close()

        if len(rows) != self.manifest.row_count:
            raise PreparedLibraryValidationError(
                "Prepared mapping metadata row count does not match manifest row_count.",
                context={"metadata_rows": len(rows), "manifest_row_count": self.manifest.row_count},
            )
        try:
            row_indices = [int(row[0]) for row in rows]
        except (TypeError, ValueError) as exc:
            raise PreparedLibraryValidationError(
                "mapping_metadata.parquet row_index values must be integers.",
                context={"prepared_root": str(self.prepared_root), "path": str(metadata_path)},
            ) from exc
        expected_row_indices = list(range(self.manifest.row_count))
        if row_indices != expected_row_indices:
            raise PreparedLibraryValidationError(
                "mapping_metadata.parquet row_index values must be unique and contiguous from 0 to row_count - 1.",
                context={"prepared_root": str(self.prepared_root), "path": str(metadata_path)},
            )

        row_ids = tuple(f"{row[1]}:{row[2]}:{row[3]}" for row in rows)
        if len(set(row_ids)) != len(row_ids):
            raise PreparedLibraryValidationError(
                "mapping_metadata.parquet contains duplicate prepared row identities.",
                context={"prepared_root": str(self.prepared_root), "path": str(metadata_path)},
            )
        sample_names = tuple(str(row[3]) for row in rows)
        return row_ids, sample_names

    def _validate_prepared_layout(self) -> None:
        for segment in SEGMENTS:
            array = self._load_hyperspectral(segment)
            expected_width = len(_segment_wavelengths(segment))
            if array.shape != (self.manifest.row_count, expected_width):
                raise PreparedLibraryValidationError(
                    "Prepared hyperspectral array shape does not match the manifest.",
                    context={
                        "segment": segment,
                        "expected_shape": [self.manifest.row_count, expected_width],
                        "actual_shape": list(array.shape),
                    },
                )

        for sensor_id in self.manifest.source_sensors:
            schema = self.get_sensor_schema(sensor_id)
            for segment in SEGMENTS:
                matrix = self._load_source_matrix(sensor_id, segment)
                expected_shape = (self.manifest.row_count, len(_source_retrieval_bands(schema, segment)))
                if matrix.shape != expected_shape:
                    raise PreparedLibraryValidationError(
                        "Prepared source retrieval matrix shape does not match the sensor schema.",
                        context={
                            "sensor_id": sensor_id,
                            "segment": segment,
                            "expected_shape": list(expected_shape),
                            "actual_shape": list(matrix.shape),
                        },
                    )

    def get_sensor_schema(self, sensor_id: str) -> SensorSRFSchema:
        if sensor_id not in self._sensor_schemas:
            raise SensorSchemaError(
                "Requested sensor_id is not present in the prepared sensor schema.",
                context={"sensor_id": sensor_id},
            )
        return self._sensor_schemas[sensor_id]

    def _load_hyperspectral(self, segment: str) -> np.ndarray:
        if segment not in self._hyperspectral_cache:
            path = self.prepared_root / SEGMENT_FILE_NAMES[segment]
            try:
                self._hyperspectral_cache[segment] = np.load(path, mmap_mode="r")
            except (OSError, ValueError) as exc:
                raise PreparedLibraryValidationError(
                    "Prepared hyperspectral array could not be loaded.",
                    context={"prepared_root": str(self.prepared_root), "path": str(path), "segment": segment},
                ) from exc
        return self._hyperspectral_cache[segment]

    def _load_source_matrix(self, source_sensor: str, segment: str) -> np.ndarray:
        key = (source_sensor, segment)
        if key not in self._source_matrix_cache:
            path = self.prepared_root / f"source_{source_sensor}_{segment}.npy"
            try:
                self._source_matrix_cache[key] = np.load(path, mmap_mode="r")
            except (OSError, ValueError) as exc:
                raise PreparedLibraryValidationError(
                    "Prepared source retrieval matrix could not be loaded.",
                    context={"path": str(path), "source_sensor": source_sensor, "segment": segment},
                ) from exc
        return self._source_matrix_cache[key]

    def _persisted_knn_index_path(self, backend: str, source_sensor: str, segment: str) -> Path | None:
        relative_path = (
            self.manifest.knn_index_artifacts.get(backend, {})
            .get(source_sensor, {})
            .get(segment)
        )
        if not relative_path:
            return None
        return self.prepared_root / relative_path

    def _load_persisted_knn_index(self, backend: str, source_sensor: str, segment: str) -> Any | None:
        path = self._persisted_knn_index_path(backend, source_sensor, segment)
        if path is None:
            return None
        key = (backend, source_sensor, segment)
        if key not in self._knn_index_cache:
            try:
                self._knn_index_cache[key] = _load_persisted_knn_index(path, backend=backend)
            except SpectralLibraryError:
                raise
            except Exception as exc:
                raise PreparedLibraryValidationError(
                    "Persisted KNN index could not be loaded.",
                    context={
                        "prepared_root": str(self.prepared_root),
                        "knn_backend": backend,
                        "source_sensor": source_sensor,
                        "segment": segment,
                        "path": str(path),
                    },
                ) from exc
        return self._knn_index_cache[key]

    def _can_use_persisted_knn_index(
        self,
        *,
        backend: str,
        source_sensor: str,
        segment: str,
        valid_mask: np.ndarray,
        candidate_row_indices: np.ndarray,
    ) -> bool:
        if backend not in SUPPORTED_PERSISTED_KNN_INDEX_BACKENDS:
            return False
        if not np.all(valid_mask):
            return False
        if candidate_row_indices.shape != self._all_row_indices.shape or not np.array_equal(candidate_row_indices, self._all_row_indices):
            return False
        return self._persisted_knn_index_path(backend, source_sensor, segment) is not None

    def _query_persisted_knn_local_indices(
        self,
        *,
        backend: str,
        source_sensor: str,
        segment: str,
        query_values: np.ndarray,
        k: int,
        knn_eps: float,
    ) -> np.ndarray | None:
        index = self._load_persisted_knn_index(backend, source_sensor, segment)
        if index is None:
            return None
        return _query_persisted_knn_index(
            index,
            query_values,
            k=k,
            knn_backend=backend,
            knn_eps=knn_eps,
        )

    def _band_response(self, sensor_id: str, band: SensorBandDefinition, *, segment_only: bool) -> np.ndarray:
        cache_key = (sensor_id, band.band_id, segment_only)
        if cache_key not in self._response_cache:
            self._response_cache[cache_key] = _resample_band_response(band, segment_only=segment_only)
        return self._response_cache[cache_key]

    def _coerce_query(
        self,
        schema: SensorSRFSchema,
        reflectance: Sequence[float] | Mapping[str, float],
        valid_mask: Sequence[bool] | Mapping[str, bool] | None,
    ) -> tuple[np.ndarray, np.ndarray]:
        if isinstance(reflectance, Mapping):
            reflectance_values = np.asarray(
                [float(reflectance.get(band.band_id, np.nan)) for band in schema.bands],
                dtype=np.float64,
            )
            if isinstance(valid_mask, Mapping):
                valid_values = np.asarray([bool(valid_mask.get(band.band_id, True)) for band in schema.bands], dtype=bool)
            elif valid_mask is None:
                valid_values = np.isfinite(reflectance_values)
            else:
                raise MappingInputError(
                    "Mapping reflectance dictionaries require valid_mask to be a dictionary when provided.",
                    context={"sensor_id": schema.sensor_id},
                )
        else:
            reflectance_values = np.asarray(reflectance, dtype=np.float64)
            if reflectance_values.ndim != 1 or reflectance_values.shape[0] != len(schema.bands):
                raise MappingInputError(
                    "Reflectance arrays must be one-dimensional and aligned to the source sensor band order.",
                    context={"sensor_id": schema.sensor_id, "expected_length": len(schema.bands)},
                )
            if valid_mask is None:
                valid_values = np.isfinite(reflectance_values)
            else:
                valid_values = np.asarray(valid_mask, dtype=bool)
                if valid_values.shape != reflectance_values.shape:
                    raise MappingInputError(
                        "valid_mask must have the same shape as reflectance.",
                        context={"sensor_id": schema.sensor_id, "expected_length": len(schema.bands)},
                    )

        valid_values = valid_values & np.isfinite(reflectance_values)
        if not np.any(valid_values):
            raise MappingInputError("At least one valid source reflectance band is required for mapping.")
        return reflectance_values, valid_values

    def _retrieve_segment(
        self,
        *,
        source_sensor: str,
        segment: str,
        query_values: np.ndarray,
        valid_mask: np.ndarray,
        k: int,
        min_valid_bands: int,
        neighbor_estimator: str,
        knn_backend: str = "numpy",
        knn_eps: float = 0.0,
        candidate_row_indices: np.ndarray,
    ) -> _SegmentRetrieval:
        source_schema = self.get_sensor_schema(source_sensor)
        query_band_ids = tuple(band.band_id for band in _source_retrieval_bands(source_schema, segment))
        valid_band_count = int(valid_mask.sum())
        if valid_band_count < min_valid_bands:
            return _SegmentRetrieval(
                segment=segment,
                valid_band_count=valid_band_count,
                query_band_ids=query_band_ids,
                query_band_values=np.asarray(query_values, dtype=np.float64),
                query_valid_mask=np.asarray(valid_mask, dtype=bool),
                success=False,
                reason="insufficient_valid_bands",
            )

        source_matrix = self._load_source_matrix(source_sensor, segment)
        if source_matrix.shape[1] != len(query_band_ids):
            raise PreparedLibraryValidationError(
                "Prepared source matrix width does not match the source sensor schema.",
                context={"source_sensor": source_sensor, "segment": segment},
            )

        valid_indices = np.flatnonzero(valid_mask)
        candidate_matrix = np.asarray(source_matrix[candidate_row_indices][:, valid_indices], dtype=np.float64)
        query_vector = query_values[valid_indices]
        if self._can_use_persisted_knn_index(
            backend=knn_backend,
            source_sensor=source_sensor,
            segment=segment,
            valid_mask=np.asarray(valid_mask, dtype=bool),
            candidate_row_indices=candidate_row_indices,
        ):
            local_indices = self._query_persisted_knn_local_indices(
                backend=knn_backend,
                source_sensor=source_sensor,
                segment=segment,
                query_values=query_values,
                k=min(int(k), int(candidate_row_indices.size)),
                knn_eps=knn_eps,
            )
            assert local_indices is not None
            neighbor_indices, neighbor_distances = _refine_neighbor_rows(
                np.asarray(source_matrix[candidate_row_indices], dtype=np.float64),
                np.asarray(query_values, dtype=np.float64),
                candidate_row_indices,
                local_indices[0],
                k=k,
            )
        else:
            neighbor_indices, neighbor_distances = _search_neighbor_rows(
                candidate_matrix,
                query_vector,
                candidate_row_indices,
                k=k,
                knn_backend=knn_backend,
                knn_eps=knn_eps,
            )

        neighbor_band_values = np.asarray(source_matrix[neighbor_indices], dtype=np.float64)
        reconstructed, neighbor_weights, source_fit_rmse = _combine_neighbor_spectra(
            self._load_hyperspectral(segment),
            neighbor_indices,
            neighbor_distances,
            neighbor_band_values[:, valid_indices],
            query_vector,
            neighbor_estimator=neighbor_estimator,
        )
        confidence_score, confidence_components = _segment_confidence_payload(
            query_vector=np.asarray(query_vector, dtype=np.float64),
            valid_band_count=valid_band_count,
            total_band_count=len(query_band_ids),
            neighbor_distances=np.asarray(neighbor_distances, dtype=np.float64),
            neighbor_weights=np.asarray(neighbor_weights, dtype=np.float64),
            source_fit_rmse=source_fit_rmse,
        )
        return _SegmentRetrieval(
            segment=segment,
            valid_band_count=valid_band_count,
            query_band_ids=query_band_ids,
            query_band_values=np.asarray(query_values, dtype=np.float64),
            query_valid_mask=np.asarray(valid_mask, dtype=bool),
            success=True,
            reconstructed=reconstructed,
            neighbor_indices=neighbor_indices,
            neighbor_ids=tuple(self._row_ids[index] for index in neighbor_indices),
            neighbor_distances=np.asarray(neighbor_distances, dtype=np.float64),
            neighbor_weights=neighbor_weights,
            neighbor_band_values=neighbor_band_values,
            source_fit_rmse=source_fit_rmse,
            confidence_score=confidence_score,
            confidence_components=confidence_components,
        )

    def _retrieve_segment_batch(
        self,
        *,
        source_sensor: str,
        segment: str,
        query_values: np.ndarray,
        valid_mask: np.ndarray,
        k: int,
        min_valid_bands: int,
        neighbor_estimator: str,
        knn_backend: str = "numpy",
        knn_eps: float = 0.0,
        candidate_row_indices: np.ndarray,
    ) -> tuple[_SegmentRetrieval, ...]:
        source_schema = self.get_sensor_schema(source_sensor)
        query_band_ids = tuple(band.band_id for band in _source_retrieval_bands(source_schema, segment))
        source_matrix = self._load_source_matrix(source_sensor, segment)
        if source_matrix.shape[1] != len(query_band_ids):
            raise PreparedLibraryValidationError(
                "Prepared source matrix width does not match the source sensor schema.",
                context={"source_sensor": source_sensor, "segment": segment},
            )

        retrievals: list[_SegmentRetrieval | None] = [None] * int(query_values.shape[0])
        candidate_matrix = np.asarray(source_matrix[candidate_row_indices], dtype=np.float64)
        segment_hyperspectral = np.asarray(self._load_hyperspectral(segment), dtype=np.float64)
        valid_patterns, inverse = np.unique(valid_mask, axis=0, return_inverse=True)

        for pattern_index, pattern in enumerate(valid_patterns):
            batch_indices = np.flatnonzero(inverse == pattern_index)
            valid_band_count = int(pattern.sum())
            if valid_band_count < min_valid_bands:
                for batch_index in batch_indices:
                    retrievals[int(batch_index)] = _SegmentRetrieval(
                        segment=segment,
                        valid_band_count=valid_band_count,
                        query_band_ids=query_band_ids,
                        query_band_values=np.asarray(query_values[batch_index], dtype=np.float64),
                        query_valid_mask=np.asarray(valid_mask[batch_index], dtype=bool),
                        success=False,
                        reason="insufficient_valid_bands",
                    )
                continue

            valid_indices = np.flatnonzero(pattern)
            candidate_valid = candidate_matrix[:, valid_indices]
            query_group = np.asarray(query_values[batch_indices][:, valid_indices], dtype=np.float64)
            if self._can_use_persisted_knn_index(
                backend=knn_backend,
                source_sensor=source_sensor,
                segment=segment,
                valid_mask=np.asarray(pattern, dtype=bool),
                candidate_row_indices=candidate_row_indices,
            ):
                group_local_indices = self._query_persisted_knn_local_indices(
                    backend=knn_backend,
                    source_sensor=source_sensor,
                    segment=segment,
                    query_values=query_values[batch_indices],
                    k=min(int(k), int(candidate_row_indices.size)),
                    knn_eps=knn_eps,
                )
            else:
                group_local_indices = _search_local_neighbor_indices(
                    candidate_valid,
                    query_group,
                    k=k,
                    knn_backend=knn_backend,
                    knn_eps=knn_eps,
                )

            for local_index, batch_index in enumerate(batch_indices):
                if group_local_indices is None:
                    neighbor_indices, neighbor_distances = _search_neighbor_rows(
                        candidate_valid,
                        query_group[local_index],
                        candidate_row_indices,
                        k=k,
                        knn_backend=knn_backend,
                        knn_eps=knn_eps,
                    )
                else:
                    neighbor_indices, neighbor_distances = _refine_neighbor_rows(
                        candidate_valid,
                        query_group[local_index],
                        candidate_row_indices,
                        group_local_indices[local_index],
                        k=k,
                    )
                neighbor_band_values = np.asarray(source_matrix[neighbor_indices], dtype=np.float64)
                reconstructed, neighbor_weights, source_fit_rmse = _combine_neighbor_spectra(
                    segment_hyperspectral,
                    neighbor_indices,
                    neighbor_distances,
                    neighbor_band_values[:, valid_indices],
                    query_group[local_index],
                    neighbor_estimator=neighbor_estimator,
                )
                confidence_score, confidence_components = _segment_confidence_payload(
                    query_vector=np.asarray(query_group[local_index], dtype=np.float64),
                    valid_band_count=valid_band_count,
                    total_band_count=len(query_band_ids),
                    neighbor_distances=np.asarray(neighbor_distances, dtype=np.float64),
                    neighbor_weights=np.asarray(neighbor_weights, dtype=np.float64),
                    source_fit_rmse=source_fit_rmse,
                )
                retrievals[int(batch_index)] = _SegmentRetrieval(
                    segment=segment,
                    valid_band_count=valid_band_count,
                    query_band_ids=query_band_ids,
                    query_band_values=np.asarray(query_values[batch_index], dtype=np.float64),
                    query_valid_mask=np.asarray(valid_mask[batch_index], dtype=bool),
                    success=True,
                    reconstructed=reconstructed,
                    neighbor_indices=neighbor_indices,
                    neighbor_ids=tuple(self._row_ids[index] for index in neighbor_indices),
                    neighbor_distances=np.asarray(neighbor_distances, dtype=np.float64),
                    neighbor_weights=neighbor_weights,
                    neighbor_band_values=neighbor_band_values,
                    source_fit_rmse=source_fit_rmse,
                    confidence_score=confidence_score,
                    confidence_components=confidence_components,
                )

        return tuple(
            retrieval
            if retrieval is not None
            else _SegmentRetrieval(
                segment=segment,
                valid_band_count=0,
                query_band_ids=query_band_ids,
                success=False,
                reason="insufficient_valid_bands",
            )
            for retrieval in retrievals
        )

    def _simulate_target_sensor(
        self,
        target_sensor: str,
        segment_outputs: Mapping[str, np.ndarray],
    ) -> tuple[np.ndarray | None, tuple[str, ...]]:
        target_schema = self.get_sensor_schema(target_sensor)
        values: list[float] = []
        band_ids: list[str] = []
        for segment in SEGMENTS:
            if segment not in segment_outputs:
                continue
            reconstructed = segment_outputs[segment]
            for band in target_schema.bands_for_segment(segment):
                response = self._band_response(target_sensor, band, segment_only=True)
                denominator = float(response.sum())
                if denominator <= 0:
                    raise SensorSchemaError(
                        "Resampled target SRF support must remain positive.",
                        context={"target_sensor": target_sensor, "band_id": band.band_id},
                    )
                values.append(float(np.dot(reconstructed, response) / denominator))
                band_ids.append(band.band_id)
        if not values:
            return None, ()
        return np.asarray(values, dtype=np.float64), tuple(band_ids)

    def _candidate_rows(
        self,
        candidate_row_indices: Sequence[int] | None,
        *,
        exclude_row_ids: Sequence[str] | None = None,
        exclude_sample_names: Sequence[str] | None = None,
    ) -> np.ndarray:
        if candidate_row_indices is None:
            candidate = np.arange(self.manifest.row_count, dtype=np.int64)
        else:
            candidate = np.asarray(candidate_row_indices, dtype=np.int64)
            if candidate.ndim != 1 or candidate.size == 0:
                raise MappingInputError("candidate_row_indices must be a non-empty one-dimensional sequence.")
            if np.any(candidate < 0) or np.any(candidate >= self.manifest.row_count):
                raise MappingInputError(
                    "candidate_row_indices must refer to valid prepared-library rows.",
                    context={"row_count": self.manifest.row_count},
                )
            candidate = np.unique(candidate)

        if not exclude_row_ids and not exclude_sample_names:
            return candidate

        excluded = self._excluded_candidate_indices(
            exclude_row_ids=exclude_row_ids,
            exclude_sample_names=exclude_sample_names,
        )
        if excluded.size:
            candidate = candidate[~np.isin(candidate, excluded)]
        if candidate.size == 0:
            raise MappingInputError(
                "Candidate exclusions removed every prepared-library row.",
                context={
                    "row_count": self.manifest.row_count,
                    "excluded_row_id_count": len(tuple(exclude_row_ids or ())),
                    "excluded_sample_name_count": len(tuple(exclude_sample_names or ())),
                },
            )
        return candidate

    def _excluded_candidate_indices(
        self,
        *,
        exclude_row_ids: Sequence[str] | None,
        exclude_sample_names: Sequence[str] | None,
    ) -> np.ndarray:
        normalized_exclude_row_ids = tuple(
            text for text in (str(value).strip() for value in (exclude_row_ids or ())) if text
        )
        normalized_exclude_sample_names = tuple(
            text for text in (str(value).strip() for value in (exclude_sample_names or ())) if text
        )

        excluded_indices: set[int] = set()
        if normalized_exclude_row_ids:
            unknown_row_ids = sorted(
                row_id for row_id in normalized_exclude_row_ids if row_id not in self._row_index_by_id
            )
            if unknown_row_ids:
                raise MappingInputError(
                    "exclude_row_ids must refer to existing prepared row ids.",
                    context={"unknown_row_ids": unknown_row_ids},
                )
            excluded_indices.update(self._row_index_by_id[row_id] for row_id in normalized_exclude_row_ids)

        if normalized_exclude_sample_names:
            unknown_sample_names = sorted(
                sample_name
                for sample_name in normalized_exclude_sample_names
                if sample_name not in self._row_indices_by_sample_name
            )
            if unknown_sample_names:
                raise MappingInputError(
                    "exclude_sample_names must refer to existing prepared sample_name values.",
                    context={"unknown_sample_names": unknown_sample_names},
                )
            for sample_name in normalized_exclude_sample_names:
                excluded_indices.update(self._row_indices_by_sample_name[sample_name])

        return np.asarray(sorted(excluded_indices), dtype=np.int64)

    def candidate_row_indices(
        self,
        candidate_row_indices: Sequence[int] | None = None,
        *,
        exclude_row_ids: Sequence[str] | None = None,
        exclude_sample_names: Sequence[str] | None = None,
    ) -> np.ndarray:
        return self._candidate_rows(
            candidate_row_indices,
            exclude_row_ids=exclude_row_ids,
            exclude_sample_names=exclude_sample_names,
        )

    def has_prepared_sample_name(self, sample_name: str) -> bool:
        return str(sample_name).strip() in self._row_indices_by_sample_name

    def _normalized_batch_exclude_row_ids(
        self,
        exclude_row_ids_per_sample: Sequence[str | None] | Mapping[str, str | None] | None,
        *,
        sample_ids: Sequence[str],
    ) -> tuple[str | None, ...]:
        if exclude_row_ids_per_sample is None:
            return (None,) * len(sample_ids)
        if isinstance(exclude_row_ids_per_sample, Mapping):
            sample_id_set = {str(sample_id) for sample_id in sample_ids}
            unknown_sample_ids = sorted(
                str(sample_id) for sample_id in exclude_row_ids_per_sample if str(sample_id) not in sample_id_set
            )
            if unknown_sample_ids:
                raise MappingInputError(
                    "exclude_row_ids_per_sample mapping keys must match sample_ids.",
                    context={"unknown_sample_ids": unknown_sample_ids},
                )
            return tuple(
                (
                    text
                    if (text := str(exclude_row_ids_per_sample.get(sample_id) or "").strip())
                    else None
                )
                for sample_id in sample_ids
            )
        if isinstance(exclude_row_ids_per_sample, Sequence) and not isinstance(exclude_row_ids_per_sample, (str, bytes)):
            values = list(exclude_row_ids_per_sample)
            if len(values) != len(sample_ids):
                raise MappingInputError(
                    "exclude_row_ids_per_sample must have the same length as reflectance_rows.",
                    context={"sample_count": len(sample_ids), "exclude_row_id_count": len(values)},
                )
            return tuple((text if (text := str(value or "").strip()) else None) for value in values)
        raise MappingInputError(
            "exclude_row_ids_per_sample must be a sequence aligned to reflectance_rows or a mapping keyed by sample_id."
        )

    def _build_mapping_result(
        self,
        *,
        source_sensor: str,
        target_sensor: str | None,
        output_mode: str,
        k: int,
        neighbor_estimator: str,
        knn_backend: str = "numpy",
        knn_eps: float = 0.0,
        segment_retrievals: Mapping[str, _SegmentRetrieval],
    ) -> MappingResult:
        segment_outputs: dict[str, np.ndarray] = {}
        segment_valid_band_counts: dict[str, int] = {}
        neighbor_ids_by_segment: dict[str, tuple[str, ...]] = {}
        neighbor_distances_by_segment: dict[str, np.ndarray] = {}

        for segment, retrieval in segment_retrievals.items():
            segment_valid_band_counts[segment] = retrieval.valid_band_count
            neighbor_ids_by_segment[segment] = retrieval.neighbor_ids
            neighbor_distances_by_segment[segment] = retrieval.neighbor_distances
            if retrieval.success and retrieval.reconstructed is not None:
                segment_outputs[segment] = retrieval.reconstructed

        diagnostics: dict[str, object] = {
            "source_sensor": source_sensor,
            "target_sensor": target_sensor,
            "output_mode": output_mode,
            "k": k,
            "neighbor_estimator": neighbor_estimator,
            "knn_backend": knn_backend,
            "knn_eps": float(knn_eps),
            "segments": {},
        }
        diagnostics_segments: dict[str, object] = {}
        confidence_weights: list[int] = []
        confidence_scores: list[float] = []
        for segment, retrieval in segment_retrievals.items():
            if retrieval.success and retrieval.confidence_score is not None:
                confidence_weights.append(max(retrieval.valid_band_count, 1))
                confidence_scores.append(float(retrieval.confidence_score))
            diagnostics_segments[segment] = {
                "status": "ok" if retrieval.success else retrieval.reason,
                "valid_band_count": retrieval.valid_band_count,
                "query_band_ids": list(retrieval.query_band_ids),
                "query_band_values": [
                    float(value) if bool(is_valid) and np.isfinite(value) else None
                    for value, is_valid in zip(retrieval.query_band_values, retrieval.query_valid_mask)
                ],
                "query_valid_mask": [bool(value) for value in retrieval.query_valid_mask],
                "neighbor_ids": list(retrieval.neighbor_ids),
                "neighbor_distances": [float(value) for value in retrieval.neighbor_distances],
                "neighbor_weights": [float(value) for value in retrieval.neighbor_weights],
                "source_fit_rmse": None if retrieval.source_fit_rmse is None else float(retrieval.source_fit_rmse),
                "confidence_score": None if retrieval.confidence_score is None else float(retrieval.confidence_score),
                "confidence_components": dict(retrieval.confidence_components),
                "neighbor_band_values": [
                    [float(value) for value in row]
                    for row in np.asarray(retrieval.neighbor_band_values, dtype=np.float64)
                ],
            }
        diagnostics["segments"] = diagnostics_segments
        if confidence_scores:
            diagnostics["confidence_score"] = float(np.average(np.asarray(confidence_scores), weights=np.asarray(confidence_weights)))
        else:
            diagnostics["confidence_score"] = 0.0

        reconstructed_vnir = segment_outputs.get("vnir")
        reconstructed_swir = segment_outputs.get("swir")
        reconstructed_full: np.ndarray | None = None
        if reconstructed_vnir is not None and reconstructed_swir is not None:
            reconstructed_full = _assemble_full_spectrum(reconstructed_vnir, reconstructed_swir)

        if output_mode == "target_sensor" and not target_sensor:
            raise MappingInputError("target_sensor is required when output_mode is target_sensor.")
        if output_mode == "vnir_spectrum" and reconstructed_vnir is None:
            raise MappingInputError(
                "VNIR reconstruction requires enough valid VNIR source bands.",
                context={"diagnostics": diagnostics},
            )
        if output_mode == "swir_spectrum" and reconstructed_swir is None:
            raise MappingInputError(
                "SWIR reconstruction requires enough valid SWIR source bands.",
                context={"diagnostics": diagnostics},
            )
        if output_mode == "full_spectrum" and reconstructed_full is None:
            raise MappingInputError(
                "Full-spectrum reconstruction requires both VNIR and SWIR segment retrievals.",
                context={"diagnostics": diagnostics},
            )

        target_reflectance: np.ndarray | None = None
        target_band_ids: tuple[str, ...] = ()
        if target_sensor:
            target_reflectance, target_band_ids = self._simulate_target_sensor(target_sensor, segment_outputs)
            if output_mode == "target_sensor" and target_reflectance is None:
                raise MappingInputError(
                    "Target-sensor mapping could not produce any bands because no target segments were retrievable.",
                    context={"diagnostics": diagnostics, "target_sensor": target_sensor},
                )

        reconstructed_wavelength_nm: np.ndarray | None = None
        if output_mode == "vnir_spectrum":
            reconstructed_wavelength_nm = VNIR_WAVELENGTHS.astype(np.float64)
        elif output_mode == "swir_spectrum":
            reconstructed_wavelength_nm = SWIR_WAVELENGTHS.astype(np.float64)
        elif output_mode == "full_spectrum":
            reconstructed_wavelength_nm = CANONICAL_WAVELENGTHS.astype(np.float64)

        return MappingResult(
            target_reflectance=target_reflectance,
            target_band_ids=target_band_ids,
            reconstructed_vnir=reconstructed_vnir,
            reconstructed_swir=reconstructed_swir,
            reconstructed_full_spectrum=reconstructed_full,
            reconstructed_wavelength_nm=reconstructed_wavelength_nm,
            neighbor_ids_by_segment=neighbor_ids_by_segment,
            neighbor_distances_by_segment=neighbor_distances_by_segment,
            segment_outputs=segment_outputs,
            segment_valid_band_counts=segment_valid_band_counts,
            diagnostics=diagnostics,
        )

    def _map_reflectance_internal(
        self,
        *,
        source_sensor: str,
        reflectance: Sequence[float] | Mapping[str, float],
        valid_mask: Sequence[bool] | Mapping[str, bool] | None,
        output_mode: str,
        target_sensor: str | None,
        k: int,
        min_valid_bands: int,
        neighbor_estimator: str,
        knn_backend: str = "numpy",
        knn_eps: float = 0.0,
        candidate_row_indices: Sequence[int] | None,
    ) -> MappingResult:
        _validate_mapping_request(
            output_mode,
            k=k,
            min_valid_bands=min_valid_bands,
            neighbor_estimator=neighbor_estimator,
            knn_backend=knn_backend,
            knn_eps=knn_eps,
        )

        source_schema = self.get_sensor_schema(source_sensor)
        query_values, query_valid_mask = self._coerce_query(source_schema, reflectance, valid_mask)
        candidate_rows = self._candidate_rows(candidate_row_indices)

        segment_retrievals: dict[str, _SegmentRetrieval] = {}
        for segment in SEGMENTS:
            segment_indices = list(_source_retrieval_band_indices(source_schema, segment))
            segment_values = query_values[segment_indices]
            segment_valid = query_valid_mask[segment_indices]
            retrieval = self._retrieve_segment(
                source_sensor=source_sensor,
                segment=segment,
                query_values=segment_values,
                valid_mask=segment_valid,
                k=k,
                min_valid_bands=min_valid_bands,
                neighbor_estimator=neighbor_estimator,
                knn_backend=knn_backend,
                knn_eps=knn_eps,
                candidate_row_indices=candidate_rows,
            )
            segment_retrievals[segment] = retrieval
        return self._build_mapping_result(
            source_sensor=source_sensor,
            target_sensor=target_sensor,
            output_mode=output_mode,
            k=k,
            neighbor_estimator=neighbor_estimator,
            knn_backend=knn_backend,
            knn_eps=knn_eps,
            segment_retrievals=segment_retrievals,
        )

    def map_reflectance(
        self,
        *,
        source_sensor: str,
        reflectance: Sequence[float] | Mapping[str, float],
        valid_mask: Sequence[bool] | Mapping[str, bool] | None = None,
        output_mode: str = "target_sensor",
        target_sensor: str | None = None,
        k: int = 10,
        min_valid_bands: int = 1,
        neighbor_estimator: str = "mean",
        knn_backend: str = "numpy",
        knn_eps: float = 0.0,
        exclude_row_ids: Sequence[str] | None = None,
        exclude_sample_names: Sequence[str] | None = None,
    ) -> MappingResult:
        return self._map_reflectance_internal(
            source_sensor=source_sensor,
            reflectance=reflectance,
            valid_mask=valid_mask,
            output_mode=output_mode,
            target_sensor=target_sensor,
            k=k,
            min_valid_bands=min_valid_bands,
            neighbor_estimator=neighbor_estimator,
            knn_backend=knn_backend,
            knn_eps=knn_eps,
            candidate_row_indices=self._candidate_rows(
                None,
                exclude_row_ids=exclude_row_ids,
                exclude_sample_names=exclude_sample_names,
            ),
        )

    def map_reflectance_batch(
        self,
        *,
        source_sensor: str,
        reflectance_rows: Sequence[Sequence[float] | Mapping[str, float]] | np.ndarray,
        valid_mask_rows: Sequence[Sequence[bool] | Mapping[str, bool] | None] | np.ndarray | None = None,
        sample_ids: Sequence[str] | None = None,
        output_mode: str = "target_sensor",
        target_sensor: str | None = None,
        k: int = 10,
        min_valid_bands: int = 1,
        neighbor_estimator: str = "mean",
        knn_backend: str = "numpy",
        knn_eps: float = 0.0,
        exclude_row_ids: Sequence[str] | None = None,
        exclude_sample_names: Sequence[str] | None = None,
        exclude_row_ids_per_sample: Sequence[str | None] | Mapping[str, str | None] | None = None,
        self_exclude_sample_id: bool = False,
    ) -> BatchMappingResult:
        _validate_mapping_request(
            output_mode,
            k=k,
            min_valid_bands=min_valid_bands,
            neighbor_estimator=neighbor_estimator,
            knn_backend=knn_backend,
            knn_eps=knn_eps,
        )
        if isinstance(reflectance_rows, Mapping):
            raise MappingInputError("map_reflectance_batch requires a batch of samples, not a single mapping.")

        if isinstance(reflectance_rows, np.ndarray):
            if reflectance_rows.ndim != 2 or reflectance_rows.shape[0] == 0:
                raise MappingInputError(
                    "reflectance_rows arrays must be two-dimensional with at least one sample row.",
                    context={"shape": list(reflectance_rows.shape)},
                )
            batch_rows: list[Sequence[float] | Mapping[str, float]] = [np.asarray(row) for row in reflectance_rows]
        elif isinstance(reflectance_rows, Sequence) and not isinstance(reflectance_rows, (str, bytes)):
            batch_rows = list(reflectance_rows)
            if not batch_rows:
                raise MappingInputError("Batch mapping requires at least one reflectance sample.")
        else:
            raise MappingInputError("reflectance_rows must be a two-dimensional array or a sequence of per-sample inputs.")

        normalized_sample_ids = _normalized_sample_ids(sample_ids, sample_count=len(batch_rows))

        if isinstance(valid_mask_rows, Mapping):
            raise MappingInputError("map_reflectance_batch requires valid_mask_rows to be batched when provided.")
        if valid_mask_rows is None:
            batch_valid_masks: list[Sequence[bool] | Mapping[str, bool] | None] = [None] * len(batch_rows)
        elif isinstance(valid_mask_rows, np.ndarray):
            if valid_mask_rows.ndim != 2 or valid_mask_rows.shape[0] != len(batch_rows):
                raise MappingInputError(
                    "valid_mask_rows arrays must be two-dimensional and aligned to reflectance_rows.",
                    context={
                        "reflectance_sample_count": len(batch_rows),
                        "valid_mask_shape": list(valid_mask_rows.shape),
                    },
                )
            batch_valid_masks = [np.asarray(row, dtype=bool) for row in valid_mask_rows]
        elif isinstance(valid_mask_rows, Sequence) and not isinstance(valid_mask_rows, (str, bytes)):
            batch_valid_masks = list(valid_mask_rows)
            if len(batch_valid_masks) != len(batch_rows):
                raise MappingInputError(
                    "valid_mask_rows must have the same length as reflectance_rows.",
                    context={"sample_count": len(batch_rows), "valid_mask_count": len(batch_valid_masks)},
                )
        else:
            raise MappingInputError("valid_mask_rows must be a two-dimensional array or a sequence of per-sample masks.")

        normalized_exclude_row_ids_per_sample = self._normalized_batch_exclude_row_ids(
            exclude_row_ids_per_sample,
            sample_ids=normalized_sample_ids,
        )

        source_schema = self.get_sensor_schema(source_sensor)
        query_values_batch = np.empty((len(batch_rows), len(source_schema.bands)), dtype=np.float64)
        query_valid_mask_batch = np.empty((len(batch_rows), len(source_schema.bands)), dtype=bool)
        for sample_index, (sample_id, reflectance, valid_mask) in enumerate(
            zip(normalized_sample_ids, batch_rows, batch_valid_masks)
        ):
            try:
                query_values, query_valid_mask = self._coerce_query(source_schema, reflectance, valid_mask)
            except SpectralLibraryError as error:
                raise _attach_sample_context(error, sample_id=sample_id, sample_index=sample_index) from error
            query_values_batch[sample_index] = query_values
            query_valid_mask_batch[sample_index] = query_valid_mask

        if exclude_row_ids or exclude_sample_names or self_exclude_sample_id or any(normalized_exclude_row_ids_per_sample):
            base_candidate_rows = self._candidate_rows(
                None,
                exclude_row_ids=exclude_row_ids,
                exclude_sample_names=exclude_sample_names,
            )
            results: list[MappingResult] = []
            for sample_index, (sample_id, reflectance, valid_mask, sample_exclude_row_id) in enumerate(
                zip(normalized_sample_ids, batch_rows, batch_valid_masks, normalized_exclude_row_ids_per_sample)
            ):
                try:
                    candidate_rows = base_candidate_rows
                    if sample_exclude_row_id:
                        candidate_rows = self._candidate_rows(candidate_rows, exclude_row_ids=[sample_exclude_row_id])
                    if self_exclude_sample_id and self.has_prepared_sample_name(sample_id):
                        candidate_rows = self._candidate_rows(candidate_rows, exclude_sample_names=[sample_id])
                    results.append(
                        self._map_reflectance_internal(
                            source_sensor=source_sensor,
                            reflectance=reflectance,
                            valid_mask=valid_mask,
                            output_mode=output_mode,
                            target_sensor=target_sensor,
                            k=k,
                            min_valid_bands=min_valid_bands,
                            neighbor_estimator=neighbor_estimator,
                            knn_backend=knn_backend,
                            knn_eps=knn_eps,
                            candidate_row_indices=candidate_rows,
                        )
                    )
                except SpectralLibraryError as error:
                    raise _attach_sample_context(error, sample_id=sample_id, sample_index=sample_index) from error
            return BatchMappingResult(sample_ids=normalized_sample_ids, results=tuple(results))

        candidate_rows = self._candidate_rows(None)
        segment_retrievals_by_segment: dict[str, tuple[_SegmentRetrieval, ...]] = {}
        for segment in SEGMENTS:
            segment_indices = list(_source_retrieval_band_indices(source_schema, segment))
            segment_retrievals_by_segment[segment] = self._retrieve_segment_batch(
                source_sensor=source_sensor,
                segment=segment,
                query_values=query_values_batch[:, segment_indices],
                valid_mask=query_valid_mask_batch[:, segment_indices],
                k=k,
                min_valid_bands=min_valid_bands,
                neighbor_estimator=neighbor_estimator,
                knn_backend=knn_backend,
                knn_eps=knn_eps,
                candidate_row_indices=candidate_rows,
            )

        results: list[MappingResult] = []
        for sample_index, sample_id in enumerate(normalized_sample_ids):
            try:
                sample_retrievals = {
                    segment: segment_retrievals_by_segment[segment][sample_index] for segment in SEGMENTS
                }
                results.append(
                    self._build_mapping_result(
                        source_sensor=source_sensor,
                        target_sensor=target_sensor,
                        output_mode=output_mode,
                        k=k,
                        neighbor_estimator=neighbor_estimator,
                        knn_backend=knn_backend,
                        knn_eps=knn_eps,
                        segment_retrievals=sample_retrievals,
                    )
                )
            except SpectralLibraryError as error:
                raise _attach_sample_context(error, sample_id=sample_id, sample_index=sample_index) from error

        return BatchMappingResult(sample_ids=normalized_sample_ids, results=tuple(results))

    def _source_queries(self, source_sensor: str) -> np.ndarray:
        if source_sensor not in self._source_query_cache:
            query_matrix, band_ids = self._simulate_full_sensor_matrix(source_sensor)
            schema = self.get_sensor_schema(source_sensor)
            if band_ids != schema.band_ids():
                raise PreparedLibraryValidationError(
                    "Prepared source query matrix band order does not match the sensor schema.",
                    context={"source_sensor": source_sensor},
                )
            self._source_query_cache[source_sensor] = query_matrix
        return self._source_query_cache[source_sensor]

    def _simulate_full_sensor_matrix(self, sensor_id: str) -> tuple[np.ndarray, tuple[str, ...]]:
        schema = self.get_sensor_schema(sensor_id)
        values: list[np.ndarray] = []
        band_ids: list[str] = []
        for segment in SEGMENTS:
            segment_hyperspectral = np.asarray(self._load_hyperspectral(segment), dtype=np.float64)
            for band in schema.bands_for_segment(segment):
                response = self._band_response(sensor_id, band, segment_only=True)
                denominator = float(response.sum())
                values.append(segment_hyperspectral @ response / denominator)
                band_ids.append(band.band_id)
        if not values:
            raise SensorSchemaError("Sensor schema must include at least one band.", context={"sensor_id": sensor_id})
        return np.column_stack(values), tuple(band_ids)


def _metric_report(predicted: np.ndarray, truth: np.ndarray) -> dict[str, object]:
    residual = np.asarray(predicted, dtype=np.float64) - np.asarray(truth, dtype=np.float64)
    rmse = np.sqrt(np.mean(residual**2, axis=0))
    mae = np.mean(np.abs(residual), axis=0)
    bias = np.mean(residual, axis=0)
    return {
        "mean_rmse": float(np.mean(rmse)),
        "mean_mae": float(np.mean(mae)),
        "mean_abs_bias": float(np.mean(np.abs(bias))),
        "per_band_rmse": [float(value) for value in rmse],
        "per_band_mae": [float(value) for value in mae],
        "per_band_bias": [float(value) for value in bias],
    }


def benchmark_mapping(
    prepared_root: Path,
    source_sensor: str,
    target_sensor: str,
    *,
    k: int = 10,
    test_fraction: float = 0.2,
    max_test_rows: int | None = None,
    random_seed: int = 0,
    neighbor_estimator: str = "mean",
    knn_backend: str = "numpy",
    knn_eps: float = 0.0,
) -> dict[str, object]:
    _validate_mapping_request(
        "target_sensor",
        k=k,
        min_valid_bands=1,
        neighbor_estimator=neighbor_estimator,
        knn_backend=knn_backend,
        knn_eps=knn_eps,
    )
    if not 0 < test_fraction < 1:
        raise MappingInputError("test_fraction must be between 0 and 1.", context={"test_fraction": test_fraction})
    if max_test_rows is not None and int(max_test_rows) < 1:
        raise MappingInputError("max_test_rows must be at least 1 when provided.", context={"max_test_rows": max_test_rows})

    mapper = SpectralMapper(prepared_root)
    source_queries = mapper._source_queries(source_sensor)
    target_truth, target_band_ids = mapper._simulate_full_sensor_matrix(target_sensor)
    vnir_truth = np.asarray(mapper._load_hyperspectral("vnir"), dtype=np.float64)
    swir_truth = np.asarray(mapper._load_hyperspectral("swir"), dtype=np.float64)
    full_truth = _assemble_full_spectrum_batch(vnir_truth, swir_truth)

    row_count = source_queries.shape[0]
    if row_count < 2:
        raise MappingInputError("benchmark_mapping requires at least two prepared-library rows.", context={"row_count": row_count})

    rng = np.random.default_rng(random_seed)
    permutation = rng.permutation(row_count)
    test_count = max(1, int(math.ceil(row_count * test_fraction)))
    if max_test_rows is not None:
        test_count = min(test_count, int(max_test_rows))
    if test_count >= row_count:
        test_count = row_count - 1
    test_indices = permutation[:test_count]
    train_indices = permutation[test_count:]

    design_train = np.column_stack([np.ones(train_indices.size, dtype=np.float64), source_queries[train_indices]])
    coefficients, _, _, _ = np.linalg.lstsq(design_train, target_truth[train_indices], rcond=None)
    design_test = np.column_stack([np.ones(test_indices.size, dtype=np.float64), source_queries[test_indices]])
    regression_predictions = design_test @ coefficients

    retrieval_target = np.empty((test_indices.size, len(target_band_ids)), dtype=np.float64)
    retrieval_vnir = np.empty((test_indices.size, len(VNIR_WAVELENGTHS)), dtype=np.float64)
    retrieval_swir = np.empty((test_indices.size, len(SWIR_WAVELENGTHS)), dtype=np.float64)
    retrieval_full = np.empty((test_indices.size, FULL_WAVELENGTH_COUNT), dtype=np.float64)

    for output_index, row_index in enumerate(test_indices):
        result = mapper._map_reflectance_internal(
            source_sensor=source_sensor,
            reflectance=source_queries[row_index],
            valid_mask=None,
            output_mode="full_spectrum",
            target_sensor=target_sensor,
            k=k,
            min_valid_bands=1,
            neighbor_estimator=neighbor_estimator,
            knn_backend=knn_backend,
            knn_eps=knn_eps,
            candidate_row_indices=train_indices,
        )
        if result.target_reflectance is None or result.reconstructed_vnir is None or result.reconstructed_swir is None:
            raise PreparedLibraryValidationError(
                "benchmark_mapping expected full mapping outputs for every held-out query.",
                context={"row_index": int(row_index)},
            )
        retrieval_target[output_index] = result.target_reflectance
        retrieval_vnir[output_index] = result.reconstructed_vnir
        retrieval_swir[output_index] = result.reconstructed_swir
        if result.reconstructed_full_spectrum is None:
            raise PreparedLibraryValidationError(
                "benchmark_mapping expected a full reconstructed spectrum for every held-out query.",
                context={"row_index": int(row_index)},
            )
        retrieval_full[output_index] = result.reconstructed_full_spectrum

    return {
        "source_sensor_id": source_sensor,
        "target_sensor_id": target_sensor,
        "k": int(k),
        "neighbor_estimator": neighbor_estimator,
        "knn_backend": knn_backend,
        "knn_eps": float(knn_eps),
        "max_test_rows": None if max_test_rows is None else int(max_test_rows),
        "random_seed": int(random_seed),
        "train_rows": int(train_indices.size),
        "test_rows": int(test_indices.size),
        "target_sensor": {
            "band_ids": list(target_band_ids),
            "retrieval": _metric_report(retrieval_target, target_truth[test_indices]),
            "regression_baseline": _metric_report(regression_predictions, target_truth[test_indices]),
        },
        "vnir_spectrum": {
            "wavelength_nm": [int(value) for value in VNIR_WAVELENGTHS],
            "retrieval": _metric_report(retrieval_vnir, vnir_truth[test_indices]),
        },
        "swir_spectrum": {
            "wavelength_nm": [int(value) for value in SWIR_WAVELENGTHS],
            "retrieval": _metric_report(retrieval_swir, swir_truth[test_indices]),
        },
        "full_spectrum": {
            "wavelength_nm": [int(value) for value in CANONICAL_WAVELENGTHS],
            "retrieval": _metric_report(retrieval_full, full_truth[test_indices]),
        },
    }
