# -*- coding: utf-8 -*-
"""Telemetry dashboard rendered as ASS events — digital readout cards
(DJI-Studio style) driven by the per-second FIT window.

Rides in the SAME subtitle file / ffmpeg pass as the narration subtitles, so
enabling the dashboard adds zero extra transcodes.

Layout (fractions of frame size, preset per the plan):

    標題 ────────────────── 置頂中央
    高度                          時間
    坡度
    迴轉數
                          功率
    心率 ────────────────── 速度（最大）
"""
from datetime import datetime, timedelta

# metric key -> (中文標籤, 單位, 數值格式)
METRICS = {
    "speed": ("速度", "KM/H", lambda r, g: _num(r.get("spd_kmh"))),
    "power": ("功率", "W", lambda r, g: _num(r.get("pwr"))),
    "hr":    ("心率", "BPM", lambda r, g: _num(r.get("hr"))),
    "cad":   ("迴轉數", "RPM", lambda r, g: _num(r.get("cad"))),
    "alt":   ("高度", "m", lambda r, g: _num(r.get("alt_m"))),
    "grad":  ("坡度", "%", lambda r, g: f"{g:+.1f}" if g is not None else "--"),
}

DEFAULT_CFG = {"speed": True, "hr": True, "cad": True, "grad": True,
               "power": False, "alt": False, "clock": False, "title": ""}

# Per-metric placement. x/y are fractions of frame size, so one layout suits
# 720p through 4K; `scale` is a multiplier on the base value size (1.0 =
# BASE_FS below), and `color` is plain #RRGGBB as the UI colour picker gives
# it. This is the shape a saved preset stores.
DEFAULT_LAYOUT = {
    "alt":   {"on": False, "x": 0.035, "y": 0.10, "scale": 1.0,  "color": "#FFFFFF"},
    "grad":  {"on": True,  "x": 0.035, "y": 0.24, "scale": 1.0,  "color": "#FFFFFF"},
    "cad":   {"on": True,  "x": 0.035, "y": 0.38, "scale": 1.0,  "color": "#FFFFFF"},
    "hr":    {"on": True,  "x": 0.035, "y": 0.97, "scale": 1.55, "color": "#FFFFFF"},
    "power": {"on": False, "x": 0.965, "y": 0.80, "scale": 1.0,  "color": "#FFFFFF"},
    "speed": {"on": True,  "x": 0.965, "y": 0.97, "scale": 1.55, "color": "#FFFFFF"},
    # fmt: time | datetime | date  (datetime puts the date on its own line
    # above the clock, the way action-cam overlays usually show it)
    "clock": {"on": False, "x": 0.965, "y": 0.06, "scale": 1.0,  "color": "#FFFFFF",
              "fmt": "time"},
    "title": {"on": True,  "x": 0.5,   "y": 0.025, "scale": 1.1, "color": "#FF3B30"},
}

BASE_FS = 0.048          # value font size as a fraction of frame height


def _ass_color(hex_rgb: str) -> str:
    """#RRGGBB -> ASS &HBBGGRR& (ASS stores colour byte-reversed)."""
    s = str(hex_rgb or "").lstrip("#")
    if len(s) != 6:
        return "&H00FFFFFF&"
    return f"&H00{s[4:6]}{s[2:4]}{s[0:2]}&".upper()


def _anchor(x: float, y: float) -> int:
    """ASS \\an numpad code derived from position, so text grows away from
    the nearest edge instead of running off it."""
    col = 1 if x < 0.34 else (3 if x > 0.66 else 2)     # left/centre/right
    row = 7 if y < 0.4 else (1 if y > 0.6 else 4)       # top/bottom/middle
    return row + (col - 1)


