"""
Mission-Agnostic Monte Carlo Geolocation Correction Pipeline.

This module provides generic Monte Carlo correction infrastructure for
geolocation component sensitivity analysis. It was developed for CLARREO,
but intended to work with any Earth observation mission through configuration.

Configuration Strategy:
----------------------
**Do not edit this file to configure for your mission.**

Instead, follow these steps:

1. Create a mission-specific config module (e.g., tests/test_correction/your_mission_config.py)
2. Copy from tests/test_correction/clarreo_config.py as a template
3. Define your mission's:
   - Kernel file paths
   - Parameter definitions (bounds, sigma, units)
   - Instrument name and settings
   - Performance thresholds
   - Earth radius and geodetic parameters
4. Use create_your_mission_monte_carlo_config() to build MonteCarloConfig object
5. Optionally save config to JSON for reproducibility
6. Pass the configuration object to Monte Carlo pipeline functions

Quick Start:
-----------
    from curryer.correction import monte_carlo as mc
    from your_mission_config import create_mission_config

    # Create configuration
    config = create_mission_config(data_dir, generic_dir)

    # Run Monte Carlo analysis
    results = mc.run_monte_carlo_pipeline(config)

For CLARREO Example:
-------------------
See tests/test_correction/clarreo_config.py for a complete reference implementation.

For Configuration Details:
-------------------------
See CONFIGURATION_GUIDE.md in the repository root.

Mission-Agnostic Design:
-----------------------
This module contains NO mission-specific values, column names, or hardcoded constants.
All mission-specific parameters must be provided through MonteCarloConfig.

Core modules in curryer/correction/ are generic. Mission-specific code belongs in
your test or application directories (e.g., tests/test_correction/clarreo_*).
"""

import json
import logging
import time
import typing
from dataclasses import dataclass
from enum import Enum, auto
from pathlib import Path
from typing import Any, NamedTuple

import numpy as np
import pandas as pd
import xarray as xr

from curryer import meta
from curryer import spicierpy as sp
from curryer.compute import spatial
from curryer.correction import correction_config
from curryer.correction.data_structures import GeolocationConfig as ImageMatchGeolocationConfig
from curryer.correction.data_structures import SearchConfig

# Import data loader protocols and validation
from curryer.correction.dataio import (
    GCPLoader,
    ScienceLoader,
    TelemetryLoader,
    validate_science_output,
    validate_telemetry_output,
)

# Import image matching modules
from curryer.correction.image_match import (
    ImageMatchingFunc,
    integrated_image_match,
    load_image_grid_from_mat,
    load_los_vectors_from_mat,
    load_optical_psf_from_mat,
    validate_image_matching_output,
)

# Import pairing protocols and validation
from curryer.correction.pairing import (
    GCPPairingFunc,
)
from curryer.correction.pairing import (
    validate_pairing_output as validate_gcp_pairing_output,
)
from curryer.kernels import create

logger = logging.getLogger(__name__)


# ============================================================================
# Helper Data Structures
# ============================================================================


class KernelContext(NamedTuple):
    """Context for SPICE kernel loading during geolocation.

    Attributes
    ----------
    mkrn : meta.MetaKernel
        MetaKernel instance with SDS and mission kernels
    dynamic_kernels : list[Path]
        List of dynamic kernel file paths (SC-SPK, SC-CK)
    param_kernels : list[Path]
        List of parameter-specific kernel file paths
    """

    mkrn: "meta.MetaKernel"
    dynamic_kernels: list[Path]
    param_kernels: list[Path]


class CalibrationData(NamedTuple):
    """Pre-loaded calibration data for image matching.

    Attributes
    ----------
    los_vectors : Optional[np.ndarray]
        Line-of-sight vectors array, or None if not using calibration
    optical_psfs : Optional[list]
        List of optical PSF entries, or None if not using calibration
    """

    los_vectors: np.ndarray | None
    optical_psfs: list | None


class ImageMatchingContext(NamedTuple):
    """Context data needed for image matching operations.

    Attributes
    ----------
    gcp_pairs : list[tuple]
        List of GCP pairing tuples from pairing function
    params : list[tuple]
        List of (ParameterConfig, parameter_value) tuples for this iteration
    pair_idx : int
        Index of current GCP pair being processed
    sci_key : str
        Science dataset identifier for this pair
    """

    gcp_pairs: list[tuple]
    params: list[tuple]
    pair_idx: int
    sci_key: str


# ============================================================================
# Standard NetCDF Variable Attributes (Mission-Agnostic)
# ============================================================================

# Standard metric attributes for NetCDF output
# These are generic geolocation/error metrics that apply to most missions
# Missions can override these in their NetCDFConfig if needed
STANDARD_NETCDF_ATTRIBUTES = {
    # Geolocation error metrics (per GCP pair)
    "rms_error_m": {"units": "meters", "long_name": "RMS geolocation error"},
    "mean_error_m": {"units": "meters", "long_name": "Mean geolocation error"},
    "max_error_m": {"units": "meters", "long_name": "Maximum geolocation error"},
    "std_error_m": {"units": "meters", "long_name": "Standard deviation of geolocation error"},
    "n_measurements": {"units": "count", "long_name": "Number of measurement points"},
    # Aggregate performance metrics (per parameter set)
    "mean_rms_all_pairs": {"units": "meters", "long_name": "Mean RMS error across all GCP pairs"},
    "worst_pair_rms": {"units": "meters", "long_name": "Worst performing GCP pair RMS error"},
    "best_pair_rms": {"units": "meters", "long_name": "Best performing GCP pair RMS error"},
    # Image matching metrics (per GCP pair)
    "im_lat_error_km": {"units": "kilometers", "long_name": "Image matching latitude error"},
    "im_lon_error_km": {"units": "kilometers", "long_name": "Image matching longitude error"},
    "im_ccv": {"units": "dimensionless", "long_name": "Image matching correlation coefficient"},
    "im_grid_step_m": {"units": "meters", "long_name": "Image matching final grid step size"},
}


# ============================================================================
# Standard Data Variable Names (Mission-Agnostic Keys)
# ============================================================================

# Standard variable names that should be present in image matching results
# Used for extracting data from xarray.Dataset objects
STANDARD_VAR_NAMES = {
    # Error measurements (required)
    "lat_error_deg": "lat_error_deg",
    "lon_error_deg": "lon_error_deg",
    # Spacecraft state (configurable names)
    "spacecraft_position": "sc_position",  # Generic default
    "boresight": "boresight",  # Generic default
    "transformation_matrix": "t_inst2ref",  # Generic default
    # Control point location (optional)
    "gcp_lat_deg": "gcp_lat_deg",
    "gcp_lon_deg": "gcp_lon_deg",
    "gcp_alt": "gcp_alt",
}


# ============================================================================
# Internal Adapter Functions (Monte Carlo <-> Image Matching)
# ============================================================================


def _geolocated_to_image_grid(geo_dataset: xr.Dataset):
    """
    Convert Monte Carlo geolocation output to ImageGrid for image matching.

    Internal adapter function: converts xarray.Dataset from geolocation step
    to ImageGrid format expected by image_match module.

    Args:
        geo_dataset: xarray.Dataset with latitude, longitude, altitude/height

    Returns:
        ImageGrid suitable for integrated_image_match()
    """
    from curryer.correction.data_structures import ImageGrid

    lat = geo_dataset["latitude"].values
    lon = geo_dataset["longitude"].values

    # Try different field names for altitude/height
    if "altitude" in geo_dataset:
        h = geo_dataset["altitude"].values
    elif "height" in geo_dataset:
        h = geo_dataset["height"].values
    else:
        h = np.zeros_like(lat)

    # Get actual radiance/reflectance data when available
    if "radiance" in geo_dataset:
        data = geo_dataset["radiance"].values
    elif "reflectance" in geo_dataset:
        data = geo_dataset["reflectance"].values
    else:
        data = np.ones_like(lat)

    return ImageGrid(data=data, lat=lat, lon=lon, h=h)


def _extract_spacecraft_position_midframe(telemetry: pd.DataFrame) -> np.ndarray:
    """
    Extract spacecraft position at mid-frame from Monte Carlo telemetry.

    Internal adapter function: extracts position from telemetry DataFrame
    with fallback logic for different column naming conventions.

    Args:
        telemetry: Telemetry DataFrame with spacecraft position columns

    Returns:
        np.ndarray, shape (3,) - [x, y, z] position in meters (J2000 frame)

    Raises:
        ValueError: If position columns cannot be found
    """
    mid_idx = len(telemetry) // 2

    # Try common column name patterns
    position_patterns = [
        ["sc_pos_x", "sc_pos_y", "sc_pos_z"],
        ["position_x", "position_y", "position_z"],
        ["r_x", "r_y", "r_z"],
        ["pos_x", "pos_y", "pos_z"],
    ]

    for cols in position_patterns:
        if all(c in telemetry.columns for c in cols):
            position = telemetry[cols].iloc[mid_idx].values.astype(np.float64)
            logger.debug(f"Extracted spacecraft position from columns {cols}: {position}")
            return position

    # If patterns don't match, try to find any column containing 'pos' or 'r_'
    pos_cols = [c for c in telemetry.columns if "pos" in c.lower() or c.startswith("r_")]
    if len(pos_cols) >= 3:
        logger.warning(f"Using first 3 position-like columns: {pos_cols[:3]}")
        return telemetry[pos_cols[:3]].iloc[mid_idx].values.astype(np.float64)

    raise ValueError(f"Cannot find position columns in telemetry. Available columns: {telemetry.columns.tolist()}")


# Configuration Loading Functions


