# This file demonstrates some additional features of functions in Python
# and common patterns in using them. It's not required to use these
# features, but a bit of judgment in using them can make your code
# easier to read.


############################################################
# early returns
############################################################


# Early returns are more of a "pattern" than a "feature", and their
# usage is simply a consequence of how return statements work. When a
# return is encountered in running a function body, the expression right
# after the return keyword is evaluated, and execution of the body code
# stops.

# We've already seen an example of this in Lecture 2's code, when we
# separated out into a function the code to specify the number of dates
# in a month. Previously, when our code wasn't in a function, we
# assigned the variable `end_date` in each conditional block. We could
# just move it all into a function and `return end_date` at the end, but
# it's more direct -- both to someone reading your code and to the
# computer -- to just directly return the intended output within each
# block.


def get_num_dates(month):
    if month == 2:
        return 28
    elif month < 8 and month % 2 == 1:
        return 31
    elif month >= 8 and month % 2 == 0:
        return 31
    else:
        return 30


# Early returns are particularly useful from within loops. Earlier in
# Lecture 2, we had a scenario where Alyssa, Ben, and Cindy had sold
# tickets, and we iterated through the possibilities for each person.
# When we had found a solution, we wanted to stop looping. However, we
# were in the inner-most loop, so we needed a boolean flag
# `found_solution` to signal the outer-level loops to stop/break as well.

# Below is the same code but now in a function. Uncomment the call to
# solve_tickets() to run it. Note the single `return` statement at the
# end of the `if` block. This has the same effect as the extra code we
# had previously to handle the boolean flag, and this is possible only
# because `return` statements can be used inside function bodies but not
# at the global level.


def solve_tickets():
    total_tickets = 1000
    for alyssa in range(total_tickets + 1):
        for ben in range(total_tickets + 1):
            for cindy in range(total_tickets + 1):
                if (
                    ben == alyssa - 20
                    and cindy == alyssa * 2
                    and alyssa + ben + cindy == total_tickets
                ):
                    print("Alyssa:", alyssa, "tickets")
                    print("Ben:   ", ben, "tickets")
                    print("Cindy: ", cindy, "tickets")
                    return

        if alyssa % 20 == 0:
            print("checked alyssa =", alyssa)


# solve_tickets()


# It may seem odd that the `return` doesn't have an expression following
# it. In this case, Python by default returns an object called `None`,
# which we'll say more about at the beginning of Lecture 4.

# A larger issue is that when code is inside a function, the variables
# exist only in its frame when the function is called, and are no longer
# directly accessible from code outside the function. To remedy this, we
# need to output the values of `alyssa`, `ben`, and `cindy` through the
# `return`.

# Hold on, you may say, we can only return one object. How would we
# return all three of them? Lists to the rescue! **hold for applause**
# If we bundle these three values up into a list, we now have a single
# object we can return, and it becomes the caller's responsibility to
# retrieve them as needed through the list's references.

# The revised code below demonstrates that by moving the print()
# statements to outside and after the function call. It also shows a
# useful principle, which is to separate the code for calculating
# something from the code for displaying it. By moving the print()
# statements outside, the logical structure becomes more compact and
# hopefully easier to read.


def solve_tickets():
    total_tickets = 1000
    for alyssa in range(total_tickets + 1):
        for ben in range(total_tickets + 1):
            for cindy in range(total_tickets + 1):
                if (
                    ben == alyssa - 20
                    and cindy == alyssa * 2
                    and alyssa + ben + cindy == total_tickets
                ):
                    return [alyssa, ben, cindy]

        if alyssa % 20 == 0:
            print("checked alyssa =", alyssa)


# answer = solve_tickets()
# print(f"Alyssa: {answer[0]} tickets")
# print(f"Ben:    {answer[1]} tickets")
# print(f"Cindy:  {answer[2]} tickets")


# EXERCISE: When we added Derek to the ticket scenario, we also provided
# two approaches in code for enumerating possible solutions. Modify the
# second approach in the way we did above, putting it in a function and
# returning the answer through a list.


############################################################
# keyword arguments
############################################################


