"""
picasso.io
~~~~~~~~~~

General purpose library for handling input and output of files.

:author: Joerg Schnitzbauer, Maximilian Thomas Strauss, 2016-2018
:copyright: Copyright (c) 2016-2018 Jungmann Lab, MPI of Biochemistry
"""

from __future__ import annotations

import abc
import glob
import re
import struct
import json
import os
import threading
import warnings
from typing import Callable

import yaml
import h5py
import nd2
import numpy as np
import pandas as pd
from PyQt5.QtWidgets import QWidget, QMessageBox

from . import lib, __version__

from .ext import bitplane

if bitplane.IMSWRITER:
    from .ext.bitplane import IMSFile


class NoMetadataFileError(FileNotFoundError):
    pass


def _user_settings_filename() -> str:
    """Return the path to the user settings file."""
    home = os.path.expanduser("~")
    return os.path.join(home, ".picasso", "settings.yaml")


def load_raw(
    path: str,
    prompt_info: Callable[[None], tuple[dict, bool]] | None = None,
    progress: None = None,
) -> tuple[np.memmap, list[dict]]:
    """Load a raw movie file and its metadata.

    Parameters
    ----------
    path : str
        The path to the raw movie file.
    prompt_info : Callable, optional
        A function to call for additional information if needed.
    progress : None, optional
        A placeholder for progress tracking, not used in this function.

    Returns
    -------
    movie : np.memmap
        A memory-mapped numpy array representing the movie, i.e., an
        array that's only partially loaded into memory.
    info : list of dicts
        A list containing a dictionary with metadata about the movie.
    """
    try:
        info = load_info(path)
    except FileNotFoundError as error:
        if prompt_info is None:
            raise error
        else:
            result = prompt_info()
            if result is None:
                return
            else:
                info, save = result
                info = [info]
                if save:
                    base, ext = os.path.splitext(path)
                    info_path = base + ".yaml"
                    save_info(info_path, info)
    dtype = np.dtype(info[0]["Data Type"])
    shape = (info[0]["Frames"], info[0]["Height"], info[0]["Width"])
    movie = np.memmap(path, dtype, "r", shape=shape)
    if info[0]["Byte Order"] != "<":
        movie = movie.byteswap()
        info[0]["Byte Order"] = "<"
    return movie, info


def load_ims(
    path: str,
    prompt_info: Callable[[list[str]], str] | None = None,
) -> tuple[np.memmap, list[dict]]:
    """Load a Bitplane IMS movie file and its metadata.

    Parameters
    ----------
    path : str
        The path to the IMS movie file.
    prompt_info : Callable, optional
        A function to call for additional information if needed.

    Returns
    -------
    movie : np.memmap
        A memory-mapped numpy array representing the movie, i.e., an
        array that's only partially loaded into memory.
    info : list of dicts
        A list containing a dictionary with metadata about the movie.
    """
    file = IMSFile(path)

    if len(file.channels) > 1:
        # Default to Channel 0 when causing localizer
        if prompt_info is None:
            channel = "Channel 0"
        else:
            channel = prompt_info(file.channels)
        print(f"Setting channel to {channel}")
        file.set_channel(channel)

    else:
        channel = "Channel 0"

    file.read_movie()

    info = {}

    info["Frames"] = file.n_frames
    info["Height"] = file.x
    info["Width"] = file.y
    info["Channel"] = channel

    if file.pixelsize is not None:
        info["Pixelsize"] = file.pixelsize

    info["GlobalExtMin0"] = file.ext_min0
    info["GlobalExtMin1"] = file.ext_min1
    info["GlobalExtMin2"] = file.ext_min2

    info["GlobalExtMax0"] = file.ext_max0
    info["GlobalExtMax1"] = file.ext_max1
    info["GlobalExtMax2"] = file.ext_max2

    info["Generated by"] = "IMS Metadata"

    info = [info]

    return file.movie, info


def load_ims_all(path: str) -> tuple[list[np.memmap], list[list[dict]]]:
    """Load all channels of a Bitplane IMS movie file and their
    metadata.

    Parameters
    ----------
    path : str
        The path to the IMS movie file.

    Returns
    -------
    movies : list of np.memmaps
        A list of memory-mapped numpy arrays representing the movie
        channels.
    infos : list of lists of dicts
        A list of lists containing dictionaries with metadata about each
        movie channel.
    """
    file = IMSFile(path)

    movies = []
    infos = []

    for channel in file.channels:

        file.set_channel(channel)

        file.read_movie()

        info = {}
        info["Frames"] = file.n_frames
        info["Height"] = file.x
        info["Width"] = file.y
        info["Channel"] = channel

        if file.pixelsize is not None:
            info["Pixelsize"] = file.pixelsize

        info["ExtMin0"] = file.ext_min0
        info["ExtMin1"] = file.ext_min1
        info["ExtMin2"] = file.ext_min2

        info["ExtMax0"] = file.ext_max0
        info["ExtMax1"] = file.ext_max1
        info["ExtMax2"] = file.ext_max2

        info["Generated by"] = "IMS Metadata"

        info = [info]

        movies.append(file.movie)
        infos.append(info)

    return movies, infos


def save_config(CONFIG: dict) -> None:
    """Save the camera configuration dictionary to a YAML file. See
    https://picassosr.readthedocs.io/en/latest/localize.html#camera-config.

    Parameters
    ----------
    CONFIG : dict
        The camera configuration dictionary to save.
    """
    this_file = os.path.abspath(__file__)
    this_directory = os.path.dirname(this_file)
    with open(os.path.join(this_directory, "config.yaml"), "w") as config_file:
        yaml.dump(CONFIG, config_file, width=1000)


def save_raw(path: str, movie: np.ndarray, info: dict) -> None:
    """Save a raw movie file and its metadata.

    Parameters
    ----------
    path : str
        The path to the raw movie file.
    movie : np.ndarray
        The raw movie data to save.
    info : dict
        The metadata information to save.
    """
    movie.tofile(path)
    info_path = os.path.splitext(path)[0] + ".yaml"
    save_info(info_path, info)


def load_tif(path: str, progress=None) -> tuple[np.memmap, list[dict]]:
    """Load a TIFF movie file and its metadata.

    Parameters
    ----------
    path : str
        The path to the TIFF movie file.
    progress : None, optional
        A placeholder for progress tracking, not used in this function.
        Default is None.

    Returns
    -------
    movie : np.memmap
        A memory-mapped numpy array representing the movie, i.e., an
        array that's only partially loaded into memory.
    info : list[dict]
        A list containing a dictionary with metadata about the movie.
    """
    movie = TiffMultiMap(path, memmap_frames=False)
    info = movie.info()
    return movie, [info]


def load_nd2(path: str) -> tuple[ND2Movie, list[dict]]:
    """Load a Nikon ND2 movie file and its metadata.

    Parameters
    ----------
    path : str
        The path to the ND2 movie file.

    Returns
    -------
    movie : ND2Movie
        The loaded ND2 movie.
    info : list of dicts
        A list containing a dictionary with metadata about the movie.
    """
    movie = ND2Movie(path)
    info = movie.info()
    return movie, [info]


