"""
 @file
 @brief This file is used to generate a Final Cut Pro export
 @author Jonathan Thomas <jonathan@openshot.org>

 @section LICENSE

 Copyright (c) 2008-2018 OpenShot Studios, LLC
 (http://www.openshotstudios.com). This file is part of
 OpenShot Video Editor (http://www.openshot.org), an open-source project
 dedicated to delivering high quality video editing and animation solutions
 to the world.

 OpenShot Video Editor is free software: you can redistribute it and/or modify
 it under the terms of the GNU General Public License as published by
 the Free Software Foundation, either version 3 of the License, or
 (at your option) any later version.

 OpenShot Video Editor is distributed in the hope that it will be useful,
 but WITHOUT ANY WARRANTY; without even the implied warranty of
 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 GNU General Public License for more details.

 You should have received a copy of the GNU General Public License
 along with OpenShot Library.  If not, see <http://www.gnu.org/licenses/>.
 """

import os
import pathlib
import shutil
import subprocess
from operator import itemgetter
from uuid import uuid1
from xml.dom import minidom

import openshot
from qt_api import QFileDialog

from classes import info
from classes.app import get_app
from classes.logger import log
from classes.path_utils import absolute_media_path, relative_export_path, normalize_path
from classes.query import Clip, Track, File

INTERPOLATION_EXPORT_MAP = {
    openshot.LINEAR: "linear",
    openshot.BEZIER: "bezier",
    openshot.CONSTANT: "constant"
}


def _set_text(node, value):
    """Safely set the text content of a DOM node."""
    if not node:
        return
    if node.firstChild:
        node.firstChild.nodeValue = str(value)
    else:
        node.appendChild(node.ownerDocument.createTextNode(str(value)))


def _is_ntsc_rate(fps_num, fps_den):
    return fps_den == 1001 and fps_num in (24000, 30000, 60000)


def _seconds_to_frames(value, fps_num, fps_den):
    if value is None:
        value = 0.0
    return int(round(float(value) * fps_num / fps_den))


def _format_timebase(fps_num, fps_den):
    fps_value = float(fps_num) / float(fps_den)
    text = ("{0:.3f}".format(fps_value)).rstrip('0').rstrip('.')
    return text or "0"


def _format_ratio(num, den):
    """Format a numeric ratio as a compact string."""
    try:
        num = float(num)
        den = float(den)
        if den == 0:
            raise ZeroDivisionError()
        value = num / den
        text = ("{0:.10f}".format(value)).rstrip('0').rstrip('.')
        return text or "1"
    except Exception:
        return "1"


def _displayformat(ntsc_value):
    """Choose drop-frame vs non-drop timecode string."""
    ntsc_upper = (ntsc_value or "").upper()
    return "DF" if ntsc_upper == "TRUE" else "NDF"


def _timecode_string(ntsc_value):
    """Return a default timecode string respecting drop/non-drop separators."""
    separator = ";" if (ntsc_value or "").upper() == "TRUE" else ":"
    return f"00{separator}00{separator}00{separator}00"


def _file_url(path_string):
    """Return a file:// URL for an absolute path if possible."""
    if not path_string:
        return ""
    try:
        return pathlib.Path(path_string).absolute().as_uri()
    except Exception:
        return normalize_path(path_string)


def _unique_file_id(path_string, existing_id, id_map):
    """Ensure a unique file id per absolute path."""
    normalized = os.path.abspath(path_string or "")
    if normalized in id_map:
        return id_map[normalized]
    candidate = existing_id or "file-%s" % uuid1()
    if candidate in id_map.values():
        candidate = "file-%s" % uuid1()
    id_map[normalized] = candidate
    return candidate


def _clip_file_info(clip):
    """Return (File object, merged metadata dict, absolute file path)."""
    file_obj = None
    file_data = {}
    file_id = clip.data.get("file_id")
    if file_id:
        file_obj = File.get(id=file_id)
        if file_obj and file_obj.data:
            file_data = dict(file_obj.data)

    reader_data = clip.data.get("reader", {}) or {}
    merged_data = dict(reader_data)
    merged_data.update(file_data)

    file_path = ""
    if file_obj:
        file_path = file_obj.absolute_path()
    if not file_path:
        file_path = absolute_media_path(reader_data.get("path"))
    if not file_path:
        file_path = reader_data.get("path", "")

    return file_obj, merged_data, file_path


def _apply_rate_settings(rate_nodes, timebase_value, ntsc_value):
    for rate_node in rate_nodes:
        timebase_nodes = rate_node.getElementsByTagName("timebase")
        if timebase_nodes:
            _set_text(timebase_nodes[0], timebase_value)
        ntsc_nodes = rate_node.getElementsByTagName("ntsc")
        if ntsc_nodes:
            _set_text(ntsc_nodes[0], ntsc_value)


