#!/usr/bin/env python3
"""
aster.py: the Aster Chai procedural system.

A single seeded, parametric source for every aster in the brand:

  Bloom(...)        one bloom, abstraction 0 (geometric star) .. 4 (loose botanical)
  field(...)        a wild prairie of blooms + grasses in five depth bands
  field_recipe(...) PhotoCraft finishing steps for a field: sky, sun, haze,
                    depth-of-field blur (smart filters), light grade
                    (adjustment layers), grain

Output is SVG. Element ids become PhotoCraft layer names when the file is
opened with `doc_open`. The recipe is a JSON list of PhotoCraft engine commands
for `command_batch`. It finds layers by name (select.findLayers), so it never
depends on layer ids.

CLI
  python aster.py bloom --level 1 --petals 8 --size 800 --out bloom.svg
  python aster.py field --light golden --seed 7 --density 0.8 --horizon 0.42 \
                        --w 1920 --h 1080 --out field_golden
"""
from __future__ import annotations

import argparse
import json
import math
import os
import random
from dataclasses import dataclass, field as dc_field, replace

HERE = os.path.dirname(os.path.abspath(__file__))
with open(os.path.join(HERE, "palette.json"), encoding="utf-8") as _f:
    PALETTE = json.load(_f)


def col(name: str) -> str:
    for group in ("brand", "prairie", "cans"):
        if name in PALETTE[group]:
            return PALETTE[group][name]["hex"]
    if name.startswith("#"):
        return name
    raise KeyError(name)


# --------------------------------------------------------------------------
# geometry helpers
# --------------------------------------------------------------------------
K = 0.5522847498  # cubic circle constant


def _f(v: float, nd: int = 2) -> str:
    s = f"{v:.{nd}f}".rstrip("0").rstrip(".")
    return "0" if s in ("-0", "") else s


class PathBuilder:
    """Collects subpaths as SVG 'd' fragments in document coordinates."""

    def __init__(self, nd: int = 2):
        self.parts: list[str] = []
        self.nd = nd

    def add(self, cmds, xf):
        """cmds: [('M',p) | ('L',p) | ('C',c1,c2,p) | ('Z',)] in local coords."""
        out = []
        for c in cmds:
            op = c[0]
            if op == "Z":
                out.append("Z")
                continue
            pts = [xf(*p) for p in c[1:]]
            out.append(op + " ".join(f"{_f(x, self.nd)} {_f(y, self.nd)}" for x, y in pts))
        self.parts.append("".join(out))

    def d(self) -> str:
        return "".join(self.parts)

    def __bool__(self):
        return bool(self.parts)


def circle_cmds(cx, cy, r):
    k = K * r
    return [
        ("M", (cx + r, cy)),
        ("C", (cx + r, cy + k), (cx + k, cy + r), (cx, cy + r)),
        ("C", (cx - k, cy + r), (cx - r, cy + k), (cx - r, cy)),
        ("C", (cx - r, cy - k), (cx - k, cy - r), (cx, cy - r)),
        ("C", (cx + k, cy - r), (cx + r, cy - k), (cx + r, cy)),
        ("Z",),
    ]


def rot(p, a):
    c, s = math.cos(a), math.sin(a)
    return (p[0] * c - p[1] * s, p[0] * s + p[1] * c)


def map_cmds(cmds, fn):
    return [c if c[0] == "Z" else (c[0],) + tuple(fn(p) for p in c[1:]) for c in cmds]


def bloom_xf(cx, cy, R, tilt=1.0, lean=0.0):
    """Bloom-local unit coords (ray tip = radius 1) -> document coords.
    tilt < 1 foreshortens the head (seen from the side); lean rotates it."""
    cl, sl = math.cos(lean), math.sin(lean)

    def f(x, y):
        x, y = x * R, y * R * tilt
        return (cx + x * cl - y * sl, cy + x * sl + y * cl)

    return f


def ribbon_cmds(p0, p1, p2, p3, w0, w1, steps=8):
    """Tapered filled ribbon along a cubic Bézier: stems and grass blades."""
    pts, nrm = [], []
    for i in range(steps + 1):
        t = i / steps
        mt = 1 - t
        x = mt**3 * p0[0] + 3 * mt * mt * t * p1[0] + 3 * mt * t * t * p2[0] + t**3 * p3[0]
        y = mt**3 * p0[1] + 3 * mt * mt * t * p1[1] + 3 * mt * t * t * p2[1] + t**3 * p3[1]
        dx = 3 * mt * mt * (p1[0] - p0[0]) + 6 * mt * t * (p2[0] - p1[0]) + 3 * t * t * (p3[0] - p2[0])
        dy = 3 * mt * mt * (p1[1] - p0[1]) + 6 * mt * t * (p2[1] - p1[1]) + 3 * t * t * (p3[1] - p2[1])
        ln = math.hypot(dx, dy) or 1.0
        w = (w0 + (w1 - w0) * t) / 2
        pts.append((x, y))
        nrm.append((-dy / ln * w, dx / ln * w))
    left = [(p[0] + n[0], p[1] + n[1]) for p, n in zip(pts, nrm)]
    right = [(p[0] - n[0], p[1] - n[1]) for p, n in zip(pts, nrm)]
    poly = left + right[::-1]
    cmds = [("M", poly[0])] + [("L", p) for p in poly[1:]] + [("Z",)]
    return cmds


