#!/usr/bin/env python3
import os, json, requests, h5py
import numpy as np
from dotenv import load_dotenv
from getpass import getpass
from APIHandler import APIHandler
from classes import *
from PIL import Image
# from schema import pld_deposition


def call_entrypoint_from_elabid(elabid):
    """
    Calls a sample from eLabFTW through its elabid, then returns an object of the Entrypoint class.
    The Entrypoint serves as the starting point in the construction of the dataset.

    If the entry is not a sample (category_title not matching exactly "Sample") returns ValueError.
    It's most likely the first error you might encounter (with a valid API key).

    Arg: elabid: int    eLabFTW internal id of the selected resource.
    """
    try:
        sample_data = APIHandler(api_key, ELABFTW_API_URL).get_entry_from_elabid(
            elabid, entryType="items"
        )
        if not sample_data.get("category_title") == "Sample":
            raise ValueError(
                "The resource you selected is not a sample, therefore it can't be used as an entrypoint."
            )
        sample_object = Entrypoint(sample_data)
    except ConnectionError as e:
        raise ConnectionError(e)
    return sample_object  # Entrypoint-class object


def call_material_from_elabid(elabid):
    """
    Calls a material from eLabFTW using its elabid, then returns an object of the Material class.

    If the entry is neither a PLD Target or a Substrate batch returns ValueError.
    Such entries always have a category_title key with its value matching exactly "PLD Target" or "Substrate".
    Because of an old typo, the value "Subtrate" (second 's' is missing) is also accepted.

    arg: elabid: int    eLabFTW internal id of the selected resource.
    """
    try:
        material_data = APIHandler(api_key, ELABFTW_API_URL).get_entry_from_elabid(
            elabid, entryType="items"
        )
        material_category = material_data.get("category_title")
        # TO-DO: correct this typo on elabftw: Subtrate → Substrate.
        if not material_category in ["PLD Target", "Substrate", "Subtrate"]:
            print(f"Category of the resource: {material_category}.")
            raise ValueError(
                f"The referenced resource (elabid = {elabid}) is not a material."
            )
        elif material_category == "PLD Target":
            material_object = Target(material_data)
        else:
            material_object = Substrate(material_data)
    except ConnectionError as e:
        raise ConnectionError(e)
    return material_object  # Material-class object


def call_layers_from_list(elabid_list):
    """
    Calls a list of (PLD deposition) experiments from eLabFTW through their elabid's, then returns a list of Layer-class objects.

    If one of the entries is not related to a deposition layer (category_title not matching exactly "PLD Deposition")
    that entry is skipped, with no error raised.

    Arg: elabid_list: list(int):    list of eLabFTW experiments.
    """
    list_of_layers = []
    for elabid in elabid_list:
        try:
            layer_data = APIHandler(api_key, ELABFTW_API_URL).get_entry_from_elabid(
                elabid, entryType="experiments"
            )
            if not layer_data.get("category_title") == "PLD Deposition":
                continue
            layer_object = Layer(layer_data)
            list_of_layers.append(layer_object)
        except ConnectionError as e:
            nums = [layer.layer_number for layer in list_of_layers]
            nums.sort()
            print(f"LIST OF THE LAYERS PROCESSED (unordered):\n" + str(nums))
            raise ConnectionError(
                f"An error occurred while fetching the experiment with elabid = {elabid}:\n"
                + str(e)
                + f"\nPlease solve the problem before retrying."
                + "\n\n"
                + f"Last resource attempted to call at base url: /experiments/{elabid}"
            )
    return list_of_layers  # list of Layer-class objects


def call_proposal_from_elabid(elabid):
    """
    Calls a proposal item from eLabFTW using their elabid and creates a Proposal-class object.

    Returns the proposal's name (method Proposal.name -> str)
    If the name starts with "Proposal " (space included) that gets omitted from the output.

    Arg: elabid: int    eLabFTW internal id of the selected resource.
    """
    try:
        proposal_data = APIHandler(api_key, ELABFTW_API_URL).get_entry_from_elabid(
            elabid, entryType="items"
        )
        proposal_category = proposal_data.get("category_title")
        # TO-DO: correct this typo on elabftw: Subtrate → Substrate.
        if (
            "Proposal" not in proposal_category
        ):  # to avoid that same old problem with trailing spaces
            print(f"Category of the resource: {proposal_category}.")
            raise ValueError(
                f"The referenced resource (elabid = {elabid}) is not a proposal."
            )
        else:
            proposal = Proposal(proposal_data)
    except ConnectionError as e:
        raise ConnectionError(e)
    return proposal.name  # String