def normalize(cfg: dict) -> dict:
    """Merge a stored config into the full layout form.

    Configs written before layouts existed are flat booleans
    ({"speed": true, ...}); those still have to keep working, so the flat
    keys are read as the `on` flags when no explicit layout is present.
    """
    cfg = cfg or {}
    stored = cfg.get("layout") or {}
    out = {}
    for key, base in DEFAULT_LAYOUT.items():
        m = {**base, **(stored.get(key) or {})}
        if key not in stored:
            if key == "title":
                m["on"] = bool(str(cfg.get("title", "")).strip())
            elif key in cfg:
                m["on"] = bool(cfg.get(key))
        out[key] = m
    return out


def _num(v):
    return f"{v:.0f}" if v is not None else "--"


def _ts(sec):
    sec = max(0.0, sec)
    h, sec = divmod(sec, 3600)
    m, s = divmod(sec, 60)
    return f"{int(h)}:{int(m):02d}:{s:05.2f}"


def _gradient_series(win, span_m=25.0):
    """Slope from altitude/distance deltas over a ~25 m lookback window.
    Barometric altitude is too noisy for per-second differentiation — the
    25 m smoothing was validated on real ride data (spikes/gradient_quality)."""
    pts = [(int(round(r["vt"])), r.get("dist_m"), r.get("alt_m")) for r in win
           if r.get("dist_m") is not None and r.get("alt_m") is not None]
    out = {}
    for i, (t, d, a) in enumerate(pts):
        j = i
        while j > 0 and d - pts[j][1] < span_m:
            j -= 1
        dd = d - pts[j][1]
        out[t] = ((a - pts[j][2]) / dd * 100) if dd > 5 else 0.0
    return out


def build_events(win, cfg, W, H, duration, video_t0_iso=None):
    """Returns a list of ASS Dialogue lines (style names: DashV/DashL/DashU
    — the caller's [V4+ Styles] block must define them, see STYLES)."""
    lay = normalize(cfg)
    by_t = {}
    for r in win:
        t = int(round(r["vt"]))
        if 0 <= t <= duration + 1:
            by_t.setdefault(t, r)
    grads = _gradient_series(win) if lay["grad"]["on"] else {}
    ev = []

    def add(start, end, style, text, layer=6):
        ev.append(f"Dialogue: {layer},{_ts(start)},{_ts(end)},{style},,0,0,0,,{text}")

    for key, m in lay.items():
        if not m.get("on") or key in ("clock", "title"):
            continue
        label, unit, fmt = METRICS[key]
        x, y = int(W * m["x"]), int(H * m["y"])
        an = _anchor(m["x"], m["y"])
        fs = max(8, round(H * BASE_FS * float(m.get("scale", 1.0))))
        col = _ass_color(m.get("color"))
        lab_fs, unit_fs = max(6, round(fs * 0.42)), max(6, round(fs * 0.5))
        # Caption and value are stacked, and which one keeps the anchor
        # point depends on the anchor: a top-anchored metric grows downward
        # (caption on the anchor, value below), a bottom-anchored one grows
        # upward (value on the anchor, caption above). The gap tracks the
        # value's own size so a rescaled metric never collides with its
        # caption.
        top_anchored = an in (7, 8, 9)
        gap = round(fs * 1.05)
        ly = y if top_anchored else y - gap
        vy = y + gap if top_anchored else y
        head = f"{{\\an{an}\\pos({x},{ly})\\fs{lab_fs}\\c{col}\\alpha&H40&}}"
        add(0, duration, "DashL", f"{head}{label}")
        # per-second values, merged across seconds where the value repeats
        # (fewer events; identical text back-to-back is invisible anyway)
        t0, prev = 0, None
        for t in range(0, int(duration) + 1):
            r = by_t.get(t, {})
            val = fmt(r, grads.get(t)) if r or key == "grad" else "--"
            if val != prev:
                if prev is not None:
                    add(t0, t, "DashV",
                        f"{{\\an{an}\\pos({x},{vy})\\fs{fs}\\c{col}}}{prev}"
                        f"{{\\fs{unit_fs}}} {unit}")
                t0, prev = t, val
        if prev is not None:
            add(t0, duration, "DashV",
                f"{{\\an{an}\\pos({x},{vy})\\fs{fs}\\c{col}}}{prev}"
                f"{{\\fs{unit_fs}}} {unit}")

    # clock (real time-of-day). Prefer the per-second record timestamps —
    # with multi-clip projects the real time JUMPS across clip boundaries,
    # and the records carry the correct per-clip times; a single t0+offset
    # would drift wrong after the first gap. Fallback to t0+offset only when
    # a second has no record.
    m = lay["clock"]
    if m["on"]:
        t0dt = (datetime.fromisoformat(video_t0_iso)
                if video_t0_iso else None)
        x, y = int(W * m["x"]), int(H * m["y"])
        an, col = _anchor(m["x"], m["y"]), _ass_color(m.get("color"))
        fs = max(8, round(H * BASE_FS * float(m.get("scale", 1.0))))
        pat = {"datetime": "%Y/%m/%d\\N%H:%M:%S",
               "date": "%Y/%m/%d"}.get(m.get("fmt"), "%H:%M:%S")
        # merge identical consecutive values — a date-only clock would
        # otherwise emit one event per second all saying the same thing
        t0, prev = 0, None
        for t in range(0, int(duration) + 1):
            r = by_t.get(t)
            if r and r.get("t"):
                clk = datetime.fromisoformat(r["t"]).strftime(pat)
            elif t0dt:
                clk = (t0dt + timedelta(seconds=t)).strftime(pat)
            else:
                clk = None
            if clk != prev:
                if prev is not None:
                    add(t0, t, "DashV",
                        f"{{\\an{an}\\pos({x},{y})\\fs{fs}\\c{col}}}{prev}")
                t0, prev = t, clk
        if prev is not None:
            add(t0, duration, "DashV",
                f"{{\\an{an}\\pos({x},{y})\\fs{fs}\\c{col}}}{prev}")

    # title
    m = lay["title"]
    title = (cfg or {}).get("title", "").strip()
    if m["on"] and title:
        x, y = int(W * m["x"]), int(H * m["y"])
        fs = max(8, round(H * BASE_FS * float(m.get("scale", 1.0))))
        add(0, duration, "DashT",
            f"{{\\an{_anchor(m['x'], m['y'])}\\pos({x},{y})}}"
            f"{{\\fs{fs}\\c{_ass_color(m.get('color'))}}}{title}")
    return ev


