#!/usr/bin/env python3
"""Phase A1 offline: 1fps detect+track+zones+roller cycle → runs + overlays + audit JSON."""

from __future__ import annotations

import argparse
import json
import time
import urllib.request
from pathlib import Path
from typing import Any

import cv2
import numpy as np


def load_zones(path: Path) -> dict[str, Any]:
    return json.loads(path.read_text(encoding="utf-8"))


def point_in_poly(x: float, y: float, poly: list[list[float]]) -> bool:
    # ray casting, poly in pixel coords
    n = len(poly)
    inside = False
    j = n - 1
    for i in range(n):
        xi, yi = poly[i]
        xj, yj = poly[j]
        if ((yi > y) != (yj > y)) and (x < (xj - xi) * (y - yi) / (yj - yi + 1e-9) + xi):
            inside = not inside
        j = i
    return inside


def norm_to_px(poly_norm: list[list[float]], w: int, h: int) -> list[list[float]]:
    return [[p[0] * w, p[1] * h] for p in poly_norm]


def zone_for_foot(cx: float, cy: float, zones_px: list[tuple[str, list[list[float]]]]) -> str:
    for zid, poly in zones_px:
        if point_in_poly(cx, cy, poly):
            return zid
    return "other"


def activity_for_zone(zid: str) -> str:
    if zid == "roller_table_work":
        return "work"
    if zid == "wait_rest":
        return "wait"
    if zid == "control_desk":
        return "work"
    return "unknown"


def http_json(url: str, payload: dict[str, Any], timeout: float = 30.0) -> dict[str, Any]:
    req = urllib.request.Request(
        url,
        data=json.dumps(payload).encode("utf-8"),
        headers={"Content-Type": "application/json"},
        method="POST",
    )
    with urllib.request.urlopen(req, timeout=timeout) as resp:
        return json.loads(resp.read().decode("utf-8"))


def detect_persons(detector_url: str, frame_path: Path, frame_id: str, run_id: str) -> list[dict[str, Any]]:
    payload = {
        "schema_version": "roven.edge.detector_service_request.v0",
        "run_id": run_id,
        "model_profile": "ms1_live_detector",
        "frames": [
            {
                "image": frame_id,
                "path": str(frame_path),
                "camera_id": "tempering_load_cam_01",
                "time": time.strftime("%Y-%m-%dT%H:%M:%S"),
                "frame_ref": f"file://{frame_path}",
            }
        ],
    }
    result = http_json(detector_url, payload, timeout=60.0)
    dets: list[dict[str, Any]] = []
    for record in result.get("records", []):
        for item in record.get("detections", []):
            label = str(item.get("label") or "").lower()
            if label and label != "person":
                continue
            bbox = [int(round(float(v))) for v in (item.get("bbox_xyxy") or [])[:4]]
            if len(bbox) != 4:
                continue
            dets.append(
                {
                    "label": "person",
                    "confidence": float(item.get("confidence") or 0.0),
                    "bbox_xyxy": bbox,
                }
            )
    return dets


def track_persons(tracker_url: str, frame_id: str, detections: list[dict[str, Any]], shape: tuple[int, int]) -> list[dict[str, Any]]:
    h, w = shape
    payload = {
        "schema_version": "roven.edge.tracker_service_request.v0",
        "frame": {
            "image": frame_id,
            "frame_ref": frame_id,
            "width": w,
            "height": h,
        },
        "detections": detections,
    }
    result = http_json(tracker_url, payload, timeout=30.0)
    tracks: list[dict[str, Any]] = []
    for record in result.get("records", []):
        for item in record.get("tracks", []):
            bbox = [int(round(float(v))) for v in (item.get("bbox_xyxy") or [])[:4]]
            if len(bbox) != 4:
                continue
            tracks.append(
                {
                    "track_id": str(item.get("track_id") or ""),
                    "label": "person",
                    "confidence": float(item.get("confidence") or 0.0),
                    "bbox_xyxy": bbox,
                }
            )
    return tracks


def roi_mask(shape: tuple[int, int], poly_px: list[list[float]]) -> np.ndarray:
    mask = np.zeros(shape[:2], dtype=np.uint8)
    pts = np.array([[int(p[0]), int(p[1])] for p in poly_px], dtype=np.int32)
    cv2.fillPoly(mask, [pts], 255)
    return mask


