import random  # We need this to shuffle the deck and let the computer make random choices

# --- Constants ---
# Constants are written in ALL_CAPS by convention to show they shouldn't change while the program runs.

# A tuple (not a list) of the four card suits. We use a tuple because this data never needs
# to change - it's fixed for the whole game.
SUITS = ("Spades", "Hearts", "Diamonds", "Clubs")

# All 13 possible face values a card can have, in order from lowest to highest.
FACES = ("Ace", "2", "3", "4", "5", "6", "7", "8", "9", "10", "Jack", "Queen", "King")

# A dictionary (lookup table) that maps each suit name to its Unicode symbol character,
# e.g. "Spades" -> "♠". This lets us print a nice symbol instead of just the word "Spades".
SYMBOLS = {"Spades": "\u2660", "Hearts": "\u2665", "Diamonds": "\u2666", "Clubs": "\u2663"}

# This is a "dict comprehension" - a compact way to build a dictionary.
# enumerate(FACES) gives us pairs like (0, "Ace"), (1, "2"), (2, "3"), etc.
# We add 1 to the index so "Ace" becomes 1, "2" becomes 2, ... "King" becomes 13.
# The result is a dictionary like {"Ace": 1, "2": 2, ..., "King": 13} that tells us
# how many "pips" (symbols) to draw for each card, and lets us sort cards by rank.
PIP_COUNT = {face: i + 1 for i, face in enumerate(FACES)}

# How many face values the computer AI is allowed to "remember" at once. This keeps the
# computer's memory limited (more human-like) instead of remembering everything forever.
COMPUTER_MEM_SIZE = 5


# --- Deck mechanics ---
# This section handles building, shuffling, and dealing cards from the deck.

def make_deck():
    """Makes a full deck of suits for each card face value.
    Returns:
        List of (face, suit) card tuples.
    """
    deck = []  # Start with an empty list that we will fill with cards
    for suit in SUITS:          # Loop through each of the 4 suits
        for face in FACES:      # For each suit, loop through all 13 face values
            # Each card is represented as a tuple: (face, suit), e.g. ("Ace", "Spades").
            # By the time both loops finish, deck will have 4 x 13 = 52 cards.
            deck.append((face, suit))
    return deck


def shuffle(deck):
    """Randomly shuffles the deck of cards.
    Args:
        deck: List of card tuples.
    """
    # random.shuffle() rearranges the list "in place" - meaning it changes the original
    # list directly instead of returning a new one. That's why this function doesn't
    # need a "return" statement.
    random.shuffle(deck)


def deal(deck, count):
    """Deals a specified number of cards from the deck.
    Args:
        deck: List of card tuples.
        count: Number of cards to deal.
    Returns:
        The list of cards that had been dealt.
    """
    hand = []  # This will hold the cards we deal out
    # We use min(count, len(deck)) so that if someone asks for more cards than
    # are left in the deck, we don't crash - we just deal as many as we can.
    for i in range(min(count, len(deck))):
        # deck.pop() removes and returns the LAST card in the list. Using pop() (instead of
        # pop(0)) is efficient because removing from the end of a list is fast in Python.
        # Since the deck was shuffled already, it doesn't matter that we always take
        # from the "end" - the order is already random.
        hand.append(deck.pop())
    return hand


def draw(deck, hand, count):
    """Draws cards from the deck and adds to a hand.
    Args:
        deck: The list of cards to draw from.
        hand: The list of cards to add to.
        count: The number of cards to draw.
    Returns:
        List of cards that had been drawn.
    """
    new_cards = deal(deck, count)   # Take cards off the top of the deck
    # hand.extend() adds each item from new_cards individually to hand (unlike
    # hand.append(new_cards), which would add the whole list as one single item).
    hand.extend(new_cards)          # Add those cards onto the end of the given hand list
    return new_cards


# --- Hand utilities ---
# Helper functions for organizing, searching, and modifying a hand of cards.

def hand_sort_key(card):
    """Function for hand sort order.
    Args:
        card: Tuple for card face and suit.
    Returns:
        A tuple of pip count and suit.
    """
    # This function is used as a "key" for sorting. card[0] is the face (like "King"),
    # and PIPS[card[0]] converts that into a number (like 13) so cards sort by rank.
    # We also include card[1] (the suit) as a tie-breaker, so cards of the same rank
    # get sorted alphabetically by suit too.
    return (PIP_COUNT[card[0]], card[1])


