"""Natural eye motion: blinks + subtle iris tremor.

Final-pass post-processor for compiled blendshape targets. Adds:
  1. Periodic blinks (Poisson-distributed, suppressed during emotional eye peaks)
  2. Subtle iris drift (low-frequency smoothed noise on eye-look channels)

Deterministic per scenario: same scenario_id always produces the same eye motion.
"""
from __future__ import annotations

import hashlib
import numpy as np

from .constants import NAME_TO_IDX

# Blendshape indices
IDX_BLINK_L = NAME_TO_IDX["eyeBlinkLeft"]
IDX_BLINK_R = NAME_TO_IDX["eyeBlinkRight"]
IDX_LOOK_DOWN_L = NAME_TO_IDX["eyeLookDownLeft"]
IDX_LOOK_DOWN_R = NAME_TO_IDX["eyeLookDownRight"]
IDX_LOOK_IN_L = NAME_TO_IDX["eyeLookInLeft"]
IDX_LOOK_IN_R = NAME_TO_IDX["eyeLookInRight"]
IDX_LOOK_OUT_L = NAME_TO_IDX["eyeLookOutLeft"]
IDX_LOOK_OUT_R = NAME_TO_IDX["eyeLookOutRight"]
IDX_LOOK_UP_L = NAME_TO_IDX["eyeLookUpLeft"]
IDX_LOOK_UP_R = NAME_TO_IDX["eyeLookUpRight"]
IDX_SQUINT_L = NAME_TO_IDX["eyeSquintLeft"]
IDX_SQUINT_R = NAME_TO_IDX["eyeSquintRight"]
IDX_WIDE_L = NAME_TO_IDX["eyeWideLeft"]
IDX_WIDE_R = NAME_TO_IDX["eyeWideRight"]


def _seeded_rng(seed_str: str) -> np.random.Generator:
    """Deterministic RNG from arbitrary string (e.g. scenario_id)."""
    h = hashlib.md5(seed_str.encode("utf-8")).hexdigest()
    return np.random.default_rng(int(h[:16], 16))


def _smoothed_noise(T: int, rng: np.random.Generator,
                     freq_hz: float = 1.5, fps: int = 30) -> np.ndarray:
    """Generate length-T noise band-limited around freq_hz.

    Method: white noise → moving average whose window matches the period.
    Result drifts smoothly at ~freq_hz, no high-frequency jitter.
    """
    window = max(3, int(fps / freq_hz))
    raw = rng.standard_normal(T + window)
    # cumulative-sum trick for fast box average
    cs = np.cumsum(np.insert(raw, 0, 0))
    smoothed = (cs[window:] - cs[:-window]) / window
    smoothed = smoothed[:T]
    # Normalize to roughly unit std
    s = smoothed.std()
    if s > 1e-6:
        smoothed = smoothed / s
    return smoothed.astype(np.float32)


def _blink_kernel() -> np.ndarray:
    """5-frame triangular blink shape, peak 0.70 at center frame.

    Was 0.95 (ARKit-standard "fully closed eye"). Visual review on the
    Ready Player Me / Wolf3D avatar showed 0.95 looked like squeezing the
    eyes shut tightly — not a natural blink. The avatar's visible blink
    lives on the `eyesClosed` morph; at 0.95 that morph is near its tight-
    closure end of travel. Peak 0.70 gives a relaxed natural blink shape
    that reads correctly on this avatar.

    Values scaled proportionally from the original [0.30, 0.75, 0.95,
    0.75, 0.30] by 0.70/0.95 = 0.737, then rounded.
    """
    return np.array([0.22, 0.55, 0.70, 0.55, 0.22], dtype=np.float32)


