#!/usr/bin/env python3
"""garden: a tiny terminal garden in the petiole palette.

Plants grow from seeds, bend in gusts that roll across the garden, drop leaves,
wilt and get replaced.

keys:  q / esc  quit        space  plant a seed     w  gust of wind
       f        fireflies   r      replant everything

flags: --once   print one fully grown garden and exit (screenshots, fastfetch, motd)
       --fps N  frames per second (default 20)
"""
import math
import os
import random
import select
import shutil
import signal
import sys
import termios
import time
import tty

# ── palette ──────────────────────────────────────────────────────────────────
HEX = {
    "bg": "#0f130d", "ink": "#ddd5bc", "pine": "#9cba8d",
    "moss": "#85a271", "olive": "#a8ac79", "accent": "#d78a51",
    "dead": "#4a4232",
}


def rgb(h):
    h = h.lstrip("#")
    return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))


C = {k: rgb(v) for k, v in HEX.items()}


def mix(a, b, t):
    t = max(0.0, min(1.0, t))
    return tuple(round(a[i] + (b[i] - a[i]) * t) for i in range(3))


# ── braille canvas: every terminal cell is a 2x4 grid of dots ────────────────
BITS = ((0x01, 0x08), (0x02, 0x10), (0x04, 0x20), (0x40, 0x80))


class Canvas:
    def __init__(self, cols, rows):
        self.cols, self.rows = cols, rows
        self.W, self.H = cols * 2, rows * 4
        self.clear()

    def clear(self):
        self.mask = {}
        self.color = {}

    def dot(self, x, y, c):
        x, y = int(x), int(y)
        if 0 <= x < self.W and 0 <= y < self.H:
            k = (x >> 1, y >> 2)
            self.mask[k] = self.mask.get(k, 0) | BITS[y & 3][x & 1]
            self.color[k] = c

    def line(self, x0, y0, x1, y1, c):
        n = int(max(abs(x1 - x0), abs(y1 - y0))) + 1
        for i in range(n + 1):
            t = i / n
            self.dot(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t, c)

    def render(self, home=True):
        out = ["\x1b[H"] if home else []
        last = None
        for r in range(self.rows):
            row = []
            for col in range(self.cols):
                m = self.mask.get((col, r))
                if m:
                    c = self.color[(col, r)]
                    if c != last:
                        row.append("\x1b[38;2;%d;%d;%dm" % c)
                        last = c
                    row.append(chr(0x2800 + m))
                else:
                    row.append(" ")
            out.append("".join(row))
            if r < self.rows - 1:
                out.append("\r\n" if home else "\n")
        out.append("\x1b[0m")
        return "".join(out)


# ── plants: L-systems drawn with a turtle ────────────────────────────────────
SPECIES = {
    "fern":  dict(axiom="X", rules={"X": "F+[[X]-X]-F[-FX]+X", "F": "FF"},
                  angle=24, iters=(4, 5), flower=0.0),
    "bush":  dict(axiom="F", rules={"F": "FF+[+F-F-F]-[-F+F+F]"},
                  angle=22, iters=(3, 3), flower=0.25),
    "weed":  dict(axiom="X", rules={"X": "F[+X]F[-X]+X", "F": "FF"},
                  angle=21, iters=(4, 5), flower=0.18),
    "sprig": dict(axiom="X", rules={"X": "F[+X][-X]FX", "F": "FF"},
                  angle=27, iters=(4, 5), flower=0.45),
}


def make_noise():
    """Smooth 1D value noise: random values at integer points, eased in between.
    Wind built from this never repeats and has no visible rhythm, unlike sines."""
    v = [random.uniform(-1, 1) for _ in range(256)]

    def noise(x):
        i = math.floor(x)
        f = x - i
        u = f * f * (3 - 2 * f)
        a, b = v[i & 255], v[(i + 1) & 255]
        return a + (b - a) * u
    return noise


# how easily each species bends (ferns are floppy, bushes are stiff)
FLEX = {"fern": 1.25, "weed": 1.1, "sprig": 1.0, "bush": 0.7}


