"""Unit tests for JobService — Phase 8 任务 2.10.

测试 Job 领域模型、Use Cases 和 Service 层。
"""

from __future__ import annotations

import os
import sys
from pathlib import Path

os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")

import pytest

sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))

from packages.application.jobs import (
    CancelJobUseCase,
    CompleteJobCommand,
    CompleteJobUseCase,
    CreateJobCommand,
    CreateJobUseCase,
    FailJobCommand,
    FailJobUseCase,
    GetJobStatisticsUseCase,
    GetJobUseCase,
    ListJobsUseCase,
    RetryJobUseCase,
    SubmitJobUseCase,
    UpdateJobProgressCommand,
    UpdateJobProgressUseCase,
)
from packages.domain.job import Job, JobStatus, JobType

# ── Fixtures ──────────────────────────────────────────────────────────────────


class FakeJobRepo:
    """内存中的 JobRepository 测试替身。"""

    def __init__(self):
        self._store: dict[str, Job] = {}

    def create(self, job: Job) -> Job:
        self._store[job.id] = job
        return job

    def get(self, job_id: str) -> Job | None:
        return self._store.get(job_id)

    def update(self, job: Job) -> Job:
        if job.id not in self._store:
            raise ValueError(f"Job {job.id} not found")
        self._store[job.id] = job
        return job

    def list_by_project(self, project_id, *, job_type=None, status=None, limit=50, offset=0):
        results = [
            j
            for j in self._store.values()
            if j.project_id == project_id
            and (job_type is None or j.job_type == job_type or j.job_type == JobType(job_type))
            and (status is None or j.status == status or j.status == JobStatus(status))
        ]
        return results[offset : offset + limit]

    def list_by_user(self, user_id, *, job_type=None, status=None, limit=50, offset=0):
        results = [
            j
            for j in self._store.values()
            if j.created_by_user_id == user_id
            and (job_type is None or j.job_type == job_type or j.job_type == JobType(job_type))
            and (status is None or j.status == status or j.status == JobStatus(status))
        ]
        return results[offset : offset + limit]

    def count_by_project(self, project_id, *, status=None):
        return len(
            [
                j
                for j in self._store.values()
                if j.project_id == project_id
                and (status is None or j.status == status or j.status == JobStatus(status))
            ]
        )

    def find_active_by_source(self, source_id, job_type):
        jt = job_type.value if isinstance(job_type, JobType) else job_type
        for j in self._store.values():
            if (
                j.source_id == source_id
                and j.job_type.value == jt
                and j.status in (JobStatus.PENDING, JobStatus.RUNNING)
            ):
                return j
        return None


@pytest.fixture
def repo():
    return FakeJobRepo()


# ── Job 领域模型测试 ──────────────────────────────────────────────────────────


