#!/usr/bin/env python3
"""Generate the textures of skin.milionar.modern (docs/plan/skin-milionar/240-modern.md).

Kodi draws no blur and no rounded corners itself, so the glass look is baked into textures:
  - glass: a translucent panel with a lighter top, a hairline edge that catches the light and a soft gloss; it sits
    over the blurred ambient light of the backdrop (hero.blur), so it reads as frosted glass;
  - 9-slice shapes (Kodi `border`): the corner radius stays sharp at any size; the border each one needs is in
    resources/skin.milionar.modern/compose.json next to the Estuary texture it replaces;
  - textures tinted by the skin (`colordiffuse`) are light grey with a white top: the accent keeps a sheen.
The cinematic Home keeps its texture names (gen_cinema_textures.build) with modern shapes. Run after changing a shape;
the PNGs are committed. Shapes only: every icon stays Estuary's own.
"""
import math
import os
import random

from PIL import Image, ImageChops, ImageDraw, ImageFilter

import gen_cinema_textures as cinema

MEDIA = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '..', 'resources', 'skin.milionar.modern', 'media')
RADIUS = 18                              # cards and buttons of the cinematic Home (border 20 in its XML)
PANEL_RADIUS = 28                        # dialogs and panels
TILE_RADIUS = 20                         # glass tiles and their focus (Estuary's flat panels, 48 px and taller)
NAVY = (14, 19, 33)


def mix(a, b, t):
    return tuple(int(round(x + (y - x) * t)) for x, y in zip(a, b))


def vertical(size, top, bottom):
    """RGBA gradient from `top` to `bottom` (RGBA tuples)."""
    w, h = size
    image = Image.new('RGBA', size)
    draw = ImageDraw.Draw(image)
    for y in range(h):
        draw.line(((0, y), (w, y)), fill=mix(top, bottom, y / max(1, h - 1)))
    return image


def masked(image, mask):
    image = image.copy()
    image.putalpha(ImageChops.multiply(image.getchannel('A'), mask))
    return image


def place(size, box, radius, corners=(True, True, True, True)):
    """Mask (L) of a rounded rectangle `box` (x0, y0, x1, y1) inside a canvas of `size`."""
    mask = Image.new('L', size, 0)
    mask.paste(cinema.rounded((box[2] - box[0], box[3] - box[1]), radius, corners), box[:2])
    return mask


def edge(size, box, radius, top_alpha, bottom_alpha, width=1):
    """Hairline along the inside of the rounded `box`: bright at the top, fading towards the bottom."""
    outer = place(size, box, radius)
    inner = place(size, (box[0] + width, box[1] + width, box[2] - width, box[3] - width), radius - width)
    ring = ImageChops.subtract(outer, inner)
    light = vertical(size, (255, 255, 255, top_alpha), (255, 255, 255, bottom_alpha))
    return masked(light, ring)


def gloss(size, box, radius, alpha, depth=0.45):
    """Soft highlight over the top part of the shape (the sheen of glass)."""
    x0, y0, x1, y1 = box
    height = max(1, int((y1 - y0) * depth))
    light = vertical((x1 - x0, height), (255, 255, 255, alpha), (255, 255, 255, 0))
    layer = Image.new('RGBA', size, (255, 255, 255, 0))
    layer.paste(light, (x0, y0))
    return masked(layer, place(size, box, radius))