def load_movie(
    path: str,
    prompt_info=None,
    progress=None,
) -> tuple[AbstractPicassoMovie, list[dict]]:
    """Load a movie file based on its extension and returns the movie
    object and its metadata. Accepted format are ``.raw``, ``ome.tif``,
    ``.ims``, and ``.nd2``.

    Parameters
    ----------
    path : str
        The path to the movie file.
    prompt_info : None
        Placeholder for prompt information, not used in this function.
    progress : None
        Placeholder for progress tracking, not used in this function.

    Returns
    -------
    movie : AbstractPicassoMovie
        The loaded movie object.
    info : list[dict]
        A list containing a dictionary with metadata about the movie.
    """
    base, ext = os.path.splitext(path)
    ext = ext.lower()
    if ext == ".raw":
        return load_raw(path, prompt_info=prompt_info)
    elif ext == ".tif" or ext == ".tiff":
        return load_tif(path)
    elif ext == ".ims":
        return load_ims(path, prompt_info=prompt_info)
    elif ext == ".nd2":
        return load_nd2(path)


def load_info(
    path: str,
    qt_parent: QWidget | None = None,
) -> list[dict]:
    """Load metadata from a YAML file associated with the movie file.

    Parameters
    ----------
    path : str
        The path to the movie file, which is used to derive the metadata
        file name.
    qt_parent : QWidget or None, optional
        The parent widget for any error messages displayed using Qt.
        Default is None.

    Returns
    -------
    info : list of dict
        A list containing a dictionary with metadata about the movie.
    """
    path_base, path_extension = os.path.splitext(path)
    filename = path_base + ".yaml"
    try:
        with open(filename, "r") as info_file:
            info = list(yaml.load_all(info_file, Loader=yaml.UnsafeLoader))
    except FileNotFoundError as e:
        print(f"\nAn error occured. Could not find metadata file:\n{filename}")
        if qt_parent is not None:
            QMessageBox.critical(
                qt_parent,
                "An error occured",
                f"Could not find metadata file:\n{filename}",
            )
        raise NoMetadataFileError(e)
    return info


def load_mask(
    path: str,
    qt_parent: QWidget | None = None,
) -> tuple[np.ndarray, dict]:
    """Load a mask generated with ``spinna.MaskGenerator``.

    Parameters
    ----------
    path : str
        The path to the mask file.
    qt_parent : QWidget or None, optional
        The parent widget for any error messages displayed using Qt.
        Default is None.

    Returns
    -------
    mask : np.ndarray
        The loaded mask array.
    info : dict
        A dictionary containing metadata about the mask.
    """
    mask = np.float64(np.load(path))
    mask = mask / mask.sum()
    info = load_info(path.replace(".npy", ".yaml"), qt_parent=qt_parent)[0]
    try:
        value = info["Generated by"]
    except KeyError:
        raise TypeError("Incorrect file loaded.")
    if "SPINNA" not in value:
        raise TypeError("Please load a mask provided by Picasso SPINNA")
    return mask, info


def load_user_settings() -> lib.AutoDict:
    """Load user settings from a YAML file containing information such
    as the default directory for loading/saving files, Render color map,
    Localize parameters, etc.

    Returns
    -------
    settings : lib.AutoDict
        The loaded user settings.
    """
    settings_filename = _user_settings_filename()
    settings = None
    try:
        settings_file = open(settings_filename, "r")
    except FileNotFoundError:
        return lib.AutoDict()
    try:
        settings = yaml.load(settings_file, Loader=yaml.FullLoader)
        settings_file.close()
    except Exception as e:
        print(e)
        print("Error reading user settings, Reset.")
    if not settings:
        return lib.AutoDict()
    return lib.AutoDict(settings)


def save_info(
    path: str,
    info: list[dict],
    default_flow_style: bool = False,
) -> None:
    """Save metadata to a YAML file.

    Parameters
    ----------
    path : str
        The path to the YAML file where metadata will be saved.
    info : list of dict
        A list containing a dictionary with metadata about the movie.
    default_flow_style : bool, optional
        If True, the YAML will be written in flow style; otherwise, it
        will be written in block style.
    """
    with open(path, "w") as file:
        yaml.dump_all(info, file, default_flow_style=default_flow_style)


def _to_dict_walk(node: dict) -> dict:
    """Convert mapping objects (subclassed from dict) to actual dict
    objects, including nested ones."""
    node = dict(node)
    for key, val in node.items():
        if isinstance(val, dict):
            node[key] = _to_dict_walk(val)
    return node


def save_user_settings(settings: dict) -> None:
    """Save user settings, for example, the default directory for
    loading/saving files to a YAML file."""
    settings = _to_dict_walk(settings)
    settings_filename = _user_settings_filename()
    os.makedirs(os.path.dirname(settings_filename), exist_ok=True)
    with open(settings_filename, "w") as settings_file:
        yaml.dump(dict(settings), settings_file, default_flow_style=False)


class AbstractPicassoMovie(abc.ABC):
    """An abstract class defining the minimal interfaces of a
    PicassoMovie used throughout Picasso."""

    @abc.abstractmethod
    def __init__(self):
        self.use_dask = False

    @abc.abstractmethod
    def __enter__(self):
        pass

    @abc.abstractmethod
    def __exit__(self, exc_type, exc_value, traceback):
        pass

    @abc.abstractmethod
    def info(self):
        pass

    @abc.abstractmethod
    def camera_parameters(self, config: dict) -> dict:
        """Get the camera specific parameters:
            * gain
            * quantum efficiency
            * wavelength
        These parameters depend on camera settings (as described in metadata)
        but the values themselves are given in the config.yaml file.
        Each filetype (nd2, ome-tiff, ..) has their own structure of metadata,
        which needs to be matched in the config.yaml description, as detailed
        in the specific child classes.

        Parameters
        ----------
        config : dict
            Description of camera parameters (for all possible settings)
            comes from the config.yaml file.

        Returns
        -------
        parameters : dict
            Keys: gain, qe, wavelength, cam_index, camera. Values are
            lists.
        """
        return {
            "gain": [1],
            "qe": [1],
            "wavelength": [0],
            "cam_index": 0,
            "camera": "None",
        }

    @abc.abstractmethod
    def __getitem__(self, it):
        pass

    @abc.abstractmethod
    def __iter__(self):
        pass

    @abc.abstractmethod
    def __len__(self) -> int:
        return self.n_frames

    def close(self):
        pass

    @abc.abstractmethod
    def get_frame(self, index: int) -> np.ndarray:
        pass

    @abc.abstractmethod
    def tofile(self, file_handle, byte_order=None):
        pass

    @property
    @abc.abstractmethod
    def dtype(self):
        return "u16"


