#!/usr/bin/env python3
"""Render the classroom's idle desktop background.

This is a STAND-IN. The classroom's chosen wallpaper is an illustrated
landscape that is not in this repository; when it arrives it replaces the
output of this script at the same path and nothing else changes.

It exists so the desktop can be judged in the right palette rather than
against the bright confetti background it is replacing: sage, teal, blue
and lavender, a river leading into the picture, and a large quiet area in
the upper middle where nothing happens.

No image library is installed on the development host, so the PNG is
written by hand — zlib and struct are enough for a truecolour image.
"""

import math
import pathlib
import struct
import zlib

WIDTH = 1920
HEIGHT = 1080
OUT = pathlib.Path(__file__).resolve().parent.parent / "config" / "wallpapers"


def mix(a, b, t):
    t = 0.0 if t < 0 else 1.0 if t > 1 else t
    return tuple(a[i] + (b[i] - a[i]) * t for i in range(3))


def ease(t):
    """Smoothstep. Bands in a gradient are the one thing that would read as
    a rendering fault rather than as a picture."""
    t = 0.0 if t < 0 else 1.0 if t > 1 else t
    return t * t * (3 - 2 * t)


# The palette, top of the sky downwards.
SKY = [
    (0.00, (34, 38, 58)),      # deep lavender-slate
    (0.30, (58, 68, 96)),      # lavender
    (0.55, (86, 108, 130)),    # blue
    (0.78, (139, 165, 168)),   # sage haze at the horizon
    (1.00, (178, 198, 192)),
]

HORIZON = 0.56          # where the land starts, as a fraction of the height
GLOW_X, GLOW_Y = 0.56, 0.545
GLOW = (232, 232, 214)

RIDGES = [
    # (base height, amplitude, wavelength, phase, colour, haze)
    (0.575, 0.028, 2.1, 0.4, (74, 96, 110), 0.55),
    (0.615, 0.040, 1.5, 2.2, (60, 84, 92), 0.34),
    (0.665, 0.052, 1.1, 4.1, (48, 72, 74), 0.18),
]

RIVER_TOP = 0.585
RIVER_COLOUR = (96, 140, 146)
RIVER_LIGHT = (186, 212, 202)

BANK = (34, 52, 48)       # the sage foreground the river runs between


def sky_colour(y):
    t = y / HEIGHT
    for i in range(len(SKY) - 1):
        t0, c0 = SKY[i]
        t1, c1 = SKY[i + 1]
        if t <= t1 or i == len(SKY) - 2:
            return mix(c0, c1, ease((t - t0) / (t1 - t0)))
    return SKY[-1][1]


def river_edges(y):
    """The river narrows towards the horizon and opens towards the viewer,
    which is what makes it lead into the picture rather than across it."""
    t = (y / HEIGHT - RIVER_TOP) / (1 - RIVER_TOP)
    if t < 0:
        return None
    t = ease(t)
    centre = 0.56 - 0.14 * t * t
    half = 0.004 + 0.30 * t * t
    return (centre - half) * WIDTH, (centre + half) * WIDTH


def render():
    rows = []
    for y in range(HEIGHT):
        base = sky_colour(y)
        row = bytearray()
        edges = river_edges(y)
        yn = y / HEIGHT

        # Ridge heights for this column are cheap to evaluate, so the land
        # is computed per pixel rather than filled as polygons.
        for x in range(WIDTH):
            xn = x / WIDTH
            r, g, b = base

            # A soft light low in the sky, off centre. It is the only
            # bright thing in the picture.
            dx = (xn - GLOW_X) * 1.7
            dy = yn - GLOW_Y
            d = math.sqrt(dx * dx + dy * dy)
            glow = math.exp(-(d * d) / 0.055) * 0.55
            if glow > 0.002:
                r, g, b = mix((r, g, b), GLOW, glow)

            land = None
            for height, amp, wavelength, phase, colour, haze in RIDGES:
                ridge = height - amp * (
                    0.6 * math.sin(xn * math.pi * 2 * wavelength + phase)
                    + 0.4 * math.sin(xn * math.pi * 2 * wavelength * 2.3 + phase * 1.7)
                )
                if yn >= ridge:
                    # Nearer ridges are darker and less hazy, which is the
                    # whole of the depth in this picture.
                    shade = mix(colour, (r, g, b), haze)
                    depth = ease((yn - ridge) / 0.16)
                    land = mix(shade, tuple(c * 0.82 for c in shade), depth)

            if land is not None:
                r, g, b = land

            if edges is not None:
                left, right = edges
                if left <= x <= right:
                    width = max(right - left, 1.0)
                    across = (x - left) / width
                    # Light gathers down the middle of the water and the
                    # banks stay dark, so the river reads as a surface.
                    sheen = math.exp(-((across - 0.5) ** 2) / 0.06)
                    ripple = 0.5 + 0.5 * math.sin(
                        yn * 130 + math.sin(xn * 18) * 1.4
                    )
                    water = mix(RIVER_COLOUR, RIVER_LIGHT, 0.30 * sheen + 0.10 * ripple)
                    water = mix(water, (r, g, b), max(0.0, 0.45 - 0.45 * ease((yn - RIVER_TOP) / 0.25)))
                    edge = min(across, 1 - across)
                    r, g, b = mix(BANK, water, ease(edge / 0.05))
                elif yn > RIVER_TOP + 0.10:
                    near = ease((yn - RIVER_TOP - 0.10) / 0.34)
                    r, g, b = mix((r, g, b), BANK, near * 0.85)

            # The bottom of the screen carries the bar. Settling it down
            # keeps the panel legible without putting a band across the
            # picture.
            if yn > 0.78:
                r, g, b = mix((r, g, b), (16, 24, 28), ease((yn - 0.78) / 0.24) * 0.62)

            row += bytes((int(r + 0.5) & 255, int(g + 0.5) & 255, int(b + 0.5) & 255))

        rows.append(bytes(row))
    return rows


def write_png(path, rows):
    raw = b"".join(b"\x00" + row for row in rows)

    def chunk(tag, data):
        payload = tag + data
        return (
            struct.pack(">I", len(data))
            + payload
            + struct.pack(">I", zlib.crc32(payload) & 0xFFFFFFFF)
        )

    png = b"\x89PNG\r\n\x1a\n"
    png += chunk(b"IHDR", struct.pack(">IIBBBBB", WIDTH, HEIGHT, 8, 2, 0, 0, 0))
    png += chunk(b"IDAT", zlib.compress(raw, 9))
    png += chunk(b"IEND", b"")
    path.write_bytes(png)


if __name__ == "__main__":
    OUT.mkdir(parents=True, exist_ok=True)
    target = OUT / "classroom-calm.png"
    write_png(target, render())
    print(f"wrote {target} ({target.stat().st_size} bytes)")
