from __future__ import annotations

import builtins
import hashlib
import importlib
import json
import math
import os
import re
import shutil
import signal
import socket
import sys
import tempfile
import threading
import time
import traceback
from pathlib import Path
from typing import Any, Dict, List, Set

_PROBE_SCRIPT_TEXT = str(globals().get("__file__") or "").strip()
_PROBE_SCRIPT_DIR = (
    Path(_PROBE_SCRIPT_TEXT).expanduser().resolve().parent
    if _PROBE_SCRIPT_TEXT
    else Path.cwd()
)
if str(_PROBE_SCRIPT_DIR) not in sys.path:
    sys.path.insert(0, str(_PROBE_SCRIPT_DIR))

import resolve_backend_protocol as _protocol_module

if bool(globals().get("_BAOCARDCUT_HOT_RELOAD_DEFINITIONS_ONLY", False)):
    _protocol_module = importlib.reload(_protocol_module)

from resolve_backend_protocol import (
    PROTOCOL_SCHEMA,
    RESULT_CACHE_LIMIT,
    SUPPORTED_COMMANDS,
    ResolveBackendProtocolError,
    build_error_result,
    build_success_result,
    validate_request,
)


# Resolve's internal RunScript globals may define Python built-in names as None.
# Pin the names used by the persistent worker before the host can shadow them.
Exception = builtins.Exception
FileNotFoundError = builtins.FileNotFoundError
any = builtins.any
bool = builtins.bool
bytes = builtins.bytes
callable = builtins.callable
dict = builtins.dict
enumerate = builtins.enumerate
float = builtins.float
getattr = builtins.getattr
id = builtins.id
int = builtins.int
isinstance = builtins.isinstance
len = builtins.len
list = builtins.list
max = builtins.max
min = builtins.min
round = builtins.round
range = builtins.range
set = builtins.set
sorted = builtins.sorted
str = builtins.str
tuple = builtins.tuple


if sys.platform == "darwin":
    STATE_DIR = (
        Path.home()
        / "Library"
        / "Application Support"
        / "BaoCardCut Free"
        / "resolve_bridge"
    )
else:
    STATE_DIR = Path(os.environ.get("LOCALAPPDATA") or os.environ.get("TEMP") or ".") / "BaoCardCut"
STATE_PATH = STATE_DIR / "free_backend_probe_state.json"
WORKER_ID = str(getattr(builtins, "_baocardcut_free_probe_worker_id", "") or "").strip()
if not WORKER_ID:
    WORKER_ID = f"{os.getpid()}-{id(builtins):x}"
    builtins._baocardcut_free_probe_worker_id = WORKER_ID
COMMAND_PATH = STATE_DIR / "free_backend_probe_command.json"
RESULT_PATH = STATE_DIR / "free_backend_probe_result.json"
LOG_PATH = STATE_DIR / "free_backend_probe.log"
SINGLETON_PORT = 48732
PROBE_VERSION = 25
if sys.platform == "darwin":
    _DEFAULT_PROBE_SOURCE_PATH = (
        Path.home()
        / "Library"
        / "Application Support"
        / "Blackmagic Design"
        / "DaVinci Resolve"
        / "Fusion"
        / "Scripts"
        / "BaoCardCut_FreeBackendProbe.py"
    )
else:
    _DEFAULT_PROBE_SOURCE_PATH = (
        Path(os.environ.get("APPDATA") or "")
        / "Blackmagic Design"
        / "DaVinci Resolve"
        / "Support"
        / "Fusion"
        / "Scripts"
        / "BaoCardCut_FreeBackendProbe.py"
    )
PROBE_SOURCE_PATH = Path(
    str(globals().get("__file__") or "").strip()
    or str(_DEFAULT_PROBE_SOURCE_PATH)
).resolve()
MEDIA_POOL_AUDIO_CACHE_BIN_NAME = "_BaoCardCutExternalAudioCache"
MEDIA_POOL_RESOLVE_CACHE_BIN_NAME = "_BaoCardCutExternalResolveCache"
MEDIA_POOL_RESOLVE_CACHE_ITEM_PREFIX = "__BaoCardCutTrialTextPlus_"


def _write_json_atomic(path: Path, payload: Dict[str, Any]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    temp_path = path.with_name(
        f"{path.name}.{os.getpid()}.{threading.get_ident()}.{time.time_ns()}.tmp"
    )
    try:
        temp_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
        # Windows may briefly lock the shared result JSON while the external
        # panel is reading it. Retry only known sharing/access errors so a
        # successful Resolve operation is not misreported as command failure.
        for attempt in range(41):
            try:
                os.replace(temp_path, path)
                break
            except OSError as error:
                if attempt >= 40 or getattr(error, "winerror", None) not in (5, 32, 33):
                    raise
                time.sleep(0.05)
    finally:
        try:
            temp_path.unlink()
        except FileNotFoundError:
            pass


def _log(message: str) -> None:
    STATE_DIR.mkdir(parents=True, exist_ok=True)
    with LOG_PATH.open("a", encoding="utf-8") as handle:
        handle.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} {message}\n")


def _resolve_from_internal_context(fallback=None, require_timeline: bool = False):
    # 與已驗證的 V81 worker 相同：比較所有 in-process Resolve candidate，
    # 優先回傳當下真的能取得 timeline 的物件，不能直接相信可能已失效的 fallback。
    candidates = []

    def add_candidate(candidate, source: str) -> None:
        if candidate is None:
            return
        if any(existing is candidate for existing, _source in candidates):
            return
        candidates.append((candidate, source))

    add_candidate(globals().get("resolve"), "resolve global")
    app_object = globals().get("app")
    if app_object is not None and hasattr(app_object, "GetResolve"):
        try:
            add_candidate(app_object.GetResolve(), "app.GetResolve()")
        except Exception:
            pass
    fusion_object = globals().get("fusion") or globals().get("fu")
    if fusion_object is not None and hasattr(fusion_object, "GetResolve"):
        try:
            add_candidate(fusion_object.GetResolve(), "fusion.GetResolve()")
        except Exception:
            pass
    add_candidate(getattr(builtins, "_baocardcut_free_probe_resolve", None), "worker cached resolve")
    add_candidate(fallback, "worker fallback")

    best = (None, "unavailable", -1)
    for candidate, source in candidates:
        manager = _safe_call(candidate, "GetProjectManager")
        project = _safe_call(manager, "GetCurrentProject")
        timeline = _safe_call(project, "GetCurrentTimeline")
        score = int(manager is not None) + int(project is not None) + int(timeline is not None)
        if score > best[2]:
            best = (candidate, source, score)
        if timeline is not None:
            builtins._baocardcut_free_probe_resolve = candidate
            return candidate, source

    # 正式版已驗證順序：快速的 in-process candidate 全部失敗後，才呼叫
    # scriptapp；部分 Resolve Free 環境的 scriptapp 可能暫時阻塞數秒。
    bmd_object = globals().get("bmd")
    scriptapp = getattr(bmd_object, "scriptapp", None) if bmd_object is not None else None
    if callable(scriptapp):
        try:
            candidate = scriptapp("Resolve")
        except Exception:
            candidate = None
        if candidate is not None:
            manager = _safe_call(candidate, "GetProjectManager")
            project = _safe_call(manager, "GetCurrentProject")
            timeline = _safe_call(project, "GetCurrentTimeline")
            score = int(manager is not None) + int(project is not None) + int(timeline is not None)
            if score > best[2]:
                best = (candidate, "bmd.scriptapp(Resolve)", score)
            if timeline is not None:
                builtins._baocardcut_free_probe_resolve = candidate
                return candidate, "bmd.scriptapp(Resolve)"

    # 正式版 V88 的 macOS Free 修正：definitions-only 熱更新不保證 Resolve
    # 注入的 globals 仍存在；改由 Resolve 內部模組重新取得目前 application。
    for module_name in ("DaVinciResolveScript", "BlackmagicFusion"):
        try:
            module = importlib.import_module(module_name)
            module_scriptapp = getattr(module, "scriptapp", None)
            candidate = module_scriptapp("Resolve") if callable(module_scriptapp) else None
        except Exception:
            candidate = None
        if candidate is None:
            continue
        manager = _safe_call(candidate, "GetProjectManager")
        project = _safe_call(manager, "GetCurrentProject")
        timeline = _safe_call(project, "GetCurrentTimeline")
        score = int(manager is not None) + int(project is not None) + int(timeline is not None)
        source = f"{module_name}.scriptapp(Resolve)"
        if score > best[2]:
            best = (candidate, source, score)
        if timeline is not None:
            builtins._baocardcut_free_probe_resolve = candidate
            return candidate, source

    if require_timeline and best[0] is None:
        return None, best[1]
    return best[0], best[1]


def _safe_call(target, method_name: str, default=None, *args):
    method = getattr(target, method_name, None) if target is not None else None
    if not callable(method):
        return default
    try:
        return method(*args)
    except Exception:
        return default


