"""
Material textures for VTT Map (terrain blocks, floors and walls): 256x256, seamlessly tiling PNGs.

    python tools/make_textures.py textures

One source of truth: the 3D view, the 2D view and the forum PNG all use these files, so a brick
wall looks like the same brick wall everywhere. Everything is procedural (seeded), no outside art.
Not deployed: only its output (textures/*.png) is.
"""
import os
import sys

import numpy as np
from PIL import Image, ImageDraw, ImageFilter

N = 256
rng_master = np.random.default_rng(20261008)


def noise(scale, seed, octaves=4, persistence=0.5):
    """Periodic (tileable) fractal noise in 0..1: low-passed white noise in the frequency domain."""
    rng = np.random.default_rng(seed)
    out = np.zeros((N, N))
    amp = 1.0
    total = 0.0
    fy = np.fft.fftfreq(N)[:, None]
    fx = np.fft.fftfreq(N)[None, :]
    f = np.sqrt(fx * fx + fy * fy)
    for o in range(octaves):
        cutoff = (1.0 / scale) * (2 ** o)
        spec = np.fft.fft2(rng.standard_normal((N, N)))
        spec *= np.exp(-(f / cutoff) ** 2)
        layer = np.real(np.fft.ifft2(spec))
        layer = (layer - layer.min()) / (np.ptp(layer) + 1e-9)
        out += layer * amp
        total += amp
        amp *= persistence
    return out / total


def ridged(scale, seed, octaves=4):
    n = noise(scale, seed, octaves)
    return 1 - np.abs(n * 2 - 1)


def rgb(arr_r, arr_g, arr_b, alpha=None):
    a = np.stack([arr_r, arr_g, arr_b] + ([alpha] if alpha is not None else []), axis=-1)
    return Image.fromarray(np.clip(a, 0, 255).astype(np.uint8), 'RGBA' if alpha is not None else 'RGB')


def tint(base, shade):
    """base = (r, g, b) 0..255, shade = 2D array ~0..1.4 → RGB image."""
    return rgb(base[0] * shade, base[1] * shade, base[2] * shade)


def wrap_draw(img, fn):
    """Draw with fn(draw, dx, dy) at the 9 wrapped offsets so shapes crossing the edge tile cleanly."""
    d = ImageDraw.Draw(img)
    for dy in (-N, 0, N):
        for dx in (-N, 0, N):
            fn(d, dx, dy)
    return img


# ---------------------------------------------------------------------------------------------
def tex_rock():
    n = noise(48, 1, 5)
    cr = ridged(70, 2, 3)
    shade = 0.55 + 0.6 * n - 0.35 * np.clip(cr - 0.82, 0, 1) * 5
    return tint((128, 120, 110), shade)


def tex_face():
    """Greyscale rock face for 'Map' cliffs: tinted to the ground color at runtime."""
    n = noise(40, 3, 5)
    y = np.arange(N)[:, None] / N
    strata = 0.08 * np.sin((y * 6 + noise(90, 4, 2) * 1.5) * 2 * np.pi)
    cr = ridged(60, 5, 3)
    v = 0.7 + 0.3 * (n - 0.5) + strata * 0.8 - 0.18 * np.clip(cr - 0.86, 0, 1) * 6
    v = np.clip(v, 0, 1) * 255
    return rgb(v, v, v)


def tex_dirt():
    n = noise(30, 6, 5)
    img = tint((122, 88, 58), 0.65 + 0.55 * n)
    rng = np.random.default_rng(7)
    pebbles = [(rng.integers(0, N), rng.integers(0, N), rng.integers(3, 8), rng.integers(70, 140)) for _ in range(60)]
    def draw(d, dx, dy):
        for x, y, r, c in pebbles:
            d.ellipse([x + dx - r, y + dy - r * 0.7, x + dx + r, y + dy + r * 0.7], fill=(c, int(c * 0.86), int(c * 0.7)))
    return wrap_draw(img, draw)


