"""
================================================================================
File: 02_tuples.py
Topic: Python Tuples - Ordered, Immutable Collections
================================================================================

This file demonstrates Python tuples, which are ordered, immutable collections.
Unlike lists, tuples cannot be modified after creation, making them useful
for data that should not change.

Key Concepts:
- Creating tuples
- Tuple immutability
- Accessing elements
- Tuple unpacking
- When to use tuples vs lists
- Named tuples for clarity

================================================================================
"""

# -----------------------------------------------------------------------------
# 1. Creating Tuples
# -----------------------------------------------------------------------------
# Tuples are created with parentheses () or just commas

print("--- Creating Tuples ---")

# Empty tuple
empty_tuple = ()
print(f"Empty tuple: {empty_tuple}")

# Tuple with elements
coordinates = (10, 20)
colors = ("red", "green", "blue")
mixed = (1, "hello", 3.14, True)

print(f"Coordinates: {coordinates}")
print(f"Colors: {colors}")
print(f"Mixed types: {mixed}")

# Single element tuple - needs trailing comma!
single = (42,)      # This is a tuple
not_tuple = (42)    # This is just an integer!
print(f"\nSingle element tuple: {single}, type: {type(single)}")
print(f"Without comma: {not_tuple}, type: {type(not_tuple)}")

# Tuple without parentheses (packing)
packed = 1, 2, 3
print(f"Packed tuple: {packed}, type: {type(packed)}")

# Using tuple() constructor
from_list = tuple([1, 2, 3])
from_string = tuple("Python")
print(f"From list: {from_list}")
print(f"From string: {from_string}")

# -----------------------------------------------------------------------------
# 2. Accessing Elements
# -----------------------------------------------------------------------------
# Same indexing and slicing as lists

print("\n--- Accessing Elements ---")

fruits = ("apple", "banana", "cherry", "date", "elderberry")
print(f"Tuple: {fruits}")

print(f"First element: {fruits[0]}")
print(f"Last element: {fruits[-1]}")
print(f"Slice [1:4]: {fruits[1:4]}")
print(f"Every other: {fruits[::2]}")

# -----------------------------------------------------------------------------
# 3. Tuple Immutability
# -----------------------------------------------------------------------------
# Tuples cannot be modified after creation

print("\n--- Immutability ---")

point = (10, 20, 30)
print(f"Original point: {point}")

# This would raise an error:
# point[0] = 100  # TypeError: 'tuple' object does not support item assignment

# However, you can create a new tuple
point = (100,) + point[1:]  # Create new tuple
print(f"New point: {point}")

# Note: Mutable objects inside tuples can still be modified
mutable_inside = ([1, 2], [3, 4])
mutable_inside[0].append(3)  # This works!
print(f"Mutable inside tuple: {mutable_inside}")

# -----------------------------------------------------------------------------
# 4. Tuple Unpacking
# -----------------------------------------------------------------------------
# Assign tuple elements to multiple variables

print("\n--- Tuple Unpacking ---")

# Basic unpacking
coordinates = (100, 200, 300)
x, y, z = coordinates
print(f"Unpacked: x={x}, y={y}, z={z}")

# Swap values using tuple unpacking
a, b = 1, 2
print(f"Before swap: a={a}, b={b}")
a, b = b, a  # Elegant swap!
print(f"After swap: a={a}, b={b}")

# Unpacking with * (extended unpacking)
numbers = (1, 2, 3, 4, 5)
first, *middle, last = numbers
print(f"\nExtended unpacking:")
print(f"  first: {first}")
print(f"  middle: {middle}")  # This becomes a list!
print(f"  last: {last}")

# Ignoring values with underscore
data = ("John", 25, "Developer", "NYC")
name, _, job, _ = data  # Ignore age and city
print(f"Name: {name}, Job: {job}")

# -----------------------------------------------------------------------------
# 5. Tuple Methods
# -----------------------------------------------------------------------------
# Tuples have only two methods (because they're immutable)

print("\n--- Tuple Methods ---")

numbers = (1, 2, 3, 2, 4, 2, 5)
print(f"Tuple: {numbers}")

# count() - Count occurrences
print(f"Count of 2: {numbers.count(2)}")

# index() - Find index of first occurrence
print(f"Index of 4: {numbers.index(4)}")

# -----------------------------------------------------------------------------
# 6. Tuple Operations
# -----------------------------------------------------------------------------

