"""This module defines a dataclass which holds metadata about a WSI.

With this class, metadata is in a normalized consistent format
which is quite useful when working with many different WSI formats.
The raw metadata is also preserved and accessible via a dictionary. The
format of this dictionary may vary between WSI formats.

"""
import warnings
from numbers import Number
from pathlib import Path
from typing import List, Mapping, Optional, Sequence, Tuple, Union

import numpy as np

Resolution = Union[Number, Tuple[Number, Number], np.ndarray]


class WSIMeta:
    """Whole slide image metadata class.

    Args:
            slide_dimensions (int, int):
                Tuple containing the width and height of the WSI. These
                are for the baseline (full resolution) image if the WSI
                is a pyramid or multi-resolution.
            level_dimensions (list):
                A list of dimensions for each level of the pyramid or
                for each resolution in the WSI.
            objective_power (float, optional):
                The power of the objective lens used to create the
                image.
            level_count: (int, optional):
                The number of levels or resolutions in the WSI. If not
                given this is assigned len(level_dimensions). Defaults
                to None.
            level_downsamples (:obj:`list` of :obj:`float`):
                List of scale values which describe how many times
                smaller the current level is compared with the baseline.
            vendor (str, optional):
                Scanner vendor/manufacturer description.
            mpp (float, float, optional):
                Microns per pixel.
            file_path (Path, optional):
                Path to the corresponding WSI file.
            raw (dict, optional):
                Dictionary of unprocessed metadata extracted from the
                WSI format. For JPEG-2000 images this contains an xml
                object under the key "xml".

    Attributes:
        slide_dimensions (tuple(int)):
            Tuple containing the width and height of the WSI. These are
            for the baseline (full resolution) image if the WSI is a
            pyramid or multi-resolution. Required.
        axes (str):
            Axes ordering of the image. This is most relevant for
            OME-TIFF images where the axes ordering can vary. For most
            images this with be "YXS" i.e. the image is store in the
            axis order of Y coordinates first, then X coordinates, and
            colour channels last.
        level_dimensions (list):
            A list of dimensions for each level of the pyramid or for
            each resolution in the WSI. Defaults to [slide_dimension].
        objective_power (float):
            The magnification power of the objective lens used to scan
            the image. Not always present or accurate. Defaults to None.
        level_count: (int):
            The number of levels or resolutions in the WSI. If not given
            this is assigned len(level_dimensions). Defaults to
            len(level_dimensions).
        level_downsamples (:obj:`list` of :obj:`float`):
            List of scale values which describe how many times smaller
            the current level is compared with the baseline. Defaults to
            (1,).
        vendor (str):
            Scanner vendor/manufacturer description.
        mpp (float, float, optional):
            Microns per pixel. Derived from objective power and sensor
            size. Not always present or accurate. Defaults to None.
        file_path (Path):
            Path to the corresponding WSI file. Defaults to None.
        raw (dict):
            Dictionary of unprocessed metadata extracted from the WSI
            format. For JP2 images this contains an xml object under the
            key "xml". Defaults to empty dictionary.

    """

    _valid_axes_characters = "YXSTZ"

    def __init__(
        self,
        slide_dimensions: Tuple[int, int],
        axes: str,
        level_dimensions: Optional[Sequence[Tuple[int, int]]] = None,
        objective_power: Optional[float] = None,
        level_count: Optional[int] = None,
        level_downsamples: Optional[Sequence[float]] = (1,),
        vendor: Optional[str] = None,
        mpp: Optional[Sequence[float]] = None,
        file_path: Optional[Path] = None,
        raw: Optional[Mapping[str, str]] = None,
    ):
        self.axes = axes
        self.objective_power = float(objective_power) if objective_power else None
        self.slide_dimensions = tuple([int(x) for x in slide_dimensions])
        self.level_dimensions = (
            tuple([(int(w), int(h)) for w, h in level_dimensions])
            if level_dimensions is not None
            else [self.slide_dimensions]
        )
        self.level_downsamples = (
            [float(x) for x in level_downsamples]
            if level_downsamples is not None
            else None
        )
        self.level_count = (
            int(level_count) if level_count is not None else len(self.level_dimensions)
        )
        self.vendor = str(vendor)
        self.mpp = np.array([float(x) for x in mpp]) if mpp is not None else None
        self.file_path = Path(file_path) if file_path is not None else None
        self.raw = raw if raw is not None else None

        self.validate()

    def validate(self):
        """Validate passed values and cast to Python types.

        Metadata values are often given as strings and must be
        parsed/cast to the appropriate python type e.g. "3.14" to 3.14
        etc.

        Returns:
            bool:
                True is validation passed, False otherwise.

        """
        passed = True

        # Fatal conditions: Should return False if not True

        if len(set(self.axes) - set(self._valid_axes_characters)) > 0:
            warnings.warn(
                "Axes contains invalid characters. "
                f"Valid characters are '{self._valid_axes_characters}'."
            )
            passed = False

        if self.level_count < 1:
            warnings.warn("Level count is not a positive integer")
            passed = False

        if self.level_dimensions is None:
            warnings.warn("level_dimensions is None")
            passed = False
        elif len(self.level_dimensions) != self.level_count:
            warnings.warn("Length of level dimensions != level count")
            passed = False

        if self.level_downsamples is None:
            warnings.warn("Level downsamples is None")
            passed = False
        elif len(self.level_downsamples) != self.level_count:
            warnings.warn("Length of level downsamples != level count")
            passed = False

        # Non-fatal conditions: Raise warning only, do not fail validation

        if self.raw is None:
            warnings.warn("Raw data is None")

        if all(x is None for x in [self.objective_power, self.mpp]):
            warnings.warn("Unknown scale (no objective_power or mpp)")

        return passed  # noqa

    def level_downsample(
        self,
        level: Union[int, float],
    ) -> float:
        """Get the downsample factor for a level.

        For non-integer values of `level`, the downsample factor is
        linearly interpolated between from the downsample factors of the
        level below and the level above.

        Args:
            level (int or float):
                Level to get downsample factor for.

        Returns:
            float:
                Downsample factor for the given level.

        """
        level_downsamples = self.level_downsamples
        if isinstance(level, int) or int(level) == level:
            # Return the downsample for the level
            return level_downsamples[int(level)]
        # Linearly interpolate between levels
        floor = int(np.floor(level))
        ceil = int(np.ceil(level))
        floor_downsample = level_downsamples[floor]
        ceil_downsample = level_downsamples[ceil]
        return np.interp(level, [floor, ceil], [floor_downsample, ceil_downsample])

    def relative_level_scales(
        self, resolution: Resolution, units: str
    ) -> List[np.ndarray]:
        """Calculate scale of each level in the WSI relative to given resolution.

        Find the relative scale of each image pyramid / resolution level
        of the WSI relative to the given resolution and units.

        Values > 1 indicate that the level has a larger scale than the
        target and < 1 indicates that it is smaller.

        Args:
            resolution (float or tuple(float)):
                Scale to calculate relative to units.
            units (str):
                Units of the scale. Allowed values are: `"mpp"`,
                `"power"`, `"level"`, `"baseline"`. Baseline refers to
                the largest resolution in the WSI (level 0).

        Raises:
            ValueError:
                Missing MPP metadata.
            ValueError:
                Missing objective power metadata.
            ValueError:
                Invalid units.

        Returns:
            list:
                Scale for each level relative to the given scale and
                units.

        Examples:
            >>> from tiatoolbox.wsicore.wsireader import WSIReader
            >>> wsi = WSIReader.open(input_img="./CMU-1.ndpi")
            >>> print(wsi.info.relative_level_scales(0.5, "mpp"))
            [array([0.91282519, 0.91012514]), array([1.82565039, 1.82025028]) ...

            >>> from tiatoolbox.wsicore.wsireader import WSIReader
            >>> wsi = WSIReader.open(input_img="./CMU-1.ndpi")
            >>> print(wsi.info.relative_level_scales(0.5, "baseline"))
            [0.125, 0.25, 0.5, 1.0, 2.0, 4.0, 8.0, 16.0, 32.0]

        """
        if units not in ("mpp", "power", "level", "baseline"):
            raise ValueError("Invalid units")

        level_downsamples = self.level_downsamples

        def np_pair(x: Union[Number, np.array]) -> np.ndarray:
            """Ensure input x is a numpy array of length 2."""
            # If one number is given, the same value is used for x and y
            if isinstance(x, Number):
                return np.array([x] * 2)
            return np.array(x)

        if units == "level":
            if resolution >= len(level_downsamples):
                raise ValueError(
                    f"Target scale level {resolution} "
                    f"> number of levels {len(level_downsamples)} in WSI"
                )
            base_scale, resolution = 1, self.level_downsample(resolution)

        resolution = np_pair(resolution)

        if units == "mpp":
            if self.mpp is None:
                raise ValueError("MPP is None. Cannot determine scale in terms of MPP.")
            base_scale = self.mpp

        if units == "power":
            if self.objective_power is None:
                raise ValueError(
                    "Objective power is None. "
                    "Cannot determine scale in terms of objective power."
                )
            base_scale, resolution = 1 / self.objective_power, 1 / resolution

        if units == "baseline":
            base_scale, resolution = 1, 1 / resolution

        return [
            (base_scale * downsample) / resolution for downsample in level_downsamples
        ]

    def as_dict(self):
        """Convert WSIMeta to dictionary of Python types.

        Returns:
            dict:
                Whole slide image meta data as dictionary.

        """
        if self.mpp is None:
            mpp = (self.mpp, self.mpp)
        else:
            mpp = tuple(self.mpp)
        return {
            "objective_power": self.objective_power,
            "slide_dimensions": self.slide_dimensions,
            "level_count": self.level_count,
            "level_dimensions": self.level_dimensions,
            "level_downsamples": self.level_downsamples,
            "vendor": self.vendor,
            "mpp": mpp,
            "file_path": self.file_path,
        }
