"""Compile a Timeline into FFmpeg ``-filter_complex`` arguments.

This is the core of the render engine.  It:

1. Infers each track's role from the asset types of its clips.
2. Builds the video xfade chain for the primary video track.
3. Builds the audio acrossfade chain for the primary video track.
4. Compiles overlay tracks (picture-in-picture / image watermarks).
5. Compiles text tracks (drawtext filters).
6. Compiles audio tracks (adelay + amix).
7. Returns the full argument list ready for ``run_ffmpeg()``.
"""
from __future__ import annotations

from dataclasses import dataclass, field
from enum import Enum, auto
from typing import TYPE_CHECKING, Optional

from ._asset import AudioAsset, ImageAsset, TextAsset, VideoAsset
from ._ffmpeg import MediaInfo
from ._types import (
    CropGravity,
    Fit,
    Position,
    POSITION_DRAWTEXT_XY,
    POSITION_OVERLAY_XY,
)

if TYPE_CHECKING:
    from ._clip import Clip
    from ._track import Track, _TrackEntry


class _TrackRole(Enum):
    VIDEO_MAIN = auto()
    VIDEO_OVERLAY = auto()
    OVERLAY = auto()
    TEXT = auto()
    AUDIO = auto()


@dataclass
class _ResolvedClip:
    """A clip with its probed metadata attached."""

    entry: _TrackEntry
    info: MediaInfo | None  # None for TextAsset
    local_path: str  # resolved local path (or empty for TextAsset)
    effective_duration: float = 0.0  # after trim+speed+duration override


@dataclass
class FilterGraphResult:
    """Output of the filter graph compiler."""

    input_args: list[str]
    filter_complex: str
    video_out_label: str
    audio_out_label: str | None


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

_GRAVITY_CROP_XY: dict[CropGravity, str] = {
    CropGravity.CENTER: "(ow-iw)/2:(oh-ih)/2",
    CropGravity.TOP: "(ow-iw)/2:0",
    CropGravity.BOTTOM: "(ow-iw)/2:oh-ih",
    CropGravity.LEFT: "0:(oh-ih)/2",
    CropGravity.RIGHT: "ow-iw:(oh-ih)/2",
}


def _fit_filters(
    clip: Clip,
    w: int,
    h: int,
    bg_color: str,
    fps: int,
) -> str:
    """Return the video pre-processing filter chain for a single clip."""
    parts: list[str] = []
    parts.append(f"fps={fps}")

    fit = clip.fit
    if fit == Fit.COVER:
        parts.append(
            f"scale={w}:{h}:force_original_aspect_ratio=increase"
        )
        grav = _GRAVITY_CROP_XY.get(clip.crop_gravity, _GRAVITY_CROP_XY[CropGravity.CENTER])
        # crop uses x:y relative to the scaled output
        parts.append(f"crop={w}:{h}")
    elif fit == Fit.CONTAIN:
        parts.append(
            f"scale={w}:{h}:force_original_aspect_ratio=decrease"
        )
        parts.append(
            f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2:color={bg_color}"
        )
    elif fit == Fit.FILL:
        parts.append(f"scale={w}:{h}")
    elif fit == Fit.NONE:
        # Keep original size; will be overlaid centered later if needed
        # but for main video track we still need to ensure canvas size
        parts.append(
            f"scale={w}:{h}:force_original_aspect_ratio=decrease"
        )
        parts.append(
            f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2:color={bg_color}"
        )

    parts.append("setsar=1")
    parts.append("format=yuv420p")
    return ",".join(parts)


def _compute_effective_duration(clip: Clip, info: MediaInfo | None) -> float:
    """Determine how long a clip actually plays."""
    if clip.duration is not None:
        base = clip.duration
    elif info is not None:
        base = info.duration
        asset = clip.asset
        if isinstance(asset, (VideoAsset, AudioAsset)) and asset.start > 0:
            base = max(0, base - asset.start)
    else:
        base = 5.0  # fallback for TextAsset without info

    if clip.speed != 1.0:
        base /= clip.speed

    return base


def _escape_drawtext(s: str) -> str:
    """Escape a string for FFmpeg drawtext ``text=`` value."""
    return s.replace("\\", "\\\\").replace("'", "'\\''").replace(":", "\\:")


def _atempo_chain(speed: float) -> str:
    """Build an atempo filter chain handling values outside [0.5, 100]."""
    filters: list[str] = []
    remaining = speed
    while remaining > 100.0:
        filters.append("atempo=100.0")
        remaining /= 100.0
    while remaining < 0.5:
        filters.append("atempo=0.5")
        remaining /= 0.5
    filters.append(f"atempo={remaining:.6f}")
    return ",".join(filters)