class Plant:
    def __init__(self, x, base, height, max_width, ground_height):
        self.species = random.choice(list(SPECIES))
        self.x, self.base = x, base
        self.wilt = 0.0
        self.theta, self.omega, self.flutter = 0.0, 0.0, 0.01   # before _fit(), which calls _shape()
        self._build(SPECIES[self.species])
        self._fit(height, max_width)
        self.g = 0.0                       # how much of the plant has grown, in units
        self.speed = self.maxd / random.uniform(8, 14)
        self.state = "grow"
        self.life = random.uniform(25, 55)
        self.tips = []
        # the plant is a damped spring pushed by the wind: tall plants sway
        # slower than short ones, and every plant lags, overshoots and settles
        self.flex = FLEX[self.species] * random.uniform(0.85, 1.15)
        self.w0 = math.tau * random.uniform(0.55, 0.75) / math.sqrt(max(0.2, height / ground_height))

    def _build(self, d):
        s = d["axiom"]
        for _ in range(random.randint(*d["iters"])):
            s = "".join(d["rules"].get(ch, ch) for ch in s)
        a = math.radians(d["angle"])

        # one segment per straight run of F's; angles are stored relative to the parent
        self.parent, self.rel, self.length, self.start, self.nest = [], [], [], [], []
        cur, pend, last_f, stack = -1, random.uniform(-0.12, 0.12), False, []
        for ch in s:
            if ch == "F":
                if last_f:
                    self.length[cur] += 1
                    continue
                self.parent.append(cur)
                self.rel.append(pend)
                self.length.append(1)
                self.start.append(0 if cur < 0 else self.start[cur] + self.length[cur])
                self.nest.append(len(stack))
                cur, pend, last_f = len(self.parent) - 1, 0.0, True
            elif ch in "+-":
                turn = a * random.uniform(0.85, 1.15)
                pend += turn if ch == "+" else -turn
                last_f = False
            elif ch == "[":
                stack.append((cur, pend))
                last_f = False
            elif ch == "]":
                cur, pend = stack.pop()
                last_f = False

        n = len(self.parent)
        has_child = [False] * n
        for p in self.parent:
            if p >= 0:
                has_child[p] = True
        self.is_tip = [not c for c in has_child]
        self.deco = [None] * n
        for i in range(n):
            if self.is_tip[i]:
                r = random.random()
                if r < d["flower"]:
                    self.deco[i] = random.choice(("accent", "accent", "ink"))
                elif r < d["flower"] + 0.55:
                    self.deco[i] = "leaf"
        self.end = [self.start[i] + self.length[i] for i in range(n)]
        self.maxd = max(self.end)
        # per-segment flutter phase and speed, so leaves don't shiver in sync
        self.fph = [random.uniform(0, math.tau) for _ in range(n)]
        self.fsp = [random.uniform(2.5, 5.5) for _ in range(n)]

    def _fit(self, height, max_width):
        xs, ys, _ = self._shape(unit=1.0)
        xs, ys = xs + [0.0], ys + [0.0]
        w = (max(xs) - min(xs)) or 1
        h = (max(ys) - min(ys)) or 1
        self.unit = min(height / h, max_width / w)

    def sway(self, dt, wind):
        """Push the spring toward the lean this much wind would hold it at."""
        # tanh: gentle wind bends almost linearly, strong gusts taper off (~30 degrees max)
        target = 0.52 * math.tanh(wind * 0.55 * self.flex)
        z = 0.22                           # damping ratio: a little bouncy, settles in a few swings
        self.omega += (self.w0 * self.w0 * (target - self.theta) - 2 * z * self.w0 * self.omega) * dt
        self.theta += self.omega * dt
        self.flutter = 0.01 + 0.035 * min(1.6, abs(wind))

    def _shape(self, unit, t=0.0):
        n = len(self.parent)
        ex, ey, ang = [0.0] * n, [0.0] * n, [0.0] * n
        bend, flut, maxd = self.theta, self.flutter, self.maxd
        for i in range(n):
            p = self.parent[i]
            if p < 0:
                a0, x0, y0 = math.pi / 2, 0.0, 0.0
            else:
                a0, x0, y0 = ang[p], ex[p], ey[p]
            f, share = self.start[i] / maxd, self.length[i] / maxd
            # bending grows toward the top, so the stem curves instead of tipping like a rod;
            # summed along any path to a tip it adds up to `bend`
            lean = bend * share * 2 * (f + share / 2)
            shiver = flut * f * share * 6 * (math.sin(t * self.fsp[i] + self.fph[i]) * 0.6
                                             + math.sin(t * self.fsp[i] * 1.7 + self.fph[i] * 2) * 0.4)
            droop = 0.25 * self.wilt * f * (1 if self.rel[i] >= 0 else -1)
            a = a0 + self.rel[i] - lean + shiver - droop
            ang[i] = a
            L = self.length[i] * unit
            ex[i], ey[i] = x0 + math.cos(a) * L, y0 + math.sin(a) * L
        return ex, ey, ang

    def colour(self, nest, near_tip):
        base = C["olive"] if nest == 0 else C["moss"] if not near_tip else C["pine"]
        if self.wilt > 0:
            base = mix(base, C["accent"], self.wilt * 2)
            base = mix(base, C["dead"], self.wilt * 2 - 1)
        return base

    def update(self, dt):
        if self.state == "grow":
            self.g += self.speed * dt
            if self.g >= self.maxd:
                self.g, self.state = self.maxd, "live"
        elif self.state == "live":
            self.life -= dt
            if self.life <= 0:
                self.state = "wilt"
        elif self.state == "wilt":
            self.wilt = min(1.0, self.wilt + dt / 5)
            if self.wilt > 0.5:
                self.g -= self.speed * 1.5 * dt
        return self.g > 0 or self.state != "wilt"

    def draw(self, cv, t):
        ex, ey, ang = self._shape(self.unit, t)
        bx, by = self.x, self.base
        self.tips = []
        for i in range(len(self.parent)):
            s = self.start[i]
            if self.g <= s:
                continue
            p = self.parent[i]
            x0 = bx + (ex[p] if p >= 0 else 0.0)
            y0 = by - (ey[p] if p >= 0 else 0.0)
            x1, y1 = bx + ex[i], by - ey[i]
            frac = min(1.0, (self.g - s) / self.length[i])
            x1, y1 = x0 + (x1 - x0) * frac, y0 + (y1 - y0) * frac
            c = self.colour(self.nest[i], self.is_tip[i])
            cv.line(x0, y0, x1, y1, c)
            if self.nest[i] == 0 and s < self.maxd * 0.35:      # thicker trunk
                cv.line(x0 + 1, y0, x1 + 1, y1, c)
            if self.is_tip[i] and frac >= 1.0:
                self.tips.append((x1, y1))
                self._deco(cv, i, x1, y1, ang[i])

    def _deco(self, cv, i, x, y, a):
        d = self.deco[i]
        if d == "leaf":
            c = self.colour(9, True)
            for side in (-0.9, 0.9):
                cv.line(x, y, x + math.cos(a + side) * 2.5, y - math.sin(a + side) * 2.5, c)
        elif d:
            c = C[d] if self.wilt == 0 else mix(C[d], C["dead"], self.wilt * 1.5)
            for dx, dy in ((0, 0), (1, 0), (-1, 0), (0, 1), (0, -1)):
                cv.dot(x + dx, y + dy, c)