def chain_entrypoint_to_batch(sample_object):
    """
    Takes an Entrypoint-class object, looks at its .batch_elabid attribute and retrieves data on the substrate batch associated to the starting sample.
    Returns a Material-class object.

    Arg: sample_object: Entrypoint:     Entrypoint-class object.
    Dependency: call_material_from_elabid.
    """
    material_elabid = sample_object.batch_elabid
    material_object = call_material_from_elabid(material_elabid)
    return material_object


def chain_entrypoint_to_layers(sample_object):
    """
    Takes an Entrypoint-class object, looks at its .linked_experiments_elabid attribute (list) and
    retrieves data on the deposition layers associated to the starting sample.
    Returns a list of Layer-class objects, sorted by progressive layer number (layer_number attribute).

    Arg: sample_object: Entrypoint:     Entrypoint-class object.
    Dependency: call_layers_from_list.
    """
    linked_experiments_elabid = (
        sample_object.linked_experiments_elabid
    )  # list of elabid
    layer_object_list = call_layers_from_list(linked_experiments_elabid)
    layer_object_list.sort(key=lambda x: x.layer_number)
    return layer_object_list


def chain_layer_to_target(layer_object):
    """
    Takes a Layer-class object, looks at its .target_elabid attribute and retrieves data on the PLD target used in the deposition of said layer.
    Returns a Material-class object.

    Arg: layer_object: Layer:   Layer-class object.
    Dependency: call_material_from_elabid.
    """
    target_elabid = layer_object.target_elabid
    material_object = call_material_from_elabid(target_elabid)
    return material_object


def deduplicate_instruments_from_layers(layers):
    """
    For each layer gets the instruments used (laser, depo chamber and RHEED).
    Creates three sets with all the instruments used regardless of the layer they've been used for.
    Turns the sets into three strings (joined with commas), then returns a dictionary in the format:
        {
            "laser_system": "Laser A, Laser B...",
            "deposition_chamber": "DC A, DC B...",
            "rheed_system": RHEED A, RHEED B..."
        }

    Arg: layers: list(Layer):   List of Layer-class objects.
    """
    lasers = []
    chambers = []
    rheeds = []
    elegant_dict = {}
    for lyr in layers:
        instruments = lyr.get_instruments(api_key, ELABFTW_API_URL)
        lasers.append(instruments["laser_system"])
        chambers.append(instruments["deposition_chamber"])
        rheeds.append(instruments["rheed_system"])
        elegant_dict[f"layer_{lyr.layer_number}"] = {
            "laser_system": instruments["laser_system"],
            "deposition_chamber": instruments["deposition_chamber"],
            "rheed_system": instruments["rheed_system"],
        }
    ded_lasers = list(set(lasers))
    ded_chambers = list(set(chambers))
    ded_rheeds = list(set(rheeds))
    elegant_dict["multilayer"] = {
        # Keep key names human readable since they're used in the messages of the following errors
        "laser_system": ", ".join(ded_lasers),
        "deposition_chamber": ", ".join(ded_chambers),
        "rheed_system": ", ".join(ded_rheeds),
    }  # dictionary's name is a joke
    return elegant_dict