class ND2Movie(AbstractPicassoMovie):
    """Subclass of the AbstractPicassoMovie to implement reading Nikon
    nd2 files.

    This class implements a version which uses only ``nd2``."""

    def __init__(self, path: str, verbose: bool = False):
        super().__init__()
        if verbose:
            print("Reading info from {}".format(path))
        self.path = os.path.abspath(path)
        self.nd2file = nd2.ND2File(path)
        self.dask = self.nd2file.to_dask()
        self.sizes = self.nd2file.sizes

        required_dims = ["T", "Y", "X"]  # exactly these, not more
        for dim in required_dims:
            if dim not in self.nd2file.sizes.keys():
                raise KeyError(
                    "Required dimension {:s} not in file {:s}".format(
                        dim, self.path
                    )
                )
        if self.nd2file.ndim != len(required_dims):
            raise KeyError(
                "File {:s} has dimensions {:s} ".format(
                    self.path, str(self.nd2file.sizes.keys())
                )
                + "but should have exactly {:s}.".format(str(required_dims))
            )

        self.meta = self.get_metadata(self.nd2file)
        self._shape = [
            self.nd2file.sizes["T"],
            self.nd2file.sizes["X"],
            self.nd2file.sizes["Y"],
        ]

    def info(self) -> dict:
        return self.meta

    def get_metadata(self, nd2file: nd2.ND2File) -> dict:
        """Bring the file metadata in a readable form, and preprocesses
        it for easier downstream use.

        Parameters
        ----------
        nd2file : nd2.ND2File
            Object holding the image incl metadata.

        Returns
        -------
        info : dict
            Metadata.
        """
        info = {
            # "Byte Order": self._tif_byte_order,
            "File": self.path,
            "Height": nd2file.sizes["Y"],
            "Width": nd2file.sizes["X"],
            "Data Type": nd2file.dtype.name,
            "Frames": nd2file.sizes["T"],
        }
        info["Acquisition Comments"] = ""

        mm_info = self.metadata_to_dict(nd2file)
        camera_name = str(
            mm_info.get("description", {})
            .get("Metadata", {})
            .get("Camera Name", "None")
        )
        info["Camera"] = camera_name

        # simulate micro manager camera data for loading config values
        # see picasso/gui/localize:680ff
        # put into camera config
        # 'Sensitivity Categories': ['PixelReadoutRate', 'ReadoutMode']
        # 'Sensitivity':
        #     '540 MHz':
        #         'Rolling Shutter at 16-bit': sensitivityvalue
        # 'Channel Device':
        #     'Name': 'Filter'
        #     'Emission Wavelengths':
        #         '2 (560)': 560
        readout_rate = str(
            mm_info.get("description", {})
            .get("Metadata", {})
            .get("Camera Settings", {})
            .get("Readout Rate", "None")
        )
        readout_mode = str(
            mm_info.get("description", {})
            .get("Metadata", {})
            .get("Camera Settings", {})
            .get("Readout Mode", "None")
        )
        conversion_gain = str(
            mm_info.get("description", {})
            .get("Metadata", {})
            .get("Camera Settings", {})
            .get("Conversion Gain", "None")
        )
        filter = str(
            mm_info.get("description", {})
            .get("Metadata", {})
            .get("Camera Settings", {})
            .get("Microscope Settings", {})
            .get("Nikon Ti2, FilterChanger(Turret-Lo)", "None")
        )

        sensitivity_category = "PixelReadoutRate"
        sensitivity_category2 = "Sensitivity/DynamicRange"
        info["Micro-Manager Metadata"] = {
            camera_name + "-" + sensitivity_category: readout_rate,
            camera_name
            + "-"
            + sensitivity_category2: (readout_mode + " " + conversion_gain),
            "Filter": filter,
        }
        info["Picasso Metadata"] = {
            "Camera": camera_name,
            "PixelReadoutRate": readout_rate,
            "ReadoutMode": readout_mode,
            "ConversionGain": conversion_gain,
            "Filter": filter,
        }
        info["nd2 Metadata"] = mm_info

        return info

    def metadata_to_dict(self, nd2file: nd2.ND2File) -> dict:
        """Extract all types of metadata in the file and returns it in
        a dict.

        Parameters
        ----------
        nd2file : nd2.ND2File
            Object holding the image incl metadata.

        Returns
        -------
        mmmeta : dict
            Metadata.
        """
        mmmeta = {}

        text_info = nd2file.text_info
        try:
            mmmeta["capturing"] = self.nikontext_to_dict(
                text_info["capturing"]
            )
        except Exception:
            pass
        try:
            mmmeta["AcquisitionDate"] = text_info["date"]
        except Exception:
            pass
        try:
            mmmeta["description"] = self.nikontext_to_dict(
                text_info["description"]
            )
        except Exception:
            pass
        try:
            mmmeta["optics"] = self.nikontext_to_dict(text_info["optics"])
        except Exception:
            pass

        mmmeta["custom_data"] = nd2file.custom_data
        mmmeta["attributes"] = nd2file.attributes._asdict()
        mmmeta["metadata"] = self.nd2metadata_to_dict(nd2file.metadata)

        return mmmeta

    @classmethod
    def nikontext_to_dict(cls, text: str) -> dict:
        """Some kinds of Nikon metadata are described with text, using
        newlines and colons. This function restructures the text into
        a dict.

        Parameters
        ----------
        text : str
            Nikon-style metadata description text.

        Returns
        -------
        out : dict
            Restructured text.
        """
        out = {}
        curr_keys = []
        for i, item in enumerate(text.split("\r\n")):
            itparts = item.split(":")
            itparts = [it.strip() for it in itparts if it.strip() != ""]
            if len(itparts) == 1:
                curr_keys.append(itparts[0])
                cls.set_nested_dict_entry(out, curr_keys, {})
            elif len(itparts) == 2:
                cls.set_nested_dict_entry(
                    out, curr_keys + [itparts[0]], itparts[1]
                )
            elif len(itparts) == 3:
                curr_keys.append(itparts[0])
                cls.set_nested_dict_entry(out, curr_keys, {})
                cls.set_nested_dict_entry(
                    out, curr_keys + [itparts[1]], itparts[2]
                )
            elif len(itparts) > 3:
                curr_keys.append(itparts[0])
                cls.set_nested_dict_entry(out, curr_keys, {})
                cls.set_nested_dict_entry(out, curr_keys + [itparts[1]], item)
                # raise KeyError(
                #     'Cannot parse three or more colons between newlines: ' +
                #     item)
        return out

    @classmethod
    def nd2metadata_to_dict(cls, meta: dict) -> dict:
        """Restructure the 'metadata' field from the package nd2 into a
        dict for independent use.
        https://github.com/tlambert03/nd2/blob/main/src/nd2/structures.py

        Parameters
        ----------
        meta : nd2 metadata structure
            The 'metadata' part of nd2 metadata.

        Returns
        -------
        out : dict
            The content as a dict.
        """
        out = {}
        out["contents"] = meta.contents.__dict__
        chans = [{}] * len(meta.channels)
        for i, chan in enumerate(meta.channels):
            chans[i] = chan.__dict__
            metachan = chan.__dict__["channel"].__dict__
            chans[i]["channel"] = {}
            for k, v in metachan.items():
                chans[i]["channel"][str(k)] = str(v)
            chans[i]["loops"] = chan.__dict__["loops"].__dict__
            chans[i]["microscope"] = chan.__dict__["microscope"].__dict__
            chans[i]["volume"] = chan.__dict__["volume"].__dict__
            axints = chans[i]["volume"]["axesInterpretation"]
            chans[i]["volume"]["axesInterpretation"] = [None] * len(axints)
            for j, axes_inter in enumerate(axints):
                chans[i]["volume"]["axesInterpretation"][j] = axes_inter
        out["channels"] = chans
        return out

    @classmethod
    def set_nested_dict_entry(cls, dict: dict, keys: list, val: any) -> None:
        """Set a value (deep) in a nested dict.

        Parameters
        ----------
        dict : dict
            The nested dict.
        keys : list
            The keys leading to the entry to set.
        val : anything
            The value to set.
        """
        currlvl = dict
        for i, key in enumerate(keys[:-1]):
            try:
                currlvl = currlvl[key]
            except KeyError:
                currlvl[key] = {}
                currlvl = currlvl[key]
        currlvl[keys[-1]] = val

    def __enter__(self) -> ND2Movie:
        return self

    def __exit__(self, exc_type, exc_value, traceback):
        self.close()

    def __getitem__(self, it: int) -> np.ndarray:
        return self.get_frame(it)

    def __iter__(self):
        for i in range(self.sizes["T"]):
            yield self[i]

    def __len__(self):
        return self.sizes["T"]

    @property
    def shape(self) -> tuple[int, int, int]:
        return self._shape

    def close(self):
        self.nd2file.close()

    def get_frame(self, index: int) -> np.ndarray:
        """Load one frame of the movie

        Parameters
        ----------
        index : int
            The frame index to retrieve.

        Returns
        -------
        frame : np.ndarray
            2D array representing the image data of the frame
        """
        return self.dask[index].compute()

    def tofile(self, file_handle, byte_order=None):
        raise NotImplementedError("Cannot write .nd2 file.")

    def camera_parameters(self, config):
        """Get the camera specific parameters:
            * gain
            * quantum efficiency
            * wavelength
        These parameters depend on camera settings (as described in metadata)
        but the values themselves are given in the config.yaml file.
        Each filetype (nd2, ome-tiff, ..) has their own structure of metadata,
        which needs to be matched in the config.yaml description, as detailed
        in the specific child classes.

        The config file for the corresponding camera should look like this:
          Zyla 4.2:
            Pixelsize: 130
            Baseline: 100
            Quantum Efficiency:
              525: 0.7
              595: 0.72
              700: 0.64
            Sensitivity Categories:
              - PixelReadoutRate
              - ReadoutMode
            Sensitivity:
              540 MHz:
                Rolling Shutter at 16-bit: 7.18
              200 MHz:
                Rolling Shutter at 16-bit: 0.45
            Filter Wavelengths:
                1-R640: 700
                2-G561: 595
                3-B489: 525

        Parameters
        ----------
        config : dict
            Description of camera parameters (for all possible
            settings).

        Returns
        -------
        parameters : dict
            Keys: gain, qe, wavelength, cam_index, camera. Values are
            lists.
        """
        parameters = {}
        info = self.meta

        try:
            assert "Cameras" in config.keys() and "Camera" in info.keys()
        except Exception:
            raise KeyError("'camera' key not found in metadata or config.")

        cameras = config["Cameras"]
        camera = info["Camera"]

        try:
            assert camera in cameras.keys()
        except Exception:
            raise KeyError("camera from metadata not found in config.")

        index = sorted(list(cameras.keys())).index(camera)
        parameters["cam_index"] = index
        parameters["camera"] = camera

        try:
            assert "Picasso Metadata" in info
        except Exception:
            return {"gain": [1], "qe": [1], "wavelength": [0], "cam_index": 0}

        pm_info = info["Picasso Metadata"]
        # mm_info = info["nd2 Metadata"]
        cam_config = config["Cameras"][camera]
        if "Gain Property Name" in cam_config:
            raise NotImplementedError(
                "Extracting Gain from nd2 files is not implemented yet."
            )
            # gain_property_name = cam_config["Gain Property Name"]
            # gain = pm_info['gain']
            # if "EM Switch Property" in cam_config:
            #     switch_property_name = cam_config[
            #         "EM Switch Property"
            #     ]["Name"]
            #     switch_property_value = mm_info[
            #         camera + "-" + switch_property_name
            #     ]
            #     if (
            #         switch_property_value
            #         == cam_config["EM Switch Property"][True]
            #     ):
            #         parameters['gain'] = int(gain)
        if "gain" not in parameters.keys():
            parameters["gain"] = [1]

        parameters["Sensitivity"] = {}
        if "Sensitivity Categories" in cam_config:
            categories = cam_config["Sensitivity Categories"]
            for i, category in enumerate(categories):
                parameters["Sensitivity"][category] = pm_info[category]
        if "Quantum Efficiency" in cam_config:
            if "Filter Wavelengths" in cam_config:
                channel = pm_info["Filter"]
                channels = cam_config["Filter Wavelengths"]
                if channel in channels:
                    wavelength = channels[channel]
                    parameters["wavelength"] = str(wavelength)
                    parameters["qe"] = cam_config["Quantum Efficiency"][
                        wavelength
                    ]
        if "qe" not in parameters.keys():
            parameters["qe"] = [1]
        if "wavelength" not in parameters.keys():
            parameters["wavelength"] = [0]
        return parameters

    @property
    def dtype(self):
        return np.dtype(self.meta["Data Type"])


