# -*- coding: utf-8 -*-
"""
Python Worker：从 queue 目录读取任务并执行切分/去字幕/合成，与 Java BackendWorker 行为一致。
用法:
  python worker.py [--data-dir DIR] [--path-replace OLD=NEW] [--workers N]
  或设置环境变量: VIDEOCONVERTER_DATA_DIR, VIDEOCONVERTER_PATH_REPLACE (OLD=NEW)
"""
import argparse
import logging
import os
import subprocess
import sys
import threading
import time
from pathlib import Path

from task_queue import QueueStore
from metadata import load_metadata, get_processed_chunks, get_pending_chunks, update_chunk_processed
from ffmpeg_runner import (
    split_video_to_chunks,
    run_desubtitle,
    merge_chunks,
    get_duration,
    get_video_size,
    check_ffmpeg_available,
    InvalidSourceError,
)

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
    datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger("worker")

# 当前正在执行任务（process_* 内）的 worker 数；退出前必须为 0，避免误判空闲时杀断正在合成的任务
_active_worker_count = 0
_active_worker_lock = threading.Lock()
# 未使用 --exit-when-idle 时，仅打印一次「全部处理完毕」提示用
_all_done_logged = False
_all_done_lock = threading.Lock()
# 队列空闲时每隔 IDLE_HEARTBEAT_SEC 秒打一次日志，避免用户误以为卡住
_idle_heartbeat_interval = 60.0
_last_idle_heartbeat_time = 0.0
# 多 worker 时多个 spinner/heartbeat 并发写 stderr，重定向到管道时易写满缓冲区卡死，故串行化写
_stderr_lock = threading.Lock()


def _cpu_ram_str() -> str:
    """若已安装 psutil 则返回 ' CPU 12% RAM 34%'，否则返回空字符串。不增加强制依赖。"""
    try:
        import psutil
        cpu = psutil.cpu_percent(interval=None)
        mem = psutil.virtual_memory().percent
        return f"  CPU {cpu:.0f}% RAM {mem:.0f}%"
    except Exception:
        return ""


def _spinner(stop_event: threading.Event, label: str = "处理中") -> None:
    """同一行跑马灯，避免用户以为死机。仅 TTY 且 1 秒刷新一次；若已装 psutil 会显示 CPU/RAM 占用。"""
    i = 0
    while not stop_event.wait(1.0):
        dots = "." * ((i % 3) + 1)
        try:
            with _stderr_lock:
                ts = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
                extra = _cpu_ram_str()
                sys.stderr.write(f"\r{ts} [INFO] worker: {label}{extra} {dots}   ")
                sys.stderr.flush()
        except (OSError, UnicodeEncodeError):
            break
        i += 1


def _desub_spinner(stop_event: threading.Event) -> None:
    """兼容简易模式调用的去字幕跑马灯。"""
    _spinner(stop_event, "去字幕中")


def _is_tty() -> bool:
    """仅真正终端跑跑马灯，避免 Colab/后台时满屏刷日志。"""
    return getattr(sys.stderr, "isatty", lambda: False)()


def _flush_log_handlers() -> None:
    """刷新所有 logging 输出，避免在 Mac mini 等重定向 stderr 时日志被缓冲不显示。"""
    try:
        for h in logging.root.handlers:
            if hasattr(h, "flush"):
                h.flush()
    except Exception:
        pass


def _heartbeat(
    stop_event: threading.Event,
    label: str,
    interval_sec: float = 15.0,
    task_log_fn=None,
) -> None:
    """后台线程：每隔 interval_sec 打一条日志；若提供 task_log_fn(msg)，同时写入任务 .log 文件。"""
    start = time.time()
    while not stop_event.wait(interval_sec):
        elapsed = int(time.time() - start)
        msg = f"{label} 进行中…（已运行 {elapsed}s）"
        logger.info("%s", msg)
        _flush_log_handlers()
        if callable(task_log_fn):
            try:
                task_log_fn(msg)
            except Exception:
                pass


def _run_with_spinner(label: str, fn, *args, task_log_fn=None, **kwargs):
    """在 TTY 下执行 fn(*args, **kwargs) 时显示跑马灯，否则直接执行。
    心跳每 15s 打一条日志；若提供 task_log_fn(msg)，同时写入当前任务 queue/<task_id>.log 便于本地确认。"""
    msg0 = f"{label} 进行中…（已运行 0s）"
    logger.info("%s", msg0)
    _flush_log_handlers()
    if callable(task_log_fn):
        try:
            task_log_fn(msg0)
        except Exception:
            pass
    # 勿把 task_log_fn 传给 fn
    kwargs.pop("task_log_fn", None)
    # 稍延后再启跑马灯，避免 \r 立刻覆盖上一行（多 worker 时上一行可能就是本线程的 0s）
    time.sleep(0.3)
    stop_heartbeat = threading.Event()
    heartbeat_thread = threading.Thread(
        target=_heartbeat,
        args=(stop_heartbeat, label, 15.0, task_log_fn),
        daemon=True,
    )
    heartbeat_thread.start()
    use_spinner = _is_tty()
    if use_spinner:
        stop_spinner = threading.Event()
        spinner_thread = threading.Thread(target=_spinner, args=(stop_spinner, label), daemon=True)
        spinner_thread.start()
    try:
        return fn(*args, **kwargs)
    finally:
        stop_heartbeat.set()
        if use_spinner:
            stop_spinner.set()
            spinner_thread.join(timeout=1.5)
            try:
                with _stderr_lock:
                    sys.stderr.write("\n")
                    sys.stderr.flush()
            except (OSError, UnicodeEncodeError):
                pass


