"""Define custom classes for TopoStats."""

from __future__ import annotations

import logging
import re
from pathlib import Path
from typing import Any

import numpy as np
import numpy.typing as npt
from pydantic import ConfigDict
from pydantic.dataclasses import dataclass

from topostats.logs.logs import LOGGER_NAME
from topostats.statistics import image_statistics
from topostats.utils import update_background_class

LOGGER = logging.getLogger(LOGGER_NAME)

# pylint: disable=too-many-instance-attributes
# pylint: disable=too-many-arguments
# pylint: disable=too-many-positional-arguments
# pylint: disable=too-many-instance-attributes
# pylint: disable=too-many-lines
# F811 - redefined-while-unused : we disable this as we want to have getter/setter methods for each attribute
# ruff: noqa: F811


class GrainCrop:
    """
    Class for storing the crops of grains.

    Parameters
    ----------
    image : npt.NDArray[np.float32]
        2-D Numpy array of the cropped image.
    mask : npt.NDArray[np.bool_]
        3-D Numpy tensor of the cropped mask.
    padding : int
        Padding added to the bounding box of the grain during cropping.
    bbox : tuple[int, int, int, int]
        Bounding box of the crop including padding.
    pixel_to_nm_scaling : float
        Pixel to nanometre scaling factor for the crop.
    thresholds : list[float]
        Thresholds used to find the grain.
    filename : str
        Filename of the image from which the crop was taken.
    threshold : str
        Direction of the molecule from the threshold (above / below).
    skeleton : npt.NDArray[np.bool_]
        3-D Numpy tensor of the skeletonised mask.
    convolved_skeleton : npt.NDArray[np.int32] | None = None
        2-D Numpy array of the convolved skeleton.
    height_profiles : dict[int, dict[int, npt.NDArray[np.float32]]] | None
        Nested dictionary  height profiles.
    stats : dict[int, dict[int, Any]] | None
        Dictionary of grain statistics.
    disordered_trace : DisorderedTrace
        A disordered trace for the grain.
    nodes : dict[str, Node]
        Dictionary of grain nodes.
    ordered_trace : OrderedTrace
        An ordered trace for the grain.
    threshold_method : str
        Threshold method used to find grains.
    """

    def __init__(  # pylint: disable=too-many-locals
        self,
        image: npt.NDArray[np.float32],
        mask: npt.NDArray[np.bool_],
        padding: int,
        bbox: tuple[int, int, int, int],
        pixel_to_nm_scaling: float,
        thresholds: list[float],
        filename: str,
        threshold: str | None = None,
        skeleton: npt.NDArray[np.bool_] | None = None,
        convolved_skeleton: npt.NDArray[np.int32] | None = None,
        height_profiles: dict[int, dict[int, npt.NDArray[np.float32]]] | None = None,
        stats: dict[int, dict[int, Any]] | None = None,
        disordered_trace: DisorderedTrace | None = None,
        nodes: dict[str, Node] | None = None,
        ordered_trace: OrderedTrace | None = None,
        threshold_method: str | None = None,
    ):
        """
        Initialise the class.

        Parameters
        ----------
        image : npt.NDArray[np.float32]
            2-D Numpy array of the cropped image.
        mask : npt.NDArray[np.bool_]
            3-D Numpy tensor of the cropped mask.
        padding : int
            Padding added to the bounding box of the grain during cropping.
        bbox : tuple[int, int, int, int]
            Bounding box of the crop including padding.
        pixel_to_nm_scaling : float
            Pixel to nanometre scaling factor for the crop.
        thresholds : list[float]
            A list of thresholds used to identify the grain.
        filename : str
            Filename of the image from which the crop was taken.
        threshold : str
            Direction of the molecule from the threshold (above / below).
        skeleton : npt.NDArray[np.bool_]
            3-D Numpy tensor of the skeletonised mask.
        convolved_skeleton : npt.NDArray[np.int32] | None = None
            2-D Numpy array of the convolved skeleton.
        height_profiles : dict[int, dict[int, npt.NDArray[np.float32]]] | None
            3-D Numpy tensor of the height profiles.
        stats : dict[str, int | float] | None
            Dictionary of grain statistics.
        disordered_trace : DisorderedTrace
            A disordered trace for the current grain.
        nodes : dict[int, Node] | None
            Grain nodes.
        ordered_trace : OrderedTrace | None
            An ordered trace for the grain.
        threshold_method : str
            Threshold method used to find grains.
        """
        self.padding = padding
        self.image = image
        # This part of the constructor must go after padding since the setter
        # for mask requires the padding.
        self.mask = mask
        self.bbox = bbox
        self.pixel_to_nm_scaling = pixel_to_nm_scaling
        self.thresholds = thresholds
        self.filename = filename
        self.threshold: str | None = threshold
        self.height_profiles: dict[int, dict[int, npt.NDArray[np.float32]]] | None = height_profiles
        self.stats: dict[str, Any] | None = {} if stats is None else stats
        self.skeleton: npt.NDArray[np.bool_] | None = skeleton
        self.convolved_skeleton: npt.NDArray[np.int32] | None = convolved_skeleton
        self.disordered_trace: DisorderedTrace | None = disordered_trace
        self.nodes: dict[int, Node] | None = nodes
        self.ordered_trace: OrderedTrace | None = ordered_trace
        self.threshold_method: str | None = threshold_method

    def __eq__(self, other: object) -> bool:
        """
        Check if two GrainCrop objects are equal.

        Parameters
        ----------
        other : object
            Object to compare to.

        Returns
        -------
        bool
            True if the objects are equal, False otherwise.
        """
        if not isinstance(other, GrainCrop):
            return False
        return (
            np.array_equal(self.image, other.image)
            and np.array_equal(self.mask, other.mask)
            and self.padding == other.padding
            and self.bbox == other.bbox
            and self.pixel_to_nm_scaling == other.pixel_to_nm_scaling
            and self.thresholds == other.thresholds
            and self.filename == other.filename
            and self.threshold == other.threshold
            and self.height_profiles == other.height_profiles
            and self.stats == other.stats
            and np.array_equal(self.skeleton, other.skeleton)
            and self.convolved_skeleton == other.convolved_skeleton
            and self.disordered_trace == other.disordered_trace
            and self.nodes == other.nodes
            and self.ordered_trace == other.ordered_trace
            and self.threshold_method == other.threshold_method
        )

    def __str__(self) -> str:
        """
        Representation function for useful statistics for the class.

        Returns
        -------
        str
            Set of formatted statistics on matched branches.
        """
        return (
            f"filename : {self.filename}\n"
            f"image shape : {self.image.shape}\n"
            f"skeleton shape : {self.skeleton.shape}\n"
            f"mask shape : {self.mask.shape}\n"
            f"padding : {self.padding}\n"
            f"thresholds : {self.thresholds}\n"
            f"threshold method : {self.threshold_method}\n"
            f"bounding box coords : {self.bbox}\n"
            f"pixel to nm scaling : {self.pixel_to_nm_scaling}\n"
            f"number of nodes : {len(self.nodes)}"
        )

    @property
    def image(self) -> npt.NDArray[float]:
        """
        Getter for the ``image`` attribute.

        Returns
        -------
        npt.NDArray
            Numpy array of the image.
        """
        return self._image

    @image.setter
    def image(self, value: npt.NDArray[float]):
        """
        Setter for the ``image`` attribute.

        Parameters
        ----------
        value : npt.NDArray
            Numpy array of the image.

        Raises
        ------
        ValueError
            If the image is not square.
        """
        if value.shape[0] != value.shape[1]:
            raise ValueError(f"Image is not square: {value.shape}")
        self._image = value

    @property
    def mask(self) -> npt.NDArray[np.bool_]:
        """
        Getter for the ``mask`` attribute.

        Returns
        -------
        npt.NDArray[np.bool_]
            Numpy array of the mask.
        """
        return self._mask

    @mask.setter
    def mask(self, value: npt.NDArray[np.bool_]) -> None:
        """
        Setter for the ``mask`` attribute.

        Parameters
        ----------
        value : npt.NDArray
            Numpy array of the ``mask`` attribute.

        Raises
        ------
        ValueError
            If the mask dimensions do not match the image.
        """
        if value.shape[0] != self.image.shape[0] or value.shape[1] != self.image.shape[1]:
            raise ValueError(f"Mask dimensions do not match image: {value.shape} vs {self.image.shape}")
        # Ensure that the padding region is blank, set it to be blank if not
        try:
            for class_index in range(1, value.shape[2]):
                class_mask = value[:, :, class_index]

                padded_region_top = class_mask[: self.padding, :]
                padded_region_bottom = class_mask[-self.padding :, :]
                padded_region_left = class_mask[:, : self.padding]
                padded_region_right = class_mask[:, -self.padding :]
                if (
                    np.any(padded_region_top)
                    or np.any(padded_region_bottom)
                    or np.any(padded_region_left)
                    or np.any(padded_region_right)
                ):
                    LOGGER.warning("Padding region is not blank, setting to blank")
                    value[: self.padding, :, class_index] = 0
                    value[-self.padding :, :, class_index] = 0
                    value[:, : self.padding, class_index] = 0
                    value[:, -self.padding :, class_index] = 0
        except IndexError as e:
            LOGGER.error(f"[{self.filename}] : Error mask is missing layers.", exc_info=e)

        # Update background class in case the mask has been edited
        value = update_background_class(value)
        self._mask: npt.NDArray[np.bool_] = value

    @property
    def padding(self) -> int:
        """
        Getter for the ``padding`` attribute.

        Returns
        -------
        int
            The padding amount.
        """
        return self._padding

    @padding.setter
    def padding(self, value: int) -> None:
        """
        Setter for the ``padding`` attribute.

        Parameters
        ----------
        value : int
            Padding amount.

        Raises
        ------
        ValueError
            If the padding is not an integer or is less than 1.
        """
        if not isinstance(value, int):
            raise ValueError(f"Padding must be an integer, but is {value}")
        if value < 1:
            raise ValueError(f"Padding must be >= 1, but is {value}")
        self._padding = value

    @property
    def bbox(self) -> tuple[int, int, int, int]:
        """
        Getter for the bounding box attribute.

        Returns
        -------
        tuple
            Bounding box of the crop.

        Raises
        ------
        ValueError
            If the bounding box is not square.
        """
        return self._bbox

    @bbox.setter
    def bbox(self, value: tuple[int, int, int, int]) -> None:
        """
        Setter for the bounding box attribute.

        Parameters
        ----------
        value : tuple[int, int, int, int]
            Bounding box of the crop.
        """
        if len(value) != 4:
            raise ValueError(f"Bounding box must have 4 elements, but has {len(value)}")
        if value[2] - value[0] != value[3] - value[1]:
            raise ValueError(
                f"Bounding box is not square: {value}, size: {value[2] - value[0]} x {value[3] - value[1]}"
            )
        self._bbox = value

    @property
    def pixel_to_nm_scaling(self) -> float:
        """
        Getter for the pixel to nanometre scaling factor attribute.

        Returns
        -------
        float
            Pixel to nanometre scaling factor.
        """
        return self._pixel_to_nm_scaling

    @pixel_to_nm_scaling.setter
    def pixel_to_nm_scaling(self, value: float) -> None:
        """
        Setter for the pixel to nanometre scaling factor attribute.

        Parameters
        ----------
        value : float
            Pixel to nanometre scaling factor.
        """
        self._pixel_to_nm_scaling = value

    @property
    def thresholds(self) -> list[float]:
        """
        Getter for the ``thresholds`` attribute.

        Returns
        -------
        list[float]
            Returns the value of ``thresholds``.
        """
        return self._thresholds

    @thresholds.setter
    def thresholds(self, value: list[float]) -> None:
        """
        Setter for the ``thresholds`` attribute.

        Parameters
        ----------
        value : list[float]
            Value to set for ``thresholds``.
        """
        self._thresholds = value

    @property
    def filename(self) -> str:
        """
        Getter for the ``filename`` attribute.

        Returns
        -------
        str
            The image ``filename`` attribute.
        """
        return self._filename

    @filename.setter
    def filename(self, value: str) -> None:
        """
        Setter for the ``filename`` attribute.

        Parameters
        ----------
        value : str
            Image ``filename`` attribute.
        """
        self._filename = value

    @property
    def skeleton(self) -> npt.NDArray:
        """
        Getter for the ``skeleton`` attribute.

        Returns
        -------
        npt.NDArray
            Returns the value of ``skeleton``.
        """
        return self._skeleton

    @skeleton.setter
    def skeleton(self, value: npt.NDArray) -> None:
        """
        Setter for the ``skeleton`` attribute.

        Parameters
        ----------
        value : npt.NDArray
            Value to set for ``skeleton``.
        """
        self._skeleton = value

    @property
    def height_profiles(self) -> npt.NDArray:
        """
        Getter for the ``height_profile`` attribute.

        Returns
        -------
        str
            The image height_profile.
        """
        return self._height_profiles

    @height_profiles.setter
    def height_profiles(self, value: npt.NDArray) -> None:
        """
        Setter for the ``height_profile`` attribute.

        Parameters
        ----------
        value : str
            Image ``height_profile``.
        """
        self._height_profiles = value

    @property
    def stats(self) -> dict[str, Any]:
        """
        Getter for the stats.

        Returns
        -------
        str
            Dictionary of image statistics.
        """
        return self._stats

    @stats.setter
    def stats(self, value: dict[str, Any]) -> None:
        """
        Setter for the stats.

        Parameters
        ----------
        value : dict[str, Any]
            Image stats.
        """
        self._stats = value

    @property
    def disordered_trace(self) -> DisorderedTrace:
        """
        Getter for the ``disordered_trace`` attribute.

        Returns
        -------
        dict[str: Any]
            Returns the value of ``disordered_trace``.
        """
        return self._disordered_trace

    @disordered_trace.setter
    def disordered_trace(self, value: DisorderedTrace) -> None:
        """
        Setter for the ``disordered_trace`` attribute.

        Parameters
        ----------
        value : dict[str: Any]
            Value to set for ``disordered_trace``.
        """
        self._disordered_trace = value

    @property
    def nodes(self) -> dict[int, Node]:
        """
        Getter for the ``nodes`` attribute.

        Returns
        -------
        dict[int, Nodes]
            Returns ``nodes``, a dictionary of Nodes.
        """
        return self._nodes

    @nodes.setter
    def nodes(self, value: Node) -> None:
        """
        Setter for the ``nodes`` attribute.

        Parameters
        ----------
        value : Nodes
            Value to set for ``nodes``.
        """
        self._nodes = value

    @property
    def ordered_trace(self) -> OrderedTrace:
        """
        Getter for the ``ordered_trace`` attribute.

        Returns
        -------
        dict[str: Any]
            Returns the value of ``ordered_trace``.
        """
        return self._ordered_trace

    @ordered_trace.setter
    def ordered_trace(self, value: OrderedTrace) -> None:
        """
        Setter for the ``ordered_trace`` attribute.

        Parameters
        ----------
        value : dict[str: Any]
            Value to set for ``ordered_trace``.
        """
        self._ordered_trace = value

    @property
    def threshold_method(self) -> list[float]:
        """
        The ``threshold_method`` used to find the grain.

        Returns
        -------
        list[float]
            Returns the value of ``threshold_method``.
        """
        return self._threshold_method

    @threshold_method.setter
    def threshold_method(self, value: list[float]) -> None:
        """
        Setter for the ``threshold_method`` attribute.

        Parameters
        ----------
        value : list[float]
            Value to set for ``threshold_method``.
        """
        self._threshold_method = value

    def debug_locate_difference(self, other: object) -> None:  # noqa: C901 # pylint: disable=too-many-branches
        """
        Debug function to find the culprit when two GrainCrop objects are not equal.

        Parameters
        ----------
        other : object
            Object to compare to.

        Raises
        ------
        ValueError
            If the objects are not equal
        """
        if not isinstance(other, GrainCrop):
            raise ValueError(f"Cannot compare GrainCrop with {type(other)}")
        if not np.array_equal(self.image, other.image):
            raise ValueError(f"Image is different\n self.image  : {self.image}\n other.image : {other.image}")
        if not np.array_equal(self.mask, other.mask):
            raise ValueError(f"Mask is different\n self.mask  : {self.mask}\n other.mask : {other.mask}")
        if self.padding != other.padding:
            raise ValueError(f"Padding is different\n self.padding  : {self.padding}\n other.padding : {other.padding}")
        if self.bbox != other.bbox:
            raise ValueError(f"Bounding box is different\n self.bbox  : {self.bbox}\n other.bbox : {other.bbox}")
        if self.pixel_to_nm_scaling != other.pixel_to_nm_scaling:
            raise ValueError(
                "Pixel to nm scaling is different\n"
                f" self.pixel_to_nm_scaling  : {self.pixel_to_nm_scaling}\n"
                f" other.pixel_to_nm_scaling : {other.pixel_to_nm_scaling}"
            )
        if self.thresholds != other.thresholds:
            raise ValueError(
                f"Thresholds differ\n self.thresholds  : {self.thresholds}\n other.thresholds : {other.thresholds}"
            )
        if self.filename != other.filename:
            raise ValueError(
                f"Filename is different\n self.filename  : {self.filename}\n other.filename : {other.filename}"
            )
        if self.height_profiles != other.height_profiles:
            raise ValueError(
                "Height profiles are different\n"
                f" self.height_profiles  : {self.height_profiles}\n"
                f" other.height_profiles : {other.height_profiles}"
            )
        if self.skeleton != other.skeleton:
            raise ValueError(
                f"Skeleton is different\n self.skeleton  : {self.skeleton}\n other.skeleton : {other.skeleton}"
            )
        if self.disordered_trace != other.disordered_trace:
            raise ValueError(
                "Disordered traces are different\n"
                f" self.disordered_trace  : {self.disordered_trace}\n"
                f" other.disordered_trace : {other.disordered_trace}"
            )
        if self.nodes != other.nodes:
            raise ValueError(f"Nodes are different\n self.nodes  : {self.nodes}\n other.nodes : {other.nodes}")
        if self.threshold_method != other.threshold_method:
            raise ValueError(
                "Threshold Method is different\n"
                f" self.threshold_method  : {self.threshold_method}\n"
                f" other.threshold_method : {other.threshold_method}"
            )
        LOGGER.info("Cannot find difference between graincrops")