def blocks(rows, cols_per_row, mortar, base, seed, jitter=0.18, offset=True, mortar_col=(70, 66, 60), noise_amt=0.35):
    """Running-bond blocks (bricks / stone blocks)."""
    rng = np.random.default_rng(seed)
    n = noise(20, seed + 1, 4)
    shade = np.zeros((N, N))
    mask = np.zeros((N, N), bool)
    rh = N / rows
    for r in range(rows):
        cw = N / cols_per_row
        off = (cw / 2 if (offset and r % 2) else 0)
        for c in range(cols_per_row + 1):
            s = 1 + rng.uniform(-jitter, jitter)
            x0 = c * cw + off
            y0 = r * rh
            for yy in range(int(y0 + mortar), int(y0 + rh - mortar)):
                xs = (np.arange(int(x0 + mortar), int(x0 + cw - mortar)) % N)
                shade[yy % N, xs] = s
                mask[yy % N, xs] = True
    v = shade * (1 - noise_amt / 2 + noise_amt * n)
    img = np.zeros((N, N, 3))
    for i in range(3):
        img[..., i] = np.where(mask, base[i] * v, mortar_col[i] * (0.8 + 0.4 * n))
    return Image.fromarray(np.clip(img, 0, 255).astype(np.uint8), 'RGB')


def tex_brick():
    img = blocks(8, 4, 2.5, (150, 66, 48), 10, jitter=0.2, mortar_col=(150, 140, 125))
    return img.filter(ImageFilter.GaussianBlur(0.6))


def tex_stone():
    img = blocks(4, 2, 3, (140, 138, 130), 20, jitter=0.15, mortar_col=(58, 56, 52), noise_amt=0.5)
    return img.filter(ImageFilter.GaussianBlur(0.7))


def tex_ancient():
    """Big worn blocks with moss in the cracks and faint carved marks."""
    img = blocks(3, 2, 4, (150, 140, 112), 30, jitter=0.12, mortar_col=(50, 58, 40), noise_amt=0.7)
    a = np.asarray(img).astype(float)
    moss = noise(25, 31, 4)
    m = np.clip((moss - 0.55) * 4, 0, 1)[..., None]
    a = a * (1 - m * 0.65) + np.array([70, 100, 50]) * m * 0.65
    img = Image.fromarray(np.clip(a, 0, 255).astype(np.uint8), 'RGB')
    rng = np.random.default_rng(32)
    marks = [(rng.integers(20, N - 20), rng.integers(20, N - 20), rng.integers(0, 3)) for _ in range(5)]
    def draw(d, dx, dy):
        for x, y, k in marks:
            col = (92, 84, 66)
            if k == 0:
                d.ellipse([x + dx - 9, y + dy - 9, x + dx + 9, y + dy + 9], outline=col, width=2)
            elif k == 1:
                d.line([x + dx - 10, y + dy, x + dx + 10, y + dy], fill=col, width=2)
                d.line([x + dx, y + dy - 10, x + dx, y + dy + 10], fill=col, width=2)
            else:
                d.polygon([(x + dx, y + dy - 10), (x + dx + 9, y + dy + 7), (x + dx - 9, y + dy + 7)], outline=col)
    return wrap_draw(img, draw).filter(ImageFilter.GaussianBlur(0.5))


