"""Generate the Oorank app tile, as vector and as raster.

The mark is a score gauge — the same audit meter the plugin shows on a page —
holding the AI spark. Its opening is deliberately narrow. A wide gap reads as a
"C", which is what the mark inherited from the plugin's former name; a narrow
one keeps the gauge legible as a meter while the ring still reads as the "O"
the name starts with.

`icon.svg` is the real asset: wp.org prefers it over the PNGs and it stays
sharp at any size. The PNGs are the fallback for wherever the SVG is not
picked up, and are deliberately rendered far above the size their filenames
claim — those names are fixed by the directory, the resolution is not.

The same geometry is drawn by hand in `../admin/app/ui/Logo.jsx` (256 units)
and `../admin/images/menu-icon.svg` (24 units). Change the numbers here and
`--emit-paths` will print the replacements for both.

Usage:
    python3 make-icon.py [--variant a|b|c] [--emit-paths]
"""

import argparse
import math

from PIL import Image, ImageDraw

CANVAS = 256         # Design units. Every constant below is in these.
SS = 3               # Supersample factor before the final downsample.

TILE_FROM, TILE_TO = (0x63, 0x66, 0xF1), (0x37, 0x30, 0xA4)
ARC_FROM, ARC_TO = (0xF1, 0xF2, 0xFF), (0xFF, 0xFF, 0xFF)
SPARK_FROM, SPARK_TO = (0xCF, 0xFA, 0xFE), (0x22, 0xD3, 0xEE)

CORNER = 62          # Tile corner radius.
GAP_CENTRE = -45     # Degrees clockwise from 3 o'clock. Top right.

# The opening was 120 degrees before the rename, which is why the mark read as
# a "C". Each variant is a different answer to how much meter to keep.
VARIANTS = {
    'a': {'radius': 70, 'stroke': 24, 'gap': 30, 'spark': 0.94, 'track': True},
    'b': {'radius': 72, 'stroke': 19, 'gap': 20, 'spark': 0.88, 'track': True},
    'c': {'radius': 70, 'stroke': 24, 'gap': 0, 'spark': 0.94, 'track': False},
    'd': {'radius': 70, 'stroke': 24, 'gap': 30, 'spark': 0.94, 'track': False},
    'e': {'radius': 72, 'stroke': 19, 'gap': 20, 'spark': 0.88, 'track': False},
}

# The unfilled part of the meter, drawn only across the opening. A full circle
# underneath would be identical everywhere the arc covers it, and along the two
# round caps the two strokes antialias against each other into a visible seam.
TRACK_OPACITY = 0.18
TRACK_BLEED = 6      # Degrees tucked under each cap, so no sliver shows.

# Spark control points at scale 1.0: quadratic beziers, (start, control, end).
SPARK_PATH = [
    ((128, 87), (133.7, 118.59), (158, 126)),
    ((158, 126), (133.7, 133.41), (128, 165)),
    ((128, 165), (122.3, 133.41), (98, 126)),
    ((98, 126), (122.3, 118.59), (128, 87)),
]


def scaled_spark(scale):
    """Spark control points scaled about the tile centre."""
    return [
        tuple((128 + (x - 128) * scale, 128 + (y - 128) * scale) for x, y in seg)
        for seg in SPARK_PATH
    ]


def arc_endpoints(radius, gap, centre=128):
    """Start and end points of the gauge arc, in design units."""
    start_deg = GAP_CENTRE + gap / 2
    end_deg = GAP_CENTRE - gap / 2
    pt = lambda d: (
        centre + radius * math.cos(math.radians(d)),
        centre + radius * math.sin(math.radians(d)),
    )
    return pt(start_deg), pt(end_deg), start_deg, end_deg


# --------------------------------------------------------------------------
# Vector
# --------------------------------------------------------------------------