def _apply_timecode_settings(timecode_nodes, timebase_value, ntsc_value):
    for timecode_node in timecode_nodes:
        _apply_rate_settings(timecode_node.getElementsByTagName("rate"), timebase_value, ntsc_value)
        displayformat_nodes = timecode_node.getElementsByTagName("displayformat")
        if displayformat_nodes:
            _set_text(displayformat_nodes[0], _displayformat(ntsc_value))
        string_nodes = timecode_node.getElementsByTagName("string")
        if string_nodes:
            _set_text(string_nodes[0], _timecode_string(ntsc_value))


def _validate_export(xml_path):
    """Validate the exported XML against the bundled Final Cut Pro DTDs."""
    xmllint_path = shutil.which("xmllint")
    if not xmllint_path:
        log.info("Skipping FCP XML validation; xmllint not available")
        return

    for dtd_file in ("fcp-xml-v4.dtd", "fcp-xml-v5.dtd"):
        dtd_path = os.path.join(info.RESOURCES_PATH, dtd_file)
        if not os.path.exists(dtd_path):
            continue

        result = subprocess.run(
            [xmllint_path, "--noout", "--dtdvalid", dtd_path, xml_path],
            capture_output=True,
            text=True,
        )
        if result.returncode == 0:
            log.info("Validated Final Cut Pro XML against %s", dtd_file)
        else:
            error_text = (result.stderr or result.stdout or "").strip()
            log.warning("Final Cut Pro XML did not validate against %s: %s", dtd_file, error_text)


def _append_link(link_parent, mediatype_value, track_index, clip_index, group_index, ref_id):
    """Append a <link> node for A/V grouping compatibility."""
    if not link_parent:
        return
    link_node = link_parent.ownerDocument.createElement("link")
    link_parent.appendChild(link_node)
    for tag, value in (
        ("linkclipref", ref_id),
        ("mediatype", mediatype_value),
        ("trackindex", track_index),
        ("clipindex", clip_index),
        ("groupindex", group_index),
    ):
        child = link_parent.ownerDocument.createElement(tag)
        child.appendChild(link_parent.ownerDocument.createTextNode(str(value)))
        link_node.appendChild(child)


def _normalized_to_center_pixels(x_norm, y_norm, frame_width, frame_height):
    """Map normalized OpenShot coords (-1..1, origin center) to pixel center values."""
    try:
        w = float(frame_width)
        h = float(frame_height)
    except (TypeError, ValueError):
        return x_norm, y_norm
    if w <= 0 or h <= 0:
        return x_norm, y_norm
    return (w / 2.0) + (x_norm * w / 2.0), (h / 2.0) + (y_norm * h / 2.0)


def _export_interp_name(value):
    """Return an interpolation name for export from int/str values."""
    if isinstance(value, str):
        val = value.strip().lower()
        if val in ("linear", "0"):
            return "linear"
        if val in ("bezier", "ease", "easein", "easeout", "1"):
            return "bezier"
        if val in ("hold", "constant", "2"):
            return "constant"
    try:
        return INTERPOLATION_EXPORT_MAP.get(int(value), "linear")
    except Exception:
        return "linear"


def _scale_mode_size(src_w, src_h, frame_w, frame_h, scale_mode):
    """Return base scaled dimensions after applying scale mode (before per-axis scale)."""
    try:
        sw = float(src_w)
        sh = float(src_h)
        fw = float(frame_w)
        fh = float(frame_h)
    except (TypeError, ValueError):
        return src_w, src_h
    if sw <= 0 or sh <= 0 or fw <= 0 or fh <= 0:
        return src_w, src_h
    if scale_mode == openshot.SCALE_STRETCH:
        return fw, fh
    if scale_mode == openshot.SCALE_CROP:
        factor = max(fw / sw, fh / sh)
        return sw * factor, sh * factor
    if scale_mode == openshot.SCALE_FIT:
        factor = min(fw / sw, fh / sh)
        return sw * factor, sh * factor
    # SCALE_NONE or unknown
    return sw, sh


def _gravity_offset(gravity, frame_w, frame_h, scaled_w, scaled_h):
    """Top-left origin based on gravity inside the frame."""
    try:
        frame_w = float(frame_w)
        frame_h = float(frame_h)
        scaled_w = float(scaled_w)
        scaled_h = float(scaled_h)
    except (TypeError, ValueError):
        return 0.0, 0.0
    x = 0.0
    y = 0.0
    if gravity == openshot.GRAVITY_TOP:
        x = (frame_w - scaled_w) / 2.0
    elif gravity == openshot.GRAVITY_TOP_RIGHT:
        x = frame_w - scaled_w
    elif gravity == openshot.GRAVITY_LEFT:
        y = (frame_h - scaled_h) / 2.0
    elif gravity == openshot.GRAVITY_CENTER:
        x = (frame_w - scaled_w) / 2.0
        y = (frame_h - scaled_h) / 2.0
    elif gravity == openshot.GRAVITY_RIGHT:
        x = frame_w - scaled_w
        y = (frame_h - scaled_h) / 2.0
    elif gravity == openshot.GRAVITY_BOTTOM_LEFT:
        y = frame_h - scaled_h
    elif gravity == openshot.GRAVITY_BOTTOM:
        x = (frame_w - scaled_w) / 2.0
        y = frame_h - scaled_h
    elif gravity == openshot.GRAVITY_BOTTOM_RIGHT:
        x = frame_w - scaled_w
        y = frame_h - scaled_h
    return x, y