def process_split_task(store: QueueStore, task: dict) -> None:
    task_id = task["task_id"]
    input_file = task["input_file"]
    output_dir = task["output_dir"]
    config = task.get("config") or {}
    range_start = config.get("startTime", 0) or 0
    range_end = config.get("endTime", 0) or 0

    store.add_log(task_id, "INFO", f"开始切分视频: {Path(input_file).name} (范围: {range_start} - {range_end}秒)")
    metadata, video_id = _run_with_spinner(
        "切分中",
        split_video_to_chunks,
        input_file,
        output_dir,
        120.0,
        range_start,
        range_end,
        task_log_fn=lambda msg: store.add_log(task_id, "INFO", msg),
    )
    store.add_log(task_id, "INFO", f"切分完成: {metadata['totalChunks']} 个小块，元数据: {metadata.get('_metadataPath', '')}")

    # 先创建去字幕任务再标记父任务完成，避免其它 worker 在空窗期误判队列空闲并退出
    chunk_files = []
    for ch in metadata.get("chunks") or []:
        rel = ch.get("originalPath", "")
        if rel:
            chunk_path = Path(output_dir) / rel
            chunk_files.append(str(chunk_path))

    for chunk_file in chunk_files:
        store.create_desubtitle_task(chunk_file, output_dir, config, task_id, video_id)
    store.add_log(task_id, "INFO", f"已创建 {len(chunk_files)} 个去字幕任务")

    store.complete_task(task_id)
    # 短暂让出，使其它 worker 能及时看到新任务（避免网络盘/缓存导致只处理一个 desub 就停）
    time.sleep(0.5)


def process_desubtitle_task(store: QueueStore, task: dict) -> None:
    task_id = task["task_id"]
    input_file = task["input_file"]
    output_dir = task["output_dir"]
    video_id = task.get("video_id") or ""
    config = dict(task.get("config") or {})

    input_path = Path(input_file)
    if not input_path.exists():
        msg = f"输入文件不存在: {input_file}"
        store.fail_task(task_id, msg)
        logger.warning("任务 %s 失败: %s", task_id, msg)
        return

    store.add_log(task_id, "INFO", f"开始去字幕: {input_path.name}")
    store.add_log(task_id, "INFO", "去字幕进行中…（已运行 0s），若卡住可查看本任务 .log 或等 30s 心跳")

    output_dir_for_file = Path(output_dir) / video_id if video_id else Path(output_dir)
    output_dir_for_file.mkdir(parents=True, exist_ok=True)
    output_name = input_path.stem + "_desub.mp4"
    output_file = output_dir_for_file / output_name

    config["inputPath"] = input_file
    config["outputPath"] = str(output_file)
    if video_id:
        config["startTime"] = 0
        config["endTime"] = 0
        config["forceKeyframeAtStart"] = True

    try:
        _run_with_spinner(
            "去字幕中",
            run_desubtitle,
            config,
            input_file,
            str(output_file),
            task_log_fn=lambda msg: store.add_log(task_id, "INFO", msg),
        )
    except subprocess.TimeoutExpired as e:
        msg = f"去字幕超时（{e.timeout}s），可能源文件过大、磁盘/网络盘无响应或 FFmpeg 卡住，可重试或减小 VIDEOCONVERTER_DESUB_TIMEOUT"
        store.fail_task(task_id, msg)
        store.add_log(task_id, "WARN", msg)
        logger.warning("任务 %s %s", task_id, msg)
        return
    except Exception as e:
        msg = str(e)
        store.fail_task(task_id, msg)
        store.add_log(task_id, "WARN", msg)
        logger.warning("任务 %s 失败: %s", task_id, msg)
        return

    store.complete_task(task_id)
    store.add_log(task_id, "INFO", f"去字幕完成: {output_name}")

    if video_id:
        metadata_path = Path(output_dir) / video_id / "metadata.json"
        if metadata_path.exists():
            chunk_id = input_path.stem
            logger.info("更新 metadata: chunk_id=%s (video_id=%s)", chunk_id, video_id)
            try:
                update_chunk_processed(str(metadata_path), chunk_id, str(output_file))
                check_and_create_merge_task(store, video_id, output_dir, config)
            except Exception as e:
                logger.warning("更新 metadata 或检查合成失败: videoId=%s, chunkId=%s, %s", video_id, chunk_id, e)