def validate_full_mask_tensor_shape(array: npt.NDArray[np.bool_]) -> npt.NDArray[np.bool_]:
    """
    Validate the shape of the full mask tensor.

    Parameters
    ----------
    array : npt.NDArray
        Numpy array to validate.

    Returns
    -------
    npt.NDArray
        Numpy array if valid.
    """
    if len(array.shape) != 3 or array.shape[2] < 2:
        raise ValueError(f"Full mask tensor must be WxHxC with C >= 2 but has shape {array.shape}")
    return array


@dataclass(
    repr=True, eq=True, config=ConfigDict(arbitrary_types_allowed=True, validate_assignment=True), validate_on_init=True
)
class DisorderedTrace:
    """
    Dataclass for storing the disordered tracing data.

    Attributes
    ----------
    images : dict[str: npt.NDArray]
        Dictionary of images generated during disordered tracing, should include ''pruned_skeleton''.
    grain_endpoints : int
        Number of Grain endpoints.
    grain_junctions : int
        Number of Grain junctions.
    total_branch_length : float
        Total branch length in nanometres.
    grain_width_mean : float
        Mean grain width in nanometres.
    stats : dict[int, Any]
        Dictionary of stats for each branch of a grain.
    """

    images: dict[str, npt.NDArray] | None = None
    grain_endpoints: int | None = None
    grain_junctions: int | None = None
    total_branch_length: float | None = None
    grain_width_mean: float | None = None
    stats: dict[int, Any] | None = None

    def __str__(self) -> str:
        """
        Representation function for useful statistics for the class.

        Returns
        -------
        str
            Set of formatted statistics on matched branches.
        """
        image_gens = ", ".join(self.images.keys())
        return (
            f"generated images : {image_gens}\n"
            f"grain endpoints : {self.grain_endpoints}\n"
            f"grain junctions : {self.grain_junctions}\n"
            f"total branch length (nm) : {self.total_branch_length}\n"
            f"mean grain width (nm) : {self.grain_width_mean}\n"
            f"number of branches : {len(self.stats)}"
        )


