# ==================================================================================
# By Bryce Summers on 10.31.2024
# Last Updated     on 11.4.2024
#
# Starting with my Deck class from my Simulation_LandDrops.py file (from 2.21.2024).
# Estimates how many resources will be needed to find a card in a Magic Deck,
# given access to cantrips, multiple copies of the card, etc.
# ==================================================================================

import random
import math

class Deck:

    def random_shuffled(x):
        x = list(x)
        random.shuffle(x)
        return x

    def __init__(self, cardList, shuffleFunction = random_shuffled):
        self._cardStack = list(reversed(cardList))
        self._shuffle   = shuffleFunction

    def __str__(self):
        return str(list(reversed(self._cardStack)))

    # Permutes the cards in this deck according to the current _shuffle function.
    def shuffle(self):
        self._cardStack = self._shuffle(self._cardStack)

    # Pops the top card off of the stack.
    def draw(self):
        if self.empty(): return None
        return self._cardStack.pop()

    def empty(self):
        return len(self._cardStack) == 0

    # Allows for cards to be added.
    # card:String
    def push(self, card):
        self._cardStack.append(card);

    def push_bottom(self, card):
        self._cardStack.insert(0, card) # Would be much more efficient using a Python deque...

    def remove(self, card):
        self._cardStack.remove(card)

    """
    # Allows for cards to be added.
    # cards: string or [string].
    def push(self, cards, quantity = 1):

        # Deal with singleton.
        if type(cards) is not list:
            cards = [cards]

        # Duplicate list elements according to quantity.
        cards = cards * quantity;

        for card in cards:
            self._cardStack.push(card);
    """




