from collections import defaultdict
from enum import Enum
from enum import unique
import itertools
import random


@unique
class Color(Enum):
    def __str__(self):
        return self.value

    green = 'green'
    purple = 'purple'
    red = 'red'


@unique
class Number(Enum):
    def __str__(self):
        return self.value

    one = 'one'
    two = 'two'
    three = 'three'


@unique
class Shading(Enum):
    def __str__(self):
        return self.value

    empty = 'empty'
    solid = 'solid'
    striped = 'striped'


@unique
class Shape(Enum):
    def __str__(self):
        return self.value

    diamond = 'diamond'
    oval = 'oval'
    squiggle = 'squiggle'


class Card():
    """Represents a card from the game of Set.

    Cards have four properties: color, number, shading, and shape.  Each
    attribute can take one of three unique values.

    """
    def __init__(self, color, shading, shape, number):
        self._color = color
        self._shading = shading
        self._shape = shape
        self._number = number

    def __eq__(self, other):
        if not isinstance(other, self.__class__):
            return False

        color = self.get_color() == other.get_color()
        shading = self.get_shading() == other.get_shading()
        shape = self.get_shape() == other.get_shape()
        number = self.get_number() == other.get_number()

        return (color and shading and shape and number)

    def __ne__(self, other):
        return not self.__eq__(other)

    def __str__(self):
        """Return the string representation of a card."""
        return ' '.join((
            self.get_number(), self.get_shading(),
            self.get_color(), self.get_shape()))

    def get_color(self):
        return self._color

    def get_number(self):
        return self._number

    def get_shading(self):
        return self._shading

    def get_shape(self):
        return self._shape


class Deck():
    """Represents a deck in the Set game, implemented as an iterable/iterator.

    The deck initially contains eighty-one (3**4 = 81) cards, each having a
    unique combination of values over its properties.

    """
    def __init__(self):
        # Populate and shuffle the deck.
        parameters = [Color, Shading, Shape, Number]
        self._cards = [Card(*x) for x in itertools.product(*parameters)]
        random.shuffle(self._cards)

    def __iter__(self):
        return self

    def __next__(self):
        """Return the next/top card in the deck."""
        try:
            return self._cards.pop()
        except IndexError:
            raise StopIteration


class Game():
    """Represents the game of Set.

    The Game contains the main modeling of the set game. As such it contains
    the main algorithm for detecting Sets.

    Note that 'players' can be any type of object, as long as they are
    comparable with respect to equality (eg. identifiers).

    """
    def __init__(self, players=[]):
        super().__init__()

        self._running = False

        # The game begins with 12 cards in play.
        self._deck = Deck()
        self._cards = list(itertools.islice(self._deck, 12))

        # Scores set when the game begins running (player array is finalized).
        self._scores = None
        self._players = players

        # Players reporting that no set exists.
        self._noset_players = set()

    def add_player(self, player):
        if not self._running:
            self._players.append(player)

    def remove_player(self, player):
        # TODO: Allow a player to quit during game play.
        if not self._running and player in self._players:
            self._players.remove(player)

    def is_running(self):
        return self._running

    def run(self):
        """Trigger the start of the game."""
        # Set scores dictionary to contain all players.
        self._scores = dict(map(lambda x: (x, 0), self._players))

        # Change the game to its running state.
        self._running = True

    def get_cards(self):
        """Return a copy of the set of cards currently in play.

        If an empty list is returned then the game is considered over.

        """
        return self._cards[:]

    def get_scores(self):
        """Return a copy of the current scores, mapped by player name."""
        return self._scores

    def found_set(self, player, *cards):
        """Allow a player to notify the game that they have found a set."""
        # There should only be three cards passed (or None).
        if len(cards) == 1 and cards[0] is None:
            # Wait until all players report None (couldn't find a set).
            self._noset_players.add(player)
            if len(self._noset_players) == len(self._players):
                # Draw more cards and add them to the list of cards in play.
                new_cards = list(itertools.islice(self._deck, 3))
                self._cards.extend(new_cards)

                # If there are no cards left & no set then the game is over.
                if not new_cards and not self._is_set(self._cards):
                    self._noset_players.clear()
                    self._cards = []

        elif len(cards) == 3:
            # Check that the specified cards are in play.
            cards_valid = all(map(lambda x: x in self.get_cards(), cards))

            # Check if the cards form a set (is_set).
            cards_form_set = cards_valid and self._is_set(*cards)

            # Update the game state and scoring. Also clear noset_players in
            # case a player finds a set where others couldn't.
            if cards_form_set:
                for card in cards:
                    self._cards.remove(card)

                # Draw more cards and add them to the list of cards in play.
                new_cards = list(itertools.islice(self._deck, 3))
                self._cards.extend(new_cards)

                self._noset_players.clear()
                self._scores[player] += 1

    def _is_set(self, *cards):
        """Main algorithm that answers if the given cards form a set.

        This function returns the set(s) found in the given list of cards.  If
        there are no sets (or less than three cards are passed) then an empty
        array is returned.

        """
        sets = []

        # For each attribute, if a value appears exactly twice in a set then
        # the set is not valid.
        for card_set in itertools.combinations(cards, 3):
            counts = defaultdict(int)
            for card in card_set:
                counts[card.get_color()] += 1
                counts[card.get_number()] += 1
                counts[card.get_shading()] += 1
                counts[card.get_shape()] += 1

            if 2 not in counts.values():
                sets.append(card_set)

        return sets
