import copy
import json
from typing import Any
from typing import Literal
from typing import Union

import numpy as np
from esrf_pathlib import Path
from sb_detectors.detectors import detector_from_name


class MetadataHandler:
    """A utility class for handling metadata from MXCuBE experiments.

    This class provides methods to load and parse the `metadata.json` file produced
    during data collection and supports retrieving nested metadata fields, calculating
    beamline-specific quantities (e.g., beam center, omega array), and generating NeXus
    HDF5 data link dictionaries for master file integration.

    Attributes
    ----------
    _metadata_filepath : Path or None
        Path to the metadata JSON file.
    _metadata : dict or None
        Cached metadata dictionary loaded from JSON.

    Examples
    --------
    >>> handler = MetadataHandler("path/to/metadata.json")
    >>> handler["MX_numberOfImages"]
    180
    >>> handler.compute_omega_array()
    array([...])

    """

    def __init__(self, filepath_metadata: Union[Path, str, None]):
        """Initialize the MetadataHandler with the path to a metadata JSON file.

        Parameters
        ----------
        filepath_metadata : Path or str or None
            Path to the `metadata.json` file generated by MXCuBE.
            Can be a string or a Path object. If None, loading will fail.

        Raises
        ------
        ValueError
            If the path is None or the file is not found.
        FileNotFoundError
            If the provided path does not exist.

        """

        if isinstance(filepath_metadata, str):
            filepath_metadata = Path(filepath_metadata)

        self._metadata_filepath = filepath_metadata
        self._metadata = None
        self._load_metadata()

    def _load_metadata(self):
        """Load metadata from the JSON file if not already cached.

        Raises
        ------
        ValueError
            If the metadata file path is not set.
        FileNotFoundError
            If the metadata file does not exist.
        ValueError
            If the file cannot be decoded as valid JSON.
        IOError
            If reading the file fails for other reasons.

        """
        if self._metadata_filepath is None:
            raise ValueError("Metadata file path has not been set on the handler.")
        if not self._metadata_filepath.is_file():
            raise FileNotFoundError(
                f"Metadata file not found: {self._metadata_filepath}"
            )
        try:
            with open(self._metadata_filepath, "r") as f:
                self._metadata = json.load(f)
        except json.JSONDecodeError as e:
            raise ValueError(f"Error decoding JSON from {self._metadata_filepath}: {e}")
        except Exception as e:
            raise IOError(f"Error reading metadata file {self._metadata_filepath}: {e}")

    def get_metadata_value(self, key_path: str) -> Any:
        """Retrieve a value from the metadata using a slash-separated path.

        Parameters
        ----------
        key_path : str
            Slash-separated keys to navigate the metadata dictionary.

        Returns
        -------
        Any
            Value found at the key path.

        Raises
        ------
        ValueError
            If a non-dictionary value is accessed as if it were a dictionary.

        """
        value = self._metadata
        for key in key_path.split("/"):
            if isinstance(value, dict):
                value = value[key]
            else:
                raise ValueError(
                    f"Cannot access key '{key}' in non-dictionary: {value}"
                )
        return copy.deepcopy(value)

    def __getitem__(self, key_path: str) -> Any:
        """Enable dictionary-style access to metadata using slash-separated paths.

        Parameters
        ----------
        key_path : str
            Metadata key path (e.g., "a/b/c").

        Returns
        -------
        Any
            Value found at the key path.

        """
        return self.get_metadata_value(key_path)

    def get_detector_info(self, key: str) -> Any:
        """Retrieve a specific attribute from the detector characteristics.

        This uses the detector model and beamline from the metadata to retrieve
        the appropriate detector configuration.

        Parameters
        ----------
        key : str
            Attribute name to retrieve from the detector.

        Returns
        -------
        Any
            Value of the requested detector attribute.

        """
        path_mx_directory = Path(self["MX_directory"])
        beamline = path_mx_directory.beamline
        detector_model = self["InstrumentDetector01_model"]

        if beamline:
            detector_info = detector_from_name(name=detector_model, beamline=beamline)
            value = getattr(detector_info, key)
            return value
        else:
            raise ValueError("beamline must be provided to resolve detector info")

    def generate_dict_data_links(
        self, nb_frames_per_files: int, h5dataset_path: str
    ) -> dict:
        """Generate a NeXus-compatible dictionary to link raw HDF5 files in the master
        file.

        Parameters
        ----------
        nb_frames_per_files : int
            Number of frames per individual HDF5 file.
        h5dataset_path : str
            Base path within the HDF5 dataset.

        Returns
        -------
        dict
            Dictionary with NeXus-style entries and data links.

        """
        data_dict = {}
        total_images = self["MX_numberOfImages"]
        template = self["MX_template"]
        starting_image_index = self["MX_startImageNumber"]
        nb_files = total_images // nb_frames_per_files

        if total_images % nb_frames_per_files != 0:
            nb_files += 1

        for i in range(starting_image_index, nb_files + starting_image_index):
            name_dataset_entry = f">data_{i:06d}"
            name_file = template % i
            str_link = f"{name_file}::{h5dataset_path}"
            data_dict[name_dataset_entry] = str_link

        data_dict["@NX_class"] = "NXdata"
        data_dict["@signal"] = f"data_{starting_image_index:06d}"
        return data_dict

    def beam_center(self, orientation: Literal["X", "Y"]) -> float:
        """Compute the beam center in pixels based on metadata.

        Parameters
        ----------
        orientation : {'X', 'Y'}
            Beam orientation axis.

        Returns
        -------
        float
            Beam center position in pixels.

        Raises
        ------
        ValueError
            If the orientation is not 'X' or 'Y'.

        """
        if orientation not in ("X", "Y"):
            raise ValueError("Orientation must be 'X' or 'Y'")

        if orientation == "X":
            beam_ctr = self["MX_xBeam"]
            pixel_size = self["InstrumentDetector01_x_pixel_size"]
        else:
            beam_ctr = self["MX_yBeam"]
            pixel_size = self["InstrumentDetector01_y_pixel_size"]

        return beam_ctr / pixel_size

    def compute_acquisition_time(self) -> str:
        """Compute the mid-point of the acquisition time range.

        Returns
        -------
        str
            ISO 8601 formatted datetime string with milliseconds.

        Raises
        ------
        KeyError
            If 'startDate' or 'endDate' keys are missing from metadata.
        ValueError
            If date strings cannot be parsed.

        """
        from datetime import datetime

        start_date = self["startDate"]
        end_date = self["endDate"]

        start = datetime.strptime(start_date, "%Y-%m-%d %H:%M:%S")
        end = datetime.strptime(end_date, "%Y-%m-%d %H:%M:%S")

        avg_timestamp = (start.timestamp() + end.timestamp()) / 2
        avg_datetime = datetime.fromtimestamp(avg_timestamp)

        return avg_datetime.isoformat(timespec="milliseconds")

    def convert_photon_energy_ev(self) -> float:
        """Convert the photon wavelength to energy in electron volts (eV).

        Returns
        -------
        float
            Photon energy in eV.

        """
        wavelength = self["InstrumentMonochromator_wavelength"]
        return 12398.419843320026 / wavelength

    def convert_detector_distance_to_m(self) -> float:
        """Convert detector distance from millimeters to meters.

        Returns
        -------
        float
            Detector distance in meters.

        """
        distance = self["MX_detectorDistance"]
        return distance / 1000

    def convert_pixel_size_to_m(self, orientation: Literal["X", "Y"]) -> float:
        """Convert the pixel size to meters based on orientation.

        Parameters
        ----------
        orientation : {'X', 'Y'}
            Pixel orientation axis.

        Returns
        -------
        float
            Pixel size in meters.

        Raises
        ------
        ValueError
            If the orientation is not 'X' or 'Y'.

        """
        if orientation not in ("X", "Y"):
            raise ValueError("Orientation must be 'X' or 'Y'")

        if orientation == "X":
            pixel_size = self["InstrumentDetector01_x_pixel_size"]
        else:
            pixel_size = self["InstrumentDetector01_y_pixel_size"]

        return pixel_size / 1000

    def compute_omega_array(self) -> np.ndarray:
        """Compute the omega angle array used in rotation-based data collection.

        Returns
        -------
        numpy.ndarray
            Array of omega angles in degrees.

        """
        nb_image = self["MX_numberOfImages"]
        oscillation_range = self["MX_oscillationRange"]
        range_total = nb_image * oscillation_range
        start_angle = self["MX_oscillationStart"]

        if abs(round(start_angle) - start_angle) < 1e-3:
            start_angle = round(start_angle)

        return np.arange(
            start_angle,
            start_angle + range_total,
            oscillation_range,
            dtype=np.float64,
        )
