"""剪辑计划生成 API 单元测试 — Phase 8 任务 2.05.

覆盖 2 个新端点：
  POST /api/v1/edit-plans/{id}/generate          — 触发剪辑渲染生成
  GET  /api/v1/edit-plans/{id}/generation-status  — 查询生成进度

使用 FastAPI TestClient + Stub Repository + dependency_overrides.
"""

from __future__ import annotations

import os
import sys
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Optional
from unittest.mock import MagicMock, patch

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

import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient

sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "api"))

from packages.domain.edit_plan import EditPlan, EditPlanStatus
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus

# ── Stub Repositories ─────────────────────────────────────────────────────────


class StubEditPlanRepository:
    """内存中模拟 EditPlan 仓储"""

    def __init__(self) -> None:
        self._store: dict[str, EditPlan] = {}

    def list_all(
        self,
        *,
        status: Optional[EditPlanStatus] = None,
        skip: int = 0,
        limit: int = 50,
    ) -> list[EditPlan]:
        items = list(self._store.values())
        if status:
            items = [p for p in items if p.status == status]
        items.sort(key=lambda p: p.created_at, reverse=True)
        return items[skip : skip + limit]

    def list_by_template(
        self,
        template_id: str,
        *,
        status: Optional[EditPlanStatus] = None,
        skip: int = 0,
        limit: int = 50,
    ) -> list[EditPlan]:
        items = [p for p in self._store.values() if p.template_id == template_id]
        if status:
            items = [p for p in items if p.status == status]
        items.sort(key=lambda p: p.created_at, reverse=True)
        return items[skip : skip + limit]

    def get(self, plan_id: str) -> Optional[EditPlan]:
        return self._store.get(plan_id)

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

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

    def delete(self, plan_id: str) -> bool:
        if plan_id in self._store:
            del self._store[plan_id]
            return True
        return False

    def count(self, *, status: Optional[EditPlanStatus] = None) -> int:
        items = list(self._store.values())
        if status:
            items = [p for p in items if p.status == status]
        return len(items)


class StubEditPlanClipRepository:
    """内存中模拟 EditPlanClip 仓储"""

    def __init__(self) -> None:
        self._store: dict[str, EditPlanClip] = {}

    def list_by_plan(
        self,
        plan_id: str,
        *,
        status: Optional[EditPlanClipStatus] = None,
        skip: int = 0,
        limit: int = 100,
    ) -> list[EditPlanClip]:
        items = [c for c in self._store.values() if c.plan_id == plan_id]
        if status:
            items = [c for c in items if c.status == status]
        items.sort(key=lambda c: c.order)
        return items[skip : skip + limit]

    def get(self, clip_id: str) -> Optional[EditPlanClip]:
        return self._store.get(clip_id)

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

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

    def delete(self, clip_id: str) -> bool:
        if clip_id in self._store:
            del self._store[clip_id]
            return True
        return False

    def delete_by_plan(self, plan_id: str) -> int:
        to_delete = [c.id for c in self._store.values() if c.plan_id == plan_id]
        for cid in to_delete:
            del self._store[cid]
        return len(to_delete)

    def count(
        self,
        plan_id: Optional[str] = None,
        *,
        status: Optional[EditPlanClipStatus] = None,
    ) -> int:
        items = list(self._store.values())
        if plan_id:
            items = [c for c in items if c.plan_id == plan_id]
        if status:
            items = [c for c in items if c.status == status]
        return len(items)


class StubGenerationTaskRepository:
    """内存中模拟 GenerationTask 仓储"""

    def __init__(self) -> None:
        self._store: dict[str, Any] = {}

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

    def get(self, task_id: str) -> Optional[Any]:
        return self._store.get(task_id)

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

    def list_by_project(self, project_id: str) -> list[Any]:
        return [t for t in self._store.values() if t.project_id == project_id]

    def list_by_user(self, user_id: str) -> list[Any]:
        return [t for t in self._store.values() if t.created_by_user_id == user_id]

    def count_by_user(self, user_id: str) -> int:
        return len([t for t in self._store.values() if t.created_by_user_id == user_id])

    def list_recent_by_user(self, user_id: str, limit: int = 5) -> list[Any]:
        items = [t for t in self._store.values() if t.created_by_user_id == user_id]
        items.sort(key=lambda t: t.created_at, reverse=True)
        return items[:limit]


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


@dataclass
class FakeUser:
    id: str = "user-001"
    email: str = "test@example.com"


