"""
    Name: Maxwell Wilburn
    CS 115U
    Platform: Windows
    simple_calc.py
    Purpose: Programming Project - Module 6
             Create a functional calculator app with a graphical user interface.
             Key bindings implemented: numbers, operators, Enter key for equals,
             Backspace key for clear, and keys (e and p) for constants e and pi.
"""


import math
from tkinter import *

# Constants used to keep button sizing consistent throughout the GUI.
BUTTON_WIDTH = 5 
BUTTON_HEIGHT = 2 

# Padding around buttons and frames
X_PADDING = 5 
Y_PADDING = 5 

# Colors for constant buttons
CONSTANT_BUTTON_COLOR = "#7BEEF8"
CONSTANT_BUTTON_COLOR_ACTIVE = "#2FD9E2"

class Calculator():
    """
    A simple GUI calculator built with Tkinter.

    This class creates a calculator window with number buttons,
    operation buttons, a display field, and logic for performing
    basic arithmetic operations.
    """
    def __init__(self):
        '''
        Initialise the calculator window, display buttons, and instance variables.
        '''

        self.root = Tk()
        self.root.title("Simple Calculator")


        self.window_color = "#404040"
        self.root.configure(bg=self.window_color)

        self.root.config(cursor="hand2")

# AI Usage: I asked ChatGPT to explain the purpose of frames in a GUI and why we tie our buttons to specific frames 
# rather than the enteire window (i.e, self.root.)
# Set up the frames for the display entry field, number buttons, operation buttons, function buttons, and clear/equal buttons.
# Each frame is placed in the grid layout of the main window. 
# ================================================================================================================================================= #
        self.entry_frame = Frame(self.root, bg="#525050", width=340, height=120, bd=1, relief=FLAT)
        self.entry_frame.grid(row=0, column=0, columnspan=4, padx=X_PADDING, pady=Y_PADDING)

        self.display = Entry(self.entry_frame, borderwidth=7, relief=FLAT, width=19, font=("Arial", 13,"italic"), justify=RIGHT)
        self.display.grid(row=0, column=0, columnspan=4,padx=X_PADDING, pady=Y_PADDING)

        self.number_buttons_frame = Frame(self.root, bg= self.window_color, width=300, height=300, bd=0, relief=FLAT)
        self.number_buttons_frame.grid(row=2, column=0, columnspan=3, padx=X_PADDING, pady=Y_PADDING)

        self.operation_buttons_frame = Frame(self.root, bg= self.window_color, width=50, height=300, bd=0, relief=FLAT)
        self.operation_buttons_frame.grid(row=2, column=3, padx=X_PADDING, pady=Y_PADDING)

        self.clear_and_equal_frame = Frame(self.root, bg= self.window_color, width=100, height=50, bd=0, relief=FLAT)
        self.clear_and_equal_frame.grid(row=6, column=1, columnspan=3, padx=X_PADDING, pady=Y_PADDING)

        self.function_buttons_frame = Frame(self.root, bg= self.window_color, width=300, height=50, bd=0, relief=FLAT)
        self.function_buttons_frame.grid(row=1, column=0, columnspan=4, padx=X_PADDING, pady=Y_PADDING)


# Set the overall window size to accommodate all the buttons and the display field, with some extra space for padding.
# ================================================================================================================================================= #
        self.window_width = 312
        self.window_height = 477

        self.root.geometry(f"{self.window_width}x{self.window_height}")

# Create the number buttons, operator buttons, constant buttons, clear and equal buttons, and function buttons by
# calling their methods. Each method is responsible for creating the buttons and placing them in the correct frame and grid position.
# ================================================================================================================================================= #
        self.make_number_buttons()
        self.make_operator_buttons()
        self.constant_buttons()
        self.make_clear_and_equal_buttons()
        self.function_buttons()


# Store the first operand, the second operand, the name of the operation (e.g., addition, division),
# and whether the calculator is ready for new input or not (set as True initially).
# ================================================================================================================================================= #
        self.operand_1 = 0
        self.operand_2 = 0
        self.operation = ''
        self.ready = True


