"""This module contains the Reporter class, which is used to report data to disk."""

from __future__ import annotations

import datetime
import re
from operator import attrgetter
from typing import TYPE_CHECKING, Any

import geopandas as gpd
import numpy as np
import pandas as pd
import zarr.codecs
import zarr.storage
from dateutil.relativedelta import relativedelta

from geb.module import Module
from geb.store import DynamicArray
from geb.types import TwoDArrayInt32
from geb.workflows.io import fast_rmtree
from geb.workflows.methods import multi_level_merge
from geb.workflows.raster import coord_to_pixel

ZARR_TIME_CHUNK_SIZE = 100

if TYPE_CHECKING:
    from geb.model import GEBModel

WATER_CIRCLE_REPORT_CONFIG = {
    "hydrology": {
        "_water_circle_storage": {
            "varname": ".current_storage",
            "type": "scalar",
        },
        "_water_circle_routing_loss": {
            "varname": ".routing_loss_m3",
            "type": "scalar",
        },
    },
    "hydrology.landsurface": {
        "_water_circle_rain": {
            "varname": ".rain_m",
            "type": "HRU",
            "function": "weightedsum",
        },
        "_water_circle_snow": {
            "varname": ".snow_m",
            "type": "HRU",
            "function": "weightedsum",
        },
        "_water_circle_transpiration": {
            "varname": ".transpiration_m",
            "type": "HRU",
            "function": "weightedsum",
        },
        "_water_circle_bare_soil_evaporation": {
            "varname": ".bare_soil_evaporation_m",
            "type": "HRU",
            "function": "weightedsum",
        },
        "_water_circle_open_water_evaporation": {
            "varname": ".open_water_evaporation_m",
            "type": "HRU",
            "function": "weightedsum",
        },
        "_water_circle_interception_evaporation": {
            "varname": ".interception_evaporation_m",
            "type": "HRU",
            "function": "weightedsum",
        },
        "_water_circle_sublimation_or_deposition": {
            "varname": ".sublimation_or_deposition_m",
            "type": "HRU",
            "function": "weightedsum",
        },
    },
    "hydrology.routing": {
        "_water_circle_river_evaporation": {
            "varname": ".total_evaporation_in_rivers_m3",
            "type": "scalar",
        },
        "_water_circle_waterbody_evaporation": {
            "varname": ".total_waterbody_evaporation_m3",
            "type": "scalar",
        },
        "_water_circle_river_outflow": {
            "varname": ".total_outflow_at_pits_m3",
            "type": "scalar",
        },
    },
    "hydrology.water_demand": {
        "_water_circle_domestic_water_loss": {
            "varname": ".domestic_water_loss_m3",
            "type": "scalar",
        },
        "_water_circle_industry_water_loss": {
            "varname": ".industry_water_loss_m3",
            "type": "scalar",
        },
        "_water_circle_livestock_water_loss": {
            "varname": ".livestock_water_loss_m3",
            "type": "scalar",
        },
    },
}


def create_time_array(
    start: datetime.datetime,
    end: datetime.datetime,
    timestep: datetime.timedelta | relativedelta,
    conf: dict,
) -> list:
    """Create a time array based on the start and end time, the timestep, and the frequency.

    Args:
        start: The start time.
        end: The end time.
        timestep: The timestep length.
        conf: The configuration for the frequency.

    Returns:
        time: The time array.

    Raises:
        ValueError: If the frequency is not recognized.
    """
    if "frequency" not in conf:
        frequency = {"every": "day"}
    else:
        frequency = conf["frequency"]
    if "every" in frequency:
        every = frequency["every"]
        time = []
        current_time = start
        while current_time <= end:
            if every == "year":
                if (
                    frequency["month"] == current_time.month
                    and frequency["day"] == current_time.day
                ):
                    time.append(current_time)
            elif every == "month":
                if frequency["day"] == current_time.day:
                    time.append(current_time)
            elif every == "day":
                time.append(current_time)
            current_time += timestep
        return time
    elif frequency == "initial":
        return [start]
    elif frequency == "final":
        return [end]
    else:
        raise ValueError(f"Frequency {frequency} not recognized.")