# ── the garden ───────────────────────────────────────────────────────────────
class Garden:
    def __init__(self, cols, rows):
        self.cv = Canvas(cols, rows)
        self.ground = self.cv.H - 3
        self.plants, self.leaves, self.specks = [], [], []
        self.max_plants = max(2, min(7, cols // 26))
        self.next_spawn = 0.0
        self.fireflies = True
        # wind: a prevailing direction, a slowly varying breeze, gust patches
        # that roll across the garden, and the odd stronger gust front
        self.dir = random.choice((-1, 1))
        self.noise = [make_noise(), make_noise(), make_noise()]
        self.fronts = []
        self.next_front = random.uniform(12, 30)
        W = self.cv.W
        # each blade: [x, height, flutter phase, current lean, response time]
        self.grass = [[x, random.randint(1, 5), random.uniform(0, math.tau), 0.0, random.uniform(0.12, 0.35)]
                      for x in range(W) if random.random() < 0.3]
        self.flies = [dict(x=random.uniform(0, W), y=random.uniform(0, self.ground * 0.8),
                           ph=random.uniform(0, math.tau), c=random.choice(("ink", "accent", "olive")))
                      for _ in range(max(3, cols // 16))]

    def wind_at(self, x, t):
        """Wind at horizontal position x (pixels) and time t. Positive blows right."""
        u, (n0, n1, n2), d = x / self.cv.W, self.noise, self.dir
        breeze = 0.3 + 0.22 * n0(t * 0.05)
        # gust texture drifting downwind, so a gust reaches one side first
        g = n1(u * 1.6 - d * t * 0.22) * 0.65 + n2(u * 3.7 - d * t * 0.31 + 11) * 0.35
        g = max(0.0, g)
        g = g * g * 1.8
        front = 0.0
        for fr in self.fronts:
            pos = -0.3 + (t - fr["t0"]) * fr["speed"] if fr["dir"] > 0 else 1.3 - (t - fr["t0"]) * fr["speed"]
            k = (u - pos) / 0.22
            front += fr["amp"] * fr["dir"] * math.exp(-k * k)
        return d * (breeze + g) + front

    def gust(self, t, amp=None, direction=None):
        if amp is None:
            amp = random.uniform(0.8, 1.4)
        if direction is None:
            direction = self.dir if random.random() < 0.8 else -self.dir
        self.fronts.append(dict(t0=t, amp=amp, dir=direction, speed=random.uniform(0.35, 0.55)))

    def spawn(self):
        W = self.cv.W
        others = [p.x for p in self.plants]
        x = max((random.uniform(W * 0.05, W * 0.95) for _ in range(12)),
                key=lambda x: min((abs(x - o) for o in others), default=1e9))
        height = random.uniform(0.35, 0.9) * self.ground
        self.plants.append(Plant(x, self.ground, height, W * 0.35, self.ground))

    def grow_all(self):
        while len(self.plants) < self.max_plants:
            self.spawn()
        for p in self.plants:
            p.g, p.state = p.maxd, "live"

    def update(self, t, dt):
        if len(self.plants) < self.max_plants and t >= self.next_spawn:
            self.spawn()
            self.next_spawn = t + random.uniform(1.5, 5)
        if t >= self.next_front:
            self.gust(t)
            self.next_front = t + random.uniform(20, 45)
        self.fronts = [fr for fr in self.fronts if (t - fr["t0"]) * fr["speed"] < 1.8]

        for p in self.plants:
            p.sway(dt, self.wind_at(p.x, t))
            rate = {"live": 0.35, "wilt": 5.0}.get(p.state, 0)
            if p.tips and random.random() < rate * dt:
                x, y = random.choice(p.tips)
                c = random.choice(("olive", "moss", "accent") if p.state == "live" else ("accent", "olive", "dead"))
                self.leaves.append(dict(x=x, y=y, vx=0.0, ph=random.uniform(0, math.tau),
                                        spin=random.uniform(2, 4), c=C[c], rest=None))
        self.plants = [p for p in self.plants if p.update(dt)]

        for b in self.grass:               # each blade eases toward the local wind at its own pace
            b[3] += (self.wind_at(b[0], t) * 0.6 - b[3]) * min(1.0, dt / b[4])

        for lf in self.leaves:
            w = self.wind_at(lf["x"], t)
            if lf["rest"] is not None:     # settled leaves only skitter in a strong gust
                if abs(w) > 1.1:
                    lf["x"] += (w - math.copysign(1.1, w)) * 10 * dt
                continue
            lf["vx"] += (w * 9 - lf["vx"]) * min(1.0, dt * 1.5)   # drag toward the wind's speed
            lf["x"] += (lf["vx"] + math.sin(t * lf["spin"] + lf["ph"]) * 3) * dt
            lf["y"] += (5 + math.sin(t * lf["spin"] * 1.3 + lf["ph"]) * 2.5 - max(0.0, abs(w) - 1) * 2) * dt
            if lf["y"] >= self.ground:
                lf["y"], lf["rest"] = self.ground, t
        self.leaves = [lf for lf in self.leaves
                       if (lf["rest"] is None or t - lf["rest"] < 6) and -5 < lf["x"] < self.cv.W + 5]

        # seeds and dust carried in strong gusts, which is what makes wind visible
        W = self.cv.W
        for _ in range(2):
            x = random.uniform(0, W)
            if random.random() < max(0.0, abs(self.wind_at(x, t)) - 0.75) * dt * 6:
                self.specks.append(dict(x=x, y=random.uniform(self.ground * 0.25, self.ground - 2),
                                        ph=random.uniform(0, math.tau), born=t))
        for s in self.specks:
            s["x"] += self.wind_at(s["x"], t) * 26 * dt
            s["y"] += math.sin(t * 2 + s["ph"]) * 4 * dt
        self.specks = [s for s in self.specks if t - s["born"] < 3 and -2 < s["x"] < W + 2]

    def draw(self, t):
        cv = self.cv
        cv.clear()
        for x in range(cv.W):
            if (x * 7) % 11 < 9:
                cv.dot(x, self.ground + 1, C["moss"])
            if (x * 5) % 13 < 3:
                cv.dot(x, self.ground + 2, C["olive"])
        for x, h, ph, lean, _ in self.grass:   # blades curve: the tip leans most
            tip_x = x + (lean + math.sin(t * 3.3 + ph) * 0.08 * (0.5 + abs(lean))) * h
            mid_x, mid_y = x + (tip_x - x) * 0.3, self.ground - h * 0.55
            tip_y = self.ground - h * (1 - min(0.35, abs(lean) * 0.25))
            cv.line(x, self.ground, mid_x, mid_y, C["pine"])
            cv.line(mid_x, mid_y, tip_x, tip_y, C["pine"])
        for p in self.plants:
            p.draw(cv, t)
        for lf in self.leaves:
            c = lf["c"] if lf["rest"] is None else mix(lf["c"], C["bg"], (t - lf["rest"]) / 6)
            cv.dot(lf["x"], lf["y"], c)
            if lf["rest"] is not None or math.sin(t * lf["spin"] + lf["ph"]) > -0.3:  # tumbling
                cv.dot(lf["x"] + 1, lf["y"], c)
        for s in self.specks:
            fade = min(1.0, (t - s["born"]) * 3) * max(0.0, 1 - (t - s["born"]) / 3)
            cv.dot(s["x"], s["y"], mix(C["bg"], C["olive"], 0.7 * fade))
        if self.fireflies:
            for f in self.flies:
                glow = math.sin(t * 1.1 + f["ph"]) * 0.5 + 0.5
                if glow > 0.35:
                    x = f["x"] + math.sin(t * 0.3 + f["ph"]) * 14 + math.sin(t * 0.71 + f["ph"] * 2) * 6
                    y = f["y"] + math.cos(t * 0.23 + f["ph"]) * 8
                    cv.dot(x % cv.W, y, mix(C["bg"], C[f["c"]], glow))
        return cv.render()


# ── terminal plumbing ────────────────────────────────────────────────────────
def main():
    args = sys.argv[1:]
    fps = float(args[args.index("--fps") + 1]) if "--fps" in args else 20.0
    cols, rows = shutil.get_terminal_size()

    if "--once" in args:
        g = Garden(cols, rows - 1)
        g.grow_all()
        g.draw(0.0)
        print(g.cv.render(home=False))
        return

    fd = sys.stdin.fileno()
    old = termios.tcgetattr(fd)
    resized = [False]
    signal.signal(signal.SIGWINCH, lambda *_: resized.__setitem__(0, True))
    out = sys.stdout
    try:
        tty.setcbreak(fd)
        out.write("\x1b[?1049h\x1b[?25l\x1b[2J")
        g = Garden(cols, rows)
        t0 = last = time.monotonic()
        while True:
            now = time.monotonic()
            t, dt = now - t0, min(0.1, now - last)
            last = now
            if resized[0]:
                resized[0] = False
                cols, rows = shutil.get_terminal_size()
                g = Garden(cols, rows)
                out.write("\x1b[2J")
            while select.select([fd], [], [], 0)[0]:
                k = os.read(fd, 32).decode(errors="ignore")
                if not k or k[0] in "q\x1b":
                    return
                if " " in k:
                    g.spawn()
                if "w" in k:
                    g.gust(time.monotonic() - t0, 1.8)
                if "f" in k:
                    g.fireflies = not g.fireflies
                if "r" in k:
                    g = Garden(cols, rows)
            g.update(t, dt)
            out.write(g.draw(t))
            out.flush()
            time.sleep(max(0.0, 1 / fps - (time.monotonic() - now)))
    except KeyboardInterrupt:
        pass
    finally:
        out.write("\x1b[0m\x1b[?25h\x1b[?1049l")
        out.flush()
        termios.tcsetattr(fd, termios.TCSADRAIN, old)


if __name__ == "__main__":
    main()
