from typing import Callable

# ── Exercise 1: List Comprehensions ───────────────────────────────────────────

# (a) Celsius to Fahrenheit
celsius = [0, 20, 37, 100]
fahrenheit = [c * 9 / 5 + 32 for c in celsius]
print(fahrenheit)  # [32.0, 68.0, 98.6, 212.0]

# (b) Filter words longer than 4 characters
words = ["cat", "elephant", "dog", "butterfly", "ox", "penguin"]
long_words = [word for word in words if len(word) > 4]
print(long_words)  # ['elephant', 'butterfly', 'penguin']

# (c) Nested list comprehension — 5x5 multiplication table
table = [[x * y for y in range(1, 6)] for x in range(1, 6)]
for row in table:
    print(row)
# [1, 2, 3, 4, 5]
# [2, 4, 6, 8, 10]
# [3, 6, 9, 12, 15]
# [4, 8, 12, 16, 20]
# [5, 10, 15, 20, 25]

# (d) Dictionary comprehension — word to length
words = ["apple", "banana", "kiwi", "strawberry"]
lengths = {word: len(word) for word in words}
print(lengths)  # {'apple': 5, 'banana': 6, 'kiwi': 4, 'strawberry': 10}

# (e) Predict then verify
result = [x * y for x in range(1, 4) for y in range(1, 4) if x != y]
print(result)
# [2, 3, 2, 6, 3, 6]
# x=1: y=2 -> 2, y=3 -> 3
# x=2: y=1 -> 2, y=3 -> 6
# x=3: y=1 -> 3, y=2 -> 6
# 6 elements: all (x, y) pairs from 1..3 x 1..3 where x != y, each giving x*y


# ── Exercise 2: Higher-Order Functions ────────────────────────────────────────

# (a) Functions as values
def double(x: int) -> int:
    return x * 2

def triple(x: int) -> int:
    return x * 3

operations = [double, triple]
for f in operations:
    print(f(10))  # 20, then 30
# Inside the loop, f has type Callable[[int], int]

# (b) apply_to_all
def apply_to_all(f: Callable[[int], int], values: list[int]) -> list[int]:
    result = []
    for v in values:
        result.append(f(v))
    return result

print(apply_to_all(double, [1, 2, 3, 4]))  # [2, 4, 6, 8]
print(apply_to_all(triple, [1, 2, 3, 4]))  # [3, 6, 9, 12]

# (c) Returning a function — make_multiplier
def make_multiplier(n: int) -> Callable[[int], int]:
    def multiply(x: int) -> int:
        return x * n
    return multiply

times5 = make_multiplier(5)
times7 = make_multiplier(7)

print(times5(3))             # 15
print(times7(3))             # 21
print(times5(times7(2)))     # times7(2) = 14, times5(14) = 70

# (d) Sorting with a key
words = ["banana", "fig", "apple", "kiwi", "strawberry", "plum"]

by_length = sorted(words, key=len)
print(by_length)  # ['fig', 'kiwi', 'plum', 'apple', 'banana', 'strawberry']

by_length_desc = sorted(words, key=len, reverse=True)
print(by_length_desc)  # ['strawberry', 'banana', 'apple', 'kiwi', 'plum', 'fig']

# (e) apply_twice
def apply_twice(f: Callable[[int], int], x: int) -> int:
    return f(f(x))

print(apply_twice(double, 3))  # double(double(3)) = double(6) = 12
# apply_twice(apply_twice(double, ...), 1) is NOT valid:
# apply_twice(double, x) returns an int, not a function,
# so it cannot be passed as the first argument to apply_twice again.


# ── Exercise 3: Lambda Expressions ────────────────────────────────────────────

# (a) Basic syntax
square   = lambda x: x ** 2
add      = lambda x, y: x + y
constant = lambda: 42

print(square(5))    # 25
print(add(3, 7))    # 10
print(constant())   # 42

