# --- Week 1: Introduction to Software Life Cycle and Programming Basics Using Python ---

# Hello World Example
print("Hello, World!")

# Simple calculation example
a = 10
b = 5
sum = a + b
print("Sum:", sum)

# A simple budgeting app (console-based)
print("=== Welcome to Simple Budgeting App ===")

# Step 1: Set your total budget
budget = float(input("Enter your total budget for the month: ₹ "))

# Variables to track expenses
expenses = []
total_spent = 0

# Step 2: Add expenses
while True:
    print("\nChoose an option:")
    print("1. Add an expense")
    print("2. View all expenses")
    print("3. View summary")
    print("4. Exit")
    choice = input("Enter your choice (1-4): ")

    if choice == "1":
        description = input("Enter expense description: ")
        amount = float(input("Enter amount spent (₹): "))
        expenses.append((description, amount))
        total_spent += amount
        print(f"Added: {description} - ₹{amount}")
    elif choice == "2":
        print("\n📋 Your Expenses:")
        for i, (desc, amt) in enumerate(expenses, start=1):
            print(f"{i}. {desc} - ₹{amt}")
    elif choice == "3":
        remaining = budget - total_spent
        print("\nBudget Summary:")
        print(f"Total Budget: ₹{budget}")
        print(f"Total Spent: ₹{total_spent}")
        print(f"Remaining Balance: ₹{remaining}")
        if remaining < 0:
            print(" You are over budget!")
    elif choice == "4":
        print("\n Exiting the app. Stay on budget!")
        break
    else:
        print("Invalid choice. Please enter 1-4.")

# --- Miscellaneous Input/Output and eval() Examples ---

# What is the expected output?
x = input("Enter a number: ")
print(x * 2)
# (Input returns a string, so "5" * 2 yields "55")

print("The sum is", 5 + 3)
# The following will raise an error because of type mismatch:
# print("The sum is" + 5 + 3)

a = input("Enter a number: ")
b = input("Enter another number: ")
print("Sum is:", a + b)
# (Both inputs are strings; concatenation happens instead of numerical addition)

print(""""Hello" World""")
# (Triple double quotes allow nested quotes without escapes)

expr1 = input("Enter an expression: ")
print("Output:", expr1)
expr2 = eval(input("Enter an expression: "))
print("Output:", expr2)
# (When inputting 5 + 3: expr1 prints the string, expr2 evaluates to 8)

eval(input("Enter a print command: "))
# (Try inputting, for example: print("Hello!"))

print("=== Expression Tester: With vs Without eval() ===")
user_input = input("Enter a Python expression (e.g., 2 + 3, 'A' * 5, [1, 2]*2): ")
# Without eval: Just shows the string
print("\n Without eval():")
print("Output:", user_input)
# With eval: Actually executes the expression
print("\n With eval():")
try:
    result = eval(user_input)
    print("Output:", result)
except Exception as e:
    print("Error while evaluating:", e)

# --- Week 2: Basic Syntax: I/O Functions and Operators ---

# Simple Calculator Example:
num1 = float(input("Enter first number: "))
num2 = float(input("Enter second number: "))
print("Addition:", num1 + num2)
print("Subtraction:", num1 - num2)
print("Multiplication:", num1 * num2)
print("Division:", num1 / num2 if num2 != 0 else "Undefined")

# String arithmetic examples:
print("5" + "5")
print("5" * 3)

