import concurrent.futures
import json
import mimetypes
import time
from pathlib import Path
from typing import Iterable, Union, List, Optional, Dict, Any
import re
import warnings

import requests

from .models import CombinedVideoResult, GenerationResult, VideoEditResult


class Golpo:
    """Python SDK for Golpo with transparent, parallel uploads.

    • Local file paths are uploaded to S3 via `/upload-url` (done in parallel).
    • Already‑hosted resources (strings that look like URLs) are passed through.
    • The backend receives every document as an **upload_urls** form field so it
      can fetch them internally.  This avoids the ALB 1‑MB limit.
    • The call blocks until the podcast is finished and returns its final URL.
    """

    _URL_RE = re.compile(r"^(https?|s3)://", re.I)

    def __init__(self, api_key: str, base_url: str = "https://api.golpoai.com") -> None:
        self.base_url = base_url.rstrip("/")
        self.headers = {"x-api-key": api_key}

    # ------------------------------------------------------------------
    # internal helper: resolve email from API key
    # ------------------------------------------------------------------
    def _get_user_email_from_api_key(self) -> Optional[str]:
        """Resolve the user email associated with the current API key."""
        try:
            resp = requests.get(
                f"{self.base_url}/api/resolve-email-from-api-key",
                headers=self.headers,
                timeout=15,
            )
            resp.raise_for_status()
            return resp.json().get("email")
        except Exception:
            return None

    # ------------------------------------------------------------------
    # internal helper: create video record before generation
    # ------------------------------------------------------------------
    def _create_video_record(
        self,
        prompt: str,
        request_payload: Optional[dict] = None,
    ) -> Optional[str]:
        """Create a video record via /api/create-video-record.

        Returns the video_id if successful, None otherwise.
        This is non-blocking — failures are silently ignored so generation
        still proceeds even if record creation fails.
        """
        try:
            email = self._get_user_email_from_api_key()
            if not email:
                return None

            body: dict = {
                "topic": prompt,
                "context": "",
                "scenes": 6,
                "pipeline": "color",
                "user_email": email,
                "share_documents": False,
                "logo_placement": None,
                "audio_clip_url": None,
                "request_payload": json.dumps(request_payload) if request_payload else None,
            }

            resp = requests.post(
                f"{self.base_url}/api/create-video-record",
                headers={**self.headers, "Content-Type": "application/json"},
                json=body,
                timeout=30,
            )
            resp.raise_for_status()
            return resp.json().get("video_id")
        except Exception:
            return None

    # ------------------------------------------------------------------
    # internal helper: presign → PUT → return S3 URL
    # ------------------------------------------------------------------
    def _upload_to_s3(self, path: Path) -> str:
        presign = requests.post(
            f"{self.base_url}/upload-url",
            headers=self.headers,
            data={"filename": path.name},
            timeout=30,
        )
        presign.raise_for_status()
        info = presign.json()  # {'url': ..., 'key': ...}

        ctype = mimetypes.guess_type(path)[0] or "application/octet-stream"
        with path.open("rb") as fh:
            put = requests.put(info["url"], data=fh, headers={"Content-Type": ctype})
        put.raise_for_status()
        return info["url"].split("?", 1)[0]

    # ------------------------------------------------------------------
    # internal helper: resolve file paths / URLs → list of URLs
    # ------------------------------------------------------------------
    def _resolve_files_to_urls(
        self,
        items: Union[str, Path, List[Union[str, Path]]],
        max_workers: int = 8,
    ) -> List[str]:
        """Resolve a mix of local file paths and URLs into a list of URLs.

        Local files are uploaded to S3 in parallel.  URLs are passed through.
        """
        if isinstance(items, (str, Path)):
            items = [items]

        local_paths: List[Path] = []
        urls: List[str] = []

        for item in items:
            if isinstance(item, Path):
                path_obj = item.expanduser()
                if not path_obj.exists():
                    raise FileNotFoundError(path_obj)
                local_paths.append(path_obj)
            else:
                item_str = str(item).strip().lstrip("<").rstrip(">")
                if self._URL_RE.match(item_str):
                    urls.append(item_str)
                else:
                    path_obj = Path(item_str).expanduser()
                    if not path_obj.exists():
                        raise FileNotFoundError(path_obj)
                    local_paths.append(path_obj)

        if local_paths:
            with concurrent.futures.ThreadPoolExecutor(
                max_workers=min(max_workers, len(local_paths))
            ) as pool:
                futs = [pool.submit(self._upload_to_s3, p) for p in local_paths]
                for fut in concurrent.futures.as_completed(futs):
                    urls.append(fut.result())

        return urls

    # ------------------------------------------------------------------
    # public API
    # ------------------------------------------------------------------
    def create_podcast(
        self,
        prompt: str,
        uploads: Optional[Union[str, Path, Iterable[Union[str, Path]]]] = None,
        *,
        add_music: bool = False,
        voice_instructions: Optional[str] = None,
        personality_1: Optional[str] = None,
        personality_2: Optional[str] = None,
        do_research: bool = False,
        tts_model: str = "accurate",
        language: Optional[str] = None,
        style: Optional[str] = "conversational",  # conversational, solo-male-3, solo-female-3
        bg_music: Optional[str] = None,  # jazz, lofi, dramatic, whimsical, engaging, hyper, inspirational, documentary
        poll_interval: int = 2,
        max_workers: int = 8,
        output_volume: float = 1.0,
        no_voice_chunking: bool = False,
        # --- newly added params matching Node SDK ---
        new_script: Optional[str] = None,
        bg_volume: float = 1.0,
        timing: Union[str, float] = "1",
    ) -> GenerationResult:
        """Generate a podcast and return its final URL."""
        if not prompt:
            raise ValueError("Prompt is mandatory")

        # Auto-estimate timing from new_script
        if new_script:
            words_per_minute = 150
            word_count = len(new_script.split())
            minutes = word_count / words_per_minute
            timing = str(round(minutes * 2) / 2)

        # --------- basic form fields ----------------------------------
        fields: List[tuple[str, str]] = [
            ("prompt", prompt),
            ("add_music", str(add_music).lower()),
            ("do_research", str(do_research).lower()),
            ("tts_model", tts_model),
            ("style", style),
            ("output_volume", str(output_volume)),
            ("bg_volume", str(bg_volume)),
            ("timing", str(timing)),
            ("video_type", "null"),
        ]
        if voice_instructions:
            fields.append(("voice_instructions", voice_instructions))
        if personality_1:
            fields.append(("personality_1", personality_1))
        if personality_2:
            fields.append(("personality_2", personality_2))
        if language:
            fields.append(("language", language))
        if bg_music:
            fields.append(("bg_music", bg_music))
        if no_voice_chunking:
            fields.append(("no_voice_chunking", str(no_voice_chunking).lower()))
        if new_script:
            fields.append(("new_script", new_script))

        # --------- gather documents -----------------------------------
        passthrough_urls: List[str] = []
        if uploads:
            if isinstance(uploads, (str, Path)):
                uploads = [uploads]

            local_paths: List[Path] = []

            for item in uploads:  # type: ignore[not-an-iterable]
                # treat str & Path uniformly
                if isinstance(item, Path):
                    path_obj = item.expanduser()
                else:
                    # trim any accidental angle‑brackets or whitespace
                    item_str = str(item).strip().lstrip("<").rstrip(">")
                    if self._URL_RE.match(item_str):
                        passthrough_urls.append(item_str)
                        continue
                    path_obj = Path(item_str).expanduser()

                if path_obj.exists():
                    local_paths.append(path_obj)
                else:
                    raise FileNotFoundError(path_obj)

            # upload local files in parallel
            if local_paths:
                with concurrent.futures.ThreadPoolExecutor(
                    max_workers=min(max_workers, len(local_paths))
                ) as pool:
                    futs = [pool.submit(self._upload_to_s3, p) for p in local_paths]
                    for fut in concurrent.futures.as_completed(futs):
                        passthrough_urls.append(fut.result())

            # add to multipart body
            fields += [("upload_urls", url) for url in passthrough_urls]

        # --------- create video record (non-blocking) -------------------
        request_payload = {
            "source": "python_sdk",
            "prompt": prompt,
            "add_music": add_music,
            "do_research": do_research,
            "tts_model": tts_model,
            "video_voice": style,
            "output_volume": output_volume,
            "bg_volume": bg_volume,
            "video_duration": str(timing),
            "video_type": None,
            "voice_instructions": voice_instructions,
            "personality_1": personality_1,
            "personality_2": personality_2,
            "language": language or "english",
            "bg_music": bg_music,
            "no_voice_chunking": no_voice_chunking,
            "direct_script": new_script,
            "script_mode": bool(new_script),
            "attached_documents": passthrough_urls,
            "audio_only": True
        }
        video_id = self._create_video_record(prompt, request_payload)
        if video_id:
            fields.append(("video_id", video_id))

        # --------- POST /generate ------------------------------------
        gen = requests.post(
            f"{self.base_url}/generate",
            headers=self.headers,
            data=fields,  # list[tuple] keeps order & repeats
            timeout=60,
        )
        gen.raise_for_status()
        job_id = gen.json()["job_id"]

        # --------- poll until finished -------------------------------
        while True:
            try:
                resp = requests.get(
                    f"{self.base_url}/status/{job_id}", headers=self.headers, timeout=30
                )
                resp.raise_for_status()
                status = resp.json()
            except (requests.exceptions.RequestException, ValueError):
                # timeout / connection / HTTP error / bad JSON — just retry
                time.sleep(poll_interval)
                continue
            if status["status"] == "completed":
                return GenerationResult(
                    url=status["podcast_url"],
                    script=status["podcast_script"],
                    video_id=job_id,
                )
            time.sleep(poll_interval)

    def create_video(
        self,
        prompt: str,
        uploads: Optional[Union[str, Path, Iterable[Union[str, Path]]]] = None,
        *,
        voice_instructions: Optional[str] = None,
        personality_1: Optional[str] = None,
        personality_2: Optional[str] = None,
        do_research: bool = False,
        tts_model: str = "accurate",
        language: Optional[str] = None,
        style: Optional[str] = "solo-female-3",  # solo-male-3 or solo-female-3
        bg_music: Optional[str] = "engaging",  # jazz, lofi, dramatic, whimsical, engaging, hyper, inspirational, documentary
        bg_volume: float = 1.0,
        video_type: Optional[str] = "long",  # short or long
        include_watermark: bool = False,
        new_script: Optional[str] = None,
        just_return_script: bool = False,
        logo: Optional[Union[str, Path]] = None,
        timing: Union[str, float] = "1",
        poll_interval: int = 2,
        max_workers: int = 8,
        output_volume: float = 1.0,
        video_instructions: Optional[str] = None,
        use_color: bool = True,
        # --- newly added params matching Node SDK ---
        logo_placement: Optional[str] = None,
        use_lineart_2_style: Optional[str] = None,
        audio_clip: Optional[Union[str, Path]] = None,
        is_public: Optional[bool] = None,
        use_as_is: Optional[Union[str, List[bool]]] = None,
        skip_animation: Optional[Union[str, List[bool]]] = None,
        user_images: Optional[Union[str, Path, List[Union[str, Path]]]] = None,
        user_images_descriptions: Optional[Union[str, List[str]]] = None,
        user_videos: Optional[Union[str, Path, List[Union[str, Path]]]] = None,
        user_videos_description: Optional[Union[str, List[str]]] = None,
        user_audio_in_video: Optional[Union[str, List[bool]]] = None,
        use_2_style: bool = False,
        image_style: Optional[str] = None,  # neon, whiteboard, modern_minimal, playful, technical, editorial
        input_images: Optional[Union[str, Path, List[Union[str, Path]]]] = None,
        pen_style: Optional[str] = None,  # stylus, marker, pen
        show_pencil_cursor: bool = False,
        pacing: str = "normal",  # normal or fast
        display_language: Optional[str] = None,  # Language for on-screen text/captions (e.g., 'English', 'Hindi'). Defaults to `language` if not provided.
        # own_narration_video_mode: Optional[str] = None,  # audio or pip
        # own_narration_pip_position: Optional[str] = None,  # left-top, right-top, left-bottom, right-bottom
    ) -> GenerationResult:
        """Generate a video and return its final URL and script."""
        def estimate_read_time(script: str) -> str:
            words_per_minute = 150
            word_count = len(script.split())
            minutes = word_count / words_per_minute
            rounded_minutes = round(minutes * 2) / 2
            return str(rounded_minutes)
        if new_script:
            timing = estimate_read_time(new_script)
        # Validate timing parameter
        try:
            timing_float = float(timing)
            if timing_float < 0.25:
                raise ValueError(f"Timing parameter must be 0.25 or above, got {timing_float}")
        except (ValueError, TypeError) as e:
            if "could not convert" in str(e):
                raise ValueError(f"Timing parameter must be a valid number, got {timing}")
            raise

        # --------- basic form fields ----------------------------------
        fields: List[tuple[str, str]] = [
            ("prompt", prompt),
            ("do_research", str(do_research).lower()),
            ("tts_model", tts_model),
            ("bg_volume", str(bg_volume)),
            ("output_volume", str(output_volume)),
            ("style", style),
            ("timing", str(timing)),
            ("use_color", str(use_color).lower()),
        ]
        if language:
            fields.append(("language", language))
        if video_instructions:
            fields.append(("video_instructions", video_instructions))
        if voice_instructions:
            fields.append(("voice_instructions", voice_instructions))
        if personality_1:
            fields.append(("personality_1", personality_1))
        if personality_2:
            fields.append(("personality_2", personality_2))
        if bg_music:
            fields.append(("bg_music", bg_music))
        if new_script:
            fields.append(("new_script", new_script))
        if just_return_script:
            fields.append(("just_return_script", str(just_return_script).lower()))

        if video_type:
            fields.append(("video_type", video_type))
        else:
            fields.append(("video_type", "long"))

        # --- handle logo ---
        if logo:
            logo_str = str(logo).strip().lstrip("<").rstrip(">")
            if isinstance(logo, Path):
                logo_url = self._upload_to_s3(logo.expanduser())
            elif not self._URL_RE.match(logo_str):
                logo_url = self._upload_to_s3(Path(logo_str).expanduser())
            else:
                logo_url = logo_str
            include_watermark = True
            fields.append(("logo", logo_url))
        fields.append(("include_watermark", str(include_watermark).lower()))

        # --- newly added fields matching Node SDK ---
        if logo_placement:
            fields.append(("logo_placement", logo_placement))
        if use_lineart_2_style:
            fields.append(("use_lineart_2_style", use_lineart_2_style))
        if is_public is not None:
            fields.append(("is_public", str(is_public).lower()))
        if use_as_is is not None:
            value = json.dumps(use_as_is) if isinstance(use_as_is, list) else use_as_is
            fields.append(("use_as_is", value))
        if skip_animation is not None:
            value = json.dumps(skip_animation) if isinstance(skip_animation, list) else skip_animation
            fields.append(("skip_animation", value))
        if user_images_descriptions is not None:
            value = json.dumps(user_images_descriptions) if isinstance(user_images_descriptions, list) else user_images_descriptions
            fields.append(("user_images_descriptions", value))
        if user_videos_description is not None:
            value = json.dumps(user_videos_description) if isinstance(user_videos_description, list) else user_videos_description
            fields.append(("user_videos_description", value))
        # Default user_audio_in_video to "[]" when user_videos is provided (backend needs it)
        if user_audio_in_video is not None:
            value = json.dumps(user_audio_in_video) if isinstance(user_audio_in_video, list) else user_audio_in_video
            fields.append(("user_audio_in_video", value))
        elif user_videos:
            fields.append(("user_audio_in_video", "[]"))
        if use_2_style is not None:
            fields.append(("use_2_0_style", str(use_2_style).lower()))
        if image_style:
            fields.append(("image_style", image_style))
        if pen_style:
            fields.append(("pen_style", pen_style))
            if not show_pencil_cursor:
                show_pencil_cursor = True
        if show_pencil_cursor is not None:
            fields.append(("show_pencil_cursor", str(show_pencil_cursor).lower()))
        if pacing:
            fields.append(("pacing", pacing))
        if display_language:
            fields.append(("display_language", display_language))
        # if own_narration_video_mode:
        #     fields.append(("own_narration_video_mode", own_narration_video_mode))
        # if own_narration_pip_position:
        #     fields.append(("own_narration_pip_position", own_narration_pip_position))

        # --- handle audio_clip (single file/URL → single URL string) ---
        if audio_clip:
            clip_str = str(audio_clip).strip().lstrip("<").rstrip(">")
            if isinstance(audio_clip, Path):
                clip_url = self._upload_to_s3(audio_clip.expanduser())
            elif not self._URL_RE.match(clip_str):
                clip_url = self._upload_to_s3(Path(clip_str).expanduser())
            else:
                clip_url = clip_str
            fields.append(("audio_clip", clip_url))

        # --- handle user_images (upload local files, keep URLs) ---
        image_urls = []
        if user_images:
            image_urls = self._resolve_files_to_urls(user_images, max_workers)
            if image_urls:
                fields.append(("user_images", json.dumps(image_urls)))

        # --- handle user_videos (upload local files, keep URLs) ---
        video_urls = []
        if user_videos:
            video_urls = self._resolve_files_to_urls(user_videos, max_workers)
            if video_urls:
                fields.append(("user_videos", json.dumps(video_urls)))

        # --- handle input_images (upload local files, keep URLs) ---
        input_img_urls = []
        if input_images:
            input_img_urls = self._resolve_files_to_urls(input_images, max_workers)
            if input_img_urls:
                fields.append(("input_images", json.dumps(input_img_urls)))

        # --------- gather documents -----------------------------------
        passthrough_urls: List[str] = []
        if uploads:
            if isinstance(uploads, (str, Path)):
                uploads = [uploads]

            local_paths: List[Path] = []

            for item in uploads:  # type: ignore[not-an-iterable]
                # treat str & Path uniformly
                if isinstance(item, Path):
                    path_obj = item.expanduser()
                else:
                    # trim any accidental angle‑brackets or whitespace
                    item_str = str(item).strip().lstrip("<").rstrip(">")
                    if self._URL_RE.match(item_str):
                        passthrough_urls.append(item_str)
                        continue
                    path_obj = Path(item_str).expanduser()

                if path_obj.exists():
                    local_paths.append(path_obj)
                else:
                    raise FileNotFoundError(path_obj)

            # upload local files in parallel
            if local_paths:
                with concurrent.futures.ThreadPoolExecutor(
                    max_workers=min(max_workers, len(local_paths))
                ) as pool:
                    futs = [pool.submit(self._upload_to_s3, p) for p in local_paths]
                    for fut in concurrent.futures.as_completed(futs):
                        passthrough_urls.append(fut.result())

            # add to multipart body
            fields += [("upload_urls", url) for url in passthrough_urls]

        # --------- create video record (non-blocking) -------------------
        logo_resolved = logo_url if logo else None
        request_payload = {
            "source": "python_sdk",
            "prompt": prompt,
            "do_research": do_research,
            "tts_model": tts_model,
            "bg_volume": bg_volume,
            "output_volume": output_volume,
            "video_voice": style,
            "video_duration": str(timing),
            "use_color": use_color,
            "language": language or "English",
            "video_instructions": video_instructions,
            "voice_instructions": voice_instructions,
            "personality_1": personality_1,
            "personality_2": personality_2,
            "bg_music": bg_music,
            "new_script": new_script,
            "script_mode": bool(new_script),
            "just_return_script": just_return_script,
            "video_type": video_type,
            "include_watermark": include_watermark,
            "logo": logo_resolved,
            "logo_url": logo_resolved,
            "logo_placement": logo_placement,
            "use_lineart_2_style": use_lineart_2_style,
            "is_public": is_public,
            "use_as_is": use_as_is,
            "skip_animation": skip_animation,
            "user_images_descriptions": user_images_descriptions,
            "user_videos_description": user_videos_description,
            "user_audio_in_video": user_audio_in_video,
            "use_2_0_style": use_2_style,
            "image_style": image_style,
            "pen_style": pen_style,
            "show_pencil_cursor": show_pencil_cursor,
            "pacing": pacing,
            "display_language": display_language,
            "user_images": image_urls,
            "user_videos": video_urls,
            "input_images": input_img_urls,
            "attached_documents": passthrough_urls,
            "uploadUrls": passthrough_urls,
            "own_narration_mode": bool(audio_clip),
            "audioFileUrl": clip_url if audio_clip else None,
        }
        if use_2_style and image_style:
            request_payload["golpo_type"] = "golpo_2_0"
        if use_lineart_2_style:
            request_payload["video_style"] = use_lineart_2_style
        video_id = self._create_video_record(prompt, request_payload)
        if video_id:
            fields.append(("video_id", video_id))

        # --------- POST /generate ------------------------------------
        gen = requests.post(
            f"{self.base_url}/generate",
            headers=self.headers,
            data=fields,  # list[tuple] keeps order & repeats
            timeout=60,
        )
        gen.raise_for_status()
        job_id = gen.json()["job_id"]

        # --------- poll until finished -------------------------------
        # justReturnScript jobs have no video URL — backend only sets podcast_script.
        # /status/{id} returns "completed" only when podcast_url exists, so it would
        # poll forever. Use /script-status/{id} instead which checks podcast_script.
        status_path = "/script-status" if just_return_script else "/status"
        while True:
            try:
                response = requests.get(
                    f"{self.base_url}{status_path}/{job_id}", headers=self.headers, timeout=30
                )
                response.raise_for_status()
                status = response.json()
            except (requests.exceptions.RequestException, ValueError):
                # timeout / connection / HTTP error / bad JSON — just retry
                time.sleep(poll_interval)
                continue

            if status["status"] == "completed":
                return GenerationResult(
                    url=status.get("podcast_url", ""),
                    script=status.get("podcast_script", ""),
                    video_id=job_id,
                )
            time.sleep(poll_interval)

    def edit_video(
        self,
        video_id: str,
        frame_ids: List[str],
        edit_prompts: List[str],
        video_url: str,
        *,
        reference_images: Optional[List[str]] = None,
        user_id: Optional[str] = None,
        poll_interval_ms: int = 2000,
        auto_combine: bool = False
    ) -> VideoEditResult:
        """Edit specific frames of a video and regenerate the video.

        This method polls until completion and returns the final edited video URL.
        If auto_combine is True, it will also combine all frames into a final video.

        Args:
            video_id: The ID of the video to edit
            frame_ids: List of frame IDs to edit
            edit_prompts: List of edit prompts (must match frame_ids length)
            video_url: URL of the original video (required)
            reference_images: Optional list of reference image URLs
            user_id: Optional user ID
            poll_interval_ms: Polling interval in milliseconds
            auto_combine: If True, automatically combine all frames after editing

        Returns:
            VideoEditResult with video_url and job_id (dict-like access supported).
        """
        if len(frame_ids) != len(edit_prompts):
            raise ValueError("frame_ids and edit_prompts must have the same length")
        
        if not video_url:
            raise ValueError("video_url is required")
        
        # Create the edit job
        job_id = self._edit_video_job(video_id, {
            "frame_ids": frame_ids,
            "edit_prompts": edit_prompts,
            "reference_images": reference_images,
            "video_url": video_url,
            "user_id": user_id
        })
        
        # Poll for completion
        edit_result = self._poll_edit_status(job_id, poll_interval_ms)
        # If auto_combine is enabled, combine all frames after edit completes
        if auto_combine:
            return self._combine_edited_frames(video_id, {
                "frame_ids": frame_ids,
                "video_url": video_url,
                "poll_interval_ms": poll_interval_ms
            })

        return VideoEditResult(video_url=edit_result, job_id=job_id)

    def combine_videos(
        self,
        mp4_urls: List[str],
        *,
        video_url: Optional[str] = None,
        poll_interval_ms: int = 2000
    ) -> CombinedVideoResult:
        """Combine multiple MP4 videos into a single video.

        This is used to combine frame animations into a final video.

        Args:
            mp4_urls: List of MP4 video URLs to combine
            video_url: Optional original video URL for audio
            poll_interval_ms: Polling interval in milliseconds

        Returns:
            CombinedVideoResult with url of the combined video.
        """
        if not mp4_urls or len(mp4_urls) == 0:
            raise ValueError("At least one MP4 URL is required")
        
        body: Dict[str, Any] = {
            "mp4_urls": mp4_urls
        }
        
        if video_url:
            body["video_url"] = video_url
        
        # Create the combine job
        response = requests.post(
            f"{self.base_url}/combine-videos",
            headers={**self.headers, "Content-Type": "application/json"},
            json=body,
            timeout=60
        )
        response.raise_for_status()
        data = response.json()
        
        combine_job_id = data.get("job_id")
        if not combine_job_id:
            raise ValueError("No job ID received from combine-videos API")

        # Poll for completion using the same edit-status endpoint
        url = self._poll_edit_status(combine_job_id, poll_interval_ms)
        return CombinedVideoResult(url=url)

    # ------------------------------------------------------------------
    # Private helper methods
    # ------------------------------------------------------------------
    
    def _edit_video_job(
        self,
        video_id: str,
        opts: Dict[str, Any]
    ) -> str:
        """Create an edit job for a video without waiting for completion.
        
        Returns the job ID that can be used to check status later.
        """
        frame_ids = opts["frame_ids"]
        edit_prompts = opts["edit_prompts"]
        
        if len(frame_ids) != len(edit_prompts):
            raise ValueError("frame_ids and edit_prompts must have the same length")
        
        body: Dict[str, Any] = {
            "job_id": video_id,
            "frame_ids": frame_ids,
            "edit_prompts": edit_prompts,
            "return_single_frame": True
        }
        
        if opts.get("reference_images"):
            body["reference_images"] = opts["reference_images"]
        if opts.get("video_url"):
            body["video_url"] = opts["video_url"]
        if opts.get("user_id"):
            body["user_id"] = opts["user_id"]

        response = requests.post(
            f"{self.base_url}/api/edit-and-reanimate-frames",
            headers={**self.headers, "Content-Type": "application/json"},
            json=body,
            timeout=60
        )
        response.raise_for_status()
        data = response.json()
        
        return data["job_id"]

    def _get_edit_status(
        self,
        job_id: str
    ) -> Dict[str, Any]:
        """Check the status of an edit job.
        
        Returns the video URL if completed, or status information if still processing.
        """
        response = requests.post(
            f"{self.base_url}/api/edit-status",
            headers={**self.headers, "Content-Type": "application/json"},
            json={"job_id": job_id}
        )
        response.raise_for_status()
        return response.json()

    def _poll_edit_status(
        self,
        job_id: str,
        interval_ms: int
    ) -> str:
        """Poll edit status until completion.
        
        Returns the video URL when completed.
        """
        max_attempts = 1200
        attempts = 0
        retry_count = 0
        max_retries = 3
        interval_seconds = interval_ms / 1000.0
        
        while True:
            if attempts >= max_attempts:
                raise TimeoutError("Edit job timed out")
            
            try:
                status = self._get_edit_status(job_id)
                
                if status.get("status") == "completed":
                    return status["url"]
                elif status.get("status") in ("failed", "not found"):
                    raise ValueError(f"Edit job failed: {status.get('status')}")
                else:
                    print(f"Edit job status: {status.get('status')}")
                
                # Reset retry count on successful request
                retry_count = 0
            except (requests.exceptions.ConnectionError, 
                    requests.exceptions.Timeout,
                    requests.exceptions.RequestException) as error:
                # Check if it's a server error that we should retry
                if hasattr(error, 'response') and error.response is not None:
                    status_code = error.response.status_code
                    if 500 <= status_code < 600:
                        if retry_count < max_retries:
                            retry_count += 1
                            wait_time = min(5.0 * retry_count, 15.0)
                            warnings.warn(
                                f"Retrying request ({retry_count}/{max_retries}) after server error..."
                            )
                            time.sleep(wait_time)
                            continue  # Don't increment attempts, just retry
                        else:
                            warnings.warn(f"Max retries ({max_retries}) exceeded for server errors")
                            retry_count = 0
                    else:
                        # Non-server error, log and continue polling
                        warnings.warn(
                            f"Error on attempt {attempts + 1}, continuing polling: {error}"
                        )
                else:
                    # Network error, retry
                    if retry_count < max_retries:
                        retry_count += 1
                        wait_time = min(5.0 * retry_count, 15.0)
                        warnings.warn(
                            f"Retrying request ({retry_count}/{max_retries}) after network error..."
                        )
                        time.sleep(wait_time)
                        continue
                    else:
                        warnings.warn(f"Max retries ({max_retries}) exceeded for network errors")
                        retry_count = 0
            except Exception as error:
                # Non-network error, log and continue polling
                warnings.warn(
                    f"Error on attempt {attempts + 1}, continuing polling: {error}"
                )
            
            time.sleep(interval_seconds)
            attempts += 1

    def _get_frame_versions(self, video_id: str) -> Dict[str, Any]:
        """Get frame versions and animations for a video.
        
        Returns both the current frame_animations and available frame_animation_versions.
        """
        response = requests.get(
            f"{self.base_url}/api/frame-versions/{video_id}",
            headers={**self.headers, "Content-Type": "application/json"}
        )
        response.raise_for_status()
        return response.json()

    def get_frame_animations(self, video_id: str) -> Dict[str, str]:
        """Get frame animations for a video.
        
        Returns a dictionary mapping frame indices to animation URLs.
        This is a convenience method that extracts frame_animations from the frame versions API.
        """
        versions = self._get_frame_versions(video_id)
        return versions.get("frame_animations", {})

    def _set_frame_version(
        self,
        video_id: str,
        frame_id: str,
        animation_url: str
    ) -> Dict[str, str]:
        """Set a specific frame animation version.
        
        Updates the frame_animations for a specific frame in the video.
        """
        response = requests.post(
            f"{self.base_url}/api/set-frame-version",
            headers={**self.headers, "Content-Type": "application/json"},
            json={
                "job_id": video_id,
                "frame_id": frame_id,
                "url": animation_url
            }
        )
        response.raise_for_status()
        return response.json()

    def _combine_edited_frames(
        self,
        video_id: str,
        opts: Dict[str, Any]
    ) -> VideoEditResult:
        """Combines edited frames into a final video with audio.

        This is a helper method used by edit_video when auto_combine is enabled.
        """
        edit_result = ''
        frame_ids = opts["frame_ids"]
        video_url = opts.get("video_url")
        poll_interval_ms = opts["poll_interval_ms"]
        job_id = video_id  # Use video_id as job_id for return value

        try:
            # Get current frame animations
            frame_versions = self._get_frame_versions(video_id)
            frame_animations = frame_versions.get("frame_animations", {}).copy()

            # Set frame versions for edited frames
            for frame_id in frame_ids:
                self._set_frame_version(video_id, frame_id, frame_animations[frame_id])
                # Refresh to get updated frame_animations
                updated_versions = self._get_frame_versions(video_id)
                frame_animations = updated_versions.get("frame_animations", {})

            # Combine all frames
            mp4_urls = [
                url for _, url in sorted(
                    frame_animations.items(),
                    key=lambda x: int(x[0]) if x[0].isdigit() else 0
                )
            ]
            if len(mp4_urls) == 0:
                raise ValueError("No frame animations found to combine")

            combined = self.combine_videos(
                mp4_urls,
                video_url=video_url,
                poll_interval_ms=poll_interval_ms
            )

            return VideoEditResult(video_url=combined.url, job_id=job_id)
        except Exception as error:
            warnings.warn(f"Failed to auto-combine videos, returning edit result: {error}")
            # Return the edit result even if combine fails
            return VideoEditResult(video_url=edit_result, job_id=job_id)

    def update_video(
        self,
        video_id: str,
        *,
        prompt: Optional[str] = None,
        context: Optional[str] = None,
        scenes: Optional[int] = None,
        video_url: Optional[str] = None,
        frame_animations: Optional[Dict[str, Any]] = None,
        jwt_token: str
    ) -> Dict[str, Any]:
        """Update video metadata (prompt, context, scenes, video_url, frame_animations).
        
        Note: This requires JWT authentication.
        
        Args:
            video_id: The ID of the video to update
            prompt: Optional new prompt
            context: Optional new context
            scenes: Optional number of scenes
            video_url: Optional new video URL
            frame_animations: Optional frame animations dictionary
            jwt_token: JWT token for authentication (required)
            
        Returns:
            Dictionary with updated video data
        """
        if not jwt_token:
            raise ValueError("JWT token is required for updating video metadata")
        
        headers = {
            **self.headers,
            "Content-Type": "application/json",
            "Authorization": f"Bearer {jwt_token}"
        }
        
        body: Dict[str, Any] = {}
        if prompt is not None:
            body["prompt"] = prompt
        if context is not None:
            body["context"] = context
        if scenes is not None:
            body["scenes"] = scenes
        if video_url is not None:
            body["video_url"] = video_url
        if frame_animations is not None:
            body["frame_animations"] = frame_animations
        
        response = requests.put(
            f"{self.base_url}/api/videos/{video_id}",
            headers=headers,
            json=body
        )
        response.raise_for_status()
        return response.json()
