"""
================================================================================
File: 02_init_methods.py
Topic: Constructors and Initialization in Python
================================================================================

This file explores the __init__ method and other initialization patterns
in Python classes. The __init__ method is called when creating new objects
and sets up the initial state of the instance.

Key Concepts:
- The __init__ constructor
- Required vs optional parameters
- Default values
- Property decorators
- Alternative constructors (classmethod)

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

# -----------------------------------------------------------------------------
# 1. Basic __init__ Method
# -----------------------------------------------------------------------------
# __init__ initializes newly created objects

print("--- Basic __init__ ---")

class Book:
    """A class representing a book."""
    
    def __init__(self, title, author):
        """
        Initialize a new book.
        
        Args:
            title: The book's title
            author: The book's author
        """
        self.title = title
        self.author = author

# Creating instances calls __init__ automatically
book = Book("1984", "George Orwell")
print(f"Book: '{book.title}' by {book.author}")

# Without proper arguments, you get an error:
# book = Book()  # TypeError: missing 2 required positional arguments

# -----------------------------------------------------------------------------
# 2. Default Parameter Values
# -----------------------------------------------------------------------------
# Make some parameters optional

print("\n--- Default Values ---")

class User:
    """A class with default parameter values."""
    
    def __init__(self, username, email=None, role="user", active=True):
        """
        Initialize a new user.
        
        Args:
            username: Required username
            email: Optional email address
            role: User role (default: "user")
            active: Is user active (default: True)
        """
        self.username = username
        self.email = email
        self.role = role
        self.active = active
    
    def __str__(self):
        email = self.email or "No email"
        status = "Active" if self.active else "Inactive"
        return f"{self.username} ({self.role}) - {email} - {status}"

# Various ways to create users
user1 = User("alice")
user2 = User("bob", "bob@example.com")
user3 = User("charlie", "charlie@example.com", "admin")
user4 = User("dave", active=False)

print(user1)
print(user2)
print(user3)
print(user4)

# -----------------------------------------------------------------------------
# 3. Validation in __init__
# -----------------------------------------------------------------------------
# Validate and process data during initialization

print("\n--- Validation in __init__ ---")

class Temperature:
    """A class with validation in __init__."""
    
    def __init__(self, celsius):
        """
        Initialize temperature in Celsius.
        
        Args:
            celsius: Temperature in Celsius
            
        Raises:
            ValueError: If temperature is below absolute zero
        """
        if celsius < -273.15:
            raise ValueError("Temperature cannot be below absolute zero!")
        self._celsius = celsius
    
    @property
    def celsius(self):
        """Get temperature in Celsius."""
        return self._celsius
    
    @property
    def fahrenheit(self):
        """Get temperature in Fahrenheit."""
        return (self._celsius * 9/5) + 32
    
    @property
    def kelvin(self):
        """Get temperature in Kelvin."""
        return self._celsius + 273.15

temp = Temperature(25)
print(f"Temperature: {temp.celsius}°C = {temp.fahrenheit}°F = {temp.kelvin}K")

# Validation in action
try:
    invalid_temp = Temperature(-300)
except ValueError as e:
    print(f"Validation error: {e}")

# -----------------------------------------------------------------------------
# 4. Mutable Default Arguments Warning
# -----------------------------------------------------------------------------
# Never use mutable objects as default arguments!

print("\n--- Mutable Defaults Warning ---")

# BAD - mutable default argument
class BadStudent:
    def __init__(self, name, grades=[]):  # DON'T DO THIS!
        self.name = name
        self.grades = grades

s1 = BadStudent("Alice")
s1.grades.append(90)

s2 = BadStudent("Bob")
print(f"Bob's grades (should be empty): {s2.grades}")  # Contains 90!

# GOOD - use None and create new list
class GoodStudent:
    def __init__(self, name, grades=None):
        self.name = name
        self.grades = grades if grades is not None else []

g1 = GoodStudent("Carol")
g1.grades.append(85)

g2 = GoodStudent("Dan")
print(f"Dan's grades (correctly empty): {g2.grades}")

# -----------------------------------------------------------------------------
# 5. Property Decorators
# -----------------------------------------------------------------------------
# Control attribute access with getters and setters

print("\n--- Property Decorators ---")

class Circle:
    """A circle with property decorators."""
    
    def __init__(self, radius):
        """Initialize circle with radius."""
        self._radius = radius  # Protected attribute
    
    @property
    def radius(self):
        """Get the radius."""
        return self._radius
    
    @radius.setter
    def radius(self, value):
        """Set the radius with validation."""
        if value <= 0:
            raise ValueError("Radius must be positive")
        self._radius = value
    
    @property
    def diameter(self):
        """Get the diameter (read-only computed property)."""
        return self._radius * 2
    
    @property
    def area(self):
        """Get the area (read-only computed property)."""
        import math
        return math.pi * self._radius ** 2

circle = Circle(5)
print(f"Radius: {circle.radius}")
print(f"Diameter: {circle.diameter}")
print(f"Area: {circle.area:.2f}")

# Using setter
circle.radius = 10
print(f"New radius: {circle.radius}")
print(f"New area: {circle.area:.2f}")

# Validation in setter
try:
    circle.radius = -5
except ValueError as e:
    print(f"Setter validation: {e}")

# -----------------------------------------------------------------------------
# 6. Alternative Constructors with @classmethod
# -----------------------------------------------------------------------------
# Create objects in different ways

print("\n--- Alternative Constructors ---")

class Person:
    """A class with alternative constructors."""
    
    def __init__(self, first_name, last_name, age):
        """Standard constructor."""
        self.first_name = first_name
        self.last_name = last_name
        self.age = age
    
    @classmethod
    def from_birth_year(cls, first_name, last_name, birth_year):
        """Create person from birth year instead of age."""
        import datetime
        age = datetime.datetime.now().year - birth_year
        return cls(first_name, last_name, age)
    
    @classmethod
    def from_full_name(cls, full_name, age):
        """Create person from full name string."""
        parts = full_name.split()
        first_name = parts[0]
        last_name = parts[-1] if len(parts) > 1 else ""
        return cls(first_name, last_name, age)
    
    @classmethod
    def from_dict(cls, data):
        """Create person from dictionary."""
        return cls(
            data.get('first_name', ''),
            data.get('last_name', ''),
            data.get('age', 0)
        )
    
    def __str__(self):
        return f"{self.first_name} {self.last_name}, age {self.age}"

# Using different constructors
p1 = Person("John", "Doe", 30)
p2 = Person.from_birth_year("Jane", "Smith", 1995)
p3 = Person.from_full_name("Bob Johnson", 25)
p4 = Person.from_dict({"first_name": "Alice", "last_name": "Williams", "age": 28})

print("Created using different constructors:")
print(f"  Standard: {p1}")
print(f"  From birth year: {p2}")
print(f"  From full name: {p3}")
print(f"  From dict: {p4}")

# -----------------------------------------------------------------------------
# 7. __new__ vs __init__
# -----------------------------------------------------------------------------
# __new__ creates the instance, __init__ initializes it

print("\n--- __new__ vs __init__ ---")

class Singleton:
    """A singleton class using __new__."""
    
    _instance = None
    
    def __new__(cls):
        """Create instance only if one doesn't exist."""
        if cls._instance is None:
            print("  Creating new instance...")
            cls._instance = super().__new__(cls)
        else:
            print("  Returning existing instance...")
        return cls._instance
    
    def __init__(self):
        """Initialize (called every time)."""
        pass