def select_rheed_data(layer):
    """
    Takes a Layer-class object and selects the attachments to use to create the RHEED dataset for the NeXus file.

    There are two categories of attachments considered: text-files and pictures.
    The only accepted formats are ".txt", ".csv" and ".tsv" for the first ones, and ".png" or ".bmp" for the others.
    The function is extension-sensitive, and only one attachment for each category will be downloaded.

    If there are more than one attachment for each category, the user is prompted to select one of them from a list.
    If there are no attachments for a category the function will return {} (empty dictionary) for that category.

    Returns the set: (rheed_data_file, rheed_image_file). Both variables are dictionaries in the following format:
        {
            "fullname": real_name (with extension),
            "hashname": long_name (with extension),
            "related_experiment": elabid
        }
    """

    n = layer.layer_number
    textual_uploads = layer.fetch_textual_uploads()
    images = layer.fetch_images()

    # Check for length. Three cases:
    # 1. len is 0, no file of this category → return {}
    # 2. len is more than 1, user must select
    # 3. len is 1, God's in his heaven, all's right with the world
    if len(textual_uploads) == 0:
        rheed_data_file = {}
    elif len(textual_uploads) > 1:
        # prompt user to select from list
        print(f"Attention: Layer {n} contains multiple TEXTUAL attachments.\n")
        print("These are used to populate the 'RHEED intensities' dataset.")
        print("=== USER INTERVENTION REQUIRED ===")
        for id in textual_uploads:
            print(f"{id} - {textual_uploads[id]}")
        ans = None
        while not type(ans) == int or not ans in range(0, len(textual_uploads)):
            ans = (
                input(
                    "Select one of the attachments from the list (0, 1, ...) [default = 0]: "
                )
                or 0
            )
            if ans.isdigit():
                ans = int(ans)
            continue
        rheed_data_file = textual_uploads[ans]  # still a dictionary
    else:
        rheed_data_file = textual_uploads[
            next(iter(textual_uploads))
        ]  # this prism of pork gets the value of the only key in the dictionary
        # it's proof like no other that my code is human-generated, and that I suck at coding. It's hubris manifest.

    # As above so below
    if len(images) == 0:
        rheed_image_file = {}
    elif len(images) > 1:
        # prompt user to select from list
        print(f"Attention: Layer {n} contains multiple PNG/BMP attachments.\n")
        print("These are used to create the RHEED heatmap.")
        print("=== USER INTERVENTION REQUIRED ===")
        for id in images:
            print(f"{id} - {images[id]}")
        ans = None
        while not type(ans) == int or not ans in range(0, len(images)):
            ans = (
                input(
                    "Select one of the attachments from the list (0, 1, ...) [default = 0]: "
                )
                or 0
            )
            if ans.isdigit():
                ans = int(ans)
            continue
        rheed_image_file = images[ans]  # still a dictionary
    else:
        rheed_image_file = images[next(iter(images))]

    return (rheed_data_file, rheed_image_file)


def analyse_rheed_data(data):
    """
    Takes the content of a tsv file and returns a dictionary with timestamps and intensities.
    The file should contain a 2D array composed of 4 columns - where the first column is a timestamp and the other three are RHEED intensities - and an unspecified number of rows.

    -----
    Time    Layer1_Int1     Layer1_Int2     Layer1_Int3
    -----

    Exceptions:
        1. Distinct ValueErrors are raised if:
            * The array is not 2-dimensional;
            * The total number of columns does not equate at least 1+3 (= 4).
        2. If the file has more than 4 columns the function prints a warning, then ignores the other columns.
        3. No exception is made for files where the first column is not the time, or the others are not intensities.

    Time is expressed in seconds, intensities are adimensional on 8 bits (min. 0, max. 255).

    Written with help from DeepSeek.
    """
    # Verifying the format of the input file:
    if data.ndim != 2:
        raise ValueError(
            f"Unexpected trace format: expected 2D array, got ndim = {data.ndim}."
        )
    n_cols = data.shape[1]  # 0 = rows, 1 = columns
    if n_cols > 4:
        print(
            f"Warning! The input file (for Realtime Window Analysis) has {n_cols - 4} more than needed.\nOnly 4 columns will be considered - with the first representing time and the others representing RHEED intensities."
        )
    if n_cols < 4:
        raise ValueError(
            f"Insufficient number of columns: expected 4, got n_cols = {n_cols}."
        )
    # n_time_points = data.shape[0]

    # Get time (all rows of col 0) as Float64:
    time = data[:, 0].astype(
        np.float64, copy=False
    )  # copy=False suggested by LLM for mem. eff.

    # Get intensities (all rows of cols 1,2,3) as Float32:
    intensities = data[:, 1:4].astype(np.float32, copy=False)

    return {
        "time": np.transpose(time),
        "intensity": np.transpose(intensities),
    }