# ---------------------------------------------------------------------------
# Track role inference
# ---------------------------------------------------------------------------

def _infer_roles(tracks: list[Track]) -> list[_TrackRole]:
    """Determine each track's processing role from its clip contents."""
    roles: list[_TrackRole] = []
    found_main_video = False

    for track in tracks:
        entries = track.entries
        if not entries:
            roles.append(_TrackRole.TEXT)  # empty track, harmless
            continue

        asset_types = {type(e.clip.asset) for e in entries}

        # Pure audio
        if asset_types == {AudioAsset}:
            roles.append(_TrackRole.AUDIO)
            continue

        # Pure text
        if asset_types == {TextAsset}:
            roles.append(_TrackRole.TEXT)
            continue

        # Video / Image tracks
        has_visual = asset_types <= {VideoAsset, ImageAsset}
        if not has_visual:
            from ._errors import ValidationError
            raise ValidationError(
                f"Track contains mixed asset types: {asset_types}. "
                "Separate video/image, audio, and text into different tracks."
            )

        # Check if any clip has overlay characteristics
        is_overlay = any(
            e.clip.position is not None or e.clip.scale != 1.0
            for e in entries
        )

        if is_overlay:
            roles.append(_TrackRole.OVERLAY)
        elif not found_main_video:
            roles.append(_TrackRole.VIDEO_MAIN)
            found_main_video = True
        else:
            roles.append(_TrackRole.VIDEO_OVERLAY)

    return roles


# ---------------------------------------------------------------------------
# Main compiler
# ---------------------------------------------------------------------------

def build_filter_graph(
    tracks: list[Track],
    resolved_clips: dict[int, list[_ResolvedClip]],
    *,
    width: int,
    height: int,
    fps: int,
    bg_color: str,
    has_gradient_bg: bool = False,
    gradient_input_index: int | None = None,
) -> FilterGraphResult:
    """Compile the full filter graph.

    Args:
        tracks: Ordered list of tracks (add_track order = z-order).
        resolved_clips: ``{track_index: [_ResolvedClip, ...]}`` with probed
            media info and local paths.
        width: Canvas width in pixels.
        height: Canvas height in pixels.
        fps: Output frame rate.
        bg_color: Background colour hex (for CONTAIN padding).
        has_gradient_bg: Whether a gradient source input was added.
        gradient_input_index: The FFmpeg input index for the gradient source.

    Returns:
        A :class:`FilterGraphResult` ready for ``run_ffmpeg()``.
    """
    roles = _infer_roles(tracks)

    input_args: list[str] = []
    filter_parts: list[str] = []
    input_idx = 0

    # If gradient background, it's already in input_args at gradient_input_index
    if has_gradient_bg and gradient_input_index is not None:
        input_idx = gradient_input_index + 1

    # Assign FFmpeg input indices to each resolved clip
    clip_input_map: dict[int, int] = {}  # id(resolved_clip) → input index

    # Collect all inputs first
    all_inputs: list[tuple[list[str], _ResolvedClip]] = []

    for tidx, track in enumerate(tracks):
        rclips = resolved_clips.get(tidx, [])
        for rc in rclips:
            asset = rc.entry.clip.asset
            inp_args: list[str] = []

            if isinstance(asset, ImageAsset):
                dur = rc.effective_duration
                inp_args = ["-loop", "1", "-t", f"{dur:.3f}", "-framerate", str(fps), "-i", rc.local_path]
            elif isinstance(asset, (VideoAsset, AudioAsset)):
                inp_args = ["-i", rc.local_path]
            elif isinstance(asset, TextAsset):
                pass  # no input needed
            else:
                continue

            if inp_args:
                clip_input_map[id(rc)] = input_idx
                input_args.extend(inp_args)
                input_idx += 1
                all_inputs.append((inp_args, rc))

    # ---- Phase 1: Main video track ----
    v_label = ""
    a_label: str | None = None

    main_track_idx: int | None = None
    for tidx, role in enumerate(roles):
        if role == _TrackRole.VIDEO_MAIN:
            main_track_idx = tidx
            break

    if main_track_idx is not None:
        rclips = resolved_clips.get(main_track_idx, [])
        if rclips:
            v_label, a_label = _build_video_chain(
                rclips, clip_input_map, filter_parts, width, height, fps, bg_color
            )

    # If no main video, create a background canvas
    if not v_label:
        if has_gradient_bg and gradient_input_index is not None:
            v_label = f"[{gradient_input_index}:v]"
        else:
            filter_parts.append(
                f"color=c={bg_color}:s={width}x{height}:d=10:r={fps},format=yuv420p[vbg]"
            )
            v_label = "[vbg]"

    # ---- Phase 2: Video overlay tracks ----
    for tidx, role in enumerate(roles):
        if role not in (_TrackRole.OVERLAY, _TrackRole.VIDEO_OVERLAY):
            continue
        rclips = resolved_clips.get(tidx, [])
        for rc in rclips:
            iidx = clip_input_map.get(id(rc))
            if iidx is None:
                continue
            v_label = _build_overlay_clip(
                rc, iidx, v_label, filter_parts, width, height, fps, bg_color
            )

    # ---- Phase 3: Text tracks ----
    for tidx, role in enumerate(roles):
        if role != _TrackRole.TEXT:
            continue
        rclips = resolved_clips.get(tidx, [])
        for rc in rclips:
            v_label = _build_text_clip(rc, v_label, filter_parts)

    # ---- Phase 4: Audio tracks ----
    extra_audio_labels: list[str] = []
    for tidx, role in enumerate(roles):
        if role != _TrackRole.AUDIO:
            continue
        rclips = resolved_clips.get(tidx, [])
        for rc in rclips:
            iidx = clip_input_map.get(id(rc))
            if iidx is None:
                continue
            lbl = _build_audio_clip(rc, iidx, filter_parts)
            extra_audio_labels.append(lbl)

    # Mix extra audio with main audio
    if extra_audio_labels:
        all_audio = []
        if a_label:
            all_audio.append(a_label)
        all_audio.extend(extra_audio_labels)

        if len(all_audio) == 1:
            a_label = all_audio[0]
        else:
            mix_in = "".join(all_audio)
            mix_out = "[afinal]"
            filter_parts.append(
                f"{mix_in}amix=inputs={len(all_audio)}:duration=first:normalize=0{mix_out}"
            )
            a_label = mix_out

    # Ensure final labels are clean
    video_out = v_label
    audio_out = a_label

    fc = ";\n".join(filter_parts)

    return FilterGraphResult(
        input_args=input_args,
        filter_complex=fc,
        video_out_label=video_out,
        audio_out_label=audio_out,
    )


