import random


def weighted_average(scores, weights=None):
    if weights is None:
        weights = [1 for _ in scores]
    assert len(scores) == len(weights)
    weighted_scores = [scores[i] * weights[i] for i in range(len(scores))]
    return sum(weighted_scores) / sum(weights)


############################################################
# last time: Student class and instances
############################################################


class Student:

    def __init__(self, kerb, name, psets, exams):
        self.kerberos = kerb
        self.name = name
        self.psets = psets
        self.exams = exams

    def calculate_total(self):
        return weighted_average(
            [weighted_average(self.psets), weighted_average(self.exams)],
            [3, 7],
        )

    def print_record(self):
        print(", ".join([
            f"kerberos: {self.kerberos}",
            f"name: {self.name}",
            f"total score: {self.calculate_total()}"
        ]))


def assemble_class():
    tony = Student("richboi", "Tony Stark", psets=[10, 9, 8], exams=[8, 7])
    riri = Student("vibegrl", "Riri Williams", psets=[8, 10, 10], exams=[7, 9])
    otto = Student("limbo", "Otto Octavius", psets=[7, 9, 6], exams=[5, 8])

    all_students = [tony, riri, otto]
    class_average = weighted_average(
        [student.calculate_total() for student in all_students]
    )

    for student in all_students:
        student.print_record()
    print(f"{class_average = }")
    print()


# assemble_class()


############################################################
# class attributes
############################################################


class Student:

    MIN_ID = 900_000_000
    MAX_ID = 999_999_999
    used_ids = set()

    def __init__(self, kerb, name, psets, exams):
        new_id = random.randint(Student.MIN_ID, Student.MAX_ID)
        while new_id in Student.used_ids:
            new_id = random.randint(Student.MIN_ID, Student.MAX_ID)
        Student.used_ids.add(new_id)
        self.id = new_id

        self.kerberos = kerb
        self.name = name
        self.psets = psets
        self.exams = exams

    def calculate_total(self):
        return weighted_average(
            [weighted_average(self.psets), weighted_average(self.exams)],
            [3, 7],
        )

    def print_record(self):
        print(", ".join([
            f"id: {self.id}",
            f"kerberos: {self.kerberos}",
            f"name: {self.name}",
            f"total score: {self.calculate_total()}"
        ]))


# assemble_class()


############################################################
# single underscore convention, getters and setters
############################################################


def tony_hack(psets, exams):
    tony = Student("richboi", "Tony Stark", psets=psets, exams=exams)
    tony.print_record()

    print("hacking psets list")
    psets[-1] = 10
    print(f"{tony.psets = }")
    tony.print_record()
    print()


# tony_hack(psets=[10, 9, 8], exams=[8, 7])


class Student:

    MIN_ID = 900_000_000
    MAX_ID = 999_999_999
    used_ids = set()

    def __init__(self, kerb, name):
        new_id = random.randint(Student.MIN_ID, Student.MAX_ID)
        while new_id in Student.used_ids:
            new_id = random.randint(Student.MIN_ID, Student.MAX_ID)
        Student.used_ids.add(new_id)
        self._id = new_id

        self._kerberos = kerb
        self._name = name
        self._psets = []
        self._exams = []

    def get_id(self):
        return self._id

    def get_kerberos(self):
        return self._kerberos

    def get_name(self):
        return self._name

    def add_pset_score(self, score):
        self._psets.append(score)

    def add_exam_score(self, score):
        self._exams.append(score)

    def calculate_total(self):
        return weighted_average(
            [weighted_average(self._psets), weighted_average(self._exams)],
            [3, 7],
        )

    def print_record(self):
        print(", ".join([
            f"id: {self._id}",
            f"kerberos: {self._kerberos}",
            f"name: {self._name}",
            f"total score: {self.calculate_total()}"
        ]))


def tony_hack_fail(psets, exams):
    tony = Student("richboi", "Tony Stark")
    tony.add_pset_score(psets[0])
    tony.add_pset_score(psets[1])
    tony.add_exam_score(exams[0])
    tony.add_pset_score(psets[2])
    tony.add_exam_score(exams[1])
    tony.print_record()

    print("hacking psets list")
    psets[-1] = 10
    print(f"{tony._psets = }")
    tony.print_record()
    print()


# tony_hack_fail(psets=[10, 9, 8], exams=[8, 7])


############################################################
# double underscore methods
############################################################


def demo_student_str():
    psets = [10, 9, 8]
    exams = [8, 7]
    tony = Student("richboi", "Tony Stark")
    tony.add_pset_score(psets[0])
    tony.add_pset_score(psets[1])
    tony.add_exam_score(exams[0])
    tony.add_pset_score(psets[2])
    tony.add_exam_score(exams[1])

    print(Student.__str__(tony))    # explicit class function call
    print(tony.__str__())           # object method call
    print(tony)                     # syntactic shorthand, preferred