# Simulates a Player executing a process using a deck.
class Player:
    def __init__(self, lastTurn = 60):
        self._library = None
        self._hand    = None
        self._firstTurn = None
        self._untappedLandsOnBattleField = None
        self._tappedLandsOnBattleField   = None

        self._lastTurn = lastTurn # Cutoff. Maybe it should be 60 by default.
        self._landsWanted = 3

    # Deck --> Result
    def play(self, decklist):
        self._library = Deck(decklist)
        self._hand = []
        self._result = Result()
        self._firstTurn = True
        self._untappedLandsOnBattleField = 0
        self._tappedLandsOnBattleField   = 0

        # Start Playing the game.
        self.shuffleLibrary()
        for x in range(7):
            self.drawACard()

        turn = 1
        while not self.turn() and turn <= self._lastTurn: turn += 1

        return self._result

    def __str__(self):
        return str(self._library) + ", " + str(self._hand)


    # Returns True if we found the Needle this turn.
    def turn(self):
        # Start Turn.
        self._result.startTurn()

        # Untap all lands.
        self.untap()

        # Draw Step.
        cardDrawnFromLibrary = self.drawACard()
        if not cardDrawnFromLibrary: return True # End the simulation.

        # Check if we have the needle in hand at start of main phase of this turn.
        if "Needle" in self._hand: return True

        # Otherwise, Try to play a land.
        if "Land" in self._hand:
            self._untappedLandsOnBattleField += 1
            self._hand.remove("Land")
        
        if self.landCount() == self.turnCount():
            self._result.hitLandDrop()

        # Play cards until there are no cards left to play.
        while self.playACard(): 
            if "Needle" in self._hand:
                return True # Found it!

        self._result.endTurn()

        # Needle was not found this turn.
        return False

    # Returns True if successful. False otherwise.
    def drawACard(self):
        if self._firstTurn:
            self._firstTurn = False
        elif self._library.empty():
            return False
        else:
            self._hand.append(self._library.draw())
            return True

    def shuffleLibrary(self):
        self._library.shuffle()

    def untap(self):
        self._untappedLandsOnBattleField += self._tappedLandsOnBattleField
        self._tappedLandsOnBattleField = 0

    def tapLands(self, quantity):
        self._tappedLandsOnBattleField   += quantity
        self._untappedLandsOnBattleField -= quantity
        self._result.spendMana(quantity)

    def landCount(self):
        return self._tappedLandsOnBattleField + \
               self._untappedLandsOnBattleField

    def turnCount(self):
        return self._result.turnFound()

    # Returns True if a card was played.
    # Returns False if no card was played.
    def playACard(self):

        if "Eladamri's Call" in self._hand and self._untappedLandsOnBattleField >= 2:
            self.tapLands(2)
            self._hand.remove("Eladamri's Call")
            
            self._library.remove("Needle")
            self._hand.append("Needle")

            return True

        # Try to play Enlightened Tutor
        if "Enlightened Tutor" in self._hand and self._untappedLandsOnBattleField >= 1:
            
            self.tapLands(1)
            self._hand.remove("Enlightened Tutor")
            
            self._library.remove("Needle")
            self._library.push("Needle")

            return True

        # Try to play Once Upon a Time.
        if "Once Upon A Time" in self._hand and self._untappedLandsOnBattleField >= 2:
            
            self.tapLands(2)
            self._hand.remove("Once Upon A Time")
            
            top5 = []
            top5.append(self._library.draw())
            top5.append(self._library.draw())
            top5.append(self._library.draw())
            top5.append(self._library.draw())
            top5.append(self._library.draw())

            if "Needle" in top5: 
                self._hand.append("Needle")
                top5.remove("Needle")
            
            # Otherwise, snag a land.
            if "Land" in top5:
                self._hand.append("Land")
                top5.remove("Land")

            # Otherwise, put the cards back on the bottom of the deck.
            # Note: Do not shuffle.
            while len(top5) > 0:
                self._library.push_bottom(top5.pop())

            return True

        # Try to play Ponders.
        if "Ponder" in self._hand and self._untappedLandsOnBattleField >= 1:
            
            self.tapLands(1)
            self._hand.remove("Ponder")

            top3 = []
            top3.append(self._library.draw())
            top3.append(self._library.draw())
            top3.append(self._library.draw())

            draw = True

            choice = self.bestCard(top3)
            if choice != None:
                self._hand.append(choice)
                top3.remove(choice)
                draw = False

            # Otherwise, put the cards back and shuffle the deck.

            while len(top3) > 0:
                self._library.push(top3.pop())
            
            # Always assume the deck will be shuffled via ponder's effect or fetchland.
            self._library.shuffle()
            
            if draw:
                # Draw a card if no card was chosen from on top.
                self.drawACard()

            return True

        # Try to play Brainstorms.
        if "Brainstorm" in self._hand and self._untappedLandsOnBattleField >= 1:
            
            self.tapLands(1)
            self._hand.remove("Brainstorm")

            
            self.drawACard()
            self.drawACard()
            self.drawACard()

            # Put the 2 worst cards back.
            for x in range(2):
                self._library.push(self._hand.remove(self.worstCard(self._hand)))
            
            # Always assume the deck will be shuffled via a fetchland.
            self._library.shuffle()

            return True

        # Try to play preordain.
        if "Preordain" in self._hand and self._untappedLandsOnBattleField >= 1:
            
            self.tapLands(1)
            self._hand.remove("Preordain")
            
            top2 = []
            top2.append(self._library.draw())
            top2.append(self._library.draw())

            choice = self.bestCard(top2)
            if choice != None:
                self._hand.append(choice)
                top2.remove(choice)
            else:
                # Draw 3rd card from deck if no card was chosen.
                self.drawACard()
                    
            # Tuck any non-chosen card under bottom of deck.
            while len(top2) > 0:
                self._library.push_bottom(top2.pop())

            return True

        

        return False # No Cards were played!

    # Returns the name of the best card found in the cardList.
    def bestCard(self, cardList):

        if "Needle" in cardList: return "Needle"

        # Use cantrips to find lands.
        if self.landCount() < self._landsWanted:
            if "Land" in cardList: return "Land"

        cards = [
                    "Needle",
                    "Eladamri's Call",
                    "Enlightened Tutor",
                    "Once Upon A Time",
                    "Ponder",
                    "Preordain",
                    "Brainstorm",
                ]

        for card in cards:
            if card in cardList: return card

        return None

    # Assumes that cardList is of length >= 1.
    # Returns the name of the least desired card in this cardList if it represents a hand of cards.
    def worstCard(self, cardList):

        if "Other" in cardList: return "Other"

        if cardList.count("Land") > 1: return "Land"

        if self._untappedLandsOnBattleField < 2 and "Once Upon A Time" in cardList:
            return "Once Upon A Time"

        cards = [
                    "Preordain",
                    "Brainstorm",
                    "Ponder",
                    "Once Upon A Time",
                    "Enlightened Tutor",
                    "Eladamri's Call",
                    "Needle",
                ]

        for card in cards:
            if card in cardList: return card

        return cardList[0]

