#!/usr/bin/env python3
"""
Art by T: generative abstract artworks.

Each piece is a small system of rules (a flow field, layered strata, orbits,
a Truchet tiling, an interference pattern, overlapping blooms). The system
is deterministic: the same seed always produces the same SVG, so every work
can be reproduced, varied and documented.

Usage:
    python3 tools/generate_art.py            # writes art/*.svg and art/works.json
    python3 tools/generate_art.py --only drift --seed 12

Requires: Python 3.9+, numpy.
"""
import argparse
import json
import math
import os

import numpy as np

W, H = 1600, 2000

PAPER = "#f1ebe0"
INK = "#14151a"
INDIGO = "#23305e"
VERMILION = "#e4572e"
OCHRE = "#e9b44c"
TEAL = "#2a9d8f"
ROSE = "#c9777b"
SAGE = "#8a9a7b"
CREAM = "#f6f0e4"

OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "art")


# --------------------------------------------------------------------------
# helpers
# --------------------------------------------------------------------------
def rng_for(seed):
    return np.random.default_rng(seed)


def f1(v):
    """Compact float formatting for SVG."""
    s = f"{v:.1f}"
    return s[:-2] if s.endswith(".0") else s


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


def mix(c1, c2, t):
    a, b = hex_to_rgb(c1), hex_to_rgb(c2)
    return "#%02x%02x%02x" % tuple(int(round(a[i] + (b[i] - a[i]) * t)) for i in range(3))


def ramp(colors, t):
    """Sample a multi-stop colour ramp, t in [0, 1]."""
    t = min(max(t, 0.0), 1.0) * (len(colors) - 1)
    i = min(int(t), len(colors) - 2)
    return mix(colors[i], colors[i + 1], t - i)


def grain(seed, strength=0.22, freq=0.9):
    return (
        f'<filter id="grain" x="0" y="0" width="100%" height="100%">'
        f'<feTurbulence type="fractalNoise" baseFrequency="{freq}" numOctaves="2" seed="{seed}" result="n"/>'
        f'<feColorMatrix in="n" type="saturate" values="0"/>'
        f'<feComponentTransfer><feFuncA type="table" tableValues="0 {strength}"/></feComponentTransfer>'
        f"</filter>"
    )


def svg_open(title, desc, bg):
    return (
        f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {W} {H}" '
        f'width="{W}" height="{H}" role="img" aria-labelledby="t d">'
        f"<title id=\"t\">{title}</title><desc id=\"d\">{desc}</desc>"
        f'<rect width="{W}" height="{H}" fill="{bg}"/>'
    )


def grain_layer():
    return f'<rect width="{W}" height="{H}" filter="url(#grain)" style="mix-blend-mode:multiply"/>'


class Field:
    """Smooth pseudo-noise built from a sum of random plane waves."""

    def __init__(self, rng, n=6, fmin=0.0012, fmax=0.006):
        self.theta = rng.uniform(0, math.tau, n)
        self.freq = rng.uniform(fmin, fmax, n)
        self.phase = rng.uniform(0, math.tau, n)
        self.amp = rng.uniform(0.5, 1.0, n)
        self.amp /= self.amp.sum()

    def __call__(self, x, y):
        out = 0.0
        for th, fr, ph, am in zip(self.theta, self.freq, self.phase, self.amp):
            out = out + am * np.sin(fr * (x * math.cos(th) + y * math.sin(th)) * math.tau + ph)
        return out  # roughly in [-1, 1]