class TiffMap:
    """Read TIFF files and return a memory-mapped numpy array
    representing the TIFF image data. This class is used for
    single-frame TIFF files, not multi-page TIFFs."""

    TIFF_TYPES = {1: "B", 2: "c", 3: "H", 4: "L", 5: "RATIONAL"}
    TYPE_SIZES = {
        "c": 1,
        "B": 1,
        "h": 2,
        "H": 2,
        "i": 4,
        "I": 4,
        "L": 4,
        "RATIONAL": 8,
    }

    def __init__(self, path: str, verbose: bool = False):
        """Initialize the TiffMap object by reading the TIFF file and
        extracting metadata such as width, height, and data type."""
        if verbose:
            print("Reading info from {}".format(path))
        self.path = os.path.abspath(path)
        self.file = open(self.path, "rb")
        self._tif_byte_order = {b"II": "<", b"MM": ">"}[self.file.read(2)]
        self.file.seek(4)
        self.first_ifd_offset = self.read("L")

        # Read info from first IFD
        self.file.seek(self.first_ifd_offset)
        n_entries = self.read("H")
        for i in range(n_entries):
            self.file.seek(self.first_ifd_offset + 2 + i * 12)
            tag = self.read("H")
            type = self.TIFF_TYPES[self.read("H")]
            count = self.read("L")
            if tag == 256:
                self.width = self.read(type, count)
            elif tag == 257:
                self.height = self.read(type, count)
            elif tag == 258:
                bits_per_sample = self.read(type, count)
                dtype_str = "u" + str(int(bits_per_sample / 8))
                # Picasso uses internally exclusively little endian byte order
                self.dtype = np.dtype(dtype_str)
                # the tif byte order might be different
                # so we also store the file dtype
                self._tif_dtype = np.dtype(self._tif_byte_order + dtype_str)
        self.frame_shape = (self.height, self.width)
        self.frame_size = self.height * self.width

        # Collect image offsets
        self.image_offsets = []
        offset = self.first_ifd_offset
        while offset != 0:
            self.file.seek(offset)
            n_entries = self.read("H")
            if n_entries is None:
                # Some MM files have trailing nonsense bytes
                break
            for i in range(n_entries):
                self.file.seek(offset + 2 + i * 12)
                tag = self.read("H")
                if tag == 273:
                    type = self.TIFF_TYPES[self.read("H")]
                    count = self.read("L")
                    self.image_offsets.append(self.read(type, count))
                    break
            self.file.seek(offset + 2 + n_entries * 12)
            last_offset = offset + 2 + n_entries * 12
            offset = self.read("L")
        self.n_frames = len(self.image_offsets)
        self.last_ifd_offset = last_offset
        self.lock = threading.Lock()

    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_value, traceback):
        self.close()

    def __getitem__(self, it):
        with self.lock:  # for reading frames from multiple threads
            if isinstance(it, tuple):
                if isinstance(it, int) or np.issubdtype(it[0], np.integer):
                    return self[it[0]][it[1:]]
                elif isinstance(it[0], slice):
                    indices = range(*it[0].indices(self.n_frames))
                    stack = np.array([self.get_frame(_) for _ in indices])
                    if len(indices) == 0:
                        return stack
                    else:
                        if len(it) == 2:
                            return stack[:, it[1]]
                        elif len(it) == 3:
                            return stack[:, it[1], it[2]]
                        else:
                            raise IndexError
                elif it[0] == Ellipsis:
                    stack = self[it[0]]
                    if len(it) == 2:
                        return stack[:, it[1]]
                    elif len(it) == 3:
                        return stack[:, it[1], it[2]]
                    else:
                        raise IndexError
            elif isinstance(it, slice):
                indices = range(*it.indices(self.n_frames))
                return np.array([self.get_frame(_) for _ in indices])
            elif it == Ellipsis:
                return np.array(
                    [self.get_frame(_) for _ in range(self.n_frames)]
                )
            elif isinstance(it, int) or np.issubdtype(it, np.integer):
                return self.get_frame(it)
            raise TypeError

    def __iter__(self):
        for i in range(self.n_frames):
            yield self[i]

    def __len__(self):
        return self.n_frames

    def info(self) -> dict:
        """Extract metadata from the TIFF file and returns it in a
        dictionary format. This includes byte order, file path, height,
        width, data type, number of frames, and Micro-Manager
        metadata."""
        info = {
            "Byte Order": self._tif_byte_order,
            "File": self.path,
            "Height": self.height,
            "Width": self.width,
            "Data Type": self.dtype.name,
            "Frames": self.n_frames,
        }
        # The following block is MM-specific
        self.file.seek(self.first_ifd_offset)
        n_entries = self.read("H")
        for i in range(n_entries):
            self.file.seek(self.first_ifd_offset + 2 + i * 12)
            tag = self.read("H")
            type = self.TIFF_TYPES[self.read("H")]
            count = self.read("L")
            if count * self.TYPE_SIZES[type] > 4:
                self.file.seek(self.read("L"))
            if tag == 51123:
                # This is the Micro-Manager tag
                # We generate an info dict that contains any info we need.
                readout = self.read(type, count).strip(
                    b"\0"
                )  # Strip null bytes which MM 1.4.22 adds
                mm_info_raw = json.loads(readout.decode())
                # Convert to ensure compatbility with MM 2.0
                mm_info = {}
                for key in mm_info_raw.keys():
                    if key != "scopeDataKeys":
                        try:
                            mm_info[key] = mm_info_raw[key].get("PropVal")
                        except AttributeError:
                            mm_info[key] = mm_info_raw[key]

                info["Micro-Manager Metadata"] = mm_info
                if "Camera" in mm_info.keys():
                    info["Camera"] = mm_info["Camera"]
                else:
                    info["Camera"] = "None"

        # Acquisition comments
        self.file.seek(self.last_ifd_offset)
        comments = ""
        offset = 0
        while True:  # Fin the block with the summary
            line = self.file.readline()
            if "Summary" in str(line):
                readout_s = str(line)
                try:
                    readout_s = readout_s[
                        readout_s.index("{") : -readout_s[::-1].index("}")
                    ]
                    comments = json.loads(readout_s)["Summary"].split("\n")
                except Exception:
                    pass
                # print(f"comments: {comments}")
                break
            if not line:
                break
            offset += len(line)

        info["Micro-Manager Acquisition Comments"] = comments
        return info

    def get_frame(self, index: int, array: None = None) -> np.ndarray:
        """Load one frame of the TIFF movie."""
        self.file.seek(self.image_offsets[index])
        frame = np.reshape(
            np.fromfile(
                self.file,
                dtype=self._tif_dtype,
                count=self.frame_size,
            ),
            self.frame_shape,
        )
        # We only want to deal with little endian byte order downstream:
        if self._tif_byte_order == ">":
            frame.byteswap(True)
            frame = frame.view(frame.dtype.newbyteorder("<"))
        return frame

    def read(self, type: str, count: int = 1) -> bytes | float | None:
        if type == "c":
            return self.file.read(count)
        elif type == "RATIONAL":
            return self.read_numbers("L") / self.read_numbers("L")
        else:
            return self.read_numbers(type, count)

    def read_numbers(self, type: str, count: int = 1) -> float | None:
        size = self.TYPE_SIZES[type]
        fmt = self._tif_byte_order + count * type
        try:
            return struct.unpack(fmt, self.file.read(count * size))[0]
        except struct.error:
            return None

    def close(self) -> None:
        self.file.close()

    def tofile(self, file_handle, byte_order=None):
        do_byteswap = byte_order != self.byte_order
        for image in self:
            if do_byteswap:
                image = image.byteswap()
            image.tofile(file_handle)