def _merge_creation_lock(store: QueueStore, video_id: str, timeout_sec: float = 60.0):
    """对同一 video_id 串行化「检查并创建 MERGE」，避免多进程同时建两个 MERGE。
    带超时：若在 timeout_sec 秒内拿不到锁则放弃，避免多 worker 时卡死。"""
    from contextlib import contextmanager
    safe_id = (video_id or "").replace("/", "_").replace("\\", "_")[:64]
    lock_path = store.queue_dir / f".merge_{safe_id}.lock"
    lock_path.parent.mkdir(parents=True, exist_ok=True)

    @contextmanager
    def _held():
        try:
            import fcntl
            lock_nb = getattr(fcntl, "LOCK_NB", 0)  # Windows 无 LOCK_NB
            f = open(lock_path, "w")
            got_lock = False
            try:
                if lock_nb:
                    try:
                        fcntl.flock(f.fileno(), fcntl.LOCK_EX | lock_nb)
                        got_lock = True
                    except (BlockingIOError, OSError):
                        pass
                else:
                    fcntl.flock(f.fileno(), fcntl.LOCK_EX)
                    got_lock = True
                if not got_lock and timeout_sec > 0:
                    deadline = time.time() + timeout_sec
                    last_log = 0.0
                    while time.time() < deadline:
                        try:
                            fcntl.flock(f.fileno(), fcntl.LOCK_EX | lock_nb)
                            got_lock = True
                            break
                        except (BlockingIOError, OSError):
                            pass
                        now = time.time()
                        if now - last_log >= 5.0:
                            logger.info(
                                "等待 merge 锁: videoId=%s，已等 %.0fs（超时 %.0fs 后放弃）",
                                video_id, now - (deadline - timeout_sec), timeout_sec,
                            )
                            last_log = now
                        time.sleep(0.5)
                if got_lock:
                    yield True
                else:
                    logger.warning(
                        "获取 merge 锁超时，跳过本次创建 MERGE: videoId=%s（其他 worker 可能已创建）",
                        video_id,
                    )
                    yield False
            finally:
                if got_lock:
                    try:
                        fcntl.flock(f.fileno(), fcntl.LOCK_UN)
                    except OSError:
                        pass
                try:
                    f.close()
                except OSError:
                    pass
        except ImportError:
            # Windows 无 fcntl，接受可能的重复 MERGE
            yield True

    @contextmanager
    def _held_blocking():
        try:
            import fcntl
            f = open(lock_path, "w")
            try:
                fcntl.flock(f.fileno(), fcntl.LOCK_EX)
                yield True
            finally:
                fcntl.flock(f.fileno(), fcntl.LOCK_UN)
                f.close()
        except ImportError:
            yield True

    # 有超时且 >0 时用轮询；否则用原来的阻塞锁（如 ensure_merge_tasks 启动时）
    if timeout_sec > 0:
        return _held()
    return _held_blocking()


def ensure_merge_tasks_for_ready_videos(store: QueueStore) -> None:
    """启动时扫描队列中所有 (output_dir, video_id)，对「全部 chunk 已处理且尚无 MERGE」的补建 MERGE 任务。
    若 queue_config.json 中配置了 output_scan_roots，还会扫描这些目录下的 <video_id>/metadata.json 并补建 MERGE。"""
    seen: set = set()
    pairs: list = []
    for o, v in store.collect_video_output_pairs():
        k = (o, v)
        if k not in seen:
            seen.add(k)
            pairs.append((o, v))
    for o, v in store.collect_video_output_pairs_from_scan_roots():
        k = (o, v)
        if k not in seen:
            seen.add(k)
            pairs.append((o, v))
    for output_dir, video_id in pairs:
        check_and_create_merge_task(store, video_id, output_dir, {})


def check_and_create_merge_task(store: QueueStore, video_id: str, output_dir: str, config: dict) -> None:
    metadata_path = Path(output_dir) / video_id / "metadata.json"
    if not metadata_path.exists():
        return
    data = load_metadata(str(metadata_path))
    chunks = data.get("chunks") or []
    total = len(chunks)
    processed = get_processed_chunks(data)
    pending = get_pending_chunks(data)
    if total == 0 or len(processed) != total or pending:
        return
    # 先在外层检查，避免长时间持锁导致另一 worker 在 flock 上阻塞
    if store.has_merge_task_for_video_id(video_id):
        logger.debug("已有 MERGE 任务: videoId=%s，跳过", video_id)
        return
    logger.info("检查/创建 MERGE 任务: videoId=%s（获取锁中…）", video_id)
    with _merge_creation_lock(store, video_id, timeout_sec=60.0) as acquired:
        if not acquired:
            return
        logger.debug("已获取 merge 锁，检查队列中是否已有 MERGE: videoId=%s", video_id)
        if store.has_merge_task_for_video_id(video_id):
            return
        store.create_merge_task(video_id, output_dir, config)
        logger.info("自动创建合成任务: videoId=%s, 已处理 %d/%d 块", video_id, len(processed), total)