def make_nexus_schema_dictionary(substrate_object, layers):
    """
    Main function, takes all the other functions to reconstruct the full dataset.
    Takes a Substrate-class object (output of the chain_entrypoint_to_batch() function),
    and a list of Layer-class objects (output of the chain_entrypoint_to_layers() function).

    Returns dictionary with the same schema as the NeXus standard for PLD fabrications.

    Args:
        substrate_object: Substrate:    Substrate-class object.
        layers: list(Layer):            List of Layer-class objects.
    """
    instruments = deduplicate_instruments_from_layers(layers)
    pld_fabrication = {
        "sample": {
            "substrate": {
                "name": substrate_object.name,
                "chemical_formula": substrate_object.get_compound_formula(
                    api_key, ELABFTW_API_URL
                ),
                "orientation": substrate_object.orientation,
                "miscut_angle": {
                    "value": substrate_object.miscut_angle,
                    "units": substrate_object.miscut_angle_unit,
                },
                "miscut_direction": substrate_object.miscut_direction,
                "thickness": {
                    "value": substrate_object.thickness,
                    "units": substrate_object.thickness_unit,
                },
                "dimensions": substrate_object.dimensions,
                "surface_treatment": substrate_object.surface_treatment,
                "manufacturer": substrate_object.manufacturer,
                "batch_id": substrate_object.batch_id,
            },
            "multilayer": {},
        },
        "instruments_used": instruments["multilayer"],
        "rheed_data": {},
    }
    multilayer = pld_fabrication["sample"]["multilayer"]
    rheed_data = pld_fabrication["rheed_data"]
    for layer in layers:
        name = "layer_" + layer.layer_number
        target_object = chain_layer_to_target(layer)
        target_dict = {
            "name": target_object.name,
            "chemical_formula": target_object.get_compound_formula(
                api_key, ELABFTW_API_URL
            ),
            "description": target_object.description,
            "shape": target_object.shape,
            "dimensions": target_object.dimensions,
            "thickness": {
                "value": target_object.thickness,
                "units": target_object.thickness_unit,
            },
            "solid_form": target_object.solid_form,
            "manufacturer": target_object.manufacturer,
            "batch_id": target_object.name,
            # TO-DO: currently not available:
        }
        multilayer[name] = {
            "target": target_dict,
            "start_time": layer.start_time,
            "operator": layer.operator,
            "description": layer.description,
            "number_of_pulses": layer.number_of_pulses,
            "deposition_time": {
                "value": layer.deposition_time,
                "units": layer.deposition_time_unit,
            },
            "temperature": {
                "value": layer.temperature,
                "units": layer.temperature_unit,
            },
            "heating_method": layer.heating_method,
            "layer_thickness": {
                "value": layer.layer_thickness,
                "units": layer.layer_thickness_unit,
            },
            "buffer_gas": layer.buffer_gas,
            "process_pressure": {
                "value": layer.process_pressure,
                "units": layer.process_pressure_unit,
            },
            "heater_target_distance": {
                "value": layer.heater_target_distance,
                "units": layer.heater_target_distance_unit,
            },
            "repetition_rate": {
                "value": layer.repetition_rate,
                "units": layer.repetition_rate_unit,
            },
            "laser_fluence": {
                "value": layer.laser_fluence,
                "units": layer.laser_fluence_unit,
            },
            "laser_spot_area": {
                "value": layer.laser_spot_area,
                "units": layer.laser_spot_area_unit,
            },
            "laser_energy": {
                "value": layer.laser_energy,
                "units": layer.laser_energy_unit,
            },
            "laser_rastering": {
                "geometry": layer.laser_rastering_geometry,
                "positions": layer.laser_rastering_positions,
                "velocities": layer.laser_rastering_velocities,
            },
            "pre_annealing": {
                "ambient_gas": layer.pre_annealing_ambient_gas,
                "pressure": {
                    "value": layer.pre_annealing_pressure,
                    "units": layer.pre_annealing_pressure_unit,
                },
                "temperature": {
                    "value": layer.pre_annealing_temperature,
                    "units": layer.pre_annealing_temperature_unit,
                },
                "duration": {
                    "value": layer.pre_annealing_duration,
                    "units": layer.pre_annealing_duration_unit,
                },
            },
            "post_annealing": {
                "ambient_gas": layer.post_annealing_ambient_gas,
                "pressure": {
                    "value": layer.post_annealing_pressure,
                    "units": layer.post_annealing_pressure_unit,
                },
                "temperature": {
                    "value": layer.post_annealing_temperature,
                    "units": layer.post_annealing_temperature_unit,
                },
                "duration": {
                    "value": layer.post_annealing_duration,
                    "units": layer.post_annealing_duration_unit,
                },
            },
            "instruments_used": instruments[name],
        }
        rheed_data[name] = {
            "layer_number": layer.layer_number,
            "data": select_rheed_data(
                layer
            ),  # tuple: (rheed_data_file, rheed_image_file)
        }
    return pld_fabrication


