"""모션 카드뉴스 렌더 큐 (파일 기반 작업 큐).

IDS Phase 5 — HTML→MP4 모션 카드뉴스 백그라운드 처리 큐
- 파일 기반 작업 큐 (/tmp/motion_render_queue/ 또는 MOTION_QUEUE_DIR 환경변수)
- ThreadPoolExecutor 기반 병렬 처리 (기본 max_concurrent=2)
- 자동 재시도 (기본 max_retries=2)
- 작업 타임아웃 처리
- CLI: python3 motion_render_queue.py --process [--max-concurrent N]

사용 예시:
    # 작업 큐에 추가
    job_id = enqueue({
        "frame_paths": ["/path/to/frame1.png", "/path/to/frame2.png"],
        "output_path": "/path/to/output.mp4",
        "size": [1080, 1080],
        "effect": "fade",
        "fps": 30,
        "duration_per_frame": 1.5,
        "bgm_path": None,
    })

    # 큐 처리
    summary = process_queue(max_concurrent=2, timeout_per_job=120, max_retries=2)
    print(summary)
"""
from __future__ import annotations

import argparse
import json
import os
import threading
import time
import uuid
import warnings
from concurrent.futures import ThreadPoolExecutor
from enum import Enum
from pathlib import Path

_DEFAULT_QUEUE_DIR: str = os.environ.get("MOTION_QUEUE_DIR", "/tmp/motion_render_queue")


class JobStatus(Enum):
    """작업 상태 열거형."""

    PENDING = "PENDING"
    RUNNING = "RUNNING"
    SUCCESS = "SUCCESS"
    FAILED = "FAILED"
    TIMEOUT = "TIMEOUT"


def _get_queue_dir() -> Path:
    """큐 디렉토리 경로를 반환하고 없으면 생성합니다."""
    queue_dir = Path(os.environ.get("MOTION_QUEUE_DIR", _DEFAULT_QUEUE_DIR))
    queue_dir.mkdir(parents=True, exist_ok=True)
    (queue_dir / "jobs").mkdir(exist_ok=True)
    return queue_dir


def enqueue(job: dict) -> str:  # type: ignore[type-arg]
    """작업을 큐에 추가합니다.

    Args:
        job: 작업 딕셔너리.
             필수 키: frame_paths, output_path, size, effect, fps,
                      duration_per_frame, bgm_path

    Returns:
        고유 작업 ID (UUID4 문자열)
    """
    job_id = str(uuid.uuid4())
    queue_dir = _get_queue_dir()

    job_data = {
        **job,
        "job_id": job_id,
        "status": JobStatus.PENDING.value,
        "created_at": time.time(),
        "retry_count": 0,
    }

    job_file = queue_dir / "jobs" / f"{job_id}.json"
    job_file.write_text(json.dumps(job_data, default=str), encoding="utf-8")
    return job_id


def _load_job(job_file: Path) -> dict:  # type: ignore[type-arg]
    """작업 파일을 로드합니다."""
    return json.loads(job_file.read_text(encoding="utf-8"))


def _save_job(job_data: dict, queue_dir: Path) -> None:  # type: ignore[type-arg]
    """작업 상태를 파일에 저장합니다."""
    job_id = job_data["job_id"]
    job_file = queue_dir / "jobs" / f"{job_id}.json"
    job_file.write_text(json.dumps(job_data, default=str), encoding="utf-8")


def _get_render_motion():  # type: ignore[return]
    """render_motion 함수를 동적으로 로드합니다.

    하이픈이 포함된 스킬 패키지명(motion-cardnews-ko)을 처리합니다.
    """
    import importlib.util as _ilu
    import sys

    skill_dir = Path(__file__).parent.parent / "skills" / "motion-cardnews-ko"
    pkg_name = "motion_cardnews_ko"

    # 패키지가 이미 로드되어 있으면 재사용
    if pkg_name + ".render" in sys.modules:
        return sys.modules[pkg_name + ".render"].render_motion

    # 서브모듈들을 먼저 로드 (의존성 순서)
    for submod in ("sizes", "effects", "frames", "render", "ocr", "bgm"):
        mod_name = f"{pkg_name}.{submod}"
        if mod_name not in sys.modules:
            spec = _ilu.spec_from_file_location(
                mod_name,
                skill_dir / f"{submod}.py",
            )
            assert spec is not None and spec.loader is not None, f"failed to load submodule {submod} from {skill_dir}"
            mod = _ilu.module_from_spec(spec)
            mod.__package__ = pkg_name  # type: ignore[assignment]
            sys.modules[mod_name] = mod
            try:
                spec.loader.exec_module(mod)  # type: ignore[union-attr]
            except Exception:
                del sys.modules[mod_name]
                raise

    # __init__.py 로드
    if pkg_name not in sys.modules:
        init_spec = _ilu.spec_from_file_location(
            pkg_name,
            skill_dir / "__init__.py",
            submodule_search_locations=[str(skill_dir)],
        )
        assert init_spec is not None and init_spec.loader is not None, f"failed to load package {pkg_name}"
        pkg_mod = _ilu.module_from_spec(init_spec)
        pkg_mod.__package__ = pkg_name  # type: ignore[assignment]
        sys.modules[pkg_name] = pkg_mod
        try:
            init_spec.loader.exec_module(pkg_mod)  # type: ignore[union-attr]
        except Exception:
            del sys.modules[pkg_name]
            raise

    return sys.modules[f"{pkg_name}.render"].render_motion


