# Making a class `Dog`

class Dog():
    # __init__ can basically be thought of as a constructor(constructs the class) for a class, its called automatically when you create a class.
    # self keyword represents the instance of the object itself and most OOP langs pass this as a hidden paramater to the methods defined on an object. But not python na na na
    def __init__(self, mybreed):
        # Attributes
        # We take in the argument and assign it using self.attribute_name
        ## Which in our case is `.breed`
        self.breed = mybreed

# So what happenes is when you create an instance of the Dog class python will call this __init__ method and its going to use self in order to represent the instance of the object itself and then its going to expect you to pass in this argument `breed`

# Creating an instance of the class Dog
my_dog = Dog(mybreed='Husk')

# Type now says that you have an instance of the class Dog
type(my_dog)

# This returns that this instance of the Dog class called `my_dog` its breed is 'Lab'
my_dog.breed
# We dont have `mybreed` avaliable as it was just a paramater

# More arguments!
class Dog():

    # Class object attribute
    # Same for any instance of a class
    species = 'mammal'

    def __init__(self, breed, name, spots):
        self.breed = breed
        self.name = name
        # Expecting a boolean
        self.spots = spots

    # Creating a method
    def bark(self, number):
        print(f"WOOF! My breed is {self.breed, number}")

my_dog = Dog(breed='German Shepard', name='Sammy', spots=False)

type(my_dog)

# Calling our method
my_dog.bark(2)

my_dog.species
my_dog.name
my_dog.breed
my_dog.spots

# Making another class
class Circle():

    # Class object attribute
    pi = 3.14

    def __init__(self, radius=1,):
        self.radius = radius
        # You dont particularly need to define a paramater in __init__
        self.area = Circle.pi * radius**2 # Only do Circle.pi when its a class object attribute

    # Method
    def get_circumference(self):
        circumference = self.radius * self.pi * 2
        return round(circumference, 2)

my_circle = Circle(24)

my_circle.get_circumference()
my_circle.area


# Inheritance

class Animal():
    def __init__(self):
        print("Animal Created.")

    def who_am_i(self):
        print("I am an Animal.")

    def eat(self):
        print("I am eating.")

# Reusing your prior work or reusing classes
class Dog(Animal):
    def __init__(self):
        Animal.__init__(self) # Creating an instance of the animal class when you create an instance of the Dog class
        print("Dog Created")

    # Overriting an older method
    def who_am_i(self):
        print("I am a dog")

    # Adding methods
    def bark(self):
        print("WOOF!")

mydog = Dog()
# All the old methods from Animal class can be used in Dog class
mydog.eat()
mydog.who_am_i()
mydog.bark()

### While functions can take in different arguments methods belong to objects the act on

# Polymorphism
### It referes to the way in which different objects classes can share the same method name and then those methods can be called from the same place even  though a variety of diff objects might be passed in.

class Dog():
    def __init__(self, name):
        self.name = name

    def speak(self):
        return self.name + " says WOOF!"

class Cat():
    def __init__(self, name):
        self.name = name

    def speak(self):
        return self.name + " says PURR!"

niko = Dog("niko")
felix = Cat("felix")

print(niko.speak())
print(felix.speak())
