"""Layout utilities (to be expanded)"""

import enum
import logging
from dataclasses import dataclass
from fractions import Fraction
from typing import Optional, Union

from .aurora_signature.config.api import ConfigurableMixin
from .aurora_signature.config.errors import ConfigurationError

__all__ = [
    'LayoutError',
    'BoxSpecificationError',
    'BoxConstraints',
    'AxisAlignment',
    'Margins',
    'InnerScaling',
    'SimpleBoxLayoutRule',
    'Positioning',
]

logger = logging.getLogger(__name__)


class LayoutError(ValueError):
    """Indicates an error in a layout computation."""

    def __init__(self, msg: str, *args):
        self.msg = msg
        super().__init__(msg, *args)


class BoxSpecificationError(LayoutError):
    """Raised when a box constraint is over/underspecified."""

    def __init__(self, msg: Optional[str] = None):
        super().__init__(msg=msg or "box constraint is over/underspecified")


class BoxConstraints:
    """Represents a box of potentially variable width and height.
    Among other uses, this can be leveraged to produce a variably sized
    box with a fixed aspect ratio.

    If width/height are not defined yet, they can be set by assigning to the
    :attr:`width` and :attr:`height` attributes.
    """

    _width: Optional[int]
    _height: Optional[int]
    _ar: Optional[Fraction]
    _fully_specified: bool

    def __init__(
        self,
        width: Union[int, float, None] = None,
        height: Union[int, float, None] = None,
        aspect_ratio: Optional[Fraction] = None,
    ):
        int_width = int(width) if width is not None else None
        int_height = int(height) if height is not None else None
        self._width = int_width
        self._height = int_height

        fully_specified = False

        self._ar = None
        if int_width is None and int_height is None and aspect_ratio is None:
            return
        elif int_width is not None and int_height is not None:
            if aspect_ratio is not None:
                raise BoxSpecificationError  # overspecified
            self._ar = Fraction(int_width, int_height)
            fully_specified = True
        elif aspect_ratio is not None:
            self._ar = aspect_ratio
            if int_height is not None:
                self._width = int(round(int_height * aspect_ratio))
            elif int_width is not None:
                self._height = int(round(int_width / aspect_ratio))

        self._fully_specified = fully_specified

    def _recalculate(self):
        if self._width is not None and self._height is not None:
            self._ar = Fraction(self._width, self._height)
            self._fully_specified = True
        elif self._ar is not None:
            if self._height is not None:
                self._width = int(self._height * self._ar)
                self._fully_specified = True
            elif self._width is not None:
                self._height = int(self._width / self._ar)
                self._fully_specified = True

    @property
    def width(self) -> int:
        """
        :return:
            The width of the box.
        :raises BoxSpecificationError:
            if the box's width could not be determined.
        """
        if self._width is not None:
            return self._width
        else:
            raise BoxSpecificationError

    @width.setter
    def width(self, width):
        if self._width is None:
            self._width = width
            self._recalculate()
        else:
            raise BoxSpecificationError

    @property
    def width_defined(self) -> bool:
        """
        :return:
            ``True`` if the box currently has a well-defined width,
            ``False`` otherwise.
        """
        return self._width is not None

    @property
    def height(self) -> int:
        """
        :return:
            The height of the box.
        :raises BoxSpecificationError:
            if the box's height could not be determined.
        """
        if self._height is not None:
            return self._height
        else:
            raise BoxSpecificationError

    @height.setter
    def height(self, height):
        if self._height is None:
            self._height = height
            self._recalculate()
        else:
            raise BoxSpecificationError

    @property
    def height_defined(self) -> bool:
        """
        :return:
            ``True`` if the box currently has a well-defined height,
            ``False`` otherwise.
        """
        return self._height is not None

    @property
    def aspect_ratio(self) -> Fraction:
        """
        :return:
            The aspect ratio of the box.
        :raises BoxSpecificationError:
            if the box's aspect ratio could not be determined.
        """
        if self._ar is not None:
            return self._ar
        else:
            raise BoxSpecificationError

    @property
    def aspect_ratio_defined(self) -> bool:
        """
        :return:
            ``True`` if the box currently has a well-defined aspect ratio,
            ``False`` otherwise.
        """
        return self._ar is not None