# Another advantage of putting code in functions is it becomes easier to
# run the same code for different settings of variables via the function
# parameters. Below is the our previous function, but revised to operate
# on three parameters instead:
#
# `total` is the total number of tickets sold
# `max_each` is an upper limit on how many tickets an individual person may sell
# `show_progress` is a boolean flag that let's us turn on or off progress printouts
#
# Uncomment the three lines below to see this flexibility in action.


def solve_tickets(total, max_each, show_progress):
    for alyssa in range(max_each + 1):
        for ben in range(max_each + 1):
            for cindy in range(max_each + 1):
                if (
                    ben == alyssa - 20
                    and cindy == alyssa * 2
                    and alyssa + ben + cindy == total
                ):
                    return [alyssa, ben, cindy]

        if show_progress and alyssa % 20 == 0:
            print("checked alyssa =", alyssa)


# print(solve_tickets(1000, 1000, True))
# print(solve_tickets(1000, 700, True))
# print(solve_tickets(500, 300, False))


# One might point out, though, that to a reader who's not familiar with
# the solve_tickets() function definition, the meaning of those numbers
# and True/False arguments being passed into those calls is not
# immediately clear. Thus, Python allows function calls to label their
# arguments with the parameter names, using `parameter=argument` syntax
# in the call.

# Moreover, once the arguments are labeled, they can be arranged in any
# order, because it's unambiguous which argument correponds to which
# parameter. Each of the following three calls is equivalent.


# print(solve_tickets(total=1000, max_each=700, show_progress=True))
# print(solve_tickets(max_each=700, total=1000, show_progress=True))
# print(solve_tickets(show_progress=True, total=1000, max_each=700))


# It's best not to abuse this feature, though. A well-designed function
# often has reasons for how its parameters are ordered. Furthermore, not
# all arguments in a function call need to be labeled with their
# parameters. Those that aren't are called positional arguments, and
# they must come before any keyword arguments.


# this is not allowed, unclear whether 1000 refers to `total` or `max_each`
# print(solve_tickets(show_progress=True, 1000, 700))

# also not allowed
# print(solve_tickets(show_progress=True, 1000, max_each=700))


# Finally, note that the formatting convention for labeling arguments
# with their keyword parameters is to not include spaces surrounding the
# `=`. This contrasts with variable assignment expressions `var = expr`,
# where spaces are expected by convention (but not required according to
# syntax).


############################################################
# default argument values
############################################################


# Finally, we can apply the same `parameter=argument` syntax within the
# function definition itself, in the header line. This specifies default
# values for such labeled parameters, so that the function call doesn't
# have to supply them.

# For example, when we release our solve_tickets() function to others,
# we may wish to not display progress printouts, but still keep the
# functionality there for our own debugging purposes. The (yet again)
# revised code below demonstrates this for the `show_progress` parameter.


def solve_tickets(total, max_each, show_progress=False):
    for alyssa in range(max_each + 1):
        for ben in range(max_each + 1):
            for cindy in range(max_each + 1):
                if (
                    ben == alyssa - 20
                    and cindy == alyssa * 2
                    and alyssa + ben + cindy == total
                ):
                    return [alyssa, ben, cindy]

        if show_progress and alyssa % 20 == 0:
            print("checked alyssa =", alyssa)


# The four calls below demonstrate possible ways of calling the
# function. In the first call, we omit the third argument, so this is
# equivalent to the second call. In both cases, we don't show progress
# printouts, and we have to wait a few seconds before the answer gets
# printed. The third call overrides the default False value, so we do
# get progress reports. And finally, we can still use keyword arguments.
# This last form is often helpful to signpost/suggest that you're
# overriding a default value.


# print(solve_tickets(1000, 700))
# print(solve_tickets(1000, 700, False))
# print(solve_tickets(1000, 700, True))
# print(solve_tickets(1000, 700, show_progress=True))


# EXERCISE: In the example above, only the last parameter had a default
# value. Could we also supply one to the middle parameter? What about
# **only** to the middle parameter?

# To answer such questions, it's often helpful to write some short test
# code and see how it behaves. We've started you off below. Make a call
# to the function, and try modifying the function header with different
# combinations of default values.


def test(a, b, c):
    print(a, b, c)
