"""Reader-Baseclass."""

from __future__ import annotations

import contextlib
import errno
import logging
import math
import os
from datetime import datetime
from itertools import product
from pathlib import Path
from types import MappingProxyType
from typing import TYPE_CHECKING
from typing import Any

import h5py
import numpy as np
import yaml
from pydantic import validate_call
from tqdm import trange
from typing_extensions import Self
from typing_extensions import deprecated

from .config import config
from .data_models.base.calibration import CalibrationPair
from .data_models.base.calibration import CalibrationSeries
from .data_models.base.timezone import local_tz
from .data_models.content.energy_environment import EnergyDType
from .decoder_waveform import Uart

if TYPE_CHECKING:
    from collections.abc import Generator
    from collections.abc import Mapping
    from collections.abc import Sequence
    from types import TracebackType


class Reader:
    """Sequentially Reads shepherd-data from HDF5 file.

    Args:
    ----
        file_path: Path of hdf5 file containing shepherd data with iv-samples, iv-curves or isc&voc
        verbose: more debug-info during usage, 'None' skips the setter

    """

    CHUNK_SAMPLES_N: int = 10_000

    MODE_TO_DTYPE: Mapping[str, Sequence[EnergyDType]] = MappingProxyType(
        {
            "harvester": (
                EnergyDType.ivsample,
                EnergyDType.ivcurve,
                EnergyDType.isc_voc,
            ),
            "emulator": (EnergyDType.ivsample,),
        }
    )

    @validate_call
    def __init__(
        self,
        file_path: Path,
        *,
        verbose: bool = True,
    ) -> None:
        self.file_path: Path = file_path.resolve()

        if not hasattr(self, "_logger"):
            self._logger: logging.Logger = logging.getLogger("SHPCore.Reader")
        if verbose is not None:
            self._logger.setLevel(logging.DEBUG if verbose else logging.INFO)

        if not hasattr(self, "samplerate_sps"):
            self.samplerate_sps: int = config.SAMPLERATE_SPS
        self.sample_interval_ns: int = round(10**9 // self.samplerate_sps)
        self.sample_interval_s: float = 1 / self.samplerate_sps

        self.max_elements: int = 40 * self.samplerate_sps
        # ⤷ per iteration (40s full res, < 200 MB RAM use)

        # init stats
        self.runtime_s: float = 0
        self.samples_n: int = 0
        self.chunks_n: int = 0
        self.file_size: int = 0
        self.data_rate: float = 0

        # open file (if not already done by writer)
        self._reader_opened: bool = False
        if not hasattr(self, "h5file"):
            if not isinstance(self.file_path, Path):
                raise TypeError("Provide a valid Path-Object to Reader!")
            if not (self.file_path.exists() and self.file_path.is_file()):
                raise FileNotFoundError(
                    errno.ENOENT, os.strerror(errno.ENOENT), self.file_path.name
                )

            try:
                self.h5file = h5py.File(self.file_path, "r")  # = readonly
                self._reader_opened = True
            except OSError as _xcp:
                msg = f"Unable to open HDF5-File '{self.file_path.name}'"
                raise TypeError(msg) from _xcp

            if self.is_valid():
                self._logger.debug("File is available now")
            else:
                self._logger.error(
                    "[FileValidation] File is faulty! "
                    "Will try to open but there might be dragons, for '%s'",
                    self.file_path.name,
                )

        if not isinstance(self.h5file, h5py.File):
            msg = (f"Type of opened file is not h5py.File, for {self.file_path.name}",)
            raise TypeError(msg)

        self.ds_time: h5py.Dataset = self.h5file["data"]["time"]
        self.ds_voltage: h5py.Dataset = self.h5file["data"]["voltage"]
        self.ds_current: h5py.Dataset = self.h5file["data"]["current"]

        # retrieve cal-data
        if not hasattr(self, "_cal"):
            cal_dict = CalibrationSeries().model_dump()
            for ds, param in product(["current", "voltage", "time"], ["gain", "offset"]):
                try:
                    cal_dict[ds][param] = self.h5file["data"][ds].attrs[param]
                except KeyError:  # noqa: PERF203
                    self._logger.debug("Cal-Param '%s' for dataset '%s' not found!", param, ds)
            self._cal = CalibrationSeries(**cal_dict)

        self._refresh_file_stats()

        if file_path is not None:
            # file opened by this reader
            self._logger.debug(
                "Reading data from '%s'\n"
                "\t- runtime %.1f s\n"
                "\t- mode = %s\n"
                "\t- window_size = %d\n"
                "\t- size = %.3f MiB\n"
                "\t- rate = %.0f KiB/s",
                self.file_path.name,
                self.runtime_s,
                self.get_mode(),
                self.get_window_samples(),
                self.file_size / 2**20,
                self.data_rate / 2**10,
            )

    def __enter__(self) -> Self:
        return self

    def __exit__(
        self,
        typ: type[BaseException] | None = None,
        exc: BaseException | None = None,
        tb: TracebackType | None = None,
        extra_arg: int = 0,
    ) -> None:
        if self._reader_opened:
            self.h5file.close()

    def __repr__(self) -> str:
        return yaml.safe_dump(
            self.get_metadata(minimal=True), default_flow_style=False, sort_keys=False
        )

    def _refresh_file_stats(self) -> None:
        """Update internal states, helpful after resampling or other changes in data-group."""
        self.h5file.flush()
        self.samples_n = min(
            self.ds_time.shape[0], self.ds_current.shape[0], self.ds_voltage.shape[0]
        )
        duration_raw = (
            (int(self.ds_time[self.samples_n - 1]) - int(self.ds_time[0]))
            if self.samples_n > 0
            else 0
        )
        # above's typecasting prevents overflow in u64-format
        if (self.samples_n > 0) and (duration_raw > 0):
            # this assumes iso-chronous sampling, TODO: not the best choice?
            duration_s = self._cal.time.raw_to_si(duration_raw)
            self.sample_interval_s = duration_s / self.samples_n
            self.sample_interval_ns = round(10**9 * self.sample_interval_s)
            self.samplerate_sps = max(round((self.samples_n - 1) / duration_s), 1)
        self.runtime_s = round(self.samples_n / self.samplerate_sps, 1)
        self.chunks_n = int(self.samples_n // self.CHUNK_SAMPLES_N)
        if isinstance(self.file_path, Path):
            self.file_size = self.file_path.stat().st_size
        else:
            self.file_size = 0
        self.data_rate = self.file_size / self.runtime_s if self.runtime_s > 0 else 0

    def read(
        self,
        start_n: int = 0,
        end_n: int | None = None,
        n_samples_per_chunk: int | None = None,
        *,
        is_raw: bool = False,
        omit_timestamps: bool = False,
    ) -> Generator[tuple, None, None]:
        """Read the specified range of chunks from the hdf5 file.

        Generator - can be configured on first call

        Args:
        ----
            :param start_n: (int) Index of first chunk to be read
            :param end_n: (int) Index of last chunk to be read
            :param n_samples_per_chunk: (int) allows changing
            :param is_raw: (bool) output original data, not transformed to SI-Units
            :param omit_timestamps: (bool) optimize reading if timestamp is never used
        Yields: chunks between start and end (tuple with time, voltage, current)

        """
        if n_samples_per_chunk is None:
            n_samples_per_chunk = self.CHUNK_SAMPLES_N
        end_max = int(self.samples_n // n_samples_per_chunk)
        end_n = end_max if end_n is None else min(end_n, end_max)
        self._logger.debug("Reading chunk %d to %d from source-file", start_n, end_n)
        _raw = is_raw
        _wts = not omit_timestamps

        for i in range(start_n, end_n):
            idx_start = i * n_samples_per_chunk
            idx_end = idx_start + n_samples_per_chunk
            if _raw:
                yield (
                    self.ds_time[idx_start:idx_end] if _wts else None,
                    self.ds_voltage[idx_start:idx_end],
                    self.ds_current[idx_start:idx_end],
                )
            else:
                yield (
                    self._cal.time.raw_to_si(self.ds_time[idx_start:idx_end]) if _wts else None,
                    self._cal.voltage.raw_to_si(self.ds_voltage[idx_start:idx_end]),
                    self._cal.current.raw_to_si(self.ds_current[idx_start:idx_end]),
                )

    @deprecated("use .read() instead")
    def read_buffers(
        self,
        start_n: int = 0,
        end_n: int | None = None,
        n_samples_per_buffer: int | None = None,
        *,
        is_raw: bool = False,
        omit_ts: bool = False,
    ) -> Generator[tuple, None, None]:
        return self.read(
            start_n=start_n,
            end_n=end_n,
            n_samples_per_chunk=n_samples_per_buffer,
            is_raw=is_raw,
            omit_timestamps=omit_ts,
        )

    def get_time_start(self) -> datetime | None:
        if self.samples_n < 1:
            return None
        return datetime.fromtimestamp(self._cal.time.raw_to_si(self.ds_time[0]), tz=local_tz())

    def get_calibration_data(self) -> CalibrationSeries:
        """Read calibration-data from hdf5 file.

        :return: Calibration data as CalibrationSeries object
        """
        return self._cal

    def get_window_samples(self) -> int:
        if "window_samples" in self.h5file["data"].attrs:
            return int(self.h5file["data"].attrs["window_samples"])
        return 0

    def get_mode(self) -> str:
        if "mode" in self.h5file.attrs:
            return self.h5file.attrs["mode"]
        return ""

    def get_config(self) -> dict:
        if "config" in self.h5file["data"].attrs:
            return yaml.safe_load(self.h5file["data"].attrs["config"])
        return {}

    def get_hostname(self) -> str:
        if "hostname" in self.h5file.attrs:
            return self.h5file.attrs["hostname"]
        return "unknown"

    def get_datatype(self) -> EnergyDType | None:
        try:
            if "datatype" in self.h5file["data"].attrs:
                return EnergyDType[self.h5file["data"].attrs["datatype"]]
        except KeyError:
            return None
        except ValueError:
            return None
        else:
            return None

    def get_voltage_step(self) -> float | None:
        """Informs about the voltage step (in volts) used during harvesting the ivcurve.

        Options for figuring out the real step:
        - look into config (if available)
        - analyze recorded data for most often used delta
        - calculate with 'steps_n * (1 + wait_cycles)' (done for calculating window_size)
        """
        voltage_step: float | None = (
            self.get_config().get("virtual_harvester", {}).get("voltage_step_mV", None)
        )
        if voltage_step is not None:  # convert mV to V
            voltage_step = 1e-3 * voltage_step
        if voltage_step is None:
            dsv = self._cal.voltage.raw_to_si(self.ds_voltage[0:2000])
            diffs_np = np.unique(dsv[1:] - dsv[0:-1], return_counts=False)
            diffs_ls = [_e for _e in list(np.array(diffs_np)) if _e > 0]
            # static voltages have 0 steps, so
            if len(diffs_ls) == 0:
                self._logger.warning("Voltage-Step could not be determined from source-material")
                return None  # or is 0 better? that may provoke div0
            voltage_step = min(diffs_ls)
        return voltage_step

    def get_hrv_config(self) -> dict:
        """Essential info for harvester.

        :return: config-dict directly for vHarvester to be used during emulation
        """
        return {
            "datatype": self.get_datatype(),
            "window_samples": self.get_window_samples(),
            "voltage_step_V": self.get_voltage_step(),
        }

    def is_valid(self) -> bool:
        """Check file for plausibility, validity / correctness.

        :return: state of validity
        """
        # hard criteria
        if "data" not in self.h5file:
            self._logger.error(
                "[FileValidation] root data-group not found in '%s'",
                self.file_path.name,
            )
            return False
        for attr in ["mode"]:
            if attr not in self.h5file.attrs:
                self._logger.error(
                    "[FileValidation] attribute '%s' not found in '%s'",
                    attr,
                    self.file_path.name,
                )
                return False
            if self.h5file.attrs["mode"] not in self.MODE_TO_DTYPE:
                self._logger.error(
                    "[FileValidation] unsupported mode '%s' in '%s'",
                    attr,
                    self.file_path.name,
                )
                return False
        for attr in ["window_samples", "datatype"]:
            if attr not in self.h5file["data"].attrs:
                self._logger.error(
                    "[FileValidationError] attribute '%s' not found in data-group in '%s'",
                    attr,
                    self.file_path.name,
                )
                return False
        for dset in ["time", "current", "voltage"]:
            if dset not in self.h5file["data"]:
                self._logger.error(
                    "[FileValidation] dataset '%s' not found in '%s'",
                    dset,
                    self.file_path.name,
                )
                return False
            for attr in ["gain", "offset"]:
                if attr not in self.h5file["data"][dset].attrs:
                    self._logger.error(
                        "[FileValidation] attribute '%s' not found in dataset '%s' in '%s'",
                        attr,
                        dset,
                        self.file_path.name,
                    )
                    return False
        if self.get_datatype() not in self.MODE_TO_DTYPE[self.get_mode()]:
            self._logger.error(
                "[FileValidation] unsupported type '%s' for mode '%s'  in '%s'",
                self.get_datatype(),
                self.get_mode(),
                self.file_path.name,
            )
            return False

        if self.get_datatype() == EnergyDType.ivcurve and self.get_window_samples() < 1:
            self._logger.error(
                "[FileValidation] window size / samples is < 1 "
                "-> invalid for ivcurve-datatype, in '%s'",
                self.file_path.name,
            )
            return False

        # soft-criteria:
        if self.get_datatype() != EnergyDType.ivcurve and self.get_window_samples() > 0:
            self._logger.warning(
                "[FileValidation] window size / samples is > 0 despite "
                "not using the ivcurve-datatype, in '%s'",
                self.file_path.name,
            )
        # same length of datasets:
        samples_n = self.h5file["data"]["time"].shape[0]
        for dset in ["voltage", "current"]:
            ds_size = self.h5file["data"][dset].shape[0]
            if ds_size != samples_n:
                self._logger.warning(
                    "[FileValidation] dataset '%s' has different size (=%d), "
                    "compared to time (=%d), in '%s'",
                    dset,
                    ds_size,
                    samples_n,
                    self.file_path.name,
                )
        # dataset-length should be multiple of chunk-size
        remaining_size = samples_n % self.CHUNK_SAMPLES_N
        if remaining_size != 0:
            self._logger.warning(
                "[FileValidation] datasets are not aligned with chunk-size in '%s'",
                self.file_path.name,
            )
        # check compression
        for dset in ["time", "current", "voltage"]:
            comp = self.h5file["data"][dset].compression
            opts = self.h5file["data"][dset].compression_opts
            if comp not in {None, "gzip", "lzf"}:
                self._logger.warning(
                    "[FileValidation] unsupported compression found "
                    "(%s != None, lzf, gzip) in '%s'",
                    comp,
                    self.file_path.name,
                )
            if (comp == "gzip") and (opts is not None) and (int(opts) > 1):
                self._logger.warning(
                    "[FileValidation] gzip compression is too high (%d > 1) for BBone in '%s'",
                    opts,
                    self.file_path.name,
                )
        # host-name
        if self.get_hostname() == "unknown":
            self._logger.warning(
                "[FileValidation] Hostname was not set in '%s'", self.file_path.name
            )
        # errors during execution
        _err = self.count_errors_in_log()
        if _err > 0:
            self._logger.warning(
                "[FileValidation] Sheep reported %d errors during execution -> check logs in '%s'",
                _err,
                self.file_path.name,
            )
        return True

    def __getitem__(self, key: str) -> Any:
        """Query attribute or (if none found) a handle for a group or dataset (if found).

        :param key: attribute, group, dataset
        :return: value of that key, or handle of object
        """
        if key in self.h5file.attrs:
            return self.h5file.attrs.__getitem__(key)
        if key in self.h5file:
            return self.h5file.__getitem__(key)
        raise KeyError

    def energy(self) -> float:
        """Determine the recorded energy of the trace.

        # multiprocessing: https://stackoverflow.com/a/71898911
        # -> failed with multiprocessing.pool and pathos.multiprocessing.ProcessPool.

        :return: sampled energy in Ws (watt-seconds)
        """
        iterations = math.ceil(self.samples_n / self.max_elements)
        job_iter = trange(
            0,
            self.samples_n,
            self.max_elements,
            desc="energy",
            leave=False,
            disable=iterations < 8,
        )

        def _calc_energy(idx_start: int) -> float:
            idx_stop = min(idx_start + self.max_elements, self.samples_n)
            vol_v = self._cal.voltage.raw_to_si(self.ds_voltage[idx_start:idx_stop])
            cur_a = self._cal.current.raw_to_si(self.ds_current[idx_start:idx_stop])
            return (vol_v[:] * cur_a[:]).sum() * self.sample_interval_s

        energy_ws = [_calc_energy(i) for i in job_iter]
        return float(sum(energy_ws))

    def _dset_statistics(
        self, dset: h5py.Dataset, cal: CalibrationPair | None = None
    ) -> dict[str, float]:
        """Create basic stats for a provided dataset.

        :param dset: dataset to evaluate
        :param cal: calibration (if wanted)
        :return: dict with entries for mean, min, max, std
        """
        si_converted = True
        if not isinstance(cal, CalibrationPair):
            if "gain" in dset.attrs and "offset" in dset.attrs:
                cal = CalibrationPair(gain=dset.attrs["gain"], offset=dset.attrs["offset"])
            else:
                cal = CalibrationPair(gain=1)
                si_converted = False
        iterations = math.ceil(dset.shape[0] / self.max_elements)
        job_iter = trange(
            0,
            dset.shape[0],
            self.max_elements,
            desc=f"{dset.name}-stats",
            leave=False,
            disable=iterations < 8,
        )

        def _calc_statistics(data: np.ndarray) -> list:
            return [np.mean(data), np.min(data), np.max(data), np.std(data)]

        stats_list = [
            _calc_statistics(cal.raw_to_si(dset[i : i + self.max_elements])) for i in job_iter
        ]
        if len(stats_list) < 1:
            return {}
        stats_nd = np.stack(stats_list)
        stats: dict[str, float] = {
            # TODO: wrong calculation for ndim-datasets with n>1
            "mean": float(stats_nd[:, 0].mean()),
            "min": float(stats_nd[:, 1].min()),
            "max": float(stats_nd[:, 2].max()),
            "std": float(stats_nd[:, 3].mean()),  # TODO: sooo wrong :)
            "si_converted": si_converted,
        }
        return stats

    def _data_timediffs(self) -> list[float]:
        """Calculate list of unique time-deltas [s] between chunks.

        Optimized version that only looks at the start of each chunk.
        Timestamps get converted to signed (it still fits > 100 years)
        to allow calculating negative diffs.

        :return: list of (unique) time-deltas between chunks [s]
        """
        iterations = math.ceil(self.samples_n / self.max_elements)
        job_iter = trange(
            0,
            self.samples_n,
            self.max_elements,
            desc="timediff",
            leave=False,
            disable=iterations < 8,
        )

        def calc_timediffs(idx_start: int) -> list:
            ds_time = self.ds_time[
                idx_start : (idx_start + self.max_elements) : self.CHUNK_SAMPLES_N
            ].astype(np.int64)
            diffs_np = np.unique(ds_time[1:] - ds_time[0:-1], return_counts=False)
            return list(np.array(diffs_np))

        diffs_ll = [calc_timediffs(i) for i in job_iter]
        diffs = {
            round(self._cal.time.raw_to_si(j) / self.CHUNK_SAMPLES_N, 6)
            for i in diffs_ll
            for j in i
        }
        return list(diffs)

    def check_timediffs(self) -> bool:
        """Validate equal time-deltas.

        Unexpected time-jumps hint at a corrupted file or faulty measurement.

        :return: True if OK
        """
        diffs = self._data_timediffs()
        if len(diffs) > 1:
            self._logger.warning(
                "Time-jumps detected -> expected equal steps, but got: %s s", diffs
            )
        return (len(diffs) <= 1) and diffs[0] == round(0.1 / self.CHUNK_SAMPLES_N, 6)

    def count_errors_in_log(self, group_name: str = "sheep", min_level: int = 40) -> int:
        if group_name not in self.h5file:
            return 0
        if "level" not in self.h5file[group_name]:
            return 0
        _lvl = self.h5file[group_name]["level"]
        if _lvl.shape[0] < 1:
            return 0
        _items = [1 for _x in _lvl[:] if _x >= min_level]
        return len(_items)

    def get_metadata(
        self,
        node: h5py.Dataset | h5py.Group | None = None,
        *,
        minimal: bool = False,
    ) -> dict[str, dict]:
        """Recursive FN to capture the structure of the file.

        :param node: starting node, leave free to go through whole file
        :param minimal: just provide a bare tree (much faster)
        :return: structure of that node with everything inside it
        """
        if node is None:
            self._refresh_file_stats()
            return self.get_metadata(self.h5file, minimal=minimal)

        metadata: dict[str, dict] = {}
        if isinstance(node, h5py.Dataset) and not minimal:
            metadata["_dataset_info"] = {
                "datatype": str(node.dtype),
                "shape": str(node.shape),
                "chunks": str(node.chunks),
                "compression": str(node.compression),
                "compression_opts": str(node.compression_opts),
            }
            if node.name == "/data/time":
                metadata["_dataset_info"]["time_diffs_s"] = self._data_timediffs()
                # TODO: already convert to str to calm the typechecker?
                #  or construct a pydantic-class
            elif "int" in str(node.dtype):
                metadata["_dataset_info"]["statistics"] = self._dset_statistics(node)
                # TODO: put this into metadata["_dataset_statistics"] ??
        for attr in node.attrs:
            attr_value = node.attrs[attr]
            if isinstance(attr_value, str):
                with contextlib.suppress(yaml.YAMLError):
                    attr_value = yaml.safe_load(attr_value)
            elif "int" in str(type(attr_value)):
                # TODO: why not isinstance? can it be list[int] other complex type?
                attr_value = int(attr_value)
            else:
                attr_value = float(attr_value)
            metadata[attr] = attr_value
        if isinstance(node, h5py.Group):
            if node.name == "/data" and not minimal:
                metadata["_group_info"] = {
                    "energy_Ws": self.energy(),
                    "runtime_s": round(self.runtime_s, 1),
                    "data_rate_KiB_s": round(self.data_rate / 2**10),
                    "file_size_MiB": round(self.file_size / 2**20, 3),
                    "valid": self.is_valid(),
                }
            for item in node:
                metadata[item] = self.get_metadata(node[item], minimal=minimal)

        return metadata

    def save_metadata(self, node: h5py.Dataset | h5py.Group | None = None) -> dict:
        """Get structure of file and dump content to yaml-file with same name as original.

        :param node: starting node, leave free to go through whole file
        :return: structure of that node with everything inside it
        """
        if isinstance(self.file_path, Path):
            yaml_path = Path(self.file_path).resolve().with_suffix(".yaml")
            if yaml_path.exists():
                self._logger.info("File already exists, will skip '%s'", yaml_path.name)
                return {}
            metadata = self.get_metadata(node)  # {"h5root": self.get_metadata(self.h5file)}
            with yaml_path.open("w", encoding="utf-8-sig") as yfd:
                yaml.safe_dump(metadata, yfd, default_flow_style=False, sort_keys=False)
        else:
            metadata = {}
        return metadata

    def get_gpio_pin_num(self, name: str) -> int | None:
        # reverse lookup in a 2D-dict: key1 are pin_num, key2 are descriptor-names
        if "gpio" not in self.h5file:
            return None
        descriptions = yaml.safe_load(self.h5file["gpio"]["value"].attrs["description"])
        for desc_name, desc in descriptions.items():
            if name in desc["name"]:
                return int(desc_name)
        return None

    @staticmethod
    def get_filter_for_redundant_states(data: np.ndarray) -> np.ndarray:
        """Input is 1D state-vector, kep only first from identical & sequential states.

        Algo: create an offset-by-one vector and compare against original.
        """
        if len(data.shape) > 1:
            raise ValueError("Array must be 1D")
        data_1 = np.concatenate(([not data[0]], data[:-1]))
        return data != data_1

    def gpio_to_waveforms(self, name: str | None = None) -> dict:
        waveforms: dict[str, np.ndarray] = {}
        if "gpio" not in self.h5file:
            return waveforms

        gpio_ts = self.h5file["gpio"]["time"]
        gpio_vs = self.h5file["gpio"]["value"]

        if name is None:
            descriptions = yaml.safe_load(self.h5file["gpio"]["value"].attrs["description"])
            pin_dict = {value["name"]: key for key, value in descriptions.items()}
        else:
            pin_dict = {name: self.get_gpio_pin_num(name)}

        for pin_name, pin_num in pin_dict.items():
            gpio_ps = (gpio_vs[:] & (0b1 << pin_num)) > 0
            gpio_f = self.get_filter_for_redundant_states(gpio_ps)
            waveforms[pin_name] = np.column_stack((gpio_ts[gpio_f], gpio_ps[gpio_f]))
            self._logger.debug(
                "GPIO '%s' has %d state-changes (includes initial state)",
                pin_name,
                sum(gpio_f),
            )
        return waveforms

    def waveform_to_csv(self, pin_name: str, pin_wf: np.ndarray, separator: str = ",") -> None:
        path_csv = self.file_path.with_suffix(f".waveform.{pin_name}.csv")
        if path_csv.exists():
            self._logger.info("File already exists, will skip '%s'", path_csv.name)
            return
        with path_csv.open("w") as csv:
            csv.write(f"timestamp [s],{pin_name}\n")
            for row in pin_wf:
                csv.write(f"{row[0] / 1e9}{separator}{int(row[1])}\n")

    def gpio_to_uart(self) -> np.ndarray | None:
        wfs = self.gpio_to_waveforms("uart")
        if len(wfs) < 1:
            return None
        pin_name, pin_wf = wfs.popitem()

        write_to_file = False
        if write_to_file:
            self.waveform_to_csv(pin_name, pin_wf)

        gpio_wf = pin_wf.astype(float)
        gpio_wf[:, 0] = gpio_wf[:, 0] / 1e9

        try:
            return Uart(gpio_wf).get_lines()
        except TypeError:
            self._logger.error("TypeError: Extracting UART from GPIO failed - will skip file.")
            return None
        except ValueError:
            self._logger.error("ValueError: Extracting UART from GPIO failed - will skip file.")
            return None