@dataclass(
    repr=True, eq=True, config=ConfigDict(arbitrary_types_allowed=True, validate_assignment=True), validate_on_init=True
)
class TopoStats:
    """
    Class for storing TopoStats objects.

    Attributes
    ----------
    grain_crops : dict[int, GrainCrop] | None
        Dictionary of ``GrainCrop`` objects.
    filename : str | None
        Filename.
    image_name : str | None
        Filename without its extension.
    pixel_to_nm_scaling : str | None
        Pixel to nanometre scaling.
    img_path : str | None
        Original path to image.
    image : npt.NDArray | None
        Flattened image (post ``Filter()``).
    image_original : npt.NDArray | None
        Original image.
    image_statistics : pd.DataFrame | None
        Pandas dataframe of
    full_mask_tensor : npt.NDArray
        Tensor mask for the full image.
    topostats_version : str | None
        TopoStats version.
    config : dict[str, Any] | None
        Configuration used when processing the grain.
    full_image_plots : dict[str, npt.NDArray[np.float64]]
        Dictionary of numpy arrays where disordered tracing, nodestats and ordered tracing skeletons are mapped back to
        the original image.
    basename : str
        Basename of image locations.
    """

    grain_crops: dict[int, GrainCrop] | None = None
    filename: str | None = None
    image_name: str | None = None
    pixel_to_nm_scaling: float | None = None
    img_path: Path | str | None = None
    image: npt.NDArray | None = None
    image_original: npt.NDArray | None = None
    image_statistics: dict[str, int | float | str] | None = None
    full_mask_tensor: npt.NDArray | None = None
    topostats_version: str | None = None
    config: dict[str, Any] | None = None
    full_image_plots: dict[str, npt.NDArray[np.float64]] | None = None
    basename: str | None = None

    def __str__(self) -> str:
        """
        Representation function for useful statistics for the class.

        Returns
        -------
        str
            Set of formatted and user-friendly statistics.
        """
        return (
            f"number of grain crops : {len(self.grain_crops)}\n"
            f"filename : {self.filename}\n"
            f"pixel to nm scaling : {self.pixel_to_nm_scaling}\n"
            f"image shape (px) : {self.image.shape}\n"
            f"image path : {self.img_path}\n"
            f"TopoStats version : {self.topostats_version}\n"
            f"Basename : {self.basename}"
        )

    def __eq__(self, other: object) -> bool:
        """
        Check if two ``TopoStats`` objects are equal.

        Parameters
        ----------
        other : object
            Other ``TopoStats`` object to compare to.

        Returns
        -------
        bool
            ``True`` if the objects are equal, ``False`` otherwise.
        """
        if not isinstance(other, TopoStats):
            return False
        return (
            self.grain_crops == other.grain_crops
            and self.filename == other.filename
            and self.pixel_to_nm_scaling == other.pixel_to_nm_scaling
            and self.img_path == other.img_path
            and self.topostats_version == other.topostats_version
            and self.config == other.config
            and np.array_equal(self.image, other.image)
            and np.array_equal(self.image_original, other.image_original)
            and np.array_equal(self.full_mask_tensor, other.full_mask_tensor)
        )

    def calculate_image_statistics(self) -> dict[str, int | float]:
        """
        Calculate the image statistics via ``statistics.image_statistics()``.

        Returns
        -------
        dict[str, int | float]
            Dictionary of image size and area in both metres and pixels, the number of grains, density and the
            root mean square of the image roughness.
        """
        self.image_statistics = image_statistics(
            image=self.image,
            filename=self.filename,
            pixel_to_nm_scaling=self.pixel_to_nm_scaling,
            n_grains=len(self.grain_crops) if self.grain_crops is not None and len(self.grain_crops) > 0 else 0,
        )
        return self.image_statistics