@dataclass
class FakeAuthenticatedUser:
    user: FakeUser = field(default_factory=FakeUser)
    session_id: str | None = None
    token_type: str | None = None


@pytest.fixture
def plan_repo() -> StubEditPlanRepository:
    return StubEditPlanRepository()


@pytest.fixture
def clip_repo() -> StubEditPlanClipRepository:
    return StubEditPlanClipRepository()


@pytest.fixture
def gen_task_repo() -> StubGenerationTaskRepository:
    return StubGenerationTaskRepository()


@pytest.fixture
def app(
    plan_repo: StubEditPlanRepository,
    clip_repo: StubEditPlanClipRepository,
    gen_task_repo: StubGenerationTaskRepository,
) -> FastAPI:
    """构建测试 FastAPI 应用，注入 Stub Repository"""
    import app.services.edit_plan_service as service_module
    from app.api.routes.edit_plans import router
    from app.auth import get_current_user
    from app.dependencies import get_db_session

    # 替换 Repository 类
    original_plan_repo = service_module.SQLAlchemyEditPlanRepository
    original_clip_repo = service_module.SQLAlchemyEditPlanClipRepository
    original_gen_repo = service_module.SQLAlchemyGenerationTaskRepository

    service_module.SQLAlchemyEditPlanRepository = lambda session: plan_repo
    service_module.SQLAlchemyEditPlanClipRepository = lambda session: clip_repo
    service_module.SQLAlchemyGenerationTaskRepository = lambda session: gen_task_repo

    test_app = FastAPI()
    test_app.include_router(router, prefix="/api/v1/edit-plans")

    def override_get_db_session():
        yield MagicMock()

    def override_get_current_user():
        return FakeAuthenticatedUser()

    test_app.dependency_overrides[get_db_session] = override_get_db_session
    test_app.dependency_overrides[get_current_user] = override_get_current_user

    yield test_app

    # 恢复
    service_module.SQLAlchemyEditPlanRepository = original_plan_repo
    service_module.SQLAlchemyEditPlanClipRepository = original_clip_repo
    service_module.SQLAlchemyGenerationTaskRepository = original_gen_repo
    test_app.dependency_overrides.clear()


@pytest.fixture
def client(app: FastAPI) -> TestClient:
    return TestClient(app)


def _make_plan(
    name: str = "测试计划",
    template_id: str = "tmpl-001",
    status: EditPlanStatus = EditPlanStatus.DRAFT,
    **kwargs: Any,
) -> EditPlan:
    plan = EditPlan.create(template_id=template_id, name=name, **kwargs)
    plan.status = status
    return plan


def _make_clip(
    plan_id: str,
    clip_type: str = "MAIN",
    order: int = 1,
    asset_id: str = "assets/video.mp4",
    status: EditPlanClipStatus = EditPlanClipStatus.PENDING,
    **kwargs: Any,
) -> EditPlanClip:
    clip = EditPlanClip.create(
        plan_id=plan_id,
        clip_type=clip_type,
        order=order,
        asset_id=asset_id,
        **kwargs,
    )
    clip.status = status
    return clip


# ── POST /api/v1/edit-plans/{id}/generate ─────────────────────────────────────