def _merge_uniform_scale(scale_x_points, scale_y_points):
    """Combine X/Y scale into a single uniform curve (average when both exist)."""
    merged = {}

    def _add(points):
        for point in points:
            t = point.get("co", {}).get("X", 1)
            v = point.get("co", {}).get("Y", 1.0)
            interp = point.get("interpolation", openshot.LINEAR)
            if t in merged:
                merged[t]["val"] = (merged[t]["val"] + v) / 2.0
                if merged[t].get("interp") is None:
                    merged[t]["interp"] = interp
            else:
                merged[t] = {"val": v, "interp": interp}

    _add(scale_x_points or [])
    _add(scale_y_points or [])

    merged_points = []
    for t in sorted(merged.keys()):
        merged_points.append(
            {
                "co": {"X": t, "Y": merged[t]["val"]},
                "interpolation": merged[t].get("interp", openshot.LINEAR),
            }
        )
    return merged_points


def _find_effect_node(node, effect_name):
    """Find the first effect node with a matching name."""
    for effectNode in node.getElementsByTagName("effect"):
        effectName = effectNode.getElementsByTagName("name")[0].childNodes[0].nodeValue
        if effectName == effect_name:
            return effectNode
    return None


def _find_parameter_node(effect_node, parameter_id):
    """Find the first parameter node with a matching id."""
    for param in effect_node.getElementsByTagName("parameter"):
        paramIdNode = param.getElementsByTagName("parameterid")
        if paramIdNode and paramIdNode[0].childNodes and paramIdNode[0].childNodes[0].nodeValue == parameter_id:
            return param
    return None


def createEffect(xmldoc, name, node, points, scale, max_frames=None, param_name=None):
    """Create the XML filter with keyframes"""
    effectNode = _find_effect_node(node, name)
    if not effectNode:
        return
    if param_name:
        parameterNode = _find_parameter_node(effectNode, param_name)
    else:
        parameterNode = effectNode.getElementsByTagName("parameter")[0]

    if not parameterNode:
        return

    # Loop through Points (remove duplicates)
    keyframes = {}
    for point in points:
        keyframeTime = point.get('co', {}).get('X', 1)
        keyframeValue = point.get('co', {}).get('Y', 1) * scale
        interpolation = point.get('interpolation', openshot.LINEAR)
        if max_frames is not None:
            keyframeTime = max(0, min(keyframeTime, max_frames))
        keyframes[keyframeTime] = (keyframeValue, interpolation)

    # Loop through Points
    for keyframeTime in sorted(keyframes.keys()):
        keyframeValue, interpolation = keyframes.get(keyframeTime)

        # Create keyframe element for each point
        keyframeNode = xmldoc.createElement("keyframe")
        parameterNode.appendChild(keyframeNode)
        whenNode = xmldoc.createElement("when")
        whenNode.appendChild(xmldoc.createTextNode(str(int(round(keyframeTime)))))
        keyframeNode.appendChild(whenNode)
        valueNode = xmldoc.createElement("value")
        valueNode.appendChild(xmldoc.createTextNode(str(keyframeValue)))
        keyframeNode.appendChild(valueNode)
        interpNode = xmldoc.createElement("interpolation")
        interpName = xmldoc.createElement("name")
        interpName.appendChild(xmldoc.createTextNode(_export_interp_name(interpolation)))
        interpNode.appendChild(interpName)
        keyframeNode.appendChild(interpNode)


