"""Draws the shared head's face textures: one per expression, a dark visor flush in the head's surface with the drawn eyes on it.

Run with python3 scripts/mascots/faces.py. The head is a UV sphere whose texture is an equirectangular map, the face at
u = 0.25 (looking down -Y), the right ear at u = 0.5, the top at v = 1. Every voice wears the same head and the same visor,
its width and height in the head's own metres in toonBody.json, measured off the butler's drawing; no expression changes them.
"""
import json
import math
from pathlib import Path

import numpy as np
from PIL import Image, ImageDraw

HERE = Path(__file__).resolve().parents[2]
OUT = HERE / "src/web/public/voices/models"
WIDTH, HEIGHT = 2048, 1024
SKIN = (248, 240, 227)
LENS = (18, 19, 38)
RIM = (36, 38, 66)
# The shared head's radii across and up, in metres, as scripts/mascots/butler.py builds it from the same file.
HEAD = json.loads((HERE / "src/web/src/domain/toonBody.json").read_text())["head"]
ACROSS, UP = HEAD["radius"] * HEAD["scale"][0], HEAD["radius"] * HEAD["scale"][2]
# How the visor's corners round, as part of its height; the eyes a little over a quarter of its width to each side and below its middle.
CORNER = 0.3
# Where the screen's middle sits below the head's middle, as part of its height.
MIDDLE = 0.045
EYES_APART, EYES_DOWN = 0.284, 0.075


EYES_WIDTH, EYES_HEIGHT = 1024, 512
PX = EYES_WIDTH / 360.0
EYE_AT = (23.0, -7.5)
WHITE = (246, 243, 234, 255)
PUPIL = (26, 28, 44, 255)


def eye(draw_shape, slant=0, size=160):
    """One eye drawn by draw_shape on a square canvas of size px, then turned by slant degrees."""
    layer = Image.new("RGBA", (size, size), (0, 0, 0, 0))
    draw_shape(ImageDraw.Draw(layer), size / 2, size / 2)
    return layer.rotate(slant, resample=Image.BICUBIC)


def crescent(draw, x, y):
    """The drawn butler's lidded eye: the open oval with its lid lowered a third, alert and cheerful."""
    draw.ellipse((x - 17, y - 31, x + 17, y + 31), fill=WHITE)
    draw.rectangle((x - 20, y - 40, x + 20, y - 17), fill=(0, 0, 0, 0))


def oval(draw, x, y, w=21, h=35):
    draw.ellipse((x - w, y - h, x + w, y + h), fill=WHITE)


def pupil(dx, dy, r=6):
    def shape(draw, x, y):
        oval(draw, x, y)
        draw.ellipse((x + dx - r, y + dy - r, x + dx + r, y + dy + r), fill=PUPIL)
    return shape


# The happy eye as drawn: a rounded bow, as high as half its width, a little thinner over its top than at its ends,
# each end rounded off.
def arc(draw, x, y, width=82, rise=0.48, top=0.14, ends=0.18):
    import math
    outer_x, outer_y = width / 2, width * rise
    inner_x, inner_y = outer_x - width * ends, outer_y - width * top
    centre_y = y + outer_y * 0.55
    around = [math.radians(a) for a in range(195, 346, 5)]
    edge = [(x + outer_x * math.cos(a), centre_y + outer_y * math.sin(a)) for a in around]
    inside = [(x + inner_x * math.cos(a), centre_y + inner_y * math.sin(a)) for a in reversed(around)]
    draw.polygon(edge + inside, fill=WHITE)
    for (ox, oy), (ix, iy) in ((edge[0], inside[-1]), (edge[-1], inside[0])):
        r = math.hypot(ox - ix, oy - iy) / 2
        cx, cy = (ox + ix) / 2, (oy + iy) / 2
        draw.ellipse((cx - r, cy - r, cx + r, cy + r), fill=WHITE)


def line(draw, x, y):
    draw.rounded_rectangle((x - 20, y - 5, x + 20, y + 5), radius=5, fill=WHITE)


EYES = {
    "rest": [(oval, 0), (oval, 0)],
    "open": [(oval, 0), (oval, 0)],
    "happy": [(arc, 0), (arc, 0)],
    "sad": [(crescent, 16), (crescent, 16)],
    "angry": [(line, 20), (line, 20)],
    "scared": [(pupil(0, -5, 5), 0), (pupil(0, -5, 5), 0)],
    "surprised": [(pupil(0, 0), 0), (pupil(0, 0), 0)],
    "thinking": [(pupil(6, -9), 0), (pupil(6, -9), 0)],
    "sleepy": [(line, 0), (line, 0)],
    "wink": [(crescent, -10), (line, 0)],
}