def sort_hand(hand):
    """Sorts a hand of cards.
    Args:
        hand: List of cards to sort.
    """
    # hand.sort() rearranges the list in place. The key=hand_sort_key part tells Python
    # to use our custom hand_sort_key() function to decide the order, instead of trying
    # to compare the tuples directly (which wouldn't sort by rank correctly).
    hand.sort(key=hand_sort_key)


def find_face_in_hand(hand, face):
    """Finds all cards in a hand that matches a face value.
    Args:
        hand: List of cards to search.
        face: Face value to match on.
    Returns:
        List of cards from hand that match the face value.
    """
    cards = []  # Will collect any matching cards we find
    for card in hand:
        if card[0] == face:
            # card[0] is the face value part of the (face, suit) tuple
            cards.append(card)
    return cards


def remove_from_hand(hand, cards):
    """Removes all the specified cards from a hand.
    Args:
        hand: List of cards that will be removed from.
        cards: List of cards to remove.
    """
    for card in cards:
        # list.remove() finds the first matching item and deletes it. Since each card
        # in "cards" should also exist in "hand" at this point, this removes them one by one.
        hand.remove(card)


def add_to_hand(hand, cards):
    """Adds cards to a hand.
    Args:
        hand: List of cards to add to.
        cards: List of cards to add to the hand.
    """
    # Just like in draw(), extend() adds each card individually rather than nesting a list.
    hand.extend(cards)


def faces_in_hand(hand):
    """Gets set of distinct face values from a hand.
    Args:
        hand: List of cards.
    """
    # This is a "generator expression" inside set(). For every (face, suit) card in hand,
    # we pull out just the face and put it into a set. A set automatically removes
    # duplicates, so if you have two Kings, "King" only shows up once in the result.
    # This is useful for knowing which values you could ask your opponent for.
    return set(face for face, suit in hand)


# --- Display ---
# Functions in this section are only responsible for printing things to the screen.
# Keeping display code separate from game logic makes the program easier to understand.

def print_card(face, suit):
    """Prints visual representation of a card.
    Args:
        face: Card's face value.
        suit: Card's suit symbol name.
    """
    label = face + " of " + suit  # e.g. "King of Spades"
    # f"{label:17}" pads the label with spaces so it's always 17 characters wide,
    # which makes all the printed cards line up neatly in columns.
    # SYMBOLS[suit] * PIPS[face] repeats the suit symbol as many times as the card's
    # rank (e.g. a "3" prints the symbol three times, a "King" prints it 13 times).
    print(f"  {label:17} : {SYMBOLS[suit] * PIP_COUNT[face]}")


def print_hidden_card():
    """Prints a visual representation of a turned over card."""
    # Used when we don't want to reveal what a card is (e.g. showing the computer's
    # hand to the player, or hiding what the computer drew).
    print("  Card Faced Down   : ?")


def print_hand(name, hand, hidden=False):
    """Prints a player's hand of cards.
    Args:
        name: The name of the player whose cards are being printed.
        hand: List of cards to print.
        hidden: Will print cards facing down if True, else cards will be shown.
    """
    # These if/elif/else branches just pick the correct grammar (singular vs plural)
    # so the message reads naturally whether there are 0, 1, or many cards.
    if len(hand) == 0:
        print(f"{name}'s hand has no cards.")
    elif len(hand) == 1:
        print(f"{name}'s hand has this 1 card:")
    else:
        print(f"{name}'s hand has these {len(hand)} cards:")

    for face, suit in hand:
        # Python lets us "unpack" each (face, suit) tuple straight into two variables
        # as we loop, instead of writing card[0] and card[1] every time.
        if hidden:
            print_hidden_card()
        else:
            print_card(face, suit)
    print()  # Print a blank line afterward to visually separate this from what comes next


def print_drawn_cards(cards, hidden=False):
    """Prints information about cards drawn from the deck.
    Args:
        cards: List of cards to print information about.
        hidden: Card will be drawn turned over if True.
    """
    if len(cards) == 0:
        print("No cards were drawn.")
    elif len(cards) == 1:
        print("This card had been drawn:")
    else:
        print(f"These {len(cards)} cards have been drawn:")
    for face, suit in cards:
        if hidden:
            print_hidden_card()
        else:
            print_card(face, suit)
    print()


