# Polymorphism: When the Same Call Does Different Things
# same method name, different behavior per class
# redhorndev.com

# ─────────────────────────────────────────────
# Polymorphism with inheritance
# ─────────────────────────────────────────────

class Animal:
    def __init__(self, name, age):
        self.name = name
        self.age  = age

    def describe(self):
        print(f"{self.name} is {self.age} years old.")

class Dog(Animal):
    def __init__(self, name, age, breed):
        super().__init__(name, age)
        self.breed = breed

    def describe(self):                         # overrides Animal.describe()
        print(f"{self.name} is a {self.age} year old {self.breed}.")

class Cat(Animal):
    def __init__(self, name, age, indoor):
        super().__init__(name, age)
        self.indoor = indoor

    def describe(self):                         # overrides Animal.describe()
        location = "indoor" if self.indoor else "outdoor"
        print(f"{self.name} is a {self.age} year old {location} cat.")

# ─────────────────────────────────────────────
# One loop — one call — different results
# ─────────────────────────────────────────────

shelter = [
    Dog("Lassie",   4, "Collie"),
    Cat("Whiskers", 2, True),
    Dog("Rex",      6, "Husky"),
    Cat("Luna",     3, False)
]

for animal in shelter:
    animal.describe()

# Lassie is a 4 year old Collie.
# Whiskers is a 2 year old indoor cat.
# Rex is a 6 year old Husky.
# Luna is a 3 year old outdoor cat.

# no isinstance() checks — each object handles describe() its own way

# ─────────────────────────────────────────────
# Polymorphism without inheritance
# ─────────────────────────────────────────────

class Dog:
    def speak(self):
        print("Woof!")

class Cat:
    def speak(self):
        print("Meow!")

class Parrot:
    def speak(self):
        print("Squawk!")

animals = [Dog(), Cat(), Parrot()]
for animal in animals:
    animal.speak()

# Woof!
# Meow!
# Squawk!

# no shared parent — just a shared method name

# ─────────────────────────────────────────────
# Practical — report function that works for any animal
# ─────────────────────────────────────────────

def print_shelter_report(animals):
    for animal in animals:
        animal.describe()   # works for Dog, Cat, or any future species

shelter = [
    Dog("Lassie",   4, "Collie"),
    Cat("Whiskers", 2, True),
]

print_shelter_report(shelter)

# add a new species — function doesn't change
class Parrot(Animal):
    def __init__(self, name, age, can_talk):
        super().__init__(name, age)
        self.can_talk = can_talk

    def describe(self):
        talk = "talks" if self.can_talk else "doesn't talk"
        print(f"{self.name} is a {self.age} year old parrot that {talk}.")

shelter.append(Parrot("Polly", 5, True))
print_shelter_report(shelter)   # Polly included — no changes to the function

# ─────────────────────────────────────────────
# Quick reference
# ─────────────────────────────────────────────

# polymorphism — same method call, different behavior per class
# Python routes the call to the object's actual class automatically
# no isinstance() checks needed
# requires a shared method name across classes
# inheritance not required — just a common interface
#
# adding new classes doesn't break existing code that calls the method