# The longitude, in degrees, at which the squared head (butler.py, squared) reaches across to part of its half width: the head's
# sphere is pushed out at each longitude to a superellipse of the head's power, so the inverse is found by halving.
def longitude(across, power=HEAD["square"]):
    reach = lambda t: math.sin(t) * (math.sin(t) ** power + math.cos(t) ** power) ** (-1 / power)
    low, high = 0.0, math.pi / 2
    for _ in range(40):
        middle = (low + high) / 2
        low, high = (middle, high) if reach(middle) < across else (low, middle)
    return math.degrees(low)


# The face as the map draws it: its visor (a centre, half width, half height and corner, in degrees of longitude and latitude) and
# where its eyes sit.
def face_of():
    visor = HEAD["visor"]
    lon = lambda x: longitude(min(x / ACROSS, 1))
    lat = lambda z: math.degrees(math.asin(max(min(z / UP, 1), -1)))
    middle, half_down = -MIDDLE * visor["height"], visor["height"] / 2
    corner = math.degrees(CORNER * visor["height"] / ACROSS)
    pane = (0.0, (lat(middle + half_down) + lat(middle - half_down)) / 2, lon(visor["width"] / 2), (lat(middle + half_down) - lat(middle - half_down)) / 2, corner)
    return {"panes": [pane], "eyes": (lon(EYES_APART * visor["width"]), lat(middle - EYES_DOWN * visor["height"]))}


def eyes_of(mood, face):
    """Both eyes on a transparent layer the size of the map, the left eye on the viewer's left."""
    layer = Image.new("RGBA", (EYES_WIDTH, EYES_HEIGHT), (0, 0, 0, 0))
    for (shape, slant), side in zip(EYES[mood], (-1, 1)):
        made = eye(shape, slant * side)
        u = 0.25 * EYES_WIDTH + side * face["eyes"][0] * PX
        v = (0.5 - face["eyes"][1] / 180.0) * EYES_HEIGHT
        layer.alpha_composite(made, (int(u - made.width / 2), int(v - made.height / 2)))
    return layer.resize((WIDTH, HEIGHT), Image.LANCZOS)


def panel(panes):
    """The visor as a mask over the map: 1 inside any of its rounded rectangles of longitude and latitude, hard at the edge."""
    u = (np.arange(WIDTH) + 0.5) / WIDTH
    v = (np.arange(HEIGHT) + 0.5) / HEIGHT
    lon, lat = np.meshgrid((u - 0.25) * 360.0, (0.5 - v) * 180.0)
    mask = np.zeros_like(lon)
    for centre_lon, centre_lat, half_across, half_down, corner in panes:
        dx = np.maximum(np.abs(lon - centre_lon) - (half_across - corner), 0)
        dy = np.maximum(np.abs(lat - centre_lat) - (half_down - corner), 0)
        mask = np.maximum(mask, np.clip(0.5 - (np.sqrt(dx * dx + dy * dy) - corner) / 0.5, 0, 1))
    return mask


def texture(mood, face):
    mask = panel(face["panes"])
    base = np.empty((HEIGHT, WIDTH, 3), dtype=np.float32)
    base[...] = SKIN
    lens = np.empty_like(base)
    lens[...] = LENS
    # A thin lighter rim just inside the edge; the drawn visor has no glass highlight.
    edge = np.clip(1 - np.abs(mask - 0.5) / 0.5, 0, 1) ** 4
    lens = lens * (1 - edge[..., None] * 0.6) + np.array(RIM, dtype=np.float32) * edge[..., None] * 0.6
    picture = base * (1 - mask[..., None]) + lens * mask[..., None]
    image = Image.fromarray(picture.astype(np.uint8), "RGB").convert("RGBA")
    image.alpha_composite(eyes_of(mood, face))
    return image.convert("RGB")


if __name__ == "__main__":
    OUT.mkdir(parents=True, exist_ok=True)
    face = face_of()
    for mood in EYES:
        texture(mood, face).save(OUT / f"face_{mood}.png", optimize=True)
    print("drew", ", ".join(EYES))
