"""
src/cv/fingerprinting.py — Image fingerprinting for the Vibe Check phase

Responsibilities (Single Responsibility Principle):
  - Text-Safe Crop: strip top/bottom fractions to hide logos & letterbox
  - Luma + Saturation histogram extraction (scale-invariant)
  - Perceptual hash (pHash) via imagehash

This module is PURELY functional — no file I/O, no video decoding,
no search logic. It takes numpy arrays and returns numeric descriptors.
"""

from __future__ import annotations

import pickle
from typing import TYPE_CHECKING

import cv2
import numpy as np

try:
    import imagehash
    from PIL import Image as PilImage
    _HAS_IMAGEHASH = True
except ImportError:
    _HAS_IMAGEHASH = False

if TYPE_CHECKING:
    from src.core.config import VibeCheckConfig


# ---------------------------------------------------------------------------
# Text-Safe Crop
# ---------------------------------------------------------------------------

def text_safe_crop(
    frame: np.ndarray,
    crop_top: float,
    crop_bottom: float,
) -> np.ndarray:
    """
    Remove the top and bottom fractions of a frame.

    This eliminates title cards, logos (top) and letterbox / subtitles
    (bottom) before any colour analysis, preventing false positives.

    Args:
        frame:       BGR or greyscale frame as (H, W[, C]) ndarray.
        crop_top:    Fraction [0, 1) of height to remove from the top.
        crop_bottom: Fraction [0, 1) of height to remove from the bottom.

    Returns:
        Cropped view (no copy — avoids memory overhead).

    Raises:
        ValueError: If crop fractions are out of range or overlap.
    """
    if not (0.0 <= crop_top < 1.0):
        raise ValueError(f"crop_top must be in [0, 1); got {crop_top}")
    if not (0.0 <= crop_bottom < 1.0):
        raise ValueError(f"crop_bottom must be in [0, 1); got {crop_bottom}")
    if crop_top + crop_bottom >= 1.0:
        raise ValueError(
            f"crop_top ({crop_top}) + crop_bottom ({crop_bottom}) must be < 1.0"
        )

    h = frame.shape[0]
    y_start = int(h * crop_top)
    y_end   = int(h * (1.0 - crop_bottom))
    return frame[y_start:y_end]


# ---------------------------------------------------------------------------
# Histogram extraction
# ---------------------------------------------------------------------------

def extract_hs_histograms(
    frame_bgr: np.ndarray,
    bins_luma: int | None = None,
    bins_sat: int | None = None,
    *,
    bins_hue: int | None = None,
) -> tuple[np.ndarray, np.ndarray]:
    """
    Compute normalised Luma (Value) and Saturation histograms from a BGR frame.

    We use Luma and Saturation (ignoring Hue) because Hue is highly sensitive
    to color grading differences between the trailer and the source movie.

    Args:
        frame_bgr: BGR frame (H, W, 3) uint8.
        bins_luma: Number of histogram bins for the Luma channel [0, 256).
        bins_hue:  Backwards-compatible alias for bins_luma.
        bins_sat:  Number of histogram bins for the Saturation channel [0, 256).

    Returns:
        (luma_hist, sat_hist) — each a 1-D float32 ndarray, L2-normalised.
    """
    if bins_luma is None:
        bins_luma = bins_hue
    elif bins_hue is not None and bins_hue != bins_luma:
        raise ValueError("bins_hue is an alias for bins_luma; pass only one value")
    if bins_luma is None or bins_sat is None:
        raise TypeError("bins_luma/bins_hue and bins_sat are required")

    hsv = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2HSV)
    luma = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2GRAY)

    # Use perceptual grayscale luma rather than HSV Value. Value would make
    # saturated red and blue look identical, weakening the scene-level filter.
    luma_hist = cv2.calcHist(
        [luma], [0], None, [bins_luma], [0, 256]
    ).flatten().astype(np.float32)

    sat_hist = cv2.calcHist(
        [hsv], [1], None, [bins_sat], [0, 256]
    ).flatten().astype(np.float32)

    # L2-normalise so scene size doesn't affect scores
    cv2.normalize(luma_hist, luma_hist, alpha=1.0, norm_type=cv2.NORM_L2)
    cv2.normalize(sat_hist,  sat_hist,  alpha=1.0, norm_type=cv2.NORM_L2)

    return luma_hist, sat_hist