def add_blinks(target: np.ndarray, seed_str: str, fps: int = 30,
               mean_interval_s: float = 4.0,
               wide_suppress: float = 0.85,
               squint_suppress: float = 0.85) -> np.ndarray:
    """Add Poisson-distributed blinks. Only suppressed when the preset is
    holding the eyes very wide (strong surprise) or hard-squinted (peak
    anger/laughter). Moderate squint (anger ~0.6) still allows blinks."""
    out = target.copy()
    T = out.shape[0]
    rng = _seeded_rng(seed_str + "::blink")

    kernel = _blink_kernel()
    klen = len(kernel)
    half = klen // 2

    mean_frames = mean_interval_s * fps
    # First blink within first 60% of clip (or first ~2s, whichever is sooner)
    first_max = max(int(0.6 * T), int(2.0 * fps))
    t = int(rng.integers(half, max(half + 1, min(T - half - 1, first_max))))

    blink_count = 0

    def _try_blink(center: int) -> bool:
        lo, hi = max(0, center - half), min(T, center + half + 1)
        wide = max(out[lo:hi, IDX_WIDE_L].max(), out[lo:hi, IDX_WIDE_R].max())
        squint = max(out[lo:hi, IDX_SQUINT_L].max(), out[lo:hi, IDX_SQUINT_R].max())
        if wide < wide_suppress and squint < squint_suppress:
            for i, k in enumerate(kernel):
                fi = center - half + i
                if 0 <= fi < T:
                    out[fi, IDX_BLINK_L] = max(out[fi, IDX_BLINK_L], k)
                    out[fi, IDX_BLINK_R] = max(out[fi, IDX_BLINK_R], k)
            return True
        return False

    while t < T - half:
        if _try_blink(t):
            blink_count += 1
        # Next blink: exponential gap, clipped to [1.5, 8]s for naturalness
        gap = rng.exponential(mean_frames)
        gap = float(np.clip(gap, 1.5 * fps, 8.0 * fps))
        t += int(gap)

    # Guarantee at least one blink for clips >= 1.5s by scanning for any
    # un-suppressed window if none landed naturally
    if blink_count == 0 and T >= int(1.5 * fps):
        # Try random positions, then sweep, until we find an open window
        for center in rng.permutation(np.arange(half, T - half)):
            if _try_blink(int(center)):
                break

    return out


def add_iris_tremor(target: np.ndarray, seed_str: str, fps: int = 30,
                    horiz_amp: float = 0.012,
                    vert_amp: float = 0.008,
                    drift_hz: float = 0.6) -> np.ndarray:
    """Add subtle continuous iris drift on the eyeLook channels.

    Both eyes stay synchronized (no cross-eyed). Amplitude is intentionally
    small (~0.04 of [0,1] range) so it reads as life, not as looking around.
    """
    out = target.copy()
    T = out.shape[0]
    rng = _seeded_rng(seed_str + "::iris")

    h = _smoothed_noise(T, rng, freq_hz=drift_hz, fps=fps) * horiz_amp
    v = _smoothed_noise(T, rng, freq_hz=drift_hz, fps=fps) * vert_amp

    h_pos = np.clip(h, 0, None)   # rightward
    h_neg = np.clip(-h, 0, None)  # leftward
    v_pos = np.clip(v, 0, None)   # upward
    v_neg = np.clip(-v, 0, None)  # downward

    # Horizontal: same-direction synchronization
    # rightward = lookIn(L) + lookOut(R)
    # leftward  = lookOut(L) + lookIn(R)
    out[:, IDX_LOOK_IN_L] = np.maximum(out[:, IDX_LOOK_IN_L], h_pos)
    out[:, IDX_LOOK_OUT_R] = np.maximum(out[:, IDX_LOOK_OUT_R], h_pos)
    out[:, IDX_LOOK_OUT_L] = np.maximum(out[:, IDX_LOOK_OUT_L], h_neg)
    out[:, IDX_LOOK_IN_R] = np.maximum(out[:, IDX_LOOK_IN_R], h_neg)

    # Vertical: symmetric
    out[:, IDX_LOOK_UP_L] = np.maximum(out[:, IDX_LOOK_UP_L], v_pos)
    out[:, IDX_LOOK_UP_R] = np.maximum(out[:, IDX_LOOK_UP_R], v_pos)
    out[:, IDX_LOOK_DOWN_L] = np.maximum(out[:, IDX_LOOK_DOWN_L], v_neg)
    out[:, IDX_LOOK_DOWN_R] = np.maximum(out[:, IDX_LOOK_DOWN_R], v_neg)

    return np.clip(out, 0.0, 1.0).astype(np.float32)


def apply_eye_motion(target: np.ndarray, seed_str: str, fps: int = 30,
                      blink_interval_s: float = 4.0,
                      iris_horiz_amp: float = 0.012,
                      iris_vert_amp: float = 0.008) -> np.ndarray:
    """Convenience wrapper: blinks + iris tremor in one pass."""
    out = add_blinks(target, seed_str, fps=fps, mean_interval_s=blink_interval_s)
    out = add_iris_tremor(out, seed_str, fps=fps,
                          horiz_amp=iris_horiz_amp, vert_amp=iris_vert_amp)
    return out
