"""
================================================================================
File: 01_try_except.py
Topic: Exception Handling with try-except
================================================================================

This file demonstrates error handling in Python using try-except blocks.
Proper error handling makes your code more robust and user-friendly by
gracefully managing unexpected situations.

Key Concepts:
- try, except, else, finally blocks
- Catching specific exceptions
- Exception hierarchy
- Raising exceptions
- Getting exception information

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

# -----------------------------------------------------------------------------
# 1. Basic try-except
# -----------------------------------------------------------------------------
# Catch and handle errors that would otherwise crash your program

print("--- Basic try-except ---")

# Without error handling - this would crash:
# result = 10 / 0  # ZeroDivisionError!

# With error handling:
try:
    result = 10 / 0
except:
    print("An error occurred!")

# Better - catch specific exception:
try:
    result = 10 / 0
except ZeroDivisionError:
    print("Cannot divide by zero!")
    result = 0

print(f"Result: {result}")

# -----------------------------------------------------------------------------
# 2. Catching Specific Exceptions
# -----------------------------------------------------------------------------
# Different errors need different handling

print("\n--- Catching Specific Exceptions ---")

def safe_divide(a, b):
    """Divide with specific error handling."""
    try:
        result = a / b
    except ZeroDivisionError:
        print("  Error: Cannot divide by zero")
        return None
    except TypeError:
        print("  Error: Invalid types for division")
        return None
    return result

print(f"10 / 2 = {safe_divide(10, 2)}")
print(f"10 / 0 = {safe_divide(10, 0)}")
print(f"10 / 'a' = {safe_divide(10, 'a')}")

# -----------------------------------------------------------------------------
# 3. Catching Multiple Exceptions
# -----------------------------------------------------------------------------

print("\n--- Multiple Exceptions ---")

# Catch multiple in one line
def process_number(value):
    """Process a number with multiple exception handlers."""
    try:
        # This might raise ValueError or TypeError
        number = int(value)
        result = 100 / number
        return result
    except (ValueError, TypeError) as e:
        print(f"  Conversion error: {e}")
        return None
    except ZeroDivisionError:
        print("  Cannot divide by zero")
        return None

print(f"process_number('5'): {process_number('5')}")
print(f"process_number('abc'): {process_number('abc')}")
print(f"process_number('0'): {process_number('0')}")

# -----------------------------------------------------------------------------
# 4. The else Clause
# -----------------------------------------------------------------------------
# Runs only if no exception occurred

print("\n--- The else Clause ---")

def divide_with_else(a, b):
    """Division with else clause for success logging."""
    try:
        result = a / b
    except ZeroDivisionError:
        print("  Division failed: Cannot divide by zero")
        return None
    else:
        # Only runs if try block succeeded
        print(f"  Division successful: {a} / {b} = {result}")
        return result

divide_with_else(10, 2)
divide_with_else(10, 0)

# -----------------------------------------------------------------------------
# 5. The finally Clause
# -----------------------------------------------------------------------------
# Always runs, regardless of whether an exception occurred

print("\n--- The finally Clause ---")

def read_file_example(filename):
    """Demonstrate finally for cleanup."""
    file = None
    try:
        print(f"  Attempting to open '{filename}'...")
        # Simulating file operation
        if filename == "missing.txt":
            raise FileNotFoundError("File not found")
        print("  File opened successfully!")
        return "File content"
    except FileNotFoundError as e:
        print(f"  Error: {e}")
        return None
    finally:
        # This ALWAYS runs
        print("  Cleanup: Closing file (if open)")

read_file_example("data.txt")
print()
read_file_example("missing.txt")

# Real-world pattern with file
print("\n--- Real file handling ---")

# Best practice: use 'with' statement (handles cleanup automatically)
# But for learning, here's the try-finally pattern:
"""
file = None
try:
    file = open("data.txt", "r")
    content = file.read()
finally:
    if file:
        file.close()  # Always closes, even if error occurred