# Set up key bindings for the Enter key (to perform the equal operation), Backspace key (to clear the display),
# number keys (to input numbers), operator keys (to select operations), and keys for constants e and pi.
# Each binding calls the corresponding method when the key is pressed.
# ================================================================================================================================================= #
        self.on_enter_pressed(self.button_equal)
        self.on_backspace_pressed(self.button_clear)
        self.on_number_key_pressed(self.button_click)
        self.on_operator_key_pressed(self.button_add)
        self.on_operator_key_pressed(self.button_subtract)
        self.on_operator_key_pressed(self.button_multiply)
        self.on_operator_key_pressed(self.button_divide)
        self.on_e_pressed(self.button_e)
        self.on_pi_pressed(self.button_pi)

# Make the window non-resizable to maintain the layout and appearance of the calculator.
        self.root.resizable(False, False)

# Start the Tkinter event loop to display the window and respond to user interactions.
        self.root.mainloop()


# END OF CLASS CALCULATOR
#===========================================================================================================================================#





    def button_click(self, number):
        '''
        Displays the number clicked by the user.
        If the calculator has just shown a result, the display is cleared
        before starting a new number.
        :param number:
        '''
        if not self.ready:
            self.display.delete(0, END)
            self.ready = True

        current = self.display.get()
        self.display.delete(0, END)
        self.display.insert(0, str(current) + str(number))


    def button_clear(self):
        '''
        Clears all text from the display field.
        '''
        self.display.delete(0, END)