def _format_duration(sec: float) -> str:
    """不足1分钟用秒，超过60秒用分钟，超过60分钟用小时。"""
    if sec < 60:
        return f"{sec:.1f}秒" if sec != int(sec) else f"{int(sec)}秒"
    if sec < 3600:
        m = int(sec // 60)
        s = int(round(sec % 60))
        return f"{m}分{s}秒" if s else f"{m}分"
    h = int(sec // 3600)
    m = int((sec % 3600) // 60)
    s = int(round(sec % 60))
    if m and s:
        return f"{h}小时{m}分{s}秒"
    if m:
        return f"{h}小时{m}分"
    if s:
        return f"{h}小时{s}秒"
    return f"{h}小时"


def _log_split_to_merge_duration(data: dict, task_id: str, store: QueueStore) -> None:
    """若 metadata 含 splitStartedAt，则计算并输出从切分到合成结束的总时长与产能预估。"""
    s = data.get("splitStartedAt") or ""
    if not s:
        return
    try:
        from datetime import datetime, timezone
        ts = datetime.fromisoformat(s.replace("Z", "+00:00"))
        if ts.tzinfo is None:
            ts = ts.replace(tzinfo=timezone.utc)
        duration_sec = (datetime.now(timezone.utc) - ts).total_seconds()
        if duration_sec < 0:
            return
        msg = f"从切分到合成结束总时长: {_format_duration(duration_sec)}"
        processed = get_processed_chunks(data)
        if processed and duration_sec > 0:
            src_duration_sec = processed[-1]["endTime"] - processed[0]["startTime"]
            capacity = (src_duration_sec / 3600.0) / (duration_sec / 3600.0)
            msg += f"，产能预估: {capacity:.2f}(HV/h)"
        logger.info("videoId=%s %s", data.get("videoId", ""), msg)
        store.add_log(task_id, "INFO", msg)
    except Exception:
        pass


def process_merge_task(store: QueueStore, task: dict) -> None:
    task_id = task["task_id"]
    video_id = task.get("video_id")
    output_dir = task["output_dir"]

    store.add_log(task_id, "INFO", f"开始合成视频: videoId={video_id}")

    metadata_path = Path(output_dir) / video_id / "metadata.json"
    if not metadata_path.exists():
        msg = f"元数据文件不存在: {metadata_path}"
        store.fail_task(task_id, msg)
        logger.warning("任务 %s 失败: %s", task_id, msg)
        return

    data = load_metadata(str(metadata_path))
    processed = get_processed_chunks(data)
    if not processed:
        msg = "没有已处理的 chunk"
        store.fail_task(task_id, msg)
        logger.warning("任务 %s 失败: %s", task_id, msg)
        return

    start_time = processed[0]["startTime"]
    end_time = processed[-1]["endTime"]
    output_file = Path(output_dir) / f"{video_id}_merged.mp4"

    store.add_log(task_id, "INFO", f"合并 {len(processed)} 个小块，时间范围: {start_time} - {end_time}秒")

    try:
        _run_with_spinner(
            "合成中",
            merge_chunks,
            data,
            start_time,
            end_time,
            str(output_file),
            task_log_fn=lambda msg: store.add_log(task_id, "INFO", msg),
        )
        store.complete_task(task_id)
        store.add_log(task_id, "INFO", f"合成完成: {output_file.name}")
        # 从切分到合成结束总时长（若 metadata 含 splitStartedAt）
        _log_split_to_merge_duration(data, task_id, store)
    except subprocess.TimeoutExpired as e:
        msg = f"合成超时（{e.timeout}s），可能输出盘慢或 FFmpeg 卡住，可重试或增大 VIDEOCONVERTER_MERGE_TIMEOUT"
        store.fail_task(task_id, msg)
        store.add_log(task_id, "WARN", msg)
        logger.warning("任务 %s 失败: %s", task_id, msg)
    except Exception as e:
        msg = str(e)
        store.fail_task(task_id, msg)
        store.add_log(task_id, "WARN", msg)
        logger.warning("任务 %s 失败: %s", task_id, msg)


def process_one_click_compose_task(store: QueueStore, task: dict) -> None:
    task_id = task["task_id"]
    input_file = task["input_file"]
    output_dir = task["output_dir"]
    config = task.get("config") or {}
    range_start = config.get("startTime", 0) or 0
    range_end = config.get("endTime", 0) or 0

    store.add_log(task_id, "INFO", f"开始一键合成: {Path(input_file).name} (videoId={task.get('video_id', '')})")

    metadata, folder_video_id = split_video_to_chunks(input_file, output_dir, 120.0, range_start, range_end)
    store.add_log(task_id, "INFO", f"切分完成: {metadata['totalChunks']} 个小块")

    # 先创建去字幕任务再标记父任务完成，避免其它 worker 在空窗期误判队列空闲并退出
    chunk_files = []
    for ch in metadata.get("chunks") or []:
        rel = ch.get("originalPath", "")
        if rel:
            chunk_files.append(str(Path(output_dir) / rel))

    for cf in chunk_files:
        store.create_desubtitle_task(cf, output_dir, config, task_id, folder_video_id)
    store.add_log(task_id, "INFO", f"已创建 {len(chunk_files)} 个去字幕任务")

    store.complete_task(task_id)
    # 短暂让出，使其它 worker 能及时看到新任务（避免网络盘/缓存导致只处理一个 desub 就停）
    time.sleep(0.5)


def work_loop(store: QueueStore, exit_idle_sec: float = 0) -> None:
    """
    :param exit_idle_sec: 队列连续空闲超过此秒数时退出（0 表示不自动退出，一直等新任务）
    """
    global _active_worker_count, _all_done_logged, _last_idle_heartbeat_time
    idle_since: float = 0.0
    while True:
        try:
            if store.is_paused():
                time.sleep(1)
                continue

            task = store.acquire_pending_task()
            if task is None:
                if exit_idle_sec > 0:
                    with _active_worker_lock:
                        active = _active_worker_count
                    stats = store.get_queue_stats()
                    by_status = stats.get("by_status") or {}
                    pending = by_status.get("PENDING", 0)
                    processing = by_status.get("PROCESSING", 0)
                    if pending > 0 or processing > 0 or active > 0:
                        # 还有待处理/正在处理的任务或仍有 worker 在执行，不能算全部完成
                        idle_since = 0.0
                    else:
                        now = time.time()
                        if idle_since == 0.0:
                            idle_since = now
                        elif now - idle_since >= exit_idle_sec:
                            # 退出前二次确认，避免竞态导致漏掉 PENDING 或正在合成的任务
                            with _active_worker_lock:
                                active_final = _active_worker_count
                            stats_final = store.get_queue_stats()
                            by_final = stats_final.get("by_status") or {}
                            if by_final.get("PENDING", 0) > 0 or by_final.get("PROCESSING", 0) > 0 or active_final > 0:
                                idle_since = 0.0
                                continue
                            # 再等 0.5 秒后最后查一次，避免其它线程刚写完状态尚未可见
                            time.sleep(0.5)
                            with _active_worker_lock:
                                active_final = _active_worker_count
                            stats_final = store.get_queue_stats()
                            by_final = stats_final.get("by_status") or {}
                            if by_final.get("PENDING", 0) > 0 or by_final.get("PROCESSING", 0) > 0 or active_final > 0:
                                idle_since = 0.0
                                continue
                            logger.info("全部任务已完成，队列已空闲 %.0f 秒，进程退出", exit_idle_sec)
                            return
                else:
                    # 未设 --exit-when-idle 时也判断是否全部完成，仅打印一次提示
                    with _active_worker_lock:
                        active = _active_worker_count
                    stats = store.get_queue_stats()
                    by_status = stats.get("by_status") or {}
                    pending = by_status.get("PENDING", 0)
                    processing = by_status.get("PROCESSING", 0)
                    if pending == 0 and processing == 0 and active == 0:
                        with _all_done_lock:
                            if not _all_done_logged:
                                # 再等一小段时间并重试一次，避免新写入的子任务文件尚未被列出就误判“全部完成”
                                time.sleep(0.3)
                                task = store.acquire_pending_task()
                                if task is None:
                                    _all_done_logged = True
                                    _last_idle_heartbeat_time = time.time()
                                    completed = by_status.get("COMPLETED", 0)
                                    failed = by_status.get("FAILED", 0)
                                    paused = by_status.get("PAUSED", 0)
                                    total = stats.get("total", 0)
                                    summary = f"共 {total} 任务，成功 {completed}，失败 {failed}，暂停 {paused}"
                                    logger.info("当前队列任务已全部处理完毕，等待新任务…（%s）", summary)
                    else:
                        with _all_done_lock:
                            _all_done_logged = False
                    idle_since = 0.0
                if task is None:
                    # 队列空闲时尝试将符合条件的失败任务自动重置为待重试，无需人工重新跑
                    retry_delay = 300.0
                    max_retries = 3
                    try:
                        r = os.environ.get("VIDEOCONVERTER_RETRY_DELAY_SEC", "").strip()
                        if r.isdigit():
                            retry_delay = float(r)
                        r = os.environ.get("VIDEOCONVERTER_MAX_RETRIES", "").strip()
                        if r.isdigit():
                            max_retries = int(r)
                    except Exception:
                        pass
                    if store.maybe_retry_one_failed_task(min_delay_sec=retry_delay, max_retries=max_retries):
                        continue
                    # 队列空闲时每隔一段时间打一次日志，避免用户误以为进程卡住
                    with _all_done_lock:
                        if _all_done_logged:
                            now = time.time()
                            if now - _last_idle_heartbeat_time >= _idle_heartbeat_interval:
                                logger.info("队列空闲，等待新任务…")
                                _last_idle_heartbeat_time = now
                    time.sleep(1)
                    continue

            with _all_done_lock:
                _all_done_logged = False
            idle_since = 0.0
            task_id = task["task_id"]
            task_type = task.get("task_type", "")
            logger.info("开始处理任务: %s (类型: %s)", task_id, task_type)

            with _active_worker_lock:
                _active_worker_count += 1
            try:
                if task_type == "SPLIT":
                    process_split_task(store, task)
                elif task_type == "DESUBTITLE":
                    process_desubtitle_task(store, task)
                elif task_type == "MERGE":
                    process_merge_task(store, task)
                elif task_type == "ONE_CLICK_COMPOSE":
                    process_one_click_compose_task(store, task)
                else:
                    msg = f"未知任务类型: {task_type}"
                    store.fail_task(task_id, msg)
                    logger.warning("任务 %s 失败: %s", task_id, msg)
            except InvalidSourceError as e:
                store.fail_task(task_id, str(e))
                store.add_log(task_id, "WARN", str(e))
                logger.warning("跳过无效源任务 %s: %s", task_id, e)
            except Exception as e:
                logger.exception("任务处理失败: %s", task_id)
                store.fail_task(task_id, str(e))
                store.add_log(task_id, "WARN", str(e))
            finally:
                with _active_worker_lock:
                    _active_worker_count -= 1
                logger.info("准备获取下一任务…")

        except KeyboardInterrupt:
            logger.info("收到中断，退出")
            break
        except Exception as e:
            logger.exception("工作循环异常: %s", e)
            time.sleep(5)


def run_simple_compose(
    input_file: str,
    output_dir: str,
    start_sec: float,
    end_sec: float,
    crop_bottom: int = 208,
    crop_left: int = 293,
    crop_right: int = 293,
) -> str:
    """
    简易模式：在当前目录/指定目录内，对单个视频做「切分→去字幕→合成」全流程，不经过队列。
    返回最终合成文件路径；异常时抛出。
    """
    input_path = Path(input_file)
    if not input_path.exists():
        raise FileNotFoundError(f"文件不存在: {input_file}")
    if end_sec <= 0:
        end_sec = get_duration(input_file)
    start_sec = max(0.0, start_sec)
    if start_sec >= end_sec:
        raise ValueError("开始时间必须小于结束时间")

    width, height = get_video_size(input_file)
    config = {
        "targetWidth": 1920,
        "targetHeight": 1080,
        "cropTop": 0,
        "cropBottom": crop_bottom,
        "cropLeft": crop_left,
        "cropRight": crop_right,
        "keepAudio": True,
        "audioBitrate": 192,
        "videoQuality": 23,
        "originalWidth": width,
        "originalHeight": height,
        "startTime": start_sec,
        "endTime": end_sec,
        "useHardwareAccel": True,  # 简易模式默认用 GPU：macOS VideoToolbox / Windows NVENC·QSV·AMF
    }

    logger.info("简易模式: 切分 %s (%.0f - %.0f秒), 字幕高度(裁底)=%d", input_path.name, start_sec, end_sec, crop_bottom)
    t0 = time.time()
    metadata, video_id = split_video_to_chunks(input_file, output_dir, 120.0, start_sec, end_sec)
    chunk_list = [c for c in (metadata.get("chunks") or []) if c.get("originalPath")]
    logger.info("切分完成: %d 块，开始去字幕", len(chunk_list))

    use_spinner = _is_tty()
    stop_spinner = threading.Event()
    spinner = threading.Thread(target=_desub_spinner, args=(stop_spinner,), daemon=True)

    def _stop_spinner_newline():
        stop_spinner.set()
        spinner.join(timeout=1.5)
        try:
            sys.stderr.write("\n")
            sys.stderr.flush()
        except (OSError, UnicodeEncodeError):
            pass

    if use_spinner:
        spinner.start()
    try:
        for i, ch in enumerate(chunk_list):
            rel = ch.get("originalPath", "")
            if not rel:
                continue
            chunk_path = Path(output_dir) / rel
            if not chunk_path.exists():
                continue
            chunk_id = ch.get("chunkId", "")
            out_dir_v = Path(output_dir) / video_id
            out_dir_v.mkdir(parents=True, exist_ok=True)
            output_file = out_dir_v / (Path(chunk_path).stem + "_desub.mp4")
            cfg = dict(config)
            cfg["inputPath"] = str(chunk_path)
            cfg["outputPath"] = str(output_file)
            cfg["startTime"] = 0
            cfg["endTime"] = 0
            cfg["forceKeyframeAtStart"] = True
            run_desubtitle(cfg, str(chunk_path), str(output_file))
            if use_spinner:
                _stop_spinner_newline()
            meta_path = Path(output_dir) / video_id / "metadata.json"
            if meta_path.exists():
                update_chunk_processed(str(meta_path), chunk_id, str(output_file))
            if use_spinner and i + 1 < len(chunk_list):
                stop_spinner = threading.Event()
                spinner = threading.Thread(target=_desub_spinner, args=(stop_spinner,), daemon=True)
                spinner.start()
    finally:
        if use_spinner:
            _stop_spinner_newline()

    data = load_metadata(str(Path(output_dir) / video_id / "metadata.json"))
    processed = get_processed_chunks(data)
    if not processed:
        raise RuntimeError("没有已处理的 chunk")
    start_t = processed[0]["startTime"]
    end_t = processed[-1]["endTime"]
    out_file = Path(output_dir) / f"{video_id}_merged.mp4"
    merge_chunks(data, start_t, end_t, str(out_file))
    elapsed = time.time() - t0
    src_duration_sec = end_sec - start_sec
    capacity = (src_duration_sec / 3600.0) / (elapsed / 3600.0) if elapsed > 0 else 0
    logger.info(
        "简易模式完成: %s，从切分到合成结束总时长: %s，产能预估: %.2f(HV/h)",
        out_file, _format_duration(elapsed), capacity,
    )
    return str(out_file)


def main() -> int:
    parser = argparse.ArgumentParser(
        description="VideoConverter Python Worker：从队列执行任务，或简易模式单文件/批量处理。",
        epilog="""
简易模式示例（不经过队列，指定开始/结束时间、字幕高度，便于测试）:
  单文件:  videoconverter --simple --dir . --start 0 --end 120 --crop-bottom 208 --file 视频.mp4
  批量:    videoconverter --simple --dir . --start 0 --end 60 --crop-bottom 208 --batch
  参数:    --dir 工作目录  --start/--end 秒(0=到结尾)  --crop-bottom 字幕高度  --output 输出目录

与 Java 一键合成配合（本地入队、服务器处理）:
  1) 在本地用 Java 界面「一键合成」加入队列（不要点「继续」），任务写入 ~/.videoconverter/data/queue/
  2) 将 data 目录拷到服务器（或挂载），若路径不同可用 --path-replace 旧前缀=新前缀
  3) 在服务器执行: videoconverter --data-dir /path/to/data --resume
  --resume 会解除队列暂停，相当于在界面点「继续」，worker 开始消费队列中的任务。
""",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    parser.add_argument(
        "--data-dir",
        default=None,
        help="数据目录（默认 $VIDEOCONVERTER_DATA_DIR 或 ~/.videoconverter/data）",
    )
    parser.add_argument(
        "--path-replace",
        default=None,
        help="路径前缀替换，便于本机任务拷到服务器: OLD=NEW，如 /Users/me/videos=/data/videos",
    )
    parser.add_argument(
        "--resume",
        action="store_true",
        help="启动时解除队列暂停（相当于在界面点「继续」），便于在服务器用 CLI 处理本地一键合成入队的任务",
    )
    parser.add_argument(
        "--workers",
        type=int,
        default=1,
        help="并发 worker 数（默认 1，多进程时由外部起多个 worker 进程）",
    )
    parser.add_argument(
        "--exit-when-idle",
        type=float,
        default=0,
        metavar="SEC",
        help="队列连续空闲超过 SEC 秒后退出（默认 0 表示不自动退出）。例如 5 表示所有任务做完后约 5 秒进程退出、CPU 归零",
    )
    parser.add_argument(
        "--show-schema",
        action="store_true",
        help="打印任务/队列输入格式说明（对接方如何喂数据）",
    )
    parser.add_argument(
        "--simple",
        action="store_true",
        help="简易模式：指定目录内输入开始/结束时间、字幕高度，单文件或批量处理（便于测试）",
    )
    parser.add_argument(
        "--dir",
        default=".",
        help="简易模式：工作目录（支持相对路径），不存在则自动创建，默认当前目录",
    )
    parser.add_argument(
        "--start",
        type=float,
        default=0.0,
        help="简易模式：开始时间（秒）",
    )
    parser.add_argument(
        "--end",
        type=float,
        default=0,
        help="简易模式：结束时间（秒），0 表示到视频结尾",
    )
    parser.add_argument(
        "--crop-bottom",
        type=int,
        default=208,
        help="简易模式：字幕高度（裁底像素），默认 208",
    )
    parser.add_argument(
        "--crop-left",
        type=int,
        default=293,
        help="简易模式：左侧裁剪像素，默认 293",
    )
    parser.add_argument(
        "--crop-right",
        type=int,
        default=293,
        help="简易模式：右侧裁剪像素，默认 293",
    )
    parser.add_argument(
        "--file",
        default=None,
        help="简易模式：指定一个视频文件（否则需用 --batch）",
    )
    parser.add_argument(
        "--batch",
        action="store_true",
        help="简易模式：批量处理当前目录下所有视频（.mp4/.mkv/.mov）",
    )
    parser.add_argument(
        "--output",
        default=None,
        help="简易模式：输出目录，默认 <dir>/videoconverter_out",
    )
    args = parser.parse_args()

    if args.show_schema:
        from schema import INPUT_SCHEMA
        print(INPUT_SCHEMA)
        return 0

    err = check_ffmpeg_available()
    if err:
        logger.error("%s", err)
        return 1

    if args.simple:
        base = Path(args.dir).resolve()
        if base.exists() and not base.is_dir():
            logger.error("路径已存在但不是目录: %s", base)
            return 1
        if not base.exists():
            base.mkdir(parents=True, exist_ok=True)
            logger.info("已创建工作目录: %s", base)
        out_dir = Path(args.output).resolve() if args.output else (base / "videoconverter_out")
        out_dir.mkdir(parents=True, exist_ok=True)
        if args.file:
            p = Path(args.file)
            files = [p.resolve() if p.is_absolute() else (base / p).resolve()]
            if not files[0].exists():
                logger.error("文件不存在: %s", files[0])
                return 1
        elif args.batch:
            exts = {".mp4", ".mkv", ".mov", ".avi", ".flv"}
            files = sorted([p for p in base.iterdir() if p.is_file() and p.suffix.lower() in exts])
            if not files:
                logger.error("目录下没有视频文件: %s", base)
                return 1
        else:
            logger.error("简易模式请指定 --file <路径> 或 --batch")
            return 1
        failed = 0
        for f in files:
            try:
                run_simple_compose(
                    str(f),
                    str(out_dir),
                    args.start,
                    args.end,
                    crop_bottom=args.crop_bottom,
                    crop_left=args.crop_left,
                    crop_right=args.crop_right,
                )
            except Exception as e:
                logger.exception("处理失败 %s: %s", f.name, e)
                failed += 1
        return 1 if failed else 0

    data_dir = args.data_dir or __import__("os").environ.get(
        "VIDEOCONVERTER_DATA_DIR",
        str(Path.home() / ".videoconverter" / "data"),
    )
    path_replace = None
    if args.path_replace:
        if "=" in args.path_replace:
            a, b = args.path_replace.split("=", 1)
            path_replace = (a.strip(), b.strip())
    env_replace = __import__("os").environ.get("VIDEOCONVERTER_PATH_REPLACE")
    if env_replace and "=" in env_replace and path_replace is None:
        a, b = env_replace.split("=", 1)
        path_replace = (a.strip(), b.strip())

    store = QueueStore(data_dir, path_replace=path_replace)
    logger.info("数据目录: %s", data_dir)
    logger.info("队列目录: %s", store.queue_dir)
    if path_replace:
        logger.info("路径替换: %s -> %s", path_replace[0], path_replace[1])

    # 断电/强制关机后：将无主 PROCESSING 任务重置为 PENDING，便于继续处理
    n = store.recover_orphaned_processing()
    if n > 0:
        logger.info("已恢复 %d 个孤儿任务（PROCESSING -> PENDING）", n)

    if args.resume:
        store.set_paused(False)
        logger.info("已解除队列暂停（--resume），开始处理任务")
        n_resumed = store.resume_paused_tasks()
        if n_resumed > 0:
            logger.info("已将 %d 个已暂停任务恢复为等待中", n_resumed)
        n_retry = store.retry_failed_tasks()
        if n_retry > 0:
            logger.info("已将 %d 个失败任务重置为等待中，将重新处理", n_retry)

    # 补建缺失的 MERGE：多个 video 已全部处理完但 MERGE 只在 DESUBTITLE 完成时创建，resume 时队列里可能没有这些 MERGE
    ensure_merge_tasks_for_ready_videos(store)

    stats = store.get_queue_stats()
    if stats["total"] == 0:
        logger.info("队列为空（queue 目录下无 *.json 任务文件），worker 将等待新任务")
    else:
        by_status = stats.get("by_status") or {}
        pending = by_status.get("PENDING", 0)
        logger.info(
            "队列统计: 共 %d 个任务 (PENDING=%d, 其他=%s)",
            stats["total"],
            pending,
            {k: v for k, v in by_status.items() if k != "PENDING"},
        )
        if pending == 0:
            logger.info("当前没有 PENDING 任务可处理（可能均为 COMPLETED/FAILED/PAUSED/PROCESSING），worker 将等待新任务")

    n_workers = max(1, int(args.workers))
    exit_idle_sec = max(0.0, float(args.exit_when_idle))
    if exit_idle_sec > 0:
        logger.info("队列空闲 %.0f 秒后将自动退出", exit_idle_sec)
    if n_workers > 1:
        logger.info("启动 %d 个并发 worker 线程", n_workers)
        for i in range(n_workers - 1):
            t = threading.Thread(target=work_loop, args=(store, exit_idle_sec), name=f"Worker-{i+2}", daemon=True)
            t.start()
    work_loop(store, exit_idle_sec)
    return 0


if __name__ == "__main__":
    sys.exit(main())
