"""FFmpeg wrapper for video assembly.

This module provides functionality to concatenate video clips
into a final video using FFmpeg, including background music mixing.
"""

from __future__ import annotations

import logging
import shutil
import subprocess
from pathlib import Path
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from sip_videogen.models.music import GeneratedMusic

logger = logging.getLogger(__name__)


class FFmpegError(Exception):
    """Exception raised for FFmpeg-related errors."""


class FFmpegAssembler:
    """FFmpeg wrapper for concatenating video clips.

    Requires FFmpeg to be installed on the system.
    Install via: brew install ffmpeg (macOS) or apt install ffmpeg (Linux).
    """

    def __init__(self):
        """Initialize FFmpeg assembler and verify FFmpeg is available."""
        self._verify_ffmpeg_installed()

    def _verify_ffmpeg_installed(self) -> None:
        """Verify that FFmpeg is installed and accessible.

        Raises:
            FFmpegError: If FFmpeg is not found in PATH.
        """
        if shutil.which("ffmpeg") is None:
            raise FFmpegError(
                "FFmpeg not found. Please install FFmpeg:\n"
                "  macOS: brew install ffmpeg\n"
                "  Linux: apt install ffmpeg\n"
                "  Windows: https://ffmpeg.org/download.html"
            )
        logger.debug("FFmpeg found in PATH")

    def concatenate_clips(
        self,
        clip_paths: list[Path],
        output_path: Path,
        reencode: bool = False,
    ) -> Path:
        """Concatenate video clips into a single video.

        Args:
            clip_paths: List of paths to video clips, in order.
            output_path: Path for the final concatenated video.
            reencode: If True, re-encode the video (slower but more compatible).
                     If False, use stream copy (faster but requires same codecs).

        Returns:
            Path to the concatenated video file.

        Raises:
            FFmpegError: If concatenation fails or no clips provided.
        """
        if not clip_paths:
            raise FFmpegError("No video clips provided for concatenation")

        # Verify all clips exist
        for clip in clip_paths:
            if not clip.exists():
                raise FFmpegError(f"Video clip not found: {clip}")

        # Create output directory if needed
        output_path.parent.mkdir(parents=True, exist_ok=True)

        # Create concat file listing all clips
        concat_file = output_path.parent / f".concat_list_{output_path.stem}.txt"
        try:
            with open(concat_file, "w") as f:
                for clip in clip_paths:
                    # Escape single quotes in path
                    escaped_path = str(clip.absolute()).replace("'", "'\\''")
                    f.write(f"file '{escaped_path}'\n")

            logger.info(
                "Concatenating %d clips into %s",
                len(clip_paths),
                output_path,
            )

            # Build FFmpeg command
            cmd = self._build_concat_command(concat_file, output_path, reencode)

            # Execute FFmpeg
            result = subprocess.run(
                cmd,
                check=True,
                capture_output=True,
                text=True,
            )

            logger.debug("FFmpeg stdout: %s", result.stdout)
            if result.stderr:
                logger.debug("FFmpeg stderr: %s", result.stderr)

            logger.info("Successfully created: %s", output_path)
            return output_path

        except subprocess.CalledProcessError as e:
            error_msg = e.stderr if e.stderr else str(e)
            raise FFmpegError(f"FFmpeg concatenation failed: {error_msg}") from e
        finally:
            # Cleanup concat file
            if concat_file.exists():
                concat_file.unlink()

    def _build_concat_command(
        self,
        concat_file: Path,
        output_path: Path,
        reencode: bool,
    ) -> list[str]:
        """Build the FFmpeg command for concatenation.

        Args:
            concat_file: Path to the concat list file.
            output_path: Path for the output video.
            reencode: Whether to re-encode the video.

        Returns:
            List of command arguments.
        """
        cmd = [
            "ffmpeg",
            "-y",  # Overwrite output file if exists
            "-f",
            "concat",
            "-safe",
            "0",  # Allow absolute paths
            "-i",
            str(concat_file),
        ]

        if reencode:
            # Re-encode for maximum compatibility
            cmd.extend(
                [
                    "-c:v",
                    "libx264",
                    "-preset",
                    "medium",
                    "-crf",
                    "23",
                    "-c:a",
                    "aac",
                    "-b:a",
                    "128k",
                ]
            )
        else:
            # Stream copy (fast, but requires same codecs)
            cmd.extend(["-c", "copy"])

        cmd.append(str(output_path))
        return cmd

    def get_video_duration(self, video_path: Path) -> float:
        """Get the duration of a video file in seconds.

        Args:
            video_path: Path to the video file.

        Returns:
            Duration in seconds.

        Raises:
            FFmpegError: If ffprobe fails or video not found.
        """
        if not video_path.exists():
            raise FFmpegError(f"Video file not found: {video_path}")

        cmd = [
            "ffprobe",
            "-v",
            "error",
            "-show_entries",
            "format=duration",
            "-of",
            "default=noprint_wrappers=1:nokey=1",
            str(video_path),
        ]

        try:
            result = subprocess.run(
                cmd,
                check=True,
                capture_output=True,
                text=True,
            )
            return float(result.stdout.strip())
        except subprocess.CalledProcessError as e:
            raise FFmpegError(f"Failed to get video duration: {e.stderr}") from e
        except ValueError as e:
            raise FFmpegError(f"Invalid duration value: {e}") from e

    def get_video_info(self, video_path: Path) -> dict:
        """Get detailed information about a video file.

        Args:
            video_path: Path to the video file.

        Returns:
            Dictionary with video information (codec, resolution, duration, etc.).

        Raises:
            FFmpegError: If ffprobe fails or video not found.
        """
        if not video_path.exists():
            raise FFmpegError(f"Video file not found: {video_path}")

        cmd = [
            "ffprobe",
            "-v",
            "error",
            "-select_streams",
            "v:0",
            "-show_entries",
            "stream=codec_name,width,height,r_frame_rate,duration",
            "-show_entries",
            "format=duration,size",
            "-of",
            "json",
            str(video_path),
        ]

        try:
            result = subprocess.run(
                cmd,
                check=True,
                capture_output=True,
                text=True,
            )
            import json

            data = json.loads(result.stdout)

            info = {"path": str(video_path)}

            # Extract stream info
            if data.get("streams"):
                stream = data["streams"][0]
                info["codec"] = stream.get("codec_name")
                info["width"] = stream.get("width")
                info["height"] = stream.get("height")
                if stream.get("r_frame_rate"):
                    # Parse frame rate (e.g., "30/1" -> 30.0)
                    fps_parts = stream["r_frame_rate"].split("/")
                    if len(fps_parts) == 2 and int(fps_parts[1]) != 0:
                        info["fps"] = int(fps_parts[0]) / int(fps_parts[1])

            # Extract format info
            if data.get("format"):
                fmt = data["format"]
                if fmt.get("duration"):
                    info["duration"] = float(fmt["duration"])
                if fmt.get("size"):
                    info["size_bytes"] = int(fmt["size"])

            return info
        except subprocess.CalledProcessError as e:
            raise FFmpegError(f"Failed to get video info: {e.stderr}") from e
        except (ValueError, KeyError) as e:
            raise FFmpegError(f"Failed to parse video info: {e}") from e

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

        Args:
            video_path: Path to the video file.

        Returns:
            True if the video has at least one audio stream, False otherwise.
        """
        if not video_path.exists():
            return False

        cmd = [
            "ffprobe",
            "-v",
            "error",
            "-select_streams",
            "a",  # Select audio streams only
            "-show_entries",
            "stream=index",
            "-of",
            "csv=p=0",
            str(video_path),
        ]

        try:
            result = subprocess.run(
                cmd,
                check=True,
                capture_output=True,
                text=True,
            )
            # If there's any output, there's at least one audio stream
            return bool(result.stdout.strip())
        except subprocess.CalledProcessError:
            return False

    def trim_clip_to_duration(
        self,
        input_path: Path,
        output_path: Path,
        target_duration: int,
    ) -> Path:
        """Trim a video clip to target duration by removing equal portions from start and end.

        VEO generates 8-second clips when using reference images. This method trims
        the clip to the target duration by removing equal amounts from the beginning
        and end, preserving the core action in the middle.

        Args:
            input_path: Path to the input video clip (typically 8 seconds).
            output_path: Path for the trimmed output clip.
            target_duration: Target duration in seconds (4, 6, or 8).

        Returns:
            Path to the trimmed clip.

        Raises:
            FFmpegError: If trimming fails or input file not found.
        """
        if not input_path.exists():
            raise FFmpegError(f"Input video not found: {input_path}")

        # Validate target duration
        if target_duration not in (4, 6, 8):
            logger.warning(
                "Unusual target duration %d, expected 4, 6, or 8",
                target_duration,
            )

        # Get actual duration of input clip
        actual_duration = self.get_video_duration(input_path)

        # If target is >= actual, just copy the file
        if target_duration >= actual_duration:
            logger.debug(
                "Target duration %ds >= actual %.1fs, copying without trim",
                target_duration,
                actual_duration,
            )
            output_path.parent.mkdir(parents=True, exist_ok=True)
            shutil.copy(input_path, output_path)
            return output_path

        # Calculate trim amounts
        trim_total = actual_duration - target_duration
        start_trim = trim_total / 2

        logger.info(
            "Trimming clip from %.1fs to %ds (removing %.1fs from each end)",
            actual_duration,
            target_duration,
            start_trim,
        )

        # Create output directory if needed
        output_path.parent.mkdir(parents=True, exist_ok=True)

        # Build FFmpeg command using stream copy for speed
        cmd = [
            "ffmpeg",
            "-y",  # Overwrite output
            "-ss",
            str(start_trim),  # Seek to start position
            "-i",
            str(input_path),
            "-t",
            str(target_duration),  # Duration to keep
            "-c",
            "copy",  # Stream copy (fast, lossless)
            str(output_path),
        ]

        try:
            result = subprocess.run(
                cmd,
                check=True,
                capture_output=True,
                text=True,
            )
            logger.debug("FFmpeg stdout: %s", result.stdout)
            if result.stderr:
                logger.debug("FFmpeg stderr: %s", result.stderr)

            logger.debug("Trimmed clip saved to: %s", output_path)
            return output_path

        except subprocess.CalledProcessError as e:
            error_msg = e.stderr if e.stderr else str(e)
            raise FFmpegError(f"FFmpeg trimming failed: {error_msg}") from e

    def assemble_with_music(
        self,
        clip_paths: list[Path],
        music: GeneratedMusic,
        output_path: Path,
        music_volume: float = 0.2,
        fade_duration: float = 2.0,
    ) -> Path:
        """Assemble video clips and overlay background music.

        Concatenates video clips, then mixes in background music with
        fade in/out effects. The music is looped to match the video
        duration if needed.

        Args:
            clip_paths: List of paths to video clips, in order.
            music: Generated music track to overlay.
            output_path: Path for the final video with music.
            music_volume: Volume level for background music (0.0-1.0).
                         Default is 0.2 (20%) to not overpower dialogue.
            fade_duration: Duration of fade in/out effects in seconds.
                          Default is 2.0 seconds.

        Returns:
            Path to the assembled video file with music.

        Raises:
            FFmpegError: If assembly or mixing fails.
        """
        if not clip_paths:
            raise FFmpegError("No video clips provided for assembly")

        # Validate music file exists
        music_path = Path(music.file_path)
        if not music_path.exists():
            raise FFmpegError(f"Music file not found: {music.file_path}")

        # Validate volume
        if not 0.0 <= music_volume <= 1.0:
            raise FFmpegError(f"music_volume must be between 0.0 and 1.0, got {music_volume}")

        # Create output directory if needed
        output_path.parent.mkdir(parents=True, exist_ok=True)

        # Step 1: Concatenate video clips to a temp file
        concat_temp = output_path.parent / f".concat_temp_{output_path.stem}.mp4"
        try:
            self.concatenate_clips(clip_paths, concat_temp, reencode=False)

            # Step 2: Get video duration for fade calculation
            duration = self.get_video_duration(concat_temp)
            fade_out_start = max(0, duration - fade_duration)

            # Step 2.5: Check if video has audio stream
            has_audio = self.has_audio_stream(concat_temp)

            logger.info(
                "Mixing music into video (duration: %.1fs, volume: %.0f%%, has_audio: %s)",
                duration,
                music_volume * 100,
                has_audio,
            )

            # Step 3: Build and run FFmpeg command for audio mixing
            cmd = self._build_music_mix_command(
                video_path=concat_temp,
                music_path=music_path,
                output_path=output_path,
                music_volume=music_volume,
                fade_duration=fade_duration,
                fade_out_start=fade_out_start,
                has_video_audio=has_audio,
            )

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

            logger.debug("FFmpeg stdout: %s", result.stdout)
            if result.stderr:
                logger.debug("FFmpeg stderr: %s", result.stderr)

            logger.info("Successfully created video with music: %s", output_path)
            return output_path

        except subprocess.CalledProcessError as e:
            error_msg = e.stderr if e.stderr else str(e)
            raise FFmpegError(f"FFmpeg music mixing failed: {error_msg}") from e
        finally:
            # Cleanup temp file
            if concat_temp.exists():
                concat_temp.unlink()

    def _build_music_mix_command(
        self,
        video_path: Path,
        music_path: Path,
        output_path: Path,
        music_volume: float,
        fade_duration: float,
        fade_out_start: float,
        has_video_audio: bool = True,
    ) -> list[str]:
        """Build FFmpeg command for mixing music with video.

        The command:
        1. Takes video input with its audio (if present)
        2. Loops the music track to match video duration
        3. Applies fade in/out to the music
        4. Adjusts music volume
        5. Mixes video audio and music together (if video has audio)
        6. Uses shortest stream to determine output length

        Args:
            video_path: Path to the concatenated video.
            music_path: Path to the music file.
            output_path: Path for the output video.
            music_volume: Volume level for music (0.0-1.0).
            fade_duration: Duration of fade effects in seconds.
            fade_out_start: When to start fade out (in seconds).
            has_video_audio: Whether the video has an audio stream.

        Returns:
            List of command arguments for FFmpeg.
        """
        if has_video_audio:
            # Build the filter complex for audio mixing
            # [1:a] = music input (looped)
            # - afade: fade in at start, fade out before end
            # - volume: reduce music volume
            # [0:a] = video audio
            # - volume: slightly reduce to make room for music
            # amix: combine both audio streams
            video_audio_volume = 1.0 - (music_volume * 0.3)  # Slight reduction

            filter_complex = (
                f"[1:a]afade=t=in:st=0:d={fade_duration},"
                f"afade=t=out:st={fade_out_start}:d={fade_duration},"
                f"volume={music_volume}[music];"
                f"[0:a]volume={video_audio_volume}[video_audio];"
                f"[video_audio][music]amix=inputs=2:duration=first:dropout_transition=2[audio_out]"
            )
        else:
            # Video has no audio - just use the music track
            logger.info("Video has no audio stream, using music only")
            filter_complex = (
                f"[1:a]afade=t=in:st=0:d={fade_duration},"
                f"afade=t=out:st={fade_out_start}:d={fade_duration},"
                f"volume={music_volume}[audio_out]"
            )

        return [
            "ffmpeg",
            "-y",  # Overwrite output
            "-i",
            str(video_path),  # Video input (index 0)
            "-stream_loop",
            "-1",  # Loop music indefinitely
            "-i",
            str(music_path),  # Music input (index 1)
            "-filter_complex",
            filter_complex,
            "-map",
            "0:v",  # Use video from input 0
            "-map",
            "[audio_out]",  # Use mixed/music-only audio
            "-shortest",  # Stop when shortest input ends
            "-c:v",
            "copy",  # Copy video stream (no re-encode)
            "-c:a",
            "aac",  # Encode audio as AAC
            "-b:a",
            "192k",  # Audio bitrate
            str(output_path),
        ]