def styles(H):
    """Style lines to merge into the ASS [V4+ Styles] block. Sizes scale
    with frame height so 720p/1080p/4K all look proportionate."""
    big = round(H * 0.075)
    mid = round(H * 0.048)
    lab = round(H * 0.024)
    ttl = round(H * 0.042)
    o = max(2, H // 400)
    return [
        f"Style: DashV,Microsoft JhengHei,{big},&H00FFFFFF,&H000000FF,"
        f"&H00101010,&H80000000,-1,0,0,0,100,100,1,0,1,{o+1},{o},7,0,0,0,1",
        f"Style: DashM,Microsoft JhengHei,{mid},&H00FFFFFF,&H000000FF,"
        f"&H00101010,&H80000000,-1,0,0,0,100,100,1,0,1,{o},{max(1,o-1)},7,0,0,0,1",
        f"Style: DashL,Microsoft JhengHei,{lab},&H00D8D8D8,&H000000FF,"
        f"&H00101010,&H80000000,-1,0,0,0,100,100,2,0,1,{max(1,o-1)},1,7,0,0,0,1",
        f"Style: DashU,Microsoft JhengHei,{lab},&H00C0C0C0,&H000000FF,"
        f"&H00101010,&H80000000,-1,0,0,0,100,100,1,0,1,{max(1,o-1)},1,7,0,0,0,1",
        f"Style: DashT,Microsoft JhengHei,{ttl},&H004050FF,&H000000FF,"
        f"&H00101010,&H80000000,-1,0,0,0,100,100,2,0,1,{o},{max(1,o-1)},8,0,0,0,1",
    ]