def load_config_from_json(config_path: Path) -> "MonteCarloConfig":
    """Load Monte Carlo configuration from a JSON file.

    Args:
        config_path: Path to the JSON configuration file (e.g., gcs_config.json)

    Returns:
        MonteCarloConfig object populated from the JSON file

    Raises:
        FileNotFoundError: If config file doesn't exist
        ValueError: If config file format is invalid
        KeyError: If required config sections are missing
    """
    config_path = Path(config_path)

    if not config_path.exists():
        raise FileNotFoundError(f"Configuration file not found: {config_path}")

    logger.info(f"Loading Monte Carlo configuration from: {config_path}")

    try:
        with open(config_path) as f:
            config_data = json.load(f)
    except json.JSONDecodeError as e:
        raise ValueError(f"Invalid JSON in config file {config_path}: {e}")

    # Extract mission configuration and kernel mappings
    mission_config = correction_config.extract_mission_config(config_data)
    constant_kernel_map = correction_config.get_kernel_mapping(config_data, "constant_kernel")
    offset_kernel_map = correction_config.get_kernel_mapping(config_data, "offset_kernel")

    logger.debug(f"Mission: {mission_config.get('mission_name', 'UNKNOWN')}")
    logger.debug(f"Constant kernel mappings: {constant_kernel_map}")
    logger.debug(f"Offset kernel mappings: {offset_kernel_map}")

    # Validate required sections exist
    if "monte_carlo" not in config_data:
        raise KeyError("Missing required 'monte_carlo' section in config file")
    if "geolocation" not in config_data:
        raise KeyError("Missing required 'geolocation' section in config file")

    # Extract monte_carlo section
    mc_config = config_data.get("monte_carlo", {})
    geo_config = config_data.get("geolocation", {})

    # Validate monte_carlo section
    if "parameters" not in mc_config:
        raise KeyError("Missing required 'parameters' in monte_carlo section")
    if not isinstance(mc_config["parameters"], list):
        raise ValueError("'parameters' must be a list")
    if len(mc_config["parameters"]) == 0:
        raise ValueError("No parameters defined in configuration")

    # Parse parameters and group related ones together
    parameters = []
    param_groups = {}

    # First pass: group parameters by their base name and type
    for param_dict in mc_config.get("parameters", []):
        param_name = param_dict.get("name", "")
        ptype_str = param_dict.get("parameter_type", "CONSTANT_KERNEL")
        ptype = ParameterType[ptype_str]

        # Group CONSTANT_KERNEL parameters by their base frame name
        if ptype == ParameterType.CONSTANT_KERNEL:
            # Extract base name (e.g., "hysics_to_cradle" from "hysics_to_cradle_roll")
            if "_roll" in param_name:
                base_name = param_name.replace("_roll", "")
                angle_type = "roll"
            elif "_pitch" in param_name:
                base_name = param_name.replace("_pitch", "")
                angle_type = "pitch"
            elif "_yaw" in param_name:
                base_name = param_name.replace("_yaw", "")
                angle_type = "yaw"
            else:
                base_name = param_name
                angle_type = "single"

            if base_name not in param_groups:
                param_groups[base_name] = {"type": ptype, "angles": {}, "template": param_dict, "config_file": None}

            param_groups[base_name]["angles"][angle_type] = param_dict.get("initial_value", 0.0)

            # Determine config file based on kernel mapping from config
            kernel_file = correction_config.find_kernel_file(base_name, constant_kernel_map)
            if kernel_file:
                param_groups[base_name]["config_file"] = Path(kernel_file)
                logger.debug(f"Mapped CONSTANT_KERNEL '{base_name}' → {kernel_file}")
            else:
                logger.warning(f"No kernel mapping found for CONSTANT_KERNEL parameter: {base_name}")

        else:
            # OFFSET_KERNEL and OFFSET_TIME parameters are individual
            param_groups[param_name] = {"type": ptype, "param_dict": param_dict, "config_file": None}

            if ptype == ParameterType.OFFSET_KERNEL:
                # Determine config file based on kernel mapping from config
                kernel_file = correction_config.find_kernel_file(param_name, offset_kernel_map)
                if kernel_file:
                    param_groups[param_name]["config_file"] = Path(kernel_file)
                    logger.debug(f"Mapped OFFSET_KERNEL '{param_name}' → {kernel_file}")
                else:
                    logger.warning(f"No kernel mapping found for OFFSET_KERNEL parameter: {param_name}")

    # Second pass: create ParameterConfig objects from groups
    for group_name, group_data in param_groups.items():
        if group_data["type"] == ParameterType.CONSTANT_KERNEL:
            # For CONSTANT_KERNEL, combine roll/pitch/yaw into a single parameter
            template = group_data["template"]
            angles = group_data["angles"]

            # Create center values array [roll, pitch, yaw] with defaults of 0.0
            center_values = [angles.get("roll", 0.0), angles.get("pitch", 0.0), angles.get("yaw", 0.0)]

            param_data = {
                "center": center_values,
                "arange": template.get("bounds", [-100, 100]),
                "sigma": template.get("sigma"),
                "units": template.get("units", "arcseconds"),
                "distribution": template.get("distribution_type", "normal"),
                "field": template.get("application_target", {}).get("field_name", None),
            }

        else:
            # For OFFSET_KERNEL and OFFSET_TIME, use the parameter as-is
            param_dict = group_data["param_dict"]
            param_data = {
                "center": param_dict.get("initial_value", 0.0),
                "arange": param_dict.get("bounds", [-100, 100]),
                "sigma": param_dict.get("sigma"),
                "units": param_dict.get("units", "radians"),
                "distribution": param_dict.get("distribution_type", "normal"),
                "field": param_dict.get("application_target", {}).get("field_name", None),
            }

        parameters.append(
            ParameterConfig(ptype=group_data["type"], config_file=group_data["config_file"], data=param_data)
        )

    logger.info(
        f"Loaded {len(parameters)} parameter groups from {len(mc_config.get('parameters', []))} individual parameters"
    )

    # Parse geolocation configuration
    # Use instrument_name from geolocation config, falling back to mission_config
    # If not specified, raise error - instrument name is required
    default_instrument = mission_config.get("instrument_name")
    instrument_name = geo_config.get("instrument_name", default_instrument)
    if instrument_name is None:
        raise ValueError("instrument_name must be specified in config (either in geolocation or mission section)")

    # Time field is required - no default to avoid mission-specific assumptions
    time_field = geo_config.get("time_field")
    if time_field is None:
        raise ValueError("time_field must be specified in geolocation config")

    geo = GeolocationConfig(
        meta_kernel_file=Path(geo_config.get("meta_kernel_file", "")),
        generic_kernel_dir=Path(geo_config.get("generic_kernel_dir", "")),
        dynamic_kernels=[Path(k) for k in geo_config.get("dynamic_kernels", [])],
        instrument_name=instrument_name,
        time_field=time_field,
    )

    # Extract required mission-specific parameters from monte_carlo section
    # These MUST be provided in the config file - no defaults
    earth_radius_m = mc_config.get("earth_radius_m")
    if earth_radius_m is None:
        raise KeyError(
            "Missing required 'earth_radius_m' in monte_carlo config section. "
            "This must be specified for your mission (e.g., 6378140.0 for WGS84)."
        )

    performance_threshold_m = mc_config.get("performance_threshold_m")
    if performance_threshold_m is None:
        raise KeyError(
            "Missing required 'performance_threshold_m' in monte_carlo config section. "
            "This must be specified for your mission (e.g., 250.0 meters for CLARREO)."
        )

    performance_spec_percent = mc_config.get("performance_spec_percent")
    if performance_spec_percent is None:
        raise KeyError(
            "Missing required 'performance_spec_percent' in monte_carlo config section. "
            "This must be specified for your mission (e.g., 39.0 percent for CLARREO)."
        )

    # Create MonteCarloConfig
    config = MonteCarloConfig(
        seed=mc_config.get("seed"),
        n_iterations=mc_config.get("n_iterations", 10),
        parameters=parameters,
        geo=geo,
        earth_radius_m=earth_radius_m,
        performance_threshold_m=performance_threshold_m,
        performance_spec_percent=performance_spec_percent,
    )

    # Validate the loaded configuration
    config.validate()

    logger.info(
        f"Configuration loaded and validated: {config.n_iterations} iterations, "
        f"{len(config.parameters)} parameter groups"
    )
    return config


# ============================================================================
# ADAPTER FUNCTIONS
# ============================================================================