# ---------------------------------------------------------------------------
# Phase 1 helpers: main video xfade + acrossfade chain
# ---------------------------------------------------------------------------

def _build_video_chain(
    rclips: list[_ResolvedClip],
    clip_input_map: dict[int, int],
    filter_parts: list[str],
    w: int, h: int, fps: int, bg_color: str,
) -> tuple[str, str | None]:
    """Build the xfade chain for the main video track.

    Returns ``(video_label, audio_label)``.
    """
    n = len(rclips)
    if n == 0:
        return "", None

    # Pre-process each clip
    v_labels: list[str] = []
    a_labels: list[str] = []

    for i, rc in enumerate(rclips):
        iidx = clip_input_map.get(id(rc))
        if iidx is None:
            continue

        clip = rc.entry.clip
        asset = clip.asset

        # Video pre-processing
        v_in = f"[{iidx}:v]"
        v_out = f"[v{i}]"

        vfilters: list[str] = []

        # Trim
        if isinstance(asset, VideoAsset) and asset.start > 0:
            end = asset.start + rc.effective_duration * clip.speed
            vfilters.append(f"trim=start={asset.start:.3f}:end={end:.3f}")
            vfilters.append("setpts=PTS-STARTPTS")

        # Speed
        if clip.speed != 1.0:
            vfilters.append(f"setpts={1.0/clip.speed:.6f}*PTS")

        # Fit
        vfilters.append(_fit_filters(clip, w, h, bg_color, fps))

        filter_parts.append(f"{v_in}{','.join(vfilters)}{v_out}")
        v_labels.append(v_out)

        # Audio pre-processing
        info = rc.info
        has_audio = info is not None and info.has_audio and not clip.is_muted

        if has_audio:
            a_in = f"[{iidx}:a]"
            a_out = f"[a{i}]"
            afilters: list[str] = []

            if isinstance(asset, VideoAsset) and asset.start > 0:
                end = asset.start + rc.effective_duration * clip.speed
                afilters.append(f"atrim=start={asset.start:.3f}:end={end:.3f}")
                afilters.append("asetpts=PTS-STARTPTS")

            if clip.speed != 1.0:
                afilters.append(_atempo_chain(clip.speed))

            vol = clip.volume
            if isinstance(asset, VideoAsset):
                vol *= asset.volume
            if vol != 1.0:
                afilters.append(f"volume={vol:.3f}")

            # Clip-level fade in/out
            trans = clip.transition
            if trans is not None:
                if trans.in_ == "fade":
                    afilters.append(f"afade=t=in:d={trans.duration:.3f}")
                if trans.out == "fade":
                    st = max(0, rc.effective_duration - trans.duration)
                    afilters.append(f"afade=t=out:st={st:.3f}:d={trans.duration:.3f}")

            if afilters:
                filter_parts.append(f"{a_in}{','.join(afilters)}{a_out}")
            else:
                filter_parts.append(f"{a_in}anull{a_out}")
            a_labels.append(a_out)
        else:
            # Generate silence
            dur = rc.effective_duration
            a_out = f"[a{i}]"
            filter_parts.append(
                f"anullsrc=channel_layout=stereo:sample_rate=48000,"
                f"atrim=duration={dur:.3f}{a_out}"
            )
            a_labels.append(a_out)

    # xfade chain
    if n == 1:
        cur_v = v_labels[0]
    else:
        cur_v = v_labels[0]
        for i in range(1, n):
            rc = rclips[i]
            xname = rc.entry.xfade_name or "fade"
            xdur = rc.entry.xfade_duration or 0.0

            if xdur > 0:
                if i == 1:
                    offset = rclips[0].effective_duration - xdur
                else:
                    composed_dur = rclips[0].effective_duration
                    for j in range(1, i):
                        td = rclips[j].entry.xfade_duration or 0.0
                        composed_dur += rclips[j].effective_duration - td
                    offset = composed_dur - xdur
            else:
                composed_dur = rclips[0].effective_duration
                for j in range(1, i):
                    td = rclips[j].entry.xfade_duration or 0.0
                    composed_dur += rclips[j].effective_duration - td
                offset = composed_dur

            is_last = i == n - 1
            out_label = f"[vx{i}]"
            fmt_suffix = ",format=yuv420p" if is_last else ""

            filter_parts.append(
                f"{cur_v}{v_labels[i]}xfade=transition={xname}:"
                f"duration={xdur:.3f}:offset={offset:.3f}{fmt_suffix}{out_label}"
            )
            cur_v = out_label

        # Clip-level video fade in/out on the composed result
        first_clip = rclips[0].entry.clip
        last_clip = rclips[-1].entry.clip
        vfade_filters: list[str] = []

        if first_clip.transition and first_clip.transition.in_ == "fade":
            d = first_clip.transition.duration
            vfade_filters.append(f"fade=t=in:d={d:.3f}")
        if last_clip.transition and last_clip.transition.out == "fade":
            d = last_clip.transition.duration
            total = rclips[0].effective_duration
            for j in range(1, n):
                td = rclips[j].entry.xfade_duration or 0.0
                total += rclips[j].effective_duration - td
            st = max(0, total - d)
            vfade_filters.append(f"fade=t=out:st={st:.3f}:d={d:.3f}")

        if vfade_filters:
            v_in = cur_v
            cur_v = "[vmain]"
            filter_parts.append(f"{v_in}{','.join(vfade_filters)}{cur_v}")

    # acrossfade chain
    if n == 1:
        cur_a: str | None = a_labels[0] if a_labels else None
    else:
        cur_a = a_labels[0] if a_labels else None
        for i in range(1, n):
            if cur_a is None or i >= len(a_labels):
                break
            xdur = rclips[i].entry.xfade_duration or 0.0
            is_last = i == n - 1
            out_label_a = f"[ax{i}]"
            if xdur > 0:
                filter_parts.append(
                    f"{cur_a}{a_labels[i]}acrossfade=d={xdur:.3f}{out_label_a}"
                )
            else:
                filter_parts.append(
                    f"{cur_a}{a_labels[i]}concat=n=2:v=0:a=1{out_label_a}"
                )
            cur_a = out_label_a

    return cur_v, cur_a