class TestJobDomainModel:
    def test_create_job(self):
        job = Job.create(
            project_id="proj-1",
            job_type=JobType.VIDEO_COMPOSE,
            payload={"plan_id": "plan-1"},
            source_id="plan-1",
            created_by_user_id="user-1",
        )
        assert job.project_id == "proj-1"
        assert job.job_type == JobType.VIDEO_COMPOSE
        assert job.status == JobStatus.PENDING
        assert job.progress == 0.0
        assert job.retry_count == 0
        assert job.max_retries == 3
        assert job.payload == {"plan_id": "plan-1"}

    def test_create_job_with_string_type(self):
        job = Job.create(project_id="proj-1", job_type="render_edit_plan")
        assert job.job_type == JobType.RENDER_EDIT_PLAN

    def test_create_job_invalid_type_raises(self):
        with pytest.raises(ValueError, match="不支持的任务类型"):
            Job.create(project_id="proj-1", job_type="invalid_type")

    def test_create_job_empty_project_id_raises(self):
        with pytest.raises(ValueError, match="project_id 不能为空"):
            Job.create(project_id="  ", job_type=JobType.VIDEO_COMPOSE)

    def test_is_terminal(self):
        job = Job.create(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE)
        assert not job.is_terminal

        job.status = JobStatus.SUCCESS
        assert job.is_terminal

        job.status = JobStatus.FAILED
        assert job.is_terminal

        job.status = JobStatus.CANCELLED
        assert job.is_terminal

    def test_is_retryable(self):
        job = Job.create(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE, max_retries=2)
        job.status = JobStatus.FAILED
        assert job.is_retryable

        job.retry_count = 2
        assert not job.is_retryable

    def test_mark_running(self):
        job = Job.create(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE)
        job.mark_running("初始化中")
        assert job.status == JobStatus.RUNNING
        assert job.current_stage == "初始化中"
        assert job.started_at is not None

    def test_mark_success(self):
        job = Job.create(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE)
        job.mark_running()
        job.mark_success(result={"output_url": "https://example.com/video.mp4"})
        assert job.status == JobStatus.SUCCESS
        assert job.progress == 100.0
        assert job.current_stage == "完成"
        assert job.result == {"output_url": "https://example.com/video.mp4"}
        assert job.completed_at is not None

    def test_mark_failed(self):
        job = Job.create(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE)
        job.mark_running()
        job.mark_failed("FFmpeg 执行失败")
        assert job.status == JobStatus.FAILED
        assert job.error_message == "FFmpeg 执行失败"
        assert job.completed_at is not None

    def test_mark_cancelled(self):
        job = Job.create(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE)
        job.mark_cancelled()
        assert job.status == JobStatus.CANCELLED
        assert job.current_stage == "已取消"

    def test_update_progress(self):
        job = Job.create(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE)
        job.update_progress(50.0, "合成中")
        assert job.progress == 50.0
        assert job.current_stage == "合成中"

    def test_update_progress_invalid_value_raises(self):
        job = Job.create(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE)
        with pytest.raises(ValueError, match="进度必须在 0~100 之间"):
            job.update_progress(150.0)

    def test_prepare_retry(self):
        job = Job.create(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE, max_retries=3)
        job.mark_running()
        job.mark_failed("临时错误")
        job.prepare_retry()
        assert job.status == JobStatus.PENDING
        assert job.retry_count == 1
        assert job.progress == 0.0
        assert job.error_message == ""
        assert "第 1 次重试" in job.current_stage

    def test_prepare_retry_exceeded_raises(self):
        job = Job.create(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE, max_retries=1)
        job.mark_running()
        job.mark_failed("错误")
        job.prepare_retry()  # retry_count = 1, max_retries = 1 → not retryable
        with pytest.raises(ValueError, match="任务不可重试"):
            job.prepare_retry()

    def test_invalid_transition_raises(self):
        job = Job.create(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE)
        job.mark_running()
        job.mark_success()
        with pytest.raises(ValueError, match="非法状态转换"):
            job.transition_to(JobStatus.RUNNING)  # success → running 不合法（终态不可转换）

    def test_to_dict(self):
        job = Job.create(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE)
        d = job.to_dict()
        assert d["project_id"] == "proj-1"
        assert d["job_type"] == "video_compose"
        assert d["status"] == "pending"
        assert "is_retryable" in d


# ── Use Case 测试 ─────────────────────────────────────────────────────────────


class TestCreateJobUseCase:
    def test_execute(self, repo):
        use_case = CreateJobUseCase(repo)
        cmd = CreateJobCommand(
            project_id="proj-1",
            job_type=JobType.VIDEO_COMPOSE,
            payload={"plan_id": "plan-1"},
            source_id="plan-1",
            created_by_user_id="user-1",
        )
        job = use_case.execute(cmd)
        assert job.id in repo._store
        assert job.status == JobStatus.PENDING

    def test_execute_with_string_type(self, repo):
        use_case = CreateJobUseCase(repo)
        cmd = CreateJobCommand(
            project_id="proj-1",
            job_type="video_compose",
        )
        job = use_case.execute(cmd)
        assert job.job_type == JobType.VIDEO_COMPOSE


class TestSubmitJobUseCase:
    def test_submit_pending_job(self, repo):
        # 先创建
        create_uc = CreateJobUseCase(repo)
        job = create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))

        # 提交
        submit_uc = SubmitJobUseCase(repo)
        submitted = submit_uc.execute(job.id, celery_task_id="celery-abc")
        assert submitted.status == JobStatus.RUNNING
        assert submitted.celery_task_id == "celery-abc"
        assert submitted.started_at is not None

    def test_submit_non_pending_raises(self, repo):
        create_uc = CreateJobUseCase(repo)
        job = create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
        job.mark_running()
        repo.update(job)

        submit_uc = SubmitJobUseCase(repo)
        with pytest.raises(ValueError, match="只有 pending 状态"):
            submit_uc.execute(job.id)

    def test_submit_nonexistent_raises(self, repo):
        submit_uc = SubmitJobUseCase(repo)
        with pytest.raises(ValueError, match="任务不存在"):
            submit_uc.execute("nonexistent-id")


class TestUpdateJobProgressUseCase:
    def test_update_running_job(self, repo):
        create_uc = CreateJobUseCase(repo)
        job = create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
        job.mark_running()
        repo.update(job)

        progress_uc = UpdateJobProgressUseCase(repo)
        updated = progress_uc.execute(UpdateJobProgressCommand(job_id=job.id, progress=75.0, current_stage="渲染中"))
        assert updated.progress == 75.0
        assert updated.current_stage == "渲染中"

    def test_update_non_running_raises(self, repo):
        create_uc = CreateJobUseCase(repo)
        job = create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))

        progress_uc = UpdateJobProgressUseCase(repo)
        with pytest.raises(ValueError, match="只有 running 状态"):
            progress_uc.execute(UpdateJobProgressCommand(job_id=job.id, progress=50.0))