class TiffMultiMap(AbstractPicassoMovie):
    """Read ``.ome.tif`` files created by MicroManager. Single files are
    maxed out at 4GB, so this class orchestrates reading from single
    files, each accessed by ``TiffMap``."""

    def __init__(
        self,
        path: str,
        memmap_frames: bool = False,
        verbose: bool = False,
    ):
        super().__init__()
        self.path = os.path.abspath(path)
        self.dir = os.path.dirname(self.path)

        # This matches the basename + an appendix of the file number
        filename = os.path.basename(self.path)
        if "NDTiffStack" in filename:
            # only one extension (.tif)
            base, ext = os.path.splitext(self.path)
            base = re.escape(base)
            pattern = re.compile(base + r"_(\d*).tif")
        else:
            # split two extensions as in .ome.tif
            base, ext = os.path.splitext(os.path.splitext(self.path)[0])
            base = re.escape(base)
            pattern = re.compile(base + r"_(\d*).ome.tif")
        entries = [_.path for _ in os.scandir(self.dir) if _.is_file()]
        matches = [re.match(pattern, _) for _ in entries]
        matches = [_ for _ in matches if _ is not None]
        paths_indices = [(int(_.group(1)), _.group(0)) for _ in matches]
        self.paths = [self.path] + [
            path for index, path in sorted(paths_indices)
        ]
        self.maps = [TiffMap(path, verbose=verbose) for path in self.paths]
        self.n_maps = len(self.maps)
        self.n_frames_per_map = [_.n_frames for _ in self.maps]
        self.n_frames = sum(self.n_frames_per_map)
        self.cum_n_frames = np.insert(np.cumsum(self.n_frames_per_map), 0, 0)
        self._dtype = self.maps[0].dtype
        self.height = self.maps[0].height
        self.width = self.maps[0].width
        self.shape = (self.n_frames, self.height, self.width)

    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_value, traceback):
        self.close()

    def __getitem__(self, it):
        if isinstance(it, tuple):
            if it[0] == Ellipsis:
                stack = self[it[0]]
                if len(it) == 2:
                    return stack[:, it[1]]
                elif len(it) == 3:
                    return stack[:, it[1], it[2]]
                else:
                    raise IndexError
            elif isinstance(it[0], slice):
                indices = range(*it[0].indices(self.n_frames))
                stack = np.array([self.get_frame(_) for _ in indices])
                if len(indices) == 0:
                    return stack
                else:
                    if len(it) == 2:
                        return stack[:, it[1]]
                    elif len(it) == 3:
                        return stack[:, it[1], it[2]]
                    else:
                        raise IndexError
            if isinstance(it[0], int) or np.issubdtype(it[0], np.integer):
                return self[it[0]][it[1:]]
        elif isinstance(it, slice):
            indices = range(*it.indices(self.n_frames))
            return np.array([self.get_frame(_) for _ in indices])
        elif it == Ellipsis:
            return np.array([self.get_frame(_) for _ in range(self.n_frames)])
        elif isinstance(it, int) or np.issubdtype(it, np.integer):
            return self.get_frame(it)
        raise TypeError

    def __iter__(self):
        for i in range(self.n_frames):
            yield self[i]

    def __len__(self):
        return self.n_frames

    def close(self):
        for map in self.maps:
            map.close()

    @property
    def dtype(self):
        return self._dtype

    def get_frame(self, index: int) -> np.ndarray:
        # TODO deal with negative numbers
        for i in range(self.n_maps):
            if self.cum_n_frames[i] <= index < self.cum_n_frames[i + 1]:
                break
        else:
            raise IndexError
        return self.maps[i][index - self.cum_n_frames[i]]

    def info(self):
        info = self.maps[0].info()
        info["Frames"] = self.n_frames
        self.meta = info
        return info

    def camera_parameters(self, config: dict) -> dict:
        """Get the camera specific parameters:
            * gain
            * quantum efficiency
            * wavelength
        These parameters depend on camera settings (as described in metadata)
        but the values themselves are given in the config.yaml file.
        Each filetype (nd2, ome-tiff, ..) has their own structure of metadata,
        which needs to be matched in the config.yaml description, as detailed
        in the specific child classes.
        This code has been moved from localize to here, as it is file type
        specific (HG, April 2022).

        Args:
        config : dict
            Description of camera parameters (for all possible
            settings).

        Returns
        -------
        parameters : dict
            Keys: gain, qe, wavelength, cam_index, camera. Values are
            lists.
        """
        # return {'gain': [1], 'qe': [1], 'wavelength': [0], 'cam_index': 0}
        parameters = {}
        info = self.meta

        try:
            assert "Cameras" in config and "Camera" in info
        except Exception:
            return {"gain": [1], "qe": [1], "wavelength": [0], "cam_index": 0}
            # raise KeyError("'camera' key not found in metadata or config.")

        cameras = config["Cameras"]
        camera = info["Camera"]

        try:
            assert camera in list(cameras.keys())
        except Exception:
            return {"gain": [1], "qe": [1], "wavelength": [0], "cam_index": 0}
            # raise KeyError('camera from metadata not found in config.')

        index = sorted(list(cameras.keys())).index(camera)
        parameters["cam_index"] = index
        parameters["camera"] = camera

        try:
            assert "Micro-Manager Metadata" in info
        except Exception:
            return {"gain": [1], "qe": [1], "wavelength": [0], "cam_index": 0}

        mm_info = info["Micro-Manager Metadata"]
        cam_config = config["Cameras"][camera]
        if "Gain Property Name" in cam_config:
            gain_property_name = cam_config["Gain Property Name"]
            gain = mm_info[camera + "-" + gain_property_name]
            if "EM Switch Property" in cam_config:
                switch_property_name = cam_config["EM Switch Property"]["Name"]
                switch_property_value = mm_info[
                    camera + "-" + switch_property_name
                ]
                if (
                    switch_property_value
                    == cam_config["EM Switch Property"][True]
                ):
                    parameters["gain"] = int(gain)
        if "gain" not in parameters.keys():
            parameters["gain"] = [1]
        parameters["Sensitivity"] = {}
        if "Sensitivity Categories" in cam_config:
            categories = cam_config["Sensitivity Categories"]
            for i, category in enumerate(categories):
                property_name = camera + "-" + category
                if property_name in mm_info:
                    exp_setting = mm_info[camera + "-" + category]
                    parameters["Sensitivity"][category] = exp_setting
        if "Quantum Efficiency" in cam_config:
            if "Channel Device" in cam_config:
                channel_device_name = cam_config["Channel Device"]["Name"]
                channel = mm_info[channel_device_name]
                channels = cam_config["Channel Device"]["Emission Wavelengths"]
                if channel in channels:
                    wavelength = channels[channel]
                    parameters["wavelength"] = [str(wavelength)]
                    parameters["qe"] = [
                        cam_config["Quantum Efficiency"][wavelength]
                    ]
        if "qe" not in parameters.keys():
            parameters["qe"] = [1]
        if "wavelength" not in parameters.keys():
            parameters["wavelength"] = [0]
        return parameters

    def tofile(self, file_handle, byte_order=None):
        for map in self.maps:
            map.tofile(file_handle, byte_order)