IDENT = lambda x, y: (x, y)  # noqa: E731


# --------------------------------------------------------------------------
# the bloom
# --------------------------------------------------------------------------
LEVEL_NAMES = {
    0: "Geometric star",
    1: "Geometric bloom",
    2: "Flat illustration",
    3: "Botanical",
    4: "Loose botanical",
}


@dataclass
class Bloom:
    petals: int = 8               # ray count (default 8, after the 8 oz can)
    abstraction: int = 1          # 0 geometric star .. 4 loose botanical
    length: float = 1.0           # ray tip radius (bloom-local units)
    width: float = 0.30           # ray half-width, relative
    length_var: float | None = None   # 0..0.4; None = level default
    width_var: float | None = None
    angle_jitter: float | None = None  # degrees
    center: float = 0.24          # disk radius, relative to ray tip
    rotation: float = -90.0       # degrees; -90 = a ray points straight up
    back_ring: bool | None = None # second, darker ray ring (botanical)
    petal_color: str = "dusty_violet"
    petal_color_2: str = "lavender"
    petal_shadow: str = "deep_aster"
    center_color: str = "golden_center"
    center_shadow: str = "center_rust"
    florets: bool = True          # False: plain disk (level-of-detail for tiny blooms)
    seed: int = 0

    # level defaults: (length_var, width_var, jitter, back_ring, wobble, bend, notch)
    _LEVELS = {
        0: (0.00, 0.00, 0.0, False, 0.00, 0.00, False),
        1: (0.00, 0.00, 0.0, False, 0.00, 0.00, False),
        2: (0.05, 0.06, 1.5, False, 0.00, 0.02, False),
        3: (0.07, 0.10, 2.5, True, 0.004, 0.05, False),
        4: (0.16, 0.18, 5.0, True, 0.010, 0.11, False),
    }

    def _lv(self):
        lv, wv, jit, br, wob, bend, notch = self._LEVELS[self.abstraction]
        return (
            lv if self.length_var is None else self.length_var,
            wv if self.width_var is None else self.width_var,
            jit if self.angle_jitter is None else self.angle_jitter,
            br if self.back_ring is None else self.back_ring,
            wob, bend, notch,
        )

    # ---- ray shapes in ray-local coords: base at x=r0, tip at x=tip, axis +x
    @staticmethod
    def _lancet(r0, tip, hw):
        L = tip - r0
        return [
            ("M", (r0, 0)),
            ("C", (r0 + 0.18 * L, hw * 1.25), (r0 + 0.62 * L, hw * 0.95), (tip, 0)),
            ("C", (r0 + 0.62 * L, -hw * 0.95), (r0 + 0.18 * L, -hw * 1.25), (r0, 0)),
            ("Z",),
        ]

    @staticmethod
    def _strap(r0, tip, hw, base, notch):
        """Ligulate ray: narrow base, widest past the middle, rounded-acute tip."""
        L = tip - r0
        cmds = [
            ("M", (r0, base)),
            ("C", (r0 + 0.30 * L, hw), (tip - 0.36 * L, hw * 1.04), (tip - 0.12 * L, hw * 0.74)),
            ("C", (tip - 0.035 * L, hw * 0.46), (tip, hw * 0.2), (tip, 0)),
        ]
        if notch:  # a hint of the minutely toothed tip
            cmds[-1] = ("C", (tip - 0.035 * L, hw * 0.46), (tip, hw * 0.3), (tip, hw * 0.12))
            cmds += [("L", (tip - 0.012 * L, 0)), ("L", (tip, -hw * 0.12)),
                     ("C", (tip, -hw * 0.3), (tip - 0.035 * L, -hw * 0.46), (tip - 0.12 * L, -hw * 0.74))]
        else:
            cmds += [("C", (tip, -hw * 0.2), (tip - 0.035 * L, -hw * 0.46), (tip - 0.12 * L, -hw * 0.74))]
        cmds += [
            ("C", (tip - 0.36 * L, -hw * 1.04), (r0 + 0.30 * L, -hw), (r0, -base)),
            ("Z",),
        ]
        return cmds

    def layers(self, xf, nd: int = 2) -> dict[str, tuple[str, PathBuilder]]:
        """Render into named layers: {name: (color_hex, PathBuilder)}."""
        rng = random.Random(self.seed)
        n = max(3, int(self.petals))
        lv, wv, jit, back, wob, bend, notch = self._lv()
        a0 = math.radians(self.rotation)
        lay = {
            "petals-back": (col(self.petal_shadow), PathBuilder(nd)),
            "petals-a": (col(self.petal_color), PathBuilder(nd)),
            "petals-b": (col(self.petal_color_2), PathBuilder(nd)),
            # disk under florets; which colour sits where depends on the level
            "disk": (col(self.center_shadow if self.abstraction >= 3 else self.center_color), PathBuilder(nd)),
            "florets": (col(self.center_color if self.abstraction >= 3 else self.center_shadow), PathBuilder(nd)),
        }
        c = self.center
        lvl = self.abstraction

        if lvl == 0:  # geometric star: one clean polygon star
            ri = max(c + 0.10, 0.40)
            pts = []
            for i in range(2 * n):
                a = a0 + math.pi * i / n
                r = self.length if i % 2 == 0 else ri
                pts.append((r * math.cos(a), r * math.sin(a)))
            lay["petals-a"][1].add([("M", pts[0])] + [("L", p) for p in pts[1:]] + [("Z",)], xf)
            lay["disk"][1].add(circle_cmds(0, 0, c), xf)
            return lay

        # spacing-aware width so neighbouring rays never fuse (levels 1-2)
        r0 = c + (0.05 if lvl <= 2 else 0.0)
        tip = self.length
        mid_r = r0 + 0.45 * (tip - r0)
        max_hw = 0.78 * mid_r * math.sin(math.pi / n)  # leaves a visible gap
        if lvl == 1:
            hw = min(self.width * 0.55, max_hw)
        elif lvl == 2:
            hw = min(self.width * 0.50 * (8 / n) ** 0.5, max_hw)
        else:  # real aster rays: long and narrow (width ~ 1/10 of length)
            hw = min(self.width * 0.13, max_hw)

        def ray(angle, length_scale, width_scale, tone, ring_r0=r0, ring_tip=tip):
            t = ring_r0 + (ring_tip - ring_r0) * length_scale
            w = hw * width_scale
            if lvl == 1:
                cm = self._lancet(ring_r0, t, w)
            else:
                cm = self._strap(ring_r0, t, w, w * 0.35, notch)
            if bend or wob:
                b = bend * rng.uniform(-1, 1)
                L = t - ring_r0

                def bendf(p, b=b):
                    u = max(0.0, (p[0] - ring_r0) / L)
                    return (p[0], p[1] + b * u * u * L + rng.uniform(-wob, wob) * u)

                cm = map_cmds(cm, bendf)
            cm = map_cmds(cm, lambda p: rot(p, angle))
            lay[tone][1].add(cm, xf)

        if back:
            for i in range(n):
                a = a0 + 2 * math.pi * (i + 0.5) / n + math.radians(rng.uniform(-jit, jit))
                ray(a, 0.93 * (1 + rng.uniform(-lv, lv * 0.5)), 1.0 + rng.uniform(-wv, wv), "petals-back",
                    ring_r0=r0 * 0.9)
        for i in range(n):
            if lvl == 4 and rng.random() < 0.06:  # an occasional missing ray: wild, not stamped
                continue
            a = a0 + 2 * math.pi * i / n + math.radians(rng.uniform(-jit, jit))
            ls = 1 + rng.uniform(-lv, lv * 0.4)
            ws = 1 + rng.uniform(-wv, wv)
            if lvl == 2:
                tone = "petals-a" if i % 2 == 0 else "petals-b"
            elif lvl >= 3:
                tone = "petals-a" if rng.random() < 0.62 else "petals-b"
            else:
                tone = "petals-a"
            ray(a, ls, ws, tone)

        # disk
        if lvl == 1:
            lay["disk"][1].add(circle_cmds(0, 0, c), xf)
        elif lvl == 2:
            lay["disk"][1].add(circle_cmds(0, 0, c), xf)
            m = max(6, n)
            for i in range(m):
                a = a0 + 2 * math.pi * (i + 0.5) / m
                lay["florets"][1].add(circle_cmds(0.62 * c * math.cos(a), 0.62 * c * math.sin(a), 0.11 * c), xf)
        elif not self.florets:
            lay["florets"][1].add(circle_cmds(0, 0, c), xf)
        else:
            lay["disk"][1].add(circle_cmds(0, 0, c * 1.02), xf)  # rust disk shows between florets
            florets = int(70 * c / 0.24) if lvl == 3 else int(90 * c / 0.24)
            age = 0.25 if lvl == 3 else 0.4
            for i in range(florets):
                r = c * 0.9 * math.sqrt((i + 0.5) / florets)
                th = i * 2.39996323
                fr = c * (0.085 if lvl == 3 else 0.075) * (1 + rng.uniform(-0.25, 0.25))
                x, y = r * math.cos(th), r * math.sin(th)
                if lvl == 4:
                    x += rng.uniform(-0.02, 0.02) * c
                    y += rng.uniform(-0.02, 0.02) * c
                # aged florets turn rust from the centre outward, as real asters do:
                # skipping a floret lets the rust disk show through
                if r / c <= 0.55 and rng.random() < age:
                    continue
                lay["florets"][1].add(circle_cmds(x, y, fr), xf)
        return lay