def print_turn_status(player_state, computer_state):
    """Prints the current status at the beginning of a turn.
    Args:
        player_state: Dict of the player's state values.
        computer_state: Dict of the computer's state values.
    """
    # We show the player their own cards face-up (default hidden=False),
    # but for the computer we only print how MANY cards it has, not what they are -
    # that would be cheating!
    print_hand("Player", player_state["hand"])
    print(f"Computer's hand has {len(computer_state['hand'])} cards")
    print()


# --- Computer memory ---
# These functions give the computer opponent a simple "memory" so it can play a bit
# smarter than picking completely at random every turn.

def remember(mem_list, face):
    """Adds face value to computer's memory.
    Args:
        mem_list: List of faces computer is remembering.
        face: Face value to add to memory.
    """
    # We check first so we don't add the same face twice - mem_list acts like a
    # set of memories, even though it's stored as a list (a list is used here so
    # we can control removal order later with limit_memory()).
    if face not in mem_list:
        mem_list.append(face)


def forget(mem_list, face):
    """Removes face value from computer's memory.
    Args:
        mem_list: List of faces computer is remembering.
        face: Face value to remove from memory.
    """
    # We check "if face in mem_list" first because list.remove() would raise an
    # error if the item isn't there at all.
    if face in mem_list:
        mem_list.remove(face)


def limit_memory(mem_list, mem_size=COMPUTER_MEM_SIZE):
    """Limits how much the computer remembers and forgets the oldest.
    Args:
        mem_list: List of faces computer is remembering.
        mem_size: Max length of memory list.
    """
    while len(mem_list) > mem_size:
        # pop(0) removes the FIRST (oldest) item in the list. Since new memories get
        # appended to the end, the oldest memory is always at the front - so this keeps
        # only the most recent COMPUTER_MEM_SIZE memories, like a "sliding window".
        mem_list.pop(0)


# --- Book handling ---
# In Go Fish, a "book" is having all 4 cards of the same face value (e.g. all 4 Kings).

def handle_book(state, opponent_state, face, is_computers_turn=False):
    """Handles checking for books (having all four of a face value).
    Args:
        state: Dict of the current player's state.
        opponent_state: Dict of the opponent player's state.
        face: Face value to check for a book.
        is_computers_turn: If it is currently the computer's turn.
    Returns:
        Face value of the book that had been formed, or None if no book.
    """
    if face:
        # "if face:" checks that face isn't None (and isn't an empty string). This guards
        # against being called with no face value to check.
        matches = find_face_in_hand(state["hand"], face)
        if len(matches) == 4:
            # There are only 4 suits, so having 4 matching cards means you have all of them.
            state["books"].append(face)
            # We take the completed set of 4 cards out of the hand since they're now
            # "used up" as a book and no longer part of active gameplay.
            remove_from_hand(state["hand"], matches)
            print(f"{state['name']} made a book of {face}!")
            print()
            if not is_computers_turn:
                # The PLAYER just formed a book, so this face has now completely left
                # their hand. If the computer was remembering that it "heard" the player
                # ask for this face, that memory is now stale (the player can't possibly
                # have any left) - so the computer should forget it.
                forget(opponent_state["heard"], face)
            return face

    return None


# --- Turn mechanics ---
# The core rules of how a turn plays out: asking for a card, going fish, taking cards, etc.

def ensure_can_take_turn(deck, state, hidden=False):
    """Tries to make sure a player can take a turn and handles if hand is empty.

    Args:
        deck: List of cards for the deck.
        state: Dict of current player's state.
        hidden: Cards will be drawn face down if True.
    Returns:
        True if player can take their turn, else False.
    """
    if len(state["hand"]) > 0:
        # Normal case: the player already has cards, so they can just take their turn.
        return True
    if len(deck) > 0:
        # If your hand is empty but the deck still has cards,
        # you draw one card for free before continuing your turn.
        drawn = draw(deck, state["hand"], 1)
        print(f"{state['name']}'s hand was empty, so a card was drawn.")
        print_drawn_cards(drawn, hidden)
        return True

    # If we get here, the hand is empty AND the deck is empty - there's nothing this
    # player can do, so their turn is skipped.
    print(f"{state['name']} has no cards and the deck is empty. Turn skipped.")
    print()
    return False