class TestGeneratePlan:
    def test_generate_success(
        self,
        client: TestClient,
        plan_repo: StubEditPlanRepository,
        clip_repo: StubEditPlanClipRepository,
    ) -> None:
        """editing 状态 + pending 片段 → 触发成功"""
        plan = _make_plan(status=EditPlanStatus.EDITING)
        plan_repo.create(plan)
        clip = _make_clip(plan.id, order=1)
        clip_repo.create(clip)

        with patch("app.api.routes.edit_plans.celery_app") as mock_celery:
            mock_celery.send_task = MagicMock()
            resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")

        assert resp.status_code == 200
        data = resp.json()
        assert data["plan_id"] == plan.id
        assert data["plan_status"] == "rendering"
        assert data["clip_count"] == 1
        assert "generation_task_id" in data

        # 验证计划状态已更新
        updated = plan_repo.get(plan.id)
        assert updated.status == EditPlanStatus.RENDERING

        # 验证片段状态已更新为 ready
        updated_clip = clip_repo.get(clip.id)
        assert updated_clip.status == EditPlanClipStatus.READY

    def test_generate_not_found(self, client: TestClient) -> None:
        """计划不存在 → 404"""
        resp = client.post("/api/v1/edit-plans/nonexistent/generate")
        assert resp.status_code == 404
        assert "不存在" in resp.json()["detail"]

    def test_generate_wrong_status_draft(
        self,
        client: TestClient,
        plan_repo: StubEditPlanRepository,
    ) -> None:
        """draft 状态 → 400"""
        plan = _make_plan(status=EditPlanStatus.DRAFT)
        plan_repo.create(plan)
        resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")
        assert resp.status_code == 400
        assert "editing" in resp.json()["detail"]

    def test_generate_wrong_status_rendering(
        self,
        client: TestClient,
        plan_repo: StubEditPlanRepository,
    ) -> None:
        """rendering 状态 → 400"""
        plan = _make_plan(status=EditPlanStatus.RENDERING)
        plan_repo.create(plan)
        resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")
        assert resp.status_code == 400

    def test_generate_wrong_status_completed(
        self,
        client: TestClient,
        plan_repo: StubEditPlanRepository,
    ) -> None:
        """completed 状态 → 400"""
        plan = _make_plan(status=EditPlanStatus.COMPLETED)
        plan_repo.create(plan)
        resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")
        assert resp.status_code == 400

    def test_generate_no_clips(
        self,
        client: TestClient,
        plan_repo: StubEditPlanRepository,
    ) -> None:
        """editing 状态但没有片段 → 400"""
        plan = _make_plan(status=EditPlanStatus.EDITING)
        plan_repo.create(plan)
        resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")
        assert resp.status_code == 400
        assert "片段" in resp.json()["detail"]

    def test_generate_with_ready_clips(
        self,
        client: TestClient,
        plan_repo: StubEditPlanRepository,
        clip_repo: StubEditPlanClipRepository,
    ) -> None:
        """已有 ready 状态的片段也可以触发生成"""
        plan = _make_plan(status=EditPlanStatus.EDITING)
        plan_repo.create(plan)
        clip = _make_clip(plan.id, order=1, status=EditPlanClipStatus.READY)
        clip_repo.create(clip)

        with patch("app.api.routes.edit_plans.celery_app") as mock_celery:
            mock_celery.send_task = MagicMock()
            resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")

        assert resp.status_code == 200
        data = resp.json()
        assert data["plan_status"] == "rendering"

    def test_generate_multiple_clips(
        self,
        client: TestClient,
        plan_repo: StubEditPlanRepository,
        clip_repo: StubEditPlanClipRepository,
    ) -> None:
        """多个片段全部从 pending 转为 ready"""
        plan = _make_plan(status=EditPlanStatus.EDITING)
        plan_repo.create(plan)
        for i in range(3):
            clip = _make_clip(plan.id, order=i + 1)
            clip_repo.create(clip)

        with patch("app.api.routes.edit_plans.celery_app") as mock_celery:
            mock_celery.send_task = MagicMock()
            resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")

        assert resp.status_code == 200
        data = resp.json()
        assert data["clip_count"] == 3

        # 验证所有片段都变为 ready
        clips = clip_repo.list_by_plan(plan.id)
        assert all(c.status == EditPlanClipStatus.READY for c in clips)


# ── GET /api/v1/edit-plans/{id}/generation-status ─────────────────────────────