class InnerScaling(enum.Enum):
    """Class representing a scaling convention."""

    NO_SCALING = enum.auto()
    """Never scale content."""

    STRETCH_FILL = enum.auto()
    """Scale content to fill the entire container."""

    STRETCH_TO_FIT = enum.auto()
    """
    Scale content while preserving aspect ratio until either the maximal 
    width or maximal height is reached.
    """

    SHRINK_TO_FIT = enum.auto()
    """
    Scale content down to fit in the container, while preserving the original
    aspect ratio.
    """

    @classmethod
    def from_config(cls, config_str: str) -> 'InnerScaling':
        """
        Convert from a configuration string.

        :param config_str:
            A string: 'none', 'stretch-fill', 'stretch-to-fit', 'shrink-to-fit'
        :return:
            An :class:`.InnerScaling` value.
        :raise ConfigurationError: on unexpected string inputs.
        """
        try:
            return {
                'none': InnerScaling.NO_SCALING,
                'stretch-fill': InnerScaling.STRETCH_FILL,
                'stretch-to-fit': InnerScaling.STRETCH_TO_FIT,
                'shrink-to-fit': InnerScaling.SHRINK_TO_FIT,
            }[config_str.lower()]
        except KeyError:
            raise ConfigurationError(
                f"'{config_str}' is not a valid inner scaling setting; valid "
                f"values are 'none', 'stretch-fill', 'stretch-to-fit', "
                f"'shrink-to-fit'."
            )


class AxisAlignment(enum.Enum):
    """Class representing one-dimensional alignment along an axis."""

    ALIGN_MIN = enum.auto()
    """
    Align maximally towards the negative end of the axis.
    """

    ALIGN_MID = enum.auto()
    """
    Center content along the axis.
    """

    ALIGN_MAX = enum.auto()
    """
    Align maximally towards the positive end of the axis.
    """

    @classmethod
    def from_x_align(cls, align_str: str) -> 'AxisAlignment':
        """
        Convert from a horizontal alignment config string.

        :param align_str:
            A string: 'left', 'mid' or 'right'.
        :return:
            An :class:`.AxisAlignment` value.
        :raise ConfigurationError: on unexpected string inputs.
        """
        try:
            return {
                'left': AxisAlignment.ALIGN_MIN,
                'mid': AxisAlignment.ALIGN_MID,
                'right': AxisAlignment.ALIGN_MAX,
            }[align_str.lower()]
        except KeyError:
            raise ConfigurationError(
                f"'{align_str}' is not a valid horizontal alignment; valid "
                f"values are 'left', 'mid', 'right'."
            )

    @classmethod
    def from_y_align(cls, align_str: str) -> 'AxisAlignment':
        """
        Convert from a vertical alignment config string.

        :param align_str:
            A string: 'bottom', 'mid' or 'top'.
        :return:
            An :class:`.AxisAlignment` value.
        :raise ConfigurationError: on unexpected string inputs.
        """
        try:
            return {
                'bottom': AxisAlignment.ALIGN_MIN,
                'mid': AxisAlignment.ALIGN_MID,
                'top': AxisAlignment.ALIGN_MAX,
            }[align_str.lower()]
        except KeyError:
            raise ConfigurationError(
                f"'{align_str}' is not a valid vertical alignment; valid "
                f"values are 'bottom', 'mid', 'top'."
            )

    @property
    def flipped(self):
        return _alignment_opposites[self]

    def align(
        self, container_len: int, inner_len: int, pre_margin, post_margin
    ) -> int:
        effective_max_len = Margins.effective(
            'length', container_len, pre_margin, post_margin
        )

        if self == AxisAlignment.ALIGN_MAX:
            # we want to start as far up the axis as possible.
            # Ignoring margins, that would be at container_len - inner_len
            # This computation makes sure that there's room for post_margin
            # in the back.
            return container_len - inner_len - post_margin
        elif self == AxisAlignment.ALIGN_MIN:
            return pre_margin
        elif inner_len > effective_max_len:
            logger.warning(
                f"Content box width/height {inner_len} is too wide for "
                f"container size {container_len} with margins "
                f"({pre_margin}, {post_margin}); post_margin will be ignored"
            )
            return pre_margin
        elif self == AxisAlignment.ALIGN_MID:
            # we'll center the inner content *within* the margins
            inner_offset = (effective_max_len - inner_len) // 2
            return pre_margin + inner_offset
        raise TypeError