def shadow(size, box, radius, alpha, blur, offset=(0, 0)):
    mask = place(size, tuple(v + offset[i % 2] for i, v in enumerate(box)), radius)
    mask = mask.filter(ImageFilter.GaussianBlur(blur)).point(lambda v: v * alpha // 255)
    image = Image.new('RGBA', size, (0, 0, 0, 0))
    image.putalpha(mask)
    return image


def glass(size, box, radius, top, bottom, edge_alpha=(46, 12), sheen=12, drop=None):
    """A glass panel: optional drop shadow, gradient body, gloss and a light-catching hairline."""
    layers = [shadow(size, box, radius, *drop) if drop else Image.new('RGBA', size, (0, 0, 0, 0))]
    body = Image.new('RGBA', size, (0, 0, 0, 0))
    body.paste(vertical((box[2] - box[0], box[3] - box[1]), top, bottom), box[:2])
    layers.append(masked(body, place(size, box, radius)))
    if sheen:
        layers.append(gloss(size, box, radius, sheen))
    layers.append(edge(size, box, radius, *edge_alpha))
    image = layers[0]
    for layer in layers[1:]:
        image = Image.alpha_composite(image, layer)
    return image


def lit(size, box, radius, base=226, glow=0, shine=255):
    """A light shape for `colordiffuse`: grey body, white top and an optional soft glow in the padding."""
    layers = [Image.new('RGBA', size, (255, 255, 255, 0))]
    if glow:
        halo = place(size, box, radius).filter(ImageFilter.GaussianBlur(glow)).point(lambda v: v * 110 // 255)
        layer = Image.new('RGBA', size, (255, 255, 255, 0))
        layer.putalpha(halo)
        layers.append(layer)
    body = Image.new('RGBA', size, (0, 0, 0, 0))
    body.paste(vertical((box[2] - box[0], box[3] - box[1]), (shine, shine, shine, 255), (base, base, base, 255)), box[:2])
    layers.append(masked(body, place(size, box, radius)))
    image = layers[0]
    for layer in layers[1:]:
        image = Image.alpha_composite(image, layer)
    return image


def ring(size, box, radius, width, glow, strength=120):
    """White focus ring on the edge of `box` with a soft outer glow (`strength`: its opacity next to the ring)."""
    outer = place(size, box, radius)
    inner = place(size, (box[0] + width, box[1] + width, box[2] - width, box[3] - width), radius - width)
    band = ImageChops.subtract(outer, inner)
    halo = ImageChops.subtract(outer.filter(ImageFilter.GaussianBlur(glow)), inner).point(lambda v: min(255, v * strength // 255))
    image = Image.new('RGBA', size, (255, 255, 255, 0))
    image.putalpha(ImageChops.lighter(band, halo))
    return image


def circle_ring(size=496, margin=56, width=6, glow=16, strength=300, overlap=2):
    """The focus ring of a round card with the cards' rich glow: twice the size of the card (192) and its frame's
    28 px (16 + gen_cinema.MODERN.glow) on each side, the ring outside the circle."""
    box = size - 2 * margin
    outer = Image.new('L', (size, size), 0)
    outer.paste(cinema.disc(box + 2 * width), (margin - width, margin - width))
    inner = Image.new('L', (size, size), 0)
    inner.paste(cinema.disc(box - 2 * overlap), (margin + overlap, margin + overlap))
    halo = ImageChops.subtract(outer.filter(ImageFilter.GaussianBlur(glow)), inner).point(lambda v: min(255, v * strength // 255))
    image = Image.new('RGBA', (size, size), (255, 255, 255, 0))
    image.putalpha(ImageChops.lighter(ImageChops.subtract(outer, inner), halo))
    return image


def circle_glow(size, pad, base=226, glow=10):
    box = (pad, pad, size - pad, size - pad)
    return lit((size, size), box, (size - 2 * pad) // 2, base=base, glow=glow)


def aurora(size=(960, 540), seed=7):
    """Default background: deep navy with soft colour light (no fanart behind it)."""
    w, h = size
    image = Image.new('RGB', size, (6, 9, 18))
    glow = Image.new('RGB', size, (0, 0, 0))
    draw = ImageDraw.Draw(glow)
    for (cx, cy, r, color) in ((0.18, 0.10, 0.55, (64, 52, 120)), (0.86, 0.22, 0.50, (20, 70, 110)),
                               (0.62, 0.95, 0.60, (120, 84, 30)), (0.05, 0.85, 0.40, (18, 52, 70))):
        draw.ellipse(((cx - r) * w, (cy - r) * w, (cx + r) * w, (cy + r) * w), fill=color)
    glow = glow.filter(ImageFilter.GaussianBlur(w * 0.12))
    image = ImageChops.add(image, glow)
    random.seed(seed)                     # a faint grain: no banding in the dark gradients
    grain = Image.new('L', size)
    grain.putdata([128 + random.randint(-3, 3) for _ in range(w * h)])
    return Image.merge('RGB', [ImageChops.add(c, grain, offset=-128) for c in image.split()])


def ambient_shade(size=(480, 270)):
    """Over the blurred backdrop: darker on the left (sidebar and hero text) and at the bottom (rows), a vignette."""
    w, h = size
    image = Image.new('RGBA', size)
    pixels = image.load()
    for y in range(h):
        for x in range(w):
            u, v = x / (w - 1.0), y / (h - 1.0)
            left = 0.40 * (1 - cinema.smooth(u / 0.7))
            bottom = 0.55 * cinema.smooth((v - 0.35) / 0.65)
            vignette = 0.35 * min(1.0, math.hypot(u - 0.6, v - 0.4) / 0.85) ** 2
            alpha = 1 - (1 - left) * (1 - bottom) * (1 - vignette)
            pixels[x, y] = (4, 6, 12, int(round(255 * min(0.92, 0.28 + 0.72 * alpha))))
    return image


def hero_scrim(size=(480, 270)):
    """Over the sharp backdrop of Home: darker behind the hero's text (left, upper part), so white text stays readable on
    a bright picture; it fades out well before the picture's centre."""
    w, h = size
    image = Image.new('RGBA', size)
    pixels = image.load()
    for y in range(h):
        for x in range(w):
            u, v = x / (w - 1.0), y / (h - 1.0)
            alpha = 0.62 * (1 - cinema.smooth((u - 0.30) / 0.40)) * (1 - 0.5 * cinema.smooth((v - 0.45) / 0.30))
            pixels[x, y] = (4, 6, 12, int(round(255 * alpha)))
    return image


def build():
    pad = 20                              # Estuary's buttons and dialogs keep 20 px around the shape
    images = {
        # dialogs and panels (Estuary dialog-bg-nobo / dialog-bg / lists/panel): nearly opaque, as Kodi cannot blur
        # what lies behind a dialog and sharp text showing through would be noise
        'modern/glass.png': glass((96, 96), (0, 0, 96, 96), TILE_RADIUS, (26, 33, 52, 240), (13, 17, 30, 246)),
        'modern/focus_tile.png': lit((96, 96), (0, 0, 96, 96), TILE_RADIUS),   # the focus fill of a glass tile
        'modern/glass_shadow.png': glass((120, 120), (pad, pad, 120 - pad, 120 - pad), PANEL_RADIUS,
                                         (26, 33, 52, 242), (13, 17, 30, 248), drop=(150, 7, (0, 4))),
        'modern/rail.png': rail(),
        # the sidebar of Home (floating glass: the collapsed rail and the expanded panel) and its menu focus
        'modern/sidebar.png': glass((120, 120), (pad, pad, 120 - pad, 120 - pad), 30, (24, 31, 52, 118),
                                    (10, 14, 26, 150), edge_alpha=(70, 16), sheen=14, drop=(170, 8, (0, 5))),
        'modern/menu_focus.png': lit((112, 64), (24, 6, 88, 58), 18, glow=0),   # 8 px inside the panel's glass
        # buttons (Estuary button / dialogbutton), list focus, thumbnail focus, round buttons
        'modern/button.png': glass((80, 80), (pad, pad, 80 - pad, 80 - pad), 12, (255, 255, 255, 26),
                                   (255, 255, 255, 12), edge_alpha=(60, 18), sheen=10),
        'modern/button_focus.png': lit((80, 80), (pad, pad, 80 - pad, 80 - pad), 12, glow=7),
        # list focus and a dialog row (tinted dialog_tint) share one shape: their edges line up
        'modern/focus.png': lit((64, 64), (0, 3, 64, 61), 14),
        'modern/row.png': lit((64, 64), (0, 3, 64, 61), 14, base=244),
        # the text field of the keyboard and the numeric dialog (Estuary button-alt-nofo): a darker inset well
        'modern/field.png': glass((80, 80), (12, 12, 68, 68), 12, (0, 0, 0, 70), (0, 0, 0, 46), edge_alpha=(64, 28),
                                  sheen=0),
        'modern/thumb_focus.png': ring((48, 48), (0, 0, 48, 48), 14, 4, 0.01),
        'modern/mask_poster.png': cinema.solid((500, 750), 2 * 16),   # Estuary's posters: 16 px inside the 20 px frame
        'modern/round_focus.png': circle_glow(120, pad),
        'modern/osd_focus.png': circle_glow(128, 8, glow=4),
        'modern/shadow.png': drop_shadow(),
        # the ambient background
        'modern/ambient_shade.png': ambient_shade(),
        'modern/hero_scrim.png': hero_scrim(),
    }
    # only the cinematic textures whose shape differs: the others come from the classic skin
    classic = cinema.build()
    images.update(('milionar/cinema/' + name, image) for name, image in cinema.build(
        radius=RADIUS,
        button=lit((64, 64), (0, 0, 64, 64), RADIUS, base=232),
        ghost=glass((64, 64), (0, 0, 64, 64), RADIUS, (22, 29, 48, 150), (12, 16, 28, 170), edge_alpha=(84, 34),
                    sheen=16),
        card=glass((64, 64), (0, 0, 64, 64), RADIUS, (26, 33, 54, 214), (14, 19, 34, 228), edge_alpha=(40, 10),
                   sheen=10),
        # the card's focus: a 3 px ring and a rich glow that fades out inside the frame's 25 px (16 + gen_cinema.MODERN
        # .glow; the rows keep room for the lifted card and its glow, gen_cinema.room)
        frame=ring((120, 120), (25, 25, 95, 95), RADIUS + 3, 3, 8, strength=300),
        # the labels on a poster (a season's name and episodes) stay readable on a bright picture
        poster_shade=(0.45, 0.94),
        ring=circle_ring(),
        # the floating sidebar leaves the backdrop's left edge in view (Home, expanded): it fades in from nothing
        fade=cinema.hero_fade(edge=0.08)).items()
        if image.tobytes() != classic[name].tobytes())
    return images


def rail(size=(64, 64)):
    """The side panel: translucent navy, lighter towards its right edge, which catches the light."""
    w, h = size
    image = Image.new('RGBA', size)
    pixels = image.load()
    for x in range(w):
        t = x / (w - 1.0)
        for y in range(h):
            pixels[x, y] = mix((10, 13, 24, 210), (20, 26, 44, 196), t)
    draw = ImageDraw.Draw(image)
    draw.line(((w - 1, 0), (w - 1, h)), fill=(255, 255, 255, 46))
    draw.line(((w - 2, 0), (w - 2, h)), fill=(255, 255, 255, 12))
    return image


def drop_shadow(size=80, content=(17, 17, 63, 63)):
    """Estuary's shadow frame (border 21, no infill) with a softer, deeper drop."""
    image = shadow((size, size), content, 18, 150, 6, offset=(0, 3))   # rounded: no square corners around glass
    cut = Image.new('L', (size, size), 0)
    ImageDraw.Draw(cut).rectangle((content[0], content[1], content[2] - 1, content[3] - 1), fill=255)
    image.putalpha(ImageChops.subtract(image.getchannel('A'), cut))
    return image


if __name__ == '__main__':
    cinema.save(build(), MEDIA)
    background = aurora()
    path = os.path.join(MEDIA, 'modern', 'aurora.jpg')
    background.save(path, quality=88, optimize=True, progressive=True)
    print('wrote modern/aurora.jpg', background.size)