def to_raw_combined(basename: str, paths: list[str]) -> None:
    """Combine multiple TIFF files into a single raw file in the OME
    format.

    Parameters
    ----------
    basename : str
        The base name for the output raw file.
    paths : list of strs
        List of paths to the TIFF files to be combined.
    """
    raw_file_name = basename + ".ome.raw"
    with open(raw_file_name, "wb") as file_handle:
        with TiffMap(paths[0]) as tif:
            tif.tofile(file_handle, "<")
            info = tif.info()
        for path in paths[1:]:
            with TiffMap(path) as tif:
                info_ = tif.info()
                info["Frames"] += info_["Frames"]
                if "Comments" in info_:
                    info["Comments"] = info_["Comments"]
                tif.tofile(file_handle, "<")
        info["Generated by"] = f"Picasso ToRaw v{__version__}"
        info["Byte Order"] = "<"
        info["Original File"] = os.path.basename(info.pop("File"))
        info["Raw File"] = os.path.basename(raw_file_name)
        save_info(basename + ".ome.yaml", [info])


def get_movie_groups(paths: list[str]) -> dict[str, list[str]]:
    """Group movie files by their base name, allowing for an optional
    appendix of the file number. This is useful for handling OME-TIFF
    files that may have multiple parts or versions.

    Parameters
    ----------
    paths : list[str]
        List of file paths to be grouped.

    Returns
    -------
    groups : dict[str, list[str]]
        A dictionary where keys are base names and values are lists of
        file paths that share the same base name.
    """
    groups = {}
    if len(paths) > 0:
        # This matches the basename + an opt appendix of the file number
        pattern = re.compile(r"(.*?)(_(\d*))?.ome.tif")
        matches = [re.match(pattern, path) for path in paths]
        match_infos = [
            {"path": _.group(), "base": _.group(1), "index": _.group(3)}
            for _ in matches
        ]
        for match_info in match_infos:
            if match_info["index"] is None:
                match_info["index"] = 0
            else:
                match_info["index"] = int(match_info["index"])
        basenames = set([_["base"] for _ in match_infos])
        for basename in basenames:
            match_infos_group = [
                _ for _ in match_infos if _["base"] == basename
            ]
            group = [_["path"] for _ in match_infos_group]
            indices = [_["index"] for _ in match_infos_group]
            group = [path for (index, path) in sorted(zip(indices, group))]
            groups[basename] = group
    return groups


