#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
offline_bg.lighting — ① 会话开始时从摄像头帧估出「摄像头画像」。

画像 = 主光方向(dx,dy,cls) + 锐度 + 噪声σ + 一张参考帧。
后面 ② 拿方向去重打光，④ 拿锐度/色彩/噪声去做画质降级。

🔴 传进来的帧必须是**连续帧**（相邻采样）：噪声估计走时域差分，
   隔帧采样会把运动当成噪声，估出来的 σ 全是假的。
"""
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional

import cv2
import numpy as np

from .config import DEFAULT, OfflineBGConfig
from .validated import estimate_light_dir, estimate_noise_sigma, laplacian_sharpness

_FACE_APP = None


def get_face_app(det_size=(640, 640), providers=None):
    """insightface FaceAnalysis 单例。

    🔴 CPU 版 onnxruntime 会遮蔽 GPU 版（insightface 自己会带入），
       provider 里没有 CUDAExecutionProvider 时这里直接报出来，别让它静默跑 CPU。
    """
    global _FACE_APP
    if _FACE_APP is None:
        from insightface.app import FaceAnalysis
        import onnxruntime as ort
        avail = ort.get_available_providers()
        if providers is None:
            providers = ["CUDAExecutionProvider", "CPUExecutionProvider"] \
                if "CUDAExecutionProvider" in avail else ["CPUExecutionProvider"]
        if "CUDAExecutionProvider" not in avail:
            print(f"[lighting] ⚠️ onnxruntime 无 CUDA provider（现有 {avail}），"
                  f"人脸检测会跑 CPU。见 CLAUDE.md §5 两层故障排查。")
        app = FaceAnalysis(name="buffalo_l", providers=providers)
        app.prepare(ctx_id=0, det_size=det_size)
        _FACE_APP = app
    return _FACE_APP


def classify_dir(dx: float, dy: float, thresh: float) -> str:
    """由 dx,dy 判方向。与 validated.estimate_light_dir 内部规则一致——
    多帧聚合后需要对「聚合后的 dx,dy」重新判类，故在此镜像同一套规则。"""
    if float(np.hypot(dx, dy)) < thresh:
        return "front"
    if abs(dx) >= abs(dy):
        return "left" if dx > 0 else "right"
    return "top" if dy > 0 else "bottom"


def background_mask(shape, faces, expand_x=1.6, up=0.8, min_bg_ratio=0.15):
    """由人脸框估出「画面里不是人物的区域」（True=背景）。

    🔴 为什么需要：画质匹配拿摄像头帧当色彩/锐度基准，而摄像头帧里人脸+人身占很大
       面积。不排除人物，Reinhard 会把肤色统计压到背景上（实测：AI 人像参考→背景发
       蓝紫；低对比真实素材参考→背景成灰雾）。锐度同理——人脸通常是对焦最实的地方。
    做法：人脸框左右各扩 1.6 倍宽、上扩 0.8 倍高、向下直到画面底（覆盖头+躯干），
    这是网络摄像头半身构图的典型占位。背景剩得太少（<15%）就退回整帧，别拿几十个
    像素去估统计量。
    """
    h, w = shape[:2]
    m = np.ones((h, w), bool)
    for f in faces:
        x1, y1, x2, y2 = f
        fw, fh = max(x2 - x1, 1), max(y2 - y1, 1)
        cx0 = int(max(x1 - expand_x * fw, 0)); cx1 = int(min(x2 + expand_x * fw, w))
        cy0 = int(max(y1 - up * fh, 0))
        m[cy0:h, cx0:cx1] = False
    return m if m.mean() >= min_bg_ratio else np.ones((h, w), bool)


@dataclass
class CameraProfile:
    """摄像头画像。ref_frame 是数组，不进 json；to_dict() 只出可序列化字段。"""
    dx: float = 0.0
    dy: float = 0.0
    mag: float = 0.0
    cls: str = "front"
    votes: Dict[str, int] = field(default_factory=dict)
    n_frames: int = 0
    n_faces: int = 0
    sharpness: float = 0.0
    noise_sigma: float = 0.0
    frame_size: Optional[tuple] = None          # (w, h)
    ref_frame: Optional[np.ndarray] = None      # 色彩/锐度基准帧
    bg_mask: Optional[np.ndarray] = None        # ref_frame 上「非人物」区域（True=背景）
    frames: Optional[List[np.ndarray]] = None   # 供 ④ 估噪声用（可为 None）
    light_reliable: bool = False                # 这次方向测量是否可信（检到过人脸）

    def to_dict(self) -> Dict[str, Any]:
        return {
            "dx": round(self.dx, 4), "dy": round(self.dy, 4), "mag": round(self.mag, 4),
            "cls": self.cls, "votes": self.votes,
            "n_frames": self.n_frames, "n_faces": self.n_faces,
            "sharpness": round(self.sharpness, 3),
            "noise_sigma": round(self.noise_sigma, 4),
            "frame_size": list(self.frame_size) if self.frame_size else None,
            "light_reliable": self.light_reliable,
            # 背景区占比：<1 说明人物被成功排除在色彩/锐度统计之外；
            # =1 说明没检到人脸或人物几乎占满画面（按设计回退整帧）
            "bg_ratio": round(float(self.bg_mask.mean()), 4) if self.bg_mask is not None else 1.0,
        }


def profile_camera(frames: List[np.ndarray],
                   face_app=None,
                   cfg: OfflineBGConfig = DEFAULT) -> CameraProfile:
    """从若干**连续**摄像头帧算出画像。

    聚合用中位数而不是均值：某一帧人脸检测框歪了 / 恰好眨眼 / 恰好一个高光，
    均值会被拖走，中位数不会。
    """
    if not frames:
        raise ValueError("profile_camera 需要至少 1 帧")

    h, w = frames[0].shape[:2]
    dxs, dys, votes = [], [], {}
    if face_app is None:
        face_app = get_face_app()

    for f in frames:
        r = estimate_light_dir(f, face_app, thresh=cfg.light_thresh)
        if r is None:                      # 这帧没检到人脸，跳过（不算失败）
            continue
        dxs.append(r["dx"]); dys.append(r["dy"])
        votes[r["cls"]] = votes.get(r["cls"], 0) + 1

    n_faces = len(dxs)
    if n_faces >= cfg.min_face_frames:
        dx, dy = float(np.median(dxs)), float(np.median(dys))
        mag = float(np.hypot(dx, dy))
        cls = classify_dir(dx, dy, cfg.light_thresh)
        # light_reliable = 「这次测量本身可不可信」（检到过人脸），
        # 而不是「方向显不显著」（那个直接看 cls != front 就行）。
        # 早期写成后者会导致 front_strategy=radial 永远触发不了：front 恒等于不可信。
        reliable = True
    else:
        # 一帧人脸都没检到：不瞎猜方向，退化成"无方向光"，只做画质匹配。
        dx = dy = mag = 0.0
        cls, reliable = "front", False

    sigma = estimate_noise_sigma(frames) if len(frames) > 1 else 0.0
    # 参考帧取锐度中位数那帧：避开运动模糊帧和偶发噪点尖峰帧
    ref = frames[int(np.argsort([laplacian_sharpness(f) for f in frames])[len(frames) // 2])]

    # 参考帧上的人物占位 → 背景掩膜。色彩/锐度基准只从背景区域取，别把人脸算进去。
    boxes = [f.bbox for f in face_app.get(ref)] if ref is not None else []
    bg_mask = background_mask(ref.shape, boxes) if boxes else None
    # 这个字段只用于展示/日志。真正用于匹配的锐度目标在 quality.match_quality 里算
    # （整帧 + 纹理块高分位口径），两者口径不同，别拿这个字段去做匹配判断。
    sharps = [laplacian_sharpness(f) for f in frames]

    return CameraProfile(
        dx=dx, dy=dy, mag=mag, cls=cls, votes=votes,
        n_frames=len(frames), n_faces=n_faces,
        sharpness=float(np.median(sharps)),
        noise_sigma=float(sigma) * cfg.noise_gain,
        frame_size=(w, h), ref_frame=ref, bg_mask=bg_mask, frames=frames,
        light_reliable=reliable,
    )


def frames_from_video(path: str, n: int = 9, start_ratio: float = 0.35) -> List[np.ndarray]:
    """从视频里取 n 帧**连续**帧（默认从 35% 处开始，避开片头黑场）。
    真实产线里这一步换成从摄像头 WebRTC 流连续抓 n 帧，其余不变。"""
    cap = cv2.VideoCapture(path)
    if not cap.isOpened():
        raise FileNotFoundError(f"打不开视频: {path}")
    total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
    if total > n:
        cap.set(cv2.CAP_PROP_POS_FRAMES, int(total * start_ratio))
    out = []
    while len(out) < n:
        ok, fr = cap.read()
        if not ok:
            break
        out.append(fr)
    cap.release()
    if not out:
        raise RuntimeError(f"视频读不出帧: {path}")
    return out