# Both variables point to the same instance
print("Creating s1:")
s1 = Singleton()
print("Creating s2:")
s2 = Singleton()
print(f"Same instance? {s1 is s2}")

# -----------------------------------------------------------------------------
# 8. Initialization with Inheritance
# -----------------------------------------------------------------------------

print("\n--- Initialization with Inheritance ---")

class Animal:
    """Base class for animals."""
    
    def __init__(self, name, species):
        """Initialize animal."""
        self.name = name
        self.species = species
    
    def speak(self):
        """Make a sound."""
        return "Some sound"

class Dog(Animal):
    """Dog class inheriting from Animal."""
    
    def __init__(self, name, breed):
        """Initialize dog with name and breed."""
        # Call parent's __init__
        super().__init__(name, species="Canis familiaris")
        self.breed = breed
    
    def speak(self):
        """Dogs bark."""
        return "Woof!"

class Cat(Animal):
    """Cat class inheriting from Animal."""
    
    def __init__(self, name, indoor=True):
        """Initialize cat."""
        super().__init__(name, species="Felis catus")
        self.indoor = indoor
    
    def speak(self):
        """Cats meow."""
        return "Meow!"

dog = Dog("Buddy", "Golden Retriever")
cat = Cat("Whiskers", indoor=True)

print(f"Dog: {dog.name} ({dog.breed}) says {dog.speak()}")
print(f"Cat: {cat.name} (Indoor: {cat.indoor}) says {cat.speak()}")

# -----------------------------------------------------------------------------
# 9. Complex Initialization Example
# -----------------------------------------------------------------------------

print("\n--- Complex Initialization ---")

from datetime import datetime

class Order:
    """A class with complex initialization."""
    
    _order_counter = 0
    
    def __init__(self, customer_name, items=None, discount=0):
        """
        Initialize an order.
        
        Args:
            customer_name: Name of the customer
            items: List of (item_name, price, quantity) tuples
            discount: Discount percentage (0-100)
        """
        # Auto-generate order ID
        Order._order_counter += 1
        self.order_id = f"ORD-{Order._order_counter:04d}"
        
        # Set basic attributes
        self.customer_name = customer_name
        self.items = items or []
        self.discount = max(0, min(100, discount))  # Clamp to 0-100
        
        # Auto-generated attributes
        self.created_at = datetime.now()
        self._subtotal = None  # Cached calculation
    
    def add_item(self, name, price, quantity=1):
        """Add an item to the order."""
        self.items.append((name, price, quantity))
        self._subtotal = None  # Invalidate cache
    
    @property
    def subtotal(self):
        """Calculate subtotal."""
        if self._subtotal is None:
            self._subtotal = sum(price * qty for _, price, qty in self.items)
        return self._subtotal
    
    @property
    def total(self):
        """Calculate total with discount."""
        return self.subtotal * (1 - self.discount / 100)
    
    def __str__(self):
        return f"Order {self.order_id} for {self.customer_name}: ${self.total:.2f}"

# Create orders
order1 = Order("Alice")
order1.add_item("Widget", 9.99, 2)
order1.add_item("Gadget", 14.99, 1)

order2 = Order("Bob", discount=10)
order2.add_item("Premium Widget", 29.99, 1)

print(order1)
print(f"  Subtotal: ${order1.subtotal:.2f}")
print(f"  Total: ${order1.total:.2f}")

print(order2)
print(f"  Subtotal: ${order2.subtotal:.2f}")
print(f"  Total (10% off): ${order2.total:.2f}")

# -----------------------------------------------------------------------------
# 10. Summary
# -----------------------------------------------------------------------------

print("\n--- Summary ---")

summary = """
Initialization patterns:
  - __init__: Main constructor, initializes attributes
  - Default values: Make parameters optional
  - Validation: Raise errors for invalid input
  - Properties: Computed/validated attributes
  - @classmethod: Alternative constructors
  - __new__: Instance creation (before __init__)
  - super().__init__(): Call parent constructor
"""

print(summary)