# Equivalent def for square:
def square_def(x: int) -> int:
    return x ** 2

print(square_def(5))  # 25 — same result

# (b) Lambda as an argument (rewriting Exercise 2d)
words = ["banana", "fig", "apple", "kiwi", "strawberry", "plum"]

by_length      = sorted(words, key=lambda w: len(w))
by_length_desc = sorted(words, key=lambda w: len(w), reverse=True)
print(by_length)
print(by_length_desc)

# (c) Lambda with apply_twice
result = apply_twice(lambda x: x + 3, 10)
print(result)   # 10 + 3 + 3 = 16

result2 = apply_twice(lambda x: x ** 2, 3)
print(result2)  # (3**2)**2 = 9**2 = 81

# (d) Sorting a list of tuples by grade
students = [
    ("Alice", 5.5),
    ("Bob",   4.0),
    ("Carol", 5.75),
    ("Dave",  4.5),
]

by_grade = sorted(students, key=lambda s: s[1])
for name, grade in by_grade:
    print(f"{name}: {grade}")
# Bob: 4.0
# Dave: 4.5
# Alice: 5.5
# Carol: 5.75

# (e) What lambdas cannot do

# Snippet 1 — valid
f = lambda x: x + 1
print(f(4))  # 5

# Snippet 2 — INVALID: lambda body must be a single expression on one line,
# not a multi-line block. This raises a SyntaxError.
# g = lambda x:
#         x + 1

# Snippet 3 — INVALID: assignment (y = ...) and return are statements,
# not expressions. Lambdas only accept a single expression. SyntaxError.
# h = lambda x: y = x + 1; return y


# ── Exercise 4: Putting It All Together ───────────────────────────────────────

transactions = [
    {"id": 1, "amount": 120.0,  "category": "food"},
    {"id": 2, "amount":  45.5,  "category": "transport"},
    {"id": 3, "amount": 200.0,  "category": "food"},
    {"id": 4, "amount":  15.0,  "category": "transport"},
    {"id": 5, "amount": 340.0,  "category": "electronics"},
    {"id": 6, "amount":  80.0,  "category": "food"},
    {"id": 7, "amount": 500.0,  "category": "electronics"},
    {"id": 8, "amount":  22.5,  "category": "transport"},
]

# (b) Filter: food transactions only
food = [t for t in transactions if t["category"] == "food"]
print(food)

# (c) Transform: (id, amount) tuples for amounts > 100
expensive = [(t["id"], t["amount"]) for t in transactions if t["amount"] > 100]
print(expensive)  # [(1, 120.0), (3, 200.0), (5, 340.0), (7, 500.0)]

# (d) Sort all transactions by amount descending
by_amount = sorted(transactions, key=lambda t: t["amount"], reverse=True)
for t in by_amount:
    print(t["id"], t["amount"])

# (e) Group totals per category using a dictionary comprehension
categories = {"food", "transport", "electronics"}

totals = {
    cat: sum(t["amount"] for t in transactions if t["category"] == cat)
    for cat in categories
}
print(totals)
# {'food': 400.0, 'transport': 83.0, 'electronics': 840.0}

# (f) Pipeline: ids of transactions with amount > 50, sorted by amount descending
result = [t["id"] for t in sorted(
    [t for t in transactions if t["amount"] > 50],
    key=lambda t: t["amount"],
    reverse=True
)]
print(result)  # [7, 5, 3, 6, 1]

# (g) Alternative using filter, sorted, map with lambdas:
result_alt = list(map(
    lambda t: t["id"],
    sorted(
        filter(lambda t: t["amount"] > 50, transactions),
        key=lambda t: t["amount"],
        reverse=True
    )
))
print(result_alt)  # [7, 5, 3, 6, 1] — same result
# The comprehension version (f) is generally considered more readable in Python
# because the data flow reads left-to-right and the filtering is explicit.
# The map/filter version is more common in functional languages like Haskell.