#!/usr/bin/env python3
"""Pin a Deadlock match to the VOD clock by reading the in-game match clock.

The match record knows every kill to the second, but only relative to match
start; `match.start_time` is when the match was CREATED, and the gap to the
clock actually starting (picks, load, pre-game) measured -30s..+76s. Everything
downstream — exact VOD events, fight clips — needs that one constant.

Previously it came from cross-correlating our kills against the HUD scanner's,
which needs the scanner to have detected kills. On 2026-08-03 the scanner's
kill pass found nothing for the first two matches of the night (its kills are
gated on a name-template match; assists aren't, which is why assists appeared
throughout and kills didn't), so those matches could not be pinned at all.

The game puts the answer on screen. Read the top-centre match clock at a frame,
and:

    offset = vod_time_of_frame - match_clock - match_start_in_vod

One good read settles it. We take several and require agreement, because any
single frame can be spoiled by a bright background behind the clock plate.
"""
from __future__ import annotations

import io
import os
import subprocess

import numpy as np
from PIL import Image

# Pillow rather than OpenCV. Only two operations were ever needed here —
# decode a frame to greyscale, and resize a glyph — and cv2 would put a
# ~40 MB wheel into every install for a game most people do not play.
# Pillow is already a dependency (the tray icon needs it).

# clock geometry at 1920x1080 (digits are ~8x16 on a dark rounded plate)
BAND = (8, 32, 920, 1010)          # y0, y1, x0, x1
BOX = (12, 20)                     # canonical glyph size for matching
INK = 150                          # digits sit ~190 grey; plate is ~40
TPL_PATH = os.path.join(os.path.dirname(__file__), "deadlock_clock_tpl.npz")
MIN_CORR = 0.55
DIGIT_W = (6, 11)                  # a single glyph; wider means digits merged


def _grab(path: str, t: float):
    p = subprocess.run(
        ["ffmpeg", "-hide_banner", "-loglevel", "error", "-ss", f"{t:.3f}",
         "-i", path, "-frames:v", "1", "-f", "image2pipe", "-vcodec", "png", "-"],
        capture_output=True, timeout=180)
    if not p.stdout:
        return None
    return _decode_gray(p.stdout)


def _decode_gray(buf: bytes):
    """Image bytes -> a greyscale uint8 array, the shape the matcher wants."""
    with Image.open(io.BytesIO(buf)) as im:
        return np.asarray(im.convert("L"), dtype=np.uint8)


def _resize_gray(g, box):
    """Resize a greyscale array to (w, h), growing with BICUBIC."""
    im = Image.fromarray(np.asarray(g, dtype=np.uint8), mode="L")
    grow = g.shape[0] < box[1]
    return np.asarray(
        im.resize(box, Image.BICUBIC if grow else Image.BOX), dtype=np.uint8
    ).astype(float)


def _runs(mask):
    cols = mask.sum(0)
    out, r = [], None
    for i, c in enumerate(cols):
        if c > 0 and r is None:
            r = i
        elif c == 0 and r is not None:
            out.append((r, i))
            r = None
    if r is not None:
        out.append((r, len(cols)))
    return out


def _split_wide(runs, s=1.0):
    """Adjacent digits touch and segment as one wide run ('18' -> 16px). Their
    width is uniform, so a wide run divides evenly."""
    out = []
    for a, b in runs:
        w = b - a
        if w <= DIGIT_W[1] * s:
            out.append((a, b))
            continue
        n = int(round(w / (8.5 * s)))           # 8.5px per glyph at 1080p
        if n < 2:
            out.append((a, b))
            continue
        step = w / n
        out += [(int(a + i * step), int(a + (i + 1) * step)) for i in range(n)]
    return out


def glyphs(img):
    # BAND and the glyph widths are measured at 1920x1080; six of the archive's
    # Deadlock vods are 720p, where those pixel coordinates point at empty sky
    # and every read silently failed. Everything here is proportional to frame
    # height, and the templates are matched after a resize to BOX, so scaling
    # the geometry is all it takes to read any resolution.
    s = img.shape[0] / 1080.0
    y0, y1, x0, x1 = [int(round(v * s)) for v in BAND]
    reg = img[y0:y1, x0:x1]
    mask = (reg > INK).astype(np.uint8)
    if mask.mean() > 0.45:
        return []                               # background bleed, not a clock
    out = []
    for a, b in _split_wide(_runs(mask), s):
        sub = mask[:, a:b]
        rows = np.where(sub.sum(1) > 0)[0]
        if len(rows) < 8 * s:                   # the colon is short
            continue
        g = reg[rows.min():rows.max() + 1, a:b].astype(np.float32)
        if g.size == 0:
            continue
        # INTER_AREA is a downsampling filter; on a 720p frame the glyph is
        # ~11px tall and has to be GROWN into BOX, where AREA degenerates to
        # nearest-neighbour and shreds the correlation against the templates.
        # BICUBIC when growing, AREA-equivalent (BOX) when shrinking: on a
        # 720p frame the glyph is ~11px tall and has to be GROWN into BOX,
        # where an area filter degenerates to nearest-neighbour and shreds
        # the correlation against the templates.
        g = _resize_gray(g, BOX)
        out.append((g - g.mean()) / (g.std() + 1e-6))
    return out


