"""Clip object class module."""

from __future__ import annotations

from collections.abc import Iterator
from pathlib import Path
from typing import TYPE_CHECKING

from fileseq.filesequence import FileSequence
from fileseq.frameset import FrameSet

from pytvpaint import george, log
from pytvpaint.camera import Camera
from pytvpaint.layer import Layer, LayerColor
from pytvpaint.sound import ClipSound
from pytvpaint.utils import (
    Removable,
    get_tvp_element,
    get_unique_name,
    position_generator,
    refreshed_property,
    render_context,
    set_as_current,
)

if TYPE_CHECKING:
    from pytvpaint.project import Project
    from pytvpaint.scene import Scene


class Clip(Removable):
    """A Clip is a container for layers and is part of a Scene."""

    def __init__(
        self,
        clip_id: int,
        project: Project | None = None,
    ) -> None:
        """Constructs a Clip from an existing TVPaint clip (giving its id).

        Note:
            You should use `Clip.new` to create a new clip

        Args:
            clip_id: an existing clip id
            project: the project or the current one if None
        """
        from pytvpaint.project import Project

        super().__init__()
        self._id = clip_id
        self._project = project or Project.current_project()
        self._data = george.tv_clip_info(self.id)

    def __repr__(self) -> str:
        """The string representation of the clip."""
        return f"Clip({self.name})<id:{self.id}>"

    def __eq__(self, other: object) -> bool:
        """Two clips are equal if their id is the same."""
        if not isinstance(other, Clip):
            return NotImplemented
        return self.id == other.id

    @classmethod
    def new(
        cls,
        name: str,
        scene: Scene | None = None,
        project: Project | None = None,
    ) -> Clip:
        """Creates a new clip.

        Args:
            name: the new clip name
            scene: the scene or the current one if None. Defaults to None.
            project: the project or the current one if None. Defaults to None.

        Returns:
            Clip: the newly created clip
        """
        from pytvpaint.project import Project

        project = project or Project.current_project()
        project.make_current()

        scene = scene or project.current_scene
        scene.make_current()

        name = get_unique_name(project.clip_names, name)
        george.tv_clip_new(name)

        return Clip.current_clip()

    def refresh(self) -> None:
        """Refreshes the clip data."""
        super().refresh()
        if not self.refresh_on_call and self._data:
            return
        self._data = george.tv_clip_info(self._id)

    def make_current(self) -> None:
        """Make the clip the current one."""
        if george.tv_clip_current_id() == self.id:
            return
        george.tv_clip_select(self.id)

    @property
    def id(self) -> int:
        """The clip id."""
        return self._id

    @property
    def project(self) -> Project:
        """The clip's project."""
        return self._project

    @property
    def scene(self) -> Scene:
        """The clip's scene."""
        for scene in self.project.scenes:
            for other_clip in scene.clips:
                if other_clip == self:
                    self.make_current()
                    return scene
        raise Exception("The clip's scene doesn't exist")

    @scene.setter
    def scene(self, value: Scene) -> None:
        """Moves the clip to another scene."""
        george.tv_clip_move(self.id, value.id, self.position)

    @property
    def camera(self) -> Camera:
        """The clip camera."""
        return Camera(clip=self)

    @property
    def position(self) -> int:
        """The position of the clip in the scene."""
        for pos, clip_id in enumerate(self.scene.clip_ids):
            if clip_id == self.id:
                return pos
        raise ValueError(f"No clip found open with id : {self.id}")

    @position.setter
    def position(self, value: int) -> None:
        """Set the position of the clip in the scene."""
        george.tv_clip_move(self.id, self.scene.id, value)

    @property
    @set_as_current
    def name(self) -> str:
        """The clip name."""
        return george.tv_clip_name_get(self.id)

    @name.setter
    def name(self, value: str) -> None:
        """Set the clip name."""
        if self.name == value:
            return
        value = get_unique_name(self.project.clip_names, value)
        george.tv_clip_name_set(self.id, value)

    @refreshed_property
    def start(self) -> int:
        """The start frame of the clip relative to the project start frame."""
        return self._data.first_frame + self.project.start_frame

    @refreshed_property
    def end(self) -> int:
        """The end frame of the clip relative to the project start frame."""
        return self._data.last_frame + self.project.start_frame

    @refreshed_property
    def frame_count(self) -> int:
        """The clip's frame count."""
        return self._data.frame_count

    @property
    def is_selected(self) -> bool:
        """Returns True if the clip is selected."""
        return george.tv_clip_selection_get(self.id)

    @is_selected.setter
    def is_selected(self, value: bool) -> None:
        """Select or deselect the clip."""
        george.tv_clip_selection_set(self.id, value)

    @property
    def is_visible(self) -> bool:
        """Returns True if the clip is visible."""
        return not george.tv_clip_hidden_get(self.id)

    @is_visible.setter
    def is_visible(self, value: bool) -> None:
        """Show or hide the clip."""
        george.tv_clip_hidden_set(self.id, not value)

    @property
    @set_as_current
    def background(
        self,
    ) -> tuple[george.RGBColor, george.RGBColor] | george.RGBColor | None:
        """Get the background color(s).

        Returns:
            a tuple of two colors if checker, a single color if solid or None if empty
        """
        return george.tv_background_get()

    @set_as_current
    def clear_background(self) -> None:
        """Clear the background color and set it to None."""
        george.tv_background_set(george.BackgroundMode.NONE)

    @set_as_current
    def set_background_solid_color(self, color: george.RGBColor) -> None:
        """Set the solid background color."""
        george.tv_background_set(george.BackgroundMode.COLOR, color)

    @set_as_current
    def set_background_checker_colors(
        self, c1: george.RGBColor, c2: george.RGBColor
    ) -> None:
        """Set the checker background colors."""
        george.tv_background_set(george.BackgroundMode.CHECK, c1, c2)

    @property
    def color_index(self) -> int:
        """Get the clip color index."""
        return george.tv_clip_color_get(self.id)

    @color_index.setter
    def color_index(self, value: int) -> None:
        """Set the clip color index."""
        george.tv_clip_color_set(self.id, value)

    @property
    def action_text(self) -> str:
        """Get the action text of the clip."""
        return george.tv_clip_action_get(self.id)

    @action_text.setter
    def action_text(self, value: str) -> None:
        """Set the action text of the clip."""
        george.tv_clip_action_set(self.id, value)

    @property
    def dialog_text(self) -> str:
        """Get the dialog text of the clip."""
        return george.tv_clip_dialog_get(self.id)

    @dialog_text.setter
    def dialog_text(self, value: str) -> None:
        """Set the dialog text of the clip."""
        george.tv_clip_dialog_set(self.id, value)

    @property
    def note_text(self) -> str:
        """Get the note text of the clip."""
        return george.tv_clip_note_get(self.id)

    @note_text.setter
    def note_text(self, value: str) -> None:
        """Set the note text of the clip."""
        george.tv_clip_note_set(self.id, value)

    @refreshed_property
    def is_current(self) -> bool:
        """Returns True if the clip is the current one."""
        return self._data.is_current

    @staticmethod
    def current_clip_id() -> int:
        """Returns the id of the current clip."""
        return george.tv_clip_current_id()

    @staticmethod
    def current_clip() -> Clip:
        """Returns the current clip object."""
        from pytvpaint.project import Project

        return Clip(Clip.current_clip_id(), Project.current_project())

    @property
    @set_as_current
    def current_frame(self) -> int:
        """Returns the current frame in the clip (timeline) relative to the project start frame."""
        return george.tv_layer_image_get() + self.project.start_frame

    @current_frame.setter
    @set_as_current
    def current_frame(self, value: int) -> None:
        """Set the current frame of the clip."""
        george.tv_layer_image(value - self.project.start_frame)

    @set_as_current
    def duplicate(self) -> Clip:
        """Duplicates the clip.

        Note:
            a new unique name is choosen for the duplicated clip with `get_unique_name`.
        """
        george.tv_clip_duplicate(self.id)
        new_clip = self.project.current_clip
        new_clip.name = get_unique_name(self.project.clip_names, new_clip.name)
        return new_clip

    def remove(self) -> None:
        """Removes the clip.

        Warning:
            the instance is not usable after that call because it's marked as removed.
        """
        george.tv_clip_close(self.id)
        self.mark_removed()

    @property
    @set_as_current
    def layer_ids(self) -> Iterator[int]:
        """Iterator over the layer ids."""
        return position_generator(lambda pos: george.tv_layer_get_id(pos))

    @property
    def layers(self) -> Iterator[Layer]:
        """Iterator over the clip's layers."""
        from pytvpaint.layer import Layer

        for layer_id in self.layer_ids:
            yield Layer(layer_id, clip=self)

    @property
    @set_as_current
    def layer_names(self) -> Iterator[str]:
        """Iterator over the clip's layer names."""
        for layer in self.layers:
            yield layer.name

    @property
    def current_layer(self) -> Layer:
        """Get the current layer in the clip."""
        for layer in self.layers:
            if layer.is_current:
                return layer
        raise Exception("Couldn't find a current layer")

    def get_layer(
        self,
        by_id: int | None = None,
        by_name: str | None = None,
    ) -> Layer | None:
        """Get a specific layer by id or name."""
        return get_tvp_element(self.layers, by_id, by_name)

    @set_as_current
    def add_layer(self, layer_name: str) -> Layer:
        """Add a new layer in the layer stack."""
        return Layer.new(name=layer_name, clip=self)

    @property
    def selected_layers(self) -> Iterator[Layer]:
        """Iterator over the selected layers."""
        yield from (layer for layer in self.layers if layer.is_selected)

    @property
    def visible_layers(self) -> Iterator[Layer]:
        """Iterator over the visible layers."""
        yield from (layer for layer in self.layers if layer.is_visible)

    @set_as_current
    @george.undoable
    def load_media(
        self,
        media_path: Path,
        start_count: tuple[int, int] | None = None,
        stretch: bool = False,
        time_stretch: bool = False,
        pre_load: bool = False,
        with_name: str = "",
        field_order: george.FieldOrder = george.FieldOrder.LOWER,
    ) -> Layer:
        """Loads a media (single frame, video, ...) into a new Layer in the clip.

        Args:
            media_path: the path of the media. If it's a file sequence, give the path of the first image.
            start_count: the start and number of image of sequence to load. Defaults to None.
            stretch: Stretch each image to the size of the layer. Defaults to None.
            time_stretch: Once loaded, the layer will have a new number of image corresponding to the project framerate. Defaults to None.
            pre_load: Load all the images in memory, no more reference on the files. Defaults to None.
            with_name: the name of the new layer
            field_order: the field order. Defaults to None.

        Returns:
            Layer: the new layer created
        """
        media_path = Path(media_path)

        george.tv_load_sequence(
            media_path,
            start_count,
            field_order,
            stretch,
            time_stretch,
            pre_load,
        )

        new_layer = Layer.current_layer()
        new_layer.name = with_name or media_path.stem

        return new_layer

    def _handle_output_range(  # noqa: C901
        self,
        output_path: Path | str | FileSequence,
        start: int | None = None,
        end: int | None = None,
        use_camera: bool = False,
        force_range: bool = False,
    ) -> tuple[FileSequence, int, int, bool, bool]:
        """Handle the different options for output paths and range, whether the user provided a range (start-end) or a filesequence with a range or not, this functions ensures we always end up with a valid range to render.

        Args:
            output_path: user provided output path
            start: user provided start frame
            end: user provided end frame
            use_camera: use the camera for rendering, otherwise render the whole canvas. Defaults to False.
            force_range: force the provided range even if it isn't incorrect or might output wrong frames

        Raises:
            ValueError: if output range (start/end) are inferior to clip start frame

        Bug:
            TVPaint will not render the requested/correct range in some cases. Having no control over this inconsistent
            behaviour and to avoid issues we will raise a ValueError if an invalid range is detected. You can however
            force an incorrect range using `force_range=True`, we will then log a warning when this happens (to help
            with debugging) and the function will render with your range without checking the output at the end.
            For more details on the different issues with frame ranges and the timeline in TVPaint, please check the
            `Limitations` section of the documentation which explains this in more detail.

        Returns:
            file_sequence: output path as a FileSequence object
            start: computed start frame
            end: computed end frame
            is_sequence: whether the output is a sequence or not
            is_image: whether the output is an image or not (a movie)
        """
        # we handle all outputs as a FileSequence, makes it a bit easier to handle ranges and padding
        if not isinstance(output_path, FileSequence):
            file_sequence = FileSequence(Path(output_path).as_posix())
        else:
            file_sequence = output_path

        frame_set = file_sequence.frameSet()
        is_image = george.SaveFormat.is_image(file_sequence.extension())

        # if the provided sequence has a range, and we don't, use the sequence range
        if frame_set and len(frame_set) >= 1 and is_image:
            start = start or file_sequence.start()
            end = end or file_sequence.end()

        # check characteristics of output path
        fseq_has_range = frame_set and len(frame_set) > 1
        fseq_is_single_image = frame_set and len(frame_set) == 1
        fseq_no_range_padding = not frame_set and file_sequence.padding()
        range_is_seq = start and end and start != end
        range_is_single_image = start and end and start == end

        is_single_image = bool(
            is_image
            and (fseq_is_single_image or not frame_set)
            and range_is_single_image
        )
        is_sequence = bool(
            is_image and (fseq_has_range or fseq_no_range_padding or range_is_seq)
        )

        # if no range provided, use clip mark in/out, if none, use clip start/end
        start = start or self.mark_in or self.start

        end = (
            start
            if (is_single_image and not end)
            else (end or self.mark_out or self.end)
        )

        frame_set = FrameSet(f"{start}-{end}")

        if not file_sequence.padding() and is_image and len(frame_set) > 1:
            file_sequence.setPadding("#")

        # we should have a range by now, set it in the sequence
        if (is_image and not is_single_image) or file_sequence.padding():
            file_sequence.setFrameSet(frame_set)

        err_msg = ""
        clip_start = self.start
        clip_end = self.end

        if use_camera:
            if start < clip_start or end > clip_end:
                err_msg = (
                    f"TVPaint will not render the full range requested ({start}-{end})"
                )
        else:
            if start < clip_start or end < clip_start:
                err_msg = (
                    f"TVPaint will not render frames before clip start ({clip_start})"
                )
            elif start == clip_start and end > clip_end:
                err_msg = (
                    f"TVPaint will not render the full range requested ({start}-{end})"
                )
            elif clip_start <= start < end and end > clip_end:
                err_msg = f"TVPaint will render the full range requested ({start}-{end}) but frames will be empty"

        if not is_image and start == end:
            err_msg = "TVPaint will not render a movie that contains a single frame"

        if err_msg:
            err_msg += ", check the documentation for more details !"
            if force_range:
                log.warning(err_msg)
            else:
                raise ValueError(err_msg)

        return file_sequence, start, end, is_sequence, is_image

    @set_as_current
    def render(
        self,
        output_path: Path | str | FileSequence,
        start: int | None = None,
        end: int | None = None,
        use_camera: bool = False,
        layer_selection: list[Layer] | None = None,
        alpha_mode: george.AlphaSaveMode = george.AlphaSaveMode.PREMULTIPLY,
        format_opts: list[str] | None = None,
        force_range: bool = False,
    ) -> None:
        """Render the clip to a single frame or frame sequence.

        Args:
            output_path: a single file or file sequence pattern
            start: the start frame to render or the mark in or project start if None. Defaults to None.
            end: the end frame to render or the mark out or project end if None. Defaults to None.
            use_camera: use the camera for rendering, otherwise render the whole canvas. Defaults to False.
            layer_selection: list of layers to render, if None render all of them. Defaults to None.
            alpha_mode: the alpha mode for rendering. Defaults to george.AlphaSaveMode.PREMULTIPLY.
            format_opts: custom format options. Defaults to None.
            force_range: force the provided range even if it isn't incorrect or might output wrong frames

        Raises:
            ValueError: if output range (start/end) are inferior to clip start frame
            FileNotFoundError: if the render failed and no files were found on disk

        Bug:
           TVPaint will not render the requested/correct range in some cases. Having no control over this inconsistent
           behaviour and to avoid issues we will raise a ValueError if an invalid range is detected. You can however
           force an incorrect range using `force_range=True`, we will then log a warning when this happens (to help
           with debugging) and the function will render with your range without checking the output at the end.
           For more details on the different issues with frame ranges and the timeline in TVPaint, please check the
           `Limitations` section of the documentation which explains this in more detail.
        """
        file_sequence, start, end, is_sequence, is_image = self._handle_output_range(
            output_path, start, end, use_camera, force_range
        )

        # get project start to get real values, note that using the camera changes the way we handle ranges
        project_start_frame = self.project.start_frame if not use_camera else 0
        # get clip real start to clamp start and end frames
        clip_real_start = self.start - project_start_frame

        start = max(clip_real_start, (start - project_start_frame))
        end = max(clip_real_start, (end - project_start_frame))

        save_format = george.SaveFormat.from_extension(
            file_sequence.extension().lower()
        )

        # render to output
        if is_image or file_sequence.padding():
            first_frame = Path(file_sequence.frame(file_sequence.start()))
        else:
            first_frame = Path(str(output_path))

        first_frame.parent.mkdir(exist_ok=True, parents=True)

        with render_context(alpha_mode, save_format, format_opts, layer_selection):
            if use_camera:
                george.tv_project_save_sequence(
                    first_frame,
                    start_end_frame=(start, end),
                    use_camera=True,
                )
            else:
                if start == end:
                    george.tv_save_display(first_frame)
                else:
                    george.tv_save_sequence(first_frame, mark_in=start, mark_out=end)

        if force_range:
            return  # user is forcing a possibly invalid range, let them handle output check

        # make sure the output exists otherwise raise an error
        if is_sequence:
            # raises error if sequence not found
            found_sequence = FileSequence.findSequenceOnDisk(str(file_sequence))
            frame_set = found_sequence.frameSet()
            file_sequence_frame_set = file_sequence.frameSet()

            if frame_set is None or file_sequence_frame_set is None:
                raise Exception("Frameset should be defined")

            if not frame_set.issuperset(file_sequence.frameSet()):
                # not all frames found
                missing_frames = file_sequence_frame_set.difference(frame_set)
                raise FileNotFoundError(
                    f"Not all frames found, missing frames ({missing_frames}) "
                    f"in sequence : {output_path}"
                )
        else:
            if not first_frame.exists():
                raise FileNotFoundError(
                    f"Could not find output at : {first_frame.as_posix()}"
                )

    @set_as_current
    def export_tvp(self, export_path: Path | str) -> None:
        """Exports the clip in .tvp format which can be imported as a project in TVPaint.

        Raises:
            FileNotFoundError: if the render failed and no files were found on disk
        """
        export_path = Path(export_path)

        if export_path.suffix != ".tvp":
            raise ValueError("The file extension must be .tvp")

        export_path.parent.mkdir(exist_ok=True, parents=True)
        george.tv_save_clip(export_path)

        if not export_path.exists():
            raise FileNotFoundError(
                f"Could not find output at : {export_path.as_posix()}"
            )

    @set_as_current
    def export_json(
        self,
        export_path: Path | str,
        save_format: george.SaveFormat,
        folder_pattern: str = r"[%3li] %ln",
        file_pattern: str = r"[%3ii] %ln",
        layer_selection: list[Layer] | None = None,
        alpha_mode: george.AlphaSaveMode = george.AlphaSaveMode.PREMULTIPLY,
        format_opts: list[str] | None = None,
        all_images: bool = False,
        ignore_duplicates: bool = False,
    ) -> None:
        """Exports the instances (or all the images) of layers in the clip and a JSON file describing the structure of that clip.

        Args:
            export_path: the JSON export path
            save_format: file format to use for rendering
            folder_pattern: the folder name pattern (%li: layer index, %ln: layer name, %fi: file index (added in 11.0.8)). Defaults to None.
            file_pattern: the file name pattern (%li: layer index, %ln: layer name, %ii: image index, %in: image name, %fi: file index (added in 11.0.8)). Defaults to None.
            layer_selection: list of layers to render or all if None. Defaults to None.
            alpha_mode: the export alpha mode. Defaults to george.AlphaSaveMode.PREMULTIPLY.
            format_opts: custom format options. Defaults to None.
            all_images: export all images (not only the instances). Defaults to False.
            ignore_duplicates: Ignore duplicates images. Defaults to None.

        Raises:
            FileNotFoundError: if the render failed and no files were found on disk
        """
        export_path = Path(export_path)
        export_path.parent.mkdir(exist_ok=True, parents=True)

        with render_context(alpha_mode, save_format, format_opts, layer_selection):
            george.tv_clip_save_structure_json(
                export_path,
                save_format,
                None,
                folder_pattern,
                file_pattern,
                all_images=all_images,
                ignore_duplicates=ignore_duplicates,
            )

        if not export_path.exists():
            raise FileNotFoundError(
                f"Could not find output at : {export_path.as_posix()}"
            )

    @set_as_current
    def export_psd(
        self,
        export_path: Path | str,
        mode: george.PSDSaveMode,
        start: int | None = None,
        end: int | None = None,
        layer_selection: list[Layer] | None = None,
        alpha_mode: george.AlphaSaveMode = george.AlphaSaveMode.PREMULTIPLY,
        format_opts: list[str] | None = None,
    ) -> None:
        """Save the current clip as a PSD.

        Args:
            export_path: the PSD save path
            mode: whether to save all the images, only the instances or inside the markin
            start: the start frame. Defaults to None.
            end: the end frame. Defaults to None.
            layer_selection: layers to render. Defaults to None (render all the layers).
            alpha_mode: the alpha save mode. Defaults to george.AlphaSaveMode.PREMULTIPLY.
            format_opts: custom format options. Defaults to None.
        """
        start = start or self.mark_in or self.start
        end = end or self.mark_out or self.end

        export_path = Path(export_path)
        image = start if mode == george.PSDSaveMode.IMAGE else None

        with render_context(
            alpha_mode, george.SaveFormat.PSD, format_opts, layer_selection
        ):
            george.tv_clip_save_structure_psd(
                export_path,
                mode,
                image=image,
                mark_in=start,
                mark_out=end,
            )

        if not export_path.exists():
            raise FileNotFoundError(
                f"Could not find output at : {export_path.as_posix()}"
            )

    @set_as_current
    def export_csv(
        self,
        export_path: Path | str,
        save_format: george.SaveFormat,
        all_images: bool = False,
        exposure_label: str = "",
        layer_selection: list[Layer] | None = None,
        alpha_mode: george.AlphaSaveMode = george.AlphaSaveMode.PREMULTIPLY,
        format_opts: list[str] | None = None,
    ) -> None:
        """Save the current clip as a CSV.

        Args:
            export_path: the .csv export path
            save_format: file format to use for rendering
            all_images: export all images or only instances. Defaults to None.
            exposure_label: give a label when the image is an exposure. Defaults to None.
            layer_selection: layers to render. Defaults to None (render all the layers).
            alpha_mode: the alpha save mode. Defaults to george.AlphaSaveMode.PREMULTIPLY.
            format_opts: custom format options. Defaults to None.

        Raises:
            ValueError: if the extension is not .csv
            FileNotFoundError: if the render failed and no files were found on disk
        """
        export_path = Path(export_path)

        if export_path.suffix != ".csv":
            raise ValueError("Export path must have .csv extension")

        with render_context(alpha_mode, save_format, format_opts, layer_selection):
            george.tv_clip_save_structure_csv(export_path, all_images, exposure_label)

        if not export_path.exists():
            raise FileNotFoundError(
                f"Could not find output at : {export_path.as_posix()}"
            )

    @set_as_current
    def export_sprites(
        self,
        export_path: Path | str,
        layout: george.SpriteLayout | None = None,
        space: int = 0,
        layer_selection: list[Layer] | None = None,
        alpha_mode: george.AlphaSaveMode = george.AlphaSaveMode.PREMULTIPLY,
        format_opts: list[str] | None = None,
    ) -> None:
        """Save the current clip as sprites in one image.

        Args:
            export_path (Path | str): _description_
            layout: the sprite layout. Defaults to None.
            space: the space between each sprite in the image. Defaults to None.
            layer_selection: layers to render. Defaults to None (render all the layers).
            alpha_mode: the alpha save mode. Defaults to george.AlphaSaveMode.PREMULTIPLY.
            format_opts: custom format options. Defaults to None.
        """
        export_path = Path(export_path)
        save_format = george.SaveFormat.from_extension(export_path.suffix)

        with render_context(alpha_mode, save_format, format_opts, layer_selection):
            george.tv_clip_save_structure_sprite(export_path, layout, space)

        # TODO check whether output was successful and files exist or not for this function and the others

    @set_as_current
    def export_flix(
        self,
        export_path: Path | str,
        start: int | None = None,
        end: int | None = None,
        import_parameters: str = "",
        file_parameters: str = "",
        send: bool = False,
        layer_selection: list[Layer] | None = None,
        alpha_mode: george.AlphaSaveMode = george.AlphaSaveMode.PREMULTIPLY,
        format_opts: list[str] | None = None,
    ) -> None:
        """Save the current clip for Flix.

        Args:
            export_path: the .xml export path
            start: the start frame. Defaults to None.
            end: the end frame. Defaults to None.
            import_parameters: the attribute(s) of the global <flixImport> tag (waitForSource/...). Defaults to None.
            file_parameters: the attribute(s) of each <image> (file) tag (dialogue/...). Defaults to None.
            send: open a browser with the prefilled url. Defaults to None.
            layer_selection: layers to render. Defaults to None (render all the layers).
            alpha_mode: the alpha save mode. Defaults to george.AlphaSaveMode.PREMULTIPLY.
            format_opts: custom format options. Defaults to None.

        Raises:
            ValueError: if the extension is not .xml
        """
        export_path = Path(export_path)

        if export_path.suffix != ".xml":
            raise ValueError("Export path must have .xml extension")

        original_file = self.project.path
        import_parameters = (
            import_parameters
            or 'waitForSource="1" multipleSetups="1" replaceSelection="0"'
        )

        # The project needs to be saved
        self.project.save()

        # save alpha mode and save format values
        with render_context(alpha_mode, None, format_opts, layer_selection):
            george.tv_clip_save_structure_flix(
                export_path,
                start,
                end,
                import_parameters,
                file_parameters,
                send,
                original_file,
            )

    @property
    @set_as_current
    def mark_in(self) -> int | None:
        """Get the mark in of the clip."""
        frame, mark_action = george.tv_mark_in_get(reference=george.MarkReference.CLIP)
        if mark_action == george.MarkAction.CLEAR:
            return None
        return frame + self.project.start_frame

    @mark_in.setter
    @set_as_current
    def mark_in(self, value: int | None) -> None:
        """Set the mark int value of the clip or None to clear it."""
        action = george.MarkAction.CLEAR if value is None else george.MarkAction.SET
        frame = (
            ((self.mark_in or 0) - self.project.start_frame)
            if value is None
            else (value - self.project.start_frame)
        )
        george.tv_mark_in_set(
            reference=george.MarkReference.CLIP, frame=frame, action=action
        )

    @property
    @set_as_current
    def mark_out(self) -> int | None:
        """Get the mark out of the clip."""
        frame, mark_action = george.tv_mark_out_get(reference=george.MarkReference.CLIP)
        if mark_action == george.MarkAction.CLEAR:
            return None
        return frame + self.project.start_frame

    @mark_out.setter
    @set_as_current
    def mark_out(self, value: int | None) -> None:
        """Set the mark in of the clip or None to clear it."""
        value = (value or 0) - self.project.start_frame
        george.tv_mark_out_set(
            reference=george.MarkReference.CLIP,
            frame=value,
            action=george.MarkAction.SET,
        )

    @property
    def layer_colors(self) -> Iterator[LayerColor]:
        """Iterator over the layer colors."""
        for color_index in range(26):
            yield LayerColor(color_index=color_index, clip=self)

    def set_layer_color(
        self,
        index: int,
        color: george.RGBColor,
        name: str | None = None,
    ) -> None:
        """Set the layer color at the provided index.

        Args:
            index: the layer color index
            color: the new color
            name: the name to change. Defaults to None.
        """
        george.tv_layer_color_set_color(self.id, index, color, name)

    def get_layer_color(
        self,
        by_index: int | None = None,
        by_name: str | None = None,
    ) -> LayerColor:
        """Get a layer color by index or name."""
        if not by_index and by_name:
            raise ValueError(
                "At least one value (by_index or by_name) must be provided"
            )

        if by_index is not None:
            return next(c for i, c in enumerate(self.layer_colors) if i == by_index)

        try:
            return next(c for c in self.layer_colors if c.name == by_name)
        except StopIteration:
            raise ValueError(
                f"No LayerColor found with name ({by_name}) in Clip ({self.name})"
            )

    @property
    def bookmarks(self) -> Iterator[int]:
        """Iterator over the clip bookmarks."""
        bookmarks_iter = position_generator(lambda pos: george.tv_bookmarks_enum(pos))
        project_start_frame = self.project.start_frame
        return (frame + project_start_frame for frame in bookmarks_iter)

    def add_bookmark(self, frame: int) -> None:
        """Add a bookmark at that frame."""
        george.tv_bookmark_set(frame - self.project.start_frame)

    def remove_bookmark(self, frame: int) -> None:
        """Remove a bookmark at that frame."""
        george.tv_bookmark_clear(frame - self.project.start_frame)

    def clear_bookmarks(self) -> None:
        """Remove all the bookmarks."""
        bookmarks = list(self.bookmarks)
        for frame in bookmarks:
            self.remove_bookmark(frame)

    @set_as_current
    def go_to_previous_bookmark(self) -> None:
        """Go to the previous bookmarks frame."""
        george.tv_bookmark_prev()

    @set_as_current
    def go_to_next_bookmark(self) -> None:
        """Go to the next bookmarks frame."""
        george.tv_bookmark_next()

    @property
    def sounds(self) -> Iterator[ClipSound]:
        """Iterates through the clip's soundtracks."""
        sounds_data = position_generator(
            lambda pos: george.tv_sound_clip_info(self.id, pos)
        )

        for track_index, _ in enumerate(sounds_data):
            yield ClipSound(track_index, clip=self)

    def get_sound(
        self,
        by_id: int | None = None,
        by_path: Path | str | None = None,
    ) -> ClipSound | None:
        """Get a clip sound by id or by path."""
        for sound in self.sounds:
            if (by_id and sound.id == by_id) or (by_path and sound.path == by_path):
                return sound
        raise ValueError("Can't find sound")

    def add_sound(self, sound_path: Path | str) -> ClipSound:
        """Adds a new clip soundtrack."""
        return ClipSound.new(sound_path, parent=self)
