from __future__ import annotations

import re
from decimal import ROUND_HALF_UP, Decimal

NUM_STR_PATTERN = r"[+-]?\d+(?:\.\d+)?(?:e[+-]?\d+)?"


# A class to represent a number with the significant figures and decimal places inferred
class SigFigNum:
    num_str: str
    "The original string representation entered by the user"
    sigfigs: int
    "The number of significant figures in the number"
    decimals: int
    "The signed decimal-place position of the last significant digit in the number"
    value: Decimal
    "The actual numerical value of the number"

    # Special methods
    def __init__(self, num: str):
        # Set self.num_str to num.lower for scientific notation - 12E3 -> 12e3
        self.num_str = num.lower()
        # Validate the number string passed in
        self._validate_num()
        # Calculate significant figures and decimals
        self._calculate_sigfig()
        self._calculate_decimals()
        # Calculate actual numerical value
        self._calculate_value()

    def __repr__(self):
        return f"{type(self).__name__}(num={self.num_str!r})"

    def __str__(self):
        return f"{type(self).__name__}:\n\tnum_str: {self.num_str}\n\tsigfigs: {self.sigfigs}\
        \n\tdecimals: {self.decimals}\n\tvalue: {self.value}"

    # Mathematical special methods
    def __add__(self, value: SigFigNum) -> SigFigNum:
        "Add two SigFigNum objects and return another SigFigNum"
        # Validate value
        valid = self._validate_sigfignum(value)
        if valid is not None:
            return valid
        # Compute numerical value
        num_value = self.value + value.value
        # Compute presicion in decimals
        rounded_decimals = min(self.decimals, value.decimals)
        # Round numerical value as string
        num_string = self._round_num_string(
            num_value, rounded_decimals, type="decimals"
        )
        # Pass numerical value into SigFigNum constructor and return
        return SigFigNum(num_string)

    def __sub__(self, value: SigFigNum) -> SigFigNum:
        "Subtract value SigFigNum from self SigFigNum and return another SigFigNum"
        # Validate value
        valid = self._validate_sigfignum(value)
        if valid is not None:
            return valid
        # Compute numerical value
        num_value = self.value - value.value
        # Compute prescion in decimals
        rounded_decimals = min(self.decimals, value.decimals)
        # Round numerical value as string
        num_string = self._round_num_string(
            num_value, rounded_decimals, type="decimals"
        )
        # Pass numerical value into SigFigNum constructor and return
        return SigFigNum(num_string)

    def __mul__(self, value: SigFigNum) -> SigFigNum:
        "Multiply two SigFigNum objects and return another SigFigNum"
        # Validate value
        valid = self._validate_sigfignum(value)
        if valid is not None:
            return valid
        # Compute numerical value
        num_value = self.value * value.value
        # Compute precision in sig figs
        rounded_sigfigs = min(self.sigfigs, value.sigfigs)
        # Round numerical value as string
        num_string = self._round_num_string(num_value, rounded_sigfigs, type="sigfigs")
        # Pass numerical value into SigFigNum constructor and return
        return SigFigNum(num_string)

    def __truediv__(self, value: SigFigNum) -> SigFigNum:
        "Divide self SigFigNum by value SigFigNum and return another SigFigNum"
        # Validate value
        valid = self._validate_sigfignum(value)
        if valid is not None:
            return valid
        # Compute numerical value
        num_value = self.value / value.value
        # Compute precision in sig figs
        rounded_sigfigs = min(self.sigfigs, value.sigfigs)
        # Round numerical value as string
        num_string = self._round_num_string(num_value, rounded_sigfigs, type="sigfigs")
        # Pass numerical value into SigFigNum constructor and return
        return SigFigNum(num_string)

    # Equality special methods
    def __eq__(self, val):
        valid = self._validate_sigfignum(val)
        if valid is not None:
            return valid
        return self.num_str == val.num_str

    # Main methods
    def _validate_num(self):
        "Validate self.num_str after it is created, raise an error if not valid"
        if not re.fullmatch(NUM_STR_PATTERN, self.num_str):
            raise ValueError(f"Invalid num_str '{self.num_str}'")

    def _calculate_sigfig(self):
        "Calculate the significant numbers after self.num_str is created"
        # Get coefficient
        coefficient, _ = self._split_num_str()

        # Check if self.num_str is decimal
        if "." in coefficient:
            # If it is decimal, strip leading zeroes and decimal point
            digits = coefficient.replace(".", "").lstrip("0")
            # Set self.sigfigs to number of sig figs, if only zeroes use number of decimal places plus zero before decimal
            self.sigfigs = len(digits) if digits else len(coefficient.split(".")[1]) + 1
        else:
            # If it is not decimal, strip leading and trailing zeroes
            digits = coefficient.lstrip("0").rstrip("0")
            # Set self.sigfigs to number of sig figs, minimum one
            self.sigfigs = len(digits) if digits else 1

    def _calculate_decimals(self):
        "Calculate the decimal places after self.num_str is created"
        # Get coefficient and exponent
        coefficient, exponent = self._split_num_str()

        if "." in coefficient:
            # If the coefficient is decimal, count digits after it
            decimals = len(coefficient.split(".")[1])
        else:
            # Else, decimal place is negative count of trailing zeroes
            decimals = -(len(coefficient) - len(coefficient.rstrip("0")))

        # Subtract exponent from decimal place and set
        self.decimals = decimals - int(exponent)

    def _calculate_value(self):
        "Calculate the actual numerical value of self.num_str"
        # Convert num_str to Decimal object and set
        self.value = Decimal(self.num_str)

    # Helper methods
    def _split_num_str(self) -> list[str]:
        "Split self.num_str into coefficient and exponent and return"
        values = self.num_str.lstrip("+-").split("e")
        if len(values) == 2:
            return values
        elif len(values) == 1:
            return [values[0], "0"]
        else:
            raise ValueError("Invalid num_str: more than 1 e")

    def _validate_sigfignum(self, value):
        "Validate value passed in is SigFigNum"
        if not isinstance(value, SigFigNum):
            return NotImplemented

    def _round_num_string(self, num: Decimal, precision: int, type: str) -> str:
        if type == "decimals":
            # Convert decimal-place position to the rounding multiple.
            multiple = Decimal(1).scaleb(-precision)

            # Round to the nearest multiple using ROUND_HALF_UP.
            rounded = (num / multiple).quantize(
                Decimal(1), rounding=ROUND_HALF_UP
            ) * multiple

            # Preserve the requested number of decimal places.
            if precision > 0:
                return f"{rounded:.{precision}f}"

            # For zero or negative precision, return a whole-number string.
            return format(rounded, ".0f")

        elif type == "sigfigs":
            if precision < 1:
                raise ValueError("Significant figures must be at least 1")

            # Zero needs special handling because it has no nonzero digit
            # from which to determine its magnitude.
            if num.is_zero():
                return "0." + "0" * (precision - 1) if precision > 1 else "0"

            # Determine the position of the last significant digit.
            magnitude = num.copy_abs().adjusted()
            decimal_places = precision - magnitude - 1

            # Round at that decimal-place position.
            multiple = Decimal(1).scaleb(-decimal_places)
            rounded = (num / multiple).quantize(
                Decimal(1), rounding=ROUND_HALF_UP
            ) * multiple

            # Scientific notation makes significant figures unambiguous
            # for whole numbers ending in zeros.
            magnitude = rounded.copy_abs().adjusted()

            if (
                magnitude >= precision
                or magnitude < -4
                or (magnitude > 0 and rounded % 10 == 0)
            ):
                mantissa = rounded.scaleb(-magnitude)
                mantissa_text = f"{mantissa:.{precision - 1}f}"
                exponent_text = f"{magnitude:+d}"
                return f"{mantissa_text}e{exponent_text}"

            # Fixed-point notation with the required decimal places.
            decimal_places = max(0, precision - magnitude - 1)
            return f"{rounded:.{decimal_places}f}"

        else:
            raise ValueError(f"Invalid 'type' parameter value: {type}")


if __name__ == "__main__":
    done = False
    while not done:
        a = input("First number: ")
        a = SigFigNum(a)
        b = input("Second number: ")
        b = SigFigNum(b)
        operator = input("Which operator should be used? (+, -, /, *, quit) ")
        match operator:
            case "+":
                print(f"Answer is: {(a + b).num_str}")
            case "-":
                print(f"Answer is: {(a - b).num_str}")
            case "/":
                print(f"Answer is: {(a / b).num_str}")
            case "*":
                print(f"Answer is: {(a * b).num_str}")
            case "quit":
                done = True
            case _:
                print("Unknown operator")
