from datetime import date as d
from math import ceil
from typing import List, Set
from uuid import uuid1


class Invoice:
    """
    This class represents an invoice object.

    `date` must be a datetime object.
    `reference_number` is an autogenerated uuid.
    """

    def __init__(self, date: d, value: float):
        self.date = date
        self.reference_number: uuid1 = uuid1()

        if not self.valid_invoice_value(value):
            raise InvalidInvoiceValueError
        else:
            self.value = value

    @staticmethod
    def valid_invoice_value(value: float) -> bool:
        """
        This method checks that an invoice has a valid value.
        """

        return True if 0.0 < value < 200000000.00 else False


class InvoiceStats:
    """
    This class represents the behaviours associated with a set of invoices.
    """

    MAX_INVOICE_SET_SIZE = 20000000  # Twenty Million

    def __init__(self, invoice_set: Set[Invoice]):
        # We do not want to add an invoice set if it contains more than our max invoice set size.
        if len(invoice_set) <= self.MAX_INVOICE_SET_SIZE:
            self.invoice_set = invoice_set
        else:
            raise InvalidInvoiceSetError

    def add_invoices(self, invoice_set: Set[Invoice]) -> None:
        """
        Adds all invoices in a set to the instance variable `invoice_set`.
        """

        # Loop through to add all new invoices, checks are done in the `add_invoice` method.
        for i in invoice_set:
            self.add_invoice(i)

    def add_invoice(self, invoice: Invoice) -> None:
        """
        Adds an invoice to the instance variable `invoice_set`.
        """

        if len(list(self.invoice_set)) >= self.MAX_INVOICE_SET_SIZE:
            raise InvalidInvoiceSetError
        else:
            self.invoice_set.add(invoice)

    def clear(self) -> None:
        """
        Clears the set of all invoices pertaining to the instance variable of this class object.
        """

        self.invoice_set.clear()

    def get_median(self) -> float:
        """
        Returns the median value of the invoice set pertaining to the instance variable
        of this class. Makes sure to round down the .05 value.
        """
        list_of_values: List[float] = []

        for i in list(self.invoice_set):
            list_of_values.append(i.value)

        sorted_list = sorted(list_of_values)

        # Find the median.
        if len(sorted_list) % 2 == 0:
            upper_bound = sorted_list[len(sorted_list) // 2]
            lower_bound = sorted_list[len(sorted_list) // 2 - 1]
            median = (upper_bound + lower_bound) / 2
        else:
            median = sorted_list[len(sorted_list) // 2]

        return round_half_down(median)

    def get_mean(self) -> float:
        """
        Returns the mean of the set of invoices of this class' object.
        Makes sure to round down the .05 value.
        """

        total: float = 0.0

        for i in list(self.invoice_set):
            total += i.value

        return round_half_down(round(total / len(self.invoice_set), 3))

    @staticmethod
    def valid_invoice_set(invoice_list: Set[Invoice]) -> bool:
        """
        Returns a boolean indicating of our invoice set is too large or not.
        """

        return True if len(invoice_list) <= 20000000 else False


class InvalidInvoiceValueError(Exception):
    """
    Custom exception pertaining to an over-sized invoice value.
    """

    message = (
        "\nException: InvalidInvoiceError:"
        "\nYour invoice is invalid."
        "\nInvoices may only have a total between 0.0 and 200000000.00."
    )


class InvalidInvoiceSetError(Exception):
    """
    Custom exception pertaining to an over-sized set of invoice values.
    """

    message = (
        "\nException: InvalidInvoiceSetError:"
        "\nYour invoice set is invalid."
        "\nAn invoice set may only contain up to 20000000 items."
    )


def round_half_down(value):
    """
    Function to round down the .05 (half cent) instead of rounding up.
    """
    multiplier = 10 ** 2
    return ceil(value * multiplier - 0.5) / multiplier


if __name__ == "__main__":
    # Creation on invoice objects.
    invoice_1 = Invoice(d.today(), 100.655)
    invoice_2 = Invoice(d.today(), 150.605)
    invoice_3 = Invoice(d.today(), 300.605)
    invoice_4 = Invoice(d.today(), 230.605)

    # Creation of a set of invoices.
    invoice_set = {invoice_1, invoice_2, invoice_4}

    # Instantiation of an instance of InvoiceStats class.
    invoice_stats = InvoiceStats(invoice_set)

    # Add a single invoice object to our InvoiceStatistics class.
    invoice_stats.add_invoice(invoice_3)
    # Add multiple invoice objects to our InvoiceStatistics class.
    invoice_stats.add_invoices(invoice_set)

    # Print the mean and median of those values.
    print(invoice_stats.get_mean())
    print(invoice_stats.get_median())

    # Remove all invoices from the InvoiceStats class object.
    invoice_stats.clear()