# AI Usage: Asked ChatGPT to explore ways I could handle errors in which the user tries to perform an operation
# while the entry field is empty, without displaying and error message. The solution I implemented was used in 
# many other methods in this class in which the same type of error could occur.

    def unary_operation(self, operation):
        '''
        Perform a unary operation (like square root or reciprocal) on the current display value.

        :param operation: A string indicating the type of unary operation to perform.
        '''
        if self.display.get() == "":
            self.display.delete(0, END)
            self.ready = False
            return
        try:
            self.operand_1 = float(self.display.get())
        except ValueError:
            self.display.delete(0, END)
            self.ready = False
            return

        self.display.delete(0, END)

        if operation == "reciprocal":
            if self.operand_1 == 0:
                self.display.insert(0, f"Cannot Divide by 0")
            else:
                result = 1 / self.operand_1
                self.display.insert(0, str(result))
                
        elif operation == "x^2":
            result = self.operand_1 ** 2
            self.display.insert(0, str(result))
        
        elif operation == "x^3":
            result = self.operand_1 ** 3
            self.display.insert(0, str(result))
        
        elif operation == "square_root":
            if self.operand_1 < 0:
                self.display.insert(0, f"Invalid Operation")
            else:
                result = math.sqrt(self.operand_1)
                self.display.insert(0, str(result))

        self.ready = False



    def button_equal(self):
        '''
        Perform the selected arithmetic operation and display the result.

        This method uses the stored first operand, reads the second operand
        from the display, and then applies the selected operation.
        '''
        if self.display.get() == "":
            self.display.delete(0, END)
            self.ready = False
            return
        try:
            self.operand_2 = float(self.display.get())
        except ValueError:
            self.display.delete(0, END)
            self.display.insert(0, f"Invalid Input")
            self.ready = False
            return


        self.display.delete(0, END)
        
        if self.operation == "addition":
            result = self.operand_1 + self.operand_2
            self.display.insert(0, str(result))

        elif self.operation == "subtraction":
            result = self.operand_1 - self.operand_2
            self.display.insert(0, str(result))

        elif self.operation == "multiplication":
            result = self.operand_1 * self.operand_2
            self.display.insert(0, str(result))

        elif self.operation == "division":
            if self.operand_2 == 0:
                self.display.insert(0, f"Cannot Divide by 0")
            else:
                result = self.operand_1 / self.operand_2
                self.display.insert(0, str(result))
                
        # After showing the result, the next number pressed should begin fresh input.
        self.ready = False


    def button_add(self):
        """
        Store the current display value as the first operand
        and set the operation to addition.
        """
        if self.display.get() == "":
            self.display.delete(0, END)
            return
        try:
            self.operand_1 = float(self.display.get())
        except ValueError:
            self.display.delete(0, END)
            self.display.insert(0, f"Invalid Input")
            return

        self.operation = "addition"
        self.display.delete(0, END)



    def button_subtract(self):
        """
        Store the current display value as the first operand
        and set the operation to subtraction.
        """
        if self.display.get() == "":
            self.display.delete(0, END)
            return
        try:
            self.operand_1 = float(self.display.get())
        except ValueError:
            self.display.delete(0, END)
            self.display.insert(0, f"Invalid Input")

            return

        self.operation = "subtraction"
        self.display.delete(0, END)


    def button_multiply(self):
        """
        Store the current display value as the first operand
        and set the operation to multiplication.
        """
        if self.display.get() == "":
            self.display.delete(0, END)
            return
        try:
            self.operand_1 = float(self.display.get())
        except ValueError:
            self.display.delete(0, END)
            self.display.insert(0, f"Invalid Input")
            return

        self.operation = "multiplication"
        self.display.delete(0, END)


    def button_divide(self):
        """
        Store the current display value as the first operand
        and set the operation to division.
        """
        if self.display.get() == "":
            self.display.delete(0, END)
            return
        try:
            self.operand_1 = float(self.display.get())
        except ValueError:
            self.display.delete(0, END)
            self.display.insert(0, f"Invalid Input")
            return
        self.operation = "division"
        self.display.delete(0, END)


    def button_pi(self):
        """
        Insert the value of pi into the display.
        """
        self.display.delete(0, END)
        self.display.insert(0, str(math.pi))


    def button_e(self):
        """
        Insert the value of e into the display.
        """
        self.display.delete(0, END)
        self.display.insert(0, str(math.e))


    def function_buttons(self):
        """
        Create buttons for functions like reciprocal and x^2, and set their functionality.
        """

        reciprocal_button = Button(self.function_buttons_frame,
                                   text="1/x",
                                   width=BUTTON_WIDTH, height=BUTTON_HEIGHT,
                                   bg="#7B53F1",
                                   activebackground="#8841DF",
                                   fg="black",
                                   bd=3,
                                   font=("Arial", 12, "bold"),
                                   command=lambda: self.unary_operation("reciprocal"))
        reciprocal_button.grid(row=1, column=0, padx=X_PADDING, pady=Y_PADDING)

        x_squared_button = Button(self.function_buttons_frame,
                                  text="x\u00B2",
                                  width=BUTTON_WIDTH, height=BUTTON_HEIGHT,
                                  bg="#7B53F1",
                                  activebackground="#8841DF",
                                  fg="black",
                                  font=("Arial", 12, "bold"),
                                  relief=RAISED,
                                  command=lambda: self.unary_operation("x^2"))
        x_squared_button.grid(row=1, column=2, padx=X_PADDING, pady=Y_PADDING)

        square_root_button = Button(self.function_buttons_frame,
                                    text="√x",
                                    width=BUTTON_WIDTH, height=BUTTON_HEIGHT,
                                    bg="#7B53F1",
                                    activebackground="#8841DF",
                                    fg="black",
                                    font=("Arial", 12, "bold"),
                                    relief=RAISED,
                                    command=lambda: self.unary_operation("square_root"))
        square_root_button.grid(row=1, column=1, padx=X_PADDING, pady=Y_PADDING)

        x_cubed_button = Button(self.function_buttons_frame,
                                    text="x\u00B3",
                                    width=BUTTON_WIDTH, height=BUTTON_HEIGHT,
                                    bg="#7B53F1",
                                    activebackground="#8841DF",
                                    fg="black",
                                    font=("Arial", 12, "bold"),
                                    relief=RAISED,
                                    command=lambda: self.unary_operation("x^3"))
        x_cubed_button.grid(row=1, column=3, padx=X_PADDING, pady=Y_PADDING)


    def constant_buttons(self):
        """
        Create buttons for constants pi and e, and set their functionality.
        """
        constant_buttons = {"π": (5, 1, self.button_pi), "e": (5, 2, self.button_e)}
        for constant, (row, col, command) in constant_buttons.items():
            button = Button(self.number_buttons_frame,
                           text=constant,
                           width=BUTTON_WIDTH, height=BUTTON_HEIGHT,
                           fg="black",
                           bg= CONSTANT_BUTTON_COLOR,
                           activebackground=CONSTANT_BUTTON_COLOR_ACTIVE,
                           font=("Aptos", 12, "bold"),
                           relief=RAISED,
                           command=command)
            button.grid(row=row, column=col, padx=X_PADDING, pady=Y_PADDING)


    def make_number_button(self, number):
        """
        Create and return a number button for the calculator.

        :param number: The digit to display on the button.

        :return: Tkinter Button widget configured for that number.
              """
        return Button(self.number_buttons_frame,
               text=f"{number}", 
               width=BUTTON_WIDTH, height=BUTTON_HEIGHT,
               bg = "lightgray",
               fg = "black",
               activebackground="#F8F8FF",
               relief=RAISED,
               bd=3,
               font=("Arial", 12, "bold"),
               command=lambda: self.button_click(number)
               )


    def make_number_buttons(self):
        """
        Create all number buttons and place them in their proper grid positions.
        """
        button_numbers = {0: (5, 0),
                          1: (4, 0),
                          2: (4, 1),
                          3: (4, 2),
                          4: (3, 0),
                          5: (3, 1),
                          6: (3, 2),
                          7: (2, 0),
                          8: (2, 1),
                          9: (2, 2),
                         }

        for number, grid_position in button_numbers.items():
            button = self.make_number_button(number)   
            row, col = grid_position
            button.grid(row=row, column=col, padx=X_PADDING, pady=Y_PADDING)


    def make_operator_buttons(self):
        """
        Create the operation and control buttons and place them in the grid.
        """
        other_buttons = {"+": (4,3, self.button_add),
                         "-": (3,3, self.button_subtract),
                         "\u00D7":(2,3, self.button_multiply),
                         "\u00F7": (1,3, self.button_divide),}

        for operator, grid_position in other_buttons.items():
            row, col, comd = grid_position
            button = Button(self.operation_buttons_frame,
                            text=operator,
                            width=BUTTON_WIDTH, height=BUTTON_HEIGHT,
                            bg="#EBCB13",
                            fg="black",
                            activebackground="#FFF700",
                            relief=RAISED,
                            bd=3,
                            font=("Arial", 12, "bold"),
                            command=comd)
            button.grid(row=row, column=col, padx=X_PADDING, pady=Y_PADDING)

            
    def make_clear_and_equal_buttons(self):
        """
        Create the clear and equal buttons and place them in the grid.
        """

        clear_button = Button(self.clear_and_equal_frame,
                              text="Clear",
                              width=BUTTON_WIDTH, height=BUTTON_HEIGHT,
                              bg="#EB3C3C",
                              fg="black",
                              activebackground="#FF0000",
                              relief=RAISED,
                              bd=4,
                              font=("Aptos", 12, "bold"),
                              command=self.button_clear)
        clear_button.grid(row=0, column=2, padx=X_PADDING, pady=Y_PADDING)


        equal_button = Button(self.clear_and_equal_frame,
                              text="=",
                              width=13, height=BUTTON_HEIGHT,
                              bg="#A1F94E",   
                              activebackground="#7FFF00",
                              fg="black",
                              relief=RAISED,
                              bd=4,
                              font=("Aptos", 12, "bold"),
                              command=self.button_equal)
        equal_button.grid(row=0, column=0, columnspan=2, padx=X_PADDING+4, pady=Y_PADDING)