@dataclass(
    repr=True, eq=True, config=ConfigDict(arbitrary_types_allowed=True, validate_assignment=True), validate_on_init=True
)
class MatchedBranch:
    """
    Class for storing matched branches data and attributes.

    Attributes
    ----------
    ordered_coords : npt.NDArray[np.int32]
        Numpy array of ordered coordinates.
    heights : npt.NDArray[np.number]
        Numpy array of heights.
    distances : npt.NDArray[np.number]
        Numpy array of distances.
    fwhm : float
        Full-width half maximum.
    fwhm_half_maxs : list[float]
        Half-maximums from a matched branch.
    fwhm_peaks : list[int | float]
        Peaks from a matched branch.
    angles : float
        Angle between branches.
    branch_statistics : dict[str, float | int | list[Any] | str]
        Dictionary of branch statistics, ``fwhm``, ``fwhm_half_maxs`` and ``fwhm_peaks``.
    """

    ordered_coords: npt.NDArray[np.int32] | None = None
    heights: npt.NDArray[np.number] | None = None
    distances: npt.NDArray[np.number] | None = None
    fwhm: float | None = None
    fwhm_half_maxs: list[float] | None = None
    fwhm_peaks: list[float] | None = None
    angles: float | list[float] | None = None
    branch_statistics: dict[str, float | int | list[Any] | str] | None = None

    def __str__(self) -> str:
        """
        Representation function for useful statistics for the class.

        Returns
        -------
        str
            Set of formatted and user-friendly statistics.
        """
        return (
            f"number of coords : {self.ordered_coords.shape[0]}\n"
            f"full width half maximum : {self.fwhm}\n"
            f"full width half maximum maximums : {self.fwhm_half_maxs}\n"
            f"full width half maximum peaks : {self.fwhm_peaks}\n"
            f"angles : {self.angles}"
        )

    def collate_branch_statistics(self, image: str, basename: str) -> dict[str, float | int | list[Any] | str]:
        """
        Collate matched branch statistics to dictionary.

        Parameters
        ----------
        image : str
            Image being processed, added to dictionary for subsequent aggregation, typically ``TopoStats.filename``.
        basename : str
            Base image path, added to dictionary for subsequent aggregation, typically ``TopoStats.img_path``.

        Returns
        -------
        dict[str, float | int | list[Any] | str]
            Dictionary of ``fwhm``, ``fwhm_half_maxs`` and ``fwhm_peaks`` along with ``image`` and ``basename``.
        """
        self.branch_statistics = {
            "image": image,
            "fwhm": self.fwhm,
            "fwhm_half_maxs": self.fwhm_half_maxs,
            "fwhm_peaks": self.fwhm_peaks,
            "basename": str(basename),
        }
        return self.branch_statistics