class TestGetGenerationStatus:
    def test_status_not_found(self, client: TestClient) -> None:
        """计划不存在 → 404"""
        resp = client.get("/api/v1/edit-plans/nonexistent/generation-status")
        assert resp.status_code == 404

    def test_status_draft_plan(
        self,
        client: TestClient,
        plan_repo: StubEditPlanRepository,
        clip_repo: StubEditPlanClipRepository,
    ) -> None:
        """draft 状态计划的生成状态"""
        plan = _make_plan(status=EditPlanStatus.DRAFT)
        plan_repo.create(plan)
        clip = _make_clip(plan.id, order=1)
        clip_repo.create(clip)

        resp = client.get(f"/api/v1/edit-plans/{plan.id}/generation-status")
        assert resp.status_code == 200
        data = resp.json()
        assert data["plan_id"] == plan.id
        assert data["plan_status"] == "draft"
        assert data["generation_task_id"] is None
        assert len(data["clips"]) == 1
        assert data["clips"][0]["status"] == "pending"

    def test_status_rendering_plan(
        self,
        client: TestClient,
        plan_repo: StubEditPlanRepository,
        clip_repo: StubEditPlanClipRepository,
    ) -> None:
        """rendering 状态计划的生成状态"""
        plan = _make_plan(status=EditPlanStatus.RENDERING)
        plan.config["generation_task_id"] = "gen-task-001"
        plan_repo.create(plan)

        clip1 = _make_clip(plan.id, order=1, status=EditPlanClipStatus.RENDERED)
        clip2 = _make_clip(plan.id, order=2, status=EditPlanClipStatus.READY)
        clip_repo.create(clip1)
        clip_repo.create(clip2)

        resp = client.get(f"/api/v1/edit-plans/{plan.id}/generation-status")
        assert resp.status_code == 200
        data = resp.json()
        assert data["plan_status"] == "rendering"
        assert data["generation_task_id"] == "gen-task-001"
        assert len(data["clips"]) == 2
        statuses = {c["status"] for c in data["clips"]}
        assert "rendered" in statuses
        assert "ready" in statuses

    def test_status_completed_plan(
        self,
        client: TestClient,
        plan_repo: StubEditPlanRepository,
        clip_repo: StubEditPlanClipRepository,
    ) -> None:
        """completed 状态计划的生成状态"""
        plan = _make_plan(status=EditPlanStatus.COMPLETED)
        plan.config["generation_task_id"] = "gen-task-002"
        plan.config["rendered_url"] = "https://oss.example.com/rendered/output.mp4"
        plan_repo.create(plan)

        clip = _make_clip(plan.id, order=1, status=EditPlanClipStatus.RENDERED)
        clip_repo.create(clip)

        resp = client.get(f"/api/v1/edit-plans/{plan.id}/generation-status")
        assert resp.status_code == 200
        data = resp.json()
        assert data["plan_status"] == "completed"
        assert data["generation_task_id"] == "gen-task-002"
        assert len(data["clips"]) == 1
        assert data["clips"][0]["status"] == "rendered"

    def test_status_clip_fields(
        self,
        client: TestClient,
        plan_repo: StubEditPlanRepository,
        clip_repo: StubEditPlanClipRepository,
    ) -> None:
        """验证片段状态返回的字段完整性"""
        plan = _make_plan(status=EditPlanStatus.EDITING)
        plan_repo.create(plan)
        clip = _make_clip(
            plan.id,
            clip_type="INTRO",
            order=1,
            asset_id="assets/intro.mp4",
            duration=5.0,
        )
        clip.text_content = "欢迎观看"
        clip_repo.create(clip)

        resp = client.get(f"/api/v1/edit-plans/{plan.id}/generation-status")
        assert resp.status_code == 200
        data = resp.json()
        clip_data = data["clips"][0]
        assert clip_data["clip_id"] == clip.id
        assert clip_data["clip_type"] == "INTRO"
        assert clip_data["order"] == 1
        assert clip_data["asset_id"] == "assets/intro.mp4"
        assert clip_data["text_content"] == "欢迎观看"
        assert clip_data["duration"] == 5.0

    def test_status_no_clips(
        self,
        client: TestClient,
        plan_repo: StubEditPlanRepository,
    ) -> None:
        """没有片段的计划也能查询状态"""
        plan = _make_plan(status=EditPlanStatus.DRAFT)
        plan_repo.create(plan)

        resp = client.get(f"/api/v1/edit-plans/{plan.id}/generation-status")
        assert resp.status_code == 200
        data = resp.json()
        assert data["clips"] == []


# ── Response Schema 验证 ──────────────────────────────────────────────────────


class TestResponseSchema:
    def test_generate_response_structure(
        self,
        client: TestClient,
        plan_repo: StubEditPlanRepository,
        clip_repo: StubEditPlanClipRepository,
    ) -> None:
        """验证 generate 端点响应结构"""
        plan = _make_plan(status=EditPlanStatus.EDITING)
        plan_repo.create(plan)
        clip = _make_clip(plan.id, order=1)
        clip_repo.create(clip)

        with patch("app.api.routes.edit_plans.celery_app") as mock_celery:
            mock_celery.send_task = MagicMock()
            resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")

        assert resp.status_code == 200
        data = resp.json()
        expected_keys = {"plan_id", "plan_status", "generation_task_id", "clip_count"}
        assert set(data.keys()) == expected_keys

    def test_generation_status_response_structure(
        self,
        client: TestClient,
        plan_repo: StubEditPlanRepository,
    ) -> None:
        """验证 generation-status 端点响应结构"""
        plan = _make_plan(status=EditPlanStatus.DRAFT)
        plan_repo.create(plan)

        resp = client.get(f"/api/v1/edit-plans/{plan.id}/generation-status")
        assert resp.status_code == 200
        data = resp.json()
        expected_keys = {"plan_id", "plan_status", "generation_task_id", "clips"}
        assert set(data.keys()) == expected_keys