"""

# -----------------------------------------------------------------------------
# 6. Complete try-except-else-finally
# -----------------------------------------------------------------------------

print("\n--- Complete Pattern ---")

def complete_example(value):
    """Show all four clauses in action."""
    print(f"\n  Processing: {value}")
    try:
        number = int(value)
        result = 100 / number
    except ValueError:
        print("  EXCEPT: Not a valid integer")
        result = None
    except ZeroDivisionError:
        print("  EXCEPT: Division by zero")
        result = None
    else:
        print(f"  ELSE: Success! Result = {result}")
    finally:
        print("  FINALLY: This always runs")
    
    return result

complete_example("10")
complete_example("abc")
complete_example("0")

# -----------------------------------------------------------------------------
# 7. Getting Exception Information
# -----------------------------------------------------------------------------

print("\n--- Exception Information ---")

try:
    numbers = [1, 2, 3]
    print(numbers[10])
except IndexError as e:
    print(f"Exception type: {type(e).__name__}")
    print(f"Exception message: {e}")
    print(f"Exception args: {e.args}")

# Getting full traceback
import traceback

try:
    result = 1 / 0
except ZeroDivisionError:
    print("\nFull traceback:")
    traceback.print_exc()

# -----------------------------------------------------------------------------
# 8. Common Built-in Exceptions
# -----------------------------------------------------------------------------

print("\n--- Common Exceptions ---")

exceptions_demo = [
    ("ValueError", "int('abc')"),
    ("TypeError", "'2' + 2"),
    ("IndexError", "[1,2,3][10]"),
    ("KeyError", "{}['missing']"),
    ("AttributeError", "'string'.missing_method()"),
    ("FileNotFoundError", "open('nonexistent.txt')"),
    ("ZeroDivisionError", "1/0"),
    ("ImportError", "import nonexistent_module"),
    ("NameError", "undefined_variable"),
]

print("Common exception types:")
for name, example in exceptions_demo:
    print(f"  {name}: {example}")

# -----------------------------------------------------------------------------
# 9. Exception Hierarchy
# -----------------------------------------------------------------------------

print("\n--- Exception Hierarchy ---")

# All exceptions inherit from BaseException
# Most use Exception as base

hierarchy = """
BaseException
├── SystemExit
├── KeyboardInterrupt
├── GeneratorExit
└── Exception
    ├── StopIteration
    ├── ArithmeticError
    │   ├── ZeroDivisionError
    │   ├── FloatingPointError
    │   └── OverflowError
    ├── LookupError
    │   ├── IndexError
    │   └── KeyError
    ├── ValueError
    ├── TypeError
    ├── AttributeError
    ├── OSError
    │   ├── FileNotFoundError
    │   └── PermissionError
    └── RuntimeError
"""

print("Exception hierarchy (simplified):")
print(hierarchy)

# Catching parent catches all children
try:
    result = 1 / 0
except ArithmeticError:  # Catches ZeroDivisionError too!
    print("Caught an arithmetic error (parent class)")

# -----------------------------------------------------------------------------
# 10. Practical Examples
# -----------------------------------------------------------------------------

print("\n--- Practical Examples ---")

# Example 1: User input validation
def get_positive_number(prompt):
    """Get a positive number from user (simulated)."""
    test_inputs = ["abc", "-5", "0", "10"]
    
    for user_input in test_inputs:
        print(f"  Input: '{user_input}'", end=" → ")
        try:
            number = float(user_input)
            if number <= 0:
                raise ValueError("Number must be positive")
            print(f"Valid! ({number})")
            return number
        except ValueError as e:
            print(f"Invalid: {e}")
    return None

print("User input validation:")
get_positive_number("Enter a positive number: ")

# Example 2: Safe dictionary access
def safe_get(dictionary, *keys, default=None):
    """Safely navigate nested dictionary."""
    current = dictionary
    try:
        for key in keys:
            current = current[key]
        return current
    except (KeyError, TypeError):
        return default

data = {"user": {"profile": {"name": "Alice"}}}
print(f"\nNested access: {safe_get(data, 'user', 'profile', 'name')}")
print(f"Missing key: {safe_get(data, 'user', 'missing', 'name', default='N/A')}")

# Example 3: Retry pattern
def fetch_with_retry(max_retries=3):
    """Simulate fetching with retry on failure."""
    import random
    
    for attempt in range(1, max_retries + 1):
        try:
            print(f"  Attempt {attempt}...", end=" ")
            # Simulate random failure
            if random.random() < 0.7:  # 70% chance of failure
                raise ConnectionError("Network error")
            print("Success!")
            return "Data"
        except ConnectionError as e:
            print(f"Failed: {e}")
            if attempt == max_retries:
                print("  All retries exhausted!")
                return None

print("\nRetry pattern:")
fetch_with_retry()