# demo_student_str()


class Fraction:
    """A representation of a fraction, i.e., a rational number."""

    def __init__(self, num, denom):
        assert isinstance(num, int)
        assert isinstance(denom, int) and denom != 0
        self.num = num
        self.denom = denom

    def __str__(self):
        return f"<Fraction {self.num} / {self.denom}>"

    def _find_gcd(self):  # greatest common divisor
        if self.num == 0:
            return 1
        for candidate in range(min(abs(self.num), abs(self.denom)), 0, -1):
            if self.num % candidate == 0 and self.denom % candidate == 0:
                return candidate

    # exercise: complete this to help finish implementing __eq__()
    def _simplify(self):
        pass


def demo_fractions():
    a = Fraction(num=2, denom=5)
    b = Fraction(num=-1, denom=4)
    c = Fraction(num=3, denom=-12)

    print(a)
    print(b)
    print(c)
    print()

    # print(float(a))
    # print(float(b))
    # print(float(c))
    # print()

    # print(Fraction.__mul__(a, b))
    # print(a.__mul__(b))
    # print(a * b)
    # print()

    # print(a / b)
    # print()

    # print(b == c)
    # print()

    # print(a + b)
    # print(a - b)
    # print()


# demo_fractions()


############################################################
# every class inherits from the base class `object`
############################################################


def demo_builtin_inheritance():
    print(isinstance(123, object))
    print(isinstance(123.0, object))
    print(isinstance("123", object))
    print(isinstance([1, 2, 3], object))
    print(isinstance({1: "a", 2: "b", 3: "c"}, object))
    print(isinstance(lambda x: x**2, object))
    print()

    print(issubclass(int, object))
    print(issubclass(float, object))
    print(issubclass(str, object))
    print(issubclass(list, object))
    print(issubclass(dict, object))
    print(issubclass(type(lambda x: x**2), object))
    print()


# demo_builtin_inheritance()


# empty Student class inherits dunder methods from object class
class Student:
    pass


def demo_custom_type_inheritance():
    tony = Student()
    print(isinstance(tony, object))
    print(issubclass(Student, object))
    print()

    # demonstrate __str__() and __eq__() inheritance
    riri = Student()
    print(tony)
    print(riri)
    print(tony == riri)
    print()

    print(Student.__init__)
    print(Student.__str__)
    print(Student.__eq__)
    print(Student.__init__ == object.__init__)
    print()


# demo_custom_type_inheritance()


############################################################
# inheritance: method reuse and overriding/shadowing
############################################################


class Animal:

    def __init__(self, age, name=None):
        self.age = age
        self.name = name

    def __str__(self):
        return f'<Animal "{self.name}" {self.age} years>'

    def get_age_diff(self, other):
        """
        Return the magnitude of the age difference between two Animals.
        """
        assert isinstance(other, Animal)
        return abs(self.age - other.age)

    def speak(self):
        """Print to the terminal some text that self would say."""
        raise NotImplementedError(
            f"Animal {self} does not have speak()ing ability yet"
        )


def demo_animal():
    creature = Animal(5, name="Morg")
    critter = Animal(2, name="Florg")
    print(creature)
    print(critter)

    # avoid creating new data attributes on-the-fly
    critter.size = "tiny"
    print(critter.size)
    print(creature.size)  # raises AttributeError

    # these are not identical method calls!
    # they just happen to give the same result
    print(creature.get_age_diff(critter))
    print(critter.get_age_diff(creature))

    creature.speak()  # raises NotImplementedERror


# demo_animal()


class Cat(Animal):
    """A Cat is an Animal that says "Meow" or something like that."""

    def __str__(self):
        return f"<Cat {self.name} {self.age} years>"

    def speak(self):
        """Print something a cat would say."""
        print("Meow")

    def confuse(self, other):
        """
        Given two Cats, make them both speak, swap their names, and
        return a string of the form:
            "<name> and <name> are <#> years apart."
        """
        self.speak()
        other.speak()
        self.name, other.name = other.name, self.name
        print(
            f"{self.name} and {other.name} are "
            f"{self.get_age_diff(other)} years apart."
        )


def demo_cats():
    lion = Cat(5, "Fluffy")
    tiger = Cat(8, "Furry")
    print(lion)
    print(tiger)

    lion.confuse(tiger)
    print(lion)
    print(tiger)


# demo_cats()


############################################################
# inheritance chains and super()
############################################################


