# Understanding OOP self
# what self is, why it exists, how Python uses it
# redhorndev.com

# ─────────────────────────────────────────────
# What self is — the object itself
# ─────────────────────────────────────────────

class Animal:
    def __init__(self, name):
        self.name = name        # stored on this specific object

    def describe(self):
        print(f"My name is {self.name}.")

lassie   = Animal("Lassie")
whiskers = Animal("Whiskers")

lassie.describe()       # My name is Lassie.
whiskers.describe()     # My name is Whiskers.

# Python translates lassie.describe() to Animal.describe(lassie)
# self receives lassie — so self.name is Lassie's name

# ─────────────────────────────────────────────
# self is passed automatically — never manually
# ─────────────────────────────────────────────

lassie.describe()           # correct — Python passes lassie as self
# Animal.describe(lassie)   # same result — how Python sees it internally
# lassie.describe(lassie)   # TypeError: too many arguments

# ─────────────────────────────────────────────
# self in __init__ — storing data per object
# ─────────────────────────────────────────────

class Animal:
    def __init__(self, name, species):
        self.name    = name       # this object's name
        self.species = species    # this object's species

lassie   = Animal("Lassie",   "dog")
whiskers = Animal("Whiskers", "cat")

# two separate __init__ calls — two separate self values
print(lassie.name)      # Lassie
print(whiskers.name)    # Whiskers — independent

# ─────────────────────────────────────────────
# self connects data and methods
# ─────────────────────────────────────────────

class Animal:
    def __init__(self, name, health):
        self.name   = name
        self.health = health
        self.status = "available"

    def is_healthy(self):
        return self.health == "healthy"

    def is_available(self):
        return self.status == "available"

    def describe(self):
        health_note = "healthy" if self.is_healthy() else "needs care"
        avail_note  = "available" if self.is_available() else "adopted"
        print(f"{self.name} is {health_note} and {avail_note}.")

lassie = Animal("Lassie", "ill")
lassie.describe()
# Lassie is needs care and available.

lassie.health = "healthy"
lassie.status = "adopted"
lassie.describe()
# Lassie is healthy and adopted.

# ─────────────────────────────────────────────
# self is convention — not a keyword
# ─────────────────────────────────────────────

class Animal:
    def __init__(this, name):       # "this" works — but don't do it
        this.name = name

    def describe(this):
        print(f"My name is {this.name}.")

lassie = Animal("Lassie")
lassie.describe()           # My name is Lassie. — works, but confusing
# always use "self" — it's the universal convention

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

# self = the object the method is called on
# Python passes self automatically — never pass it manually
# self.attr — access this object's attribute
# self.method() — call another method on the same object
#
# self is convention — everyone uses it, so should you
# every method must have self as first parameter
# without self, a method can't access the object's own data