# Class variables in enums are weird, so let's put this here
_alignment_opposites = {
    AxisAlignment.ALIGN_MID: AxisAlignment.ALIGN_MID,
    AxisAlignment.ALIGN_MIN: AxisAlignment.ALIGN_MAX,
    AxisAlignment.ALIGN_MAX: AxisAlignment.ALIGN_MIN,
}


@dataclass(frozen=True)
class Positioning(ConfigurableMixin):
    """
    Class describing the position and scaling of an object in a container.
    """

    x_pos: int
    """Horizontal coordinate"""

    y_pos: int
    """Vertical coordinate"""

    x_scale: float
    """Horizontal scaling"""

    y_scale: float
    """Vertical scaling"""

    def as_cm(self):
        """
        Convenience method to convert this :class:`.Positioning` into a PDF
        ``cm`` operator.

        :return:
            A byte string representing the ``cm`` operator corresponding
            to this :class:`.Positioning`.
        """
        return b'%g 0 0 %g %g %g cm' % (
            self.x_scale,
            self.y_scale,
            self.x_pos,
            self.y_pos,
        )


def _aln_width(
    alignment: AxisAlignment,
    container_box: BoxConstraints,
    inner_nat_width: int,
    pre_margin: int,
    post_margin: int,
):
    if container_box.width_defined:
        return alignment.align(
            container_box.width, inner_nat_width, pre_margin, post_margin
        )
    else:
        container_box.width = inner_nat_width + pre_margin + post_margin
        return pre_margin


def _aln_height(
    alignment: AxisAlignment,
    container_box: BoxConstraints,
    inner_nat_height: int,
    pre_margin: int,
    post_margin: int,
):
    if container_box.height_defined:
        return alignment.align(
            container_box.height, inner_nat_height, pre_margin, post_margin
        )
    else:
        container_box.height = inner_nat_height + pre_margin + post_margin
        return pre_margin


@dataclass(frozen=True)
class Margins(ConfigurableMixin):
    """Class describing a set of margins."""

    left: int = 0

    right: int = 0

    top: int = 0

    bottom: int = 0

    @classmethod
    def uniform(cls, num):
        """
        Return a set of uniform margins.

        :param num:
            The uniform margin to apply to all four sides.
        :return:
            ``Margins(num, num, num, num)``
        """
        return Margins(num, num, num, num)

    @staticmethod
    def effective(dim_name, container_len, pre, post):
        """Internal helper method to compute effective margins."""
        eff = container_len - pre - post
        if eff < 0:
            raise LayoutError(
                f"Margins ({pre}, {post}) too wide for container "
                f"{dim_name} {container_len}."
            )
        return eff

    def effective_width(self, width):
        """
        Compute width without margins.

        :param width:
            The container width.
        :return:
            The width after subtracting the left and right margins.
        :raises LayoutError:
            if the container width is too short to accommodate the margins.
        """
        return Margins.effective('width', width, self.left, self.right)

    def effective_height(self, height):
        """
        Compute height without margins.

        :param height:
            The container height.
        :return:
            The height after subtracting the top and bottom margins.
        :raises LayoutError:
            if the container height is too short to accommodate the margins.
        """
        return Margins.effective('height', height, self.bottom, self.top)

    @classmethod
    def from_config(cls, config_dict):
        # convenience
        if isinstance(config_dict, list):
            config_dict = dict(
                zip(("left", "right", "top", "bottom"), config_dict)
            )
        return super().from_config(config_dict)