def createCenterEffect(
    xmldoc,
    node,
    x_points,
    y_points,
    frame_width,
    frame_height,
    src_width,
    src_height,
    scale_mode,
    gravity,
    scale_x_points,
    scale_y_points,
    max_frames=None,
):
    """Create the Basic Motion center keyframes using project/frame units."""
    effectNode = _find_effect_node(node, "Basic Motion")
    if not effectNode:
        return

    parameterNode = _find_parameter_node(effectNode, "center")
    if not parameterNode:
        return

    keyframes = {}

    def _add_points(points, axis_key):
        for point in points:
            keyframeTime = point.get("co", {}).get("X", 1)
            interpolation = point.get("interpolation", openshot.LINEAR)
            if max_frames is not None:
                keyframeTime = max(0, min(keyframeTime, max_frames))
            entry = keyframes.setdefault(keyframeTime, {})
            entry[axis_key] = point.get("co", {}).get("Y", 0.0)
            entry["interp_%s" % axis_key] = interpolation

    _add_points(x_points, "x")
    _add_points(y_points, "y")

    def _value_at_time(points, t, fallback=1.0):
        if not points:
            return fallback
        sorted_points = sorted(points, key=lambda p: p.get("co", {}).get("X", 0))
        last_val = sorted_points[0].get("co", {}).get("Y", fallback)
        for p in sorted_points:
            pt_time = p.get("co", {}).get("X", 0)
            if max_frames is not None:
                pt_time = max(0, min(pt_time, max_frames))
            if pt_time <= t:
                last_val = p.get("co", {}).get("Y", fallback)
            else:
                break
        return last_val

    last_x = 0.0
    last_y = 0.0

    for keyframeTime in sorted(keyframes.keys()):
        entry = keyframes[keyframeTime]
        x_val = entry.get("x", last_x)
        y_val = entry.get("y", last_y)
        last_x = x_val
        last_y = y_val
        if "interp_x" in entry:
            interpolation = entry.get("interp_x")
        elif "interp_y" in entry:
            interpolation = entry.get("interp_y")
        else:
            interpolation = openshot.LINEAR

        base_w, base_h = _scale_mode_size(src_width, src_height, frame_width, frame_height, scale_mode)
        sx = _value_at_time(scale_x_points, keyframeTime, 1.0)
        sy = _value_at_time(scale_y_points, keyframeTime, 1.0)
        scaled_w = base_w * sx
        scaled_h = base_h * sy
        origin_x, origin_y = _gravity_offset(gravity, frame_width, frame_height, scaled_w, scaled_h)
        center_x, center_y = origin_x + (scaled_w / 2.0), origin_y + (scaled_h / 2.0)
        center_x += frame_width * x_val
        center_y += frame_height * y_val

        keyframeNode = xmldoc.createElement("keyframe")
        parameterNode.appendChild(keyframeNode)
        whenNode = xmldoc.createElement("when")
        whenNode.appendChild(xmldoc.createTextNode(str(int(round(keyframeTime)))))
        keyframeNode.appendChild(whenNode)

        valueNode = xmldoc.createElement("value")
        horizNode = xmldoc.createElement("horiz")
        horizNode.appendChild(xmldoc.createTextNode(str(center_x)))
        valueNode.appendChild(horizNode)
        vertNode = xmldoc.createElement("vert")
        vertNode.appendChild(xmldoc.createTextNode(str(center_y)))
        valueNode.appendChild(vertNode)
        keyframeNode.appendChild(valueNode)

        interpNode = xmldoc.createElement("interpolation")
        interpName = xmldoc.createElement("name")
        interpName.appendChild(xmldoc.createTextNode(_export_interp_name(interpolation)))
        interpNode.appendChild(interpName)
        keyframeNode.appendChild(interpNode)