def go_fish(deck, state, selected_face, is_computers_turn):
    """Has the player "go fish" to draw a card and handle match.
    Args:
        deck: List of cards for the deck.
        state: Dict of current player's state values.
        is_computers_turn: True if it is currently the computer's turn, else false.
        selected_face: Face value that the player had selected.
    Returns:
        True if player gets to take another turn, else False.
        Face of new card drawn from the pile, or None if no card drawn.
    """
    another_turn = False   # Will become True only if the drawn card happens to match
    new_card_face = None   # Will store the face value of whatever card gets drawn
    print("Go fish!")
    print()
    drawn_cards = draw(deck, state["hand"], 1)
    print_drawn_cards(drawn_cards, is_computers_turn)

    if len(drawn_cards) > 0:
        # drawn_cards[0] is the single card we drew, e.g. ("7", "Hearts")
        new_card_face = drawn_cards[0][0]
        # Lucky draw! The player drew exactly the card they asked their opponent for,
        # so by the rules of Go Fish, they get to go again.
        if drawn_cards[0][0] == selected_face:
            another_turn = True
            print(f"{state['name']} got what they wanted!")
            print()
    else:
        # This happens if the deck was already empty and there was nothing left to draw.
        print("A card could not be drawn.")

    # These next lines just print how many cards are left in the deck, again handling
    # singular/plural grammar so the message reads naturally.
    if len(deck) == 0:
        print("The deck is empty.")
    elif len(deck) == 1:
        print("There is now 1 card remaining in the deck.")
    else:
        print(f"There are now {len(deck)} cards remaining in the deck.")
    print()
    # Note: this function returns TWO values at once. Python packs them into a tuple
    # automatically, and whoever calls this function can "unpack" both values at once,
    # e.g. `another_turn, new_card_face = go_fish(...)`.
    return another_turn, new_card_face


def take_cards(state, opponent_state, selected_face, found_cards, is_computers_turn):
    """Has the player take cards from opponent.
    Args:
        state: Dict of the current player's state values.
        opponent_state: Dict of the opponent player's state values.
        selected_face: Face value the player had selected.
        found_cards: Cards found to match the selected face value.
        is_computers_turn: True if it currently is the computer's turn.
    """
    # Together, these two lines move the matching cards from the opponent's hand
    # into the current player's hand.
    remove_from_hand(opponent_state["hand"], found_cards)
    add_to_hand(state["hand"], found_cards)

    if len(found_cards) == 1:
        print(f"{state['name']} took this card:")
    else:
        print(f"{state['name']} took these cards:")
    for face, suit in found_cards:
        print_card(face, suit)
    print()

    if is_computers_turn:
        # If the computer just successfully got the cards it asked for, it no longer
        # needs to "remember" that value as something to ask for again later.
        forget(state["heard"], selected_face)


def redraw_if_empty_hand(deck, state, is_computers_turn):
    """Tries to draw a new card if hand is empty.
    Args:
        deck: List of cards in the deck.
        state: Dict of the current player's state values.
        is_computers_turn: True if it currently is the computer's turn.
    """
    if len(state["hand"]) == 0 and len(deck) > 0:
        # If a player's hand became empty (for example, after successfully taking all
        # matching cards from the opponent) and the deck still has cards, give them
        # one new card so they aren't stuck with nothing.
        new_card = draw(deck, state["hand"], 1)
        if len(new_card) > 0:
            print(f"{state['name']} drew a card because their hand was empty.")
            if not is_computers_turn:
                # Only reveal the actual card if it's the human player's card,
                # otherwise keep the computer's new card secret.
                face, suit = new_card[0]
                print_card(face, suit)
            else:
                print_hidden_card()
            print()