def build_nexus_file(pld_fabrication, output_path="output/nffa-di_unnamed.h5"):
    """
    The function which actually builds the NeXus file for *PLD DEPOSITIONS*.
    Saves the file in the specified directory.

    Args:
        pld_fabrication:    A dictionary with a specific schema, one only the function
                            make_nexus_schema_dictionary should make.
        output_path:        The full path to the output file, including filename complete with extension.
                            It's a string, which should be produced with os.path.
                            Default value is: "output/nffa-di_unnamed.h5" - which is NOT NFFA-DI compliant.
    """
    # NOTE: look at the mail attachment from Emiliano...
    with h5py.File(output_path, "w") as f:
        nx_pld_entry = f.create_group("pld_fabrication")
        nx_pld_entry.attrs["NX_class"] = "NXentry"

        # Sample section
        nx_sample = nx_pld_entry.create_group("sample")
        nx_sample.attrs["NX_class"] = "NXsample"
        sample_dict = pld_fabrication["sample"]

        # Substrate sub-section
        nx_substrate = nx_sample.create_group("substrate")
        nx_substrate.attrs["NX_class"] = "NXsubentry"
        substrate_dict = sample_dict["substrate"]
        try:
            # Substrate fields (datasets)
            nx_substrate.create_dataset("name", data=substrate_dict["name"])
            nx_substrate.create_dataset(
                "chemical_formula", data=substrate_dict["chemical_formula"]
            )
            nx_substrate.create_dataset(
                "orientation", data=substrate_dict["orientation"]
            )
            nx_substrate.create_dataset(
                "miscut_angle", data=substrate_dict["miscut_angle"]["value"]
            )  # float
            nx_substrate["miscut_angle"].attrs["units"] = substrate_dict[
                "miscut_angle"
            ]["units"]
            nx_substrate.create_dataset(
                "miscut_direction", data=substrate_dict["miscut_direction"]
            )
            nx_substrate.create_dataset(
                "thickness", data=substrate_dict["thickness"]["value"]
            )  # float/int
            nx_substrate["thickness"].attrs["units"] = substrate_dict["thickness"][
                "units"
            ]
            nx_substrate.create_dataset("dimensions", data=substrate_dict["dimensions"])
            nx_substrate.create_dataset(
                "surface_treatment", data=substrate_dict["surface_treatment"]
            )
            nx_substrate.create_dataset(
                "manufacturer", data=substrate_dict["manufacturer"]
            )
            nx_substrate.create_dataset("batch_id", data=substrate_dict["batch_id"])
        except TypeError as te:
            # sooner or later I'll handle this too - not today tho
            raise TypeError(te)

        # Multilayer sub-section
        nx_multilayer = nx_sample.create_group("multilayer")
        nx_multilayer.attrs["NX_class"] = "NXsubentry"
        multilayer_dict = sample_dict["multilayer"]
        # Repeat FOR EACH LAYER:
        for layer in multilayer_dict:
            nx_layer = nx_multilayer.create_group(layer)
            nx_layer.attrs["NX_class"] = "NXsubentry"
            layer_dict = multilayer_dict[layer]
            # Sub-groups of a layer
            ## Target
            nx_target = nx_layer.create_group("target")
            nx_target.attrs["NX_class"] = "NXsample"
            target_dict = layer_dict["target"]
            ## Rastering and Annealing
            nx_laser_rastering = nx_layer.create_group("laser_rastering")
            nx_laser_rastering.attrs["NX_class"] = "NXprocess"
            rastering_dict = layer_dict["laser_rastering"]
            nx_pre_annealing = nx_layer.create_group("pre_annealing")
            nx_pre_annealing.attrs["NX_class"] = "NXprocess"
            pre_ann_dict = layer_dict["pre_annealing"]
            nx_post_annealing = nx_layer.create_group("post_annealing")
            nx_post_annealing.attrs["NX_class"] = "NXprocess"
            post_ann_dict = layer_dict["post_annealing"]
            nx_layer_instruments = nx_layer.create_group("instruments_used")
            nx_layer_instruments.attrs["NX_class"] = "NXinstrument"
            layer_instruments_dict = layer_dict["instruments_used"]

            ## Target metadata
            try:
                nx_target.create_dataset("name", data=target_dict["name"])
                nx_target.create_dataset(
                    "chemical_formula", data=target_dict["chemical_formula"]
                )
                nx_target.create_dataset("description", data=target_dict["description"])
                nx_target.create_dataset("shape", data=target_dict["shape"])
                nx_target.create_dataset("dimensions", data=target_dict["dimensions"])
                nx_target.create_dataset(
                    "thickness", data=target_dict["thickness"]["value"]
                )  # float/int
                nx_target["thickness"].attrs["units"] = target_dict["thickness"][
                    "units"
                ]
                nx_target.create_dataset("solid_form", data=target_dict["solid_form"])
                nx_target.create_dataset(
                    "manufacturer", data=target_dict["manufacturer"]
                )
                nx_target.create_dataset("batch_id", data=target_dict["batch_id"])
            except TypeError as te:
                raise TypeError(te)
            ## Other layer-specific metadata
            try:
                nx_layer.create_dataset("start_time", data=layer_dict["start_time"])
                nx_layer.create_dataset("operator", data=layer_dict["operator"])
                nx_layer.create_dataset(
                    "number_of_pulses", data=layer_dict["number_of_pulses"]
                )
                nx_layer.create_dataset(
                    "deposition_time", data=layer_dict["deposition_time"]["value"]
                )
                nx_layer["deposition_time"].attrs["units"] = layer_dict[
                    "deposition_time"
                ]["units"]
                nx_layer.create_dataset(
                    "repetition_rate", data=layer_dict["repetition_rate"]["value"]
                )
                nx_layer["repetition_rate"].attrs["units"] = layer_dict[
                    "repetition_rate"
                ]["units"]
                nx_layer.create_dataset(
                    "temperature", data=layer_dict["temperature"]["value"]
                )
                nx_layer["temperature"].attrs["units"] = layer_dict["temperature"][
                    "units"
                ]
                nx_layer.create_dataset(
                    "heating_method", data=layer_dict["heating_method"]
                )
                nx_layer.create_dataset(
                    "layer_thickness", data=layer_dict["layer_thickness"]["value"]
                )
                nx_layer["layer_thickness"].attrs["units"] = layer_dict[
                    "layer_thickness"
                ]["units"]
                nx_layer.create_dataset("buffer_gas", data=layer_dict["buffer_gas"])
                nx_layer.create_dataset(
                    "process_pressure", data=layer_dict["process_pressure"]["value"]
                )
                nx_layer["process_pressure"].attrs["units"] = layer_dict[
                    "process_pressure"
                ]["units"]
                nx_layer.create_dataset(
                    "heater_target_distance",
                    data=layer_dict["heater_target_distance"]["value"],
                )
                nx_layer["heater_target_distance"].attrs["units"] = layer_dict[
                    "heater_target_distance"
                ]["units"]
                nx_layer.create_dataset(
                    "laser_fluence", data=layer_dict["laser_fluence"]["value"]
                )
                nx_layer["laser_fluence"].attrs["units"] = layer_dict["laser_fluence"][
                    "units"
                ]
                nx_layer.create_dataset(
                    "laser_spot_area", data=layer_dict["laser_spot_area"]["value"]
                )
                nx_layer["laser_spot_area"].attrs["units"] = layer_dict[
                    "laser_spot_area"
                ]["units"]
                nx_layer.create_dataset(
                    "laser_energy", data=layer_dict["laser_energy"]["value"]
                )
                nx_layer["laser_energy"].attrs["units"] = layer_dict["laser_energy"][
                    "units"
                ]
            except TypeError as te:
                raise TypeError(te)
            ## Rastering metadata
            try:
                nx_laser_rastering.create_dataset(
                    "geometry", data=rastering_dict["geometry"]
                )
                nx_laser_rastering.create_dataset(
                    "positions", data=rastering_dict["positions"]
                )
                nx_laser_rastering.create_dataset(
                    "velocities", data=rastering_dict["velocities"]
                )
            except TypeError as te:
                raise TypeError(te)
            ## Annealing metadata
            try:
                nx_pre_annealing.create_dataset(
                    "ambient_gas", data=pre_ann_dict["ambient_gas"]
                )
                nx_pre_annealing.create_dataset(
                    "pressure", data=pre_ann_dict["pressure"]["value"]
                )
                nx_pre_annealing["pressure"].attrs["units"] = pre_ann_dict["pressure"][
                    "units"
                ]
                nx_pre_annealing.create_dataset(
                    "temperature", data=pre_ann_dict["temperature"]["value"]
                )
                nx_pre_annealing["temperature"].attrs["units"] = pre_ann_dict[
                    "temperature"
                ]["units"]
                nx_pre_annealing.create_dataset(
                    "duration", data=pre_ann_dict["duration"]["value"]
                )
                nx_pre_annealing["duration"].attrs["units"] = pre_ann_dict["duration"][
                    "units"
                ]
            except TypeError as te:
                raise TypeError(te)
            try:
                nx_post_annealing.create_dataset(
                    "ambient_gas", data=post_ann_dict["ambient_gas"]
                )
                nx_post_annealing.create_dataset(
                    "pressure", data=post_ann_dict["pressure"]["value"]
                )
                nx_post_annealing["pressure"].attrs["units"] = post_ann_dict[
                    "pressure"
                ]["units"]
                nx_post_annealing.create_dataset(
                    "temperature", data=post_ann_dict["temperature"]["value"]
                )
                nx_post_annealing["temperature"].attrs["units"] = post_ann_dict[
                    "temperature"
                ]["units"]
                nx_post_annealing.create_dataset(
                    "duration", data=post_ann_dict["duration"]["value"]
                )
                nx_post_annealing["duration"].attrs["units"] = post_ann_dict[
                    "duration"
                ]["units"]
            except TypeError as te:
                raise TypeError(te)
            try:
                nx_layer_instruments.create_dataset(
                    "laser_system", data=layer_instruments_dict["laser_system"]
                )
                nx_layer_instruments.create_dataset(
                    "deposition_chamber",
                    data=layer_instruments_dict["deposition_chamber"],
                )
                nx_layer_instruments.create_dataset(
                    "rheed_system", data=layer_instruments_dict["rheed_system"]
                )
            except TypeError as te:
                raise TypeError(te)

        # Instruments used section
        nx_instruments = nx_pld_entry.create_group("instruments_used")
        nx_instruments.attrs["NX_class"] = "NXinstrument"
        instruments_dict = pld_fabrication["instruments_used"]
        try:
            nx_instruments.create_dataset(
                "laser_system", data=instruments_dict["laser_system"]
            )
            nx_instruments.create_dataset(
                "deposition_chamber", data=instruments_dict["deposition_chamber"]
            )
            nx_instruments.create_dataset(
                "rheed_system", data=instruments_dict["rheed_system"]
            )
        except TypeError as te:
            raise TypeError(te)

        # RHEED data section
        nx_rheed = nx_pld_entry.create_group("rheed_data")
        nx_rheed.attrs["NX_class"] = "NXdata"

        rheed_data = pld_fabrication["rheed_data"]
        for layer in rheed_data:
            nx_rheed_layer = nx_rheed.create_group(layer)

            layer_dict = rheed_data[layer]
            n = layer_dict["layer_number"]
            rheed_data_file = layer_dict["data"][0]  # first in the tuple
            rheed_image_file = layer_dict["data"][1]  # second in the tuple
            handler = APIHandler(api_key, ELABFTW_API_URL)

            # TO-DO: maybe make a dedicated function???
            data_path = None
            image_path = None

            if rheed_data_file != {}:
                try:
                    elabid = rheed_data_file["related_experiment"]
                    upload_id = rheed_data_file["id"]
                except KeyError as ke:
                    raise KeyError(
                        f"Missing key in your file: {rheed_data_file.get('filename') or '<missing name>'}: {ke}"
                    )
                data_path = handler.download_attachment_to_disk(
                    elabid=elabid, upload_id=upload_id
                )

            if rheed_image_file != {}:
                try:
                    upload_id = rheed_image_file["id"]
                    elabid = rheed_image_file["related_experiment"]
                except KeyError as ke:
                    raise KeyError(
                        f"Missing key in your file: {rheed_data_file.get('filename') or '<missing name>'}: {ke}"
                    )
                image_path = handler.download_attachment_to_disk(
                    elabid=elabid, upload_id=upload_id
                )

            if data_path and os.path.isfile(data_path):
                with open(data_path, "r") as o:
                    osc = np.loadtxt(o, delimiter="\t")
                try:
                    rheed_osc = (
                        analyse_rheed_data(data=osc) or None
                    )  # analyze rheed data first, build the file later
                except ValueError as ve:
                    raise ValueError(
                        f"Error with function analyse_rheed_data. {ve}\nPlease make sure the Realtime Window Analysis file is exactly 4 columns wide - where the first column represents time and the others are RHEED intensities."
                    )
                if rheed_osc is not None:
                    # Time axis (needed?)
                    t_ds = nx_rheed_layer.create_dataset("time", data=rheed_osc["time"])
                    t_ds.attrs["units"] = "s"
                    t_ds.attrs["long_name"] = "Time"

                    # Intensity shape (n_layers, n_timepoints, 3)
                    i_ds = nx_rheed_layer.create_dataset(
                        "intensity", data=rheed_osc["intensity"]
                    )
                    i_ds.attrs["units"] = "a.u."
                    i_ds.attrs["long_name"] = "RHEED Intensity"

                    # NXdata attributes — NeXus 3.x notation
                    nx_rheed_layer.attrs["signal"] = "intensity"
                    nx_rheed_layer.attrs["axes"] = [
                        ".",
                        "time",
                        ".",
                    ]  # only time axis (1) is named
                    nx_rheed_layer.attrs["time_indices"] = np.array([1], dtype=np.int32)

            if image_path and os.path.isfile(image_path):
                img = Image.open(image_path).convert("L")
                heatmap_matrix = np.array(img, dtype=np.uint8)  # or None
                # heatmap_matrix = heatmap_matrix.astype(np.float32) / 255.0  # toggle to normalize matrix values

            if heatmap_matrix is not None:
                heatmap = nx_rheed_layer.create_dataset(
                    "diffraction_image", data=heatmap_matrix
                )
                heatmap.attrs["long_name"] = "Diffraction Image"
                heatmap.attrs["units"] = "a.u."
                heatmap.attrs["interpretation"] = "spectrum"
    return
    # TO-DO: ↓↓↓ comment cleanup ↓↓↓
    #
    # here's what we gon do: (to be read with the voice of Mike from Breaking Bad)
    # 1. rheed_osc and heatmap_matrix are NOT given in input to the function so no need for checking that
    # 2. loop through the layers, each with its elabid and metadata
    # 2a. read said metadata for each layer, print list of txt and png files (dedicated Layer class methods)
    # 2b. prompt the user for file choice (1 text file per layer - in tsv format, 1 picture file - either png [default] or bmp)
    # 2c. download the chosen file
    # 2d. with chosen file do analysis as before
    # 3. the schema should be:
    #   * /rheed_data
    #       * /layer_n
    #           * time (rheed_osc)
    #           * intensity (rheed_osc)
    #           * diffraction_image (heatmap_matrix)
    # first problem is probably finding out how to recover the following meta from the original Layer object:
    #   * Layer.elabid - integer
    #   * Layer.fetch_textual_uploads() - dictionary
    #   * Layer.fetch_images() - dictionary