def image_matching(
    geolocated_data: xr.Dataset,
    gcp_reference_file: Path,
    telemetry: pd.DataFrame,
    calibration_dir: Path,
    params_info: list,
    config: "MonteCarloConfig",
    los_vectors_cached: np.ndarray | None = None,
    optical_psfs_cached: list | None = None,
) -> xr.Dataset:
    """
    Image matching using integrated_image_match() module.

    This function performs actual image correlation between geolocated
    pixels and Landsat GCP reference imagery.

    Args:
        geolocated_data: xarray.Dataset with latitude, longitude from geolocation
        gcp_reference_file: Path to GCP reference image (MATLAB .mat file)
        telemetry: Telemetry DataFrame with spacecraft state
        calibration_dir: Directory containing calibration files (LOS vectors, PSF)
        params_info: Current parameter values for error tracking
        config: MonteCarloConfig with coordinate name mappings
        los_vectors_cached: Pre-loaded LOS vectors (optional, for performance)
        optical_psfs_cached: Pre-loaded optical PSF entries (optional, for performance)

    Returns:
        xarray.Dataset with error measurements in format expected by error_stats:
            - lat_error_deg, lon_error_deg: Spatial errors in degrees
            - Additional metadata for error statistics processing

    Raises:
        FileNotFoundError: If calibration files are missing
        ValueError: If geolocation data is invalid
    """
    logger.info(f"Image Matching: correlation with {gcp_reference_file.name}")
    start_time = time.time()

    # Convert geolocation output to ImageGrid
    logger.info("  Converting geolocation data to ImageGrid format...")
    subimage = _geolocated_to_image_grid(geolocated_data)
    logger.info(f"    Subimage shape: {subimage.data.shape}")

    # Load GCP reference image
    logger.info(f"  Loading GCP reference from {gcp_reference_file}...")
    gcp = load_image_grid_from_mat(gcp_reference_file, key="GCP")
    # Get GCP center location (center pixel)
    gcp_center_lat = float(gcp.lat[gcp.lat.shape[0] // 2, gcp.lat.shape[1] // 2])
    gcp_center_lon = float(gcp.lon[gcp.lon.shape[0] // 2, gcp.lon.shape[1] // 2])
    logger.info(f"    GCP shape: {gcp.data.shape}, center: ({gcp_center_lat:.4f}, {gcp_center_lon:.4f})")

    # Use cached calibration data if available, otherwise load
    logger.info("  Loading calibration data...")

    if los_vectors_cached is not None and optical_psfs_cached is not None:
        # Use cached data (fast path)
        los_vectors = los_vectors_cached
        optical_psfs = optical_psfs_cached
        logger.info("    Using cached calibration data")
    else:
        # Load from files
        # Use configurable calibration file names
        los_filename = config.get_calibration_file("los_vectors", default="b_HS.mat")
        los_file = calibration_dir / los_filename
        los_vectors = load_los_vectors_from_mat(los_file)
        logger.info(f"    LOS vectors: {los_vectors.shape}")

        psf_filename = config.get_calibration_file("optical_psf", default="optical_PSF_675nm_upsampled.mat")
        psf_file = calibration_dir / psf_filename
        optical_psfs = load_optical_psf_from_mat(psf_file)
        logger.info(f"    Optical PSF: {len(optical_psfs)} entries")

    # Extract spacecraft position from telemetry
    r_iss_midframe = _extract_spacecraft_position_midframe(telemetry)
    logger.info(f"    Spacecraft position: {r_iss_midframe}")

    # Run real image matching
    logger.info("  Running integrated_image_match()...")
    geolocation_config = ImageMatchGeolocationConfig()
    search_config = SearchConfig()

    result = integrated_image_match(
        subimage=subimage,
        gcp=gcp,
        r_iss_midframe_m=r_iss_midframe,
        los_vectors_hs=los_vectors,
        optical_psfs=optical_psfs,
        geolocation_config=geolocation_config,
        search_config=search_config,
    )

    # Convert IntegratedImageMatchResult to xarray.Dataset format
    logger.info("  Converting results to error_stats format...")

    # Create single measurement result (image matching produces one correlation per GCP)

    # NOTE: Boresight and transformation matrix for error_stats module
    # ----------------------------------------------------------------
    # These values are NOT used by image_matching() itself - the image correlation
    # is complete and accurate without them. They are needed by call_error_stats_module()
    # for converting off-nadir errors to nadir-equivalent errors.
    #
    # Currently using simplified nadir assumptions which are acceptable for:
    # - Near-nadir observations (< ~5 degrees off-nadir)
    # - Testing image matching correlation accuracy (doesn't affect matching)
    #
    # For accurate nadir-equivalent error conversion with off-nadir pointing, these
    # should be extracted from SPICE/geolocation data:
    # - boresight: Extract from spicierpy.getfov(instrument) and transform via geo_dataset['attitude']
    # - t_matrix: Extract from geo_dataset['attitude'] (transformation from instrument to CTRS)
    #
    # See: geolocation_error_stats.py _transform_boresight_vectors() for usage
    # See: BORESIGHT_TRANSFORM_ANALYSIS.md for detailed analysis and future enhancement plan

    t_matrix = np.eye(3)  # Simplified: Identity matrix (no rotation)
    boresight = np.array([0.0, 0.0, 1.0])  # Simplified: Nadir pointing assumption

    # Convert errors from km to degrees
    lat_error_deg = result.lat_error_km / 111.0  # ~111 km per degree latitude
    lon_radius_km = 6378.0 * np.cos(np.deg2rad(gcp_center_lat))
    lon_error_deg = result.lon_error_km / (lon_radius_km * np.pi / 180.0)

    processing_time = time.time() - start_time

    logger.info(f"  Image matching complete in {processing_time:.2f}s:")
    logger.info(f"    Lat error: {result.lat_error_km:.3f} km ({lat_error_deg:.6f}°)")
    logger.info(f"    Lon error: {result.lon_error_km:.3f} km ({lon_error_deg:.6f}°)")
    logger.info(f"    Correlation: {result.ccv_final:.4f}")
    logger.info(f"    Grid step: {result.final_grid_step_m:.1f} m")

    # Get coordinate names from config
    sc_pos_name = config.spacecraft_position_name
    boresight_name = config.boresight_name
    transform_name = config.transformation_matrix_name

    # Create output dataset in error_stats format (use config names)
    output = xr.Dataset(
        {
            "lat_error_deg": (["measurement"], [lat_error_deg]),
            "lon_error_deg": (["measurement"], [lon_error_deg]),
            sc_pos_name: (["measurement", "xyz"], [r_iss_midframe]),
            boresight_name: (["measurement", "xyz"], [boresight]),
            transform_name: (["measurement", "xyz_from", "xyz_to"], t_matrix[np.newaxis, :, :]),
            "gcp_lat_deg": (["measurement"], [gcp_center_lat]),
            "gcp_lon_deg": (["measurement"], [gcp_center_lon]),
            "gcp_alt": (["measurement"], [0.0]),  # GCP at ground level
        },
        coords={"measurement": [0], "xyz": ["x", "y", "z"], "xyz_from": ["x", "y", "z"], "xyz_to": ["x", "y", "z"]},
    )

    # Add detailed metadata (Fix #3 Part B: Add km errors to attrs)
    output.attrs.update(
        {
            "lat_error_km": result.lat_error_km,
            "lon_error_km": result.lon_error_km,
            "correlation_ccv": result.ccv_final,
            "final_grid_step_m": result.final_grid_step_m,
            "final_index_row": result.final_index_row,
            "final_index_col": result.final_index_col,
            "processing_time_s": processing_time,
            "gcp_file": str(gcp_reference_file.name),
            "gcp_center_lat": gcp_center_lat,
            "gcp_center_lon": gcp_center_lon,
        }
    )

    return output


def call_error_stats_module(image_matching_results, monte_carlo_config: "MonteCarloConfig"):
    """
    Call the error_stats module with image matching output.

    Args:
        image_matching_results: Either a single image matching result (xarray.Dataset)
                              or a list of image matching results from multiple GCP pairs
        monte_carlo_config: MonteCarloConfig with all configuration (REQUIRED)

    Returns:
        Aggregate error statistics dataset
    """
    # Handle both single result and list of results
    if not isinstance(image_matching_results, list):
        image_matching_results = [image_matching_results]

    try:
        from curryer.correction.geolocation_error_stats import ErrorStatsProcessor
        from curryer.correction.geolocation_error_stats import GeolocationConfig as ErrorStatsGeolocationConfig

        logger.info(f"Error Statistics: Processing geolocation errors from {len(image_matching_results)} GCP pairs")

        # Create error stats config directly from Monte Carlo config (single source of truth)
        error_config = ErrorStatsGeolocationConfig.from_monte_carlo_config(monte_carlo_config)

        processor = ErrorStatsProcessor(config=error_config)

        if len(image_matching_results) == 1:
            # Single GCP pair case
            error_results = processor.process_geolocation_errors(image_matching_results[0])
        else:
            # Multiple GCP pairs - aggregate the data first
            aggregated_data = _aggregate_image_matching_results(image_matching_results, monte_carlo_config)
            error_results = processor.process_geolocation_errors(aggregated_data)

        return error_results

    except ImportError as e:
        logger.warning(f"Error stats module not available: {e}")
        logger.info(f"Error Statistics: Using placeholder calculations for {len(image_matching_results)} GCP pairs")

        # Fallback: compute basic statistics across all GCP pairs
        all_lat_errors = []
        all_lon_errors = []
        total_measurements = 0

        for result in image_matching_results:
            lat_errors = result["lat_error_deg"].values
            lon_errors = result["lon_error_deg"].values
            all_lat_errors.extend(lat_errors)
            all_lon_errors.extend(lon_errors)
            total_measurements += len(lat_errors)

        all_lat_errors = np.array(all_lat_errors)
        all_lon_errors = np.array(all_lon_errors)

        # Convert to meters (approximate)
        lat_error_m = all_lat_errors * 111000
        lon_error_m = all_lon_errors * 111000
        total_error_m = np.sqrt(lat_error_m**2 + lon_error_m**2)

        mean_error = float(np.mean(total_error_m))
        rms_error = float(np.sqrt(np.mean(total_error_m**2)))
        std_error = float(np.std(total_error_m))

        return xr.Dataset(
            {
                "mean_error": mean_error,
                "rms_error": rms_error,
                "std_error": std_error,
                "max_error": float(np.max(total_error_m)),
                "min_error": float(np.min(total_error_m)),
            }
        )


def _aggregate_image_matching_results(image_matching_results, config: "MonteCarloConfig"):
    """
    Aggregate multiple image matching results into a single dataset for error stats processing.

    Args:
        image_matching_results: List of xarray.Dataset objects from image matching
        config: MonteCarloConfig with coordinate name mappings

    Returns:
        Single aggregated xarray.Dataset with all measurements combined
    """
    logger.info(f"Aggregating {len(image_matching_results)} image matching results")

    # Get coordinate names from config
    sc_pos_name = config.spacecraft_position_name
    boresight_name = config.boresight_name
    transform_name = config.transformation_matrix_name

    # Combine all measurements into single arrays
    all_lat_errors = []
    all_lon_errors = []
    all_sc_positions = []
    all_boresights = []
    all_transforms = []
    all_gcp_lats = []
    all_gcp_lons = []
    all_gcp_alts = []

    for i, result in enumerate(image_matching_results):
        # Add GCP pair identifier to track source
        n_measurements = len(result["lat_error_deg"])

        all_lat_errors.extend(result["lat_error_deg"].values)
        all_lon_errors.extend(result["lon_error_deg"].values)

        # Handle coordinate transformation data (use config names)
        # NOTE: Individual results have shape (1, 3) for vectors and (1, 3, 3) for matrices
        if sc_pos_name in result:
            # Shape: (1, 3) -> extract as (3,) for each measurement
            for j in range(n_measurements):
                all_sc_positions.append(result[sc_pos_name].values[j])
        if boresight_name in result:
            # Shape: (1, 3) -> extract as (3,) for each measurement
            for j in range(n_measurements):
                all_boresights.append(result[boresight_name].values[j])
        if transform_name in result:
            # Shape: (1, 3, 3) -> extract as (3, 3) for each measurement
            for j in range(n_measurements):
                all_transforms.append(result[transform_name].values[j, :, :])
        if "gcp_lat_deg" in result:
            all_gcp_lats.extend(result["gcp_lat_deg"].values)
        if "gcp_lon_deg" in result:
            all_gcp_lons.extend(result["gcp_lon_deg"].values)
        if "gcp_alt" in result:
            all_gcp_alts.extend(result["gcp_alt"].values)

    n_total = len(all_lat_errors)

    # Create aggregated dataset with correct dimension names for error_stats
    aggregated = xr.Dataset(
        {
            "lat_error_deg": (["measurement"], np.array(all_lat_errors)),
            "lon_error_deg": (["measurement"], np.array(all_lon_errors)),
        },
        coords={"measurement": np.arange(n_total)},
    )

    # Add optional coordinate transformation data if available (use config names)
    # Use dimension names that match error_stats expectations
    if all_sc_positions:
        # Stack into (n_measurements, 3)
        aggregated[sc_pos_name] = (["measurement", "xyz"], np.array(all_sc_positions))
        aggregated = aggregated.assign_coords({"xyz": ["x", "y", "z"]})

    if all_boresights:
        # Stack into (n_measurements, 3)
        aggregated[boresight_name] = (["measurement", "xyz"], np.array(all_boresights))

    if all_transforms:
        # Stack into (n_measurements, 3, 3) to match error_stats format
        t_stacked = np.stack(all_transforms, axis=0)
        aggregated[transform_name] = (["measurement", "xyz_from", "xyz_to"], t_stacked)
        aggregated = aggregated.assign_coords({"xyz_from": ["x", "y", "z"], "xyz_to": ["x", "y", "z"]})

    if all_gcp_lats:
        aggregated["gcp_lat_deg"] = (["measurement"], np.array(all_gcp_lats))
    if all_gcp_lons:
        aggregated["gcp_lon_deg"] = (["measurement"], np.array(all_gcp_lons))
    if all_gcp_alts:
        aggregated["gcp_alt"] = (["measurement"], np.array(all_gcp_alts))

    aggregated.attrs["source_gcp_pairs"] = len(image_matching_results)
    aggregated.attrs["total_measurements"] = n_total

    logger.info(f"  Aggregated dataset: {n_total} measurements from {len(image_matching_results)} GCP pairs")
    logger.info(f"  Dimensions: {dict(aggregated.sizes)}")

    return aggregated


# Original Functions


class ParameterType(Enum):
    CONSTANT_KERNEL = auto()  # Set a specific value.
    OFFSET_KERNEL = auto()  # Modify input kernel data by an offset.
    OFFSET_TIME = auto()  # Modify input timetags by an offset


@dataclass
class ParameterConfig:
    ptype: ParameterType
    config_file: Path | None
    data: typing.Any


@dataclass
class GeolocationConfig:
    meta_kernel_file: Path
    generic_kernel_dir: Path
    dynamic_kernels: [Path]  # Kernels that are dynamic but *not* altered by param!
    instrument_name: str
    time_field: str
    minimum_correlation: float | None = None  # Filter threshold for image matching quality (0.0-1.0)


@dataclass
class NetCDFParameterMetadata:
    """Metadata for a single parameter in NetCDF output."""

    variable_name: str  # NetCDF variable name (e.g., 'param_hysics_roll')
    units: str  # Units (e.g., 'arcseconds', 'milliseconds')
    long_name: str  # Human-readable description


@dataclass
class NetCDFConfig:
    """Configuration for NetCDF output structure and metadata.

    This class defines the structure and metadata for NetCDF output files.
    All mission-specific information should be provided here rather than
    hardcoded in the monte_carlo module.

    The performance_threshold_m is required and should match the value in
    MonteCarloConfig. It's used to generate threshold-specific variable names
    in the NetCDF output (e.g., "percent_under_250m").
    """

    performance_threshold_m: float  # Required: accuracy threshold in meters
    title: str = "Monte Carlo Geolocation Analysis Results"
    description: str = "Parameter sensitivity analysis"

    # Parameter metadata - maps parameter config to NetCDF metadata
    # If None, will be auto-generated from config.parameters
    parameter_metadata: dict[str, NetCDFParameterMetadata] | None = None

    # Standard variable attributes - allows mission-specific overrides
    # If None, uses STANDARD_NETCDF_ATTRIBUTES module constant
    standard_attributes: dict[str, dict[str, str]] | None = None

    def get_threshold_metric_name(self) -> str:
        """Generate metric name dynamically from threshold."""
        threshold_m = int(self.performance_threshold_m)
        return f"percent_under_{threshold_m}m"

    def get_standard_attributes(self) -> dict[str, dict[str, str]]:
        """
        Get standard variable attributes, using mission overrides if provided.

        Returns:
            Dictionary mapping variable names to their attributes (units, long_name)
        """
        if self.standard_attributes is not None:
            # Use mission-specific overrides
            return self.standard_attributes
        else:
            # Use module-level defaults
            return STANDARD_NETCDF_ATTRIBUTES.copy()

    def get_parameter_netcdf_metadata(
        self, param_config: ParameterConfig, angle_type: str | None = None
    ) -> NetCDFParameterMetadata:
        """
        Get NetCDF metadata for a parameter.

        Args:
            param_config: Parameter configuration
            angle_type: For CONSTANT_KERNEL parameters: 'roll', 'pitch', or 'yaw'

        Returns:
            NetCDFParameterMetadata with variable name, units, and description
        """
        # Generate key for lookup
        if param_config.config_file:
            param_stem = param_config.config_file.stem
            if angle_type:
                lookup_key = f"{param_stem}_{angle_type}"
            else:
                lookup_key = param_stem
        else:
            lookup_key = f"param_{param_config.ptype.name.lower()}"

        # Try to find in provided metadata
        if self.parameter_metadata and lookup_key in self.parameter_metadata:
            return self.parameter_metadata[lookup_key]

        # Auto-generate if not provided
        return self._auto_generate_metadata(param_config, angle_type, lookup_key)

    def _auto_generate_metadata(
        self, param_config: ParameterConfig, angle_type: str | None, base_key: str
    ) -> NetCDFParameterMetadata:
        """Auto-generate NetCDF metadata from parameter configuration."""

        # Determine units based on parameter type
        if param_config.ptype == ParameterType.CONSTANT_KERNEL:
            units = "arcseconds"
        elif param_config.ptype == ParameterType.OFFSET_KERNEL:
            units = "arcseconds"  # Typical for angle offsets
        elif param_config.ptype == ParameterType.OFFSET_TIME:
            units = "milliseconds"
        else:
            units = "unknown"

        # Check if units specified in parameter data
        if isinstance(param_config.data, dict) and "units" in param_config.data:
            units = param_config.data["units"]

        # Generate variable name (ensure it starts with 'param_')
        var_name = base_key.replace(".", "_").replace("-", "_")
        if not var_name.startswith("param_"):
            var_name = f"param_{var_name}"

        # Generate human-readable description
        if param_config.config_file:
            # Extract frame names from config file path
            file_stem = param_config.config_file.stem
            # Remove version numbers and file extensions
            clean_name = file_stem.replace("_v01", "").replace("_v02", "").replace(".attitude.ck", "")
            clean_name = clean_name.replace("_", " ").title()

            if angle_type:
                long_name = f"{clean_name} {angle_type} correction"
            else:
                long_name = f"{clean_name} correction"
        else:
            long_name = f"{param_config.ptype.name.replace('_', ' ').title()} parameter"

        return NetCDFParameterMetadata(variable_name=var_name, units=units, long_name=long_name)


@dataclass
class MonteCarloConfig:
    """The configuration object for Monte Carlo geolocation analysis.

    This config contains everything needed for a Monte Carlo run:
    - What parameters to vary (parameters list)
    - How to vary them (seed, n_iterations)
    - How to load data (telemetry_loader, science_loader)
    - How to process data (gcp_pairing_func, image_matching_func)
    - Geolocation settings (geo: GeolocationConfig)
    - Success criteria (performance_threshold_m, performance_spec_percent)
    - Output configuration (netcdf: NetCDFConfig, output_filename)

    Create one MonteCarloConfig object and pass it to mc.loop() to run.

    Parameters
    ----------
    CORE MONTE CARLO SETTINGS (Required - define what the analysis does):
        seed : Optional[int]
            Random seed for reproducibility, or None for non-reproducible runs.
        n_iterations : int
            Number of parameter set iterations (e.g., 5, 100, 1000).
        parameters : list[ParameterConfig]
            List of parameters to vary (defines sensitivity analysis).

    GEOLOCATION & PERFORMANCE REQUIREMENTS (Required - mission-specific settings):
        geo : GeolocationConfig
            SPICE kernels and instrument configuration.
        performance_threshold_m : float
            Nadir-equivalent accuracy threshold in meters (e.g., 250.0 for CLARREO).
        performance_spec_percent : float
            Requirement as percentage of observations that meet threshold (e.g., 39.0 for CLARREO).
        earth_radius_m : float
            Earth radius for geodetic calculations (can use curryer.constants.WGS84_EARTH_RADIUS_M).

    DATA LOADERS (Required for pipeline execution - mission-specific implementations):
        telemetry_loader : Optional[TelemetryLoader], default=None
            Load spacecraft telemetry. Must be set before calling mc.loop().
        science_loader : Optional[ScienceLoader], default=None
            Load science frame timing. Must be set before calling mc.loop().
        gcp_loader : Optional[GCPLoader], default=None
            Load GCP reference data (optional).

    PROCESSING FUNCTIONS (Optional - will use defaults/stubs if not provided):
        gcp_pairing_func : Optional[GCPPairingFunc], default=None
            Spatial pairing of science data to GCP.
        image_matching_func : Optional[ImageMatchingFunc], default=None
            Image correlation for errors.

    OUTPUT CONFIGURATION (Optional - sensible defaults provided):
        netcdf : Optional[NetCDFConfig], default=None
            NetCDF metadata (auto-generated if None).
        output_filename : Optional[str], default=None
            Output filename (auto-generates with timestamp if None).

    CALIBRATION CONFIGURATION (Optional - only needed when image_matching_func uses calibration):
        calibration_dir : Optional[Path], default=None
            Directory with LOS vectors, optical PSF, GCP files.
            Set when using image_matching_func that requires calibration files.
        calibration_file_names : Optional[dict[str, str]], default=None
            Mission-specific calibration filenames.
            Example: {'los_vectors': 'b_HS.mat', 'optical_psf': 'optical_PSF_675nm_upsampled.mat'}

    MISSION-SPECIFIC NAMING (Optional - override generic defaults):
        spacecraft_position_name : str, default="sc_position"
            Variable name for spacecraft position in output NetCDF.
        boresight_name : str, default="boresight"
            Variable name for boresight in output NetCDF.
        transformation_matrix_name : str, default="t_inst2ref"
            Variable name for transformation matrix in output NetCDF.
    """

    # CORE MONTE CARLO SETTINGS
    seed: int | None
    n_iterations: int
    parameters: list[ParameterConfig]

    # GEOLOCATION & PERFORMANCE REQUIREMENTS
    geo: GeolocationConfig
    performance_threshold_m: float
    performance_spec_percent: float
    earth_radius_m: float

    # DATA LOADERS
    telemetry_loader: TelemetryLoader | None = None
    science_loader: ScienceLoader | None = None
    gcp_loader: GCPLoader | None = None

    # PROCESSING FUNCTIONS
    gcp_pairing_func: GCPPairingFunc | None = None
    image_matching_func: ImageMatchingFunc | None = None

    # OUTPUT CONFIGURATION
    netcdf: NetCDFConfig | None = None
    output_filename: str | None = None

    # CALIBRATION CONFIGURATION
    calibration_dir: Path | None = None
    calibration_file_names: dict[str, str] | None = None

    # MISSION-SPECIFIC NAMING
    spacecraft_position_name: str = "sc_position"
    boresight_name: str = "boresight"
    transformation_matrix_name: str = "t_inst2ref"

    def get_calibration_file(self, file_type: str, default: str = None) -> str:
        """Get calibration filename for given type with fallback to default."""
        if self.calibration_file_names and file_type in self.calibration_file_names:
            return self.calibration_file_names[file_type]
        if default:
            return default
        raise ValueError(f"No calibration file configured for type: {file_type}")

    def validate(self, check_loaders: bool = False):
        """Validate that all required configuration values are present.

        Args:
            check_loaders: If True, validate that loaders are present.
                          Set to False when validating configs during creation,
                          before loaders have been added.

        Raises:
            ValueError: If any required fields are missing or invalid
        """
        errors = []

        # Check required fields
        if self.n_iterations is None or self.n_iterations <= 0:
            errors.append("n_iterations must be a positive integer")

        if self.parameters is None or len(self.parameters) == 0:
            errors.append("parameters list cannot be empty")

        if self.geo is None:
            errors.append("geo (GeolocationConfig) is required")

        if self.earth_radius_m is None or self.earth_radius_m <= 0:
            errors.append("earth_radius_m must be a positive number (e.g., 6378140.0 for WGS84)")

        if self.performance_threshold_m is None or self.performance_threshold_m <= 0:
            errors.append("performance_threshold_m must be a positive number (e.g., 250.0 meters)")

        if self.performance_spec_percent is None or not (0 <= self.performance_spec_percent <= 100):
            errors.append("performance_spec_percent must be between 0 and 100 (e.g., 39.0)")

        # Check required data loaders (Config-Centric Design) - only if requested
        if check_loaders:
            if self.telemetry_loader is None:
                errors.append(
                    "telemetry_loader is required.\n"
                    "    Add to config: config.telemetry_loader = load_your_telemetry\n"
                    "    Example: from your_loaders import load_mission_telemetry\n"
                    "             config.telemetry_loader = load_mission_telemetry"
                )

            if self.science_loader is None:
                errors.append(
                    "science_loader is required.\n"
                    "    Add to config: config.science_loader = load_your_science\n"
                    "    Example: from your_loaders import load_mission_science\n"
                    "             config.science_loader = load_mission_science"
                )

        if errors:
            error_msg = "MonteCarloConfig validation failed:\n  - " + "\n  - ".join(errors)
            error_msg += "\n\nThese values must be provided in your mission configuration."
            error_msg += "\nSee tests/test_correction/clarreo_config.py for an example."
            raise ValueError(error_msg)

        # Check optional processing functions (warnings only) - only if checking loaders
        if check_loaders:
            if self.gcp_pairing_func is None:
                logger.warning(
                    "gcp_pairing_func not provided - GCP pairing will return empty results.\n"
                    "    For testing: config.gcp_pairing_func = synthetic_gcp_pairing\n"
                    "    For production: config.gcp_pairing_func = real_spatial_pairing"
                )

            if self.image_matching_func is None:
                logger.warning(
                    "image_matching_func not provided - will use empty stub.\n"
                    "    For testing: config.image_matching_func = synthetic_image_matching\n"
                    "    For production: config.image_matching_func = real_image_matching\n"
                    "                   and set config.calibration_dir if needed"
                )

        logger.debug("MonteCarloConfig validation passed")

    def ensure_netcdf_config(self):
        """Ensure NetCDFConfig exists, creating with defaults if needed."""
        if self.netcdf is None:
            self.netcdf = NetCDFConfig(performance_threshold_m=self.performance_threshold_m)

    def get_output_filename(self, default: str = "monte_carlo_results.nc") -> str:
        """
        Get output filename with optional auto-generation.

        Args:
            default: Default filename if output_filename is None

        Returns:
            Filename string (can include timestamp/parameters if configured)
        """
        if self.output_filename:
            return self.output_filename
        return default

    @staticmethod
    def generate_timestamped_filename(prefix: str = "monte_carlo", suffix: str = "") -> str:
        """
        Generate a timestamped output filename for production use.

        This prevents overwriting previous results and provides unique identifiers.

        Args:
            prefix: Filename prefix (e.g., 'monte_carlo', 'clarreo_gcs')
            suffix: Optional suffix before extension (e.g., 'upstream', 'test')

        Returns:
            Filename with format: {prefix}_YYYYMMDD_HHMMSS[_{suffix}].nc

        Examples:
            >>> MonteCarloConfig.generate_timestamped_filename()
            'monte_carlo_20251029_143022.nc'

            >>> MonteCarloConfig.generate_timestamped_filename('clarreo_gcs', 'production')
            'clarreo_gcs_20251029_143022_production.nc'
        """
        import datetime

        timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
        if suffix:
            return f"{prefix}_{timestamp}_{suffix}.nc"
        return f"{prefix}_{timestamp}.nc"


def load_param_sets(config: MonteCarloConfig) -> [ParameterConfig, typing.Any]:
    """
    Generate random parameter sets for Monte Carlo iterations.
    Each parameter is sampled according to its distribution and bounds.

    The parameter generation works as follows:
    - current_value: The baseline/current parameter value
    - bounds: The limits for random offsets (in same units as current_value and sigma)
    - sigma: Standard deviation for normal distribution of offsets
    - Generated offsets are centered around 0, then applied to current_value
    - Final value = current_value + random_offset

    Handles all parameter types:
    - CONSTANT_KERNEL: 3D attitude corrections (roll, pitch, yaw)
    - OFFSET_KERNEL: Single angle biases for telemetry fields
    - OFFSET_TIME: Timing corrections for science frames
    """

    if config.seed is not None:
        np.random.seed(config.seed)
        logger.info(f"Set random seed to {config.seed} for reproducible parameter generation")

    output = []

    logger.info(f"Generating {config.n_iterations} parameter sets for {len(config.parameters)} parameters:")
    for i, param in enumerate(config.parameters):
        param_name = param.config_file.name if param.config_file else f"param_{i}"
        current_value = param.data.get("current_value", param.data.get("center", 0.0))
        bounds = param.data.get("bounds", param.data.get("arange", [-1.0, 1.0]))
        logger.info(
            f"  {i + 1}. {param_name} ({param.ptype.name}): "
            f"current_value={current_value}, sigma={param.data.get('sigma', 'N/A')}, "
            f"bounds={bounds}, units={param.data.get('units', 'N/A')}"
        )

    for ith in range(config.n_iterations):
        out_set = []
        logger.debug(f"Generating parameter set {ith + 1}/{config.n_iterations}")

        for param_idx, param in enumerate(config.parameters):
            # Get parameter configuration with backward compatibility
            current_value = param.data.get("current_value", param.data.get("center", 0.0))
            bounds = param.data.get("bounds", param.data.get("arange", [-1.0, 1.0]))

            # Handle different parameter structure types
            if param.ptype == ParameterType.CONSTANT_KERNEL:
                # CONSTANT_KERNEL parameters are 3D attitude corrections (roll, pitch, yaw)
                if isinstance(current_value, list) and len(current_value) == 3:
                    # Multi-dimensional parameter (roll, pitch, yaw)
                    param_vals = []
                    for i, current_val in enumerate(current_value):
                        # Check if parameter should be varied
                        if "sigma" in param.data and param.data["sigma"] is not None and param.data["sigma"] > 0:
                            # Apply variation: Generate offset around 0, then apply to current_value
                            if param.data.get("units") == "arcseconds":
                                # Convert arcsec to radians for sampling
                                sigma_rad = np.deg2rad(param.data["sigma"] / 3600.0)
                                current_val_rad = np.deg2rad(current_val / 3600.0) if current_val != 0 else current_val
                                # Convert bounds from arcsec to radians (these are offset bounds around 0)
                                bounds_rad = [np.deg2rad(bounds[0] / 3600.0), np.deg2rad(bounds[1] / 3600.0)]
                            else:
                                # Assume all values are already in radians
                                sigma_rad = param.data["sigma"]
                                current_val_rad = current_val
                                bounds_rad = bounds

                            # Generate offset around 0, clamp to bounds, and add to current value
                            offset = np.random.normal(0, sigma_rad)
                            offset = np.clip(offset, bounds_rad[0], bounds_rad[1])
                            param_vals.append(current_val_rad + offset)
                        else:
                            # No variation: use current_value directly
                            if "sigma" not in param.data or param.data["sigma"] is None:
                                logger.debug(
                                    f"  Parameter {param_idx} axis {i}: No sigma specified, using fixed current_value"
                                )
                            elif param.data["sigma"] == 0:
                                logger.debug(f"  Parameter {param_idx} axis {i}: sigma=0, using fixed current_value")

                            # Convert to appropriate units if needed
                            if param.data.get("units") == "arcseconds":
                                current_val_rad = np.deg2rad(current_val / 3600.0) if current_val != 0 else current_val
                            else:
                                current_val_rad = current_val
                            param_vals.append(current_val_rad)
                else:
                    # Single angle or default to zero for each axis
                    param_vals = [0.0, 0.0, 0.0]  # [roll, pitch, yaw]
                    if "sigma" in param.data and param.data["sigma"] is not None and param.data["sigma"] > 0:
                        # Apply variation
                        if param.data.get("units") == "arcseconds":
                            sigma_rad = np.deg2rad(param.data["sigma"] / 3600.0)
                            bounds_rad = [np.deg2rad(bounds[0] / 3600.0), np.deg2rad(bounds[1] / 3600.0)]
                            current_val_rad = (
                                np.deg2rad(current_value / 3600.0) if current_value != 0 else current_value
                            )
                        else:
                            sigma_rad = param.data["sigma"]
                            bounds_rad = bounds
                            current_val_rad = current_value

                        for i in range(3):
                            # Generate offset around 0, clamp to bounds, add to current value
                            offset = np.random.normal(0, sigma_rad)
                            offset = np.clip(offset, bounds_rad[0], bounds_rad[1])
                            param_vals[i] = current_val_rad + offset
                    else:
                        # No variation: use current_value directly for all axes
                        if "sigma" not in param.data or param.data["sigma"] is None:
                            logger.debug(f"  Parameter {param_idx}: No sigma specified, using fixed current_value")
                        elif param.data["sigma"] == 0:
                            logger.debug(f"  Parameter {param_idx}: sigma=0, using fixed current_value")

                        # Convert to appropriate units if needed
                        if param.data.get("units") == "arcseconds":
                            current_val_rad = (
                                np.deg2rad(current_value / 3600.0) if current_value != 0 else current_value
                            )
                        else:
                            current_val_rad = current_value

                        # Use same value for all three axes (this handles scalar current_value)
                        param_vals = [current_val_rad, current_val_rad, current_val_rad]

                # Convert to DataFrame format expected by kernel creation
                param_vals = pd.DataFrame(
                    {
                        "ugps": [0, 2209075218000000],  # Start and end times
                        "angle_x": [param_vals[0], param_vals[0]],  # Roll (constant over time)
                        "angle_y": [param_vals[1], param_vals[1]],  # Pitch (constant over time)
                        "angle_z": [param_vals[2], param_vals[2]],  # Yaw (constant over time)
                    }
                )

                logger.debug(
                    f"  CONSTANT_KERNEL {param_idx}: angles=[{param_vals['angle_x'].iloc[0]:.6e}, "
                    f"{param_vals['angle_y'].iloc[0]:.6e}, {param_vals['angle_z'].iloc[0]:.6e}] rad"
                )

            elif param.ptype == ParameterType.OFFSET_KERNEL:
                # OFFSET_KERNEL parameters are angle biases (single values)
                if "sigma" in param.data and param.data["sigma"] is not None and param.data["sigma"] > 0:
                    # Apply variation: Generate offset around 0, then apply to current_value
                    if param.data.get("units") == "arcseconds":
                        # Convert arcsec to radians for sampling
                        sigma_rad = np.deg2rad(param.data["sigma"] / 3600.0)
                        current_val_rad = np.deg2rad(current_value / 3600.0) if current_value != 0 else current_value
                        # Convert bounds from arcsec to radians (these are offset bounds around 0)
                        bounds_rad = [np.deg2rad(bounds[0] / 3600.0), np.deg2rad(bounds[1] / 3600.0)]
                    else:
                        # Assume all values are already in radians
                        sigma_rad = param.data["sigma"]
                        current_val_rad = current_value
                        bounds_rad = bounds

                    # Generate offset around 0, clamp to bounds, and add to current value
                    offset = np.random.normal(0, sigma_rad)
                    offset = np.clip(offset, bounds_rad[0], bounds_rad[1])
                    param_vals = current_val_rad + offset
                else:
                    # No variation: use current_value directly
                    if "sigma" not in param.data or param.data["sigma"] is None:
                        logger.debug(f"  Parameter {param_idx}: No sigma specified, using fixed current_value")
                    elif param.data["sigma"] == 0:
                        logger.debug(f"  Parameter {param_idx}: sigma=0, using fixed current_value")

                    # Convert to appropriate units if needed
                    if param.data.get("units") == "arcseconds":
                        current_val_rad = np.deg2rad(current_value / 3600.0) if current_val != 0 else current_val
                    else:
                        current_val_rad = current_value
                    param_vals = current_val_rad

                logger.debug(f"  OFFSET_KERNEL {param_idx}: {param_vals:.6e} rad")

            elif param.ptype == ParameterType.OFFSET_TIME:
                # OFFSET_TIME parameters are timing corrections (single values)
                if "sigma" in param.data and param.data["sigma"] is not None and param.data["sigma"] > 0:
                    # Apply variation: Generate offset around 0, then apply to current_value
                    if param.data.get("units") == "seconds":
                        # Time parameters typically use seconds, no conversion needed
                        sigma_time = param.data["sigma"]
                        current_val_time = current_value
                        bounds_time = bounds
                    elif param.data.get("units") == "milliseconds":
                        # Convert milliseconds to seconds
                        sigma_time = param.data["sigma"] / 1000.0
                        current_val_time = current_value / 1000.0
                        bounds_time = [bounds[0] / 1000.0, bounds[1] / 1000.0]
                    elif param.data.get("units") == "microseconds":
                        # Convert microseconds to seconds
                        sigma_time = param.data["sigma"] / 1000000.0
                        current_val_time = current_value / 1000000.0
                        bounds_time = [bounds[0] / 1000000.0, bounds[1] / 1000000.0]
                    else:
                        # Default to seconds if units not specified
                        sigma_time = param.data["sigma"]
                        current_val_time = current_value
                        bounds_time = bounds

                    # Generate offset around 0, clamp to bounds, then add to current value
                    offset = np.random.normal(0, sigma_time)
                    offset = np.clip(offset, bounds_time[0], bounds_time[1])
                    param_vals = current_val_time + offset
                else:
                    # No variation: use current_value directly
                    if "sigma" not in param.data or param.data["sigma"] is None:
                        logger.debug(f"  Parameter {param_idx}: No sigma specified, using fixed current_value")
                    elif param.data["sigma"] == 0:
                        logger.debug(f"  Parameter {param_idx}: sigma=0, using fixed current_value")

                    # Convert to appropriate units if needed
                    if param.data.get("units") == "milliseconds":
                        current_val_time = current_value / 1000.0
                    elif param.data.get("units") == "microseconds":
                        current_val_time = current_value / 1000000.0
                    else:
                        current_val_time = current_value
                    param_vals = current_val_time

                logger.debug(f"  OFFSET_TIME {param_idx}: {param_vals:.6e} seconds")

            out_set.append((param, param_vals))
        output.append(out_set)

    logger.info(f"Generated {len(output)} parameter sets with {len(output[0])} parameters each")
    return output


def load_telemetry(tlm_key: str, config: MonteCarloConfig, loader_func=None) -> pd.DataFrame:
    """
    Load telemetry data using provided mission-specific loader function.

    This is a generic interface. The actual telemetry loading logic should be
    provided by the mission-specific loader function.

    Args:
        tlm_key: Identifier for telemetry data (path, key, etc.)
        config: Monte Carlo configuration
        loader_func: Mission-specific loader function(tlm_key, config) -> DataFrame

    Returns:
        DataFrame with telemetry data

    Raises:
        ValueError: If no loader function provided

    Example:
        from clarreo_data_loaders import load_clarreo_telemetry
        tlm_data = load_telemetry(tlm_key, config, loader_func=load_clarreo_telemetry)
    """
    if loader_func is None:
        raise ValueError(
            "No telemetry loader function provided. "
            "Pass loader_func parameter with mission-specific loader.\n"
            "Example: load_telemetry(tlm_key, config, loader_func=load_clarreo_telemetry)"
        )

    return loader_func(tlm_key, config)


def load_science(sci_key: str, config: MonteCarloConfig, loader_func=None) -> pd.DataFrame:
    """
    Load science data using provided mission-specific loader function.

    This is a generic interface. The actual science data loading logic should be
    provided by the mission-specific loader function.

    Args:
        sci_key: Identifier for science data (path, key, etc.)
        config: Monte Carlo configuration
        loader_func: Mission-specific loader function(sci_key, config) -> DataFrame

    Returns:
        DataFrame with science data

    Raises:
        ValueError: If no loader function provided

    Example:
        from clarreo_data_loaders import load_clarreo_science
        sci_data = load_science(sci_key, config, loader_func=load_clarreo_science)
    """
    if loader_func is None:
        raise ValueError(
            "No science loader function provided. "
            "Pass loader_func parameter with mission-specific loader.\n"
            "Example: load_science(sci_key, config, loader_func=load_clarreo_science)"
        )

    return loader_func(sci_key, config)


def load_gcp(gcp_key: str, config: MonteCarloConfig, loader_func=None):
    """
    Load Ground Control Point (GCP) reference data using mission-specific loader.

    This is a generic interface. The actual GCP loading logic should be
    provided by the mission-specific loader function.

    Args:
        gcp_key: Identifier for GCP data (path, key, etc.)
        config: Monte Carlo configuration
        loader_func: Mission-specific loader function(gcp_key, config) -> GCP data

    Returns:
        GCP reference data (format defined by mission)

    Note:
        If loader_func is None, returns None (allows placeholder behavior)

    Example:
        from clarreo_data_loaders import load_clarreo_gcp
        gcp_data = load_gcp(gcp_key, config, loader_func=load_clarreo_gcp)
    """
    if loader_func is None:
        logger.info(f"No GCP loader provided for: {gcp_key} (returning None)")
        return None

    return loader_func(gcp_key, config)


def apply_offset(config: ParameterConfig, param_data, input_data):
    """
    Apply parameter offsets to input data based on parameter type.

    Args:
        config: ParameterConfig specifying how to apply the offset
        param_data: The parameter values to apply (offset amounts)
        input_data: The input dataset to modify

    Returns:
        Modified copy of input_data with parameter offsets applied
    """
    logger.info(f"Applying {config.ptype.name} offset to {config.data.get('field', 'unknown field')}")

    # Make a copy to avoid modifying the original
    if isinstance(input_data, pd.DataFrame):
        modified_data = input_data.copy()
    else:
        modified_data = input_data.copy() if hasattr(input_data, "copy") else input_data

    if config.ptype == ParameterType.OFFSET_KERNEL:
        # Apply offset to telemetry fields for dynamic kernels (azimuth/elevation angles)
        field_name = config.data.get("field")
        if field_name and field_name in modified_data.columns:
            # Convert parameter value to appropriate units
            offset_value = param_data
            if config.data.get("units") == "arcseconds":
                # Convert arcseconds to radians for application
                offset_value = np.deg2rad(param_data / 3600.0)
            elif config.data.get("units") == "milliseconds":
                # Convert milliseconds to seconds
                offset_value = param_data / 1000.0
        field_name = config.data.get("field")
        if not field_name:
            raise ValueError("OFFSET_TIME parameter requires 'field' to be specified in config")

            # Apply additive offset
            logger.info(f"Applying offset {offset_value} to field {field_name}")
            modified_data[field_name] = modified_data[field_name] + offset_value

        else:
            logger.warning(f"Field {field_name} not found in telemetry data for offset application")

    elif config.ptype == ParameterType.OFFSET_TIME:
        # Apply time offset to science frame timing
        field_name = config.data.get("field", "corrected_timestamp")
        if hasattr(modified_data, "__getitem__") and field_name in modified_data:
            offset_value = param_data
            if config.data.get("units") == "milliseconds":
                # Convert milliseconds to microseconds (uGPS)
                offset_value = param_data * 1000.0

            logger.info(f"Applying time offset {offset_value} to field {field_name}")
            modified_data[field_name] = modified_data[field_name] + offset_value

    elif config.ptype == ParameterType.CONSTANT_KERNEL:
        # For constant kernels, param_data should already be in the correct format
        # (DataFrame with ugps, angle_x, angle_y, angle_z columns)
        logger.info(
            f"Using constant kernel data with {len(param_data) if hasattr(param_data, '__len__') else 1} entries"
        )
        modified_data = param_data

    else:
        raise NotImplementedError(f"Parameter type {config.ptype} not implemented")

    return modified_data


def _build_netcdf_structure(config: MonteCarloConfig, n_param_sets: int, n_gcp_pairs: int) -> dict:
    """
    Build NetCDF data structure dynamically from configuration.

    This creates the netcdf_data dictionary with proper variable names based on
    the parameters defined in the configuration, avoiding hardcoded mission-specific names.

    Args:
        config: MonteCarloConfig with parameters and optional NetCDF config
        n_param_sets: Number of parameter sets (iterations)
        n_gcp_pairs: Number of GCP pairs

    Returns:
        Dictionary with initialized arrays for all NetCDF variables
    """
    logger.info(f"Building NetCDF data structure for {n_param_sets} parameter sets × {n_gcp_pairs} GCP pairs")

    # Ensure NetCDFConfig exists
    config.ensure_netcdf_config()

    # Start with coordinate dimensions
    netcdf_data = {
        "parameter_set_id": np.arange(n_param_sets),
        "gcp_pair_id": np.arange(n_gcp_pairs),
    }

    # Add parameter variables dynamically based on config.parameters
    param_count = 0
    for param in config.parameters:
        if param.ptype == ParameterType.CONSTANT_KERNEL:
            # CONSTANT_KERNEL parameters have roll, pitch, yaw components
            for angle in ["roll", "pitch", "yaw"]:
                metadata = config.netcdf.get_parameter_netcdf_metadata(param, angle)
                var_name = metadata.variable_name
                netcdf_data[var_name] = np.full(n_param_sets, np.nan)
                logger.debug(f"  Added parameter variable: {var_name} ({metadata.long_name})")
                param_count += 1
        else:
            # OFFSET_KERNEL and OFFSET_TIME are single values
            metadata = config.netcdf.get_parameter_netcdf_metadata(param)
            var_name = metadata.variable_name
            netcdf_data[var_name] = np.full(n_param_sets, np.nan)
            logger.debug(f"  Added parameter variable: {var_name} ({metadata.long_name})")
            param_count += 1

    logger.info(f"  Created {param_count} parameter variables from {len(config.parameters)} parameter configs")

    # Add standard error statistics (2D: parameter_set_id × gcp_pair_id)
    error_metrics = {
        "rms_error_m": "RMS geolocation error",
        "mean_error_m": "Mean geolocation error",
        "max_error_m": "Maximum geolocation error",
        "std_error_m": "Standard deviation of geolocation error",
        "n_measurements": "Number of measurement points",
    }

    for var_name, description in error_metrics.items():
        if var_name == "n_measurements":
            netcdf_data[var_name] = np.full((n_param_sets, n_gcp_pairs), 0, dtype=int)
        else:
            netcdf_data[var_name] = np.full((n_param_sets, n_gcp_pairs), np.nan)
        logger.debug(f"  Added error metric: {var_name}")

    # Add image matching results (2D: parameter_set_id × gcp_pair_id)
    image_match_vars = {
        "im_lat_error_km": "Image matching latitude error",
        "im_lon_error_km": "Image matching longitude error",
        "im_ccv": "Image matching correlation coefficient",
        "im_grid_step_m": "Image matching final grid step size",
    }

    for var_name, description in image_match_vars.items():
        netcdf_data[var_name] = np.full((n_param_sets, n_gcp_pairs), np.nan)
        logger.debug(f"  Added image matching variable: {var_name}")

    # Add overall performance metrics (1D: parameter_set_id)
    # Use dynamic threshold metric name
    threshold_metric = config.netcdf.get_threshold_metric_name()
    overall_metrics = {
        threshold_metric: f"Percentage of pairs with error < {config.performance_threshold_m}m",
        "mean_rms_all_pairs": "Mean RMS error across all GCP pairs",
        "worst_pair_rms": "Worst performing GCP pair RMS error",
        "best_pair_rms": "Best performing GCP pair RMS error",
    }

    for var_name, description in overall_metrics.items():
        netcdf_data[var_name] = np.full(n_param_sets, np.nan)
        logger.debug(f"  Added overall metric: {var_name}")

    logger.info(f"NetCDF data structure created with {len(netcdf_data)} variables")

    return netcdf_data


# =============================================================================
# HELPER FUNCTIONS
# =============================================================================
# These functions extract reusable logic from the main loop to simplify the structure


def _load_calibration_data(config: "MonteCarloConfig") -> CalibrationData:
    """Load LOS vectors and optical PSF if calibration_dir is configured.

    This function centralizes calibration data loading, which is now called once
    per GCP pair in the optimized implementation (previously called once per parameter set).

    Parameters
    ----------
    config : MonteCarloConfig
        Configuration with calibration_dir and calibration settings

    Returns
    -------
    CalibrationData
        NamedTuple containing (los_vectors, optical_psfs), or (None, None) if
        no calibration directory configured

    Raises
    ------
    FileNotFoundError
        If calibration directory is configured but files don't exist
    ValueError
        If calibration files exist but fail to load properly

    Examples
    --------
    >>> calib_data = _load_calibration_data(config)
    >>> if calib_data.los_vectors is not None:
    ...     # Use calibration data in image matching
    ...     pass
    """
    if not config.calibration_dir:
        return CalibrationData(los_vectors=None, optical_psfs=None)

    logger.info("Loading calibration data...")

    # Use configurable calibration file names
    los_filename = config.get_calibration_file("los_vectors", default="b_HS.mat")
    los_file = config.calibration_dir / los_filename

    if not los_file.exists():
        raise FileNotFoundError(
            f"LOS vectors calibration file not found: {los_file}\nExpected in calibration_dir: {config.calibration_dir}"
        )

    los_vectors_cached = load_los_vectors_from_mat(los_file)

    if los_vectors_cached is None:
        raise ValueError(
            f"Failed to load LOS vectors from {los_file}. File exists but load_los_vectors_from_mat() returned None."
        )

    psf_filename = config.get_calibration_file("optical_psf", default="optical_PSF_675nm_upsampled.mat")
    psf_file = config.calibration_dir / psf_filename

    if not psf_file.exists():
        raise FileNotFoundError(
            f"Optical PSF calibration file not found: {psf_file}\nExpected in calibration_dir: {config.calibration_dir}"
        )

    optical_psfs_cached = load_optical_psf_from_mat(psf_file)

    if optical_psfs_cached is None:
        raise ValueError(
            f"Failed to load optical PSF from {psf_file}. File exists but load_optical_psf_from_mat() returned None."
        )

    logger.info(f"  Cached LOS vectors: {los_vectors_cached.shape}")
    logger.info(f"  Cached optical PSF: {len(optical_psfs_cached)} entries")

    return CalibrationData(los_vectors=los_vectors_cached, optical_psfs=optical_psfs_cached)


def _load_image_pair_data(
    tlm_key: str,
    sci_key: str,
    config: "MonteCarloConfig",
    telemetry_loader: TelemetryLoader,
    science_loader: ScienceLoader,
) -> tuple[pd.DataFrame, pd.DataFrame, Any]:
    """Load telemetry and science data for an image pair.

    Parameters
    ----------
    tlm_key : str
        Identifier for telemetry data.
    sci_key : str
        Identifier for science data.
    config : MonteCarloConfig
        Configuration containing geolocation settings and field names.
    telemetry_loader : callable
        Function with signature ``telemetry_loader(tlm_key, config) -> pandas.DataFrame`` that
        loads telemetry (L1) data for the given key.
    science_loader : callable
        Function with signature ``science_loader(sci_key, config) -> pandas.DataFrame`` that
        loads science frame timing (L1A) data for the given key.

    Returns
    -------
    tlm_dataset : pandas.DataFrame
        DataFrame containing spacecraft state / telemetry records (position, velocity, attitude, time).
    sci_dataset : pandas.DataFrame
        DataFrame containing science frame timing information.
    ugps_times : array_like
        Time array extracted from the science dataset (e.g., uGPS times).

    Raises
    ------
    ValueError
        If required loader functions are not provided or returned data are invalid.
    FileNotFoundError
        If underlying loader raises when expected files are missing.

    Notes
    -----
    This function loads and validates both telemetry and science datasets for a
    single GCP pair. In the current implementation it is called once per image pair.

    Examples
    --------
    >>> tlm, sci, times = _load_image_pair_data(
    ...     "tlm_001", "sci_001", config, load_clarreo_telemetry, load_clarreo_science
    ... )
    """
    # Load telemetry (L1) data using mission-specific loader
    tlm_dataset = load_telemetry(tlm_key, config, loader_func=telemetry_loader)
    validate_telemetry_output(tlm_dataset, config)

    # Load science (L1A) data using mission-specific loader
    sci_dataset = load_science(sci_key, config, loader_func=science_loader)
    validate_science_output(sci_dataset, config)
    ugps_times = sci_dataset[config.geo.time_field]  # Can be altered by later steps

    return tlm_dataset, sci_dataset, ugps_times


def _create_dynamic_kernels(
    config: "MonteCarloConfig",
    work_dir: Path,
    tlm_dataset: pd.DataFrame,
    creator: "create.KernelCreator",
) -> list[Path]:
    """Create dynamic SPICE kernels from telemetry data.

    Dynamic kernels (SC-SPK, SC-CK) are generated from spacecraft telemetry
    and do not change with parameter variations. In the current implementation,
    these are created once per image.

    Parameters
    ----------
    config : MonteCarloConfig
        Configuration with geo settings and dynamic_kernels list
    work_dir : Path
        Working directory for kernel files
    tlm_dataset : pd.DataFrame
        Spacecraft state data with position, velocity, attitude, and time columns
    creator : create.KernelCreator
        KernelCreator instance for writing kernels

    Returns
    -------
    list[Path]
        List of kernel file paths created (e.g., [sc_ephemeris.bsp, sc_attitude.bc])

    Examples
    --------
    >>> from curryer.kernels import create
    >>> creator = create.KernelCreator(overwrite=True, append=False)
    >>> dynamic_kernels = _create_dynamic_kernels(config, work_dir, tlm_dataset, creator)
    >>> # Use in SPICE context
    >>> with sp.ext.load_kernel(dynamic_kernels):
    ...     # Perform geolocation
    ...     pass
    """
    logger.info("    Creating dynamic kernels from telemetry...")
    dynamic_kernels = []
    for kernel_config in config.geo.dynamic_kernels:
        dynamic_kernels.append(
            creator.write_from_json(
                kernel_config,
                output_kernel=work_dir,
                input_data=tlm_dataset,
            )
        )
    logger.info(f"    Created {len(dynamic_kernels)} dynamic kernels")
    return dynamic_kernels


def _create_parameter_kernels(
    params: list[tuple["ParameterConfig", Any]],
    work_dir: Path,
    tlm_dataset: pd.DataFrame,
    sci_dataset: pd.DataFrame,
    ugps_times: Any,
    config: "MonteCarloConfig",
    creator: "create.KernelCreator",
) -> tuple[list[Path], Any]:
    """Create parameter-specific SPICE kernels and apply time offsets.

    This function applies parameter variations by creating modified kernels
    (CONSTANT_KERNEL, OFFSET_KERNEL) or modifying time tags (OFFSET_TIME).
    Each parameter set produces different kernels and/or time modifications.

    Parameters
    ----------
    params : list[tuple[ParameterConfig, Any]]
        List of (ParameterConfig, parameter_value) tuples for this iteration
    work_dir : Path
        Working directory for kernel files
    tlm_dataset : pd.DataFrame
        Spacecraft state data (may be modified for OFFSET_KERNEL) with position, velocity, attitude, and time columns
    sci_dataset : pd.DataFrame
        Science frame time data (may be modified for OFFSET_TIME), may include optional measurement columns
    ugps_times : array-like
        Original time array from science dataset
    config : MonteCarloConfig
        Configuration with geo settings
    creator : create.KernelCreator
        KernelCreator instance for writing kernels

    Returns
    -------
    param_kernels : list[Path]
        List of parameter-specific kernel file paths
    ugps_times_modified : array-like
        Modified time array if OFFSET_TIME applied, otherwise original times

    Examples
    --------
    >>> param_kernels, times = _create_parameter_kernels(
    ...     params, work_dir, tlm_dataset, sci_dataset, ugps_times, config, creator
    ... )
    >>> # Use in SPICE context with dynamic kernels
    >>> with sp.ext.load_kernel([dynamic_kernels, param_kernels]):
    ...     geo = geolocate(times)
    """
    param_kernels = []
    ugps_times_modified = ugps_times.copy() if hasattr(ugps_times, "copy") else ugps_times

    # Apply each individual parameter change
    logger.info("    Applying parameter changes:")
    for a_param, p_data in params:  # [ParameterConfig, typing.Any]
        # Create static changing SPICE kernels
        if a_param.ptype == ParameterType.CONSTANT_KERNEL:
            # Aka: BASE-CK, YOKE-CK, HYSICS-CK
            param_kernels.append(
                creator.write_from_json(
                    a_param.config_file,
                    output_kernel=work_dir,
                    input_data=p_data,
                )
            )

        # Create dynamic changing SPICE kernels
        elif a_param.ptype == ParameterType.OFFSET_KERNEL:
            # Aka: AZ-CK, EL-CK
            tlm_dataset_alt = apply_offset(a_param, p_data, tlm_dataset)
            param_kernels.append(
                creator.write_from_json(
                    a_param.config_file,
                    output_kernel=work_dir,
                    input_data=tlm_dataset_alt,
                )
            )

        # Alter non-kernel data
        elif a_param.ptype == ParameterType.OFFSET_TIME:
            # Aka: Frame-times...
            sci_dataset_alt = apply_offset(a_param, p_data, sci_dataset)
            ugps_times_modified = sci_dataset_alt[config.geo.time_field].values

        else:
            raise NotImplementedError(a_param.ptype)

    logger.info(f"    Created {len(param_kernels)} parameter-specific kernels")
    return param_kernels, ugps_times_modified


def _geolocate_and_match(
    config: "MonteCarloConfig",
    kernel_ctx: KernelContext,
    ugps_times_modified: Any,
    tlm_dataset: pd.DataFrame,
    calibration: CalibrationData,
    image_matching_func: ImageMatchingFunc,
    match_ctx: ImageMatchingContext,
) -> tuple[xr.Dataset, xr.Dataset]:
    """Perform geolocation and image matching for a parameter set.

    This function loads SPICE kernels, performs geolocation, and runs image
    matching against GCP reference data. It's the core computation step that
    combines all previous setup (kernels, data loading) into results.

    Parameters
    ----------
    config : MonteCarloConfig
        Configuration with geo and image matching settings
    kernel_ctx : KernelContext
        NamedTuple containing:
        - mkrn: MetaKernel instance with SDS and mission kernels
        - dynamic_kernels: List of dynamic kernel file paths
        - param_kernels: List of parameter-specific kernel file paths
    ugps_times_modified : array-like
        Time array (possibly modified by OFFSET_TIME parameter)
    tlm_dataset : pd.DataFrame
        Spacecraft state telemetry data
    calibration : CalibrationData
        NamedTuple containing:
        - los_vectors: Pre-loaded LOS vectors (or None)
        - optical_psfs: Pre-loaded optical PSF (or None)
    image_matching_func : ImageMatchingFunc
        Function to perform image matching (e.g., integrated_image_match)
    match_ctx : ImageMatchingContext
        NamedTuple containing:
        - gcp_pairs: List of GCP pairing tuples
        - params: List of (ParameterConfig, parameter_value) tuples
        - pair_idx: Index of current GCP pair
        - sci_key: Science dataset identifier for this pair

    Returns
    -------
    geo_dataset : xr.Dataset
        Geolocated points with latitude, longitude, altitude
    image_matching_output : xr.Dataset
        Matching results with error measurements and metadata

    Examples
    --------
    >>> kernel_ctx = KernelContext(mkrn, dynamic_kernels, param_kernels)
    >>> calibration = CalibrationData(los_vectors, optical_psfs)
    >>> match_ctx = ImageMatchingContext(gcp_pairs, params, 0, "sci_001")
    >>> geo, matching = _geolocate_and_match(
    ...     config, kernel_ctx, times, tlm_dataset,
    ...     calibration, integrated_image_match, match_ctx
    ... )
    """
    logger.info("    Performing geolocation...")
    with sp.ext.load_kernel(
        [
            kernel_ctx.mkrn.sds_kernels,
            kernel_ctx.mkrn.mission_kernels,
            kernel_ctx.dynamic_kernels,
            kernel_ctx.param_kernels,
        ]
    ):
        geoloc_inst = spatial.Geolocate(config.geo.instrument_name)
        geo_dataset = geoloc_inst(ugps_times_modified)

        # === IMAGE MATCHING MODULE ===
        logger.info("    === IMAGE MATCHING MODULE ===")

        # Use injected image matching function
        gcp_file = Path(match_ctx.gcp_pairs[0][1]) if match_ctx.gcp_pairs else Path("synthetic_gcp.tif")

        # All image matching functions use the same signature
        image_matching_output = image_matching_func(
            geolocated_data=geo_dataset,
            gcp_reference_file=gcp_file,
            telemetry=tlm_dataset,
            calibration_dir=config.calibration_dir,
            params_info=match_ctx.params,
            config=config,
            los_vectors_cached=calibration.los_vectors,
            optical_psfs_cached=calibration.optical_psfs,
        )
        validate_image_matching_output(image_matching_output)
        logger.info("    Image matching complete")

        logger.info(f"    Generated error measurements for {len(image_matching_output.measurement)} points")

        # Store metadata for tracking
        image_matching_output.attrs["gcp_pair_index"] = match_ctx.pair_idx
        image_matching_output.attrs["gcp_pair_id"] = f"{match_ctx.sci_key}_pair_{match_ctx.pair_idx}"

    return geo_dataset, image_matching_output


def loop(
    config: MonteCarloConfig, work_dir: Path, tlm_sci_gcp_sets: [(str, str, str)], resume_from_checkpoint: bool = False
):
    """
    Monte Carlo loop for parameter sensitivity analysis.

    Parameters
    ----------
    config : MonteCarloConfig
        The single configuration containing all settings:
        - Required: parameters, iterations, thresholds, geo config
        - Required loaders: telemetry_loader, science_loader
        - Optional processing: gcp_pairing_func, image_matching_func
        - Calibration: `calibration_dir` (if image_matching_func uses calibration)
        - Output: netcdf, output_filename
    work_dir : Path
        Working directory for temporary files.
    tlm_sci_gcp_sets : list of (str, str, str)
        List of (`telemetry_key`, `science_key`, `gcp_key`) tuples.
    resume_from_checkpoint : bool, optional
        If True, resume from an existing checkpoint.

    Returns
    -------
    results : list
        List of iteration results (order: `pair_idx * N + param_idx`).
    netcdf_data : dict
        Dictionary of NetCDF variables indexed as `[param_idx, pair_idx]`.

    Notes
    -----
    This implementation uses a pair-outer, parameter-inner loop order:
    - Outer loop: GCP pairs (load data once per image)
    - Inner loop: Parameter sets (reuse loaded data)
    This reduces file I/O and centralizes mission-specific behavior through the
    `config` object.

    Examples
    --------
        >>> from clarreo_data_loaders import load_clarreo_telemetry, load_clarreo_science
        >>>
        >>> config = MonteCarloConfig(
        ...     seed=42,
        ...     n_iterations=100,
        ...     parameters=[...],
        ...     geo=geo_config,
        ...     telemetry_loader=load_clarreo_telemetry,
        ...     science_loader=load_clarreo_science,
        ...     gcp_pairing_func=spatial_pairing,
        ...     image_matching_func=image_matching,
        ... )
        >>> results, netcdf_data = loop(config, work_dir, tlm_sci_gcp_sets)
    """
    logger.info("=== MONTE CARLO PIPELINE ===")
    logger.info(f"  GCP pairs: {len(tlm_sci_gcp_sets)} (outer loop - load data once)")

    # Extract injected functions
    telemetry_loader = config.telemetry_loader
    science_loader = config.science_loader
    image_matching_func = config.image_matching_func
    gcp_pairing_func = config.gcp_pairing_func

    # Validate required loaders
    if telemetry_loader is None:
        raise ValueError("config.telemetry_loader is required but was None.")
    if science_loader is None:
        raise ValueError("config.science_loader is required but was None.")

    # Validate required processing functions
    if gcp_pairing_func is None:
        raise ValueError("config.gcp_pairing_func is required but was None.")
    if image_matching_func is None:
        raise ValueError("config.image_matching_func is required but was None.")

    # Initialize parameter sets
    params_set = load_param_sets(config)
    logger.info(f"  Parameter sets: {len(params_set)} (inner loop)")

    # Build NetCDF data structure
    n_param_sets = len(params_set)
    n_gcp_pairs = len(tlm_sci_gcp_sets)

    # Try to load checkpoint if resuming
    output_file = work_dir / config.get_output_filename()
    start_pair_idx = 0
    if resume_from_checkpoint:
        checkpoint_data, completed_pairs = _load_checkpoint(output_file, config)
        if checkpoint_data is not None:
            netcdf_data = checkpoint_data
            start_pair_idx = completed_pairs
            logger.info(f"Resuming from checkpoint: starting at GCP pair {start_pair_idx + 1}/{n_gcp_pairs}")
        else:
            netcdf_data = _build_netcdf_structure(config, n_param_sets, n_gcp_pairs)
            logger.info("No valid checkpoint found, starting from beginning")
    else:
        netcdf_data = _build_netcdf_structure(config, n_param_sets, n_gcp_pairs)

    # Initialize results dict with (param_idx, pair_idx) keys
    # This avoids nested search complexity when aggregating statistics
    results_dict = {}

    # Prepare SPICE environment
    mkrn = meta.MetaKernel.from_json(
        config.geo.meta_kernel_file,
        relative=True,
        sds_dir=config.geo.generic_kernel_dir,
    )
    creator = create.KernelCreator(overwrite=True, append=False)

    # Load calibration data once (LOS vectors and optical PSF are static instrument calibration)
    calibration_data = _load_calibration_data(config)

    # Store parameter values once (before loops)
    for param_idx, params in enumerate(params_set):
        param_values = _extract_parameter_values(params)
        _store_parameter_values(netcdf_data, param_idx, param_values)

    # OUTER LOOP: Iterate through GCP pairs
    for pair_idx, (tlm_key, sci_key, gcp_key) in enumerate(tlm_sci_gcp_sets):
        # Skip already-completed pairs if resuming
        if pair_idx < start_pair_idx:
            logger.info(f"=== GCP Pair {pair_idx + 1}/{n_gcp_pairs}: {sci_key} === (SKIPPED - already completed)")
            continue

        logger.info(f"=== GCP Pair {pair_idx + 1}/{n_gcp_pairs}: {sci_key} ===")

        # Load image pair data once
        tlm_dataset, sci_dataset, ugps_times = _load_image_pair_data(
            tlm_key, sci_key, config, telemetry_loader, science_loader
        )

        # Create dynamic kernels once (these don't change with parameters)
        dynamic_kernels = _create_dynamic_kernels(config, work_dir, tlm_dataset, creator)

        # Get GCP pairing ONCE
        gcp_pairs = gcp_pairing_func([sci_key])
        validate_gcp_pairing_output(gcp_pairs)
        logger.info(f"  Found {len(gcp_pairs)} GCP pairs for processing")

        # INNER LOOP: Iterate through parameter sets
        for param_idx, params in enumerate(params_set):
            logger.info(f"  Parameter Set {param_idx + 1}/{n_param_sets}")

            # Create parameter-specific kernels (these change with parameters)
            param_kernels, ugps_times_modified = _create_parameter_kernels(
                params, work_dir, tlm_dataset, sci_dataset, ugps_times, config, creator
            )

            # Prepare context objects for cleaner function call
            kernel_ctx = KernelContext(mkrn=mkrn, dynamic_kernels=dynamic_kernels, param_kernels=param_kernels)
            match_ctx = ImageMatchingContext(gcp_pairs=gcp_pairs, params=params, pair_idx=pair_idx, sci_key=sci_key)

            # Geolocate and perform image matching
            geo_dataset, image_matching_output = _geolocate_and_match(
                config,
                kernel_ctx,
                ugps_times_modified,
                tlm_dataset,
                calibration_data,
                image_matching_func,
                match_ctx,
            )

            # Process individual pair error statistics
            individual_stats = call_error_stats_module(image_matching_output, monte_carlo_config=config)
            individual_metrics = _extract_error_metrics(individual_stats)

            # Store results in NetCDF (maintain [param_idx, pair_idx] ordering)
            _store_gcp_pair_results(netcdf_data, param_idx, pair_idx, individual_metrics)
            netcdf_data["im_lat_error_km"][param_idx, pair_idx] = image_matching_output.attrs.get(
                "lat_error_km", np.nan
            )
            netcdf_data["im_lon_error_km"][param_idx, pair_idx] = image_matching_output.attrs.get(
                "lon_error_km", np.nan
            )
            netcdf_data["im_ccv"][param_idx, pair_idx] = image_matching_output.attrs.get("correlation_ccv", np.nan)
            netcdf_data["im_grid_step_m"][param_idx, pair_idx] = image_matching_output.attrs.get(
                "final_grid_step_m", np.nan
            )

            # Store results in dict with (param_idx, pair_idx) key
            # Note: iteration index reflects reversed order (pair_idx * n_params + param_idx)
            param_values = _extract_parameter_values(params)
            iteration_result = {
                "iteration": pair_idx * n_param_sets + param_idx,
                "pair_index": pair_idx,
                "param_index": param_idx,
                "parameters": param_values,
                "geolocation": geo_dataset,
                "gcp_pairs": gcp_pairs,
                "image_matching": image_matching_output,
                "error_stats": individual_stats,
                "rms_error_m": individual_metrics["rms_error_m"],
                "aggregate_rms_error_m": None,
            }
            results_dict[(param_idx, pair_idx)] = iteration_result

            logger.info(
                f"    RMS error: {individual_metrics['rms_error_m']:.2f}m "
                f"({individual_metrics['n_measurements']} measurements)"
            )

        logger.info(f"  GCP pair {pair_idx + 1} complete (processed {n_param_sets} parameter sets)")

        # Save checkpoint after each pair completes
        if resume_from_checkpoint:
            _save_netcdf_checkpoint(netcdf_data, output_file, config, pair_idx)

    # Compute aggregate statistics for each parameter set (after all pairs complete)
    logger.info("=== Computing aggregate statistics for all parameter sets ===")
    for param_idx in range(n_param_sets):
        # Collect all image matching results for this parameter set
        param_image_matching_results = []
        for pair_idx in range(n_gcp_pairs):
            result = results_dict.get((param_idx, pair_idx))
            if result:
                param_image_matching_results.append(result["image_matching"])

        # Compute aggregate statistics
        aggregate_stats = call_error_stats_module(param_image_matching_results, monte_carlo_config=config)
        aggregate_error_metrics = _extract_error_metrics(aggregate_stats)

        # Extract pair errors for threshold calculation
        pair_errors = [netcdf_data["rms_error_m"][param_idx, pair_idx] for pair_idx in range(n_gcp_pairs)]
        _compute_parameter_set_metrics(netcdf_data, param_idx, pair_errors, threshold_m=config.performance_threshold_m)

        logger.info(f"  Parameter set {param_idx + 1}: Aggregate RMS = {aggregate_error_metrics['rms_error_m']:.2f}m")

        # Add aggregate stats to all results for this parameter set
        for pair_idx in range(n_gcp_pairs):
            key = (param_idx, pair_idx)
            if key in results_dict:
                results_dict[key]["aggregate_error_stats"] = aggregate_stats
                results_dict[key]["aggregate_rms_error_m"] = aggregate_error_metrics["rms_error_m"]
    # Convert results_dict back to list for backward compatibility
    # Sort by iteration index to maintain consistent ordering
    results = [results_dict[key] for key in sorted(results_dict.keys(), key=lambda k: results_dict[k]["iteration"])]

    # Save final NetCDF results
    _save_netcdf_results(netcdf_data, output_file, config)

    # Clean up checkpoint file after successful completion
    if resume_from_checkpoint:
        _cleanup_checkpoint(output_file)

    logger.info(f"=== Loop Complete: Processed {n_gcp_pairs} GCP pairs × {n_param_sets} parameter sets ===")
    logger.info(f"  Total iterations: {len(results)}")
    logger.info(f"  NetCDF output: {output_file}")

    return results, netcdf_data


def _extract_parameter_values(params):
    """Extract parameter values from a parameter set into a dictionary."""
    param_values = {}

    for param_config, param_data in params:
        if param_config.config_file:
            param_name = param_config.config_file.stem

            if param_config.ptype == ParameterType.CONSTANT_KERNEL:
                # Extract roll, pitch, yaw from DataFrame
                if isinstance(param_data, pd.DataFrame) and "angle_x" in param_data.columns:
                    # Convert back to arcseconds for storage
                    param_values[f"{param_name}_roll"] = np.degrees(param_data["angle_x"].iloc[0]) * 3600
                    param_values[f"{param_name}_pitch"] = np.degrees(param_data["angle_y"].iloc[0]) * 3600
                    param_values[f"{param_name}_yaw"] = np.degrees(param_data["angle_z"].iloc[0]) * 3600

            elif param_config.ptype == ParameterType.OFFSET_KERNEL:
                # Single bias value (keep in original units)
                param_values[param_name] = param_data

            elif param_config.ptype == ParameterType.OFFSET_TIME:
                # Time correction (keep in original units)
                param_values[param_name] = param_data

    return param_values


def _store_parameter_values(netcdf_data, param_idx, param_values):
    """Store parameter values in the NetCDF data structure.

    This function maps parameter names to NetCDF variable names for storage.
    It handles the naming convention used by _build_netcdf_structure.
    """

    for param_name, value in param_values.items():
        # Generate NetCDF variable name using same logic as _build_netcdf_structure
        # Replace dots and dashes with underscores, ensure param_ prefix
        netcdf_var = param_name.replace(".", "_").replace("-", "_")
        if not netcdf_var.startswith("param_"):
            netcdf_var = f"param_{netcdf_var}"

        if netcdf_var in netcdf_data:
            netcdf_data[netcdf_var][param_idx] = value
            logger.debug(f"  Stored {netcdf_var}[{param_idx}] = {value}")
        else:
            # Try to find a matching variable with debug info
            logger.warning(
                f"  Parameter variable '{netcdf_var}' not found in netcdf_data. Available keys: {[k for k in netcdf_data.keys() if k.startswith('param_')]}"
            )


def _extract_error_metrics(stats_dataset):
    """Extract error metrics from error statistics dataset."""
    if hasattr(stats_dataset, "attrs"):
        # Real error stats module
        return {
            "rms_error_m": stats_dataset.attrs.get("rms_error_m", np.nan),
            "mean_error_m": stats_dataset.attrs.get("mean_error_m", np.nan),
            "max_error_m": stats_dataset.attrs.get("max_error_m", np.nan),
            "std_error_m": stats_dataset.attrs.get("std_error_m", np.nan),
            "n_measurements": stats_dataset.attrs.get("total_measurements", 0),
        }
    else:
        # Fallback for placeholder
        return {
            "rms_error_m": float(stats_dataset.get("rms_error", np.nan)),
            "mean_error_m": float(stats_dataset.get("mean_error", np.nan)),
            "max_error_m": float(stats_dataset.get("max_error", np.nan)),
            "std_error_m": float(stats_dataset.get("std_error", np.nan)),
            "n_measurements": int(stats_dataset.get("n_measurements", 0)),
        }


def _store_gcp_pair_results(netcdf_data, param_idx, pair_idx, error_metrics):
    """Store GCP pair results in the NetCDF data structure."""
    netcdf_data["rms_error_m"][param_idx, pair_idx] = error_metrics["rms_error_m"]
    netcdf_data["mean_error_m"][param_idx, pair_idx] = error_metrics["mean_error_m"]
    netcdf_data["max_error_m"][param_idx, pair_idx] = error_metrics["max_error_m"]
    netcdf_data["std_error_m"][param_idx, pair_idx] = error_metrics["std_error_m"]
    netcdf_data["n_measurements"][param_idx, pair_idx] = error_metrics["n_measurements"]


def _compute_parameter_set_metrics(netcdf_data, param_idx, pair_errors, threshold_m=250.0):
    """
    Compute overall performance metrics for a parameter set.

    Args:
        netcdf_data: NetCDF data dictionary
        param_idx: Parameter set index
        pair_errors: Array of RMS errors for each GCP pair
        threshold_m: Performance threshold in meters
    """
    pair_errors = np.array(pair_errors)
    valid_errors = pair_errors[~np.isnan(pair_errors)]

    if len(valid_errors) > 0:
        # Percentage of pairs with error < threshold
        # Find the threshold metric key dynamically
        threshold_metric = None
        for key in netcdf_data.keys():
            if key.startswith("percent_under_") and key.endswith("m"):
                threshold_metric = key
                break

        if threshold_metric:
            percent_under_threshold = (valid_errors < threshold_m).sum() / len(valid_errors) * 100
            netcdf_data[threshold_metric][param_idx] = percent_under_threshold

        # Mean RMS across all pairs
        netcdf_data["mean_rms_all_pairs"][param_idx] = np.mean(valid_errors)

        # Best and worst pair performance
        netcdf_data["best_pair_rms"][param_idx] = np.min(valid_errors)
        netcdf_data["worst_pair_rms"][param_idx] = np.max(valid_errors)


# =============================================================================
# Incremental NetCDF Saving (Checkpoint/Resume)
# =============================================================================


def _save_netcdf_checkpoint(netcdf_data, output_file, config, pair_idx_completed):
    """
    Save NetCDF checkpoint with partial results after each GCP pair completes.

    This enables resuming Monte Carlo runs if they are interrupted.
    Adapted for pair-outer loop order where each pair processes all parameters.

    Args:
        netcdf_data: Dictionary with current NetCDF data
        output_file: Path to final output file (checkpoint uses .checkpoint.nc suffix)
        config: MonteCarloConfig with metadata
        pair_idx_completed: Index of the last completed GCP pair (for pair-outer loop)
    """
    import xarray as xr

    checkpoint_file = output_file.parent / f"{output_file.stem}_checkpoint.nc"

    # Ensure NetCDFConfig exists
    config.ensure_netcdf_config()

    # Create coordinate arrays
    coords = {
        "parameter_set_id": netcdf_data["parameter_set_id"],
        "gcp_pair_id": netcdf_data["gcp_pair_id"],
    }

    # Build variable list dynamically from netcdf_data keys
    data_vars = {}
    for var_name, var_data in netcdf_data.items():
        if var_name not in coords:
            if isinstance(var_data, np.ndarray):
                if var_data.ndim == 1:
                    data_vars[var_name] = (["parameter_set_id"], var_data)
                elif var_data.ndim == 2:
                    data_vars[var_name] = (["parameter_set_id", "gcp_pair_id"], var_data)

    # Create dataset
    ds = xr.Dataset(data_vars, coords=coords)

    # Add regular metadata
    ds.attrs.update(
        {
            "title": config.netcdf.title,
            "description": config.netcdf.description,
            "created": pd.Timestamp.now().isoformat(),
            "monte_carlo_iterations": config.n_iterations,
            "performance_threshold_m": config.netcdf.performance_threshold_m,
            "parameter_count": len(config.parameters),
            "random_seed": str(config.seed) if config.seed is not None else "None",
        }
    )

    # Add checkpoint-specific metadata (NetCDF-compatible types)
    ds.attrs["checkpoint"] = 1  # Use integer instead of boolean for NetCDF compatibility
    ds.attrs["completed_gcp_pairs"] = int(pair_idx_completed + 1)
    ds.attrs["total_gcp_pairs"] = int(len(netcdf_data["gcp_pair_id"]))
    ds.attrs["checkpoint_timestamp"] = pd.Timestamp.now().isoformat()

    # Add parameter variable attributes from config
    for param in config.parameters:
        if param.ptype == ParameterType.CONSTANT_KERNEL:
            for angle in ["roll", "pitch", "yaw"]:
                metadata = config.netcdf.get_parameter_netcdf_metadata(param, angle)
                if metadata.variable_name in ds.data_vars:
                    ds[metadata.variable_name].attrs.update({"units": metadata.units, "long_name": metadata.long_name})
        else:
            metadata = config.netcdf.get_parameter_netcdf_metadata(param)
            if metadata.variable_name in ds.data_vars:
                ds[metadata.variable_name].attrs.update({"units": metadata.units, "long_name": metadata.long_name})

    # Add standard metric attributes
    standard_attrs = config.netcdf.get_standard_attributes()
    threshold_metric = config.netcdf.get_threshold_metric_name()
    standard_attrs[threshold_metric] = {
        "units": "percent",
        "long_name": f"Percentage of pairs with error < {config.performance_threshold_m}m",
    }
    for var, attrs in standard_attrs.items():
        if var in ds.data_vars:
            ds[var].attrs.update(attrs)

    # Save to file in one operation
    checkpoint_file.parent.mkdir(parents=True, exist_ok=True)
    ds.to_netcdf(checkpoint_file, mode="w")  # Force overwrite mode
    ds.close()

    logger.info(f"  Checkpoint saved: {pair_idx_completed + 1}/{len(netcdf_data['gcp_pair_id'])} GCP pairs complete")


def _load_checkpoint(output_file, config):
    """
    Load checkpoint if it exists and convert back to netcdf_data dict.

    Args:
        output_file: Path to final output file (will check for .checkpoint.nc)
        config: MonteCarloConfig for structure information

    Returns:
        Tuple of (netcdf_data dict, start_idx) or (None, 0) if no checkpoint
    """
    import xarray as xr

    checkpoint_file = output_file.parent / f"{output_file.stem}_checkpoint.nc"

    if not checkpoint_file.exists():
        return None, 0

    logger.info(f"Found checkpoint file: {checkpoint_file}")

    try:
        ds = xr.open_dataset(checkpoint_file, decode_timedelta=False)

        # Verify this is actually a checkpoint (checkpoint attribute is 1 for true, 0 or missing for false)
        checkpoint_flag = ds.attrs.get("checkpoint", 0)
        if not checkpoint_flag:  # Will be True if checkpoint=1, False if checkpoint=0 or missing
            logger.warning("File exists but is not marked as checkpoint, ignoring")
            ds.close()
            return None, 0

        completed = ds.attrs.get("completed_gcp_pairs", 0)
        total = ds.attrs.get("total_gcp_pairs", 0)
        timestamp = ds.attrs.get("checkpoint_timestamp", "unknown")

        logger.info(f"Checkpoint from {timestamp}: {completed}/{total} GCP pairs complete")

        # Convert xarray.Dataset back to netcdf_data dictionary
        netcdf_data = {}

        # Add coordinates
        netcdf_data["parameter_set_id"] = ds.coords["parameter_set_id"].values
        netcdf_data["gcp_pair_id"] = ds.coords["gcp_pair_id"].values

        # Add all data variables
        for var_name in ds.data_vars:
            netcdf_data[var_name] = ds[var_name].values

        ds.close()

        logger.info(f"Checkpoint loaded successfully, resuming from GCP pair {completed}")

        return netcdf_data, completed

    except Exception as e:
        logger.error(f"Failed to load checkpoint: {e}")
        return None, 0


def _cleanup_checkpoint(output_file):
    """
    Remove checkpoint file after successful completion.

    Args:
        output_file: Path to final output file (will remove .checkpoint.nc)
    """
    checkpoint_file = output_file.parent / f"{output_file.stem}_checkpoint.nc"

    if checkpoint_file.exists():
        try:
            checkpoint_file.unlink()
            logger.info(f"Checkpoint file cleaned up: {checkpoint_file}")
        except Exception as e:
            logger.warning(f"Failed to remove checkpoint file: {e}")


def _save_netcdf_results(netcdf_data, output_file, config):
    """
    Save results to NetCDF file using config-driven metadata.

    This function dynamically builds the NetCDF file structure from the
    netcdf_data dictionary, using configuration for all metadata rather
    than hardcoding mission-specific values.

    Args:
        netcdf_data: Dictionary with all NetCDF variables and data
        output_file: Path to output NetCDF file
        config: MonteCarloConfig with NetCDF metadata
    """
    import xarray as xr

    logger.info(f"Saving NetCDF results to: {output_file}")

    # Ensure NetCDFConfig exists
    config.ensure_netcdf_config()

    # Create coordinate arrays
    coords = {
        "parameter_set_id": netcdf_data["parameter_set_id"],
        "gcp_pair_id": netcdf_data["gcp_pair_id"],
    }

    # Build variable list dynamically from netcdf_data keys
    data_vars = {}

    # Add all non-coordinate variables, determining dimensions from array shape
    for var_name, var_data in netcdf_data.items():
        if var_name not in coords:
            # Determine dimensions from array shape
            if isinstance(var_data, np.ndarray):
                if var_data.ndim == 1:
                    data_vars[var_name] = (["parameter_set_id"], var_data)
                elif var_data.ndim == 2:
                    data_vars[var_name] = (["parameter_set_id", "gcp_pair_id"], var_data)

    logger.info(f"  Creating dataset with {len(data_vars)} data variables")

    # Create dataset
    ds = xr.Dataset(data_vars, coords=coords)

    # Add global metadata from config
    ds.attrs.update(
        {
            "title": config.netcdf.title,
            "description": config.netcdf.description,
            "created": pd.Timestamp.now().isoformat(),
            "monte_carlo_iterations": config.n_iterations,
            "performance_threshold_m": config.netcdf.performance_threshold_m,
            "parameter_count": len(config.parameters),
            "random_seed": str(config.seed) if config.seed is not None else "None",
        }
    )

    # Add parameter variable attributes from config
    for param in config.parameters:
        if param.ptype == ParameterType.CONSTANT_KERNEL:
            # Add metadata for roll, pitch, yaw components
            for angle in ["roll", "pitch", "yaw"]:
                metadata = config.netcdf.get_parameter_netcdf_metadata(param, angle)
                if metadata.variable_name in ds.data_vars:
                    ds[metadata.variable_name].attrs.update({"units": metadata.units, "long_name": metadata.long_name})
        else:
            # Add metadata for single-value parameters
            metadata = config.netcdf.get_parameter_netcdf_metadata(param)
            if metadata.variable_name in ds.data_vars:
                ds[metadata.variable_name].attrs.update({"units": metadata.units, "long_name": metadata.long_name})

    # Add standard metric attributes from config (allows mission overrides)
    standard_attrs = config.netcdf.get_standard_attributes()

    # Add dynamic threshold metric
    threshold_metric = config.netcdf.get_threshold_metric_name()
    standard_attrs[threshold_metric] = {
        "units": "percent",
        "long_name": f"Percentage of pairs with error < {config.performance_threshold_m}m",
    }

    for var, attrs in standard_attrs.items():
        if var in ds.data_vars:
            ds[var].attrs.update(attrs)

    # Save to file
    output_file.parent.mkdir(parents=True, exist_ok=True)
    ds.to_netcdf(output_file)

    logger.info(f"  NetCDF file saved successfully")
    logger.info(f"  Dimensions: {dict(ds.sizes)}")
    logger.info(f"  Data variables: {len(list(ds.data_vars.keys()))}")
    logger.info(f"  File: {output_file}")