class Result:

    def __init__(self):
        self._manaSpent = 0
        self._turnFound = 0
        #self._trials   = 0
        self._landDropsHit = 0

    def __str__(self):
        return f"Mana: {self._manaSpent}, Turns: {self._turnFound}"

    def startTurn(self):
        self._turnFound += 1

    def endTurn(self):
        #self._turnFound += 1
        pass

    def spendMana(self, quantity = 1):
        self._manaSpent += quantity

    #def newTrial(self): self._trial += 1

    def turnFound(self): return self._turnFound
    def manaSpent(self): return self._manaSpent
    #def trials   (self): return self._trials

    def hitLandDrop(self):
        self._landDropsHit += 1

    def landDropsHit(self):
        return self._landDropsHit



def testDeckList(haystack, lastTurn = 60):
    player = Player(lastTurn)
    results = []

    # Play many games.
    for x in range(100000):
        result = player.play(haystack)
        results.append(result)

    # ([Result], (Result) -> Int) --> None (prints output.)
    def v(results, value):
        # Process all of the results, then print to user.
        all_values = []

        for result in results:
            all_values.append(value(result))

        all_values.sort()
        return all_values

    """
    calculateStatistics(v(results, lambda r : r.turnFound   ()), "Turns", "<=")
    print("\n")
    calculateStatistics(v(results, lambda r : r.manaSpent   ()), "Mana ", "<=")
    print("\n")
    """

    values = v(results, lambda r : r.landDropsHit())
    values.reverse()
    #calculateStatistics(values, "Land Drops Hit", ">=")
    #print("\n")

    land3 = len([x for x in values if x >= 3])
    land2 = (values.count(2) + land3)
    land1 = (values.count(1) + land2)

    N = len(values)
    print(f"Hits 3 Land Drops: {land3 / N:.2f}")
    print(f"Hits 2 Land Drops: {land2 / N:.2f}")
    print(f"Hits 1 Land Drops: {land1 / N:.2f}")


"""
# ([Result], (Result) -> Int) --> None (prints output.)
def _calculateStatistics(results, value, name):
    # Process all of the results, then print to user.
    total_turns = 0
    turns_min = len(results)
    turns_max = 0

    for result in results:
        turns = value(result)
        total_turns += turns
        turns_min = min(turns_min, turns)
        turns_max = max(turns_max, turns)
    turns_expected_value = total_turns / len(results)

    turns_variance = 0
    for result in results:
        difference = value(result) - turns_expected_value
        turns_variance += difference ** 2
    turns_variance = turns_variance / len(results)
    turns_standard_deviation = turns_variance ** .5

    print(f"{name}: Expected Value     {turns_expected_value:.2f}")
    print(f"{name}: Standard Deviation {turns_standard_deviation:.2f}")
    print(f"{name}: Lower Bound        {turns_min}")
    print(f"{name}: Upper Bound        {turns_max}")
    print()
    low  = max(turns_min, turns_expected_value - turns_standard_deviation)
    high = min(turns_max, turns_expected_value + turns_standard_deviation)
    print(f"68.2% of the time: [{low :.2f} - " +\
                              f"{high:.2f}]")
    low  = max(turns_min, turns_expected_value - 2*turns_standard_deviation)
    high = min(turns_max, turns_expected_value + 2*turns_standard_deviation)
    print(f"95.4% of the time: [{low :.2f} - " +\
                              f"{high:.2f}]")
    print(f"100 % of the time: [{turns_min:.2f} - " +\
                              f"{turns_max:.2f}]")
    """