# --------------------------------------------------------------------------
# 1. Drift: flow-field streamlines
# --------------------------------------------------------------------------
def drift(seed=7):
    rng = rng_for(seed)
    field = Field(rng, n=5, fmin=0.0006, fmax=0.0026)
    n_lines, steps, step = 800, 80, 8.0
    pts = np.column_stack([rng.uniform(-50, W + 50, n_lines), rng.uniform(-50, H + 50, n_lines)])
    paths = [[tuple(p)] for p in pts]
    for _ in range(steps):
        ang = field(pts[:, 0], pts[:, 1]) * math.pi * 1.6
        pts = pts + step * np.column_stack([np.cos(ang), np.sin(ang)])
        for i, p in enumerate(pts):
            paths[i].append(tuple(p))
    paths = [p[::3] + ([p[-1]] if (len(p) - 1) % 3 else []) for p in paths]
    cols = [VERMILION, OCHRE, CREAM, TEAL, ROSE]
    weights = [0.28, 0.2, 0.3, 0.12, 0.10]
    out = [svg_open("Drift", "Flow-field streamlines in vermilion, ochre and cream on ink.", INK), "<defs>", grain(seed, 0.16), "</defs>"]
    out.append('<g fill="none" stroke-linecap="round">')
    for i, p in enumerate(paths):
        c = cols[rng.choice(len(cols), p=weights)]
        sw = rng.choice([1.2, 2.2, 3.6], p=[0.55, 0.33, 0.12])
        op = rng.uniform(0.35, 0.85)
        d = "M" + " L".join(f"{f1(x)},{f1(y)}" for x, y in p)
        out.append(f'<path d="{d}" stroke="{c}" stroke-width="{sw}" opacity="{op:.2f}"/>')
    out.append("</g>")
    out.append(grain_layer())
    out.append("</svg>")
    return "".join(out)


# --------------------------------------------------------------------------
# 2. Strata: layered contour bands
# --------------------------------------------------------------------------
def strata(seed=21):
    rng = rng_for(seed)
    fields = [Field(rng, n=4, fmin=0.0004, fmax=0.0016) for _ in range(3)]
    bands = 15
    colors = [INK, INDIGO, TEAL, SAGE, OCHRE, VERMILION]
    xs = np.linspace(-20, W + 20, 120)
    out = [svg_open("Strata", "Layered bands of colour with wandering edges, like cut paper or sediment.", PAPER), "<defs>", grain(seed, 0.2, 0.7), "</defs>"]
    for b in range(bands):
        base = 160 + b * (H - 260) / bands
        amp = 40 + 110 * (b / bands) ** 0.8
        f = fields[b % 3]
        ys = base + amp * f(xs, np.full_like(xs, b * 90.0))
        t = b / (bands - 1)
        fill = ramp(colors, (t + rng.uniform(-0.06, 0.06)))
        d = "M-20," + f1(H + 20) + " L" + " L".join(f"{f1(x)},{f1(y)}" for x, y in zip(xs, ys)) + f" L{f1(W + 20)},{f1(H + 20)} Z"
        out.append(f'<path d="{d}" fill="{fill}"/>')
        edge = "M" + " L".join(f"{f1(x)},{f1(y)}" for x, y in zip(xs, ys))
        out.append(f'<path d="{edge}" fill="none" stroke="{CREAM}" stroke-opacity="0.35" stroke-width="2"/>')
    out.append(grain_layer())
    out.append("</svg>")
    return "".join(out)


