import math
from typing import Optional, Tuple, Iterable, Union, Set, Callable

import numpy as np
from shapely.affinity import translate, rotate
from shapely.geometry import Point, LinearRing, Polygon, LineString
from shapely.ops import clip_by_rect, unary_union

from ..grooves import GrooveBase
from ..hooks import HookHost, Hook


class Profile(HookHost):
    """Represents a profile aka a workpiece state."""

    cross_section = Hook[Polygon]()
    """Shape of the profile's cross-section."""

    classifiers = Hook[Set[str]]()
    """Classifiers of the profile's shape's type."""

    x = Hook[float]()
    """Spacial coordinate in rolling direction."""

    t = Hook[float]()
    """Temporal coordinate."""

    velocity = Hook[float]()
    """Mean material flow velocity."""

    height = Hook[float]()
    """Maximum height (y-direction)."""

    width = Hook[float]()
    """Maximum width (z-direction)."""

    length = Hook[float]()
    """Length of the workpiece (x-direction)."""

    equivalent_rectangle = Hook[Polygon]()
    """Equivalent rectangle geometry for use with equivalent flat pass models."""

    equivalent_height = Hook[float]()
    """Height of the equivalent rectangle."""

    equivalent_width = Hook[float]()
    """Width of the equivalent rectangle."""

    temperature = Hook[float]()
    """Mean temperature of the profile cross-section."""

    surface_temperature = Hook[float]()
    """Mean temperature of the profile surface."""

    core_temperature = Hook[float]()
    """Temperature of the profile core."""

    strain = Hook[float]()
    """Mean equivalent strain of the profile cross-section."""

    flow_stress = Hook[float]()
    """Mean flow stress of the profile material."""

    flow_stress_function = Hook[Callable[[float, float, float], float]]()
    """Flow stress depended on strain, strain-rate and temperature"""

    elastic_modulus = Hook[float]()
    """Mean elastic modulus of the profile material."""

    poissons_ratio = Hook[float]()
    """Mean Poisson's ratio of the profile material."""

    thermal_conductivity = Hook[float]()
    """Mean thermal conductivity of the profile material."""

    thermal_capacity = Hook[float]()
    """Mean thermal capacity of the profile material."""

    density = Hook[float]()
    """Mean density (specific weight) of the profile material."""

    material = Hook[Union[str, Iterable[str]]]()
    """String or sequence of strings classifying the material of the profile.
    Can be used by material databases to retrieve respective data."""

    grain_size = Hook[float]()
    """Average grain size of the profile's material."""

    chemical_composition = Hook[dict[str, float]]()
    """Chemical composition of the profile's material as dict of element symbols to atom fractions (0 to 1)."""

    microstructure_composition = Hook[dict[str, float]]()
    """Phase resp. constituent composition of the profile's material 
    as dict of constituent names to volume fractions (0 to 1)."""

    def __init__(self, **kwargs):
        """Using the ``__init__`` is not recommended, use one of the factory class methods instead."""
        self.__dict__.update(kwargs)
        super().__init__()

    @classmethod
    def _base_factory(cls, **kwargs):
        if "t" not in kwargs:
            kwargs["t"] = 0
        return cls(
            **kwargs
        )

    @classmethod
    def from_groove(
            cls,
            groove: GrooveBase,
            width: Optional[float] = None,
            filling: Optional[float] = None,
            height: Optional[float] = None,
            gap: Optional[float] = None,
            **kwargs
    ) -> 'Profile':
        """
        Create a profile instance based on a given groove.
        The dimensioning of the profile is determined by the parameters ``width``, ``filling``, ``height`` and ``gap``.
        Give exactly one of ``width`` and ``filling``.
        Give exactly one of ``height`` and ``gap``.

        :param groove: the groove the profile should be created from
        :param width: the width of the resulting profile, must be > 0
        :param filling: the filling ratio of the groove, must be > 0
        :param height: the height of the profile, must be > 0
        :param gap: the gap between the groove contours (roll gap), must be >= 0
        :param kwargs: additional keyword arguments to be passed to the Profile constructor
        :raises TypeError: on invalid argument combinations
        :raises ValueError: if arguments are out of range
        """

        if width is not None and filling is None:
            filling = width / groove.usable_width
        elif filling is not None and width is None:
            width = filling * groove.usable_width
        else:
            raise TypeError("either 'width' or 'filling' must be given")

        if height is not None and gap is None:
            gap = height - 2 * groove.depth
        elif gap is not None and height is None:
            height = gap + 2 * groove.depth
        else:
            raise TypeError("either 'gap' or 'height' must be given")

        if (
                filling <= 0
                or width <= 0
                or height <= 0
                or gap < 0

        ):
            raise ValueError("argument value(s) out of range")

        if filling > 1:
            # noinspection PyUnresolvedReferences
            cls.logger.warning("Encountered overfilled groove in profile construction.")

        upper_contour_line = translate(groove.contour_line, yoff=gap / 2)
        lower_contour_line = rotate(upper_contour_line, angle=180, origin=(0, 0))

        poly = Polygon(
            np.concatenate(
                [
                    upper_contour_line.coords,
                    lower_contour_line.coords
                ]
            )
        )

        if (
                # one percent tolerance to bypass discretization issues
                - width / 2 < poly.bounds[0] * 1.01
                or width / 2 > poly.bounds[2] * 1.01
        ):
            raise ValueError(
                "Profile's width can not be larger than its contour lines."
                "May be caused by critical overfilling."
            )

        polygon = clip_by_rect(poly, -width / 2, -math.inf, width / 2, math.inf)

        return cls._base_factory(
            cross_section=polygon,
            classifiers=set(groove.classifiers),
            **kwargs
        )

    @classmethod
    def round(
            cls,
            radius: Optional[float] = None,
            diameter: Optional[float] = None,
            **kwargs
    ) -> 'Profile':
        """
        Creates a round shaped profile (a real circle round, without imperfections of round grooves).
        Give exactly one of ``radius`` and ``diameter``.

        :param radius: the radius of the round profile, must be > 0
        :param diameter: the diameter of the round profile, must be > 0
        :param kwargs: additional keyword arguments to be passed to the Profile constructor
        :raises TypeError: on invalid argument combinations
        :raises ValueError: if arguments are out of range
        """

        if radius is not None and diameter is None:
            diameter = 2 * radius
        elif diameter is not None and radius is None:
            radius = diameter / 2
        else:
            raise TypeError("either 'radius' or 'diameter' must be given")

        if radius <= 0:
            raise ValueError("argument value(s) out of range")

        center = Point((0, 0))
        circle = center.buffer(radius)

        return cls._base_factory(
            cross_section=circle,
            classifiers={"round"},
            **kwargs
        )

    @classmethod
    def square(
            cls,
            side: Optional[float] = None,
            diagonal: Optional[float] = None,
            corner_radius: float = 0,
            **kwargs
    ) -> 'Profile':
        """
        Creates a square shaped profile (a real square with rounded corners, without imperfections of square grooves).
        A square is oriented to stand on its corner, use :py:meth:`box` to create a side standing one.
        Give exactly one of ``side`` and ``diagonal``.

        :param side: the side length of the square profile, must be > 0
        :param diagonal: the diagonal's length of the square profile, must be > 0.
            Note, that the diagonal is measured at the tips, as if the corner radii were not present
            for consistency with :py:meth:`box`.
        :param corner_radius: the radius of the square's corners, must be >= 0 and <= side / 2
        :param kwargs: additional keyword arguments to be passed to the Profile constructor
        :raises TypeError: on invalid argument combinations
        :raises ValueError: if arguments are out of range
        """

        if side is not None and diagonal is None:
            diagonal = np.sqrt(2) * side
        elif diagonal is not None and side is None:
            side = diagonal / np.sqrt(2)
        else:
            raise TypeError("either 'side' or 'diagonal' must be given")

        if (
                side <= 0
                or corner_radius < 0
                or corner_radius > side / 2
        ):
            raise ValueError("argument value(s) out of range")

        line = LinearRing(np.array([(1, 0), (0, 1), (-1, 0), (0, -1)]) * (diagonal / 2 - corner_radius * np.sqrt(2)))
        polygon = Polygon(line)
        polygon = polygon.buffer(corner_radius)

        return cls._base_factory(
            cross_section=polygon,
            classifiers={"square", "diamond"},
            **kwargs
        )

    @classmethod
    def box(
            cls,
            height: float,
            width: float,
            corner_radius: float = 0,
            **kwargs
    ) -> 'Profile':
        """
        Creates a box shaped profile (a real rectangular shape with rounded corners,
        without imperfections of box grooves).
        A box is oriented to stand on its side, use :py:meth:`square` to create a corner standing square.

        :param height: the height of the box profile, must be > 0
        :param width: the width of the box profile, must be > 0
        :param corner_radius: the radius of the square's corners, must be >= 0, <= height / 2 and <= width / 2
        :param kwargs: additional keyword arguments to be passed to the Profile constructor
        :raises ValueError: if arguments are out of range
        """

        if (
                height <= 0
                or width <= 0
                or corner_radius < 0
                or corner_radius > height / 2
                or corner_radius > width / 2
        ):
            raise ValueError("argument value(s) out of range")

        line = LinearRing(
            np.array([(1, -1), (1, 1), (-1, 1), (-1, -1)])
            * (width / 2 - corner_radius, height / 2 - corner_radius)
        )
        polygon = Polygon(line)
        polygon = polygon.buffer(corner_radius)

        return cls._base_factory(
            cross_section=polygon,
            classifiers={"box"},
            **kwargs
        )

    @classmethod
    def diamond(
            cls,
            height: float,
            width: float,
            corner_radius: float = 0,
            **kwargs
    ) -> 'Profile':
        """
        Creates a diamond shaped profile (a real diamond shape with rounded corners,
        without imperfections of diamond grooves).
        A diamond is oriented to stand on its corner.

        :param height: the height of the diamond profile, must be > 0
        :param width: the width of the diamond profile, must be > 0
        :param corner_radius: the radius of the diamonds's corners, must be >= 0, <= height / 2 and <= width / 2
        :param kwargs: additional keyword arguments to be passed to the Profile constructor
        :raises ValueError: if arguments are out of range
        """

        if (
                height <= 0
                or width <= 0
                or corner_radius < 0
                or corner_radius > height / 2
                or corner_radius > width / 2
        ):
            raise ValueError("argument value(s) out of range")

        line = LinearRing(
            np.array([(1, 0), (0, 1), (-1, 0), (0, -1)])
            * (width / 2 - corner_radius, height / 2 - corner_radius)
        )
        polygon = Polygon(line)
        polygon = polygon.buffer(corner_radius)

        return cls._base_factory(
            cross_section=polygon,
            classifiers={"diamond"},
            **kwargs
        )

    def local_height(self, z: float) -> float:
        coords = np.array([(1, -1), (1, 1)]) * (z, self.height)

        vline = LineString(
            coords
        )

        intersection = vline.intersection(self.cross_section)

        return intersection.length

    def local_width(self, y: float) -> float:
        coords = np.array([(-1, 1), (1, 1)]) * (self.width, y)

        hline = LineString(
            coords
        )

        intersection = hline.intersection(self.cross_section)

        return intersection.length