def tex_wood():
    """Planks with grain, seams and a few knots (good for floors, crates and log walls)."""
    rng = np.random.default_rng(40)
    y = np.arange(N)[:, None]
    x = np.arange(N)[None, :]
    rows = 5
    rh = N / rows
    plank = (y // rh).astype(int)
    grain_n = noise(90, 41, 3)
    grain = 0.5 + 0.5 * np.sin((y / 2.4 + grain_n * 22 + plank * 3.1) * 1.0)
    tone = np.array([rng.uniform(0.82, 1.12) for _ in range(rows)])[plank]
    shade = tone * (0.72 + 0.22 * grain + 0.18 * noise(12, 42, 3))
    seam = ((y % rh) < 2.2)
    # end joints, staggered per plank
    joints = np.zeros((N, N), bool)
    for r in range(rows):
        jx = int(rng.uniform(0, N))
        joints[int(r * rh):int((r + 1) * rh), [(jx + k) % N for k in range(2)]] = True
    shade = np.where(seam | joints, 0.35, shade)
    img = tint((150, 100, 60), shade)
    knots = [(rng.integers(0, N), int((r + 0.5) * rh), rng.integers(4, 7)) for r in range(rows) for _ in range(1)]
    def draw(d, dx, dy):
        for kx, ky, kr in knots:
            d.ellipse([kx + dx - kr, ky + dy - kr * 0.6, kx + dx + kr, ky + dy + kr * 0.6], outline=(80, 50, 28), width=2)
    return wrap_draw(img, draw).filter(ImageFilter.GaussianBlur(0.4))


def tex_metal():
    """Steel plates: brushed, seams, rivets."""
    n = noise(8, 50, 2)
    brushed = 0.82 + 0.16 * noise(3, 51, 1)[:, :1].repeat(N, 1).T * 0 + 0.1 * (noise(60, 52, 2))
    y = np.arange(N)[:, None]
    x = np.arange(N)[None, :]
    stripe = 0.04 * np.sin(y * 1.7 + n * 6)
    shade = brushed + stripe
    seam = ((x % 128) < 2) | ((y % 128) < 2)
    shade = np.where(seam, 0.38, shade)
    img = tint((150, 156, 162), shade)
    def draw(d, dx, dy):
        for cx in (10, 118, 138, 246):
            for cy in (10, 118, 138, 246):
                d.ellipse([cx + dx - 4, cy + dy - 4, cx + dx + 4, cy + dy + 4], fill=(110, 114, 120), outline=(60, 62, 66))
                d.ellipse([cx + dx - 2, cy + dy - 3, cx + dx + 1, cy + dy], fill=(200, 205, 210))
    return wrap_draw(img, draw)


def tex_hedge():
    """Dense leaves: lots of small blobs, darker in the gaps."""
    img = Image.new('RGB', (N, N), (22, 48, 20))
    rng = np.random.default_rng(60)
    leaves = [(rng.integers(0, N), rng.integers(0, N), rng.integers(4, 10), rng.uniform(0.6, 1.25)) for _ in range(900)]
    def draw(d, dx, dy):
        for x, y, r, s in leaves:
            c = (int(52 * s), int(110 * s), int(40 * s))
            d.ellipse([x + dx - r, y + dy - r * 0.7, x + dx + r, y + dy + r * 0.7], fill=c)
            d.ellipse([x + dx - r * 0.5, y + dy - r * 0.5, x + dx + r * 0.2, y + dy], fill=(min(255, c[0] + 30), min(255, c[1] + 40), min(255, c[2] + 20)))
    return wrap_draw(img, draw).filter(ImageFilter.GaussianBlur(0.5))


def tex_ice():
    n = noise(60, 70, 4)
    cr = ridged(45, 71, 3)
    v = 0.78 + 0.25 * n
    crack = np.clip(cr - 0.9, 0, 1) * 10
    r = 170 * v + 85 * crack
    g = 215 * v + 40 * crack
    b = 240 * v + 15 * crack
    a = np.full((N, N), 215.0)
    return rgb(r, g, b, a)


def tex_water():
    n = noise(40, 80, 4)
    c = ridged(28, 81, 3)
    caust = np.clip(c - 0.7, 0, 1) * 3.2
    r = 18 + 30 * n + 70 * caust
    g = 70 + 45 * n + 95 * caust
    b = 125 + 45 * n + 75 * caust
    a = np.full((N, N), 225.0)
    return rgb(r, g, b, a)


def tex_lava():
    """Dark crust with glowing cracks (hot yellow in the middle of each crack)."""
    crust = noise(36, 90, 4)
    cr = ridged(55, 91, 4)
    glow = np.clip((cr - 0.83) * 5.0, 0, 1)
    core = np.clip((cr - 0.94) * 14, 0, 1)
    r = 40 + 40 * crust + 215 * glow
    g = 18 + 14 * crust + 110 * glow + 120 * core
    b = 14 + 8 * crust + 20 * glow + 60 * core
    return rgb(r, g, b)


def tex_circuit():
    """Dark green board with copper traces, pads and chips."""
    base = noise(20, 100, 3)
    img = tint((26, 74, 52), 0.8 + 0.3 * base)
    rng = np.random.default_rng(101)
    traces = []
    for _ in range(26):
        x, y = rng.integers(0, 16) * 16, rng.integers(0, 16) * 16
        pts = [(x, y)]
        for _ in range(rng.integers(2, 5)):
            if rng.random() < 0.5:
                x += rng.integers(-5, 6) * 16
            else:
                y += rng.integers(-5, 6) * 16
            pts.append((x, y))
        traces.append(pts)
    chips = [(rng.integers(1, 14) * 16, rng.integers(1, 14) * 16, rng.integers(2, 4) * 8, rng.integers(1, 3) * 8) for _ in range(4)]
    def draw(d, dx, dy):
        for pts in traces:
            p = [(px + dx, py + dy) for px, py in pts]
            d.line(p, fill=(196, 150, 70), width=3)
            for px, py in (p[0], p[-1]):
                d.ellipse([px - 4, py - 4, px + 4, py + 4], fill=(220, 180, 90), outline=(120, 90, 40))
        for cx, cy, w, h in chips:
            d.rectangle([cx + dx - w, cy + dy - h, cx + dx + w, cy + dy + h], fill=(28, 30, 34), outline=(70, 74, 80))
            for k in range(-w + 4, w - 2, 6):
                d.line([cx + dx + k, cy + dy - h - 4, cx + dx + k, cy + dy - h], fill=(200, 200, 205), width=2)
                d.line([cx + dx + k, cy + dy + h, cx + dx + k, cy + dy + h + 4], fill=(200, 200, 205), width=2)
    return wrap_draw(img, draw)


def tex_pipes():
    """Horizontal pipes with flanges on a dark wall."""
    img = tint((44, 46, 50), 0.8 + 0.3 * noise(16, 110, 3))
    a = np.asarray(img).astype(float)
    y = np.arange(N)[:, None].repeat(N, 1)
    x = np.arange(N)[None, :].repeat(N, 0)
    pipes = [(32, 22, (150, 92, 60)), (96, 16, (120, 126, 132)), (160, 26, (150, 92, 60)), (226, 14, (100, 130, 110))]
    for cy, r, col in pipes:
        dy = (y - cy)
        inside = np.abs(dy) < r
        shade = np.sqrt(np.clip(1 - (dy / r) ** 2, 0, 1))  # round
        light = 0.35 + 0.75 * shade - 0.25 * (dy / r)
        for i in range(3):
            a[..., i] = np.where(inside, col[i] * light, a[..., i])
        for fx in (40, 168):  # flanges
            fl = inside & (np.abs(((x - fx + 128) % 256) - 128) < 5) | ((np.abs(dy) < r + 4) & (np.abs(((x - fx + 128) % 256) - 128) < 5))
            for i in range(3):
                a[..., i] = np.where(fl, col[i] * 0.55 * (0.8 + 0.4 * shade), a[..., i])
    return Image.fromarray(np.clip(a, 0, 255).astype(np.uint8), 'RGB')


TEXTURES = {
    'face': tex_face, 'rock': tex_rock, 'dirt': tex_dirt, 'stone': tex_stone, 'brick': tex_brick, 'ancient': tex_ancient,
    'wood': tex_wood, 'metal': tex_metal, 'hedge': tex_hedge, 'ice': tex_ice, 'water': tex_water, 'lava': tex_lava,
    'circuit': tex_circuit, 'pipes': tex_pipes,
}

if __name__ == '__main__':
    out = sys.argv[1] if len(sys.argv) > 1 else 'textures'
    os.makedirs(out, exist_ok=True)
    for name, fn in TEXTURES.items():
        img = fn()
        img.save(os.path.join(out, name + '.png'), optimize=True)
        print(name, img.mode, os.path.getsize(os.path.join(out, name + '.png')), 'bytes')
    # contact sheet (each tile 2x2 to show the seams are invisible)
    sheet = Image.new('RGB', (len(TEXTURES) * 140 + 10, 300), (12, 16, 24))
    d = ImageDraw.Draw(sheet)
    for i, name in enumerate(TEXTURES):
        t = Image.open(os.path.join(out, name + '.png')).convert('RGB').resize((64, 64))
        for yy in range(2):
            for xx in range(2):
                sheet.paste(t, (10 + i * 140 + xx * 64, 10 + yy * 64))
        d.text((10 + i * 140, 140), name, fill=(220, 220, 220))
    sheet.save(os.path.join(out, 'preview.png'))