# --------------------------------------------------------------------------
# SVG writer
# --------------------------------------------------------------------------
class SVG:
    def __init__(self, w, h):
        self.w, self.h = w, h
        self.body: list[str] = []

    def path(self, id_, d, fill, opacity=None):
        if not d:
            return
        op = f' opacity="{_f(opacity, 3)}"' if opacity is not None and opacity < 1 else ""
        self.body.append(f'<path id="{id_}" d="{d}" fill="{fill}"{op}/>')

    def rect(self, id_, x, y, w, h, fill, opacity=None):
        op = f' opacity="{_f(opacity, 3)}"' if opacity is not None and opacity < 1 else ""
        self.body.append(f'<rect id="{id_}" x="{_f(x)}" y="{_f(y)}" width="{_f(w)}" height="{_f(h)}" fill="{fill}"{op}/>')

    def open_group(self, id_):
        self.body.append(f'<g id="{id_}">')

    def close_group(self):
        self.body.append("</g>")

    def text(self):
        return (
            f'<svg xmlns="http://www.w3.org/2000/svg" width="{self.w}" height="{self.h}" '
            f'viewBox="0 0 {self.w} {self.h}">\n' + "\n".join(self.body) + "\n</svg>\n"
        )

    def save(self, path):
        with open(path, "w", encoding="utf-8") as f:
            f.write(self.text())