def turn(deck, state, opponent_state, selected_face, is_computers_turn=False):
    """Takes the current player's turn and updates the state of the game.
    Args:
        deck: List of cards in the deck.
        state: Dict of the current player's state values.
        opponent_state: Dict of the opponent player's state values.
        selected_face: The face value the player selected to play.
        is_computers_turn: True if it is currently the computer's turn.
    Returns:
        True if the player gets to take another turn, else False.
    """
    another_turn = False
    new_card_face = None

    # Check whether the opponent actually has any cards matching the face we asked for.
    found_cards = find_face_in_hand(opponent_state["hand"], selected_face)

    if len(found_cards) == 0:
        # The opponent doesn't have any matches, so the current player must "go fish"
        # (draw a card from the deck instead).
        another_turn, new_card_face = go_fish(deck, state, selected_face, is_computers_turn)
    else:
        # The opponent DID have matching cards - the classic Go Fish rule is that a
        # successful ask always earns you another turn.
        another_turn = True
        new_card_face = selected_face
        take_cards(state, opponent_state, selected_face, found_cards, is_computers_turn)

    # After either outcome, check if the new card (whichever face it turned out to be)
    # completed a book of 4 for the current player.
    handle_book(state, opponent_state, new_card_face, is_computers_turn)

    if another_turn:
        print(f"{state['name']} gets to go again!")
        print()

    # If taking cards or drawing emptied this player's hand, give them a fresh card
    # (as long as the deck isn't also empty).
    redraw_if_empty_hand(deck, state, is_computers_turn)

    return another_turn


# --- Player's turn ---
# Functions that handle getting input from the human player.

def ask_face_from_player(valid_faces):
    """Asks the player to pick a value from a set of values.
    Args:
        valid_faces: Set of face values to pick from.
    Returns:
        The face value the player had selected.
    """
    selected_face = None
    # This is a "validation loop" - it keeps asking the player for input until
    # they type something that is actually a valid choice (a face value they hold).
    while selected_face not in valid_faces:
        # .strip() removes any accidental extra spaces the player typed.
        # .capitalize() makes the first letter uppercase and the rest lowercase, so
        # typing "king" or "KING" both get turned into "King" to match our FACES tuple.
        selected_face = input("Ask, do you have a... ").strip().capitalize()
        print()
        if selected_face in valid_faces:
            return selected_face
        print("That is not in your hand.  Try again.")
        print()
        # If the input wasn't valid, we loop back around and ask again.


def players_turn(deck, player_state, computer_state):
    """Processes the player's turn in the game.
    Args:
        deck: List of cards for the deck.
        player_state: Dict of the player's state values.
        computer_state: Dict of the computer's state values.
    """
    print("*** It is now the Player's turn ***")
    print()
    another_turn = True
    # This while loop lets the player keep taking turns in a row as long as they keep
    # getting matches (Go Fish rule: successful asks earn another go).
    while another_turn and ensure_can_take_turn(deck, player_state):
        another_turn = False  # Reset each loop; only set back to True if they succeed again
        print_turn_status(player_state, computer_state)
        # The player can only ask for a face value they themselves currently hold -
        # that's a standard Go Fish rule.
        valid_faces = faces_in_hand(player_state["hand"])
        print(f"These can be asked for: {', '.join(sorted(valid_faces))}")
        selected_face = ask_face_from_player(valid_faces)

        # Whatever the player asks for, the computer "hears" it and
        # remembers it for its own strategy later.
        remember(computer_state["heard"], selected_face)

        another_turn = turn(deck, player_state, computer_state, selected_face)
        # Re-sorting after every turn keeps the player's hand easy to read.
        sort_hand(player_state["hand"])


# --- Computer's turn ---
# Functions that control the simple AI logic for the computer opponent.

def select_face_ai(computer_state):
    """Selects face value from what the computer heard, not recently asked, or picks randomly.
    Args:
        computer_state: Dict of the computer's state values.
    Returns:
        Face selected by computer to play.
    """
    # The computer can only ask for faces it actually holds, same rule as the player.
    valid_faces = faces_in_hand(computer_state["hand"])

    # Strategy 1 (best option): ask for a face value the computer both holds AND has
    # heard the player ask for recently - since the player asking for it suggests
    # the player might have (or want) that card, which the computer can use.
    valid_heard = set(computer_state["heard"]) & valid_faces
    # The "&" operator finds the INTERSECTION of two sets - values present in both.
    if len(valid_heard) > 0:
        # random.choice() needs something indexable like a tuple/list, not a set,
        # which is why we convert it with tuple() first.
        return random.choice(tuple(valid_heard))

    # Strategy 2 (fallback): if there's nothing useful "heard", try asking for
    # something it hasn't already asked for recently, to avoid repeating itself.
    not_asked = valid_faces - set(computer_state["asked"])
    # The "-" operator finds values in valid_faces that are NOT in computer_state["asked"].
    if len(not_asked) > 0:
        return random.choice(tuple(not_asked))

    # Strategy 3 (last resort): if there's nothing smarter to do, just pick any
    # random face value from its own hand.
    else:
        return random.choice(tuple(valid_faces))