class Reporter:
    """This class is used to report data to disk."""

    def __init__(self, model: GEBModel, clean: bool) -> None:
        """The constructor for the Reporter class.

        Loops over the reporter configuration and creates the necessary files and data structures,
        that are then used while the model is run to add data to.

        For full documentation of the report configuration, see the documentation.

        There are also several pre-defined report configurations that can be activated by adding
        special keys to the report configuration. These are:
        - _discharge_stations: if set to True, discharge at all discharge stations is reported.
        - _water_circle: if set to True, a standard set of variables to monitor the water circle is reported.

        Args:
            model: The GEB model instance.
            clean: If True, the report folder is cleaned at the start of the model run.

        Raises:
            ValueError: If the variable type is not recognized.
        """
        self.model = model
        if self.model.simulate_hydrology:
            self.hydrology = model.hydrology
        self.report_folder = self.model.output_folder / "report" / self.model.run_name
        # optionally clean report model at start of run
        if clean and self.report_folder.exists():
            fast_rmtree(self.report_folder)
        self.report_folder.mkdir(parents=True, exist_ok=True)

        self.variables = {}
        self.timesteps = []

        if (
            self.model.mode == "w"
            and "report" in self.model.config
            and self.model.config["report"]
        ):
            self.activated: bool = True

            report_config: dict[str, Any] = self.model.config["report"]

            to_delete: list[str] = []
            for module_name, module_values in list(report_config.items()):
                if module_name.startswith("_"):
                    if module_name == "_discharge_stations":
                        if module_values is True:
                            stations = gpd.read_parquet(
                                self.model.files["geom"][
                                    "discharge/discharge_snapped_locations"
                                ]
                            )

                            station_reporters = {}
                            for station_ID, station_info in stations.iterrows():
                                xy_grid = station_info["snapped_grid_pixel_xy"]
                                station_reporters[
                                    f"discharge_hourly_m3_per_s_{station_ID}"
                                ] = {
                                    "varname": f"grid.var.discharge_m3_s_per_substep",
                                    "type": "grid",
                                    "function": f"sample_xy,{xy_grid[0]},{xy_grid[1]}",
                                    "substeps": 24,
                                }
                            report_config = multi_level_merge(
                                report_config,
                                {"hydrology.routing": station_reporters},
                            )
                    elif module_name == "_water_circle":
                        if module_values is True:
                            report_config = multi_level_merge(
                                report_config,
                                WATER_CIRCLE_REPORT_CONFIG,
                            )
                    else:
                        raise ValueError(
                            f"Module {module_name} is not a valid module for reporting."
                        )

                    to_delete.append(module_name)

            for module_name in to_delete:
                del self.model.config["report"][module_name]

            for module_name, configs in self.model.config["report"].items():
                self.variables[module_name] = {}
                for name, config in configs.items():
                    assert isinstance(config, dict), (
                        f"Configuration for {module_name}.{name} must be a dictionary, but is {type(config)}."
                    )
                    self.variables[module_name][name] = self.create_variable(
                        config, module_name, name
                    )
        else:
            self.activated = False

    def create_variable(
        self, config: dict, module_name: str, name: str
    ) -> list | zarr.storage.LocalStore:
        """This function creates a variable for the reporter.

        For
        configurations without a aggregation function, a zarr file is created. For
        configurations with an aggregation function, the data
        is stored in memory and exported on the final timestep.

        Args:
            config: The configuration for the variable.
            module_name: The name of the module to which the variable belongs.
            name: The name of the variable.

        Returns:
            zarr_store: The zarr store for the variable, or an empty list if the
                variable is a scalar or has an aggregation function.

        Raises:
            ValueError: If the variable type is not recognized.
        """
        if config["type"] == "scalar":
            assert "function" not in config or config["function"] is None, (
                "Scalar variables cannot have a function. "
            )
            return []
        elif config["type"] in ("grid", "HRU"):
            if config["function"] is not None:
                return []
            else:
                if config["type"] == "HRU":
                    raster = self.hydrology.HRU
                else:
                    raster = self.hydrology.grid
                zarr_path = self.report_folder / module_name / (name + ".zarr")
                zarr_path.parent.mkdir(parents=True, exist_ok=True)
                config["path"] = str(zarr_path)

                zarr_store = zarr.storage.LocalStore(zarr_path, read_only=False)
                zarr_group = zarr.open_group(zarr_store, mode="w")

                time = create_time_array(
                    start=self.model.simulation_start,
                    end=self.model.simulation_end,
                    timestep=self.model.timestep_length,
                    conf=config,
                )

                time = (
                    np.array(time, dtype="datetime64[ns]")
                    .astype("datetime64[s]")
                    .astype(np.int64)
                )
                time_group = zarr_group.create_array(
                    "time",
                    shape=time.shape,
                    dtype=time.dtype,
                    dimension_names=["time"],
                )
                time_group[:] = time
                time_group.attrs.update(
                    {
                        "standard_name": "time",
                        "units": "seconds since 1970-01-01T00:00:00",
                        "calendar": "gregorian",
                    }
                )

                y_group = zarr_group.create_array(
                    "y",
                    shape=raster.lat.shape,
                    dtype=raster.lat.dtype,
                    dimension_names=["y"],
                )
                y_group[:] = raster.lat
                y_group.attrs.update(
                    {
                        "standard_name": "latitude",
                        "units": "degrees_north",
                    }
                )

                x_group = zarr_group.create_array(
                    "x",
                    shape=raster.lon.shape,
                    dtype=raster.lon.dtype,
                    dimension_names=["x"],
                )
                x_group[:] = raster.lon
                x_group.attrs.update(
                    {
                        "standard_name": "longitude",
                        "units": "degrees_east",
                    }
                )

                zarr_data = zarr_group.create_array(
                    name,
                    shape=(
                        time.size,
                        raster.lat.size,
                        raster.lon.size,
                    ),
                    chunks=(
                        min(ZARR_TIME_CHUNK_SIZE, time.size),
                        raster.lat.size,
                        raster.lon.size,
                    ),
                    dtype=np.float32,
                    compressors=(
                        zarr.codecs.BloscCodec(
                            cname="zlib",
                            clevel=9,
                            shuffle=zarr.codecs.BloscShuffle.shuffle,
                        ),
                    ),
                    fill_value=np.nan,
                    dimension_names=["time", "y", "x"],
                )

                crs = raster.crs
                assert isinstance(crs, str)

                zarr_data.attrs.update(
                    {
                        "grid_mapping": "crs",
                        "coordinates": "time y x",
                        "units": "unknown",
                        "long_name": name,
                        "_CRS": {"wkt": crs},
                    }
                )
                config["_buffers"] = []
                config["_store"] = zarr_store
                return zarr_store

        elif config["type"] == "agents":
            if config["function"] is not None:
                return []
            else:
                filepath = zarr_path = (
                    self.report_folder / module_name / (name + ".zarr")
                )
                filepath.parent.mkdir(parents=True, exist_ok=True)

                store = zarr.storage.LocalStore(filepath, read_only=False)
                zarr_group = zarr.open_group(store, mode="w")

                time = create_time_array(
                    start=self.model.simulation_start,
                    end=self.model.simulation_end,
                    timestep=self.model.timestep_length,
                    conf=config,
                )
                time = np.array(
                    [np.datetime64(t, "s").astype(np.int64) for t in time],
                    dtype=np.int64,
                )

                time_group = zarr_group.create_array(
                    "time",
                    shape=time.shape,
                    dtype=time.dtype,
                    dimension_names=["time"],
                )
                time_group[:] = time

                time_group.attrs.update(
                    {
                        "standard_name": "time",
                        "units": "seconds since 1970-01-01T00:00:00",
                        "calendar": "gregorian",
                    }
                )

                config["_file"] = zarr_group
                config["_time_index"] = time
                config["_buffers"] = []
                config["_store"] = store

                return store

        else:
            raise ValueError(
                f"Type {config['type']} not recognized. Must be 'scalar', 'grid', 'agents' or 'HRU'."
            )

    def maybe_report_value(
        self,
        module_name: str,
        name: str,
        module: Any,
        local_variables: dict,
        config: dict,
    ) -> None:
        """This method is used to save and/or export model values.

        Args:
            module_name: Name of the module to which the value belongs.
            name: Name of the value to be exported.
            module: The module to report data from.
            local_variables: A dictionary of local variables from the function
                that calls this one.
            config: Configuration for saving the file. Contains options such a file format, and whether to export the data or save the data in the model.

        Raises:
            ValueError: If the frequency is not recognized.
            KeyError: If the variable is not found in the local variables or module attributes.
            AttributeError: If the attribute is not found in the module.
        """
        # here we return None if the value is not to be reported on this timestep
        if "frequency" in config:
            if config["frequency"] == "initial":
                if self.model.current_timestep != 0:
                    return None
            elif config["frequency"] == "final":
                if self.model.current_timestep != self.model.n_timesteps - 1:
                    return None
            elif "every" in config["frequency"]:
                every = config["frequency"]["every"]
                if every == "year":
                    month = config["frequency"]["month"]
                    day = config["frequency"]["day"]
                    if (
                        self.model.current_time.month != month
                        or self.model.current_time.day != day
                    ):
                        return None
                elif every == "month":
                    day = config["frequency"]["day"]
                    if self.model.current_time.day != day:
                        return None
                elif every == "day":
                    pass
                else:
                    raise ValueError(
                        f"Frequency every {config['every']} not recognized (must be 'yearly', or 'monthly')."
                    )
            else:
                raise ValueError(f"Frequency {config['frequency']} not recognized.")

        varname = config["varname"]
        fancy_index = re.search(r"\[.*?\]", varname)
        if fancy_index:
            fancy_index = fancy_index.group(0)
            varname = varname.replace(fancy_index, "")

        # get the variable
        if varname.startswith("."):
            varname = varname[1:]
            try:
                value = local_variables[varname]
            except KeyError:
                raise KeyError(
                    f"Variable {varname} not found in local variables of {module_name}. "
                )
        else:
            try:
                value = attrgetter(varname)(module)
            except AttributeError:
                raise AttributeError(
                    f"Attribute {varname} not found in module {module_name}. "
                )

        if fancy_index:
            value = eval(f"value{fancy_index}")

        # if the value is not None, we check whether the value is valid
        if isinstance(value, list):
            value = np.array([v.item() for v in value])
            for v in value:
                assert not np.isnan(value) and not np.isinf(v)
        elif np.isscalar(value):
            value = value.item()
            assert not np.isnan(value) and not np.isinf(value)

        self.process_value(module_name, name, value, config)

    def process_value(
        self,
        module_name: str,
        name: str,
        value: np.ndarray | int | float | np.floating | np.integer,
        config: dict,
    ) -> None:
        """Exports an array of values to the export folder.

        Args:
            module_name: Name of the module to which the value belongs.
            name: Name of the value to be exported.
            value: The array itself.
            config: Configuration for saving the file. Contains options such a file format, and whether to export the array in this timestep at all.

        Raises:
            ValueError: If the function is not recognized,
                or if the variable type is not recognized.
            IndexError: If the coordinate is in sample_lon_lat is outside the model domain.
        """
        type_: str | None = config.get("type", None)
        if type_ is None:
            raise ValueError(f"Type not specified for {config}.")
        if type_ in ("grid", "HRU"):
            if not isinstance(value, np.ndarray):
                raise ValueError(
                    f"Value for {module_name}.{name} must be a numpy ndarray, but is {type(value)}."
                )

            grid_size = self.hydrology.grid.compressed_size
            HRU_size = self.hydrology.HRU.compressed_size
            if type_ == "grid" and value.shape[-1] != grid_size:
                error = f"Value for {module_name}.{name} has size {value.shape[-1]}, but grid size is {grid_size}."
                if value.shape[-1] == HRU_size:
                    error += f" HRU is size {HRU_size}. Did you mean to set type to 'HRU' instead of 'grid'?"
                raise ValueError(error)

            if type_ == "HRU" and value.shape[-1] != HRU_size:
                error = f"Value for {module_name}.{name} has size {value.shape[-1]}, but HRU size is {HRU_size}."
                if value.shape[-1] == grid_size:
                    error += f" grid id size {grid_size}. Did you mean to set type to 'grid' instead of 'HRU'?"
                raise ValueError(error)

            if config["function"] is None:
                if type_ == "HRU":
                    value: np.ndarray = self.hydrology.HRU.decompress(value)
                else:
                    value: np.ndarray = self.hydrology.grid.decompress(value)

                zarr_group: zarr.Group = zarr.open_group(
                    self.variables[module_name][name]
                )
                time_array = zarr_group.get("time")
                assert isinstance(time_array, zarr.Array), (
                    "time_array must be a zarr.Array"
                )
                time_array = time_array[:]
                if (
                    np.isin(self.model.current_time_unix_s, time_array)
                    and value is not None
                ):
                    time_index = np.where(time_array == self.model.current_time_unix_s)[
                        0
                    ].item()
                    if "substeps" in config:
                        time_index_start = np.where(time_index)[0][0]
                        time_index_end = time_index_start + config["substeps"]
                        data_array = zarr_group[name]
                        assert isinstance(data_array, zarr.Array), (
                            f"{name} must be a zarr.Array"
                        )
                        # Write data to zarr array
                        data_array[time_index_start:time_index_end, ...] = value
                    else:
                        # Batch write
                        config["_buffers"].append((time_index, value))
                        if len(config["_buffers"]) == ZARR_TIME_CHUNK_SIZE:
                            self._flush_buffer_grid_hru(config, name)
                return None
            else:
                function, *args = config["function"].split(",")
                if function == "mean":
                    value = np.mean(value)
                elif function == "nanmean":
                    value = np.nanmean(value)
                elif function == "sum":
                    value = np.sum(value)
                elif function == "nansum":
                    value = np.nansum(value)
                elif function in ("sample_xy", "sample_lonlat"):
                    # for sample_xy, args are pixel coordinates, which we
                    # first need to convert to their pixel index in x and y
                    if function == "sample_lonlat":
                        if type_ == "grid":
                            gt: tuple[float, float, float, float, float, float] = (
                                self.model.hydrology.grid.gt
                            )
                        elif type_ == "HRU":
                            gt: tuple[float, float, float, float, float, float] = (
                                self.hydrology.HRU.gt
                            )
                        else:
                            raise ValueError(
                                f"Unknown varname type {config['varname']}"
                            )
                        px, py = coord_to_pixel((float(args[0]), float(args[1])), gt)
                    else:
                        # for sample_xy, args are pixel indices
                        px: int = int(args[0])
                        py: int = int(args[1])

                    # both for the grid and HRU, the data is stored efficiently, so that
                    # all data is in a 1D array, and we need to map the pixel coordinates
                    # to the index in that 1D array
                    # therefore, we first get the linear mapping and use that to get the index
                    # then we extract the value at that index
                    if type_ == "grid":
                        linear_mapping: TwoDArrayInt32 = (
                            self.hydrology.grid.linear_mapping
                        )
                    elif type_ == "HRU":
                        linear_mapping: TwoDArrayInt32 = (
                            self.hydrology.HRU.linear_mapping
                        )
                    else:
                        raise ValueError(f"Unknown varname type {config['varname']}")

                    try:
                        idx = linear_mapping[py, px]
                    except IndexError:
                        raise IndexError(
                            f"Coordinate ({px}, {py}) is outside the model domain, which has shape {linear_mapping.shape}."
                        )
                    if idx == -1:
                        raise IndexError(
                            f"Coordinate ({px}, {py}) is not a valid cell."
                        )

                    # extract the value at that index
                    value = value[..., idx]
                elif function in (
                    "weightedmean",
                    "weightednanmean",
                    "weightedsum",
                    "weightednansum",
                ):
                    if type_ == "HRU":
                        cell_area = self.hydrology.HRU.var.cell_area
                    else:
                        cell_area = self.hydrology.grid.var.cell_area
                    if function == "weightedmean":
                        value = np.average(value, weights=cell_area)
                    elif function == "weightednanmean":
                        value = np.nansum(value * cell_area) / np.sum(cell_area)
                    elif function == "weightedsum":
                        value = np.sum(value * cell_area)
                    elif function == "weightednansum":
                        value = np.nansum(value * cell_area)

                else:
                    raise ValueError(f"Function {function} not recognized")

        elif type_ == "agents":
            if config["function"] is None:
                ds = config["_file"]
                if name not in ds:
                    # zarr file has not been created yet
                    if isinstance(value, (float, int)):
                        shape = (ds["time"].size,)
                        chunks = (min(ZARR_TIME_CHUNK_SIZE, ds["time"].size),)
                        compressor = None
                        dtype = type(value)
                        array_dimensions = ["time"]
                    else:
                        shape = (ds["time"].size, value.size)
                        chunks = (
                            min(ZARR_TIME_CHUNK_SIZE, ds["time"].size),
                            value.size,
                        )
                        compressor = zarr.codecs.BloscCodec(
                            cname="zlib",
                            clevel=9,
                            shuffle=zarr.codecs.BloscShuffle.shuffle,
                        )
                        dtype = value.dtype
                        array_dimensions = ["time", "agents"]
                    if dtype in (float, np.float32, np.float64):
                        fill_value = np.nan
                    elif dtype in (int, np.int32, np.int64):
                        fill_value = -1
                    elif dtype == bool:
                        fill_value = False
                    else:
                        raise ValueError(
                            f"Value {dtype} of type {type(dtype)} not recognized."
                        )
                    ds.create_array(
                        name,
                        shape=shape,
                        chunks=chunks,
                        dtype=dtype,
                        compressors=(compressor,),
                        fill_value=fill_value,
                        dimension_names=array_dimensions,
                    )
                index = np.argwhere(
                    config["_time_index"]
                    == np.datetime64(self.model.current_time, "s").astype(
                        config["_time_index"].dtype
                    )
                ).item()
                assert isinstance(value, (np.ndarray, DynamicArray))
                if value.size < ds[name][index].size:
                    print("Padding array with NaNs or -1 - temporary solution")
                    value = np.pad(
                        value.data if isinstance(value, DynamicArray) else value,
                        (0, ds[name][index].size - value.size),
                        mode="constant",
                        constant_values=np.nan
                        if value.dtype in (float, np.float32, np.float64)
                        else -1,
                    )
                # Batch write
                config["_buffers"].append((index, value))
                if len(config["_buffers"]) == ZARR_TIME_CHUNK_SIZE:
                    self._flush_buffer_agents(config, name)
                return None
            else:
                function, *args = config["function"].split(",")
                if function == "mean":
                    value = np.mean(value)
                elif function == "nanmean":
                    value = np.nanmean(value)
                elif function == "sum":
                    value = np.sum(value)
                elif function == "nansum":
                    value = np.nansum(value)
                else:
                    raise ValueError(f"Function {function} not recognized")

        if module_name not in self.variables:
            self.variables[module_name] = {}

        if name not in self.variables[module_name]:
            self.variables[module_name][name] = []

        if "substeps" in config:
            assert isinstance(value, np.ndarray)
            assert len(value) == config["substeps"], (
                f"Value for {module_name}.{name} has length {len(value)}, but {config['substeps']} substeps are expected."
            )
            self.variables[module_name][name].extend(
                [
                    (
                        self.model.current_time
                        + i * self.model.timestep_length / config["substeps"],
                        v,
                    )
                    for i, v in enumerate(value)
                ]
            )
        else:
            self.variables[module_name][name].append((self.model.current_time, value))

    def _flush_buffer_grid_hru(self, config: dict, name: str) -> None:
        """Flush the buffer for grid/HRU variables.

        Args:
            config: The configuration dictionary for the variable.
            name: The name of the variable.
        """
        buffer = config["_buffers"]
        zarr_group = zarr.open_group(config["_store"])
        time_indices = np.array([t for t, v in buffer])
        values = np.stack([v for t, v in buffer])
        zarr_group[name][time_indices] = values
        config["_buffers"] = []

    def _flush_buffer_agents(self, config: dict, name: str) -> None:
        """Flush the buffer for agents variables.

        Args:
            config: The configuration dictionary for the variable.
            name: The name of the variable.
        """
        buffer = config["_buffers"]
        zarr_group = zarr.open_group(config["_store"])
        time_indices = np.array([t for t, v in buffer])
        values = np.stack([v for t, v in buffer])
        zarr_group[name][time_indices] = values
        config["_buffers"] = []

    def finalize(self) -> None:
        """At the end of the model run, all previously collected data is reported to disk."""
        # Flush any remaining buffers
        for module_name, configs in self.model.config["report"].items():
            for name, config in configs.items():
                if "_buffers" in config and config["_buffers"]:
                    if config["type"] in ("grid", "HRU"):
                        self._flush_buffer_grid_hru(config, name)
                    elif config["type"] == "agents":
                        self._flush_buffer_agents(config, name)
        for module_name, variables in self.variables.items():
            for name, values in variables.items():
                if self.model.config["report"][module_name][name][
                    "type"
                ] == "scalar" or (
                    self.model.config["report"][module_name][name]["function"]
                    is not None
                ):
                    # if the variable is a scalar or has an aggregation function, we report
                    df = pd.DataFrame.from_records(
                        values, columns=["date", name], index="date"
                    )

                    folder = self.report_folder / module_name
                    folder.mkdir(parents=True, exist_ok=True)

                    df.to_csv(folder / (name + ".csv"))

    def report(
        self, module: Module, local_variables: dict[str, Any], module_name: str
    ) -> None:
        """This method is in every step function to report data to disk.

        Args:
            module: The module to report data from.
            local_variables: A dictionary of local variables from the function
                that calls this one.
            module_name: The name of the module.
        """
        if not self.activated:
            return None
        report = self.model.config["report"].get(module_name, None)
        if report is not None:
            for name, config in report.items():
                self.maybe_report_value(
                    module_name=module_name,
                    name=name,
                    module=module,
                    local_variables=local_variables,
                    config=config,
                )