def _execute_job(job_data: dict) -> dict:  # type: ignore[type-arg]
    """단일 작업을 실행합니다.

    Returns:
        업데이트된 job_data 딕셔너리
    """
    render_motion = _get_render_motion()

    frame_paths = [Path(p) for p in job_data.get("frame_paths", [])]
    output_path = Path(job_data.get("output_path", "output.mp4"))

    size_raw = job_data.get("size", [1080, 1080])
    if isinstance(size_raw, (list, tuple)) and len(size_raw) >= 2:
        size = (int(size_raw[0]), int(size_raw[1]))
    else:
        size = (1080, 1080)

    bgm_raw = job_data.get("bgm_path", None)
    bgm_path = Path(bgm_raw) if bgm_raw else None

    result = render_motion(
        frame_paths=frame_paths,
        output_path=output_path,
        size=size,
        effect=str(job_data.get("effect", "fade")),
        fps=int(job_data.get("fps", 30)),
        duration_per_frame=float(job_data.get("duration_per_frame", 1.0)),
        bgm_path=bgm_path,
    )

    job_data["status"] = JobStatus.SUCCESS.value
    job_data["completed_at"] = time.time()
    _ = result
    return job_data


def _run_job_with_retry(
    job_data: dict,  # type: ignore[type-arg]
    queue_dir: Path,
    timeout_per_job: int,
    max_retries: int,
    summary: dict,  # type: ignore[type-arg]
    lock: threading.Lock,
) -> None:
    """단일 작업을 재시도 로직과 함께 직접 실행합니다 (스레드 내에서 호출).

    타임아웃은 threading.Timer로 구현합니다.
    summary 접근은 lock으로 보호합니다.
    """
    retry_count = 0
    last_error: str = ""

    while retry_count <= max_retries:
        # RUNNING 상태로 업데이트
        job_data["status"] = JobStatus.RUNNING.value
        _save_job(job_data, queue_dir)

        # 타임아웃 플래그
        timed_out = [False]
        result_holder: list[dict] = []  # type: ignore[type-arg]
        exc_holder: list[Exception] = []

        def worker() -> None:
            try:
                updated = _execute_job(dict(job_data))
                result_holder.append(updated)
            except Exception as e:
                exc_holder.append(e)

        t = threading.Thread(target=worker, daemon=True)
        t.start()
        t.join(timeout=timeout_per_job)

        if t.is_alive():
            # タイムアウト
            timed_out[0] = True
            job_data["status"] = JobStatus.TIMEOUT.value
            job_data["error"] = f"타임아웃 ({timeout_per_job}초)"
            _save_job(job_data, queue_dir)
            with lock:
                summary["timeout"] = summary.get("timeout", 0) + 1
                summary["failed"] = summary.get("failed", 0) + 1
            return

        if result_holder:
            # 성공
            updated_job = result_holder[0]
            _save_job(updated_job, queue_dir)
            with lock:
                summary["success"] = summary.get("success", 0) + 1
                if retry_count > 0:
                    summary["retry_total"] = summary.get("retry_total", 0) + retry_count
            return

        # 실패 — 재시도
        if exc_holder:
            last_error = str(exc_holder[0])
        else:
            last_error = "알 수 없는 오류"

        retry_count += 1
        job_data["retry_count"] = retry_count
        job_data["last_error"] = last_error

        if retry_count <= max_retries:
            with lock:
                summary["retry_total"] = summary.get("retry_total", 0) + 1

    # 모든 재시도 소진 → FAILED
    job_data["status"] = JobStatus.FAILED.value
    job_data["last_error"] = last_error
    _save_job(job_data, queue_dir)
    with lock:
        summary["failed"] = summary.get("failed", 0) + 1