def bloom_svg(bloom: Bloom, size=800, pad=0.06, bg=None, prefix="bloom"):
    svg = SVG(size, size)
    if bg:
        svg.rect("bg", 0, 0, size, size, col(bg))
    R = size * (0.5 - pad)
    lay = bloom.layers(bloom_xf(size / 2, size / 2, R))
    svg.open_group(prefix)
    for name, (fill, pb) in lay.items():
        svg.path(f"{prefix}-{name}", pb.d(), fill)
    svg.close_group()
    return svg


# --------------------------------------------------------------------------
# the field
# --------------------------------------------------------------------------
class Noise1D:
    """Smooth value noise in [0,1]: asters grow in drifts, not evenly."""

    def __init__(self, rng, cells=12):
        self.v = [rng.random() for _ in range(cells + 2)]
        self.cells = cells

    def __call__(self, u):
        x = min(max(u, 0.0), 0.9999) * self.cells
        i = int(x)
        t = x - i
        t = t * t * (3 - 2 * t)
        return self.v[i] * (1 - t) + self.v[i + 1] * t


@dataclass
class FieldParams:
    w: int = 1920
    h: int = 1080
    seed: int = 7
    density: float = 0.8      # 0.2 sparse .. 1.2 lush
    horizon: float = 0.42     # horizon height, fraction from top
    light: str = "golden"     # golden | dusk | overcast
    bloom_scale: float = 1.0  # size of the nearest blooms
    clear_zone: tuple | None = None  # (x0, x1) fraction kept lower in the front band (product spot)
    bokeh: bool = True
    sky: bool = True          # False: transparent sky (field strips that sit on a can or a layout)
    floret_min: float = 14.0  # blooms smaller than this (px radius) get a plain disk: no blotchy centres


# depth bands: (name, s0, s1, blur radius @1920 wide, haze strength above band,
#               aster clumps @ density 1 & 1920 wide, grass blades @ density 1 & 1920 wide)
# s = depth on the ground plane: 0 at the horizon, 1 at the bottom edge.
BANDS = [
    ("depth-5", 0.015, 0.11, 2.4, 0.38, 300, 1500),
    ("depth-4", 0.11, 0.24, 1.5, 0.26, 170, 1150),
    ("depth-3", 0.24, 0.44, 0.8, 0.15, 80, 820),
    ("depth-2", 0.44, 0.70, 0.0, 0.06, 40, 700),
    ("depth-1", 0.70, 1.22, 0.7, 0.0, 18, 380),
]

SPECIES = [  # (weight, tone-front, tone-alt, ray range, relative size)
    (0.45, "petals-a", "petals-b", (22, 34), 1.00),   # New England-type: dusty violet, many rays
    (0.40, "petals-b", "petals-a", (16, 24), 0.85),   # smooth blue-type: lavender
    (0.15, "petals-c", "petals-b", (12, 18), 0.55),   # heath-type: tiny, pale
]


