"""Regenerate the PWA icon set from the Navi logo geometry.

The mark is defined once, in `webclient/public/images/logo.svg` (a 256-unit
viewBox): an open circle arc (r=90, stroke 8, dash 520/40 of the circumference
— a 331 deg arc whose opening sits just above 3 o'clock) plus a rounded plus
(half-arm 40, stroke 10). The Android adaptive icon
(`android-client/.../drawable/ic_launcher_foreground.xml`) draws the same mark
with the same proportions on its own 108-unit canvas.

This script redraws the mark with Pillow (already a project dependency, so no
SVG rasterizer is needed) and writes the sizes `manifest.webmanifest` and
`index.html` reference.

`INK` below is the fraction of the canvas spanned by the mark's outer circle,
i.e. by the arc. Every target uses 0.734 — the mark exactly as `logo.svg`
places it in its 256-unit box, which is also how much of the launcher tile the
mark covers in the Android app icon:

  * "any" icons and the apple-touch-icon fill their tile, as before;
  * maskable icons use the same 0.734. Chrome crops a maskable icon to a mask
    no smaller than a circle of 80% of the canvas; the mark's outer radius is
    36.7% of the canvas, so it stays inside that circle with room to spare.
    Earlier revisions of the maskable files were generated at ~0.21, which left
    the mark looking like a fragment of the logo rather than the logo.

Usage:
    .venv/bin/python scripts/gen_pwa_icons.py           # rewrite the icons
    .venv/bin/python scripts/gen_pwa_icons.py --check   # measure what is on disk
"""

from __future__ import annotations

import argparse
import math
from pathlib import Path

from PIL import Image, ImageDraw

IMAGES = Path(__file__).resolve().parents[1] / "webclient" / "public" / "images"

# --- logo.svg, in its own 256-unit viewBox --------------------------------
ARC_R = 90.0
ARC_STROKE = 8.0
ARC_DASH = 520.0  # of a 2*pi*90 = 565.49 circumference
CROSS_HALF = 40.0
CROSS_STROKE = 10.0

ARC_SWEEP = 360.0 * ARC_DASH / (2 * math.pi * ARC_R)
INK_UNITS = 2 * (ARC_R + ARC_STROKE / 2)  # 188

BG = (0x16, 0x16, 0x1E)  # manifest background_color / theme_color
FG = (0x00, 0xB5, 0xD8)

INK = 0.734
SUPERSAMPLE = 8

TARGETS = (
    ("icon-180.png", 180, INK),
    ("icon-192.png", 192, INK),
    ("icon-512.png", 512, INK),
    ("icon-maskable-192.png", 192, INK),
    ("icon-maskable-512.png", 512, INK),
)


def _cap(draw: ImageDraw.ImageDraw, cx: float, cy: float, x: float, y: float, r: float) -> None:
    """Round line cap: a filled circle of radius r centred on (x, y)."""
    draw.ellipse([x - r, y - r, x + r, y + r], fill=FG)


def render(size: int, ink: float) -> Image.Image:
    """Render the mark at `size` px, its outer circle spanning `ink` of the canvas."""
    su = size * SUPERSAMPLE
    scale = ink * su / INK_UNITS  # logo.svg units -> supersampled pixels
    c = su / 2

    r = ARC_R * scale
    arc_w = max(1, round(ARC_STROKE * scale))
    half = CROSS_HALF * scale
    cross_w = max(1, round(CROSS_STROKE * scale))

    img = Image.new("RGB", (su, su), BG)
    d = ImageDraw.Draw(img)

    # Pillow strokes a thick arc inward from the given bbox, so pass the outer
    # radius and let it fill towards the centreline.
    outer = r + arc_w / 2
    d.arc(
        [c - outer, c - outer, c + outer, c + outer],
        start=0,
        end=ARC_SWEEP,
        fill=FG,
        width=arc_w,
    )  # 0 deg is 3 o'clock, angles grow clockwise — same as the SVG dash start
    for deg in (0.0, ARC_SWEEP):
        a = math.radians(deg)
        _cap(d, c, c, c + r * math.cos(a), c + r * math.sin(a), arc_w / 2)

    d.line([c - half, c, c + half, c], fill=FG, width=cross_w)
    d.line([c, c - half, c, c + half], fill=FG, width=cross_w)
    for x, y in ((c - half, c), (c + half, c), (c, c - half), (c, c + half)):
        _cap(d, c, c, x, y, cross_w / 2)

    return img.resize((size, size), Image.LANCZOS)


def measure(path: Path) -> tuple[int, int, float]:
    """Ink bounding box and its fraction of the canvas, for a written icon.

    Edges are counted at 50% coverage rather than "any pixel that is not the
    background" — an antialiased edge fades out over a pixel or two, and the
    loose threshold would report the mark a pixel wider on each side.
    """
    im = Image.open(path).convert("RGB")
    w, h = im.size
    px = im.load()
    bg = px[0, 0]
    threshold = 0.5 * sum(abs(a - b) for a, b in zip(bg, FG))
    minx, miny, maxx, maxy = w, h, -1, -1
    for y in range(h):
        for x in range(w):
            p = px[x, y]
            if sum(abs(a - b) for a, b in zip(bg, p)) >= threshold:
                minx, miny = min(minx, x), min(miny, y)
                maxx, maxy = max(maxx, x), max(maxy, y)
    if maxx < 0:
        raise SystemExit(f"{path.name}: no ink found")
    return maxx - minx + 1, maxy - miny + 1, (maxx - minx + 1) / w


def verdict(w: int, h: int, expected: int) -> str:
    """`ok` when the mark covers the canvas as asked — 1 px of slack for the
    antialiased edge, which lands either side of the 50% line by a fraction."""
    return "ok" if abs(w - expected) <= 1 and abs(h - expected) <= 1 else "SUSPECT"


def main() -> None:
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--check", action="store_true", help="measure on-disk icons, write nothing")
    args = ap.parse_args()

    for name, size, ink in TARGETS:
        path = IMAGES / name
        expected = round(ink * size)
        if not args.check:
            render(size, ink).save(path, optimize=True)
        w, h, frac = measure(path)
        print(f"{name:24s} {size}x{size}  ink {w}x{h} = {frac:.1%}  expected {ink:.1%}  {verdict(w, h, expected)}")


if __name__ == "__main__":
    main()