@dataclass(
    repr=True, eq=True, config=ConfigDict(arbitrary_types_allowed=True, validate_assignment=True), validate_on_init=True
)
class UnMatchedBranch:
    """
    Class for storing matched branches data and attributes.

    Attributes
    ----------
    angles : float
        Angle between branches.
    """

    angles: float | list[float] | None = None

    def __str__(self) -> str:
        """
        Representation function for useful statistics for the class.

        Returns
        -------
        str
            Set of formatted and user-friendly statistics.
        """
        return f"angles : {self.angles}"


@dataclass(
    repr=True, eq=True, config=ConfigDict(arbitrary_types_allowed=True, validate_assignment=True), validate_on_init=True
)
class Node:
    """
    Class for storing Node data and attributes.

    Attributes
    ----------
    error : bool
        Whether an error occurred calculating statistics for this node.
    pixel_to_nm_scaling : np.float64
        Pixel to nanometre scaling.
    branch_stats : dict[int, MatchedBranch]
        Dictionary of ``MatchedBranch`` objects.
    unmatched_branch_stats : dict[int, UnMatchedBranch]
        Dictionary of ``UnmatchedBranch`` objects.
    node_coords : npt.NDArray[np.int32]
        Numpy array of node coordinates.
    confidence : np.float64
        Normalised confidence of the crossing in the range of ``0-1``.
    reduced_node_area : npt.NDArray[np.int32]
        The molecule skeleton, with all branches removed that are not connected to the current node.
    node_area_skeleton : npt.NDArray[np.int32]
        Numpy array of skeleton.
    node_branch_mask : npt.NDArray[np.int32]
        Numpy array of branch mask.
    node_avg_mask : npt.NDArray[np.int32]
        Numpy array of averaged mask.
    writhe : str
        Writhe type for the node.
    """

    error: bool | None = None
    pixel_to_nm_scaling: float | None = None
    branch_stats: dict[int, MatchedBranch] | None = None
    unmatched_branch_stats: dict[int, UnMatchedBranch] | None = None
    node_coords: npt.NDArray[np.int32] | None = None
    confidence: float | None = None
    reduced_node_area: npt.NDArray[np.int32] | None = None
    node_area_skeleton: npt.NDArray[np.int32] | None = None
    node_branch_mask: npt.NDArray[np.int32] | None = None
    node_avg_mask: npt.NDArray[np.int32] | None = None
    writhe: str | None = None

    def __str__(self) -> str:
        """
        Representation function for useful statistics for the class.

        Returns
        -------
        str
            Set of formatted and user-friendly statistics.
        """
        return (
            f"error : {self.error}\n"
            f"pixel to nm scaling (nm) : {self.pixel_to_nm_scaling}\n"
            f"number of matched branches : {len(self.branch_stats)}\n"
            f"number of unmatched branches : {len(self.unmatched_branch_stats)}\n"
            f"number of coords : {self.node_coords.shape[0]}\n"
            f"confidence : {self.confidence}\n"
            f"reduced node area : {self.reduced_node_area}"
        )


