#!/usr/bin/env python3
"""The Black Swan's own six-turbine slide, measured.

The video (23:55-26:08) presents a VLC screenshot of its footage DSCN7246.MOV "taken at
time 1.37", numbers the six turbines, and gives their distances: 8.4, 8.8, 9.3, 9.8, 10.8
and 11.2 miles from a 2-ft observer (contrast raised to 75 %, no resizing). The same slide
is the still that circulates on TikTok. Here it is read in pixels.

For each turbine the yellow transition piece is found by colour; its width (the piece is a
cylinder of known order, 6 m) is the scale that removes perspective, and two heights are
read in units of that width: the visible yellow, and the dark intertidal strip beneath it.
Both strips are the same physical height on every turbine (one design, one tide), so any
change with distance beyond perspective is the atmosphere and the geometry acting together:

    visible_i = full_height - hidden(h, D_i, k)

is fitted for (full_height, k) with the same hidden-height formula as the refraction solver
(long_path_k_bands.hidden_globe). The two strips give two independent fits; the dark strip's
fitted full height should come out at the tidal range (Worthing springs 5-6 m) if it is
what it looks like, the intertidal zone.

    python3 rampion_bands.py            -> the table and the fits
    python3 rampion_bands.py --figure   -> docs/rampion/assets/blackswan-bands-measured.jpg
"""
import os
import sys

import numpy as np
from PIL import Image, ImageDraw, ImageFont

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from long_path_k_bands import gradient, hidden_globe  # noqa: E402

HERE = os.path.dirname(os.path.abspath(__file__))
ASSETS = os.path.join(HERE, "..", "docs", "rampion", "assets")
SRC = os.path.join(ASSETS, "blackswan-turbines-labeled.jpg")
OUT = os.path.join(ASSETS, "blackswan-bands-measured.jpg")

EYE = 0.61                                   # 2 ft, the slide's own observer
MILE = 1609.344
DIST_MI = [8.4, 8.8, 9.3, 9.8, 10.8, 11.2]    # the video's figures, turbines 1-6
DIA = (5.5, 6.0, 6.5)                        # transition-piece diameter: the Order caps monopiles at 6.5 m
T_SURF = 287.0                               # 14 degC Channel water


def measure(path=SRC):
    """Per turbine: x-range, yellow rows, yellow width (px), yellow height (px), dark strip (px)."""
    im = np.asarray(Image.open(path).convert("RGB")).astype(int)
    R, G, B = im[..., 0], im[..., 1], im[..., 2]
    yellow = (R - B > 50) & (G - B > 30) & (R > 120)
    lum = 0.299 * R + 0.587 * G + 0.114 * B
    xs = np.where(yellow.sum(0) > 3)[0]
    groups = []
    for x in xs:
        if groups and x - groups[-1][-1] <= 3:
            groups[-1].append(x)
        else:
            groups.append([x])
    out = []
    for g in groups:
        x0, x1 = g[0], g[-1]
        rows = yellow[:, x0:x1 + 1].sum(1)
        ys = np.where(rows >= 0.5 * rows.max())[0]
        y0, y1 = int(ys[0]), int(ys[-1])
        if y1 - y0 < 20:
            continue                          # the player's icon, not a base
        width = float(np.median(rows[y0:y1 + 1]))
        col = lum[:, x0:x1 + 1].mean(1)
        sea = np.median(col[y1 + 30:y1 + 60])
        dark = np.where(col[y1 + 1:y1 + 40] < sea - 12)[0]
        dh = int(dark[-1] + 1) if len(dark) else 0
        out.append(dict(x0=x0, x1=x1, y0=y0, y1=y1, width=width, yellow=y1 - y0 + 1, dark=dh))
    assert len(out) == 6, f"expected six bases, found {len(out)}"
    return out


def fit(vis_m, D):
    """Least squares over k for visible_i = Y0 - hidden(EYE, D_i, k); returns (k, Y0, rms)."""
    ks = np.linspace(-0.5, 0.999, 1500)
    best = None
    for k in ks:
        hid = np.array([hidden_globe(EYE, d, k) for d in D])
        Y0 = float(np.mean(vis_m + hid))
        ss = float(((vis_m - (Y0 - hid)) ** 2).sum())
        if best is None or ss < best[2]:
            best = (float(k), Y0, ss)
    k, Y0, ss = best
    return k, Y0, (ss / len(D)) ** 0.5


def bootstrap(heights, widths, D, n=4000, px=1.5, seed=1):
    """Spread of the fit under +-px pixel noise on every height and width and a 5.5-6.5 m diameter."""
    rng = np.random.default_rng(seed)
    ks = np.linspace(-0.5, 0.999, 600)
    HID = np.array([[hidden_globe(EYE, d, k) for d in D] for k in ks])
    kk, yy = [], []
    for _ in range(n):
        dia = rng.uniform(DIA[0], DIA[-1])
        vis = (heights + rng.normal(0, px, len(D))) / (widths + rng.normal(0, px, len(D))) * dia
        Y0 = (vis + HID).mean(1, keepdims=True)
        i = ((vis - (Y0 - HID)) ** 2).sum(1).argmin()
        kk.append(ks[i]); yy.append(Y0[i, 0])
    kk, yy = np.array(kk), np.array(yy)
    return dict(k=float(np.median(kk)), k16=float(np.percentile(kk, 16)), k84=float(np.percentile(kk, 84)),
                k025=float(np.percentile(kk, 2.5)), k975=float(np.percentile(kk, 97.5)),
                Y0=float(np.median(yy)), Y016=float(np.percentile(yy, 16)), Y084=float(np.percentile(yy, 84)))