def field(p: FieldParams):
    rng = random.Random(p.seed)
    W, H = p.w, p.h
    yh = H * p.horizon
    nd = 1 if W >= 1600 else 2
    sc = W / 1920.0
    svg = SVG(W, H)
    light = PALETTE["light"][p.light]

    if p.sky:
        svg.rect("sky-base", 0, 0, W, H, light["sky"][1])

    # distant oak-savanna treeline: groves of rounded crowns with open prairie between
    ranks = [(0.006, 0.014, "#8A847D", 0.25, (5, 14)), (0.010, 0.026, col("treeline"), 0.5, (3, 9))]
    for rank, (rmin, rmax, colr, gap, grove) in enumerate(ranks):
        pb = PathBuilder(nd)
        x = -60 * sc
        while x < W + 60 * sc:
            if rng.random() < gap:
                x += W * rng.uniform(0.03, 0.12)
                continue
            for _c in range(rng.randint(*grove)):
                r = H * rng.uniform(rmin, rmax)
                cy = yh - r * rng.uniform(0.35, 0.9)
                pb.add(circle_cmds(x, cy, r), IDENT)
                pb.add([("M", (x - r, cy)), ("L", (x + r, cy)), ("L", (x + r, yh + 2)), ("L", (x - r, yh + 2)), ("Z",)], IDENT)
                x += r * rng.uniform(0.8, 1.4)
            x += W * rng.uniform(0.005, 0.03)
        svg.path(f"treeline-{'far' if rank == 0 else 'near'}", pb.d(), colr)

    svg.rect("ground-base", 0, yh, W, H - yh, col("ground"))
    drift = Noise1D(rng, 9)

    tones = {
        "petals-back": col("deep_aster"),
        "petals-a": col("dusty_violet"),
        "petals-b": col("lavender"),
        "petals-c": col("pale_aster"),
    }
    stats = {}

    def y_of(s):
        return yh + s * (H - yh)

    for bi, (bname, s0, s1, _blur, _haze, n_clumps, n_blades) in enumerate(BANDS):
        k = bname.split("-")[1]
        L = {}

        def lay(name, fill):
            if name not in L:
                L[name] = (fill, PathBuilder(nd))
            return L[name][1]

        band_h = y_of(s1) - y_of(s0)
        smid = (s0 + s1) / 2

        # ---- grass behind the flowers
        blades = int(n_blades * p.density * sc)
        grass_tones = [("grass-tan", col("grass_tan"), 0.42), ("grass-rust", col("grass_rust"), 0.30),
                       ("grass-sage", col("grass_sage"), 0.18), ("grass-straw", col("grass_straw"), 0.10)]

        def blade(front=False):
            s = rng.uniform(s0, s1)
            gx = rng.uniform(-0.03, 1.03) * W
            gy = y_of(s) + (rng.uniform(0, 0.05) * H * s if front else 0)
            ht = H * min(s, 0.9) * rng.uniform(0.10, 0.34) * (1.15 if front else 1.0)
            lean = rng.gauss(0.22, 0.34)
            tipx = gx + ht * lean
            p0 = (gx, gy)
            arch = rng.random() < 0.3  # some blades arch over and droop
            p1 = (gx + ht * lean * 0.05, gy - ht * 0.45)
            p2 = (gx + ht * lean * (0.9 if arch else 0.55), gy - ht * (1.05 if arch else 0.85))
            p3 = (tipx + (ht * lean * 0.4 if arch else 0), gy - ht * (0.78 if arch else 1.0))
            wbase = max(0.7 * sc, W * 0.0042 * s * rng.uniform(0.5, 1.2))
            r = rng.random()
            acc = 0
            for name, fill, wgt in grass_tones:
                acc += wgt
                if r <= acc:
                    break
            lay(f"d{k}-{name}{'-front' if front else ''}", fill).add(ribbon_cmds(p0, p1, p2, p3, wbase, 0.0, 6), IDENT)
            if rng.random() < 0.05 and s > 0.08:  # little bluestem seed tufts catch the light
                tr = max(0.6 * sc, ht * 0.008)
                lay(f"d{k}-seedheads", col("seed_glint")).add(
                    ribbon_cmds((tipx - tr * lean, gy - ht + tr * 3), (tipx, gy - ht + tr * 2), (tipx, gy - ht + tr),
                                (tipx + tr * 0.3, gy - ht), tr * 1.6, tr * 0.4, 4), IDENT)

        for _ in range(blades):
            blade()

        # ---- aster clumps
        clumps = max(2, int(n_clumps * p.density * sc))
        heads_total = 0
        for _ in range(clumps):
            s = rng.uniform(s0, s1)
            gx = rng.uniform(-0.04, 1.04) * W
            if p.clear_zone and bname in ("depth-1", "depth-2"):
                x0, x1 = p.clear_zone
                if x0 * W < gx < x1 * W and rng.random() < 0.85:
                    continue
            # drifts: wide in front, finer toward the horizon
            u = (gx / W) * (0.6 + 1.6 * (1 - min(s, 1)))
            if rng.random() > 0.28 + 0.95 * drift(u % 1.0) ** 1.3:
                continue
            gy = y_of(s)
            wts = [sp[0] for sp in SPECIES]
            sp = rng.choices(SPECIES, wts)[0]
            Rbase = W * 0.030 * s * p.bloom_scale * sp[4]
            nheads = rng.randint(3, 9) if Rbase > 4 else rng.randint(2, 6)
            stem_h = H * s * rng.uniform(0.22, 0.42)
            hub = (gx + rng.uniform(-0.3, 0.3) * stem_h * 0.25, gy - stem_h * rng.uniform(0.62, 0.78))  # branching point
            for _h in range(nheads):
                R = Rbase * rng.uniform(0.7, 1.25)
                hx = hub[0] + rng.uniform(-1, 1) * max(R * 2.6, stem_h * 0.22)
                hy = gy - stem_h * rng.uniform(0.86, 1.10)
                heads_total += 1
                sw = max(0.6 * sc, R * 0.07)
                # stem: ground -> hub -> head
                if R > 1.6:
                    lay(f"d{k}-stems", col("stem")).add(
                        ribbon_cmds((gx, gy), (gx, gy - stem_h * 0.3), (hub[0], hub[1] + stem_h * 0.1), hub, sw * 1.5, sw * 1.1, 5), IDENT)
                    lay(f"d{k}-stems", col("stem")).add(
                        ribbon_cmds(hub, (hub[0] + (hx - hub[0]) * 0.2, hub[1] - (hub[1] - hy) * 0.5),
                                    (hx, hy + (hub[1] - hy) * 0.4), (hx, hy), sw, sw * 0.7, 6), IDENT)
                    if R > 14 and rng.random() < 0.5:  # a narrow lance leaf
                        lx, ly = (hub[0] + hx) / 2, (hub[1] + hy) / 2
                        dirn = 1 if rng.random() < 0.5 else -1
                        lay(f"d{k}-leaves", col("leaf")).add(
                            ribbon_cmds((lx, ly), (lx + dirn * R * 0.8, ly - R * 0.25), (lx + dirn * R * 1.6, ly - R * 0.2),
                                        (lx + dirn * R * 2.2, ly - R * 0.55), R * 0.32, 0.0, 6), IDENT)
                tilt = rng.uniform(0.45, 0.95)
                lean = math.radians(rng.uniform(-18, 18))
                xf = bloom_xf(hx, hy, R, tilt, lean)
                if R < 2.2:  # far: a speck of colour
                    lay(f"d{k}-{sp[1]}", tones[sp[1]]).add(circle_cmds(0, 0, 1.0), xf)
                    continue
                if R < 7:
                    lvl, rays = 2, rng.randint(10, 14)
                elif R < 26:
                    lvl, rays = 3, rng.randint(*sp[3])
                else:
                    lvl, rays = 4, rng.randint(*sp[3])
                alt = rng.random() < 0.25
                b = Bloom(petals=rays, abstraction=lvl, center=rng.uniform(0.2, 0.27),
                          width=0.30 * min(2.2, max(1.0, 12.0 / R)),  # thin rays stay visible when small
                          florets=R >= p.floret_min, rotation=rng.uniform(0, 360), seed=rng.randrange(1 << 30))
                for lname, (_fill, pb) in b.layers(xf, nd).items():
                    if not pb:
                        continue
                    if lname == "petals-a":
                        tgt = sp[2] if alt else sp[1]
                    elif lname == "petals-b":
                        tgt = sp[1] if alt else sp[2]
                    elif lname == "petals-back":
                        tgt = "petals-back"
                    elif _fill == col("golden_center"):
                        tgt = "centers"
                    else:
                        tgt = "centers-shadow"
                    fill = tones.get(tgt, col("golden_center") if tgt == "centers" else col("center_rust"))
                    L.setdefault(f"d{k}-{tgt}", (fill, PathBuilder(nd)))[1].parts.extend(pb.parts)

        # ---- grass in front, so flowers sit *in* the prairie, not on it
        for _ in range(int(blades * 0.28)):
            blade(front=True)

        order = ["grass-tan", "grass-rust", "grass-sage", "grass-straw", "stems", "leaves",
                 "petals-back", "petals-a", "petals-b", "petals-c", "centers-shadow", "centers",
                 "grass-tan-front", "grass-rust-front", "grass-sage-front", "grass-straw-front", "seedheads"]
        svg.open_group(bname)
        for o in order:
            key = f"d{k}-{o}"
            if key in L:
                fill, pb = L[key]
                svg.path(key, pb.d(), fill)
        svg.close_group()
        stats[bname] = {"clumps": clumps, "heads": heads_total, "blades": blades}

    if p.bokeh:  # a few big, very out-of-focus blooms right at the lens
        svg.open_group("bokeh-front")
        L = {}
        spots = [(0.03, 0.97), (0.97, 0.93), (0.16, 1.04)]
        for i, (fx, fy) in enumerate(spots[: 2 + (p.seed % 2)]):
            R = W * rng.uniform(0.06, 0.085) * p.bloom_scale
            b = Bloom(petals=rng.randint(22, 28), abstraction=3, center=0.24, rotation=rng.uniform(0, 360), seed=p.seed * 31 + i)
            for lname, (fill, pb) in b.layers(bloom_xf(fx * W, fy * H - R * 0.2, R, rng.uniform(0.6, 0.85), rng.uniform(-0.3, 0.3)), nd).items():
                if pb:
                    L.setdefault(lname, (fill, PathBuilder(nd)))[1].parts.extend(pb.parts)
        for lname, (fill, pb) in L.items():
            svg.path(f"bokeh-{lname}", pb.d(), fill)
        svg.close_group()

    return svg, stats