@dataclass(frozen=True)
class SimpleBoxLayoutRule(ConfigurableMixin):
    """
    Class describing alignment, scaling and margin rules for a box
    positioned inside another box.
    """

    x_align: AxisAlignment
    """
    Horizontal alignment settings.
    """

    y_align: AxisAlignment
    """
    Vertical alignment settings.
    """

    margins: Margins = Margins()
    """
    Container (inner) margins. Defaults to all zeroes.
    """

    inner_content_scaling: InnerScaling = InnerScaling.SHRINK_TO_FIT
    """
    Inner content scaling rule.
    """

    @classmethod
    def process_entries(cls, config_dict):
        # in config processing, we default to MID for everything
        x_align = config_dict.get('x_align', AxisAlignment.ALIGN_MID)
        if isinstance(x_align, str):
            x_align = AxisAlignment.from_x_align(x_align)
        config_dict['x_align'] = x_align

        y_align = config_dict.get('y_align', AxisAlignment.ALIGN_MID)
        if isinstance(y_align, str):
            y_align = AxisAlignment.from_y_align(y_align)
        config_dict['y_align'] = y_align

        scaling = config_dict.get('inner_content_scaling', None)
        if scaling is not None:
            config_dict['inner_content_scaling'] = InnerScaling.from_config(
                scaling
            )

    def substitute_margins(self, new_margins: Margins) -> 'SimpleBoxLayoutRule':
        return SimpleBoxLayoutRule(
            x_align=self.x_align,
            y_align=self.y_align,
            margins=new_margins,
            inner_content_scaling=self.inner_content_scaling,
        )

    def fit(
        self,
        container_box: BoxConstraints,
        inner_nat_width: int,
        inner_nat_height: int,
    ) -> Positioning:
        """
        Position and possibly scale a box within a container, according
        to this layout rule.

        :param container_box:
            :class:`.BoxConstraints` describing the container.
        :param inner_nat_width:
            The inner box's natural width.
        :param inner_nat_height:
            The inner box's natural height.
        :return:
            A :class:`.Positioning` describing the scaling & position of the
            lower left corner of the inner box.
        """

        margins = self.margins
        scaling = self.inner_content_scaling
        x_scale = y_scale = 1
        if (
            scaling != InnerScaling.NO_SCALING
            and container_box.width_defined
            and container_box.height_defined
        ):
            eff_width = margins.effective_width(container_box.width)
            eff_height = margins.effective_height(container_box.height)

            x_scale = (
                (eff_width / inner_nat_width) if inner_nat_width != 0 else 1
            )
            y_scale = (
                (eff_height / inner_nat_height) if inner_nat_height != 0 else 1
            )
            if scaling == InnerScaling.STRETCH_TO_FIT:
                x_scale = y_scale = min(x_scale, y_scale)
            elif scaling == InnerScaling.SHRINK_TO_FIT:
                # same as stretch to fit, with the additional stipulation
                # that it can't scale up, only down.
                x_scale = y_scale = min(x_scale, y_scale, 1)

        x_pos = _aln_width(
            self.x_align,
            container_box,
            inner_nat_width * x_scale,
            margins.left,
            margins.right,
        )
        y_pos = _aln_height(
            self.y_align,
            container_box,
            inner_nat_height * y_scale,
            margins.bottom,
            margins.top,
        )
        return Positioning(
            x_pos=x_pos, y_pos=y_pos, x_scale=x_scale, y_scale=y_scale
        )
