"""
Stegasoo Video Utilities (v4.4.0)

Video format detection, frame extraction, and transcoding for video steganography.

Dependencies:
- ffmpeg binary: Required for all video operations
- numpy: For frame data manipulation
- PIL/Pillow: For frame image handling

Uses ffmpeg for:
- Format detection and metadata extraction
- I-frame extraction
- Video reassembly with FFV1 lossless codec
"""

from __future__ import annotations

import json
import os
import shutil
import subprocess
import tempfile
from pathlib import Path

from .constants import (
    EMBED_MODE_VIDEO_AUTO,
    EMBED_MODE_VIDEO_LSB,
    MAX_VIDEO_DURATION,
    MAX_VIDEO_FILE_SIZE,
    MAX_VIDEO_RESOLUTION,
    MIN_VIDEO_RESOLUTION,
    VALID_VIDEO_EMBED_MODES,
    VIDEO_OUTPUT_CODEC,
    VIDEO_OUTPUT_CONTAINER,
)
from .debug import get_logger
from .exceptions import (
    UnsupportedVideoFormatError,
    VideoTranscodeError,
    VideoValidationError,
)
from .models import ValidationResult, VideoCapacityInfo, VideoInfo

logger = get_logger(__name__)


# =============================================================================
# FFMPEG AVAILABILITY
# =============================================================================


def has_ffmpeg_support() -> bool:
    """Check if ffmpeg is available on the system.

    Returns:
        True if ffmpeg is found on PATH, False otherwise.
    """
    return shutil.which("ffmpeg") is not None


def has_ffprobe_support() -> bool:
    """Check if ffprobe is available on the system.

    Returns:
        True if ffprobe is found on PATH, False otherwise.
    """
    return shutil.which("ffprobe") is not None


def _require_ffmpeg() -> None:
    """Raise error if ffmpeg is not available."""
    if not has_ffmpeg_support():
        raise VideoTranscodeError(
            "ffmpeg is required for video operations. Install ffmpeg on your system."
        )


def _require_ffprobe() -> None:
    """Raise error if ffprobe is not available."""
    if not has_ffprobe_support():
        raise VideoTranscodeError(
            "ffprobe is required for video metadata. Install ffmpeg on your system."
        )


# =============================================================================
# FORMAT DETECTION
# =============================================================================


def detect_video_format(video_data: bytes) -> str:
    """Detect video format from magic bytes.

    Examines the first bytes of video data to identify the container format.

    Magic byte signatures:
    - MP4/M4V: b"ftyp" at offset 4
    - MKV/WebM: b"\\x1a\\x45\\xdf\\xa3" (EBML header)
    - AVI: b"RIFF" at offset 0 + b"AVI " at offset 8
    - MOV: b"ftyp" with "qt" brand or b"moov"/"mdat" early

    Args:
        video_data: Raw video file bytes.

    Returns:
        Format string: "mp4", "mkv", "webm", "avi", "mov", or "unknown".
    """
    if len(video_data) < 12:
        logger.debug("detect_video_format: data too short (%d bytes)", len(video_data))
        return "unknown"

    # MP4/M4V/MOV: "ftyp" atom at offset 4
    if video_data[4:8] == b"ftyp":
        # Check brand for specific type
        brand = video_data[8:12]
        if brand in (b"qt  ", b"mqt "):
            return "mov"
        if brand in (b"isom", b"iso2", b"mp41", b"mp42", b"avc1", b"M4V "):
            return "mp4"
        # Default to mp4 for ftyp containers
        return "mp4"

    # MKV/WebM: EBML header
    if video_data[:4] == b"\x1a\x45\xdf\xa3":
        # Check doctype to distinguish MKV from WebM
        # WebM uses "webm" doctype, MKV uses "matroska"
        # Simple heuristic: search for doctype string in first 64 bytes
        header = video_data[:64]
        if b"webm" in header.lower():
            return "webm"
        return "mkv"

    # AVI: RIFF....AVI
    if video_data[:4] == b"RIFF" and video_data[8:12] == b"AVI ":
        return "avi"

    # MOV without ftyp (older format): check for moov/mdat atoms
    if video_data[4:8] in (b"moov", b"mdat", b"wide", b"free"):
        return "mov"

    return "unknown"