# AI Usage: I discussed what the lambda function does in the context of key bindings with ChatGPT,
# and how it allows me to pass the correct function to be called when a key is pressed.

    def on_enter_pressed(self, event):
        """
        Perform the equal operation when the Enter key is pressed.
        """
        self.root.bind("<Return>", lambda event: self.button_equal())


    def on_backspace_pressed(self, event):
        """
        Clear the display when the Backspace key is pressed.
        """
        self.root.bind("<BackSpace>", lambda event: self.button_clear())


    def on_number_key_pressed(self, event):
        """
        Bind the number keys (0-9) to their corresponding button click functions.
        """
        for number in range(10):
            self.root.bind(str(number), lambda event, num=number: self.button_click(num))


    def on_operator_key_pressed(self, event):
        """
        Bind the operator keys (+, -, *, /) to their corresponding button functions.
        """
        operators = {"+": self.button_add,
                     "-": self.button_subtract,
                     "*": self.button_multiply,
                     "/": self.button_divide}

        for operator, command in operators.items():
            self.root.bind(operator, lambda event, cmd=command: cmd())


    def on_e_pressed(self, event):
        """
        Insert the value of e when the 'e' key is pressed.
        """
        self.root.bind("e", lambda event: self.button_e())


    def on_pi_pressed(self, event):
        """
        Insert the value of pi when the 'p' key is pressed.
        """
        self.root.bind("p", lambda event: self.button_pi())






if __name__ == "__main__":
    app = Calculator()



