from __future__ import annotations

import math
import shutil
import subprocess
from pathlib import Path

import numpy as np
from PIL import Image, ImageFilter, ImageOps

from src.core.config import AppConfig
from src.core.models import TrailerBeat


def _run(cmd: list[str]) -> None:
    result = subprocess.run(cmd, capture_output=True)
    if result.returncode != 0:
        raise RuntimeError(result.stderr.decode(errors="replace"))


def _extract_frames(
    video_path: Path,
    start_s: float,
    duration_s: float,
    fps: float,
    out_dir: Path,
    prefix: str,
) -> None:
    out_dir.mkdir(parents=True, exist_ok=True)
    _run([
        "ffmpeg", "-y", "-loglevel", "error",
        "-ss", str(max(0.0, start_s)),
        "-i", str(video_path),
        "-t", str(max(0.04, duration_s)),
        "-vf", f"scale=640:360,fps={fps}",
        str(out_dir / f"{prefix}_%04d.png"),
    ])


def _cropped_image(path: Path, cfg: AppConfig) -> Image.Image:
    image = Image.open(path).convert("L")
    image = _trim_dark_borders(image)
    w, h = image.size
    # Final validation should see the composition. The broader text-safe crop
    # used for coarse search can remove bodies, furniture and lower-frame
    # spatial cues that distinguish otherwise similar face/window shots.
    top = int(h * 0.05)
    bottom = int(h * 0.95)
    return image.crop((0, top, w, bottom))


def _trim_dark_borders(image: Image.Image) -> Image.Image:
    """Remove encoded black matte/pillarbox borders before content scoring."""
    gray = image.convert("L")
    arr = np.asarray(gray, dtype=np.float32)
    if arr.size == 0:
        return image
    h, w = arr.shape[:2]
    col_signal = np.percentile(arr, 90, axis=0)
    row_signal = np.percentile(arr, 90, axis=1)
    active_cols = np.where(col_signal > 18.0)[0]
    active_rows = np.where(row_signal > 18.0)[0]
    if active_cols.size >= max(8, int(w * 0.35)):
        x0 = max(0, int(active_cols[0]) - 2)
        x1 = min(w, int(active_cols[-1]) + 3)
    else:
        x0, x1 = 0, w
    if active_rows.size >= max(8, int(h * 0.35)):
        y0 = max(0, int(active_rows[0]) - 2)
        y1 = min(h, int(active_rows[-1]) + 3)
    else:
        y0, y1 = 0, h
    if x1 - x0 < int(w * 0.35) or y1 - y0 < int(h * 0.35):
        return image
    return image.crop((x0, y0, x1, y1))


def _feature(path: Path, cfg: AppConfig) -> np.ndarray:
    image = _cropped_image(path, cfg)
    w, h = image.size
    image = image.crop((int(w * 0.10), int(h * 0.10), int(w * 0.90), int(h * 0.90)))
    image = ImageOps.equalize(image).filter(ImageFilter.FIND_EDGES).resize((160, 62))
    arr = np.asarray(image, dtype=np.float32)
    return (arr - arr.mean()) / (arr.std() + 1e-6)


def _luma_feature(path: Path, cfg: AppConfig) -> np.ndarray:
    image = ImageOps.equalize(_cropped_image(path, cfg)).resize((160, 80))
    arr = np.asarray(image, dtype=np.float32)
    return (arr - arr.mean()) / (arr.std() + 1e-6)


def _hist_feature(path: Path, cfg: AppConfig) -> np.ndarray:
    image = _trim_dark_borders(Image.open(path).convert("RGB"))
    w, h = image.size
    top = int(h * 0.05)
    bottom = int(h * 0.95)
    arr = np.asarray(image.crop((0, top, w, bottom)).resize((160, 80)), dtype=np.float32)
    hist_parts = []
    for channel in range(3):
        hist, _ = np.histogram(arr[:, :, channel], bins=32, range=(0, 255))
        hist = hist.astype(np.float32)
        hist_parts.append(hist / (hist.sum() + 1e-6))
    return np.concatenate(hist_parts)


def _spatial_hist_feature(path: Path, cfg: AppConfig) -> np.ndarray:
    image = _trim_dark_borders(Image.open(path).convert("RGB"))
    w, h = image.size
    top = int(h * 0.05)
    bottom = int(h * 0.95)
    arr = np.asarray(image.crop((0, top, w, bottom)).resize((160, 80)), dtype=np.float32)
    cells = []
    grid_y = 4
    grid_x = 4
    cell_h = arr.shape[0] // grid_y
    cell_w = arr.shape[1] // grid_x
    for gy in range(grid_y):
        for gx in range(grid_x):
            cell = arr[gy * cell_h:(gy + 1) * cell_h, gx * cell_w:(gx + 1) * cell_w, :]
            for channel in range(3):
                hist, _ = np.histogram(cell[:, :, channel], bins=16, range=(0, 255))
                hist = hist.astype(np.float32)
                cells.append(hist / (hist.sum() + 1e-6))
    return np.concatenate(cells)