def export_xml():
    """Export final cut pro XML file"""
    app = get_app()
    _ = app._tr

    # Get FPS info
    fps_num = get_app().project.get("fps").get("num", 24)
    fps_den = get_app().project.get("fps").get("den", 1)
    timebase_value = _format_timebase(fps_num, fps_den)
    ntsc_value = "TRUE" if _is_ntsc_rate(fps_num, fps_den) else "FALSE"
    project_data = getattr(app.project, "_data", {}) or {}
    project_interlaced = False
    try:
        project_interlaced = bool(project_data.get("interlaced_frame"))
    except Exception:
        project_interlaced = False
    project_pixel_ratio = project_data.get("pixel_ratio") or {}
    project_pixel_ratio_value = _format_ratio(project_pixel_ratio.get("num", 1), project_pixel_ratio.get("den", 1))
    try:
        project_pixel_ratio_float = float(project_pixel_ratio.get("num", 1)) / float(project_pixel_ratio.get("den", 1))
    except Exception:
        project_pixel_ratio_float = 1.0
    project_anamorphic_value = "TRUE" if abs(project_pixel_ratio_float - 1.0) > 0.0005 else "FALSE"
    project_field_dominance = "lower" if project_interlaced else "none"

    # Get path
    recommended_path = get_app().project.current_filepath or ""
    if not recommended_path:
        recommended_path = os.path.join(info.HOME_PATH, "%s.xml" % _("Untitled Project"))
    else:
        recommended_path = recommended_path.replace(".osp", ".xml")
    file_path = QFileDialog.getSaveFileName(app.window, _("Export XML..."), recommended_path,
                                            _("Final Cut Pro (*.xml)"))[0]
    if not file_path:
        # User canceled dialog
        return

    # Append .xml if needed
    if not file_path.endswith(".xml"):
        file_path = "%s.xml" % file_path

    export_folder = os.path.dirname(os.path.abspath(file_path))

    # Get filename with no path
    file_name = os.path.basename(file_path)

    # Determine max frame (based on clips)
    duration = 0.0
    all_clips = Clip.filter()
    for clip in all_clips:
        clip_last_frame = (clip.data.get("position") or 0.0) + ((clip.data.get("end") or 0.0) - (clip.data.get("start") or 0.0))
        if clip_last_frame > duration:
            # Set max length of timeline
            duration = clip_last_frame
    duration_frames = _seconds_to_frames(duration, fps_num, fps_den)

    # XML template path
    xmldoc = minidom.parse(os.path.join(info.RESOURCES_PATH, 'export-project-template.xml'))

    # Set Project Details
    _set_text(xmldoc.getElementsByTagName("name")[0], file_name)
    _set_text(xmldoc.getElementsByTagName("uuid")[0], str(uuid1()))
    _set_text(xmldoc.getElementsByTagName("duration")[0], duration_frames)
    _set_text(xmldoc.getElementsByTagName("width")[0], app.project.get("width"))
    _set_text(xmldoc.getElementsByTagName("height")[0], app.project.get("height"))
    for par_node in xmldoc.getElementsByTagName("pixelaspectratio"):
        _set_text(par_node, project_pixel_ratio_value)
    for anamorphic_node in xmldoc.getElementsByTagName("anamorphic"):
        _set_text(anamorphic_node, project_anamorphic_value)
    _set_text(xmldoc.getElementsByTagName("samplerate")[0], app.project.get("sample_rate"))
    for field_node in xmldoc.getElementsByTagName("fielddominance"):
        _set_text(field_node, project_field_dominance)
    _apply_timecode_settings(xmldoc.getElementsByTagName("timecode"), timebase_value, ntsc_value)
    sequence_node = xmldoc.getElementsByTagName("sequence")[0]
    if app.project.get("id"):
        sequence_node.setAttribute("id", app.project.get("id"))
    for childNode in xmldoc.getElementsByTagName("timebase"):
        _set_text(childNode, timebase_value)
    for ntsc_node in xmldoc.getElementsByTagName("ntsc"):
        _set_text(ntsc_node, ntsc_value)

    # Get parent nodes
    parentAudioNode = xmldoc.getElementsByTagName("audio")[0]
    parentVideoNode = xmldoc.getElementsByTagName("video")[0]
    num_output_channels = parentAudioNode.getElementsByTagName("channelcount")
    if num_output_channels:
        project_channels = app.project.get("channels")
        if project_channels is None:
            project_channels = 2
        _set_text(num_output_channels[0], project_channels)

    # Loop through tracks
    all_tracks = get_app().project.get("layers")
    video_track_index = 1
    audio_track_index = 1
    clip_link_info = {}
    file_id_map = {}
    for track in sorted(all_tracks, key=itemgetter('number')):
        existing_track = Track.get(number=track.get("number"))
        if not existing_track:
            # Log error and fail silently, and continue
            log.error('No track object found with number: %s' % track.get("number"))
            continue

        # Track details
        track_locked = track.get("lock", False)
        clips_on_track = sorted(Clip.filter(layer=track.get("number")), key=lambda c: c.data.get('position', 0.0))
        if not clips_on_track:
            continue

        has_video = any(c.data.get("reader", {}).get("has_video") for c in clips_on_track)
        has_audio = any(c.data.get("reader", {}).get("has_audio") for c in clips_on_track)

        videoTrackNode = None
        audioTrackNode = None
        audioTrackNumber = None
        videoTrackNumber = None

        if has_video:
            trackTemplateDoc = minidom.parse(os.path.join(info.RESOURCES_PATH, 'export-track-video-template.xml'))
            videoTrackNode = trackTemplateDoc.getElementsByTagName('track')[0]
            parentVideoNode.appendChild(videoTrackNode)
            videoTrackNumber = video_track_index
            video_track_index += 1
            if track_locked:
                _set_text(videoTrackNode.getElementsByTagName("locked")[0], "TRUE")

        if has_audio:
            trackTemplateDoc = minidom.parse(os.path.join(info.RESOURCES_PATH, 'export-track-audio-template.xml'))
            audioTrackNode = trackTemplateDoc.getElementsByTagName('track')[0]
            parentAudioNode.appendChild(audioTrackNode)
            audioTrackNumber = audio_track_index
            audio_track_index += 1
            output_nodes = audioTrackNode.getElementsByTagName("outputchannelindex")
            if output_nodes:
                _set_text(output_nodes[0], audioTrackNumber)
            if track_locked:
                _set_text(audioTrackNode.getElementsByTagName("locked")[0], "TRUE")

        video_clip_index = 1
        audio_clip_index = 1

        # Loop through clips on this track
        for clip in clips_on_track:
            clip_reader = clip.data.get("reader", {}) or {}
            clip_duration_frames = _seconds_to_frames((clip.data.get('end') or 0.0) - (clip.data.get('start') or 0.0), fps_num, fps_den)
            if clip_duration_frames <= 0:
                continue

            clip_in_frames = _seconds_to_frames(clip.data.get('start') or 0.0, fps_num, fps_den)
            clip_out_frames = clip_in_frames + clip_duration_frames
            timeline_in_frames = _seconds_to_frames(clip.data.get('position') or 0.0, fps_num, fps_den)
            timeline_out_frames = timeline_in_frames + clip_duration_frames

            file_obj, merged_data, abs_media_path = _clip_file_info(clip)
            display_name = merged_data.get("name") or os.path.basename(merged_data.get("path", "") or clip.data.get('title') or "")
            clip_title = clip.data.get('title') or display_name or os.path.basename(abs_media_path or "") or "Clip"
            sample_rate = merged_data.get("sample_rate") or merged_data.get("audio_sample_rate") or app.project.get("sample_rate") or 48000
            channel_count = merged_data.get("channels") or merged_data.get("channel_count") or 2
            merged_pixel_ratio = merged_data.get("pixel_ratio") or project_pixel_ratio
            clip_pixel_ratio_value = _format_ratio(merged_pixel_ratio.get("num", 1), merged_pixel_ratio.get("den", 1))
            try:
                clip_pixel_ratio_float = float(merged_pixel_ratio.get("num", 1)) / float(merged_pixel_ratio.get("den", 1))
            except Exception:
                clip_pixel_ratio_float = 1.0
            clip_anamorphic_value = "TRUE" if abs(clip_pixel_ratio_float - 1.0) > 0.0005 else "FALSE"
            clip_field_dominance = project_field_dominance
            if merged_data.get("interlaced_frame"):
                clip_field_dominance = "lower"
            try:
                sample_rate = int(sample_rate)
            except (TypeError, ValueError):
                sample_rate = 48000
            try:
                channel_count = int(channel_count)
            except (TypeError, ValueError):
                channel_count = 2
            relative_media_path = relative_export_path(abs_media_path, export_folder)
            file_id_value = _unique_file_id(abs_media_path or merged_data.get("path"), clip.data.get('file_id'), file_id_map)

            # Use the single authoritative duration from the File object (if available)
            file_duration_frames = None
            if file_obj and isinstance(file_obj.data.get("duration"), (int, float)):
                file_duration_frames = _seconds_to_frames(file_obj.data.get("duration"), fps_num, fps_den)

            if file_duration_frames is not None:
                clip_in_frames = min(clip_in_frames, file_duration_frames)
                max_available = max(0, file_duration_frames - clip_in_frames)
                clip_duration_frames = min(clip_duration_frames, max_available)
                clip_out_frames = clip_in_frames + clip_duration_frames
                timeline_out_frames = timeline_in_frames + clip_duration_frames
            if clip_duration_frames <= 0:
                continue

            if clip_reader.get("has_video") and videoTrackNode:
                clipTemplateDoc = minidom.parse(os.path.join(info.RESOURCES_PATH, 'export-clip-video-template.xml'))
                clipNode = clipTemplateDoc.getElementsByTagName('clipitem')[0]
                videoTrackNode.appendChild(clipNode)

                clipNode.setAttribute('id', clip.data.get('id'))
                clip_file_node = clipNode.getElementsByTagName("file")[0]
                clip_file_node.setAttribute('id', file_id_value)
                clip_names = clipNode.getElementsByTagName("name")
                if clip_names:
                    _set_text(clip_names[0], clip_title)
                if len(clip_names) > 1:
                    _set_text(clip_names[1], clip_title)
                _set_text(clipNode.getElementsByTagName("start")[0], timeline_in_frames)
                _set_text(clipNode.getElementsByTagName("end")[0], timeline_out_frames)
                _set_text(clipNode.getElementsByTagName("in")[0], clip_in_frames)
                _set_text(clipNode.getElementsByTagName("out")[0], clip_out_frames)
                _set_text(clipNode.getElementsByTagName("duration")[0], clip_duration_frames)
                for par_node in clipNode.getElementsByTagName("pixelaspectratio"):
                    _set_text(par_node, clip_pixel_ratio_value)
                for anamorphic_node in clipNode.getElementsByTagName("anamorphic"):
                    _set_text(anamorphic_node, clip_anamorphic_value)
                _apply_rate_settings(clipNode.getElementsByTagName("rate"), timebase_value, ntsc_value)

                file_path_nodes = clip_file_node.getElementsByTagName("pathurl")
                if file_path_nodes:
                    preferred_path = abs_media_path or relative_media_path or clip_title
                    _set_text(file_path_nodes[0], _file_url(preferred_path))
                file_name_nodes = clip_file_node.getElementsByTagName("name")
                if file_name_nodes:
                    _set_text(file_name_nodes[0], display_name)
                file_duration_nodes = clip_file_node.getElementsByTagName("duration")
                if file_duration_nodes:
                    if file_duration_frames is not None:
                        _set_text(file_duration_nodes[0], file_duration_frames)
                    else:
                        _set_text(file_duration_nodes[0], clip_duration_frames)
                width_nodes = clip_file_node.getElementsByTagName("width")
                if width_nodes:
                    _set_text(width_nodes[0], merged_data.get("width") or app.project.get("width"))
                height_nodes = clip_file_node.getElementsByTagName("height")
                if height_nodes:
                    _set_text(height_nodes[0], merged_data.get("height") or app.project.get("height"))
                field_dom_nodes = clip_file_node.getElementsByTagName("fielddominance")
                for field_node in field_dom_nodes:
                    _set_text(field_node, clip_field_dominance)
                for par_node in clip_file_node.getElementsByTagName("pixelaspectratio"):
                    _set_text(par_node, clip_pixel_ratio_value)
                for anamorphic_node in clip_file_node.getElementsByTagName("anamorphic"):
                    _set_text(anamorphic_node, clip_anamorphic_value)
                _apply_timecode_settings(clip_file_node.getElementsByTagName("timecode"), timebase_value, ntsc_value)
                samplerate_nodes = clip_file_node.getElementsByTagName("samplerate")
                if samplerate_nodes:
                    _set_text(samplerate_nodes[0], sample_rate)
                channel_nodes = clip_file_node.getElementsByTagName("channelcount")
                if channel_nodes:
                    _set_text(channel_nodes[0], channel_count)
                media_nodes = clip_file_node.getElementsByTagName("media")
                if media_nodes:
                    video_nodes = media_nodes[0].getElementsByTagName("video")
                    audio_nodes = media_nodes[0].getElementsByTagName("audio")
                    if not clip_reader.get("has_audio"):
                        for n in list(audio_nodes):
                            media_nodes[0].removeChild(n)
                    if not clip_reader.get("has_video"):
                        for n in list(video_nodes):
                            media_nodes[0].removeChild(n)

                if clip_reader.get("has_video"):
                    frame_width = app.project.get("width") or merged_data.get("width")
                    frame_height = app.project.get("height") or merged_data.get("height")
                    src_width = merged_data.get("width") or frame_width
                    src_height = merged_data.get("height") or frame_height
                    scale_mode = clip.data.get("scale", openshot.SCALE_FIT)
                    gravity = clip.data.get("gravity", openshot.GRAVITY_CENTER)
                    createEffect(
                        xmldoc,
                        "Opacity",
                        clipNode,
                        clip.data.get("alpha", {}).get("Points", []),
                        100.0,
                        clip_duration_frames,
                        "opacity",
                    )
                    createCenterEffect(
                        xmldoc,
                        clipNode,
                        clip.data.get("location_x", {}).get("Points", []),
                        clip.data.get("location_y", {}).get("Points", []),
                        frame_width,
                        frame_height,
                        src_width,
                        src_height,
                        scale_mode,
                        gravity,
                        clip.data.get("scale_x", {}).get("Points", []),
                        clip.data.get("scale_y", {}).get("Points", []),
                        clip_duration_frames,
                    )
                    scale_points = _merge_uniform_scale(
                        clip.data.get("scale_x", {}).get("Points", []),
                        clip.data.get("scale_y", {}).get("Points", []),
                    )
                    createEffect(
                        xmldoc,
                        "Basic Motion",
                        clipNode,
                        scale_points,
                        100.0,
                        clip_duration_frames,
                        "scale",
                    )
                    createEffect(
                        xmldoc,
                        "Basic Motion",
                        clipNode,
                        clip.data.get("rotation", {}).get("Points", []),
                        1.0,
                        clip_duration_frames,
                        "rotation",
                    )
                logging_nodes = clipNode.getElementsByTagName("good")
                if logging_nodes:
                    _set_text(logging_nodes[0], "FALSE")
                base_id = clip.data.get('id')
                if base_id:
                    clip_link_info.setdefault(base_id, {})
                    clip_link_info[base_id]["video"] = {
                        "node": clipNode,
                        "trackindex": videoTrackNumber or 1,
                        "clipindex": video_clip_index,
                        "id": clip.data.get('id')
                    }
                    video_clip_index += 1
                    link_parts = clip_link_info[base_id]
                    if "audio" in link_parts:
                        audio_info = link_parts["audio"]
                        _append_link(clipNode, "video", videoTrackNumber or 1, link_parts["video"]["clipindex"], 1, clip.data.get('id'))
                        _append_link(clipNode, "audio", audio_info["trackindex"], audio_info["clipindex"], 1, audio_info["id"])
                        _append_link(audio_info["node"], "audio", audio_info["trackindex"], audio_info["clipindex"], 1, audio_info["id"])
                        _append_link(audio_info["node"], "video", videoTrackNumber or 1, link_parts["video"]["clipindex"], 1, clip.data.get('id'))

            if clip_reader.get("has_audio") and audioTrackNode:
                clipTemplateDoc = minidom.parse(os.path.join(info.RESOURCES_PATH, 'export-clip-audio-template.xml'))
                clipAudioNode = clipTemplateDoc.getElementsByTagName('clipitem')[0]
                audioTrackNode.appendChild(clipAudioNode)

                clipAudioNode.setAttribute('id', "%s-audio" % clip.data.get('id'))

                audio_file_node = clipAudioNode.getElementsByTagName("file")[0]
                audio_file_node.setAttribute('id', file_id_value)
                audio_name_nodes = clipAudioNode.getElementsByTagName("name")
                if audio_name_nodes:
                    _set_text(audio_name_nodes[0], clip_title)
                if len(audio_name_nodes) > 1:
                    _set_text(audio_name_nodes[1], clip_title)
                _set_text(clipAudioNode.getElementsByTagName("start")[0], timeline_in_frames)
                _set_text(clipAudioNode.getElementsByTagName("end")[0], timeline_out_frames)
                _set_text(clipAudioNode.getElementsByTagName("in")[0], clip_in_frames)
                _set_text(clipAudioNode.getElementsByTagName("out")[0], clip_out_frames)
                _set_text(clipAudioNode.getElementsByTagName("duration")[0], clip_duration_frames)
                _apply_rate_settings(clipAudioNode.getElementsByTagName("rate"), timebase_value, ntsc_value)

                audio_path_nodes = audio_file_node.getElementsByTagName("pathurl")
                if audio_path_nodes:
                    preferred_audio_path = abs_media_path or relative_media_path or clip_title
                    _set_text(audio_path_nodes[0], _file_url(preferred_audio_path))
                audio_file_names = audio_file_node.getElementsByTagName("name")
                if audio_file_names:
                    _set_text(audio_file_names[0], display_name)
                audio_file_duration_nodes = audio_file_node.getElementsByTagName("duration")
                if audio_file_duration_nodes:
                    _set_text(
                        audio_file_duration_nodes[0],
                        file_duration_frames if file_duration_frames is not None else clip_duration_frames
                    )
                samplerate_nodes = audio_file_node.getElementsByTagName("samplerate")
                if samplerate_nodes:
                    _set_text(samplerate_nodes[0], sample_rate)
                channel_nodes = audio_file_node.getElementsByTagName("channelcount")
                if channel_nodes:
                    _set_text(channel_nodes[0], channel_count)
                _apply_timecode_settings(audio_file_node.getElementsByTagName("timecode"), timebase_value, ntsc_value)

                sourcetrack_nodes = clipAudioNode.getElementsByTagName("sourcetrack")
                if sourcetrack_nodes and audioTrackNumber is not None:
                    track_index_nodes = sourcetrack_nodes[0].getElementsByTagName("trackindex")
                    if track_index_nodes:
                        _set_text(track_index_nodes[0], audioTrackNumber)

                track_index_nodes = clipAudioNode.getElementsByTagName("trackindex")
                if track_index_nodes and audioTrackNumber is not None:
                    _set_text(track_index_nodes[0], audioTrackNumber)

                createEffect(xmldoc, "Audio Levels", clipAudioNode, clip.data.get('volume', {}).get('Points', []), 1.0, clip_duration_frames)
                logging_nodes = clipAudioNode.getElementsByTagName("good")
                if logging_nodes:
                    _set_text(logging_nodes[0], "FALSE")
                base_id = clip.data.get('id')
                if base_id:
                    clip_link_info.setdefault(base_id, {})
                    audio_id = "%s-audio" % base_id
                    clip_link_info[base_id]["audio"] = {
                        "node": clipAudioNode,
                        "trackindex": audioTrackNumber or 1,
                        "clipindex": audio_clip_index,
                        "id": audio_id
                    }
                    audio_clip_index += 1
                    link_parts = clip_link_info[base_id]
                    if "video" in link_parts:
                        video_info = link_parts["video"]
                        _append_link(clipAudioNode, "audio", audioTrackNumber or 1, link_parts["audio"]["clipindex"], 1, audio_id)
                        _append_link(clipAudioNode, "video", video_info["trackindex"], video_info["clipindex"], 1, video_info["id"])
                        _append_link(video_info["node"], "video", video_info["trackindex"], video_info["clipindex"], 1, video_info["id"])
                        _append_link(video_info["node"], "audio", audioTrackNumber or 1, link_parts["audio"]["clipindex"], 1, audio_id)

    try:
        with open(os.fsencode(file_path), "wb") as file:
            file.write(bytes(xmldoc.toxml(), 'UTF-8'))
        _validate_export(file_path)
    except IOError as inst:
        log.error("Error writing XML export: {}".format(str(inst)))
    finally:
        # Free up DOM memory
        xmldoc.unlink()