def to_raw(path: str, verbose: bool = True) -> None:
    """Convert TIFF files matching the given path pattern into a single
    raw file in the OME format. This function groups files by their base
    name and processes each group to create a combined raw file."""
    paths = glob.glob(path)
    groups = get_movie_groups(paths)
    n_groups = len(groups)
    if n_groups:
        for i, (basename, group) in enumerate(groups.items()):
            if verbose:
                print(
                    "Converting movie {}/{}...".format(i + 1, n_groups),
                    end="\r",
                )
            to_raw_combined(basename, group)
        if verbose:
            print()
    else:
        if verbose:
            print("No files matching {}".format(path))


def save_datasets(path: str, info: dict, **kwargs) -> None:
    """Save multiple datasets to an HDF5 file at the specified path."""
    # for key, val in kwargs.items():
    #     val.to_hdf(path, key=key, mode="a")
    # cannot use to_hdf for backward compatibility with older Picasso
    with h5py.File(path, "w") as locs_file:
        for key, val in kwargs.items():
            rec_locs = val.to_records(index=False)
            locs_file.create_dataset(key, data=rec_locs)
    base, ext = os.path.splitext(path)
    info_path = base + ".yaml"
    save_info(info_path, info)


def save_locs(path: str, locs: pd.DataFrame, info: list[dict]) -> None:
    """Save localization data to an HDF5 file.

    Parameters
    ----------
    path : str
        The path where the localization data will be saved.
    locs : pd.DataFrame
        The localization data to be saved.
    info : list of dict
        Metadata information to be saved alongside the localization
        data.
    """
    locs = lib.ensure_sanity(locs, info)
    # locs.to_hdf(path, key="locs", mode="w", format="fixed")
    # cannot use to_hdf for backward compatibility with older Picasso
    rec_locs = locs.to_records(index=False)
    with h5py.File(path, "w") as locs_file:
        locs_file.create_dataset("locs", data=rec_locs)
    base, ext = os.path.splitext(path)
    info_path = base + ".yaml"
    save_info(info_path, info)


def load_locs(
    path: str, qt_parent: QWidget | None = None
) -> tuple[pd.DataFrame, list[dict]]:
    """Load localization data from an HDF5 file.

    Parameters
    ----------
    path : str
        The path to the HDF5 file containing localization data.
    qt_parent : QWidget or None, optional
        Parent widget for any Qt-related operations, default is None.

    Returns
    -------
    locs : pd.DataFrame
        The localization data loaded from the file.
    info : list[dict]
        Metadata information loaded from the file, typically a list of
        dictionaries containing various metadata fields.
    """
    locs = pd.read_hdf(path, key="locs")
    info = load_info(path, qt_parent=qt_parent)
    locs = lib.ensure_sanity(locs, info)
    return locs, info


def load_clusters(path: str) -> pd.DataFrame:
    """Load cluster data from an HDF5 file.

    Parameters
    ----------
    path : str
        The path to the HDF5 file containing cluster data.

    Returns
    -------
    clusters : pd.DataFrame
        The cluster data loaded from the file.
    """
    try:
        clusters = pd.read_hdf(path, key="clusters")
    except KeyError:
        clusters = pd.read_hdf(path, key="locs")
    return clusters


def load_filter(
    path: str,
    qt_parent: QWidget | None = None,
) -> tuple[pd.DataFrame, list[dict]]:
    """Load localization data from an HDF5 file, checking for different
    possible keys for the localization data. This function is used to
    handle files that may contain localization data under different
    keys such as 'locs', 'groups', or 'clusters'.

    Parameters
    ----------
    path : str
        The path to the HDF5 file containing localization data.
    qt_parent : QWidget | None, optional
        Parent widget for any Qt-related operations, default is None.

    Returns
    -------
    locs : pd.DataFrame
        The localization data loaded from the file.
    info : list[dict]
        Metadata information loaded from the file, typically a list of
        dictionaries containing various metadata fields.
    """
    try:
        locs = pd.read_hdf(path, key="locs")
        info = load_info(path, qt_parent=qt_parent)
    except KeyError:
        try:
            locs = pd.read_hdf(path, key="groups")
            info = load_info(path, qt_parent=qt_parent)
        except KeyError:
            locs = pd.read_hdf(path, key="clusters")
            info = []
    return locs, info


def export_txt_imagej(
    path: str, locs: pd.DataFrame, info: list[dict] | None = None
) -> None:
    """Export localizations to a text file compatible with ImageJ.

    Parameters
    ----------
    path : str
        The path where the text file will be saved.
    locs : pd.DataFrame
        The localization data to be exported.
    info : list of dicts, optional
        Metadata dictionaries. Ignored but kept for compatibility with
        other export functions.
    """
    loctxt = locs[["frame", "x", "y"]]
    np.savetxt(
        path,
        loctxt.to_records(index=False),
        fmt=["%.1i", "%.5f", "%.5f"],
        newline="\r\n",
        delimiter="   ",
    )


def export_txt_nis(path: str, locs: pd.DataFrame, info: list[dict]) -> None:
    """Export localizations as .txt for NIS.

    Parameters
    ----------
    path : str
        The path where the text file will be saved.
    locs : pd.DataFrame
        The localization data to be exported.
    info : list of dicts
        Metadata dictionaries.
    """
    z_header = b"X\tY\tZ\tChannel\tWidth\tBG\tLength\tArea\tFrame\r\n"
    fmt_z = [
        "%.2f",
        "%.2f",
        "%.2f",
        "%.i",
        "%.2f",
        "%.i",
        "%.i",
        "%.i",
        "%.i",
    ]
    header = b"X\tY\tChannel\tWidth\tBG\tLength\tArea\tFrame\r\n"
    fmt = [
        "%.2f",
        "%.2f",
        "%.i",
        "%.2f",
        "%.i",
        "%.i",
        "%.i",
        "%.i",
    ]
    pixelsize = lib.get_from_metadata(info, "Pixelsize", raise_error=True)
    columns_original = [
        "x",
        "y",
        "z",
        "sx",
        "bg",
        "photons",
        "frame",
    ]
    if "z" not in locs.columns:
        columns_original.remove("z")
    loctxt = locs[columns_original].copy()
    loctxt["frame"] += 1
    loctxt[["x", "y", "sx"]] *= pixelsize
    loctxt["Channel"] = 1
    loctxt["Length"] = 1
    loctxt["bg"] = loctxt["bg"].round().astype(int)
    loctxt["photons"] = loctxt["photons"].round().astype(int)
    if "z" in locs.columns:
        header = z_header
        fmt = fmt_z
    with open(path, "wb") as f:
        f.write(header)
        np.savetxt(
            f,
            loctxt.to_numpy(),
            fmt=fmt,
            newline="\r\n",
            delimiter="\t",
        )