def process_queue(
    *,
    max_concurrent: int = 2,
    timeout_per_job: int = 120,
    max_retries: int = 2,
) -> dict:  # type: ignore[type-arg]
    """큐의 모든 PENDING 작업을 처리합니다.

    Args:
        max_concurrent: 동시 처리할 최대 작업 수 (ThreadPoolExecutor max_workers)
        timeout_per_job: 작업당 최대 대기 시간(초)
        max_retries: 실패 시 최대 재시도 횟수

    Returns:
        처리 요약 딕셔너리:
        {
            "total": int,
            "success": int,
            "failed": int,
            "timeout": int,
            "retry_total": int,
        }
    """
    queue_dir = _get_queue_dir()
    job_files = sorted((queue_dir / "jobs").glob("*.json"))

    pending_jobs: list[dict] = []  # type: ignore[type-arg]
    for jf in job_files:
        try:
            jd = _load_job(jf)
            if jd.get("status") == JobStatus.PENDING.value:
                pending_jobs.append(jd)
        except Exception as e:
            warnings.warn(f"작업 파일 로드 실패 ({jf.name}): {e}", UserWarning)

    summary: dict = {  # type: ignore[type-arg]
        "total": len(pending_jobs),
        "success": 0,
        "failed": 0,
        "timeout": 0,
        "retry_total": 0,
        "started_at": time.time(),
    }

    if not pending_jobs:
        return summary

    # RUNNING 상태로 마킹
    for jd in pending_jobs:
        jd["status"] = JobStatus.RUNNING.value
        _save_job(jd, queue_dir)

    lock = threading.Lock()

    with ThreadPoolExecutor(max_workers=max_concurrent) as executor:
        futures = [
            executor.submit(
                _run_job_with_retry,
                jd,
                queue_dir,
                timeout_per_job,
                max_retries,
                summary,
                lock,
            )
            for jd in pending_jobs
        ]
        for f in futures:
            f.result(timeout=timeout_per_job * (max_retries + 2))

    return summary


def list_jobs() -> list[dict]:  # type: ignore[type-arg]
    """큐의 모든 작업 상태를 반환합니다."""
    queue_dir = _get_queue_dir()
    jobs: list[dict] = []  # type: ignore[type-arg]
    for jf in sorted((queue_dir / "jobs").glob("*.json")):
        try:
            jobs.append(_load_job(jf))
        except Exception:
            pass
    return jobs


def main() -> None:
    """CLI 진입점."""
    parser = argparse.ArgumentParser(
        description="모션 카드뉴스 렌더 큐 처리기",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
사용 예시:
  # 큐 처리 (기본 설정)
  python3 motion_render_queue.py --process

  # 병렬 워커 수 지정
  python3 motion_render_queue.py --process --max-concurrent 4

  # 작업 목록 조회
  python3 motion_render_queue.py --list
        """,
    )
    parser.add_argument("--process", action="store_true", help="큐의 PENDING 작업을 처리합니다.")
    parser.add_argument("--list", action="store_true", help="큐의 모든 작업 상태를 출력합니다.")
    parser.add_argument("--max-concurrent", type=int, default=2, metavar="N", help="동시 처리할 최대 작업 수 (기본값: 2)")
    parser.add_argument("--timeout", type=int, default=120, metavar="SEC", help="작업당 타임아웃(초) (기본값: 120)")
    parser.add_argument("--max-retries", type=int, default=2, help="최대 재시도 횟수 (기본값: 2)")
    parser.add_argument(
        "--queue-dir", type=str, default=None, metavar="DIR",
        help=f"큐 디렉토리 경로 (기본값: {_DEFAULT_QUEUE_DIR})"
    )

    args = parser.parse_args()

    if args.queue_dir:
        os.environ["MOTION_QUEUE_DIR"] = args.queue_dir

    if args.list:
        jobs = list_jobs()
        if not jobs:
            print("큐가 비어있습니다.")
            return
        for jd in jobs:
            print(f"[{jd.get('status', '?')}] {jd.get('job_id', '')} — {jd.get('output_path', '')}")
        return

    if args.process:
        print(f"큐 처리 시작 (max_concurrent={args.max_concurrent})...")
        result = process_queue(
            max_concurrent=args.max_concurrent,
            timeout_per_job=args.timeout,
            max_retries=args.max_retries,
        )
        print("처리 완료:")
        for k, v in result.items():
            print(f"  {k}: {v}")
        return

    parser.print_help()


if __name__ == "__main__":
    main()