# ---------------------------------------------------------------------------
# Phase 2: overlay clips
# ---------------------------------------------------------------------------

def _build_overlay_clip(
    rc: _ResolvedClip,
    iidx: int,
    v_base: str,
    filter_parts: list[str],
    w: int, h: int, fps: int, bg_color: str,
) -> str:
    """Add an overlay clip and return the new video label."""
    clip = rc.entry.clip
    start = rc.entry.start
    end = start + rc.effective_duration

    # Scale the overlay
    target_w = int(w * clip.scale)
    target_h = int(h * clip.scale)
    if target_w % 2 != 0:
        target_w += 1
    if target_h % 2 != 0:
        target_h += 1

    ov_label = f"[ov{iidx}]"
    ov_filters = [
        f"fps={fps}",
        f"scale={target_w}:{target_h}:force_original_aspect_ratio=decrease",
        "setsar=1",
        "format=yuva420p",
    ]

    if clip.opacity < 1.0:
        alpha = int(clip.opacity * 255)
        ov_filters.append(f"colorchannelmixer=aa={clip.opacity:.3f}")

    filter_parts.append(f"[{iidx}:v]{','.join(ov_filters)}{ov_label}")

    # Position
    pos = clip.position or Position.CENTER
    ox, oy = POSITION_OVERLAY_XY[pos]

    out_label = f"[vov{iidx}]"
    filter_parts.append(
        f"{v_base}{ov_label}overlay={ox}:{oy}:"
        f"enable='between(t,{start:.3f},{end:.3f})'{out_label}"
    )
    return out_label