def main(
    api_key,
    ELABFTW_API_URL,
    elabid,
):
    handler = APIHandler(api_key, ELABFTW_API_URL)
    sample_raw_data = handler.get_entry_from_elabid(elabid)
    sample = Entrypoint(sample_raw_data)
    sample_name = sample.name.strip().replace(
        " ", "-"
    )  # returns error if no "title" or title is not str
    # Less important variables:
    operative_unit = os.getenv("operative_unit") or None
    if operative_unit:
        operative_unit = operative_unit.strip().replace(" ", "-")
    if sample.proposal:
        sample_proposal = call_proposal_from_elabid(sample.proposal)
    else:
        sample_proposal = None
    substrate_object = chain_entrypoint_to_batch(sample)  # Substrate-class object
    layers = chain_entrypoint_to_layers(sample)  # list of Layer-class objects
    # n_layers = len(layers)  # total number of layers on the sample
    fn_base = (
        "nffa-di_"
        + (f"{sample_proposal}_" if sample_proposal else "")
        + (f"{operative_unit}_" if operative_unit else "")
        + "PLD_"
        + sample_name[:9]
    )
    result = make_nexus_schema_dictionary(substrate_object, layers)
    return {"result": result, "fn_base": fn_base}


if __name__ == "__main__":
    load_dotenv()
    api_key = os.getenv("api_key") or getpass("Paste API key here: ", echo_char="*")
    elabid = (
        os.getenv("elabid")
        or input("Enter elabid of your starting sample [default = 1111]: ")
        or 1111
    )
    ELABFTW_API_URL = os.getenv("ELABFTW_API_URL") or input(
        "Enter a valid eLabFTW API URL (ends with '/api/v2)': "
    )

    output = main(
        api_key,
        ELABFTW_API_URL,
        elabid,
    )

    result = output["result"]
    fn_base = output["fn_base"]

    with open(f"output/{fn_base}.json", "w") as f:
        json.dump(result, f, indent=3)

    build_nexus_file(result, output_path=f"output/{fn_base}.nxs")
