#!/usr/bin/env python3
"""Measure where the speaker is, and report where an overlay card may actually go.

Guessing this is how a graphic ends up on someone's forehead. A "framed vertical
talking head" is assumed to put the face in the upper third, but real footage varies
enormously: on the material this was built against the subject occupied y 350..1653
of 1920, leaving one usable band of 350 px above the head and nothing else.

The subject is located by skin-tone probability averaged over several frames, then
the free bands above and below are reported together with the platform's safe area
and the caption zone, so a card can be placed against numbers instead of a guess.

Skin detection is deliberately crude — an RGB rule, not a face model. It does not
need to find a face; it needs to find *where the person is*, and for that a coarse
mask averaged over frames is both sufficient and dependency-free.
"""
from __future__ import annotations

import argparse
import json
import subprocess
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))

from media_probe import probe  # noqa: E402
from skill_config import canvas as resolve_canvas, emit, require_binary, utf8_stdout  # noqa: E402


def sample_frame(video: Path, at: float, width: int, height: int):
    import numpy

    completed = subprocess.run(
        ["ffmpeg", "-v", "error", "-nostdin", "-ss", f"{at:.3f}", "-i", str(video),
         "-frames:v", "1", "-f", "rawvideo", "-pix_fmt", "rgb24", "-"],
        capture_output=True,
    )
    expected = width * height * 3
    if completed.returncode or len(completed.stdout) < expected:
        return None
    return numpy.frombuffer(completed.stdout[:expected], dtype=numpy.uint8) \
        .reshape(height, width, 3).astype(numpy.int16)


def skin_mask(frame):
    """Coarse RGB skin rule. Finds the person, not the face."""
    import numpy

    red, green, blue = frame[:, :, 0], frame[:, :, 1], frame[:, :, 2]
    brightest = frame.max(axis=2)
    darkest = frame.min(axis=2)
    return (
        (red > 70) & (green > 36) & (blue > 18)
        & ((brightest - darkest) > 12)
        & (numpy.abs(red - green) > 10)
        & (red > green) & (red > blue)
    )


def free_bands(row_probability, threshold: float, height: int) -> list[dict]:
    """Contiguous rows where the subject is essentially absent."""
    import numpy

    occupied = row_probability > threshold
    bands = []
    start = None
    for y in range(height):
        if not occupied[y] and start is None:
            start = y
        elif occupied[y] and start is not None:
            bands.append({"top": start, "bottom": y, "height": y - start})
            start = None
    if start is not None:
        bands.append({"top": start, "bottom": height, "height": height - start})
    return [band for band in bands if band["height"] >= 60]