# ---------------------------------------------------------------------------
# Phase 3: text clips
# ---------------------------------------------------------------------------

def _build_text_clip(
    rc: _ResolvedClip,
    v_base: str,
    filter_parts: list[str],
) -> str:
    """Add a drawtext filter and return the new video label."""
    clip = rc.entry.clip
    asset = clip.asset
    if not isinstance(asset, TextAsset):
        return v_base

    start = rc.entry.start
    end = start + rc.effective_duration

    pos = clip.position or Position.CENTER
    tx, ty = POSITION_DRAWTEXT_XY[pos]

    text_escaped = _escape_drawtext(asset.text)

    dt_parts = [f"text='{text_escaped}'"]
    dt_parts.append(f"fontsize={asset.font_size}")
    dt_parts.append(f"fontcolor={asset.color}")
    dt_parts.append(f"x={tx}")
    dt_parts.append(f"y={ty}")

    if asset.font_file:
        dt_parts.append(f"fontfile='{asset.font_file}'")

    if asset.box:
        dt_parts.append("box=1")
        dt_parts.append(f"boxcolor={asset.box_color}")
        dt_parts.append(f"boxborderw={asset.box_border_w}")

    if asset.border_width > 0:
        dt_parts.append(f"borderw={asset.border_width}")
        dt_parts.append(f"bordercolor={asset.border_color}")

    dt_parts.append(f"enable='between(t,{start:.3f},{end:.3f})'")

    # Alpha animation for fade_in / fade_out
    if asset.fade_in > 0 or asset.fade_out > 0:
        fi = asset.fade_in
        fo = asset.fade_out
        # alpha expression: ramp up during fade_in, ramp down during fade_out
        alpha_expr = "1"
        if fi > 0 and fo > 0:
            alpha_expr = (
                f"if(lt(t-{start:.3f},{fi:.3f}),"
                f"(t-{start:.3f})/{fi:.3f},"
                f"if(gt(t,{end:.3f}-{fo:.3f}),"
                f"({end:.3f}-t)/{fo:.3f},1))"
            )
        elif fi > 0:
            alpha_expr = (
                f"if(lt(t-{start:.3f},{fi:.3f}),"
                f"(t-{start:.3f})/{fi:.3f},1)"
            )
        elif fo > 0:
            alpha_expr = (
                f"if(gt(t,{end:.3f}-{fo:.3f}),"
                f"({end:.3f}-t)/{fo:.3f},1)"
            )
        dt_parts.append(f"alpha='{alpha_expr}'")

    out_label = f"[vtxt{id(rc) % 10000}]"
    filter_parts.append(
        f"{v_base}drawtext={':'.join(dt_parts)}{out_label}"
    )
    return out_label


# ---------------------------------------------------------------------------
# Phase 4: audio clips
# ---------------------------------------------------------------------------

def _build_audio_clip(
    rc: _ResolvedClip,
    iidx: int,
    filter_parts: list[str],
) -> str:
    """Process an audio track clip and return its label."""
    clip = rc.entry.clip
    asset = clip.asset
    start_ms = int(rc.entry.start * 1000)

    a_in = f"[{iidx}:a]"
    out_label = f"[aex{iidx}]"
    afilters: list[str] = []

    if isinstance(asset, (AudioAsset, VideoAsset)) and asset.start > 0:
        end = asset.start + rc.effective_duration
        afilters.append(f"atrim=start={asset.start:.3f}:end={end:.3f}")
        afilters.append("asetpts=PTS-STARTPTS")

    vol = clip.volume
    if isinstance(asset, AudioAsset):
        vol *= asset.volume
    if vol != 1.0:
        afilters.append(f"volume={vol:.3f}")

    if start_ms > 0:
        afilters.append(f"adelay={start_ms}|{start_ms}")

    if afilters:
        filter_parts.append(f"{a_in}{','.join(afilters)}{out_label}")
    else:
        filter_parts.append(f"{a_in}anull{out_label}")

    return out_label