def _is_dark(path: Path, cfg: AppConfig) -> bool:
    image = _trim_dark_borders(Image.open(path).convert("L"))
    w, h = image.size
    top = int(h * 0.05)
    bottom = int(h * 0.95)
    arr = np.asarray(image.crop((0, top, w, bottom)), dtype=np.float32)
    return float(arr.mean()) < 28.0 and float(np.percentile(arr, 90)) < 58.0


def _corr(a: np.ndarray, b: np.ndarray) -> float:
    return float((a * b).mean())


def _hist_intersection(a: np.ndarray, b: np.ndarray) -> float:
    return float(np.minimum(a, b).sum() / (np.maximum(a, b).sum() + 1e-6))


def _paired_frame_score(ref_path: Path, src_path: Path, cfg: AppConfig) -> float:
    edge_score = _corr(_feature(ref_path, cfg), _feature(src_path, cfg))
    luma_score = _corr(_luma_feature(ref_path, cfg), _luma_feature(src_path, cfg))
    hist_score = _hist_intersection(_hist_feature(ref_path, cfg), _hist_feature(src_path, cfg))
    spatial_score = _hist_intersection(_spatial_hist_feature(ref_path, cfg), _spatial_hist_feature(src_path, cfg))
    return (
        edge_score * 0.24
        + luma_score * 0.24
        + hist_score * 0.14
        + spatial_score * 0.38
    )


def align_cached_match_by_content(
    beat: TrailerBeat,
    estimated_in_point_s: float,
    cfg: AppConfig,
    search_window_s: float | None = None,
    fps: float = 25.0,
) -> tuple[float, float]:
    """
    Measure the local source offset directly from rendered frame content.

    This is intentionally independent from the global OpenCV matcher: it only
    needs FFmpeg, Pillow and numpy, and it scans a small window around an
    already plausible candidate.
    """
    window_s = (
        search_window_s
        if search_window_s is not None
        else cfg.cv.deep_scan.content_align_window_seconds
    )
    sample_step_s = max(1.0 / fps, cfg.cv.deep_scan.content_align_sample_step_s)
    source_start_s = max(0.0, estimated_in_point_s - window_s)
    source_duration_s = beat.duration_s + (2.0 * window_s) + 0.5

    tmp = cfg.paths.output_dir / "align_tmp" / f"beat_{beat.beat_id:03d}"
    shutil.rmtree(tmp, ignore_errors=True)
    tmp.mkdir(parents=True, exist_ok=True)
    try:
        ref_dir = tmp / "ref"
        src_dir = tmp / "src"
        _extract_frames(beat.trailer_path, beat.start_s, beat.duration_s, fps, ref_dir, "ref")
        _extract_frames(cfg.paths.source_movie, source_start_s, source_duration_s, fps, src_dir, "src")

        ref_frames = sorted(ref_dir.glob("ref_*.png"))
        src_frames = sorted(src_dir.glob("src_*.png"))
        if not ref_frames or not src_frames:
            return estimated_in_point_s, 0.0

        sample_frame_step = max(1, int(round(sample_step_s * fps)))
        min_matchable_frames = max(1, len(ref_frames) - int(round(0.24 * fps)))
        template_offsets: list[int] = []
        templates: list[tuple[int, np.ndarray]] = []
        for idx in range(0, min_matchable_frames, sample_frame_step):
            path = ref_frames[idx]
            if _is_dark(path, cfg):
                continue
            template_offsets.append(idx)
            templates.append((idx, _feature(path, cfg)))

        if len(templates) < 3:
            template_offsets = list(range(0, min_matchable_frames, sample_frame_step))
            templates = [
                (idx, _feature(ref_frames[idx], cfg))
                for idx in template_offsets
            ]

        search_start_frame = 0
        search_end_frame = max(0, len(src_frames) - min_matchable_frames)
        estimated_frame = int(round((estimated_in_point_s - source_start_s) * fps))
        best_frame = estimated_frame
        best_score = -1.0

        for candidate_frame in range(search_start_frame, search_end_frame + 1):
            scores: list[float] = []
            for offset_frame in template_offsets:
                src_idx = candidate_frame + offset_frame
                if src_idx < 0 or src_idx >= len(src_frames):
                    break
                scores.append(_paired_frame_score(ref_frames[offset_frame], src_frames[src_idx], cfg))
            if len(scores) < max(3, math.ceil(len(templates) * 0.65)):
                continue

            avg_score = sum(scores) / len(scores)
            min_score = min(scores)
            score = (avg_score * 0.68) + (min_score * 0.32)
            if score > best_score + 0.003:
                best_score = score
                best_frame = candidate_frame
            elif score >= best_score - 0.003 and abs(candidate_frame - estimated_frame) < abs(best_frame - estimated_frame):
                best_frame = candidate_frame

        return source_start_s + (best_frame / fps), max(0.0, best_score)
    finally:
        shutil.rmtree(tmp, ignore_errors=True)