def export_xyz_chimera(
    path: str, locs: pd.DataFrame, info: list[dict]
) -> None:
    """Export localizations as .xyz for CHIMERA. The file contains
    only x, y, z. Raise a warning if no z coordinate found.

    Parameters
    ----------
    path : str
        The path where the xyz file will be saved.
    locs : pd.DataFrame
        The localization data to be exported.
    info : list of dicts
        Metadata dictionaries.
    """
    pixelsize = lib.get_from_metadata(info, "Pixelsize", raise_error=True)
    if "z" in locs.columns:
        loctxt = locs[["x", "y", "z"]].copy()
        loctxt["molecule"] = 1
        loctxt[["x", "y"]] *= pixelsize
        loctxt = loctxt[["molecule", "x", "y", "z"]]
        with open(path, "wb") as f:
            f.write(b"Molecule export\r\n")
            np.savetxt(
                f,
                loctxt.to_numpy(),
                fmt=["%i", "%.5f", "%.5f", "%.5f"],
                newline="\r\n",
                delimiter="\t",
            )
    else:
        warnings.warn(
            "No z coordinate found in localizations; cannot export to .xyz "
            "for CHIMERA."
        )


def export_3d_visp(path: str, locs: pd.DataFrame, info: list[dict]) -> None:
    """Export localizations as .3d for ViSP. Show a warning if no z
    coordinate found.

    Parameters
    ----------
    path : str
        The path where the 3d file will be saved.
    locs : pd.DataFrame
        The localization data to be exported.
    info : list of dicts
        Metadata dictionaries.
    """
    pixelsize = lib.get_from_metadata(info, "Pixelsize", raise_error=True)
    if "z" in locs.columns:
        loctxt = locs[["x", "y", "z", "photons", "frame"]].copy()
        loctxt[["x", "y"]] *= pixelsize
        loctxt["frame"] = loctxt["frame"].astype(int)
        with open(path, "wb") as f:
            np.savetxt(
                f,
                loctxt.to_records(index=False),
                fmt=["%.1f", "%.1f", "%.1f", "%.1f", "%d"],
                newline="\r\n",
            )
    else:
        warnings.warn(
            "No z coordinate found in localizations; cannot export to .3d "
            "for ViSP."
        )


def export_thunderstorm(
    path: str, locs: pd.DataFrame, info: list[dict]
) -> None:
    """Export localizations as .csv for ThunderSTORM.

    Parameters
    ----------
    path : str
        The path where the csv file will be saved.
    locs : pd.DataFrame
        The localization data to be exported.
    info : list of dicts
        Metadata dictionaries.
    """
    pixelsize = lib.get_from_metadata(info, "Pixelsize", raise_error=True)
    columns_original = [
        "frame",
        "x",
        "y",
        "sx",
        "sy",
        "photons",
        "bg",
        "lpx",
        "lpy",
    ]
    if "z" in locs.columns:
        columns_original.append("z")
    if "len" in locs.columns:
        columns_original.append("len")
    loctxt = locs[columns_original].copy()

    # add the columns
    loctxt["photons"] = loctxt["photons"].astype(np.int32)
    loctxt["bg"] = loctxt["bg"].astype(np.int32)
    loctxt["id"] = np.arange(len(loctxt), dtype=np.int32)
    loctxt[["x", "y", "sx", "sy"]] *= pixelsize
    loctxt["bkgstd [photon]"] = 0
    loctxt["uncertainty_xy [nm]"] = (
        (loctxt["lpx"] + loctxt["lpy"]) / 2 * pixelsize
    )
    column_mapper = {
        "x": "x [nm]",
        "y": "y [nm]",
        "sx": "sigma1 [nm]",
        "sy": "sigma2 [nm]",
        "photons": "intensity [photon]",
        "bg": "offset [photon]",
    }
    if "z" in loctxt.columns:
        column_mapper["z"] = "z [nm]"
    if "len" in loctxt.columns:
        loctxt.rename(columns={"len": "detections"}, inplace=True)
    loctxt.rename(columns=column_mapper, inplace=True)
    loctxt.drop(columns=["lpx", "lpy"], inplace=True)
    # change the order of columns
    columns_final = [
        "id",
        "frame",
        "x [nm]",
        "y [nm]",
        "z [nm]",
        "sigma1 [nm]",
        "sigma2 [nm]",
        "intensity [photon]",
        "offset [photon]",
        "bkgstd [photon]",
        "uncertainty_xy [nm]",
        "detections",
    ]
    if "z [nm]" not in loctxt.columns:
        columns_final.remove("z [nm]")
        columns_final.remove("sigma2 [nm]")
        columns_final[4] = "sigma [nm]"
        loctxt.rename(
            columns={"sigma1 [nm]": "sigma [nm]"},
            inplace=True,
        )
        loctxt.drop(columns=["sigma2 [nm]"], inplace=True)
    if "detections" not in loctxt.columns:
        columns_final.remove("detections")
    loctxt = loctxt[columns_final]
    # save
    loctxt.to_csv(path, index=False)


def import_ts(path: str, pixelsize: float) -> tuple[pd.DataFrame, list[dict]]:
    """Import localization data from a ThunderSTORM .csv file.

    Parameters
    ----------
    path : str
        The path to the ThunderSTORM .csv file.
    pixelsize : float
        Camera pixel size in nm. Picasso saves xy coordinates in units
        of camera pixels.

    Returns
    -------
    locs : pd.DataFrame
        The localization data imported from the file.
    info : list of dicts
        Minimal metadata information.
    """
    data = pd.read_csv(path)
    frames = data["frame"].astype(int)
    # make sure frames start at zero:
    frames = frames - np.min(frames)
    x = data["x [nm]"] / pixelsize
    y = data["y [nm]"] / pixelsize
    photons = data["intensity [photon]"].astype(int)

    bg = data["offset [photon]"].astype(int)
    lpx = data["uncertainty_xy [nm]"] / pixelsize
    lpy = data["uncertainty_xy [nm]"] / pixelsize

    if "z [nm]" in data.columns:
        z = data["z [nm]"]
        sx = data["sigma1 [nm]"] / pixelsize
        sy = data["sigma2 [nm]"] / pixelsize
        locs = pd.DataFrame(
            {
                "frame": frames.astype(np.uint32),
                "x": x.astype(np.float32),
                "y": y.astype(np.float32),
                "z": z.astype(np.float32),
                "photons": photons.astype(np.float32),
                "sx": sx.astype(np.float32),
                "sy": sy.astype(np.float32),
                "bg": bg.astype(np.float32),
                "lpx": lpx.astype(np.float32),
                "lpy": lpy.astype(np.float32),
            }
        )
    else:
        sx = data["sigma [nm]"] / pixelsize
        sy = data["sigma [nm]"] / pixelsize
        locs = pd.DataFrame(
            {
                "frame": frames.astype(np.uint32),
                "x": x.astype(np.float32),
                "y": y.astype(np.float32),
                "photons": photons.astype(np.float32),
                "sx": sx.astype(np.float32),
                "sy": sy.astype(np.float32),
                "bg": bg.astype(np.float32),
                "lpx": lpx.astype(np.float32),
                "lpy": lpy.astype(np.float32),
            }
        )
    locs.sort_values(kind="quicksort", by="frame", inplace=True)

    img_info = {}
    img_info["Generated by"] = f"Picasso v{__version__} csv2hdf"
    img_info["Frames"] = int(np.max(frames)) + 1
    img_info["Height"] = int(np.ceil(np.max(y)))
    img_info["Width"] = int(np.ceil(np.max(x)))
    img_info["Pixelsize"] = float(pixelsize)

    base, ext = os.path.splitext(path)
    out_path = base + "_locs.hdf5"
    save_locs(out_path, locs, [img_info])
    return locs, [img_info]