def main() -> None:
    utf8_stdout()
    parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    parser.add_argument("video")
    parser.add_argument("--canvas", default="reels")
    parser.add_argument("--samples", type=int, default=10)
    parser.add_argument("--threshold", type=float, default=0.06,
                        help="Доля кадров со «кожей» в строке, выше которой строка занята")
    parser.add_argument("--skip", default="",
                        help="Интервалы, которые не считать (там б-ролл, а не спикер): "
                             "'4.5-8,17.8-21'")
    parser.add_argument("--caption-map", help="Учесть зону титров как занятую")
    parser.add_argument("--report")
    args = parser.parse_args()

    try:
        import numpy
    except ImportError:
        raise SystemExit("Нужен numpy: pip install numpy")

    require_binary("ffmpeg")
    video = Path(args.video).resolve()
    if not video.is_file():
        raise SystemExit(f"Not found: {video}")
    info = probe(video)
    stream = info.get("video") or {}
    width = stream.get("display_width")
    height = stream.get("display_height")
    duration = float(info.get("duration") or 0.0)
    if not width or not height or duration <= 0:
        raise SystemExit("Не удалось прочитать геометрию или длительность")
    board = resolve_canvas(args.canvas)

    skip: list[tuple[float, float]] = []
    for chunk in args.skip.split(","):
        if "-" in chunk:
            low, _, high = chunk.partition("-")
            try:
                skip.append((float(low), float(high)))
            except ValueError:
                continue

    times = []
    step = duration / (args.samples + 1)
    for index in range(1, args.samples + 1):
        at = step * index
        if any(low <= at <= high for low, high in skip):
            continue
        times.append(round(at, 3))
    if not times:
        raise SystemExit("Все выбранные моменты попали в --skip")

    accumulator = numpy.zeros((height, width), dtype=numpy.float32)
    used = 0
    for at in times:
        frame = sample_frame(video, at, width, height)
        if frame is None:
            continue
        accumulator += skin_mask(frame)
        used += 1
    if used == 0:
        raise SystemExit("Не удалось декодировать ни один кадр")
    probability = accumulator / used
    rows = probability.mean(axis=1)

    occupied = numpy.where(rows > args.threshold)[0]
    subject = {
        "top": int(occupied.min()) if occupied.size else None,
        "bottom": int(occupied.max()) if occupied.size else None,
    }
    bands = free_bands(rows, args.threshold, height)

    caption_zone = None
    if args.caption_map:
        payload = json.loads(Path(args.caption_map).read_text(encoding="utf-8"))
        ys = [word["y"] for block in payload.get("blocks", [])
              for word in block.get("words", []) if word.get("y")]
        if ys:
            size = payload.get("nominal_font_size", 60)
            caption_zone = {"top": int(min(ys) - size), "bottom": int(max(ys) + size)}

    safe = board.safe_box
    scored = []
    for band in bands:
        top = band["top"]
        bottom = band["bottom"]
        # Trim against the caption zone: a card over the captions is as bad as a card
        # over the face.
        if caption_zone:
            if top < caption_zone["bottom"] and bottom > caption_zone["top"]:
                if caption_zone["top"] - top > bottom - caption_zone["bottom"]:
                    bottom = min(bottom, caption_zone["top"])
                else:
                    top = max(top, caption_zone["bottom"])
        usable = bottom - top
        if usable < 60:
            continue
        # The bottom of a vertical frame belongs to the platform's own interface.
        in_platform_ui = bottom > safe["bottom"]
        scored.append({
            "top": top,
            "bottom": bottom,
            "height": usable,
            "overlaps_platform_ui": in_platform_ui,
            "recommended": usable >= 200 and not in_platform_ui,
        })
    scored.sort(key=lambda item: (-int(item["recommended"]), -item["height"]))

    best = next((item for item in scored if item["recommended"]), None)
    advice: list[str] = []
    if best:
        advice.append(
            f"Ставь карточку в y {best['top']}..{best['bottom']} и держи её высоту "
            f"не больше {best['height'] - 40} px с запасом на тень."
        )
    else:
        advice.append(
            "Свободной полосы нужного размера нет: спикер занимает почти весь кадр. "
            "Варианты — сделать карточку узкой и вынести в угол, уменьшить её высоту "
            "под самую большую полосу, или отвести карточке отдельный кадр вместо "
            "наложения."
        )
    if subject["top"] is not None and subject["top"] < 120:
        advice.append("Голова начинается почти от верхней кромки: сверху места нет.")

    report = {
        "video": str(video),
        "geometry": f"{width}x{height}",
        "frames_used": used,
        "sampled_at": times,
        "subject_rows": subject,
        "subject_height": (subject["bottom"] - subject["top"])
        if subject["top"] is not None else None,
        "free_bands": scored,
        "best_band": best,
        "platform_safe_box": safe,
        "caption_zone": caption_zone,
        "row_profile": [
            {"y": y, "occupancy": round(float(rows[y:y + 96].mean()), 3)}
            for y in range(0, height, 96)
        ],
        "advice": advice,
        "method": (
            "Грубая маска кожи по RGB, усреднённая по кадрам. Задача не найти лицо, "
            "а найти, где находится человек — для этого достаточно и не нужны "
            "дополнительные зависимости."
        ),
    }
    if args.report:
        Path(args.report).parent.mkdir(parents=True, exist_ok=True)
        Path(args.report).write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n",
                                     encoding="utf-8")
    emit(report)


if __name__ == "__main__":
    main()