def svg(v):
    """The tile as standalone SVG — the asset wp.org prefers."""
    radius, stroke, gap, spark_scale = v['radius'], v['stroke'], v['gap'], v['spark']
    track = ''

    if gap > 0:
        (sx, sy), (ex, ey), _, _ = arc_endpoints(radius, gap)
        # The arc is the long way round, hence large-arc-flag 1, sweep 1.
        meter = (
            f'<path d="M {sx:.2f} {sy:.2f} A {radius} {radius} 0 1 1 {ex:.2f} {ey:.2f}" '
            f'fill="none" stroke="url(#oorank-arc)" stroke-width="{stroke}" stroke-linecap="round"/>'
        )
    else:
        meter = (
            f'<circle cx="128" cy="128" r="{radius}" fill="none" '
            f'stroke="url(#oorank-arc)" stroke-width="{stroke}"/>'
        )

    if gap > 0 and v['track']:
        (tx, ty), (ux, uy), _, _ = arc_endpoints(radius, -(gap + TRACK_BLEED * 2))
        track = (
            f'<path d="M {ux:.2f} {uy:.2f} A {radius} {radius} 0 0 1 {tx:.2f} {ty:.2f}" '
            f'fill="none" stroke="#ffffff" stroke-opacity="{TRACK_OPACITY}" stroke-width="{stroke}"/>'
        )
    else:
        track = ''

    s = scaled_spark(spark_scale)
    d = f'M {s[0][0][0]:.2f} {s[0][0][1]:.2f} ' + ' '.join(
        f'Q {c[0]:.2f} {c[1]:.2f} {e[0]:.2f} {e[1]:.2f}' for _, c, e in s
    ) + ' Z'

    hexed = lambda c: '#%02X%02X%02X' % c
    return f'''<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256" role="img" aria-label="Oorank">
	<defs>
		<linearGradient id="oorank-tile" x1="0.05" y1="0" x2="0.95" y2="1">
			<stop offset="0" stop-color="{hexed(TILE_FROM)}"/>
			<stop offset="1" stop-color="{hexed(TILE_TO)}"/>
		</linearGradient>
		<linearGradient id="oorank-arc" x1="0" y1="1" x2="1" y2="0">
			<stop offset="0" stop-color="{hexed(ARC_FROM)}"/>
			<stop offset="1" stop-color="{hexed(ARC_TO)}"/>
		</linearGradient>
		<linearGradient id="oorank-spark" x1="0" y1="0" x2="0.5" y2="1">
			<stop offset="0" stop-color="{hexed(SPARK_FROM)}"/>
			<stop offset="1" stop-color="{hexed(SPARK_TO)}"/>
		</linearGradient>
		<clipPath id="oorank-tile-clip">
			<rect width="256" height="256" rx="{CORNER}"/>
		</clipPath>
	</defs>

	<g clip-path="url(#oorank-tile-clip)">
		<rect width="256" height="256" fill="url(#oorank-tile)"/>
		{track}
		{meter}
		<path d="{d}" fill="url(#oorank-spark)"/>
	</g>
</svg>
'''


# --------------------------------------------------------------------------
# Raster
# --------------------------------------------------------------------------

def linear_gradient(size, start, end, frm, to):
    """RGB image blended along the line from `start` to `end` (unit coords)."""
    img = Image.new('RGB', (size, size))
    px = img.load()
    dx, dy = end[0] - start[0], end[1] - start[1]
    denom = dx * dx + dy * dy
    for y in range(size):
        fy = y / (size - 1)
        for x in range(size):
            fx = x / (size - 1)
            t = ((fx - start[0]) * dx + (fy - start[1]) * dy) / denom
            t = 0.0 if t < 0 else 1.0 if t > 1 else t
            px[x, y] = tuple(int(frm[i] + (to[i] - frm[i]) * t) for i in range(3))
    return img


def ring_mask(size, v, opacity, gap, round_caps):
    """Alpha mask holding one stroked arc, with optional round caps."""
    radius, stroke = v['radius'], v['stroke']
    mask = Image.new('L', (size, size), 0)
    d = ImageDraw.Draw(mask)
    s = size / CANVAS
    fill = int(round(255 * opacity))

    # Pillow strokes an arc *inward* from the bounding box, so the box has to be
    # the outer edge of the stroke for the path itself to sit on the radius. Get
    # this wrong and the round caps below — placed on the radius — bulge off the
    # ring as two loose beads.
    outer = (radius + stroke / 2) * s
    box = [128 * s - outer, 128 * s - outer, 128 * s + outer, 128 * s + outer]

    if gap < 0:
        _, _, start_deg, end_deg = arc_endpoints(radius, -gap)
        d.arc(box, end_deg, start_deg, fill=fill, width=round(stroke * s))
    elif gap > 0:
        _, _, start_deg, end_deg = arc_endpoints(radius, gap)
        d.arc(box, start_deg, end_deg + 360, fill=fill, width=round(stroke * s))
        if round_caps:
            cap = stroke * s / 2
            for deg in (start_deg, end_deg):
                a = math.radians(deg)
                cx = (128 + radius * math.cos(a)) * s
                cy = (128 + radius * math.sin(a)) * s
                d.ellipse([cx - cap, cy - cap, cx + cap, cy + cap], fill=fill)
    else:
        d.ellipse(box, outline=fill, width=round(stroke * s))
    return mask