class TestCompleteJobUseCase:
    def test_complete_running_job(self, repo):
        create_uc = CreateJobUseCase(repo)
        job = create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
        job.mark_running()
        repo.update(job)

        complete_uc = CompleteJobUseCase(repo)
        completed = complete_uc.execute(CompleteJobCommand(job_id=job.id, result={"url": "https://example.com/v.mp4"}))
        assert completed.status == JobStatus.SUCCESS
        assert completed.progress == 100.0
        assert completed.result == {"url": "https://example.com/v.mp4"}

    def test_complete_non_running_raises(self, repo):
        create_uc = CreateJobUseCase(repo)
        job = create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
        job.mark_running()
        job.mark_success()  # 已经是终态

        complete_uc = CompleteJobUseCase(repo)
        with pytest.raises(ValueError, match="只有 running/pending 状态"):
            complete_uc.execute(CompleteJobCommand(job_id=job.id))


class TestFailJobUseCase:
    def test_fail_job(self, repo):
        create_uc = CreateJobUseCase(repo)
        job = create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
        job.mark_running()
        repo.update(job)

        fail_uc = FailJobUseCase(repo)
        failed = fail_uc.execute(FailJobCommand(job_id=job.id, error_message="磁盘空间不足"))
        assert failed.status == JobStatus.FAILED
        assert failed.error_message == "磁盘空间不足"


class TestRetryJobUseCase:
    def test_retry_failed_job(self, repo):
        create_uc = CreateJobUseCase(repo)
        job = create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE, max_retries=3))
        job.mark_running()
        job.mark_failed("网络超时")
        repo.update(job)

        retry_uc = RetryJobUseCase(repo)
        retried = retry_uc.execute(job.id)
        assert retried.status == JobStatus.PENDING
        assert retried.retry_count == 1
        assert retried.error_message == ""

    def test_retry_non_failed_raises(self, repo):
        create_uc = CreateJobUseCase(repo)
        job = create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))

        retry_uc = RetryJobUseCase(repo)
        with pytest.raises(ValueError, match="任务不可重试"):
            retry_uc.execute(job.id)


class TestCancelJobUseCase:
    def test_cancel_pending_job(self, repo):
        create_uc = CreateJobUseCase(repo)
        job = create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))

        cancel_uc = CancelJobUseCase(repo)
        cancelled = cancel_uc.execute(job.id)
        assert cancelled.status == JobStatus.CANCELLED

    def test_cancel_completed_raises(self, repo):
        create_uc = CreateJobUseCase(repo)
        job = create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
        job.mark_running()
        job.mark_success()
        repo.update(job)

        cancel_uc = CancelJobUseCase(repo)
        with pytest.raises(ValueError, match="已处于终态"):
            cancel_uc.execute(job.id)


class TestGetJobUseCase:
    def test_get_existing_job(self, repo):
        create_uc = CreateJobUseCase(repo)
        job = create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))

        get_uc = GetJobUseCase(repo)
        fetched = get_uc.execute(job.id)
        assert fetched is not None
        assert fetched.id == job.id

    def test_get_nonexistent_returns_none(self, repo):
        get_uc = GetJobUseCase(repo)
        assert get_uc.execute("nonexistent") is None


class TestListJobsUseCase:
    def test_list_by_project(self, repo):
        create_uc = CreateJobUseCase(repo)
        create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
        create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.RENDER_EDIT_PLAN))
        create_uc.execute(CreateJobCommand(project_id="proj-2", job_type=JobType.VIDEO_COMPOSE))

        list_uc = ListJobsUseCase(repo)
        jobs = list_uc.execute(project_id="proj-1")
        assert len(jobs) == 2

    def test_list_by_project_with_type_filter(self, repo):
        create_uc = CreateJobUseCase(repo)
        create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
        create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.RENDER_EDIT_PLAN))

        list_uc = ListJobsUseCase(repo)
        jobs = list_uc.execute(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE)
        assert len(jobs) == 1
        assert jobs[0].job_type == JobType.VIDEO_COMPOSE

    def test_list_by_project_with_status_filter(self, repo):
        create_uc = CreateJobUseCase(repo)
        create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
        j2 = create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
        j2.mark_running()
        repo.update(j2)

        list_uc = ListJobsUseCase(repo)
        running_jobs = list_uc.execute(project_id="proj-1", status=JobStatus.RUNNING)
        assert len(running_jobs) == 1

    def test_list_by_user(self, repo):
        create_uc = CreateJobUseCase(repo)
        create_uc.execute(
            CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE, created_by_user_id="user-1")
        )
        create_uc.execute(
            CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE, created_by_user_id="user-2")
        )

        list_uc = ListJobsUseCase(repo)
        jobs = list_uc.execute(user_id="user-1")
        assert len(jobs) == 1

    def test_list_without_project_or_user_raises(self, repo):
        list_uc = ListJobsUseCase(repo)
        with pytest.raises(ValueError, match="必须指定"):
            list_uc.execute()