# =============================================================================
# METADATA EXTRACTION
# =============================================================================


def get_video_info(video_data: bytes) -> VideoInfo:
    """Extract video metadata from raw video bytes.

    Uses ffprobe to extract detailed video information including
    resolution, frame rate, duration, codec, and I-frame count.

    Args:
        video_data: Raw video file bytes.

    Returns:
        VideoInfo dataclass with video metadata.

    Raises:
        UnsupportedVideoFormatError: If the format cannot be detected.
        VideoTranscodeError: If metadata extraction fails.
    """
    _require_ffprobe()

    fmt = detect_video_format(video_data)
    if fmt == "unknown":
        raise UnsupportedVideoFormatError(
            "Cannot detect video format. Supported: MP4, MKV, WebM, AVI, MOV."
        )

    # Write to temp file for ffprobe
    with tempfile.NamedTemporaryFile(suffix=f".{fmt}", delete=False) as f:
        f.write(video_data)
        temp_path = f.name

    try:
        # Get stream info
        result = subprocess.run(
            [
                "ffprobe",
                "-v",
                "quiet",
                "-print_format",
                "json",
                "-show_format",
                "-show_streams",
                "-select_streams",
                "v:0",
                temp_path,
            ],
            capture_output=True,
            text=True,
            timeout=60,
        )

        if result.returncode != 0:
            raise VideoTranscodeError(f"ffprobe failed: {result.stderr}")

        info = json.loads(result.stdout)

        # Extract video stream info
        if not info.get("streams"):
            raise VideoTranscodeError("No video stream found in file")

        stream = info["streams"][0]
        format_info = info.get("format", {})

        width = int(stream.get("width", 0))
        height = int(stream.get("height", 0))
        codec = stream.get("codec_name", "unknown")

        # Parse frame rate (can be "30/1" or "29.97")
        fps_str = stream.get("r_frame_rate", "0/1")
        if "/" in fps_str:
            num, den = fps_str.split("/")
            fps = float(num) / float(den) if float(den) > 0 else 0.0
        else:
            fps = float(fps_str)

        # Get duration
        duration = float(stream.get("duration", format_info.get("duration", 0)))

        # Get total frames
        nb_frames = stream.get("nb_frames")
        if nb_frames:
            total_frames = int(nb_frames)
        else:
            # Estimate from duration and fps
            total_frames = int(duration * fps) if fps > 0 else 0

        # Get bitrate
        bitrate = None
        if format_info.get("bit_rate"):
            bitrate = int(format_info["bit_rate"])

        # Count I-frames using ffprobe
        i_frame_count = _count_i_frames(temp_path, timeout=120)

        return VideoInfo(
            width=width,
            height=height,
            fps=fps,
            duration_seconds=duration,
            total_frames=total_frames,
            i_frame_count=i_frame_count,
            format=fmt,
            codec=codec,
            bitrate=bitrate,
        )

    except json.JSONDecodeError as e:
        raise VideoTranscodeError(f"Failed to parse ffprobe output: {e}")
    except subprocess.TimeoutExpired:
        raise VideoTranscodeError("ffprobe timed out")
    finally:
        os.unlink(temp_path)


def _count_i_frames(video_path: str, timeout: int = 120) -> int:
    """Count I-frames (keyframes) in a video file.

    Args:
        video_path: Path to video file.
        timeout: Maximum time in seconds.

    Returns:
        Number of I-frames in the video.
    """
    try:
        result = subprocess.run(
            [
                "ffprobe",
                "-v",
                "quiet",
                "-select_streams",
                "v:0",
                "-show_entries",
                "frame=pict_type",
                "-of",
                "csv=p=0",
                video_path,
            ],
            capture_output=True,
            text=True,
            timeout=timeout,
        )

        if result.returncode != 0:
            logger.warning("Failed to count I-frames: %s", result.stderr)
            return 0

        # Count lines containing 'I'
        return sum(1 for line in result.stdout.strip().split("\n") if line.strip() == "I")

    except subprocess.TimeoutExpired:
        logger.warning("I-frame counting timed out")
        return 0
    except Exception as e:
        logger.warning("I-frame counting failed: %s", e)
        return 0