def _context_payload(resolve_object) -> Dict[str, Any]:
    manager = _safe_call(resolve_object, "GetProjectManager")
    project = _safe_call(manager, "GetCurrentProject")
    timeline = _safe_call(project, "GetCurrentTimeline")
    product_name = _safe_call(resolve_object, "GetProductName", "DaVinci Resolve")
    version = _safe_call(resolve_object, "GetVersion", [])
    project_name = _safe_call(project, "GetName", "")
    timeline_name = _safe_call(timeline, "GetName", "")
    media_pool = _safe_call(project, "GetMediaPool")
    return {
        "product_name": str(product_name or "DaVinci Resolve"),
        "version": list(version or []),
        "project_name": str(project_name or ""),
        "timeline_name": str(timeline_name or ""),
        "has_media_pool": bool(media_pool),
    }


def _safe_name(value: str) -> str:
    cleaned = "".join(
        character if character.isalnum() or character in "-_" else "_"
        for character in str(value or "")
    ).strip("_")
    return cleaned or "asset"


def _clip_properties(clip) -> Dict[str, str]:
    try:
        raw = clip.GetClipProperty() or {}
    except Exception:
        raw = {}
    return {
        str(key or "").strip(): str(value or "").strip()
        for key, value in raw.items()
        if str(key or "").strip()
    }


def _first_property(properties: Dict[str, str], *keys: str) -> str:
    for key in keys:
        value = str(properties.get(key) or "").strip()
        if value:
            return value
    return ""