def results(path=SRC):
    m = measure(path)
    D = np.array(DIST_MI) * MILE
    w = np.array([r["width"] for r in m]); yel = np.array([r["yellow"] for r in m], float); drk = np.array([r["dark"] for r in m], float)
    res = dict(measured=m, airless=[hidden_globe(EYE, d, 0.0) for d in D],
               yellow_w=(yel / w).tolist(), dark_w=(drk / w).tolist(),
               dark_ratio_raw=float(drk[-1] / drk[0]), dark_ratio_norm=float((drk[-1] / w[-1]) / (drk[0] / w[0])),
               perspective=DIST_MI[0] / DIST_MI[-1], fits={}, boot={})
    for lab, hh in (("yellow", yel), ("dark", drk)):
        res["fits"][lab] = {dia: fit(hh / w * dia, D) for dia in DIA}
        res["boot"][lab] = bootstrap(hh, w, D)
    return res


def main():
    r = results()
    print("turbine  dist(mi)  airless hidden(m)  width(px)  yellow(px)  yellow/w  dark(px)  dark/w")
    for i, (row, d, a) in enumerate(zip(r["measured"], DIST_MI, r["airless"]), 1):
        print(f"   {i}      {d:5.1f}       {a:5.1f}            {row['width']:4.0f}      {row['yellow']:4d}       {row['yellow']/row['width']:.2f}     {row['dark']:3d}      {row['dark']/row['width']:.2f}")
    print(f"dark strip, turbine 6 / turbine 1: {r['dark_ratio_raw']:.2f} raw, {r['dark_ratio_norm']:.2f} per width; perspective alone {r['perspective']:.2f}")
    for lab in ("yellow", "dark"):
        for dia, (k, Y0, rms) in r["fits"][lab].items():
            print(f"{lab:6s} dia {dia}: k={k:.2f} full height {Y0:.1f} m rms {rms:.2f} m  "
                  f"{gradient(k, T_SURF):+.0f} degC/km; flat k'={k-1:.2f} {gradient(k-1, T_SURF):+.0f} degC/km")
        b = r["boot"][lab]
        print(f"{lab:6s} bootstrap: k {b['k']:.2f} (68 % {b['k16']:.2f}-{b['k84']:.2f}, 95 % {b['k025']:.2f}-{b['k975']:.2f}); "
              f"full height {b['Y0']:.1f} m ({b['Y016']:.1f}-{b['Y084']:.1f})")
        k = b["k"]
        print(f"        hidden at {DIST_MI[0]} / {DIST_MI[-1]} mi at that k: {hidden_globe(EYE, DIST_MI[0]*MILE, k):.1f} / {hidden_globe(EYE, DIST_MI[-1]*MILE, k):.1f} m")


def font(sz, bold=False):
    f = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
    return ImageFont.truetype(f, sz) if os.path.exists(f) else ImageFont.load_default()


def figure(out=OUT):
    r = results()
    im = Image.open(SRC).convert("RGB")
    c = im.crop((120, 300, 1200, 550)).resize((2160, 500), Image.LANCZOS)   # the bases, doubled
    sx = sy = 2.0; ox, oy = 120, 300
    d = ImageDraw.Draw(c, "RGBA")
    for i, row in enumerate(r["measured"], 1):
        x0, x1 = (row["x0"] - ox) * sx, (row["x1"] - ox + 1) * sx
        y0, y1 = (row["y0"] - oy) * sy, (row["y1"] - oy + 1) * sy
        yd = y1 + row["dark"] * sy
        d.rectangle((x0 - 2, y0, x1 + 2, y1), outline="#f2c14e", width=2)
        d.rectangle((x0 - 2, y1, x1 + 2, yd), outline="#ff6b57", width=2)
        cx = (x0 + x1) / 2
        d.text((cx, y0 - 8), f"{i}   {DIST_MI[i-1]} mi", fill="#ffffff", font=font(15, True), anchor="mb")
        d.text((cx, yd + 6), f"yellow {r['yellow_w'][i-1]:.2f} w\ndark {r['dark_w'][i-1]:.2f} w", fill="#ffffff", font=font(13), anchor="ma", align="center")
    b = r["boot"]["dark"]; by = r["boot"]["yellow"]
    txt = (f"Heights in units of the piece's own width (w), which removes perspective.  Dark strip, turbine 6 / turbine 1: "
           f"{r['dark_ratio_norm']:.2f} per width, {r['dark_ratio_raw']:.2f} raw; perspective alone {r['perspective']:.2f}.\n"
           f"Fit of visible = full height - hidden(2 ft, D, k):  dark strip k = {b['k']:.2f} ({b['k16']:.2f}-{b['k84']:.2f}), "
           f"full height {b['Y0']:.1f} m = the tidal range;  yellow k = {by['k']:.2f} ({by['k16']:.2f}-{by['k84']:.2f}).")
    d.rectangle((0, 500 - 52, 2160, 500), fill=(0, 0, 0, 170))
    d.text((12, 500 - 46), txt, fill="#dfe6ee", font=font(14))
    c.save(out, quality=88)
    print("wrote", os.path.normpath(out))


if __name__ == "__main__":
    figure() if "--figure" in sys.argv else main()