def field_recipe(p: FieldParams, grain=True):
    """PhotoCraft finishing steps for a field opened from its SVG. Every step
    makes a separate, editable layer: fill layers, smart filters, adjustment layers."""
    W, H = p.w, p.h
    yh = H * p.horizon
    lt = PALETTE["light"][p.light]
    sc = W / 1920.0
    S = []

    def sel(name):
        S.append({"id": "select.findLayers", "params": {"name": name}})

    def name_active(n, **kw):
        S.append({"id": "layer.setProps", "params": {"name": n, **kw}})

    # sky + sun (only when the field has its own sky)
    if not p.sky:
        return _field_recipe_body(p, S, sel, name_active, W, H, yh, lt, sc, grain=False, vignette=False)
    sel("sky-base")
    S.append({"id": "gradient.fill.create", "params": {
        "from": [W / 2, 0], "to": [W / 2, yh + H * 0.02], "style": "linear",
        "stops": [[0, lt["sky"][0]], [0.62, lt["sky"][1]], [1, lt["sky"][2]]],
        "transparency": [[0, 100], [1, 100]]}})
    name_active("sky")
    if p.light == "golden":
        sx, sy, sr = W * 0.73, yh - H * 0.07, W * 0.42
    elif p.light == "dusk":
        sx, sy, sr = W * 0.62, yh + H * 0.02, W * 0.55
    else:
        sx, sy, sr = W * 0.55, yh - H * 0.25, W * 0.6
    S.append({"id": "gradient.fill.create", "params": {
        "from": [sx, sy], "to": [sx + sr, sy], "style": "radial",
        "stops": [[0, lt["sun"]], [1, lt["sun"]]],
        "transparency": [[0, 70 if p.light != "overcast" else 40], [0.2, 30 if p.light != "overcast" else 18], [1, 0]],
        "mode": "screen"}})
    name_active("sun-glow")
    if p.light == "golden":
        S.append({"id": "shape.create", "params": {"kind": "ellipse", "rect": [sx - W * 0.018, sy - W * 0.018, W * 0.036, W * 0.036],
                                                   "fill": "#FFF1D6", "name": "sun-disc"}})
        S.append({"id": "layer.layerStyle.outerGlow", "params": {"color": "#FFE2AE", "opacity": 70, "blend": "screen",
                                                                 "technique": "softer", "size": int(60 * sc), "spread": 0}})

    return _field_recipe_body(p, S, sel, name_active, W, H, yh, lt, sc, grain=grain, vignette=True, sun=(sx, sy))