# =============================================================================
# FRAME EXTRACTION
# =============================================================================


def extract_frames(
    video_data: bytes,
    output_dir: Path | None = None,
    keyframes_only: bool = True,
) -> tuple[list[Path], VideoInfo]:
    """Extract frames from video as PNG images.

    Uses ffmpeg to extract frames from the video. By default extracts only
    I-frames (keyframes) which are more robust to re-encoding.

    Args:
        video_data: Raw video file bytes.
        output_dir: Directory to save frames (temp dir if None).
        keyframes_only: If True, only extract I-frames (keyframes).

    Returns:
        Tuple of (list of frame paths sorted by frame number, VideoInfo).

    Raises:
        VideoTranscodeError: If frame extraction fails.
    """
    _require_ffmpeg()

    fmt = detect_video_format(video_data)
    if fmt == "unknown":
        raise UnsupportedVideoFormatError(
            "Cannot detect video format. Supported: MP4, MKV, WebM, AVI, MOV."
        )

    # Get video info first
    video_info = get_video_info(video_data)

    # Create output directory
    if output_dir is None:
        output_dir = Path(tempfile.mkdtemp(prefix="stegasoo_frames_"))
    else:
        output_dir = Path(output_dir)
        output_dir.mkdir(parents=True, exist_ok=True)

    # Write video to temp file
    with tempfile.NamedTemporaryFile(suffix=f".{fmt}", delete=False) as f:
        f.write(video_data)
        video_path = f.name

    try:
        # Build ffmpeg command
        cmd = [
            "ffmpeg",
            "-i",
            video_path,
            "-vsync",
            "0",
        ]

        if keyframes_only:
            # Extract only I-frames
            cmd.extend(["-vf", "select='eq(pict_type,I)'"])

        # Output as PNG with frame number
        output_pattern = str(output_dir / "frame_%06d.png")
        cmd.extend(["-start_number", "0", output_pattern])

        result = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            timeout=600,  # 10 minute timeout
        )

        if result.returncode != 0:
            raise VideoTranscodeError(f"Frame extraction failed: {result.stderr}")

        # Collect extracted frames
        frames = sorted(output_dir.glob("frame_*.png"))

        if not frames:
            raise VideoTranscodeError("No frames were extracted from video")

        logger.info(
            "Extracted %d %s from video",
            len(frames),
            "I-frames" if keyframes_only else "frames",
        )

        return frames, video_info

    except subprocess.TimeoutExpired:
        raise VideoTranscodeError("Frame extraction timed out")
    finally:
        os.unlink(video_path)


# =============================================================================
# VIDEO REASSEMBLY
# =============================================================================