def glass_feature(gray: np.ndarray, mask: np.ndarray, prev_gray: np.ndarray | None) -> dict[str, float]:
    roi = gray[mask > 0]
    if roi.size == 0:
        return {"edge": 0.0, "motion": 0.0, "mean": 0.0, "std": 0.0}
    edges = cv2.Canny(gray, 40, 120)
    edge = float(edges[mask > 0].mean())
    motion = 0.0
    if prev_gray is not None:
        diff = cv2.absdiff(gray, prev_gray)
        motion = float(diff[mask > 0].mean())
    return {"edge": edge, "motion": motion, "mean": float(roi.mean()), "std": float(roi.std())}


def merge_intervals(flags: list[bool], min_len: int = 3, max_gap: int = 2) -> list[tuple[int, int]]:
    raw: list[tuple[int, int]] = []
    i = 0
    n = len(flags)
    while i < n:
        if not flags[i]:
            i += 1
            continue
        j = i
        while j < n and flags[j]:
            j += 1
        raw.append((i, j))
        i = j
    if not raw:
        return []
    merged = [list(raw[0])]
    for a, b in raw[1:]:
        if a - merged[-1][1] <= max_gap:
            merged[-1][1] = b
        else:
            merged.append([a, b])
    return [(a, b) for a, b in merged if (b - a) >= min_len]


def run_cycle_sm(
    feats: list[dict[str, float]],
    *,
    edge_thr: float,
    motion_thr: float,
    min_occupy: int,
    min_advance: int,
    min_gap: int,
) -> list[dict[str, Any]]:
    """Heuristic: occupied when edge high; advancing when motion high while occupied; clear when edge drops."""
    occupied = [f["edge"] >= edge_thr for f in feats]
    advancing = [occupied[i] and feats[i]["motion"] >= motion_thr for i in range(len(feats))]

    cycles: list[dict[str, Any]] = []
    state = "empty"
    t_start = t_push = None
    last_end = -10_000
    occupy_run = 0
    advance_run = 0

    for t, (occ, adv) in enumerate(zip(occupied, advancing)):
        if state == "empty":
            if occ:
                occupy_run += 1
                if occupy_run >= min_occupy and t - last_end >= min_gap:
                    state = "occupied"
                    t_start = t - occupy_run + 1
                    t_push = None
                    advance_run = 0
            else:
                occupy_run = 0
        elif state == "occupied":
            if not occ:
                # false start
                state = "empty"
                occupy_run = 0
                t_start = None
            elif adv:
                advance_run += 1
                if advance_run >= min_advance:
                    state = "advancing"
                    t_push = t - advance_run + 1
            else:
                advance_run = 0
        elif state == "advancing":
            if not occ:
                state = "empty"
                t_end = t
                cycles.append(
                    {
                        "t_start": int(t_start or t),
                        "t_push": int(t_push or t),
                        "t_end": int(t_end),
                        "dur_sec": int(t_end - (t_start or t)),
                    }
                )
                last_end = t_end
                occupy_run = 0
                advance_run = 0
                t_start = t_push = None
            elif not adv:
                # still occupied but motion calmed — keep advancing until clear
                pass

    # auto-calibrate suggestion stored by caller if empty
    return cycles, occupied, advancing  # type: ignore[return-value]