def compare_histograms(
    hist_a: np.ndarray,
    hist_b: np.ndarray,
    method: int,
) -> float:
    """
    Compare two histograms using cv2.compareHist.

    Args:
        hist_a, hist_b: 1-D float32 ndarrays of identical shape.
        method:         cv2.HISTCMP_* constant (e.g. cv2.HISTCMP_CORREL = 0).

    Returns:
        Raw score from cv2.compareHist (range depends on method).
        For CORREL: [-1, 1], higher = more similar.
        For BHATTACHARYYA: [0, 1], lower = more similar.
    """
    return float(cv2.compareHist(hist_a, hist_b, method))


# ---------------------------------------------------------------------------
# Perceptual Hash
# ---------------------------------------------------------------------------

def compute_phash(frame_bgr: np.ndarray, hash_size: int = 8) -> str:
    """
    Compute a perceptual hash (pHash) of a BGR frame.

    pHash is rotation- and scale-invariant; it catches visual similarity
    even when resolution differs between trailer proxy and source movie.

    Args:
        frame_bgr: BGR frame (H, W, 3) uint8.
        hash_size: DCT block size; 8 → 64-bit hash (default).

    Returns:
        Hex string representation of the 64-bit hash (e.g. "f8e0e0e0...").

    Raises:
        RuntimeError: If imagehash is not installed.
    """
    if not _HAS_IMAGEHASH:
        raise RuntimeError(
            "imagehash is not installed. Run: pip install imagehash"
        )
    rgb   = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)
    pil   = PilImage.fromarray(rgb)
    phash = imagehash.phash(pil, hash_size=hash_size)
    return str(phash)


def phash_distance(hash_a: str, hash_b: str) -> int:
    """
    Compute Hamming distance between two pHash hex strings.

    Args:
        hash_a, hash_b: Hex strings as returned by compute_phash().

    Returns:
        Integer Hamming distance [0, 64].  0 = identical.
    """
    if not _HAS_IMAGEHASH:
        raise RuntimeError("imagehash is not installed.")
    return int(imagehash.hex_to_hash(hash_a) - imagehash.hex_to_hash(hash_b))


# ---------------------------------------------------------------------------
# Serialisation helpers (histograms ↔ bytes for caching)
# ---------------------------------------------------------------------------

def hist_to_bytes(hist: np.ndarray) -> bytes:
    """Serialise a numpy histogram array for storage in a Scene/Beat model."""
    return pickle.dumps(hist, protocol=pickle.HIGHEST_PROTOCOL)


def bytes_to_hist(data: bytes) -> np.ndarray:
    """Deserialise a numpy histogram array from bytes."""
    return pickle.loads(data)  # noqa: S301  (trusted internal cache only)


# ---------------------------------------------------------------------------
# High-level convenience: fingerprint one frame using config
# ---------------------------------------------------------------------------

def fingerprint_frame(
    frame_bgr: np.ndarray,
    cfg: "VibeCheckConfig",
) -> tuple[bytes, bytes, str]:
    """
    Apply Text-Safe Crop, histogram extraction, and pHash in one call.

    Args:
        frame_bgr: Full BGR frame (H, W, 3) uint8.
        cfg:       VibeCheckConfig carrying crop fractions and bin counts.

    Returns:
        (luma_hist_bytes, sat_hist_bytes, phash_hex)
    """
    cropped          = text_safe_crop(frame_bgr, cfg.crop_top_fraction, cfg.crop_bottom_fraction)
    luma_hist, sat_hist = extract_hs_histograms(cropped, cfg.hist_bins_hue, cfg.hist_bins_saturation)
    phash_hex        = compute_phash(cropped)

    return hist_to_bytes(luma_hist), hist_to_bytes(sat_hist), phash_hex
