############################################################
# review function call semantics
############################################################


import time


def count_down(n):
    for k in range(n, 0, -1):
        print(k)
        time.sleep(1)


def launch(spaceship):
    count_down(3)
    print(f"Lift off for {spaceship}!")


# launch("USS Enterprise")


def count_down_recursive(n):
    if n == 0:
        return
    print(n)
    time.sleep(1)
    count_down_recursive(n - 1)


count_down = count_down_recursive
# launch("USS Enterprise")


############################################################
# using list and dictionary representations
############################################################


########################################
# nested lists
########################################


def make_scores():
    tony = ["richboi", ["psets", [10, 9, 8]], ["exams", [8, 7]]]
    riri = ["vibegrl", ["psets", [8, 10, 10]], ["exams", [7, 9]]]
    otto = ["limbo", ["psets", [7, 9, 6]], ["exams", [5, 8]]]
    return tony, riri, otto


def get_pset(scores, user, ps_num):
    for record in scores:
        kerb, psets, exams = record
        if kerb == user:
            label, scores = psets
            assert label == "psets"
            assert len(scores) >= ps_num
            return scores[ps_num - 1]


# print(get_pset(make_scores(), "richboi", 3))


########################################
# parallel lists
########################################


def make_scores():
    kerberoses = ["richboi", "vibegrl", "limbo"]
    pset_scores = [[10, 9, 8], [8, 10, 10], [7, 9, 6]]
    exam_scores = [[8, 7], [7, 9], [5, 8]]
    return kerberoses, pset_scores, exam_scores


def get_pset(scores, user, ps_num):
    kerbs, psets, exams = scores
    assert len(kerbs) == len(psets) == len(exams)
    for i in range(len(kerbs)):
        if kerbs[i] == user:
            scores = psets[i]
            assert len(scores) >= ps_num
            return scores[ps_num - 1]


# print(get_pset(make_scores(), "richboi", 3))


########################################
# nested dictionaries
########################################


def make_scores():
    scores = {}
    scores["richboi"] = {"psets": [10, 9, 8], "exams": [8, 7]}
    scores["vibegrl"] = {"psets": [8, 10, 10], "exams": [7, 9]}
    scores["limbo"] = {"psets": [7, 9, 6], "exams": [5, 8]}
    return scores


# exercise: write get_pset()


########################################
# parallel dictionaries
########################################


def make_scores():
    pset_scores = {
        "richboi": [10, 9, 8],
        "vibegrl": [8, 10, 10],
        "limbo": [7, 9, 6],
    }
    exam_scores = {
        "richboi": [8, 7],
        "vibegrl": [7, 9],
        "limbo": [5, 8],
    }
    return pset_scores, exam_scores


# exercise: write get_pset()


############################################################
# data abstraction: represent students as custom data type with attributes
############################################################


class Student:
    pass


def make_tony(verbose=False):
    # create new object of Student type
    tony = Student()
    if verbose:
        print(f"{tony = }")
        print(f"{type(tony) = }")
        # print(f"{Student = }")
        # print(f"{isinstance(tony, Student) = }")
        # print(f"{type(Student) = }")
        # print(f"{isinstance(Student, type) = }")
        # print()

    # store and retrieve attributes using dot notation
    tony.kerberos = "richboi"
    tony.name = "Tony Stark"
    tony.psets = [10, 9, 8]
    tony.exams = [8, 7]
    if verbose:
        # print(f"{tony.kerberos = }")
        # print(f"{tony.name = }")
        # print(f"{tony.psets = }")
        # print(f"{tony.exams = }")
        print()

    return tony


# make_tony(verbose=True)


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)


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


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


# print_record(make_tony())


############################################################
# associate functions with classes
# use them as bound methods: operations that are associated with *objects*
############################################################


class Student:

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

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


def use_bound_methods():
    tony = make_tony()

    # these two lines are equivalent, always use the latter
    Student.print_record(tony)      # explicit class function call
    tony.print_record()             # method call
    print()

    print(f"{Student.print_record = }") # actual function
    print(f"{tony.print_record = }")    # virtual function, bound method of tony
    print()


# use_bound_methods()


############################################################
# use `self` convention for first parameter of methods
############################################################


class Student:

    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()}"
        ]))


# use_bound_methods()


############################################################
# use __init__() to initialize object through class call
############################################################


def assemble_class():
    tony = Student()
    tony.kerberos = "richboi"
    tony.name = "Tony Stark"
    tony.psets = [10, 9, 8]
    tony.exams = [8, 7]

    riri = Student()
    riri.kerberos = "vibegrl"
    riri.name = "Riri Williams"
    riri.psets = [8, 10, 10]
    riri.exams = [7, 9]

    otto = Student()
    otto.kerberos = "limbo"
    otto.name = "Otto Octavius"
    otto.psets = [7, 9, 6]
    otto.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 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()


############################################################
# assign a unique id number to each student
############################################################


import random


class Student:

    used_ids = set()

    def __init__(self, kerb, name, psets, exams):
        new_id = random.randint(900_000_000, 999_999_999)
        while new_id in Student.used_ids:
            new_id = random.randint(900_000_000, 999_999_999)
        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()


############################################################
# protect internal state with getter and setter methods
############################################################


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:

    used_ids = set()

    def __init__(self, kerb, name):
        new_id = random.randint(900_000_000, 999_999_999)
        while new_id in Student.used_ids:
            new_id = random.randint(900_000_000, 999_999_999)
        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])


############################################################
# use __str__() to control printing
############################################################


class Student:

    def __init__(self, kerb, name):
        self._kerberos = kerb
        self._name = name
        self._psets = []
        self._exams = []

    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 __str__(self):
        return ", ".join([
            f"kerberos: {self._kerberos}",
            f"name: {self._name}",
            f"total score: {self.calculate_total()}"
        ])


def use_str_dunder():
    tony = Student("richboi", "Tony Stark")
    tony.add_pset_score(10)
    tony.add_pset_score(9)
    tony.add_exam_score(8)
    tony.add_pset_score(8)
    tony.add_exam_score(7)

    print(tony.__str__())
    print(str(tony))
    print(tony)
    print()


# use_str_dunder()