def _field_recipe_body(p, S, sel, name_active, W, H, yh, lt, sc, grain=True, vignette=True, sun=None):
    sx, sy = sun if sun else (W * 0.7, yh - H * 0.08)
    # ground shade: the prairie floor darkens toward the viewer, under the grass
    sel("ground-base")
    S.append({"id": "gradient.fill.create", "params": {
        "from": [W / 2, yh], "to": [W / 2, H], "style": "linear",
        "stops": [[0, "#3E3024"], [1, "#2A2019"]], "transparency": [[0, 0], [0.4, 35], [1, 80]], "mode": "multiply"}})
    name_active("ground-shade")

    # haze above the treeline and each far band (atmospheric perspective).
    # Haze names never contain a band name, because findLayers matches substrings.
    haze_after = [("ground-shade", "haze-0", 0.48, 0.20)] + [
        (b[0], f"haze-{b[0][-1]}", b[4], b[2]) for b in BANDS[:-1] if b[4] > 0]
    for anchor, hname, strength, s_end in haze_after:
        sel(anchor)
        # a fill layer holds its end values beyond from/to, so the haze must start
        # and end at 0: up from nothing just above the treeline, strongest at
        # the horizon, gone by the band's front edge
        y0 = yh - H * 0.10
        y_end = yh + (H - yh) * min(1.0, s_end + 0.25)
        th = (yh - y0) / (y_end - y0)
        S.append({"id": "gradient.fill.create", "params": {
            "from": [W / 2, y0], "to": [W / 2, y_end], "style": "linear",
            "stops": [[0, lt["haze"]], [1, lt["haze"]]],
            "transparency": [[0, 0], [round(th, 3), int(strength * 100)],
                             [round(th + (1 - th) * 0.3, 3), int(strength * 75)], [1, 0]]}})
        name_active(hname)

    # depth of field: each band becomes a smart object with a live Gaussian Blur
    for bname, _s0, _s1, blur, *_ in BANDS:
        if blur <= 0:
            continue
        sel(bname)
        S.append({"id": "layer.smartObjects.convertToSmartObject", "params": {}})
        name_active(bname)
        S.append({"id": "filter.blur.gaussianBlur", "params": {"radius": round(blur * sc, 2)}})
    if p.bokeh:
        sel("bokeh-front")
        S.append({"id": "layer.smartObjects.convertToSmartObject", "params": {}})
        name_active("bokeh-front", opacity=0.92)
        S.append({"id": "filter.blur.gaussianBlur", "params": {"radius": round(13 * sc, 1)}})

    # light grade (adjustment layers on top)
    S.append({"id": "layer.selectTop", "params": {}})
    if p.light == "golden":
        S.append({"id": "layer.newAdjustmentLayer.colorBalance", "params": {
            "shadows": [5, -2, -8], "midtones": [5, 1, -8], "highlights": [3, 2, -5]}})
        name_active("grade-warm")
        S.append({"id": "layer.newAdjustmentLayer.curves", "params": {"points": [[0, 8], [70, 64], [180, 192], [255, 250]]}})
        name_active("grade-contrast")
        if p.sky:  # a screen fill over a transparent sky would paint the sky
            S.append({"id": "gradient.fill.create", "params": {
                "from": [sx, sy], "to": [sx + W * 0.9, sy], "style": "radial",
                "stops": [[0, "#FFD9A0"], [1, "#FFD9A0"]], "transparency": [[0, 18], [1, 0]], "mode": "screen"}})
            name_active("backlight-bloom")
    elif p.light == "dusk":
        S.append({"id": "layer.newAdjustmentLayer.colorBalance", "params": {
            "shadows": [-4, -2, 8], "midtones": [2, -3, 4], "highlights": [8, 0, -6]}})
        name_active("grade-dusk")
        S.append({"id": "layer.newAdjustmentLayer.hueSaturation", "params": {"saturation": -22}})
        name_active("grade-dusk-desat")
        S.append({"id": "layer.newAdjustmentLayer.curves", "params": {"points": [[0, 4], [90, 62], [200, 176], [255, 236]]}})
        name_active("grade-lowlight")
        S.append({"id": "gradient.fill.create", "params": {
            "from": [W / 2, yh], "to": [W / 2, H], "style": "linear",
            "stops": [[0, "#4A3F5E"], [1, "#2E2840"]], "transparency": [[0, 0], [1, 45]], "mode": "multiply"}})
        name_active("dusk-ground-shadow")
    elif p.light == "frost":
        S.append({"id": "layer.newAdjustmentLayer.hueSaturation", "params": {"saturation": -38, "lightness": 6}})
        name_active("grade-frost-desat")
        S.append({"id": "layer.newAdjustmentLayer.colorBalance", "params": {"shadows": [-6, 0, 10], "midtones": [-6, 0, 9], "highlights": [-2, 0, 6]}})
        name_active("grade-frost-cool")
        S.append({"id": "gradient.fill.create", "params": {
            "from": [W / 2, yh], "to": [W / 2, H], "style": "linear",
            "stops": [[0, "#EEF2F4"], [1, "#EEF2F4"]], "transparency": [[0, 0], [0.5, 10], [1, 26]], "mode": "screen"}})
        name_active("frost-rime")
    else:
        S.append({"id": "layer.newAdjustmentLayer.hueSaturation", "params": {"saturation": -28, "lightness": 2}})
        name_active("grade-overcast-desat")
        S.append({"id": "layer.newAdjustmentLayer.curves", "params": {"points": [[0, 22], [128, 132], [255, 238]]}})
        name_active("grade-overcast-flat")
        S.append({"id": "layer.newAdjustmentLayer.colorBalance", "params": {"midtones": [-3, 0, 5]}})
        name_active("grade-overcast-cool")

    # vignette + grain
    if vignette:
        S.append({"id": "gradient.fill.create", "params": {
            "from": [W / 2, H * 0.55], "to": [W * 1.08, H * 0.55], "style": "radial",
            "stops": [[0, "#2B211B"], [1, "#2B211B"]], "transparency": [[0, 0], [0.55, 0], [1, 38]], "mode": "multiply"}})
        name_active("vignette")
    if grain:
        S.append({"id": "layer.new.layer", "params": {"name": "grain"}})
        S.append({"id": "edit.fill", "params": {"contents": "gray"}})
        S.append({"id": "filter.noise.addNoise", "params": {"amount": 6, "distribution": "gaussian", "monochromatic": True, "seed": p.seed}})
        S.append({"id": "layer.setProps", "params": {"blend": "Overlay", "opacity": 0.45}})
    return S