# Given sorted list of numbers, from most desirable to least desirable.
# ([number], String("turns"), String("<="))
def calculateStatistics(all_values, name, op_name):

    N  = len(all_values)
    EV = sum(all_values) / N
    
    print(f"{name}: Expected Value     {EV:.2f}")
    #print(f"{name}: Standard Deviation {turns_standard_deviation:.2f}")
    print(f"{name}: Lower Bound        {all_values[ 0]}")
    print(f"{name}: Upper Bound        {all_values[-1]}")
    print()

    high_68 = all_values[math.floor(N*.682)]
    high_80 = all_values[math.floor(N*.8)]
    high_95 = all_values[math.floor(N*.954)]
    print(f"68.2% of the time: {op_name} {high_68:.2f}")
    print(f"80.0% of the time: {op_name} {high_80:.2f}")
    print(f"95.4% of the time: {op_name} {high_95:.2f}")
    print(f"100 % of the time: {all_values[0]} - " +\
                             f"{all_values[-1]}")
    


# Experiment with various decks.
deck = ["Needle"]*4 + ["Other"]*56
"""
Turns: Expected Value     7.51
Turns: Standard Deviation 8.40
Turns: Lower Bound        1
Turns: Upper Bound        50

68.2% of the time: [1.00 - 15.91]
95.4% of the time: [1.00 - 24.31]
100 % of the time: [1.00 - 50.00]
[Finished in 953ms]
"""

deck = ["Needle"]*8 + ["Other"]*52
"""
Turns: Expected Value     3.11
Turns: Standard Deviation 4.10
Turns: Lower Bound        1
Turns: Upper Bound        38

68.2% of the time: [1.00 - 7.21]
95.4% of the time: [1.00 - 11.31]
100 % of the time: [1.00 - 38.00]
[Finished in 879ms]
"""

"""
for x in range(1, 61):
    deck = ["Needle"]*x + ["Other"]*(60 - x)
    print(f"#Drawing >=1 of {x} needles from a 60 card deck.")
    testDeckList(deck)
    print("\n"*4)
"""

# See "Expected turn to draw 1 of x cards from a 60 card deck in MTG.txt"
# Computed with no mulligan.

lands   = 25
needles = 1
ponders = 0
brainstorms = 0
onceUponATimes = 0
preordains = 0
enlightenedTutors = 0
eladamrisCalls = 0
other   = 60 - ponders - needles - lands - onceUponATimes - preordains - enlightenedTutors - brainstorms - eladamrisCalls

deck = ["Land"]*lands + ["Needle"]*needles + ["Other"]*other + ["Ponder"]*ponders + ["Brainstorm"]*brainstorms + \
       ["Once Upon A Time"]*onceUponATimes + ["Preordain"]*preordains + ["Enlightened Tutor"]*enlightenedTutors + ["Eladamri's Call"]*eladamrisCalls
#testDeckList(deck)

#print(len(deck))

#deck = ["Land"]*20 + ["Needle"]*4 + ["Other"]*36


for x in range(0, 60):
    lands   = x
    needles = 0
    ponders = 0
    brainstorms = 0
    onceUponATimes = 4
    preordains = 0
    enlightenedTutors = 0
    eladamrisCalls = 0
    other   = 60 - ponders - needles - lands - onceUponATimes - preordains - enlightenedTutors - brainstorms - eladamrisCalls

    deck = ["Land"]*lands + ["Needle"]*needles + ["Other"]*other + ["Ponder"]*ponders + ["Brainstorm"]*brainstorms + \
           ["Once Upon A Time"]*onceUponATimes + ["Preordain"]*preordains + ["Enlightened Tutor"]*enlightenedTutors + ["Eladamri's Call"]*eladamrisCalls
    print(f"\n\n# -- {lands} lands.")
    testDeckList(deck, 3) # only test first 3 turns.