def computers_turn(deck, player_state, computer_state):
    """Processes the computer's turn in the game.
    Args:
        deck: List of cards for the deck.
        player_state: Dict of the player's state values.
        computer_state: Dict of the computer's state values.
    """
    print("*** It is now the Computer's turn ***")
    print()
    # Trim the computer's memory lists down to size before it starts thinking,
    # so its "recent memory" stays limited and realistic.
    limit_memory(computer_state["heard"])
    limit_memory(computer_state["asked"])
    print_turn_status(player_state, computer_state)

    another_turn = True
    # hidden=True here means if the computer has to draw a "free" card because
    # its hand was empty, we don't show the player what it drew.
    while another_turn and ensure_can_take_turn(deck, computer_state, hidden=True):
        another_turn = False
        selected_face = select_face_ai(computer_state)
        # Remember what it just asked for, so it can avoid repeating itself (Strategy 2 above).
        remember(computer_state["asked"], selected_face)
        print(f"Do you have a {selected_face}?")
        print()
        # Passing True as the last argument tells turn() this is the computer's turn,
        # which affects things like whether cards get shown or hidden.
        another_turn = turn(deck, computer_state, player_state, selected_face, True)


# --- Main ---
# This is where the overall game gets set up and run from start to finish.

def main():
    """Go Fish Game."""
    deck = make_deck()   # Build a fresh 52-card deck
    shuffle(deck)        # Randomize the order of the deck

    # A "state" dictionary bundles together all the information we need to track
    # for one player, so we can pass it around to functions as a single object
    # instead of many separate variables.
    player_state = {
        "name": "Player",
        "hand": deal(deck, 7),   # Standard Go Fish deals 7 cards to start (with 2 players)
        "books": []              # Will collect any completed 4-of-a-kind sets
    }
    sort_hand(player_state["hand"])

    computer_state = {
        "name": "Computer",
        "hand": deal(deck, 7),
        "books": [],
        "asked": [],   # Faces the computer has already asked the player for (its memory)
        "heard": []    # Faces the computer has heard the player ask for (its memory)
    }

    print("*** Go Fish! ***")
    print()

    # Before the game even starts, check the starting hands in case either player
    # was dealt all 4 of a kind right off the bat (unlikely, but the rules still apply!).
    for face in faces_in_hand(player_state["hand"]):
        handle_book(player_state, computer_state, face)
    for face in faces_in_hand(computer_state["hand"]):
        handle_book(computer_state, player_state, face, True)

    # Randomly choose whose turn it is -- either the computer's or player's.
    currently_players_turn = random.randrange(2) == 0

    # Main game loop: keep playing as long as there are cards left in the deck,
    # OR both players still have cards in hand to play with. The game ends once
    # the deck is empty AND at least one player has run out of cards.
    while len(deck) > 0 or (len(player_state["hand"]) > 0 and len(computer_state["hand"]) > 0):
        if currently_players_turn:
            players_turn(deck, player_state, computer_state)
        else:
            computers_turn(deck, player_state, computer_state)

        # Alternate whose turn it currently is.
        currently_players_turn = not currently_players_turn

        print('-' * 40)  # Prints a dashed line as a visual divider between turns
        print()

    # Once the loop ends, the game is over - show the final results.
    print("*** The game is over! ***")
    print()
    print(f"Player's books: {', '.join(player_state['books'])}")
    print(f"Computer's books: {', '.join(computer_state['books'])}")
    print()

    # Whoever collected the most books (4-of-a-kind sets) wins.
    if len(player_state["books"]) > len(computer_state["books"]):
        print("Player won!")
    elif len(player_state["books"]) < len(computer_state["books"]):
        print("Computer won!")
    else:
        print("It was a draw!")


if __name__ == "__main__":
    # This check means main() only runs when this file is executed directly
    # (like `python go_fish.py`), and NOT if this file were imported as a module
    # into another Python program. It's a very common Python pattern.
    main()