@dataclass(
    repr=True, eq=True, config=ConfigDict(arbitrary_types_allowed=True, validate_assignment=True), validate_on_init=True
)
class OrderedTrace:
    """
    Class for Ordered Trace data and attributes.

    molecule_data : dict[int, Molecule]
        Dictionary of ``Molecule`` objects indexed by molecule number.
    tracing_stats : dict | None
        Tracing statistics.
    grain_molstats : Any | None
        Grain molecule statistics.
    molecules : int
        Number of molecules within the grain.
    writhe : str
        The writhe sign, can be either `+`, `-` or `0` for positive, negative or no writhe.
    pixel_to_nm_scaling: np.float64 | None
        Pixel to nm scaling.
    images: dict[str, npt.NDArray] | None
        Diagnostic images produced during processing.
    error: bool | None
        Errors encountered?
    molecule_statistics : dict[int, dict[str, bool | str | float | None]] | None
        Dictionary of molecule statistics, with one entry for each molecule.
    """

    molecule_data: dict[int, Molecule] | None = None
    tracing_stats: dict | None = None
    grain_molstats: Any | None = None
    molecules: int | None = None
    writhe: str | None = None
    pixel_to_nm_scaling: float | None = None
    images: dict[str, npt.NDArray] | None = None
    error: bool | None = None
    molecule_statistics: dict[int, dict[str, bool | str | float | None]] | None = None

    def __str__(self) -> str:
        """
        Representation function for useful statistics for the class.

        Returns
        -------
        str
            Set of formatted and user-friendly statistics.
        """
        writhe = {"+": "positive", "-": "negative", "0": "no writhe"}.get(self.writhe)
        return (
            f"number of molecules : {self.molecules}\n"
            f"number of images : {len(self.images)}\n"
            f"writhe : {writhe}\n"
            f"pixel to nm scaling : {self.pixel_to_nm_scaling}\n"
            f"error : {self.error}"
        )

    def collate_molecule_statistics(self) -> dict[int, dict[str, bool | int | str | None]]:
        """
        Collate molecule statistics for all molecules to dictionary.

        The resulting dictionary can be easily converted to Pandas Dataframes for writing to CSV.

        Returns
        -------
        dict[int, dict[str, bool | int | str | None]]
            Dictionary, indexed by molecule where the value is the molecules statistics for the given molecule.
        """
        self.molecule_statistics = {
            molecule_number: molecule.collate_molecule_statistics()
            for molecule_number, molecule in self.molecule_data.items()
        }
        return self.molecule_statistics


