from collections.abc import Sequence

from ..constants import BOTTOM, LEFT, RIGHT, TOP


def _comma_list(items, joiner="and"):
    items = [f"'{item}'" for item in items]

    if len(items) == 1:
        return items[0]

    if len(items) == 2:
        return f"{items[0]} {joiner} {items[1]}"

    items = (*items[:-1], f"{joiner} {items[-1]}")
    return ", ".join(items)


class shorthand_property:
    """A base class for list / composite properties."""

    def __set_name__(self, style_class, name):
        self.name = name
        style_class._BASE_ALL_PROPERTIES[style_class].add(self.name)

    def __get__(self, style, style_class=None):
        if style is None:
            return self

        return tuple(style[name] for name in self.property_names)

    def __set__(self, style, value):
        raise NotImplementedError  # pragma: no cover

    def __delete__(self, style):
        with style.batch_apply():
            for name in self.property_names:
                del style[name]

    def is_set_on(self, style):
        return any(name in style for name in self.property_names)


class directional_property(shorthand_property):
    ASSIGNMENT_SCHEMES = {
        #   T  R  B  L
        1: [0, 0, 0, 0],
        2: [0, 1, 0, 1],
        3: [0, 1, 2, 1],
        4: [0, 1, 2, 3],
    }

    def __init__(self, name_format):
        """Define a property that proxies for top/right/bottom/left alternatives.

        :param name_format: The format from which to generate subproperties. "{}" will
            be replaced with "_top", etc.
        """
        self.property_names = [
            name_format.format(f"_{direction}")
            for direction in [TOP, RIGHT, BOTTOM, LEFT]
        ]

    def __set__(self, style, value):
        if value is self:
            # This happens during autogenerated dataclass __init__ when no value is
            # supplied.
            return

        if not isinstance(value, tuple):
            value = (value,)

        if order := self.ASSIGNMENT_SCHEMES.get(len(value)):
            with style.batch_apply():
                for name, index in zip(self.property_names, order, strict=True):
                    style[name] = value[index]
        else:
            raise ValueError(
                f"Invalid value for '{self.name}'; value must be a number, or a 1-4 "
                f"tuple."
            )


class composite_property(shorthand_property):
    def __init__(self, optional, required):
        """Define a property attribute that proxies for an arbitrary set of properties.

        :param optional: The names of aliased properties that are optional in
           assignment. Order is irrelevant, unless the same value is valid for more than
           one property; in that case, values are assigned by priority of which one
           could apply to the fewest properties, and when that ties, they're assigned in
           order.
        :param required: Which properties, if any, are required when setting this
            property. In assignment, these must be specified last and in order.
        """
        self.optional = optional
        self.required = required
        self.property_names = self.optional + self.required
        self.min_num = len(self.required)
        self.max_num = len(self.required) + len(self.optional)

    def __set__(self, style, composite_value):
        if composite_value is self:
            # This happens during autogenerated dataclass __init__ when no value is
            # supplied.
            return

        if (
            not isinstance(composite_value, Sequence)
            or isinstance(composite_value, str)
            or not self.min_num <= len(composite_value) <= self.max_num
        ):
            if self.optional:
                addendum = ", optionally preceded by " + _comma_list(
                    self.optional, joiner="and/or"
                )
            else:
                addendum = ""

            raise TypeError(
                f"Composite property '{self.name}' assignment must provide "
                f"{_comma_list(self.required)}{addendum}."
            )

        # Don't clear and set values until we're sure everything validates.
        staged = {}

        # Handle the required values first. They have to be there, and in order, or the
        # whole assignment is invalid.
        required_values = composite_value[-self.min_num :]

        for name, value in zip(self.required, required_values, strict=True):
            # Let error propagate if it raises.
            staged[name] = getattr(style.__class__, name).validate(value)

        # Next, look through the optional values.
        optional_values = composite_value[: -self.min_num]

        # For each value, determine which properties can accept it.
        values_with_valid_props = []
        for value in optional_values:
            valid_props = []
            for name in self.optional:
                try:
                    getattr(style.__class__, name).validate(value)
                except ValueError:
                    pass
                else:
                    valid_props.append(name)
            if not valid_props:
                raise ValueError(
                    f"Value {value!r} not valid for any optional properties of "
                    f"composite property '{self.name}'"
                )

            values_with_valid_props.append((value, valid_props))

        # Then assign the values in order of specificity; that is, a value
        # gets "priority" if it matches fewer possible properties. (Values of equal
        # specificity are simply assigned to properties in order.)
        for value, valid_props in sorted(
            values_with_valid_props, key=lambda tup: len(tup[1])
        ):
            for name in valid_props:
                if name not in staged:
                    staged[name] = value
                    break
            else:
                # Didn't break; no valid property is still free.
                raise ValueError(
                    f"Value {value!r} not valid for any optional properties of "
                    f"composite property '{self.name}' that are not already being "
                    "assigned"
                )

        # Apply staged properties, and clear any that haven't been staged.
        with style.batch_apply():
            for name in self.optional:
                if name not in staged:
                    del style[name]
            style |= staged