class TestGetJobStatisticsUseCase:
    def test_statistics(self, repo):
        create_uc = CreateJobUseCase(repo)
        create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
        j2 = create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
        j2.mark_running()
        repo.update(j2)
        j3 = create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
        j3.mark_running()
        j3.mark_success()
        repo.update(j3)
        j4 = create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
        j4.mark_running()
        j4.mark_failed("err")
        repo.update(j4)

        stats_uc = GetJobStatisticsUseCase(repo)
        stats = stats_uc.execute("proj-1")
        assert stats["total"] == 4
        assert stats["pending"] == 1
        assert stats["running"] == 1
        assert stats["success"] == 1
        assert stats["failed"] == 1


# ── JobService 集成测试 ───────────────────────────────────────────────────────


class TestJobService:
    def test_create_compose_job(self, repo):
        from app.services.job_service import JobService

        svc = JobService(repo)
        job = svc.create_compose_job(
            project_id="proj-1",
            plan_id="plan-1",
            user_id="user-1",
        )
        assert job.job_type == JobType.VIDEO_COMPOSE
        assert job.payload == {"plan_id": "plan-1"}
        assert job.source_id == "plan-1"
        assert job.created_by_user_id == "user-1"

    def test_create_render_job(self, repo):
        from app.services.job_service import JobService

        svc = JobService(repo)
        job = svc.create_render_job(
            project_id="proj-1",
            plan_id="plan-1",
            user_id="user-1",
        )
        assert job.job_type == JobType.RENDER_EDIT_PLAN

    def test_submit_compose_if_not_exists_creates_new(self, repo):
        from app.services.job_service import JobService

        svc = JobService(repo)
        job, created = svc.submit_compose_if_not_exists("proj-1", "plan-1", "user-1", "celery-1")
        assert created is True
        assert job.status == JobStatus.RUNNING
        assert job.celery_task_id == "celery-1"

    def test_submit_compose_if_not_exists_returns_existing(self, repo):
        from app.services.job_service import JobService

        svc = JobService(repo)
        job1, created1 = svc.submit_compose_if_not_exists("proj-1", "plan-1", "user-1", "celery-1")
        job2, created2 = svc.submit_compose_if_not_exists("proj-1", "plan-1", "user-1", "celery-2")
        assert created2 is False
        assert job2.id == job1.id

    def test_full_lifecycle(self, repo):
        """完整生命周期测试：创建 → 提交 → 更新进度 → 完成。"""
        from app.services.job_service import JobService

        svc = JobService(repo)

        # 创建
        job = svc.create_compose_job("proj-1", "plan-1", "user-1")
        assert job.status == JobStatus.PENDING

        # 提交
        job = svc.submit_job(job.id, "celery-xyz")
        assert job.status == JobStatus.RUNNING
        assert job.celery_task_id == "celery-xyz"

        # 更新进度
        job = svc.update_progress(job.id, 50.0, "合成中")
        assert job.progress == 50.0

        job = svc.update_progress(job.id, 80.0, "上传结果")
        assert job.progress == 80.0

        # 完成
        job = svc.complete_job(job.id, result={"url": "https://example.com/v.mp4"})
        assert job.status == JobStatus.SUCCESS
        assert job.progress == 100.0

    def test_full_lifecycle_with_retry(self, repo):
        """带重试的完整生命周期测试。"""
        from app.services.job_service import JobService

        svc = JobService(repo)

        # 创建并执行
        job = svc.create_compose_job("proj-1", "plan-1", "user-1", max_retries=2)
        job = svc.submit_job(job.id, "celery-1")

        # 失败
        job = svc.fail_job(job.id, "临时网络错误")
        assert job.status == JobStatus.FAILED

        # 重试
        job = svc.retry_job(job.id)
        assert job.status == JobStatus.PENDING
        assert job.retry_count == 1

        # 再次提交
        job = svc.submit_job(job.id, "celery-2")
        assert job.status == JobStatus.RUNNING

        # 成功
        job = svc.complete_job(job.id, result={"url": "ok"})
        assert job.status == JobStatus.SUCCESS