def spark_mask(size, scale):
    """Alpha mask holding the four-lobed spark, flattened from its beziers."""
    pts = []
    s = size / CANVAS
    for p0, p1, p2 in scaled_spark(scale):
        for i in range(64):
            t = i / 64
            u = 1 - t
            pts.append((
                (u * u * p0[0] + 2 * u * t * p1[0] + t * t * p2[0]) * s,
                (u * u * p0[1] + 2 * u * t * p1[1] + t * t * p2[1]) * s,
            ))
    mask = Image.new('L', (size, size), 0)
    ImageDraw.Draw(mask).polygon(pts, fill=255)
    return mask


def render(v, out_size):
    """The tile as RGBA, rendered at `out_size` via a supersampled pass."""
    size = out_size * SS
    s = size / CANVAS

    img = linear_gradient(size, (0.05, 0.0), (0.95, 1.0), TILE_FROM, TILE_TO).convert('RGBA')

    if v['gap'] > 0 and v['track']:
        white = Image.new('RGBA', (size, size), (255, 255, 255, 255))
        img.paste(white, (0, 0), ring_mask(size, v, TRACK_OPACITY, -(v['gap'] + TRACK_BLEED * 2), round_caps=False))

    arc = linear_gradient(size, (0.0, 1.0), (1.0, 0.0), ARC_FROM, ARC_TO).convert('RGBA')
    img.paste(arc, (0, 0), ring_mask(size, v, 1.0, v['gap'], round_caps=True))

    spark = linear_gradient(size, (0.0, 0.0), (0.5, 1.0), SPARK_FROM, SPARK_TO).convert('RGBA')
    img.paste(spark, (0, 0), spark_mask(size, v['spark']))

    corner = Image.new('L', (size, size), 0)
    ImageDraw.Draw(corner).rounded_rectangle([0, 0, size - 1, size - 1], radius=CORNER * s, fill=255)
    img.putalpha(corner)

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


def emit_paths(v):
    """The arc path for the two hand-drawn copies of this geometry."""
    radius, gap = v['radius'], v['gap']
    for name, units in (('admin/app/ui/Logo.jsx', 256), ('admin/images/menu-icon.svg', 24)):
        f = units / CANVAS
        rr = round(radius * f, 2)
        (sx, sy), (ex, ey), _, _ = arc_endpoints(rr, gap, centre=128 * f)
        print(f'{name}:')
        if gap > 0:
            print(f'  d="M {sx:.2f} {sy:.2f} A {rr} {rr} 0 1 1 {ex:.2f} {ey:.2f}"')
        else:
            print(f'  full circle r="{rr}"')
        print(f'  stroke-width="{v["stroke"] * f:.2f}"')


if __name__ == '__main__':
    ap = argparse.ArgumentParser()
    ap.add_argument('--variant', default='d', choices=sorted(VARIANTS))
    ap.add_argument('--emit-paths', action='store_true')
    args = ap.parse_args()
    v = VARIANTS[args.variant]

    if args.emit_paths:
        emit_paths(v)
        raise SystemExit

    with open('icon.svg', 'w') as fh:
        fh.write(svg(v))

    # The filenames are fixed by the directory; the resolution is not. Both are
    # rendered well above their nominal size so the tile stays crisp anywhere
    # wp.org, or a future asset, scales it up.
    render(v, 1024).save('icon-256x256.png')
    render(v, 512).save('icon-128x128.png')
    print(f'variant {args.variant}: wrote icon.svg, icon-256x256.png (1024px), icon-128x128.png (512px)')