# --------------------------------------------------------------------------
# 3. Orbit Study: broken concentric rings
# --------------------------------------------------------------------------
def orbit(seed=33):
    rng = rng_for(seed)
    cols = [VERMILION, OCHRE, CREAM, TEAL, ROSE]
    out = [svg_open("Orbit Study", "Broken concentric rings around several centres on deep indigo.", "#171d3a"), "<defs>", grain(seed, 0.16), "</defs>"]
    centres = [(480, 520, 430), (1160, 760, 360), (620, 1320, 400), (1180, 1560, 320), (300, 1790, 220)]
    for cx, cy, rmax in centres:
        rings = int(rng.integers(12, 22))
        for k in range(rings):
            r = rmax * (k + 1) / rings
            c = cols[rng.integers(len(cols))]
            sw = float(rng.choice([2, 4, 8, 14], p=[0.35, 0.3, 0.22, 0.13]))
            circ = math.tau * r
            dash = float(rng.uniform(0.15, 0.9)) * circ
            gap = circ - dash
            rot = float(rng.uniform(0, 360))
            op = float(rng.uniform(0.5, 0.95))
            out.append(
                f'<circle cx="{cx}" cy="{cy}" r="{f1(r)}" fill="none" stroke="{c}" stroke-width="{sw}" '
                f'stroke-dasharray="{f1(dash)} {f1(gap)}" transform="rotate({f1(rot)} {cx} {cy})" opacity="{op:.2f}" stroke-linecap="round"/>'
            )
        out.append(f'<circle cx="{cx}" cy="{cy}" r="{f1(rmax / rings * 0.6)}" fill="{OCHRE}"/>')
    out.append(grain_layer())
    out.append("</svg>")
    return "".join(out)


# --------------------------------------------------------------------------
# 4. Quiet Grid: Truchet tiling with clustered colour
# --------------------------------------------------------------------------
def quiet_grid(seed=45):
    rng = rng_for(seed)
    cell = 100
    cols_n, rows_n = W // cell, H // cell
    field = Field(rng, n=4, fmin=0.0008, fmax=0.002)
    palette = [INDIGO, TEAL, SAGE, OCHRE, VERMILION]
    out = [svg_open("Quiet Grid", "A Truchet tiling of quarter-circle arcs in clustered colour on paper.", PAPER), "<defs>", grain(seed, 0.18, 0.8), "</defs>"]
    h = cell / 2
    out.append(f'<g fill="none" stroke-width="{cell * 0.28:.0f}" stroke-linecap="butt">')
    for j in range(rows_n):
        for i in range(cols_n):
            x, y = i * cell, j * cell
            v = field(x + h, y + h)
            col = ramp(palette, (v + 1) / 2)
            if rng.random() < 0.5:
                d = f"M{x + h},{y} A{h},{h} 0 0 1 {x},{y + h} M{x + cell - h},{y + cell} A{h},{h} 0 0 1 {x + cell},{y + cell - h}"
            else:
                d = f"M{x + cell - h},{y} A{h},{h} 0 0 0 {x + cell},{y + h} M{x + h},{y + cell} A{h},{h} 0 0 0 {x},{y + cell - h}"
            out.append(f'<path d="{d}" stroke="{col}"/>')
    out.append("</g>")
    out.append(grain_layer())
    out.append("</svg>")
    return "".join(out)


# --------------------------------------------------------------------------
# 5. Interference: overlapping concentric line sets
# --------------------------------------------------------------------------
def interference(seed=58):
    rng = rng_for(seed)
    out = [svg_open("Interference", "Three sets of fine concentric circles overlapping to form moire patterns.", INK), "<defs>", grain(seed, 0.12, 1.0), "</defs>"]
    sets = [
        (rng.uniform(300, 700), rng.uniform(500, 900), CREAM, 17.0),
        (rng.uniform(900, 1300), rng.uniform(1000, 1500), VERMILION, 18.0),
        (rng.uniform(500, 1100), rng.uniform(1400, 1800), OCHRE, 19.0),
    ]
    for cx, cy, col, spacing in sets:
        out.append(f'<g fill="none" stroke="{col}" stroke-width="2.4" opacity="0.85" style="mix-blend-mode:screen">')
        r = spacing
        while r < 950:
            out.append(f'<circle cx="{f1(cx)}" cy="{f1(cy)}" r="{f1(r)}"/>')
            r += spacing
        out.append("</g>")
    out.append(grain_layer())
    out.append("</svg>")
    return "".join(out)