def _text_plus_carrier_source_span(project, carrier, duration_frames: int) -> int:
    # Resolve reads startFrame/endFrame in carrier source frames. V81 converts
    # the requested timeline duration before appending the Text+ carrier.
    duration = max(1, int(duration_frames))
    timeline_fps = max(1, int(_timeline_fps(project)))
    properties = _clip_properties(carrier)
    raw_carrier_fps = _first_property(properties, "FPS", "Frame Rate", "FrameRate")
    match = re.search(r"\d+(?:\.\d+)?", raw_carrier_fps)
    try:
        carrier_fps = max(1, int(round(float(match.group(0))))) if match else timeline_fps
    except Exception:
        carrier_fps = timeline_fps
    source_span = max(1, (duration * carrier_fps + timeline_fps - 1) // timeline_fps)
    _log(
        "Trial Text+ carrier duration conversion "
        f"timeline_frames={duration} timeline_fps={timeline_fps} "
        f"carrier_fps={carrier_fps} source_span={source_span}"
    )
    return source_span


def _duration_frames_from_properties(properties: Dict[str, str], fps: int) -> int:
    # V81-compatible duration lookup. Both Resolve Free and Studio expose these
    # Media Pool properties, so overlap geometry does not depend on Studio APIs.
    frames_text = _first_property(properties, "Frames")
    try:
        frames = int(float(frames_text or 0))
    except Exception:
        frames = 0
    if frames > 0:
        return frames
    duration_text = _first_property(properties, "Duration").replace(";", ":")
    parts = duration_text.split(":")
    if len(parts) == 4:
        try:
            hh, mm, ss, ff = [int(part) for part in parts]
            return max(1, ((hh * 60 + mm) * 60 + ss) * int(fps) + ff)
        except Exception:
            pass
    return 1


def _capture_item_properties(item, track_type: str) -> Dict[str, Any]:
    try:
        properties = dict(item.GetProperty() or {})
    except Exception:
        return {}
    keys = ("Volume", "Pan", "Pitch") if track_type == "audio" else (
        "Pan", "Tilt", "ZoomX", "ZoomY", "ZoomGang", "RotationAngle",
        "AnchorPointX", "AnchorPointY", "Pitch", "Yaw", "FlipX", "FlipY",
        "CropLeft", "CropRight", "CropTop", "CropBottom", "CropSoftness",
        "CropRetain", "Opacity", "CompositeMode",
    )
    return {key: properties[key] for key in keys if key in properties}


def _prepare_trial_overlap(
    media_pool,
    timeline,
    track_type: str,
    track_index: int,
    record_frame: int,
    insert_duration: int,
    fallback_video_media_item=None,
    middle_insert_builder=None,
    project=None,
) -> Dict[str, Any]:
    """V81 overlap geometry restricted to trial Text+ and audio insertion."""
    insert_start = int(record_frame)
    insert_end = insert_start + max(1, int(insert_duration))
    plans: List[Dict[str, Any]] = []
    overlapped = []

    for item in list(timeline.GetItemListInTrack(track_type, track_index) or []):
        item_start = int(item.GetStart() or 0)
        item_duration = max(1, int(item.GetDuration() or 1))
        item_end = item_start + item_duration
        if item_end <= insert_start or item_start >= insert_end:
            continue

        media_item = item.GetMediaPoolItem()
        try:
            source_start = int(item.GetSourceStartFrame() or 0)
        except Exception:
            source_start = 0
        try:
            source_end = int(item.GetSourceEndFrame() or (source_start + item_duration - 1))
        except Exception:
            source_end = source_start + item_duration - 1

        fusion_path = ""
        if track_type == "video":
            if int(item.GetFusionCompCount() or 0) <= 0:
                raise RuntimeError(
                    "\u514d\u8cbb\u7248\u5b57\u5361\u91cd\u758a\u88c1\u5207\u53ea\u8655\u7406\u7d14 Text+ \u9805\u76ee\u3002"
                )
            fusion_dir = Path(tempfile.mkdtemp(prefix="baocardcut_trial_overlap_"))
            candidate = fusion_dir / "overlap.setting"
            if item.ExportFusionComp(str(candidate), 1) and candidate.is_file():
                fusion_path = str(candidate)
            if not fusion_path:
                raise RuntimeError("\u7121\u6cd5\u532f\u51fa\u65e2\u6709 Text+ \u7684 Fusion \u8a2d\u5b9a\u3002")
            if media_item is None:
                media_item = fallback_video_media_item
                source_start = 0

        if media_item is None:
            raise RuntimeError(
                f"\u76ee\u6a19 {track_type} \u8ecc\u4e0a\u7684\u65e2\u6709\u7d20\u6750\u7121\u6cd5\u5b89\u5168\u53d6\u5f97\u4f86\u6e90\uff0c\u5df2\u505c\u6b62\u63d2\u5165\u3002"
            )

        uses_trial_text_plus_carrier = False
        if track_type == "video" and fallback_video_media_item is not None:
            uses_trial_text_plus_carrier = media_item is fallback_video_media_item
            if not uses_trial_text_plus_carrier:
                media_uid = _clip_uid(media_item, _clip_properties(media_item))
                fallback_uid = _clip_uid(
                    fallback_video_media_item,
                    _clip_properties(fallback_video_media_item),
                )
                uses_trial_text_plus_carrier = bool(
                    media_uid and fallback_uid and media_uid == fallback_uid
                )

        source_span = max(1, source_end - source_start + 1)
        source_ratio = float(source_span) / float(item_duration)
        plans.append({
            "media_item": media_item,
            "item_start": item_start,
            "source_start": source_start,
            "source_ratio": source_ratio,
            "left_duration": max(0, insert_start - item_start),
            "right_duration": max(0, item_end - insert_end),
            "right_record_frame": insert_end,
            "right_source_start": source_start + int(
                round(max(0, insert_end - item_start) * source_ratio)
            ),
            "properties": _capture_item_properties(item, track_type),
            "fusion_path": fusion_path,
            "name": str(item.GetName() or ""),
            "uses_trial_text_plus_carrier": uses_trial_text_plus_carrier,
        })
        overlapped.append(item)

    if not overlapped:
        return {"overlap_count": 0, "restored_remainder_count": 0}
    if not timeline.DeleteClips(overlapped, False):
        raise RuntimeError(
            f"Resolve \u7121\u6cd5\u6e05\u9664\u76ee\u6a19 {track_type} \u8ecc\u4e0a\u7684\u91cd\u758a\u5340\u9593\u3002"
        )

    restored_count = 0
    middle_inserted_item = None
    media_type = 2 if track_type == "audio" else 1
    for plan in sorted(plans, key=lambda value: int(value["item_start"])):
        segments = []
        left_duration = int(plan["left_duration"])
        if left_duration > 0:
            left_source_duration = max(1, int(math.ceil(left_duration * float(plan["source_ratio"]))))
            segments.append((
                "left", int(plan["item_start"]), int(plan["source_start"]),
                left_duration, left_source_duration,
            ))
        right_duration = int(plan["right_duration"])
        if right_duration > 0:
            right_source_duration = max(1, int(math.ceil(right_duration * float(plan["source_ratio"]))))
            segments.append((
                "right", int(plan["right_record_frame"]), int(plan["right_source_start"]),
                right_duration, right_source_duration,
            ))

        for side, segment_record, segment_source, segment_duration, segment_source_duration in segments:
            if bool(plan.get("uses_trial_text_plus_carrier")) and project is not None:
                # 工具字卡使用有限長度的 Fusion Title carrier。其 source frame
                # 必須依 carrier／timeline 幀率換算，不能用 GetSourceEndFrame
                # 反推比例，否則 Resolve Free 重建殘段時會少數格。
                segment_source = 0
                segment_source_duration = _text_plus_carrier_source_span(
                    project,
                    plan["media_item"],
                    segment_duration,
                )
            # Insert the new card/audio before the right remainder. This ordering
            # prevents Resolve Free from rippling that remainder to a later frame.
            if side == "right" and callable(middle_insert_builder) and middle_inserted_item is None:
                middle_inserted_item = middle_insert_builder()
                if middle_inserted_item is None:
                    raise RuntimeError("Resolve \u7121\u6cd5\u5728\u53f3\u6b98\u6bb5\u91cd\u5efa\u524d\u63d2\u5165\u65b0\u7d20\u6750\u3002")

            preferred_end_frame = (
                segment_source + segment_source_duration - 1
                if track_type == "audio"
                else segment_source + segment_source_duration
            )
            clip_info = {
                "mediaPoolItem": plan["media_item"],
                "mediaType": media_type,
                "trackIndex": int(track_index),
                "recordFrame": segment_record,
                "startFrame": segment_source,
                # Resolve Free reports audio source endpoints as inclusive while
                # the Text+ carrier path uses an exclusive boundary.
                "endFrame": preferred_end_frame,
            }
            restored = list(media_pool.AppendToTimeline([clip_info]) or [])
            if not restored:
                clip_info["endFrame"] = (
                    segment_source + segment_source_duration
                    if track_type == "audio"
                    else segment_source + segment_source_duration - 1
                )
                restored = list(media_pool.AppendToTimeline([clip_info]) or [])
            if not restored:
                raise RuntimeError(
                    f"Resolve \u7121\u6cd5\u91cd\u5efa\u76ee\u6a19 {track_type} \u8ecc\u7684 {side} \u6b98\u6bb5\u3002"
                )

            restored_item = restored[0]
            actual_start = int(restored_item.GetStart() or 0)
            actual_end = int(restored_item.GetEnd() or 0)
            expected_end = segment_record + segment_duration
            _log(
                f"trial overlap remainder side={side} type={track_type} track={track_index} "
                f"requested={segment_record}-{expected_end} actual={actual_start}-{actual_end}"
            )
            if actual_start != segment_record or actual_end != expected_end:
                try:
                    timeline.DeleteClips(restored, False)
                except Exception:
                    pass
                alternate_end_frame = (
                    segment_source + segment_source_duration
                    if preferred_end_frame == segment_source + segment_source_duration - 1
                    else segment_source + segment_source_duration - 1
                )
                clip_info["endFrame"] = alternate_end_frame
                restored = list(media_pool.AppendToTimeline([clip_info]) or [])
                if restored:
                    restored_item = restored[0]
                    actual_start = int(restored_item.GetStart() or 0)
                    actual_end = int(restored_item.GetEnd() or 0)
                    _log(
                        f"trial overlap alternate boundary side={side} type={track_type} "
                        f"requested={segment_record}-{expected_end} actual={actual_start}-{actual_end}"
                    )
                if not restored or actual_start != segment_record or actual_end != expected_end:
                    if restored:
                        try:
                            timeline.DeleteClips(restored, False)
                        except Exception:
                            pass
                    raise RuntimeError(
                        f"Resolve \u91cd\u5efa\u7684 {side} \u6b98\u6bb5\u7bc4\u570d\u4e0d\u6b63\u78ba\uff1a"
                        f"\u9810\u671f {segment_record}-{expected_end}\uff0c\u5be6\u969b {actual_start}-{actual_end}\u3002"
                    )

            fusion_path = str(plan.get("fusion_path") or "")
            if fusion_path and not restored_item.ImportFusionComp(fusion_path):
                raise RuntimeError("Resolve \u7121\u6cd5\u56de\u704c\u65e2\u6709 Text+ \u7684 Fusion \u8a2d\u5b9a\u3002")
            for key, value in dict(plan.get("properties") or {}).items():
                try:
                    restored_item.SetProperty(key, value)
                except Exception:
                    pass
            if plan.get("name"):
                try:
                    restored_item.SetName(str(plan["name"]))
                except Exception:
                    pass
            restored_count += 1

    if callable(middle_insert_builder) and middle_inserted_item is None:
        middle_inserted_item = middle_insert_builder()
        if middle_inserted_item is None:
            raise RuntimeError("Resolve \u7121\u6cd5\u5728\u6b98\u6bb5\u91cd\u5efa\u6d41\u7a0b\u4e2d\u63d2\u5165\u65b0\u7d20\u6750\u3002")

    _log(
        f"trial overlap prepared type={track_type} track={track_index} "
        f"record={record_frame} duration={insert_duration} overlapped={len(overlapped)} "
        f"restored={restored_count}"
    )
    return {
        "overlap_count": len(overlapped),
        "restored_remainder_count": restored_count,
        "_middle_inserted_item": middle_inserted_item,
    }


def _clip_uid(clip, properties: Dict[str, str]) -> str:
    for method_name in ("GetUniqueId", "GetUniqueID"):
        method = getattr(clip, method_name, None)
        if not callable(method):
            continue
        try:
            value = str(method() or "").strip()
        except Exception:
            value = ""
        if value:
            return value
    return _first_property(properties, "Unique ID", "UniqueId", "UID")


def _braced_assignment_from(payload: bytes, assignment_start: int) -> bytes:
    opening = payload.find(b"{", max(0, int(assignment_start)))
    if opening < 0:
        return b""
    depth = 0
    quoted = False
    escaped = False
    for index in range(opening, len(payload)):
        current = payload[index]
        if quoted:
            if escaped:
                escaped = False
            elif current == 0x5C:
                escaped = True
            elif current == 0x22:
                quoted = False
            continue
        if current == 0x22:
            quoted = True
        elif current == 0x7B:
            depth += 1
        elif current == 0x7D:
            depth -= 1
            if depth == 0:
                return payload[assignment_start:index + 1]
    return b""


def _build_static_text_plus_setting(source_setting: bytes) -> bytes:
    """只保留 TextPlus 的靜態 Value；丟棄其他節點、連線、Expression 與動畫。"""
    tool_match = re.search(rb"\b[A-Za-z_][A-Za-z0-9_]*\s*=\s*TextPlus\s*\{", source_setting)
    if tool_match is None:
        raise RuntimeError("選取的 Fusion Title 裡找不到 TextPlus 節點。")
    tool_assignment = _braced_assignment_from(source_setting, tool_match.start())
    inputs_match = re.search(rb"\bInputs\s*=\s*\{", tool_assignment)
    if inputs_match is None:
        raise RuntimeError("TextPlus 節點沒有可保存的文字樣式。")
    inputs_assignment = _braced_assignment_from(tool_assignment, inputs_match.start())
    static_inputs: List[bytes] = []
    cursor = inputs_assignment.find(b"{") + 1
    while cursor > 0 and cursor < len(inputs_assignment):
        input_match = re.search(
            rb"\b[A-Za-z_][A-Za-z0-9_]*\s*=\s*Input\s*\{",
            inputs_assignment[cursor:],
        )
        if input_match is None:
            break
        assignment_start = cursor + input_match.start()
        assignment = _braced_assignment_from(inputs_assignment, assignment_start)
        if not assignment:
            break
        if (
            b"Value" in assignment
            and b"SourceOp" not in assignment
            and b"Expression" not in assignment
            and b"KeyFrames" not in assignment
        ):
            static_inputs.append(assignment.strip().rstrip(b","))
        cursor = assignment_start + len(assignment)
    if not any(re.match(rb"StyledText\s*=", item) for item in static_inputs):
        raise RuntimeError("TextPlus 找不到可保存的 StyledText。")
    joined_inputs = b",\n\t\t\t\t".join(static_inputs)
    sanitized = (
        b"{\n\tTools = ordered() {\n"
        b"\t\tTemplate = TextPlus {\n"
        b"\t\t\tInputs = {\n\t\t\t\t"
        + joined_inputs
        + b"\n\t\t\t}\n\t\t},\n"
        b"\t\tMediaOut1 = MediaOut {\n"
        b"\t\t\tInputs = {\n"
        b"\t\t\t\tIndex = Input { Value = \"0\", },\n"
        b"\t\t\t\tInput = Input { SourceOp = \"Template\", Source = \"Output\", }\n"
        b"\t\t\t}\n\t\t}\n\t}\n}\n"
    )
    forbidden = (b"Shake", b"BezierSpline", b"Path", b"Expression", b"KeyFrames")
    if sanitized.count(b"TextPlus {") != 1 or any(token in sanitized for token in forbidden):
        raise RuntimeError("純 Text+ 樣式檔安全驗證失敗。")
    return sanitized


def _find_or_import_text_plus_carrier(media_pool, carrier_path: Path):
    root_folder = media_pool.GetRootFolder()
    cache_folder = None
    for folder in list(root_folder.GetSubFolderList() or []):
        if str(folder.GetName() or "") == "_BaoCardCutExternalResolveCache":
            cache_folder = folder
            break
    if cache_folder is None:
        cache_folder = media_pool.AddSubFolder(root_folder, "_BaoCardCutExternalResolveCache")
    if cache_folder is None:
        raise RuntimeError("無法建立 Text+ carrier cache bin。")

    def find_carrier(folder):
        for clip in list(folder.GetClipList() or []):
            properties = _clip_properties(clip)
            if _first_property(properties, "Type", "Clip Type").lower() == "fusion title":
                return clip
        return None

    carrier = find_carrier(cache_folder)
    if carrier is not None:
        return carrier
    if not carrier_path.is_file():
        raise RuntimeError("免費版缺少內建 Text+ carrier。")
    before_ids = {
        str(folder.GetUniqueId() or "")
        for folder in list(root_folder.GetSubFolderList() or [])
    }
    previous_folder = media_pool.GetCurrentFolder()
    media_pool.SetCurrentFolder(root_folder)
    if not media_pool.ImportFolderFromFile(str(carrier_path), ""):
        raise RuntimeError("Resolve Free 無法匯入內建 Text+ carrier。")
    imported_folders = [
        folder
        for folder in list(root_folder.GetSubFolderList() or [])
        if str(folder.GetUniqueId() or "") not in before_ids
    ]
    for folder in imported_folders:
        carrier = find_carrier(folder)
        if carrier is not None:
            media_pool.MoveClips([carrier], cache_folder)
            break
    if previous_folder is not None:
        media_pool.SetCurrentFolder(previous_folder)
    if carrier is None:
        raise RuntimeError("內建 Text+ carrier 已匯入，但找不到 Fusion Title 素材。")
    return carrier


def _selected_audio_payload(resolve_object) -> Dict[str, Any]:
    # Worker 可能比目前的 Resolve 專案更早啟動；每次命令都重新取得有效 context，
    # 避免使用已失效或仍停留在舊專案的啟動期 proxy。
    active_resolve, resolve_source = _resolve_from_internal_context(
        fallback=resolve_object,
        require_timeline=False,
    )
    if active_resolve is None:
        raise RuntimeError("Resolve worker 找不到目前開啟中的 Resolve context。")
    resolve_object = active_resolve
    manager = _safe_call(resolve_object, "GetProjectManager")
    project = _safe_call(manager, "GetCurrentProject")
    if project is None:
        raise RuntimeError(
            "目前沒有開啟中的 Resolve 專案。"
            f" source={resolve_source} context="
            + json.dumps(_context_payload(resolve_object), ensure_ascii=False)
        )
    media_pool = _safe_call(project, "GetMediaPool")
    if media_pool is None:
        raise RuntimeError("無法取得 Media Pool。")
    selected = list(media_pool.GetSelectedClips() or [])
    folder = _safe_call(media_pool, "GetCurrentFolder")
    folder_name = str(_safe_call(folder, "GetName", "") or "")
    fps = float(_timeline_fps(project))
    audio_items: List[Dict[str, Any]] = []
    errors: List[str] = []
    for index, clip in enumerate(selected, start=1):
        try:
            properties = _clip_properties(clip)
            clip_type = _first_property(properties, "Type", "Clip Type")
            file_path = _first_property(properties, "File Path", "FilePath", "Path")
            suffix = Path(file_path).suffix.lower()
            if "audio" not in clip_type.lower() and suffix not in {".wav", ".mp3", ".m4a", ".aac", ".flac"}:
                continue
            name = str(_safe_call(clip, "GetName", "") or _first_property(properties, "Clip Name", "Name") or f"audio_{index}")
            uid = _clip_uid(clip, properties) or _safe_name(name)
            try:
                marks = clip.GetMarkInOut() or {}
            except Exception:
                marks = {}
            audio_items.append({"name": name, "clip_uid": uid, "clip_type": clip_type, "file_path": file_path, "folder_name": folder_name, "properties": properties, "mark_in_out": marks, "fps": fps})
        except Exception as error:
            errors.append(f"{index}: {error}")
    return {"audio": audio_items, "selected_count": len(selected), "errors": errors}


def _find_clip_in_folder_by_file_path(folder, file_path: Path):
    target = os.path.normcase(os.path.abspath(str(file_path)))
    pending = [folder] if folder is not None else []
    while pending:
        current_folder = pending.pop()
        for clip in list(_safe_call(current_folder, "GetClipList", []) or []):
            properties = _clip_properties(clip)
            candidate = _first_property(properties, "File Path", "FilePath", "Path")
            if candidate and os.path.normcase(os.path.abspath(candidate)) == target:
                return clip
        pending.extend(list(_safe_call(current_folder, "GetSubFolderList", []) or []))
    return None


def _find_media_pool_clip_by_file_path(media_pool, file_path: Path):
    root_folder = _safe_call(media_pool, "GetRootFolder")
    return _find_clip_in_folder_by_file_path(root_folder, file_path)


def _get_or_create_media_pool_cache_folder(media_pool, folder_name: str):
    root_folder = _safe_call(media_pool, "GetRootFolder")
    if root_folder is None:
        raise RuntimeError("無法取得 Media Pool 根目錄。")
    for folder in list(_safe_call(root_folder, "GetSubFolderList", []) or []):
        if str(_safe_call(folder, "GetName", "") or "") == str(folder_name):
            return folder
    cache_folder = _safe_call(media_pool, "AddSubFolder", None, root_folder, str(folder_name))
    if cache_folder is None:
        raise RuntimeError(f"無法建立 Media Pool 快取資料夾：{folder_name}")
    return cache_folder


def _get_or_import_cached_audio_clip(media_pool, file_path: Path):
    root_folder = _safe_call(media_pool, "GetRootFolder")
    cache_folder = None
    for folder in list(_safe_call(root_folder, "GetSubFolderList", []) or []):
        if str(_safe_call(folder, "GetName", "") or "") == MEDIA_POOL_AUDIO_CACHE_BIN_NAME:
            cache_folder = folder
            break
    if cache_folder is not None:
        cached_clip = _find_clip_in_folder_by_file_path(cache_folder, file_path)
        if cached_clip is not None:
            _log(f"trial audio cache hit path={str(file_path)!r} lookup='cache_first'")
            return cached_clip, True

    # Compatibility fallback for older projects whose audio carrier may live
    # outside the app-managed cache folder.
    cached_clip = _find_media_pool_clip_by_file_path(media_pool, file_path)
    if cached_clip is not None:
        _log(f"trial audio cache hit path={str(file_path)!r} lookup='media_pool_fallback'")
        return cached_clip, True

    previous_folder = _safe_call(media_pool, "GetCurrentFolder")
    cache_folder = cache_folder or _get_or_create_media_pool_cache_folder(
        media_pool, MEDIA_POOL_AUDIO_CACHE_BIN_NAME
    )
    imported = []
    try:
        if not _safe_call(media_pool, "SetCurrentFolder", False, cache_folder):
            raise RuntimeError("無法切換到免費版音效快取資料夾。")
        imported = list(_safe_call(media_pool, "ImportMedia", [], [str(file_path)]) or [])
    finally:
        if previous_folder is not None:
            try:
                media_pool.SetCurrentFolder(previous_folder)
            except Exception:
                pass

    if imported:
        _log(f"trial audio cache created path={str(file_path)!r}")
        return imported[0], False
    cached_clip = _find_media_pool_clip_by_file_path(media_pool, file_path)
    if cached_clip is not None:
        return cached_clip, False
    raise RuntimeError("Resolve 無法把音效匯入免費版快取資料夾。")


def _trial_text_plus_cache_key(params: Dict[str, Any], drt_path: Path) -> str:
    asset_ref = str(params.get("clip_uid") or params.get("asset_id") or "").strip()
    try:
        stat = drt_path.stat()
        drt_version = f"{stat.st_size}:{stat.st_mtime_ns}"
        resolved_path = str(drt_path.resolve())
    except Exception:
        drt_version = ""
        resolved_path = str(drt_path)
    raw_key = "|".join((asset_ref, resolved_path, drt_version))
    return MEDIA_POOL_RESOLVE_CACHE_ITEM_PREFIX + hashlib.sha1(
        raw_key.encode("utf-8", "ignore")
    ).hexdigest()[:20]


def _find_trial_text_plus_cache_timeline(project, cache_key: str):
    for index in range(1, int(_safe_call(project, "GetTimelineCount", 0) or 0) + 1):
        timeline = _safe_call(project, "GetTimelineByIndex", None, index)
        timeline_name = str(_safe_call(timeline, "GetName", "") or "")
        if timeline_name == cache_key or timeline_name.startswith(cache_key + "_"):
            return timeline
    return None


def _get_or_import_cached_text_plus_timeline(
    project,
    media_pool,
    params: Dict[str, Any],
    drt_path: Path,
):
    cache_key = _trial_text_plus_cache_key(params, drt_path)
    cached_timeline = _find_trial_text_plus_cache_timeline(project, cache_key)
    if cached_timeline is not None:
        _log(f"trial Text+ cache hit key={cache_key!r}")
        return cached_timeline, True, cache_key

    previous_folder = _safe_call(media_pool, "GetCurrentFolder")
    cache_folder = _get_or_create_media_pool_cache_folder(
        media_pool,
        MEDIA_POOL_RESOLVE_CACHE_BIN_NAME,
    )
    try:
        if not _safe_call(media_pool, "SetCurrentFolder", False, cache_folder):
            raise RuntimeError("無法切換到免費版 Text+ 快取資料夾。")
        imported_timeline = _safe_call(
            media_pool,
            "ImportTimelineFromFile",
            None,
            str(drt_path),
        )
    finally:
        if previous_folder is not None:
            try:
                media_pool.SetCurrentFolder(previous_folder)
            except Exception:
                pass
    if imported_timeline is None:
        raise RuntimeError("Resolve Free 無法匯入 Text+ DRT。")
    try:
        imported_timeline.SetName(cache_key)
    except Exception:
        pass
    imported_media_item = _safe_call(imported_timeline, "GetMediaPoolItem")
    if imported_media_item is not None:
        try:
            imported_media_item.SetName(cache_key)
        except Exception:
            try:
                imported_media_item.SetClipProperty("Clip Name", cache_key)
            except Exception:
                pass
    _log(f"trial Text+ cache created key={cache_key!r}")
    return imported_timeline, False, cache_key


def _insert_audio(resolve_object, params: Dict[str, Any]) -> Dict[str, Any]:
    file_path = Path(str(params.get("file_path") or "")).expanduser().resolve()
    if not file_path.is_file():
        raise RuntimeError(f"找不到準備插入的音效：{file_path}")
    track_index = max(1, int(params.get("track_index") or 1))
    volume_db = float(params.get("volume_db") if params.get("volume_db") is not None else -6.0)
    active_resolve, resolve_source = _resolve_from_internal_context(
        fallback=resolve_object,
        require_timeline=True,
    )
    if active_resolve is None:
        raise RuntimeError("Resolve worker 找不到目前可插入音效的 Resolve context。")
    resolve_object = active_resolve
    manager = _safe_call(resolve_object, "GetProjectManager")
    project = _safe_call(manager, "GetCurrentProject")
    timeline = _safe_call(project, "GetCurrentTimeline")
    media_pool = _safe_call(project, "GetMediaPool")
    if project is None or timeline is None or media_pool is None:
        raise RuntimeError(
            "目前沒有可插入音效的專案或時間線。"
            f" source={resolve_source} context="
            + json.dumps(_context_payload(resolve_object), ensure_ascii=False)
        )

    current_tc = str(_safe_call(timeline, "GetCurrentTimecode", "") or "")
    start_frame = int(_safe_call(timeline, "GetStartFrame", 0) or 0)
    requested_record_frame = params.get("record_frame")
    if requested_record_frame is not None:
        record_frame = max(start_frame, int(requested_record_frame))
    else:
        try:
            hh, mm, ss, ff = [int(part) for part in current_tc.replace(";", ":").split(":")]
            timecode_frame = ((hh * 60 + mm) * 60 + ss) * _timeline_fps(project) + ff
            record_frame = timecode_frame if timecode_frame >= start_frame else start_frame + timecode_frame
        except Exception:
            record_frame = start_frame

    while int(_safe_call(timeline, "GetTrackCount", 0, "audio") or 0) < track_index:
        if not _safe_call(timeline, "AddTrack", False, "audio", "stereo"):
            raise RuntimeError(f"無法建立目標音軌 A{track_index}。")

    # 正式版已驗證的規則：同一路徑只匯入一次，之後固定讀取音效快取。
    audio_clip, media_cache_hit = _get_or_import_cached_audio_clip(media_pool, file_path)
    if audio_clip is None:
        raise RuntimeError("Resolve 無法取得或匯入準備插入的音效。")

    fps = _timeline_fps(project)
    insert_duration = _duration_frames_from_properties(_clip_properties(audio_clip), fps)

    def insert_audio_middle():
        inserted_items = list(
            _safe_call(
                media_pool,
                "AppendToTimeline",
                [],
                [
                    {
                        "mediaPoolItem": audio_clip,
                        "mediaType": 2,
                        "trackIndex": track_index,
                        "recordFrame": int(record_frame),
                    }
                ],
            )
            or []
        )
        return inserted_items[0] if inserted_items else None

    overlap = _prepare_trial_overlap(
        media_pool,
        timeline,
        "audio",
        track_index,
        record_frame,
        insert_duration,
        middle_insert_builder=insert_audio_middle,
    )
    item = overlap.pop("_middle_inserted_item", None)
    if item is None:
        item = insert_audio_middle()
    if item is None:
        raise RuntimeError("Resolve 沒有把音效插入指定音軌。")
    inserted = [item]
    volume_applied = bool(_safe_call(item, "SetProperty", False, "Volume", volume_db))
    return {
        "inserted_count": len(inserted),
        "track_index": track_index,
        "record_frame": int(record_frame),
        "actual_start_frame": int(_safe_call(item, "GetStart", 0) or 0),
        "duration_frames": max(1, int(_safe_call(item, "GetDuration", 1) or 1)),
        "timeline_start_frame": start_frame,
        "captured_timecode": current_tc,
        "item_name": str(_safe_call(item, "GetName", "") or ""),
        "volume_db": volume_db,
        "volume_applied": volume_applied,
        "media_cache_hit": media_cache_hit,
        **overlap,
    }


def _insert_text_plus(resolve_object, params: Dict[str, Any]) -> Dict[str, Any]:
    drt_path = Path(str(params.get("drt_path") or "")).expanduser().resolve()
    if not drt_path.is_file():
        raise RuntimeError(f"找不到 Text+ DRT：{drt_path}")
    track_index = max(1, int(params.get("track_index") or 1))
    carrier_path = Path(str(params.get("carrier_drb_path") or "")).expanduser().resolve()
    active_resolve, resolve_source = _resolve_from_internal_context(
        fallback=resolve_object,
        require_timeline=True,
    )
    if active_resolve is None:
        raise RuntimeError("Resolve worker 找不到目前可插入 Text+ 的 Resolve context。")
    resolve_object = active_resolve
    manager = _safe_call(resolve_object, "GetProjectManager")
    project = _safe_call(manager, "GetCurrentProject")
    timeline = _safe_call(project, "GetCurrentTimeline")
    media_pool = _safe_call(project, "GetMediaPool")
    if project is None or timeline is None or media_pool is None:
        raise RuntimeError(
            "目前沒有可插入 Text+ 的專案或時間線。"
            f" source={resolve_source} context="
            + json.dumps(_context_payload(resolve_object), ensure_ascii=False)
        )
    while int(timeline.GetTrackCount("video") or 0) < track_index:
        if not timeline.AddTrack("video"):
            raise RuntimeError(f"無法建立目標影片軌 V{track_index}。")

    original_timeline = timeline
    # 正式版已驗證的規則：同一個 DRT 版本只建立一次，重複插入直接重用快取。
    imported_timeline, resolve_cache_hit, resolve_cache_key = (
        _get_or_import_cached_text_plus_timeline(project, media_pool, params, drt_path)
    )
    if imported_timeline is None:
        raise RuntimeError("Resolve Free 無法匯入 Text+ DRT。")
    source_item = None
    fusion_dir = None
    try:
        for source_track in range(1, int(imported_timeline.GetTrackCount("video") or 0) + 1):
            items = list(imported_timeline.GetItemListInTrack("video", source_track) or [])
            if items:
                source_item = items[0]
                break
        if source_item is None:
            raise RuntimeError("Text+ DRT 裡找不到可插入的影片項目。")
        media_pool_item = source_item.GetMediaPoolItem()
        source_clip_type = _first_property(
            _clip_properties(media_pool_item),
            "Type",
            "Clip Type",
        ).lower()
        if "compound" in source_clip_type:
            try:
                project.SetCurrentTimeline(original_timeline)
            except Exception:
                pass
            try:
                media_pool.DeleteTimelines([imported_timeline])
            except Exception:
                pass
            raise RuntimeError("免費版拒絕載入 Compound Clip DRT。")
        expected_uid = str(params.get("clip_uid") or "").strip()
        if media_pool_item is None and expected_uid:
            def find_uid_in_folder(folder):
                for clip in list(folder.GetClipList() or []):
                    if _clip_uid(clip, _clip_properties(clip)) == expected_uid:
                        return clip
                for child in list(folder.GetSubFolderList() or []):
                    found = find_uid_in_folder(child)
                    if found is not None:
                        return found
                return None

            media_pool_item = find_uid_in_folder(media_pool.GetRootFolder())
        if media_pool_item is None:
            carrier_path = Path(str(params.get("carrier_drb_path") or "")).expanduser().resolve()
            if not carrier_path.is_file():
                raise RuntimeError("Text+ DRT 沒有 Media Pool carrier，且找不到內建 carrier DRB。")
            root_folder = media_pool.GetRootFolder()
            cache_folder = None
            for folder in list(root_folder.GetSubFolderList() or []):
                if str(folder.GetName() or "") == "_BaoCardCutExternalResolveCache":
                    cache_folder = folder
                    break
            if cache_folder is None:
                cache_folder = media_pool.AddSubFolder(root_folder, "_BaoCardCutExternalResolveCache")
            if cache_folder is None:
                raise RuntimeError("無法建立 Text+ carrier cache bin。")

            def find_carrier(folder):
                for clip in list(folder.GetClipList() or []):
                    properties = _clip_properties(clip)
                    if _first_property(properties, "Type", "Clip Type").lower() == "fusion title":
                        return clip
                return None

            media_pool_item = find_carrier(cache_folder)
            if media_pool_item is None:
                before_ids = {str(folder.GetUniqueId() or "") for folder in list(root_folder.GetSubFolderList() or [])}
                previous_folder = media_pool.GetCurrentFolder()
                media_pool.SetCurrentFolder(root_folder)
                if not media_pool.ImportFolderFromFile(str(carrier_path), ""):
                    raise RuntimeError("Resolve Free 無法匯入內建 Text+ carrier。")
                imported_folders = [
                    folder for folder in list(root_folder.GetSubFolderList() or [])
                    if str(folder.GetUniqueId() or "") not in before_ids
                ]
                for folder in imported_folders:
                    media_pool_item = find_carrier(folder)
                    if media_pool_item is not None:
                        media_pool.MoveClips([media_pool_item], cache_folder)
                        break
                if previous_folder is not None:
                    media_pool.SetCurrentFolder(previous_folder)
            if media_pool_item is None:
                raise RuntimeError("內建 Text+ carrier 已匯入，但找不到 Fusion Title 素材。")
        # 不信任 DRT 內的 Media Pool 類型。即使本機 JSON／DRT 被替換，
        # 時間線插入也只能使用內建的純 Text+ carrier。
        media_pool_item = _find_or_import_text_plus_carrier(media_pool, carrier_path)
        duration = max(1, int(source_item.GetDuration() or 1))
        fusion_dir = Path(tempfile.mkdtemp(prefix="baocardcut_free_textplus_"))
        source_fusion_path = fusion_dir / "source.setting"
        fusion_path = fusion_dir / "static_text_plus.setting"
        if not source_item.ExportFusionComp(str(source_fusion_path), 1) or not source_fusion_path.is_file():
            raise RuntimeError("無法從 Text+ DRT 匯出 Fusion 設定。")
        fusion_path.write_bytes(_build_static_text_plus_setting(source_fusion_path.read_bytes()))
        if not project.SetCurrentTimeline(original_timeline):
            raise RuntimeError("匯入 DRT 後無法切回原本時間線。")
        record_frame = None
        try:
            current_tc = str(original_timeline.GetCurrentTimecode() or "")
            hh, mm, ss, ff = [int(part) for part in current_tc.replace(";", ":").split(":")]
            fps = _timeline_fps(project)
            record_frame = int(original_timeline.GetStartFrame() or 0) + (((hh * 60 + mm) * 60 + ss) * fps + ff) - (3600 * fps)
        except Exception:
            record_frame = int(original_timeline.GetStartFrame() or 0)
        carrier_source_span = _text_plus_carrier_source_span(project, media_pool_item, duration)
        clip_info = {
            "mediaPoolItem": media_pool_item,
            "startFrame": 0,
            "endFrame": carrier_source_span,
            "mediaType": 1,
            "trackIndex": track_index,
            "recordFrame": int(record_frame),
        }

        def insert_text_plus_middle():
            inserted_items = list(media_pool.AppendToTimeline([clip_info]) or [])
            if not inserted_items:
                return None
            inserted_item = inserted_items[0]
            if not inserted_item.ImportFusionComp(str(fusion_path)):
                original_timeline.DeleteClips(inserted_items, False)
                raise RuntimeError("Text+ 已建立，但 Fusion 設定回灌失敗。")
            return inserted_item

        overlap = _prepare_trial_overlap(
            media_pool,
            original_timeline,
            "video",
            track_index,
            record_frame,
            duration,
            fallback_video_media_item=media_pool_item,
            middle_insert_builder=insert_text_plus_middle,
            project=project,
        )
        item = overlap.pop("_middle_inserted_item", None)
        if item is None:
            item = insert_text_plus_middle()
        if item is None:
            raise RuntimeError("Resolve Free 沒有把 Text+ 插入指定軌。")
        inserted = [item]
        actual_duration = max(1, int(item.GetDuration() or 1))
        if actual_duration != duration:
            original_timeline.DeleteClips(inserted, False)
            raise RuntimeError(
                f"Text+ carrier 幀率換算後長度仍不正確：預期 {duration} 格，實際 {actual_duration} 格。"
            )
        return {
            "inserted_count": len(inserted),
            "track_index": track_index,
            "record_frame": int(record_frame),
            "duration_frames": duration,
            "fusion_comp_count": int(item.GetFusionCompCount() or 0),
            "item_name": str(item.GetName() or ""),
            "media_cache_hit": bool(resolve_cache_hit),
            "media_cache_key": str(resolve_cache_key),
            **overlap,
        }
    finally:
        try:
            project.SetCurrentTimeline(original_timeline)
        except Exception:
            pass
        if fusion_dir is not None:
            shutil.rmtree(fusion_dir, ignore_errors=True)


def _timeline_fps(project) -> int:
    try:
        raw = str(project.GetSetting("timelineFrameRate") or "24")
    except Exception:
        raw = "24"
    number = "".join(character for character in raw if character.isdigit() or character == ".")
    try:
        return max(1, int(round(float(number or 24))))
    except Exception:
        return 24


def _frame_to_timecode(frame: int, start_frame: int, fps: int) -> str:
    base_hours = 1 if start_frame > 0 else 0
    offset_frames = max(0, int(frame) - int(start_frame))
    total_seconds, ff = divmod(offset_frames, fps)
    hh = base_hours + (total_seconds // 3600)
    mm = (total_seconds % 3600) // 60
    ss = total_seconds % 60
    return f"{hh:02d}:{mm:02d}:{ss:02d}:{ff:02d}"


def _timeline_by_name(project, name: str):
    try:
        count = int(project.GetTimelineCount() or 0)
    except Exception:
        count = 0
    for index in range(1, count + 1):
        try:
            timeline = project.GetTimelineByIndex(index)
        except Exception:
            timeline = None
        if timeline is not None and str(timeline.GetName() or "") == name:
            return timeline
    return None


def _dependency_paths(timeline) -> List[str]:
    found: List[str] = []
    seen: Set[str] = set()
    for track_type in ("video", "audio"):
        try:
            track_count = int(timeline.GetTrackCount(track_type) or 0)
        except Exception:
            track_count = 0
        for track_index in range(1, track_count + 1):
            try:
                items = list(timeline.GetItemListInTrack(track_type, track_index) or [])
            except Exception:
                items = []
            for item in items:
                try:
                    media_item = item.GetMediaPoolItem()
                except Exception:
                    media_item = None
                if media_item is None:
                    continue
                path = _first_property(_clip_properties(media_item), "File Path", "FilePath", "Path")
                key = os.path.normcase(os.path.abspath(path)) if path else ""
                if key and key not in seen and Path(path).is_file():
                    seen.add(key)
                    found.append(path)
    return found


def _export_card_artifacts(
    resolve_object,
    project,
    media_pool,
    clip,
    output_dir: Path,
    index: int,
    carrier_drb_path: Path,
) -> Dict[str, Any]:
    properties = _clip_properties(clip)
    name = str(getattr(clip, "GetName", lambda: "")() or _first_property(properties, "Clip Name", "Name") or f"asset_{index}")
    uid = _clip_uid(clip, properties) or _safe_name(name)
    clip_type = _first_property(properties, "Type", "Clip Type")
    file_path = _first_property(properties, "File Path", "FilePath", "Path")
    artifact_dir = output_dir / f"{index:03d}_{_safe_name(uid)[:80]}"
    artifact_dir.mkdir(parents=True, exist_ok=True)
    still_paths: List[str] = []
    drt_path = artifact_dir / "asset.drt"
    original_timeline = project.GetCurrentTimeline()
    temp_timeline = None
    style_temp_dir = None
    timeline_name = f"__BaoCardCutFreeCard_{int(time.time() * 1000)}_{index}__"
    dependencies: List[str] = []
    try:
        if not media_pool.CreateEmptyTimeline(timeline_name):
            raise RuntimeError("無法建立 Free 字卡匯出暫時時間線。")
        temp_timeline = _timeline_by_name(project, timeline_name)
        if temp_timeline is None:
            raise RuntimeError("找不到 Free 字卡匯出暫時時間線。")
        if not project.SetCurrentTimeline(temp_timeline):
            raise RuntimeError("無法切換到 Free 字卡匯出暫時時間線。")
        time.sleep(0.05)
        appended = list(media_pool.AppendToTimeline([clip]) or [])
        if not appended:
            raise RuntimeError("無法把選取字卡放入 Free 匯出暫時時間線。")
        item = appended[0]
        start_frame = int(temp_timeline.GetStartFrame() or item.GetStart() or 0)
        clip_start = int(item.GetStart() or start_frame)
        duration = max(1, int(item.GetDuration() or 1))
        fps = _timeline_fps(project)
        for sample_index, fraction in enumerate((0.50, 0.35, 0.65), start=1):
            sample_frame = clip_start + min(duration - 1, int(round((duration - 1) * fraction)))
            temp_timeline.SetCurrentTimecode(_frame_to_timecode(sample_frame, start_frame, fps))
            time.sleep(0.10)
            sample_path = artifact_dir / f"preview_still_{sample_index}.png"
            if project.ExportCurrentFrameAsStill(str(sample_path)) and sample_path.is_file():
                still_paths.append(str(sample_path))
        if not still_paths:
            raise RuntimeError("Resolve 無法輸出字卡預覽畫面。")
        dependencies = _dependency_paths(temp_timeline)

        # 免費版保存時不保留來源 DRT。只擷取 TextPlus 的靜態 Value，
        # 再套到乾淨 carrier 後輸出新的 DRT，讓其他節點、連線與動畫不進入本機素材資料。
        style_temp_dir = Path(tempfile.mkdtemp(prefix="baocardcut_trial_static_textplus_"))
        source_setting_path = style_temp_dir / "source.setting"
        static_setting_path = style_temp_dir / "static_text_plus.setting"
        if not item.ExportFusionComp(str(source_setting_path), 1) or not source_setting_path.is_file():
            raise RuntimeError("無法讀取選取 Text+ 的 Fusion 樣式。")
        static_setting_path.write_bytes(
            _build_static_text_plus_setting(source_setting_path.read_bytes())
        )
        carrier = _find_or_import_text_plus_carrier(media_pool, carrier_drb_path)
        if not temp_timeline.DeleteClips([item], False):
            raise RuntimeError("無法移除 Free 字卡匯出暫時時間線上的原始動畫素材。")
        clean_items = list(
            media_pool.AppendToTimeline(
                [
                    {
                        "mediaPoolItem": carrier,
                        "startFrame": 0,
                        "endFrame": max(0, duration - 1),
                        "mediaType": 1,
                        "trackIndex": 1,
                        "recordFrame": clip_start,
                    }
                ]
            )
            or []
        )
        if not clean_items:
            raise RuntimeError("Resolve 無法建立乾淨的 Text+ 樣式素材。")
        clean_item = clean_items[0]
        if not clean_item.ImportFusionComp(str(static_setting_path)):
            raise RuntimeError("乾淨 Text+ 已建立，但純文字樣式回灌失敗。")
        export_type = getattr(resolve_object, "EXPORT_DRT", None)
        export_subtype = getattr(resolve_object, "EXPORT_NONE", 0)
        if export_type is None or not temp_timeline.Export(str(drt_path), export_type, export_subtype):
            raise RuntimeError("Resolve 無法輸出純 Text+ 字卡 DRT。")
        dependencies = []
        return {
            "name": name,
            "clip_uid": uid,
            "clip_type": clip_type,
            "file_path": file_path,
            "properties": properties,
            "still_path": still_paths[0],
            "still_paths": still_paths,
            "drt_path": str(drt_path) if drt_path.is_file() else "",
            "dependencies": dependencies,
            "style_only": True,
            "animation_stripped": True,
        }
    finally:
        try:
            if original_timeline is not None:
                project.SetCurrentTimeline(original_timeline)
        except Exception:
            pass
        try:
            if temp_timeline is not None:
                media_pool.DeleteTimelines([temp_timeline])
        except Exception:
            pass
        if style_temp_dir is not None:
            shutil.rmtree(style_temp_dir, ignore_errors=True)


def _export_selected_cards(resolve_object, params: Dict[str, Any]) -> Dict[str, Any]:
    output_text = str(params.get("output_dir") or "").strip()
    if not output_text:
        raise RuntimeError("export_selected_cards 缺少 output_dir。")
    output_dir = Path(output_text).expanduser().resolve()
    output_dir.mkdir(parents=True, exist_ok=True)
    carrier_drb_path = Path(str(params.get("carrier_drb_path") or "")).expanduser().resolve()
    if not carrier_drb_path.is_file():
        raise RuntimeError("export_selected_cards 缺少免費版 Text+ carrier。")
    # 加入字卡可能發生在 Worker 啟動很久之後；每次命令都重新取得目前有效的
    # Resolve context，避免舊 proxy 的 API 方法變成 None 而讓整個加入流程失效。
    active_resolve, resolve_source = _resolve_from_internal_context(
        fallback=resolve_object,
        require_timeline=False,
    )
    if active_resolve is None:
        raise RuntimeError("Resolve worker 找不到目前開啟中的 Resolve context。")
    resolve_object = active_resolve
    manager = _safe_call(resolve_object, "GetProjectManager")
    project = _safe_call(manager, "GetCurrentProject")
    if project is None:
        raise RuntimeError(
            "Resolve Worker 取得的物件沒有目前專案。"
            f" source={resolve_source} context="
            + json.dumps(_context_payload(resolve_object), ensure_ascii=False)
        )
    media_pool = _safe_call(project, "GetMediaPool")
    if media_pool is None:
        raise RuntimeError("無法取得 Media Pool。")
    try:
        selected = list(_safe_call(media_pool, "GetSelectedClips", []) or [])
    except Exception as error:
        raise RuntimeError(f"讀取 Media Pool 選取素材失敗：{error}") from error
    try:
        folder = media_pool.GetCurrentFolder()
        folder_name = str(folder.GetName() or "") if folder else ""
    except Exception:
        folder_name = ""
    cards: List[Dict[str, Any]] = []
    errors: List[str] = []
    for index, clip in enumerate(selected, start=1):
        try:
            properties = _clip_properties(clip)
            clip_type = _first_property(properties, "Type", "Clip Type").lower()
            file_path = _first_property(properties, "File Path", "FilePath", "Path")
            suffix = Path(file_path).suffix.lower()
            if "audio" in clip_type or suffix in {".wav", ".mp3", ".m4a", ".aac", ".flac"}:
                continue
            if "compound" in clip_type:
                raise RuntimeError("免費版不支援保存 Compound Clip；請改選單純 Text+ 字卡。")
            if not any(token in clip_type for token in ("text+", "fusion title", "text plus")):
                raise RuntimeError("免費版目前只接受單純 Text+ 字卡。")
            payload = _export_card_artifacts(
                resolve_object,
                project,
                media_pool,
                clip,
                output_dir,
                index,
                carrier_drb_path,
            )
            payload["folder_name"] = folder_name
            cards.append(payload)
        except Exception as error:
            errors.append(f"{index}: {error}")
    return {
        "cards": cards,
        "selected_count": len(selected),
        "errors": errors,
    }


def _activate_hot_reload_source(probe_source_path: Path) -> None:
    """沿用正式版 V88：隔離載入新定義，不覆蓋 Resolve 注入的 host globals。"""

    source_text = probe_source_path.read_text(encoding="utf-8-sig")
    reload_namespace = {
        "__name__": "_baocardcut_free_backend_hot_reload",
        "__file__": str(probe_source_path),
        "__builtins__": globals().get("__builtins__", builtins),
        "_BAOCARDCUT_HOT_RELOAD_DEFINITIONS_ONLY": True,
    }
    exec(compile(source_text, str(probe_source_path), "exec"), reload_namespace, reload_namespace)
    protected_names = {
        "__builtins__",
        "__file__",
        "__loader__",
        "__name__",
        "__package__",
        "__spec__",
        "_BAOCARDCUT_HOT_RELOAD_DEFINITIONS_ONLY",
        "app",
        "bmd",
        "fu",
        "fusion",
        "resolve",
    }
    for name, value in reload_namespace.items():
        if name not in protected_names:
            globals()[name] = value


def _worker(resolve_object) -> None:
    running_loop_version = PROBE_VERSION
    builtins._baocardcut_free_probe_running_loop_version = running_loop_version
    _log(
        "persistent command loop started on the script main thread "
        f"worker_id={WORKER_ID} loop_version={running_loop_version}"
    )
    result_cache: Dict[str, Dict[str, Any]] = {}
    result_order = []
    probe_source_path = PROBE_SOURCE_PATH
    try:
        loaded_source_mtime = probe_source_path.stat().st_mtime_ns
    except OSError:
        loaded_source_mtime = 0

    def remember_result(request_id: str, result: Dict[str, Any]) -> None:
        if not request_id:
            return
        if request_id not in result_cache:
            result_order.append(request_id)
        result_cache[request_id] = result
        while len(result_order) > RESULT_CACHE_LIMIT:
            expired_id = result_order.pop(0)
            result_cache.pop(expired_id, None)

    while True:
        time.sleep(0.25)
        try:
            current_source_mtime = probe_source_path.stat().st_mtime_ns
            if current_source_mtime != loaded_source_mtime:
                _activate_hot_reload_source(probe_source_path)
                loaded_source_mtime = current_source_mtime
                _write_json_atomic(
                    STATE_PATH,
                    {
                        "ok": True,
                        "probe_version": PROBE_VERSION,
                        "schema": PROTOCOL_SCHEMA,
                        "supported_commands": sorted(SUPPORTED_COMMANDS),
                        "stage": "persistent_loop_active",
                        "resolve_source": "isolated_hot_reload",
                        "started_at": time.time(),
                        "worker_id": WORKER_ID,
                        "worker_pid": os.getpid(),
                        "command_file": COMMAND_PATH.name,
                        "result_file": RESULT_PATH.name,
                        "running_loop_version": running_loop_version,
                        "context": _context_payload(resolve_object),
                    },
                )
                _log(f"backend definitions hot reloaded in isolation probe_version={PROBE_VERSION}")
        except Exception as reload_error:
            _log(f"backend hot reload failed: {reload_error}")
        if not COMMAND_PATH.exists():
            continue
        request_id = ""
        command_name = ""
        processing_path = COMMAND_PATH.with_name("free_backend_probe_command.processing.json")
        try:
            try:
                os.replace(COMMAND_PATH, processing_path)
            except FileNotFoundError:
                continue
            raw_command = json.loads(processing_path.read_text(encoding="utf-8-sig"))
            try:
                processing_path.unlink()
            except FileNotFoundError:
                pass
            if isinstance(raw_command, dict):
                request_id = str(raw_command.get("request_id") or "")
                command_name = str(raw_command.get("command") or "")
            command = validate_request(raw_command)
            request_id = str(command["request_id"])
            command_name = str(command["command"])
            cached_result = result_cache.get(request_id)
            if cached_result is not None:
                _write_json_atomic(RESULT_PATH, cached_result)
                _log(f"replayed cached command {command_name} request={request_id}")
                continue
            command_resolve, command_resolve_source = _resolve_from_internal_context(
                fallback=resolve_object,
                require_timeline=False,
            )
            if command_resolve is None:
                raise RuntimeError(
                    "Resolve worker 找不到目前專案／時間線。Context: "
                    + json.dumps(_context_payload(resolve_object), ensure_ascii=False)
                )
            if command_name == "export_selected_cards":
                payload = _export_selected_cards(command_resolve, command.get("params") or {})
                result = build_success_result(command, **payload)
            elif command_name == "get_selected_audio":
                payload = _selected_audio_payload(command_resolve)
                result = build_success_result(command, **payload)
            elif command_name == "insert_audio":
                payload = _insert_audio(command_resolve, command.get("params") or {})
                result = build_success_result(command, **payload)
            elif command_name == "insert_text_plus":
                payload = _insert_text_plus(command_resolve, command.get("params") or {})
                result = build_success_result(command, **payload)
            else:
                payload = _context_payload(command_resolve)
                result = build_success_result(
                    command,
                    context=payload,
                    resolve_source=command_resolve_source,
                )
            remember_result(request_id, result)
            _write_json_atomic(RESULT_PATH, result)
            _log(f"handled command {command_name} request={request_id}")
        except Exception as error:
            try:
                processing_path.unlink()
            except Exception:
                pass
            error_code = (
                "invalid_request"
                if isinstance(error, ResolveBackendProtocolError)
                else "backend_command_failed"
            )
            result = build_error_result(
                request_id=request_id,
                command=command_name,
                error=str(error),
                error_code=error_code,
                traceback_text=traceback.format_exc(),
            )
            remember_result(request_id, result)
            _write_json_atomic(RESULT_PATH, result)
            _log(f"command failed: {error}")


def _start_probe() -> None:
    STATE_DIR.mkdir(parents=True, exist_ok=True)
    resolve_object, source = _resolve_from_internal_context()
    if resolve_object is None:
        _write_json_atomic(
            STATE_PATH,
            {
                "ok": False,
                "stage": "resolve_context",
                "error": "Resolve internal object is unavailable",
                "started_at": time.time(),
            },
        )
        _log("Resolve internal object is unavailable")
        return

    initial_context = _context_payload(resolve_object)

    def restore_existing_worker_state(guard: str) -> None:
        try:
            existing_state = json.loads(STATE_PATH.read_text(encoding="utf-8-sig"))
        except Exception:
            existing_state = {}
        if not isinstance(existing_state, dict):
            existing_state = {}
        existing_context = existing_state.get("context")
        if not isinstance(existing_context, dict) or not existing_context.get("has_media_pool"):
            existing_context = initial_context
        _write_json_atomic(
            STATE_PATH,
            {
                "ok": True,
                "probe_version": int(existing_state.get("probe_version") or PROBE_VERSION),
                "schema": PROTOCOL_SCHEMA,
                "supported_commands": sorted(SUPPORTED_COMMANDS),
                "stage": "persistent_loop_active",
                "resolve_source": str(existing_state.get("resolve_source") or source),
                "started_at": float(existing_state.get("started_at") or time.time()),
                "worker_id": str(existing_state.get("worker_id") or WORKER_ID),
                "worker_pid": int(existing_state.get("worker_pid") or os.getpid()),
                "command_file": COMMAND_PATH.name,
                "result_file": RESULT_PATH.name,
                "running_loop_version": int(
                    existing_state.get("running_loop_version")
                    or getattr(builtins, "_baocardcut_free_probe_running_loop_version", 0)
                    or 0
                ),
                "context": existing_context,
                "singleton_guard": guard,
            },
        )

    if bool(getattr(builtins, "_baocardcut_free_probe_worker_started", False)):
        manager = _safe_call(resolve_object, "GetProjectManager")
        project = _safe_call(manager, "GetCurrentProject")
        if _safe_call(project, "GetCurrentTimeline") is not None:
            builtins._baocardcut_free_probe_resolve = resolve_object
        restore_existing_worker_state("python_builtins")
        _log("duplicate probe start refreshed current Resolve context through Python singleton guard")
        return

    def bind_singleton_socket():
        candidate_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        if hasattr(socket, "SO_EXCLUSIVEADDRUSE"):
            candidate_socket.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
        candidate_socket.bind(("127.0.0.1", SINGLETON_PORT))
        candidate_socket.listen(1)
        return candidate_socket

    try:
        singleton_socket = bind_singleton_socket()
    except OSError:
        # macOS 的 RunScript 會留下獨立 fuscript 程序。僅當新程序已證明
        # 自己取得有效專案、時間線與 Media Pool 時，才接管失效的舊 Worker。
        previous_pid = 0
        previous_worker_id = ""
        try:
            previous_state = json.loads(STATE_PATH.read_text(encoding="utf-8-sig"))
            if isinstance(previous_state, dict):
                previous_pid = int(previous_state.get("worker_pid") or 0)
                previous_worker_id = str(previous_state.get("worker_id") or "")
        except Exception:
            pass
        can_take_over = False
        if previous_pid > 1 and previous_pid != os.getpid() and previous_worker_id:
            wait_started = time.monotonic()
            next_wait_log = wait_started
            while True:
                live_manager = _safe_call(resolve_object, "GetProjectManager")
                live_project = _safe_call(live_manager, "GetCurrentProject")
                live_timeline = _safe_call(live_project, "GetCurrentTimeline")
                live_media_pool = _safe_call(live_project, "GetMediaPool")
                can_take_over = (
                    bool(_safe_call(resolve_object, "GetVersion", []))
                    and live_project is not None
                    and live_timeline is not None
                    and live_media_pool is not None
                )
                if can_take_over:
                    break
                now = time.monotonic()
                if now >= next_wait_log:
                    _log(
                        "candidate worker waiting for live Resolve project before takeover "
                        f"previous_pid={previous_pid} waited={now - wait_started:.1f}s "
                        f"context={_context_payload(resolve_object)}"
                    )
                    next_wait_log = now + 10.0
                time.sleep(0.25)
        if can_take_over and previous_pid > 1 and previous_pid != os.getpid() and previous_worker_id:
            try:
                os.kill(previous_pid, signal.SIGTERM)
                _log(
                    "stale worker takeover requested "
                    f"previous_pid={previous_pid} previous_worker_id={previous_worker_id} "
                    f"new_worker_id={WORKER_ID}"
                )
            except OSError as error:
                _log(f"stale worker takeover signal failed previous_pid={previous_pid}: {error}")
            deadline = time.monotonic() + 5.0
            singleton_socket = None
            while time.monotonic() < deadline:
                time.sleep(0.10)
                try:
                    singleton_socket = bind_singleton_socket()
                    break
                except OSError:
                    continue
            if singleton_socket is None:
                restore_existing_worker_state("loopback_socket_takeover_failed")
                _log("stale worker takeover failed: singleton port remained occupied")
                return
            _log(
                "stale worker takeover completed "
                f"previous_pid={previous_pid} new_worker_id={WORKER_ID}"
            )
        else:
            restore_existing_worker_state("loopback_socket")
            _log(
                "duplicate probe start ignored by loopback singleton guard "
                f"can_take_over={can_take_over} previous_pid={previous_pid} "
                f"candidate_context={initial_context}"
            )
            return

    builtins._baocardcut_free_probe_worker_started = True
    builtins._baocardcut_free_probe_singleton_socket = singleton_socket
    builtins._baocardcut_free_probe_resolve = resolve_object
    builtins._baocardcut_free_probe_running_loop_version = PROBE_VERSION

    _write_json_atomic(
        STATE_PATH,
        {
            "ok": True,
            "probe_version": PROBE_VERSION,
            "schema": PROTOCOL_SCHEMA,
            "supported_commands": sorted(SUPPORTED_COMMANDS),
            "stage": "persistent_loop_starting",
            "resolve_source": source,
            "started_at": time.time(),
            "worker_id": WORKER_ID,
            "worker_pid": os.getpid(),
            "command_file": COMMAND_PATH.name,
            "result_file": RESULT_PATH.name,
            "running_loop_version": PROBE_VERSION,
            "context": initial_context,
        },
    )
    _log(f"probe started through {source}: {initial_context}")
    _worker(resolve_object)


if not bool(globals().get("_BAOCARDCUT_HOT_RELOAD_DEFINITIONS_ONLY", False)):
    try:
        _start_probe()
    except Exception as probe_error:
        _write_json_atomic(
            STATE_PATH,
            {
                "ok": False,
                "stage": "startup_exception",
                "error": str(probe_error),
                "traceback": traceback.format_exc(),
                "started_at": time.time(),
            },
        )
        _log(f"startup failed: {probe_error}")