@dataclass(
    repr=True, eq=True, config=ConfigDict(arbitrary_types_allowed=True, validate_assignment=True), validate_on_init=True
)
class Molecule:
    """
    Class for Molecules identified during ordered tracing.

    threshold : str
        Direction from threshold of molecule (above / below)
    molecule_number : int
        Index of the molecule (per grain)
    circular : str, bool, optional
        Whether the molecule is circular or linear.
    processing : str
        Which processing type was used, topostats or nodestats.
    topology : str, optional
        Topological classification of the molecule.
    topology_flip : Any, optional
        Unknown?
    ordered_coords : npt.NDArray, optional
        Ordered coordinates for the molecule.
    splined_coords : npt.NDArray, optional
        Smoothed (aka splined) coordinates for the molecule.
    contour_length : float
        Length of the molecule.
    end_to_end_distance : float
        Distance between ends of molecule. Will be ``0.0`` for circular molecules which don't have ends.
    heights : npt.NDArray
        Height along molecule.
    distances : npt.NDArray
        Distance between points on the molecule.
    curvature_stats : npt.NDArray, optional
        Angle changes along molecule. NB - These are always positive due to use of ``np.abs()`` during calculation.
    bbox : tuple[int, int, int, int], optional
        Bounding box.
    """

    threshold: str | None = None
    molecule_number: int | None = None
    circular: str | bool | None = None
    processing: str | None = None
    topology: str | None = None
    topology_flip: Any | None = None
    ordered_coords: npt.NDArray | None = None
    splined_coords: npt.NDArray | None = None
    contour_length: float | None = None
    end_to_end_distance: float | None = None
    heights: npt.NDArray | None = None
    distances: npt.NDArray | None = None
    curvature_stats: npt.NDArray | None = None
    bbox: tuple[int, int, int, int] | None = None
    molecule_statistics: dict[str, bool | str | float | None] | None = None

    def __str__(self) -> str:
        """
        Representation function for useful statistics for the class.

        Returns
        -------
        str
            Set of formatted and user-friendly statistics.
        """
        return (
            f"circular : {self.circular}\n"
            f"topology : {self.topology}\n"
            f"topology flip : {self.topology_flip}\n"
            f"number of ordered coords : {self.ordered_coords.shape[0]}\n"
            f"number of spline coords : {self.splined_coords.shape[0]}\n"
            f"contour length : {self.contour_length}\n"
            f"end to end distance : {self.end_to_end_distance}\n"
            f"bounding box coords : {self.bbox}"
        )

    def collate_molecule_statistics(self) -> dict[str, bool | str | float | None]:
        """
        Collate the molecule statsistics to a dictionary.

        Returns
        -------
        dict[str, bool | str | float | None]
            Dataframe of the classes attributes and their data.
        """
        self.molecule_statistics = {
            "circular": self.circular,
            "topology": self.topology,
            "topology_flip": self.topology_flip,
            "contour_length": self.contour_length,
            "end_to_end_distance": self.end_to_end_distance,
        }
        return self.molecule_statistics


