#!/usr/bin/env python3

import argparse
import enum
import random


class Suit(enum.Enum):
    HEARTS = "♥"
    DIAMONDS = "♦"
    CLUBS = "♣"
    SPADES = "♠"

    def __str__(self):
        return self.value


class Card:
    def __init__(self, suit: Suit, rank: str, flipped: bool = False):
        self.suit = suit
        self.rank = rank

        "flipped = False means the card is face down in the deck, else it is face up."
        self.flipped = flipped

    def __repr__(self):
        return f"{self.rank!s} of {self.suit!s}{"'" if self.flipped else ''}"


class Deck:
    """
    A deck of cards. Convention is that the 0th index is the "top" of the deck, if you're holding it in your hand.
    """

    def __init__(self):
        self.cards = []


def generate_deck(deck: Deck):
    """
    Implement step 1-3 of the trick: generate a deck of 16 cards, consisting of 12 random cards (no aces) and 4 aces.
    Ensure the aces are flipped, then shuffle the deck.
    """
    suits = [Suit.HEARTS, Suit.DIAMONDS, Suit.CLUBS, Suit.SPADES]
    ranks = [  # no aces
        "2",
        "3",
        "4",
        "5",
        "6",
        "7",
        "8",
        "9",
        "10",
        "J",
        "Q",
        "K",
    ]
    all_cards = []
    for suit in suits:
        for rank in ranks:
            all_cards.append(Card(suit, rank))

    # select 12 random cards from the non-aces
    deck.cards = random.sample(all_cards, 12)

    # add in the 4 aces, flipped
    deck.cards += [Card(suit, "A", flipped=True) for suit in suits]

    random.shuffle(deck.cards)
    return deck


def alternate_flipped(deck: Deck):
    """
    Implement step 4 of the trick: deal the cards into a new pile, while alternating between dealing them and flipping
    them.
    """
    new_deck = Deck()
    for i in range(len(deck.cards)):
        # "deal, deal and flip, deal, deal and flip, ..." -- only every other
        # card gets flipped as it's dealt.
        if i % 2 == 1:
            deck.cards[i].flipped ^= True
        new_deck.cards.insert(0, deck.cards[i])
    return new_deck


def alternate_pair(
    deck: Deck,
    audience_prefs: tuple[bool, bool, bool, bool, bool, bool, bool, bool],
) -> Deck:
    """
    Implement step 5 of the trick: deal the cards into a new pile a pair at a time, while alternating between dealing them and flipping
    them, but this time using the audience's preferences to determine whether to flip or not.
    """
    new_deck = Deck()
    for i in range(len(audience_prefs)):
        if not audience_prefs[i]:
            new_deck.cards.insert(0, deck.cards[i * 2 + 1])
            new_deck.cards.insert(0, deck.cards[i * 2])
        else:
            deck.cards[i * 2].flipped ^= True
            deck.cards[i * 2 + 1].flipped ^= True
            new_deck.cards.insert(0, deck.cards[i * 2])
            new_deck.cards.insert(0, deck.cards[i * 2 + 1])
    return new_deck


def fold_in(
    deck: Deck,
    # True if the audience wants to fold in the left edge, False if they want
    # to fold in the top edge.
    audience_prefs: tuple[bool, bool, bool, bool, bool, bool, bool],
) -> Deck:
    """
    Implement step 6 of the trick,

    Lay the cards out in a $4 \times 4$ pattern as follows, with the first card in the deck numbered "1", the second
    numbered "2" and so on.

        ```
        1  2  3  4
        8  7  6  5
        9 10 11 12
        16 15 14 13
        ```

    Now allow the audience to choose between the left or top edge to "fold in". For example, if they choose the left edge,
    for every pile of cards (currently just a single card) in the first column, you flip it onto its neighbor. So card 1
    gets flipped onto card 2, card 8 flipped onto card 7 and so on. For a row flip, you'd flip 1 onto 8, 2 onto 7 and so on.
    Keep this process going until all cards have been folded into the bottom right corner, leaving only one pile left.
    """
    # The boustrophedon layout: layout[row][col] is the index (into deck.cards)
    # of the card that starts out in that grid cell.
    layout = [
        [0, 1, 2, 3],
        [7, 6, 5, 4],
        [8, 9, 10, 11],
        [15, 14, 13, 12],
    ]

    # A grid of piles. Each pile is a list of cards ordered top-to-bottom, so
    # grid[row][col][0] is the card you'd see looking down at that cell.
    grid = [[[deck.cards[idx]] for idx in row] for row in layout]

    def flip_pile(pile: list[Card]) -> list[Card]:
        """Turn a pile over: reverse its order and invert every card's orientation."""
        for card in pile:
            card.flipped ^= True
        pile.reverse()
        return pile

    def fold_left():
        """Flip the first column of piles onto the second column."""
        for r in range(len(grid)):
            grid[r][1] = flip_pile(grid[r][0]) + grid[r][1]
            del grid[r][0]

    def fold_top():
        """Flip the first row of piles onto the second row."""
        for c in range(len(grid[0])):
            grid[1][c] = flip_pile(grid[0][c]) + grid[1][c]
        del grid[0]

    for wants_left in audience_prefs:
        if wants_left and len(grid[0]) > 1:
            fold_left()
        elif not wants_left and len(grid) > 1:
            fold_top()
        # If the chosen edge is already fully folded in, the choice is a no-op.

    # Six folds (three per axis) are enough to collapse the 4x4 grid; if the
    # audience's choices didn't get us there, finish the job.
    while len(grid) > 1 or len(grid[0]) > 1:
        if len(grid[0]) > 1:
            fold_left()
        else:
            fold_top()

    new_deck = Deck()
    new_deck.cards = grid[0][0]
    return new_deck


def check_orientation(deck: Deck) -> bool:
    """
    Implement step 8 of the trick: check that the trick worked. All four aces
    should share one orientation and every other card the opposite one (the
    trick guarantees the two groups are separated, not which way each faces).
    """
    aces = [card.flipped for card in deck.cards if card.rank == "A"]
    others = [card.flipped for card in deck.cards if card.rank != "A"]
    return len(set(aces)) == 1 and len(set(others)) == 1 and aces[0] != others[0]


def run_trick() -> Deck:
    """Run one full pass of the trick with random audience choices."""
    deck = Deck()
    generate_deck(deck)
    deck = alternate_flipped(deck)
    deck = alternate_pair(deck, tuple(random.choice([True, False]) for _ in range(8)))
    deck = fold_in(deck, tuple(random.choice([True, False]) for _ in range(7)))
    return deck


def main():
    parser = argparse.ArgumentParser(
        description="Simulate the card trick many times and report the pass/fail rate.",
    )
    parser.add_argument(
        "-n",
        "--trials",
        type=int,
        default=10_000,
        help="number of times to run the simulation (default: 10000)",
    )
    parser.add_argument(
        "--seed",
        type=int,
        default=None,
        help="seed the RNG for reproducible runs",
    )
    args = parser.parse_args()

    if args.seed is not None:
        random.seed(args.seed)

    passed = 0
    for _ in range(args.trials):
        if check_orientation(run_trick()):
            passed += 1
    failed = args.trials - passed

    rate = passed / args.trials if args.trials else 0.0
    print(f"trials: {args.trials}")
    print(f"passed: {passed} ({rate:.2%})")
    print(f"failed: {failed} ({1 - rate:.2%})")

    return 0 if failed == 0 else 1


if __name__ == "__main__":
    raise SystemExit(main())