# --------------------------------------------------------------------------
# CLI
# --------------------------------------------------------------------------
def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    sub = ap.add_subparsers(dest="cmd", required=True)
    b = sub.add_parser("bloom")
    b.add_argument("--level", type=int, default=1)
    b.add_argument("--petals", type=int, default=8)
    b.add_argument("--center", type=float, default=0.24)
    b.add_argument("--width", type=float, default=0.30)
    b.add_argument("--length-var", type=float)
    b.add_argument("--width-var", type=float)
    b.add_argument("--seed", type=int, default=0)
    b.add_argument("--petal-color", default="dusty_violet")
    b.add_argument("--petal-color-2", default="lavender")
    b.add_argument("--center-color", default="golden_center")
    b.add_argument("--bg")
    b.add_argument("--size", type=int, default=800)
    b.add_argument("--out", required=True)
    f = sub.add_parser("field")
    f.add_argument("--w", type=int, default=1920)
    f.add_argument("--h", type=int, default=1080)
    f.add_argument("--seed", type=int, default=7)
    f.add_argument("--density", type=float, default=0.8)
    f.add_argument("--horizon", type=float, default=0.42)
    f.add_argument("--light", default="golden", choices=list(PALETTE["light"]))
    f.add_argument("--bloom-scale", type=float, default=1.0)
    f.add_argument("--clear-zone", type=float, nargs=2)
    f.add_argument("--no-bokeh", action="store_true")
    f.add_argument("--no-sky", action="store_true", help="transparent sky (strips for cans and layouts)")
    f.add_argument("--floret-min", type=float, default=14.0)
    f.add_argument("--out", required=True, help="basename; writes <out>.svg and <out>.recipe.json")
    a = ap.parse_args()
    if a.cmd == "bloom":
        bl = Bloom(petals=a.petals, abstraction=a.level, center=a.center, width=a.width, length_var=a.length_var,
                   width_var=a.width_var, seed=a.seed, petal_color=a.petal_color, petal_color_2=a.petal_color_2,
                   center_color=a.center_color)
        bloom_svg(bl, a.size, bg=a.bg).save(a.out)
        print(a.out)
    else:
        fp = FieldParams(w=a.w, h=a.h, seed=a.seed, density=a.density, horizon=a.horizon, light=a.light,
                         bloom_scale=a.bloom_scale, clear_zone=tuple(a.clear_zone) if a.clear_zone else None,
                         bokeh=not a.no_bokeh, sky=not a.no_sky, floret_min=a.floret_min)
        svg, stats = field(fp)
        svg.save(a.out + ".svg")
        with open(a.out + ".recipe.json", "w", encoding="utf-8") as fh:
            json.dump(field_recipe(fp), fh, indent=1)
        print(json.dumps({"svg": a.out + ".svg", "bytes": os.path.getsize(a.out + ".svg"), "bands": stats}))


if __name__ == "__main__":
    main()