def load_templates(path=TPL_PATH):
    if not os.path.exists(path):
        return {}
    d = np.load(path)
    return {int(k): d[k] for k in d.files}


def read_clock(img, tpl):
    """-> seconds since match start, or None if this frame can't be trusted."""
    gs = glyphs(img)
    if len(gs) not in (3, 4) or not tpl:
        return None
    ds = []
    for g in gs:
        best, score = None, -9.0
        for d, t in tpl.items():
            c = float((g * t).mean())
            if c > score:
                best, score = d, c
        if score < MIN_CORR:
            return None
        ds.append(best)
    if len(ds) == 4:
        mm, ss = ds[0] * 10 + ds[1], ds[2] * 10 + ds[3]
    else:
        mm, ss = ds[0], ds[1] * 10 + ds[2]
    if ss > 59:
        return None
    return mm * 60 + ss


def build_templates(path: str, t_known: float, clock_known: int, out=TPL_PATH):
    """Bootstrap digit templates: the seconds-ones place cycles 0-9 over ten
    consecutive seconds, so ten frames one second apart label themselves."""
    acc = {}
    for n in range(10):
        img = _grab(path, t_known + n)
        if img is None:
            continue
        gs = glyphs(img)
        if len(gs) != 4:
            continue
        acc.setdefault((clock_known + n) % 10, []).append(gs[3])
    tpl = {d: np.mean(v, axis=0) for d, v in acc.items() if v}
    if len(tpl) == 10:
        np.savez(out, **{str(d): a for d, a in tpl.items()})
    return tpl


def anchors(vod_path: str, match_start_vod_s: float, duration_s: int,
            tpl=None, step_s: float = 90.0, verbose=False):
    """Sample the clock across the match and return verified (vod_t, game_t)
    anchor points.

    A SINGLE offset is not enough: recordings drift. On vod 1640 the offset was
    +45s for the first half of a match and +81s for the second — a ~36s stall
    in the capture (80GB of 60fps going to an SMB share). Both halves were
    confirmed against the footage, so the match clock and the VOD clock really
    do diverge mid-match. Anchors capture that; one number cannot.

    Isolated misreads are rejected by comparing each sample's implied local
    offset against its neighbours' median — a genuine step persists across many
    samples, a misread does not."""
    tpl = tpl or load_templates()
    if not tpl:
        return []
    pts = []
    n = max(3, int(duration_s / max(20.0, step_s)))
    for i in range(n):
        frac = 0.06 + 0.88 * (i / max(1, n - 1))
        vt = match_start_vod_s + duration_s * frac
        img = _grab(vod_path, vt)
        if img is None:
            continue
        c = read_clock(img, tpl)
        if c is not None:
            pts.append((vt, float(c), vt - c - match_start_vod_s))
    if len(pts) < 3:
        return [(v, g) for v, g, _ in pts]
    keep = []
    for i, (vt, c, off) in enumerate(pts):
        lo, hi = max(0, i - 2), min(len(pts), i + 3)
        local = sorted(p[2] for p in pts[lo:hi])
        med = local[len(local) // 2]
        if abs(off - med) <= 3.0:
            keep.append((vt, c))
        elif verbose:
            print(f"    drop vod {vt:.0f}s (offset {off:+.0f} vs local {med:+.0f})")
    return keep


def to_vod(anchor_pts, game_t: float):
    """game_time -> vod time, interpolating between anchors so a mid-match
    drift is followed instead of averaged away."""
    if not anchor_pts:
        return None
    pts = sorted(anchor_pts, key=lambda p: p[1])
    if game_t <= pts[0][1]:
        return pts[0][0] + (game_t - pts[0][1])
    if game_t >= pts[-1][1]:
        return pts[-1][0] + (game_t - pts[-1][1])
    for (v0, g0), (v1, g1) in zip(pts, pts[1:]):
        if g0 <= game_t <= g1:
            if g1 == g0:
                return v0
            return v0 + (v1 - v0) * (game_t - g0) / (g1 - g0)
    return pts[-1][0] + (game_t - pts[-1][1])


def find_offset(vod_path: str, match_start_vod_s: float, duration_s: int,
                tpl=None, samples: int = 9, verbose=False):
    """Legacy single-offset estimate. Kept only for a quick sanity read —
    prefer anchors()/to_vod(), which survive mid-match drift."""
    tpl = tpl or load_templates()
    if not tpl:
        return None, 0, 0
    votes = []
    for i in range(samples):
        frac = 0.12 + 0.76 * (i / max(1, samples - 1))
        vt = match_start_vod_s + duration_s * frac
        img = _grab(vod_path, vt)
        if img is None:
            continue
        c = read_clock(img, tpl)
        if c is None:
            continue
        off = vt - c - match_start_vod_s
        votes.append(off)
        if verbose:
            print(f"    vod {vt:.0f}s  clock {c//60}:{c%60:02d}  -> offset {off:+.0f}s")
    if not votes:
        return None, 0, 0
    votes.sort()
    med = votes[len(votes) // 2]
    agree = [v for v in votes if abs(v - med) <= 2.5]
    if len(agree) < 2:
        return None, len(agree), len(votes)
    return round(sum(agree) / len(agree), 1), len(agree), len(votes)