# Division operators:
print(5 / 2)
print(5 // 2)

# Modulus operator:
print(-7 % 3)

# Comparison example:
print("5" == 5)

# Chained comparisons:
print(1 < 2 < 3)
print(1 < 2 > 3)

# Floating point precision:
print(0.1 + 0.2 == 0.3)
print(round(0.1 + 0.2, 10) == round(0.3, 10))
import math
print(math.isclose(0.1 + 0.2, 0.3))

# Logical operators examples:
print(0 or 5)
print(1 and 3)
print(not 0)     # True
print(not 10)    # False

def check():
    print("called")
    return True

print("False and check():", False and check())   # check() not called
print("True and check():", True and check())     # check() called
print("False or check():", False or check())     # check() called
print("True or check():", True or check())       # check() not called

# --- Week 3: Control Structures: if, else, elif ---

# Grading System Example:
score = float(input("Enter your score: "))
if score >= 90:
    grade = "A"
elif score >= 80:
    grade = "B"
elif score >= 70:
    grade = "C"
elif score >= 60:
    grade = "D"
else:
    grade = "F"
print("Your grade is:", grade)

# Simple if condition:
x = 5
if x > 0:
    print("Positive")
    print("Still in if")
print("Outside if")

# if-elif-else chain:
x = 10
if x < 10:
    print("Less than 10")
elif x < 20:
    print("Less than 20")
else:
    print("Something else")

# Truthiness of non-empty strings:
x = "False"
y = ""
if x:
    print("x: It's True")
else:
    print("x: It's False")
if y:
    print("y: It's True")
else:
    print("y: It's False")

# Exclusive conditions:
x = 7
if x > 5:
    print("Greater than 5")
elif x > 6:
    print("Greater than 6")

# Boolean equivalence:
x = 0
if x == True:
    print("x is True")
elif x == False:
    print("x is False")
else:
    print("x is... confusing")

# One-line if statement (note the indentation effect):
x = 10
if x > 5: print("A"); print("B")

# --- Week 4: Looping: for and while ---

# For loop iterating over a list:
fruits = ["apple", "banana", "cherry"]
for fruit in fruits:
    print("Fruit:", fruit)

# While loop counting from 1 to 5:
count = 1
while count <= 5:
    print("Count:", count)
    count += 1

# Removing items during iteration:
nums = [1, 2, 3, 4]
for num in nums:
    if num % 2 == 0:
        nums.remove(num)
print(nums)

# Potential infinite loop example:
x = 0
while x < 5:
    print(x)
    x += 1
    if x == 3:
        x = -1

# Reassigning loop variable in a for loop:
for i in "123":
    i = int(i) + 1
print(i)

# For loop with else clause:
for i in range(3):
    print(i)
else:
    print("Done!")

# For loop with empty iterable:
for i in []:
    print("You won't see me")
else:
    print("But you'll see me!")

# For loop with non-tuple iterated:
for i in (5):
    print(i)

# Nested loops with break in inner loop:
for i in range(3):
    for j in range(3):
        print(i, j)
        if j == 1:
            break

# --- Week 5 & 6: Data Structures: Lists, Dictionaries, Tuples, and Sets ---

# List example:
numbers = [1, 2, 3, 4, 5]
numbers.append(6)
print("List:", numbers)

# Dictionary example:
student = {"name": "Alice", "age": 20, "major": "Computer Science"}
print("Student Name:", student["name"])

# Tuple example:
dimensions = (1920, 1080)
print("Dimensions:", dimensions)

# Set example:
unique_numbers = {1, 2, 2, 3, 4, 4, 5}
print("Unique Numbers:", unique_numbers)

# --- Tricky Data Structure Snippets ---

# 1. Modifying a list during iteration
numbers = [1, 2, 3, 4, 5]
for num in numbers:
    if num % 2 == 0:
        numbers.remove(num)
print("Modified list:", numbers)

# 2. Shallow copy pitfall with nested lists
original = [[1, 2], [3, 4]]
shallow_copy = original[:]  # Shallow copy
shallow_copy[0][0] = 100
print("Original list after modification:", original)

# 3. Duplicate keys in dictionary comprehension
keys = ["apple", "banana", "apple"]
fruit_dict = {k: len(k) for k in keys}
print("Dictionary with duplicate keys:", fruit_dict)

# 4. Shared mutable default using dict.fromkeys
d = dict.fromkeys(["x", "y", "z"], [])
d["x"].append(1)
print("Dictionary with shared mutable default:", d)

# 5. Mutable element inside an immutable tuple
t = ([1, 2], 3, 4)
t[0].append(99)
print("Tuple with a modified mutable element:", t)

# 6. Set: duplicate elimination and unordered nature
s = set([4, 2, 2, 3, 1, 4])
print("Set with duplicates removed:", s)

# 7. Frozenset comparison in a set
s = {frozenset({1, 2}), frozenset({2, 1})}
print("Number of unique frozensets:", len(s))

# --- Week 7: User-Defined Functions ---

# Factorial function example
def factorial(n):
    """Return the factorial of n."""
    result = 1
    for i in range(1, n + 1):
        result *= i
    return result

num = 5
print(f"Factorial of {num} is:", factorial(num))

# Mutable default argument pitfall
def add_item(item, my_list=[]):
    my_list.append(item)
    return my_list

print("First call:", add_item(1))
print("Second call:", add_item(2))

# Corrected version using None as default
def add_item(item, my_list=None):
    if my_list is None:
        my_list = []
    my_list.append(item)
    return my_list

print("First call:", add_item(1))
print("Second call:", add_item(2))

# Closure: Returning a function (multiplier)
def make_multiplier(n):
    def multiplier(x):
        return x * n
    return multiplier

times3 = make_multiplier(3)
print("3 multiplied by 10 is:", times3(10))

# Function with arbitrary arguments
def combine(*args, **kwargs):
    print("Positional arguments:", args)
    print("Keyword arguments:", kwargs)

combine(1, 2, 3, a="apple", b="banana")

# --- Week 8: Error Handling and Debugging ---

# Handling division errors with try/except/else/finally
def safe_divide(a, b):
    try:
        result = a / b
    except ZeroDivisionError:
        print("Error: Division by zero is not allowed.")
        return None
    else:
        print("Division successful.")
        return result
    finally:
        print("Execution of safe_divide complete.")

print("Result:", safe_divide(10, 2))
print("Result:", safe_divide(10, 0))

# Tricky return behavior with try/except/finally
def tricky_return():
    try:
        print("In try block")
        return "from try"
    except Exception:
        print("In except block")
        return "from except"
    finally:
        print("In finally block")
        return "from finally"  # Overrides previous returns

result = tricky_return()
print("Result:", result)

# Square root function with error handling (using built-in ValueError)
def square_root(x):
    if x < 0:
        raise ValueError("Cannot compute square root of a negative number.")
    return x ** 0.5

try:
    print("Square root:", square_root(9))
    print("Square root:", square_root(-4))
except ValueError as e:
    print("Caught an error:", e)

# Debugging scenario: Calculating average with a logic error
def calculate_average(numbers):
    total = 0
    # Intentional error: loop iterates over all but the last element.
    for i in range(len(numbers) - 1):
        total += numbers[i]
    average = total / len(numbers)
    return average

numbers = [10, 20, 30, 40]
print("Average:", calculate_average(numbers))