print("\n--- Tuple Operations ---")

# Concatenation
t1 = (1, 2, 3)
t2 = (4, 5, 6)
combined = t1 + t2
print(f"{t1} + {t2} = {combined}")

# Repetition
repeated = (0,) * 5
print(f"(0,) * 5 = {repeated}")

# Membership
colors = ("red", "green", "blue")
print(f"'green' in colors: {'green' in colors}")

# Length
print(f"len(colors): {len(colors)}")

# Min, Max, Sum
nums = (5, 2, 8, 1, 9)
print(f"min: {min(nums)}, max: {max(nums)}, sum: {sum(nums)}")

# -----------------------------------------------------------------------------
# 7. Tuples as Dictionary Keys
# -----------------------------------------------------------------------------
# Tuples can be used as dictionary keys (lists cannot)

print("\n--- Tuples as Dictionary Keys ---")

# Mapping (x, y) coordinates to location names
locations = {
    (40.7128, -74.0060): "New York City",
    (34.0522, -118.2437): "Los Angeles",
    (51.5074, -0.1278): "London"
}

print("Locations dictionary:")
for coords, city in locations.items():
    print(f"  {coords}: {city}")

# Access by tuple key
coord = (40.7128, -74.0060)
print(f"\nLocation at {coord}: {locations[coord]}")

# -----------------------------------------------------------------------------
# 8. Returning Multiple Values from Functions
# -----------------------------------------------------------------------------
# Functions can return tuples

print("\n--- Functions Returning Tuples ---")

def get_stats(numbers):
    """Return multiple statistics as a tuple."""
    return min(numbers), max(numbers), sum(numbers) / len(numbers)

data = [10, 20, 30, 40, 50]
minimum, maximum, average = get_stats(data)
print(f"Stats for {data}:")
print(f"  Min: {minimum}, Max: {maximum}, Avg: {average}")

def divide_with_remainder(a, b):
    """Return quotient and remainder."""
    return a // b, a % b

quotient, remainder = divide_with_remainder(17, 5)
print(f"\n17 ÷ 5 = {quotient} remainder {remainder}")

# -----------------------------------------------------------------------------
# 9. Named Tuples
# -----------------------------------------------------------------------------
# Tuples with named fields for clarity

print("\n--- Named Tuples ---")

from collections import namedtuple

# Define a named tuple type
Person = namedtuple("Person", ["name", "age", "city"])

# Create instances
person1 = Person("Baraa", 25, "Gaza")
person2 = Person(name="Sara", age=30, city="Cairo")

print(f"Person 1: {person1}")
print(f"Person 2: {person2}")

# Access by name or index
print(f"\nAccess by name: {person1.name}")
print(f"Access by index: {person1[0]}")

# Named tuple is still immutable
# person1.age = 26  # This would raise an error

# Convert to dictionary
print(f"As dict: {person1._asdict()}")

# Create from dictionary
data = {"name": "Ali", "age": 28, "city": "Dubai"}
person3 = Person(**data)
print(f"From dict: {person3}")

# -----------------------------------------------------------------------------
# 10. When to Use Tuples vs Lists
# -----------------------------------------------------------------------------

print("\n--- Tuples vs Lists ---")

# Use TUPLES when:
# 1. Data should not change
rgb_red = (255, 0, 0)  # Color values shouldn't change

# 2. Using as dictionary keys
grid_positions = {(0, 0): "start", (9, 9): "end"}

# 3. Returning multiple values from functions
def get_point(): return (10, 20)

# 4. Heterogeneous data (different types, specific meaning)
person = ("John", 30, "Engineer")  # name, age, job

# Use LISTS when:
# 1. Data will be modified
shopping_cart = ["apple", "bread"]  # Items will be added/removed

# 2. Homogeneous collection
scores = [85, 90, 78, 92]  # All same type, will be processed together

# 3. Order matters and you need to sort/shuffle
rankings = [3, 1, 4, 1, 5]
rankings.sort()  # Lists are sortable in place

print("Summary:")
print("  Tuples: Immutable, hashable, for fixed data")
print("  Lists: Mutable, flexible, for changing data")

# Performance note: Tuples are slightly faster and use less memory
import sys
list_size = sys.getsizeof([1, 2, 3, 4, 5])
tuple_size = sys.getsizeof((1, 2, 3, 4, 5))
print(f"\nMemory: List={list_size} bytes, Tuple={tuple_size} bytes")