def convert_to_dict(to_convert: Any) -> dict[str, Any]:
    """
    Convert a class to a dictionary representation of itself.

    Classes that are part of the ``topostats`` package (``TopoStats``, ``GrainCrop``, ``DisorderedTrace``, ``Node``,
    ``OrderedTrace``, ``MatchedBranch``, ``UnMatchedBranch`` and ``Molecule``) can be converted to dictionaries along
    with any other class that has the ``__dict__`` attribute.

    The keys are the attribute names (with the ``_`` prefix removed) and the values are the stored values of the
    attribute. Values of type ``str``, ``int``, ``float``, ``bool``, ``tuple``, ``np.ndarray`` and ``pathlib.Path`` are
    returned as is. If a value is itself a dictionary (e.g. ``TopoStats.grain_crops`` ``Node.branch_stats`` or
    ``OrderedTrace.molecule_data`` which are all dictionaries of the objects) then the object is recursed.

    If lists or dictionaries are provided a ``TypeError`` is raised and any object that doesn't have the ``__dict__``
    attribute then an ``AttributeError`` is raised.

    Parameters
    ----------
    to_convert : dict[str, Any]
        An object to be converted to a dictionary / dictionary item.

    Returns
    -------
    Any
        Input parameter as a dictionary / dictionary item.
    """
    if isinstance(to_convert, (int | float | str | Path | bool | np.ndarray | tuple | None)):
        return to_convert
    if isinstance(to_convert, list):
        return [convert_to_dict(item) for item in to_convert]
    if isinstance(to_convert, dict):
        return {key: convert_to_dict(value) for key, value in to_convert.items()}
    if hasattr(to_convert, "__dict__"):
        return {re.sub(r"^_", "", key): convert_to_dict(value) for key, value in to_convert.__dict__.items()}

    return to_convert