def reassemble_video(
    frames: list[Path],
    original_video_data: bytes,
    output_path: Path | None = None,
    fps: float | None = None,
    audio_data: bytes | None = None,
) -> bytes:
    """Reassemble frames back into a video file.

    Creates a new video from the modified frames using FFV1 lossless codec
    in an MKV container. This preserves the embedded data perfectly.

    Args:
        frames: List of frame image paths in order.
        original_video_data: Original video bytes (for audio track extraction).
        output_path: Optional output path (temp file if None).
        fps: Frame rate (auto-detected from original if None).
        audio_data: Optional audio track data to mux in.

    Returns:
        Video file bytes (MKV container with FFV1 codec).

    Raises:
        VideoTranscodeError: If reassembly fails.
    """
    _require_ffmpeg()

    if not frames:
        raise VideoTranscodeError("No frames provided for reassembly")

    # Get original video format
    fmt = detect_video_format(original_video_data)

    if fps is None:
        # Use a fixed low framerate for I-frame sequences
        # since I-frames are sparse (typically 1 per 30-60 frames)
        fps = 1.0  # 1 fps for I-frame only videos

    # Create temp directory for work
    with tempfile.TemporaryDirectory(prefix="stegasoo_reassemble_") as temp_dir_str:
        temp_dir = Path(temp_dir_str)

        # Write original video for audio extraction
        original_path = temp_dir / f"original.{fmt}"
        original_path.write_bytes(original_video_data)

        # Create frame list file for ffmpeg
        frame_list = temp_dir / "frames.txt"
        with open(frame_list, "w") as f:
            for frame in frames:
                # FFmpeg concat format
                f.write(f"file '{frame.absolute()}'\n")
                f.write(f"duration {1.0 / fps}\n")

        # Output path
        if output_path is None:
            output_file = temp_dir / f"output.{VIDEO_OUTPUT_CONTAINER}"
        else:
            output_file = Path(output_path)

        # Build ffmpeg command
        cmd = [
            "ffmpeg",
            "-y",  # Overwrite output
            "-f",
            "concat",
            "-safe",
            "0",
            "-i",
            str(frame_list),
        ]

        # Add audio from original video if available
        # Check if original has audio
        has_audio = _video_has_audio(original_path)
        if has_audio:
            cmd.extend(["-i", str(original_path)])

        # Video encoding settings (FFV1 lossless)
        cmd.extend(
            [
                "-c:v",
                VIDEO_OUTPUT_CODEC,
                "-level",
                "3",  # FFV1 level 3 for better compression
                "-coder",
                "1",  # Range coder
                "-context",
                "1",  # Large context
                "-slicecrc",
                "1",  # Error detection
            ]
        )

        # Audio settings
        if has_audio:
            cmd.extend(
                [
                    "-map",
                    "0:v",  # Video from frames
                    "-map",
                    "1:a?",  # Audio from original (if exists)
                    "-c:a",
                    "copy",  # Copy audio without re-encoding
                ]
            )

        cmd.append(str(output_file))

        logger.debug("Running ffmpeg: %s", " ".join(cmd))

        result = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            timeout=600,
        )

        if result.returncode != 0:
            raise VideoTranscodeError(f"Video reassembly failed: {result.stderr}")

        # Read output
        return output_file.read_bytes()


def _video_has_audio(video_path: Path) -> bool:
    """Check if a video file has an audio stream.

    Args:
        video_path: Path to video file.

    Returns:
        True if video has audio, False otherwise.
    """
    try:
        result = subprocess.run(
            [
                "ffprobe",
                "-v",
                "quiet",
                "-select_streams",
                "a:0",
                "-show_entries",
                "stream=index",
                "-of",
                "csv=p=0",
                str(video_path),
            ],
            capture_output=True,
            text=True,
            timeout=30,
        )
        return bool(result.stdout.strip())
    except Exception:
        return False


# =============================================================================
# VALIDATION
# =============================================================================