def draw_overlay(
    frame: np.ndarray,
    people: list[dict[str, Any]],
    zones_px: list[tuple[str, list[list[float]]]],
    glass_poly: list[list[float]],
    cycle_active: bool,
    t_local: int,
    wall_label: str,
) -> np.ndarray:
    out = frame.copy()
    colors = {
        "roller_table_work": (0, 180, 255),
        "wait_rest": (80, 200, 80),
        "control_desk": (200, 160, 0),
    }
    for zid, poly in zones_px:
        pts = np.array([[int(p[0]), int(p[1])] for p in poly], dtype=np.int32)
        cv2.polylines(out, [pts], True, colors.get(zid, (180, 180, 180)), 2)
    gpts = np.array([[int(p[0]), int(p[1])] for p in glass_poly], dtype=np.int32)
    cv2.polylines(out, [gpts], True, (0, 0, 255) if cycle_active else (120, 120, 255), 2)
    for p in people:
        x1, y1, x2, y2 = p["bbox_xyxy"]
        zid = p.get("zone_id") or "other"
        color = (0, 255, 255) if activity_for_zone(zid) == "work" else (0, 255, 0) if activity_for_zone(zid) == "wait" else (200, 200, 200)
        cv2.rectangle(out, (x1, y1), (x2, y2), color, 2)
        label = f"{p.get('track_id','?')} {zid}"
        cv2.putText(out, label, (x1, max(20, y1 - 6)), cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 1, cv2.LINE_AA)
    hud = f"t={t_local}s {wall_label} cycle={'Y' if cycle_active else 'n'} n={len(people)}"
    cv2.rectangle(out, (0, 0), (900, 36), (0, 0, 0), -1)
    cv2.putText(out, hud, (10, 24), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2, cv2.LINE_AA)
    return out


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--proxy", default="", help="1fps mp4 (optional if --frames-dir set)")
    ap.add_argument("--frames-dir", default="", help="directory of f_00001.jpg ... 1fps frames")
    ap.add_argument("--zones", required=True)
    ap.add_argument("--out-dir", required=True)
    ap.add_argument("--video-t0", type=int, default=5400, help="absolute video seconds of proxy frame 0")
    ap.add_argument("--detector-url", default="http://127.0.0.1:18085/roven/detect")
    ap.add_argument("--tracker-url", default="http://127.0.0.1:18086/roven/track")
    ap.add_argument("--run-id", default="glass-a1-pilot")
    ap.add_argument("--max-frames", type=int, default=0)
    ap.add_argument("--overlay-every", type=int, default=1)
    args = ap.parse_args()

    out_dir = Path(args.out_dir)
    frames_work = out_dir / "frames_work"
    overlay_dir = out_dir / "overlay_jpg"
    frames_work.mkdir(parents=True, exist_ok=True)
    overlay_dir.mkdir(parents=True, exist_ok=True)

    zcfg = load_zones(Path(args.zones))
    rules = zcfg.get("event_rules") or {}
    min_occupy = int(rules.get("cycle_min_occupy_sec", 8))
    min_advance = int(rules.get("cycle_min_advance_sec", 3))
    min_gap = int(rules.get("cycle_min_gap_sec", 20))

    frame_paths: list[Path] = []
    if args.frames_dir:
        frame_paths = sorted(Path(args.frames_dir).glob("f_*.jpg"))
        if not frame_paths:
            raise SystemExit(f"no frames in {args.frames_dir}")
    elif args.proxy:
        cap = cv2.VideoCapture(str(args.proxy))
        if not cap.isOpened():
            raise SystemExit(f"cannot open proxy: {args.proxy}")
    else:
        raise SystemExit("need --proxy or --frames-dir")

    tracks_path = out_dir / "tracks.jsonl"
    feats: list[dict[str, float]] = []
    records: list[dict[str, Any]] = []
    prev_gray = None
    glass_poly_px = None
    zones_px = None
    mask = None
    source_frames: list[Path] = []

    def iter_frames():
        if frame_paths:
            for i, p in enumerate(frame_paths):
                if args.max_frames and i >= args.max_frames:
                    break
                img = cv2.imread(str(p))
                if img is None:
                    continue
                yield i, img, p
        else:
            i = 0
            while True:
                ok, img = cap.read()
                if not ok:
                    break
                if args.max_frames and i >= args.max_frames:
                    break
                yield i, img, None
                i += 1

    with tracks_path.open("w", encoding="utf-8") as tf:
        for t, frame, src_path in iter_frames():
            h, w = frame.shape[:2]
            if zones_px is None:
                zones_px = [(z["zone_id"], norm_to_px(z["polygon_norm"], w, h)) for z in zcfg["zones"]]
                glass_poly_px = norm_to_px(zcfg["roller_glass_roi"]["polygon_norm"], w, h)
                mask = roi_mask(frame.shape, glass_poly_px)

            if src_path is not None:
                frame_path = src_path
            else:
                frame_path = frames_work / f"f{t:05d}.jpg"
                cv2.imwrite(str(frame_path), frame, [int(cv2.IMWRITE_JPEG_QUALITY), 85])
            source_frames.append(frame_path)
            frame_id = f"a1_{t:05d}"
            dets: list[dict[str, Any]] = []
            try:
                dets = detect_persons(args.detector_url, frame_path, frame_id, args.run_id)
                people = track_persons(args.tracker_url, frame_id, dets, (h, w))
            except Exception as exc:  # noqa: BLE001
                print(f"[t={t}] detect/track error: {exc}", flush=True)
                people = []

            for p in people:
                x1, y1, x2, y2 = p["bbox_xyxy"]
                fx = (x1 + x2) / 2.0
                fy = float(y2)
                zid = zone_for_foot(fx, fy, zones_px)
                p["zone_id"] = zid
                p["activity_state"] = activity_for_zone(zid)

            gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
            feat = glass_feature(gray, mask, prev_gray)
            feats.append(feat)
            prev_gray = gray

            abs_t = args.video_t0 + t
            rec = {
                "t_local": t,
                "t_abs": abs_t,
                "n_det": len(dets),
                "people": people,
                "glass": feat,
                "scene_activity": (
                    "work"
                    if any(p.get("activity_state") == "work" for p in people)
                    else "wait"
                    if any(p.get("activity_state") == "wait" for p in people)
                    else "unknown"
                ),
            }
            tf.write(json.dumps(rec, ensure_ascii=False) + "\n")
            records.append(rec)
            if t % 30 == 0:
                print(f"t={t} people={len(people)} edge={feat['edge']:.1f} motion={feat['motion']:.1f}", flush=True)

    if not frame_paths:
        cap.release()

    # calibrate thresholds from distribution
    edges = np.array([f["edge"] for f in feats], dtype=np.float32)
    motions = np.array([f["motion"] for f in feats], dtype=np.float32)
    edge_thr = float(np.percentile(edges, 70)) if len(edges) else 20.0
    motion_thr = float(max(np.percentile(motions, 75), 4.0)) if len(motions) else 5.0

    cycles, occupied, advancing = run_cycle_sm(
        feats,
        edge_thr=edge_thr,
        motion_thr=motion_thr,
        min_occupy=min_occupy,
        min_advance=min_advance,
        min_gap=min_gap,
    )

    # occupancy intervals
    work_flags = [r["scene_activity"] == "work" for r in records]
    wait_flags = [r["scene_activity"] == "wait" for r in records]
    work_iv = [{"t0": args.video_t0 + a, "t1": args.video_t0 + b, "kind": "work"} for a, b in merge_intervals(work_flags)]
    wait_iv = [{"t0": args.video_t0 + a, "t1": args.video_t0 + b, "kind": "wait"} for a, b in merge_intervals(wait_flags)]

    for c in cycles:
        c["t_start_abs"] = args.video_t0 + c["t_start"]
        c["t_push_abs"] = args.video_t0 + c["t_push"]
        c["t_end_abs"] = args.video_t0 + c["t_end"]

    # second pass overlays with cycle flags
    cycle_active = [False] * len(records)
    for c in cycles:
        for i in range(c["t_start"], min(c["t_end"], len(cycle_active))):
            cycle_active[i] = True

    for t, rec in enumerate(records):
        if t % args.overlay_every != 0:
            continue
        frame = cv2.imread(str(source_frames[t]))
        if frame is None:
            continue
        h, w = frame.shape[:2]
        if zones_px is None:
            zones_px = [(z["zone_id"], norm_to_px(z["polygon_norm"], w, h)) for z in zcfg["zones"]]
            glass_poly_px = norm_to_px(zcfg["roller_glass_roi"]["polygon_norm"], w, h)
        abs_t = args.video_t0 + t
        wh = 8 + abs_t // 3600
        wm = (abs_t % 3600) // 60
        ws = abs_t % 60
        wall_label = f"{wh:02d}:{wm:02d}:{ws:02d}"
        ov = draw_overlay(frame, rec["people"], zones_px, glass_poly_px, cycle_active[t], t, wall_label)
        cv2.imwrite(str(overlay_dir / f"ov_{t:05d}.jpg"), ov, [int(cv2.IMWRITE_JPEG_QUALITY), 80])

    # overlay mp4 for scrub source
    overlay_mp4 = out_dir / "overlay_1fps.mp4"
    # build from jpg sequence
    import subprocess

    subprocess.run(
        [
            "ffmpeg",
            "-y",
            "-framerate",
            "1",
            "-i",
            str(overlay_dir / "ov_%05d.jpg"),
            "-c:v",
            "libx264",
            "-pix_fmt",
            "yuv420p",
            "-crf",
            "23",
            str(overlay_mp4),
        ],
        check=False,
        capture_output=True,
    )

    summary = {
        "video_t0": args.video_t0,
        "n_frames": len(records),
        "edge_thr": edge_thr,
        "motion_thr": motion_thr,
        "n_cycles": len(cycles),
        "work_sec": sum(i["t1"] - i["t0"] for i in work_iv),
        "wait_sec": sum(i["t1"] - i["t0"] for i in wait_iv),
        "unique_track_ids": sorted({p["track_id"] for r in records for p in r["people"] if p.get("track_id")}),
    }
    (out_dir / "load_cycles.json").write_text(json.dumps({"cycles": cycles, "occupied": occupied, "advancing": advancing}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    (out_dir / "occupancy_intervals.json").write_text(json.dumps({"work": work_iv, "wait": wait_iv}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    (out_dir / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    (out_dir / "glass_features.json").write_text(json.dumps(feats, ensure_ascii=False) + "\n", encoding="utf-8")
    print(json.dumps(summary, ensure_ascii=False, indent=2))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