class Person(Animal):
    """A Person is an Animal with other Person friends."""

    def __init__(self, name, age):  # note the order of the parameters!
        # use Animal to set common attributes
        Animal.__init__(self, age, name)
        # super().__init__(age, name)  # equivalent to line above

        # store Person-specific info
        self.friends = set()

        # super() reinterprets self as an object of type Animal
        # print(Animal.__init__)
        # print(self.__init__)
        # print(super().__init__)
        # print()

    def __str__(self):
        return f'<Person "{self.name}" {self.age} years old>'

    # enables __str__()-like printing of objects within collections
    # __repr__ = __str__

    def speak(self):
        print(f"Hello. My name is {self.name}.")

    def add_friend(self, other):
        """Record other as a friend of self and vice versa."""
        assert isinstance(other, Person)
        self.friends.add(other)
        other.friends.add(self)


def demo_person_friends():
    inigo = Person("Inigo Montoya", 35)
    fezzik = Person("Fezzik", 41)
    westley = Person("Dread Pirate Roberts", 25)
    max = Person("Miracle Max", 39)

    inigo.speak()
    inigo.add_friend(fezzik)
    inigo.add_friend(westley)
    print(inigo.friends)
    print(fezzik.friends)
    print()

    # use add_friend() to ensure mutual friendship
    # inigo.friends.add(max)
    # print(inigo.friends)
    # print(max.friends)
    # print()


# demo_person_friends()


class Student(Person):

    def __init__(self, name, age, major=None):
        super().__init__(name, age)
        self.major = major

    def __str__(self):
        name = f'"{self.name}"'
        age = f"{self.age} years old"
        major = f"Course {self.major}"
        return f"<Student {", ".join([name, age, major])}>"

    def speak(self):
        """Introduce yourself and then a friend a random."""
        super().speak()
        friend = random.choice(list(self.friends))
        if isinstance(friend, Student):
            print(f"My friend {friend.name} is Course {friend.major}.")
        else:
            print(f"My friend {friend.name} isn't a student.")
        print()


def demo_student_speak():
    alex = Student("Alex", 20, "6-4")
    kelly = Student("Kelly", 22, "CMS")
    sam = Student("Sam", 18, "2-A")
    max = Person("Miracle Max", 39)

    print(alex)
    alex.add_friend(kelly)
    alex.add_friend(sam)
    alex.add_friend(max)
    for _ in range(6):
        alex.speak()


# demo_student_speak()


############################################################
# designing __eq__() when attributes contain objects of the same type
############################################################


class Rabbit(Animal):

    next_tag = 1

    def __init__(self, age, parent1=None, parent2=None):
        super().__init__(age)
        self.parents = (parent1, parent2)
        self.id = Rabbit.next_tag
        Rabbit.next_tag += 1

    def __str__(self):
        return f"<Rabbit {self.id:>03}>"

    __repr__ = __str__

    def __add__(self, other):
        """Make a new Rabbit offspring of self and other."""
        return Rabbit(0, self, other)

    def __eq__(self, other):
        """Return True if and only if self and other have the same parents."""
        return self.__eq__v1(other)
        # return self.__eq__v2(other)
        # return self.__eq__v3(other)

    def __eq__v1(self, other):
        print(self, other)

        # this ends up recursively calling __eq__()
        # on self.parents[0] # and other.parents[0],
        # doesn't work if one is a Rabbit and the other is None
        return (
            self.parents == other.parents
            or self.parents == other.parents[::-1]
        )

    def __eq__v2(self, other):
        # this almost works, but not when self's or other's parents are None
        self_parent_ids = [p.id for p in self.parents]
        other_parent_ids = [p.id for p in other.parents]
        return (
            self_parent_ids == other_parent_ids
            or self_parent_ids == other_parent_ids[::-1]
        )

    def __eq__v3(self, other):
        get_parent_id = lambda x: x.id if isinstance(x, Rabbit) else None
        self_parent_ids = [get_parent_id(p) for p in self.parents]
        other_parent_ids = [get_parent_id(p) for p in other.parents]
        return (
            self_parent_ids == other_parent_ids
            or self_parent_ids == other_parent_ids[::-1]
        )


def demo_rabbit_equality():
    r1 = Rabbit(age=3)
    r2 = Rabbit(age=4)
    r3 = Rabbit(age=5)
    print(f"{r1 = }")
    print(f"{r2 = }")
    print(f"{r3 = }")
    print()

    print(f"{r1.parents = }")
    r4 = r1 + r2
    print(f"{r4 = }")
    print(f"{r4.parents = }")
    print()

    r5 = r3 + r4
    print(f"{r5 = }")
    print(f"{r5.parents = }")
    print()

    r6 = r4 + r3
    print(f"{r6 = }")
    print(f"{r6.parents = }")
    print()

    print(f"{(r5 == r6) = }")  # should be True
    print(f"{(r4 == r6) = }")  # should be False
    print(f"{(r1 == r2) = }")  # should be True
    print()

    r7 = r1 + r2 + r3  # what are r7's parents?
    print(f"{r7 = }")
    print(f"{r7.parents = }")
    print(f"{r7.parents[0].parents = }")
    print()


# demo_rabbit_equality()
