## @file evaluator.py
#  @brief Contains the mathematical evaluation engine for the calculator.
#  @details Implements Dijkstra's Shunting Yard algorithm to convert infix mathematical 
#           expressions to Postfix (Reverse Polish Notation), and evaluates them using a stack.

import mathlib

## @class ExpressionEvaluator
#  @brief Evaluates mathematical expressions using Postfix (RPN) and a Stack.
#  @details Handles operator precedence, unary/binary operations, and parenthesis matching.
class ExpressionEvaluator:
    """Evaluates mathematical expressions using Postfix (RPN) and a Stack."""
    
    # Operator precedence
    PRECEDENCE = {
        '+': 1, '-': 1,
        '*': 2, '/': 2,
        '^': 3, '√': 3,
        'log': 3 
    }

    ## @brief Determines if a given string token is a valid numeric value.
    #  @param token The string token to check.
    #  @return True if the token can be successfully cast to a float, False otherwise.
    @staticmethod
    def is_number(token: str) -> bool:
        try:
            float(token)
            return True
        except ValueError:
            return False
    
    ## @brief Converts an infix token list to postfix (RPN) using the Shunting Yard algorithm.
    #  @param tokens A list of mathematical tokens in standard infix notation.
    #  @return A list of mathematical tokens reordered into postfix notation.
    @classmethod
    def infix_to_postfix(cls, tokens: list[str]) -> list[str]:
        """Converts an infix token list to postfix using Shunting Yard."""
        output_queue = []
        operator_stack = []

        for token in tokens:
            if cls.is_number(token) or token == '!':
                output_queue.append(token)
            elif token == '(':
                operator_stack.append(token)
            elif token == ')':
                while operator_stack and operator_stack[-1] != '(':
                    output_queue.append(operator_stack.pop())
                if operator_stack:
                    operator_stack.pop() # Discard '('

                while operator_stack and operator_stack[-1] in ['ln', 'exp', 'u-']:
                    output_queue.append(operator_stack.pop())


            elif token in cls.PRECEDENCE:
                while (operator_stack and operator_stack[-1] != '(' and
                       cls.PRECEDENCE.get(operator_stack[-1], 0) >= cls.PRECEDENCE[token]):
                    output_queue.append(operator_stack.pop())
                operator_stack.append(token)
            elif token in ['ln', 'exp', 'u-']:
                # Unary functions have highest precedence
                operator_stack.append(token)

        while operator_stack:
            output_queue.append(operator_stack.pop())

        return output_queue
    ## @brief Evaluates a postfix list of tokens using a mathematical stack.
    #  @param postfix_tokens A list of tokens in postfix notation.
    #  @return The final calculated float value of the expression.
    #  @raises ValueError If the expression has missing operands, unknown tokens, or unbalanced operators.
    @classmethod
    def evaluate_postfix(cls, postfix_tokens: list[str]) -> float:
        """Evaluates a postfix list of tokens using a stack."""
        stack = []

        for token in postfix_tokens:
            if cls.is_number(token):
                stack.append(float(token))
                
            # Handle Binary Operators
            elif token in ['+', '-', '*', '/', '^', '√', 'log']:
                if len(stack) < 2:
                    raise ValueError(f"Invalid expression for {token}")
                
                right = stack.pop()
                left = stack.pop()

                if token == '+': stack.append(mathlib.add(left, right))
                elif token == '-': stack.append(mathlib.subtract(left, right))
                elif token == '*': stack.append(mathlib.multiply(left, right))
                elif token == '/': stack.append(mathlib.divide(left, right))
                elif token == '^': stack.append(mathlib.pow(left, int(right) if right.is_integer() else right))
                elif token == '√': stack.append(mathlib.root_n(right, left)) 
                elif token == 'log': stack.append(mathlib.log(right, left))  
            
            # Handle Unary Operators
            elif token in ['ln', 'exp', '!','u-']:
                if len(stack) < 1:
                    raise ValueError(f"Missing operand for {token}")
                
                val = stack.pop()
                
                if token == 'ln': stack.append(mathlib.ln(val))
                elif token == 'exp': stack.append(mathlib.exp(val))
                elif token == 'u-': stack.append(-val)
                elif token == '!': 
                    if not float(val).is_integer():
                        raise TypeError("factorial: n must be int")
                    stack.append(mathlib.factorial(int(val)))
            else:
                raise ValueError(f"Unknown token: {token}")

        if len(stack) != 1:
            raise ValueError("Unbalanced operators and operands")

        return stack[0]
        
    ## @brief Main entry point for calculation.
    #  @details Pipes the input through infix_to_postfix and evaluate_postfix.
    #  @param tokens A list of user-inputted strings representing the calculation.
    #  @return The final calculated result as a float (returns 0.0 if tokens list is empty).
    @classmethod
    def calculate(cls, tokens: list[str]) -> float:
        """Main entry point: infix -> postfix -> evaluate."""
        if not tokens:
            return 0.0
        #evaluating - infront of ()
        processed_tokens = []
        for i, token in enumerate(tokens):
            if token == '-':
     
                if i == 0 or tokens[i-1] in ['+', '-', '*', '/', '^', '√', 'log', '(']:
                    processed_tokens.append('u-')
                else:
                    processed_tokens.append(token)
            else:
                processed_tokens.append(token)

        postfix = cls.infix_to_postfix(processed_tokens)
        return cls.evaluate_postfix(postfix)