# --------------------------------------------------------------------------
# 6. Bloom: overlapping translucent organic forms
# --------------------------------------------------------------------------
def bloom(seed=71):
    rng = rng_for(seed)
    cols = [VERMILION, OCHRE, TEAL, ROSE, INDIGO, SAGE]
    out = [svg_open("Bloom", "Overlapping translucent organic forms that multiply where they meet.", PAPER), "<defs>", grain(seed, 0.2, 0.75), "</defs>"]
    theta = np.linspace(0, math.tau, 140, endpoint=False)
    out.append('<g style="mix-blend-mode:multiply">')
    for k in range(11):
        cx, cy = rng.uniform(250, W - 250), rng.uniform(300, H - 300)
        base = rng.uniform(190, 420)
        r = np.ones_like(theta)
        for m in range(2, 6):
            r += rng.uniform(0.02, 0.14) / (m - 1) * np.sin(m * theta + rng.uniform(0, math.tau))
        r *= base
        xs, ys = cx + r * np.cos(theta), cy + r * np.sin(theta)
        d = "M" + " L".join(f"{f1(x)},{f1(y)}" for x, y in zip(xs, ys)) + " Z"
        c = cols[k % len(cols)]
        out.append(f'<path d="{d}" fill="{c}" fill-opacity="{rng.uniform(0.5, 0.72):.2f}"/>')
    out.append("</g>")
    out.append(grain_layer())
    out.append("</svg>")
    return "".join(out)


# --------------------------------------------------------------------------
WORKS = [
    dict(slug="drift", title="Drift", fn=drift, seed=7,
         system="Flow field",
         note="800 streamlines follow an angle field built from five overlapping plane waves. Each line is nudged forward 80 times; colour and weight are drawn from weighted palettes."),
    dict(slug="strata", title="Strata", fn=strata, seed=21,
         system="Layered contour bands",
         note="Fifteen bands, each with an edge shaped by slow noise. Amplitude grows down the page, so the top is calm and the bottom restless. Painted back to front."),
    dict(slug="orbit-study", title="Orbit Study", fn=orbit, seed=33,
         system="Broken concentric rings",
         note="Five centres, each wrapped in 12 to 22 rings. Every ring is dashed at a random ratio and rotated, so the circles read as arcs caught mid-motion."),
    dict(slug="quiet-grid", title="Quiet Grid", fn=quiet_grid, seed=45,
         system="Truchet tiling",
         note="A 16 by 20 grid where each tile holds two quarter-circle arcs in one of two orientations. Colour follows a smooth field so neighbouring tiles share a mood."),
    dict(slug="interference", title="Interference", fn=interference, seed=58,
         system="Moiré of concentric lines",
         note="Three sets of fine circles, each with a slightly different spacing. The patterns you see where they cross are not drawn; they emerge from the overlap."),
    dict(slug="bloom", title="Bloom", fn=bloom, seed=71,
         system="Overlapping organic forms",
         note="Eleven closed curves, each a circle distorted by a handful of harmonics. Multiply blending darkens the overlaps so new colours appear where forms meet."),
]


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--only", help="slug of a single work to regenerate")
    ap.add_argument("--seed", type=int, help="override the seed for --only")
    args = ap.parse_args()

    os.makedirs(OUT, exist_ok=True)
    meta = []
    for w in WORKS:
        seed = args.seed if (args.only == w["slug"] and args.seed is not None) else w["seed"]
        if args.only and args.only != w["slug"]:
            meta.append({k: v for k, v in w.items() if k != "fn"})
            continue
        svg = w["fn"](seed)
        path = os.path.join(OUT, f"{w['slug']}.svg")
        with open(path, "w", encoding="utf-8") as fh:
            fh.write(svg)
        print(f"wrote {os.path.relpath(path)}  seed={seed}  {len(svg) / 1024:.0f} KB")
        entry = {k: v for k, v in w.items() if k != "fn"}
        entry["seed"] = seed
        meta.append(entry)
    with open(os.path.join(OUT, "works.json"), "w", encoding="utf-8") as fh:
        json.dump(meta, fh, indent=2)


if __name__ == "__main__":
    main()