def validate_video(
    video_data: bytes,
    name: str = "Video",
    check_duration: bool = True,
) -> ValidationResult:
    """Validate video data for steganography.

    Checks:
    - Not empty
    - Not too large (MAX_VIDEO_FILE_SIZE)
    - Valid video format (detectable via magic bytes)
    - Duration within limits (MAX_VIDEO_DURATION) if check_duration=True
    - Resolution within limits (MIN/MAX_VIDEO_RESOLUTION)

    Args:
        video_data: Raw video file bytes.
        name: Descriptive name for error messages (default: "Video").
        check_duration: Whether to enforce duration limit (default: True).

    Returns:
        ValidationResult with video info in details on success.
    """
    if not video_data:
        return ValidationResult.error(f"{name} is required")

    if len(video_data) > MAX_VIDEO_FILE_SIZE:
        size_gb = len(video_data) / (1024**3)
        max_gb = MAX_VIDEO_FILE_SIZE / (1024**3)
        return ValidationResult.error(
            f"{name} too large ({size_gb:.1f} GB). Maximum: {max_gb:.0f} GB"
        )

    # Detect format
    fmt = detect_video_format(video_data)
    if fmt == "unknown":
        return ValidationResult.error(
            f"Could not detect {name} format. " "Supported formats: MP4, MKV, WebM, AVI, MOV."
        )

    # Check ffmpeg availability
    if not has_ffmpeg_support():
        return ValidationResult.error(
            "ffmpeg is required for video processing. Please install ffmpeg."
        )

    # Extract metadata for further validation
    try:
        info = get_video_info(video_data)
    except (VideoTranscodeError, UnsupportedVideoFormatError) as e:
        return ValidationResult.error(f"Could not read {name}: {e}")
    except Exception as e:
        return ValidationResult.error(f"Could not read {name}: {e}")

    # Check duration
    if check_duration and info.duration_seconds > MAX_VIDEO_DURATION:
        return ValidationResult.error(
            f"{name} too long ({info.duration_seconds:.1f}s). "
            f"Maximum: {MAX_VIDEO_DURATION}s ({MAX_VIDEO_DURATION // 60} minutes)"
        )

    # Check resolution
    if info.width < MIN_VIDEO_RESOLUTION[0] or info.height < MIN_VIDEO_RESOLUTION[1]:
        return ValidationResult.error(
            f"{name} resolution too small ({info.width}x{info.height}). "
            f"Minimum: {MIN_VIDEO_RESOLUTION[0]}x{MIN_VIDEO_RESOLUTION[1]}"
        )

    if info.width > MAX_VIDEO_RESOLUTION[0] or info.height > MAX_VIDEO_RESOLUTION[1]:
        return ValidationResult.error(
            f"{name} resolution too large ({info.width}x{info.height}). "
            f"Maximum: {MAX_VIDEO_RESOLUTION[0]}x{MAX_VIDEO_RESOLUTION[1]}"
        )

    # Check I-frame count
    if info.i_frame_count < 1:
        return ValidationResult.error(f"{name} has no I-frames (keyframes) for embedding")

    return ValidationResult.ok(
        width=info.width,
        height=info.height,
        fps=info.fps,
        duration=info.duration_seconds,
        total_frames=info.total_frames,
        i_frame_count=info.i_frame_count,
        format=info.format,
        codec=info.codec,
        bitrate=info.bitrate,
    )


def require_valid_video(video_data: bytes, name: str = "Video") -> None:
    """Validate video, raising VideoValidationError on failure.

    Args:
        video_data: Raw video file bytes.
        name: Descriptive name for error messages.

    Raises:
        VideoValidationError: If validation fails.
    """
    result = validate_video(video_data, name)
    if not result.is_valid:
        raise VideoValidationError(result.error_message)


def validate_video_embed_mode(mode: str) -> ValidationResult:
    """Validate video embedding mode string.

    Args:
        mode: Embedding mode to validate.

    Returns:
        ValidationResult with mode in details on success.
    """
    valid_modes = VALID_VIDEO_EMBED_MODES | {EMBED_MODE_VIDEO_AUTO}
    if mode not in valid_modes:
        return ValidationResult.error(
            f"Invalid video embed_mode: '{mode}'. "
            f"Valid options: {', '.join(sorted(valid_modes))}"
        )
    return ValidationResult.ok(mode=mode)


# =============================================================================
# CAPACITY CALCULATION
# =============================================================================


def calculate_video_capacity(video_data: bytes, embed_mode: str = EMBED_MODE_VIDEO_LSB) -> VideoCapacityInfo:
    """Calculate steganographic capacity for a video file.

    Capacity is based on I-frames only (keyframes). Each I-frame provides
    capacity similar to an image of the same dimensions.

    Args:
        video_data: Raw video file bytes.
        embed_mode: Embedding mode (currently only video_lsb).

    Returns:
        VideoCapacityInfo with capacity details.
    """
    info = get_video_info(video_data)

    # Calculate capacity per I-frame
    # RGB image: 3 bits per pixel (1 bit per channel) / 8 = 0.375 bytes per pixel
    # Subtract overhead per frame for header
    pixels_per_frame = info.width * info.height
    bytes_per_frame = (pixels_per_frame * 3) // 8  # 3 bits per pixel

    # Total capacity across all I-frames
    # Subtract 70 bytes overhead for the encrypted payload header
    from .steganography import ENCRYPTION_OVERHEAD

    total_capacity = (bytes_per_frame * info.i_frame_count) - ENCRYPTION_OVERHEAD

    return VideoCapacityInfo(
        total_frames=info.total_frames,
        i_frames=info.i_frame_count,
        usable_capacity_bytes=max(0, total_capacity),
        embed_mode=embed_mode,
        resolution=(info.width, info.height),
        duration_seconds=info.duration_seconds,
    )
