"""Video engine route coverage — the on-demand request trigger, the held
video_post draft list/approve/reject queue, and the TikTok credentials
sub-router. CEO-only throughout."""

from __future__ import annotations

from http import HTTPStatus
from types import SimpleNamespace
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock, patch
from uuid import UUID, uuid4

import pytest
import pytest_asyncio
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from roboco.api.deps import get_agent_context, get_db
from roboco.api.routes import video as video_module
from roboco.api.routes.video import router as video_router
from roboco.api.routes.video import tiktok_router
from roboco.config import settings as cfg
from roboco.db.tables import AgentTable, ProjectTable, TaskTable
from roboco.foundation import identity as _foundation
from roboco.foundation.policy.content import markers
from roboco.models import AgentRole, AgentStatus, Team
from roboco.models.base import Complexity, TaskNature, TaskStatus, TaskType
from roboco.models.permissions import AgentContext
from roboco.services import minio_client
from roboco.services.heartbeat_mutex import HeartbeatMutex
from roboco.services.task import VIDEO_POST_SOURCE, VIDEO_SOURCE, get_task_service
from roboco.services.tiktok_credentials import get_tiktok_credentials_service
from roboco.services.video_post_service import XVideoPostResult
from roboco.services.x_credentials import get_x_credentials_service
from roboco.services.x_video_client import LiveXVideoPoster
from sqlalchemy import delete, select

if TYPE_CHECKING:
    from collections.abc import AsyncIterator
    from pathlib import Path

    from sqlalchemy.ext.asyncio import AsyncSession

SLUG = "roboco-video-route-test"
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
UX_DEV_1_UUID = _foundation.AGENTS["ux-dev-1"].uuid
UX_DEV_2_UUID = _foundation.AGENTS["ux-dev-2"].uuid
HISTORY_LIMIT = 2
RETRY_ATTEMPTS = 2


async def _seed(session: AsyncSession) -> None:
    for uuid_, slug, role, team in (
        (SYSTEM_UUID, "system", AgentRole.SYSTEM, None),
        (UX_DEV_1_UUID, "ux-dev-1", AgentRole.DEVELOPER, Team.UX_UI),
        (UX_DEV_2_UUID, "ux-dev-2", AgentRole.DEVELOPER, Team.UX_UI),
    ):
        if await session.get(AgentTable, uuid_) is None:
            session.add(
                AgentTable(
                    id=uuid_,
                    name=slug,
                    slug=slug,
                    role=role,
                    team=team,
                    status=AgentStatus.ACTIVE,
                    model_config={},
                    system_prompt="x",
                    capabilities=[],
                    permissions={},
                    metrics={},
                )
            )
    await session.flush()
    existing = await session.execute(
        select(ProjectTable).where(ProjectTable.slug == SLUG)
    )
    if existing.scalar_one_or_none() is None:
        session.add(
            ProjectTable(
                name="RoboCo",
                slug=SLUG,
                git_url="https://github.com/x/roboco.git",
                default_branch="master",
                protected_branches=["master"],
                assigned_cell=Team.BACKEND,
                created_by=SYSTEM_UUID,
                is_active=True,
                video_engine_enabled=True,
            )
        )
    await session.flush()


async def _seed_agent(session: AsyncSession, role: AgentRole, slug: str) -> AgentTable:
    agent = AgentTable(
        id=uuid4(),
        name=slug,
        slug=f"{slug}-{uuid4().hex[:6]}",
        role=role,
        team=None,
        status=AgentStatus.ACTIVE,
        model_config={},
        system_prompt="x",
        capabilities=[],
        permissions={},
        metrics={},
    )
    session.add(agent)
    await session.flush()
    return agent


async def _seed_draft(
    session: AsyncSession,
    *,
    platforms: list[str] | None = None,
    mp4_paths: dict[str, str] | None = None,
) -> TaskTable:
    """A held ``video_post`` draft — the approve/reject/list queue basis."""
    system = await _seed_agent(session, AgentRole.SYSTEM, "system")
    secretary = await _seed_agent(session, AgentRole.SECRETARY, "secretary")
    project = ProjectTable(
        id=uuid4(),
        name="RoboCo",
        slug=f"roboco-{uuid4().hex[:6]}",
        git_url="https://example.com/roboco.git",
        assigned_cell=Team.BACKEND,
        created_by=system.id,
    )
    session.add(project)
    await session.flush()
    task = TaskTable(
        id=uuid4(),
        title="Video post: release 1.0",
        description="script",
        acceptance_criteria=["CEO approves or rejects the draft"],
        status=TaskStatus.PENDING,
        priority=2,
        task_type=TaskType.ADMINISTRATIVE,
        nature=TaskNature.NON_TECHNICAL,
        estimated_complexity=Complexity.LOW,
        project_id=project.id,
        created_by=system.id,
        assigned_to=secretary.id,
        team=Team.MAIN_PM,
        source=VIDEO_POST_SOURCE,
        confirmed_by_human=False,
    )
    session.add(task)
    await session.flush()
    markers.set_video_draft(
        task,
        {
            "occasion": "release 1.0",
            "script": "script",
            "platforms": platforms if platforms is not None else ["x"],
            "mp4_paths": mp4_paths
            if mp4_paths is not None
            else {
                "square": "/render/out/1-square.mp4",
                "vertical": "/render/out/1-vertical.mp4",
            },
            "x_caption": "Check out this clip",
            "tiktok_caption": "Check out this clip on TikTok",
            "render_status": "rendered",
        },
    )
    await session.flush()
    return task


async def _seed_authoring_task(
    session: AsyncSession,
    *,
    status: TaskStatus = TaskStatus.IN_PROGRESS,
    draft_extra: dict[str, object] | None = None,
    pr_number: int | None = None,
) -> TaskTable:
    """A ``source=video`` UX/UI authoring task — the pipeline route's basis.
    Mirrors ``_seed_draft`` but for the pre-render authoring stage."""
    system = await _seed_agent(session, AgentRole.SYSTEM, "system")
    ux_dev = await _seed_agent(session, AgentRole.DEVELOPER, "ux-dev")
    project = ProjectTable(
        id=uuid4(),
        name="RoboCo",
        slug=f"roboco-{uuid4().hex[:6]}",
        git_url="https://example.com/roboco.git",
        assigned_cell=Team.UX_UI,
        created_by=system.id,
    )
    session.add(project)
    await session.flush()
    task = TaskTable(
        id=uuid4(),
        title="Video: launch teaser",
        description="A short teaser for the launch",
        acceptance_criteria=["dev builds the composition"],
        status=status,
        priority=2,
        task_type=TaskType.CODE,
        nature=TaskNature.TECHNICAL,
        estimated_complexity=Complexity.LOW,
        project_id=project.id,
        created_by=system.id,
        assigned_to=ux_dev.id,
        team=Team.UX_UI,
        source=VIDEO_SOURCE,
        confirmed_by_human=True,
        pr_number=pr_number,
    )
    session.add(task)
    await session.flush()
    markers.set_video_draft(
        task,
        {"occasion": "launch teaser", "script": "script", **(draft_extra or {})},
    )
    await session.flush()
    return task


def _build_app(
    db_session: AsyncSession | None, role: AgentRole, agent_id: UUID
) -> FastAPI:
    app = FastAPI()
    app.include_router(video_router, prefix="/api/video")
    app.include_router(tiktok_router, prefix="/api/tiktok")

    async def _override_db() -> AsyncIterator[AsyncSession | None]:
        # DB-independent tests pass db_session=None and monkeypatch the task
        # service so the route never awaits the session — yielding None is
        # safe because the route body uses the patched service, not get_db.
        yield db_session

    async def _override_agent() -> AgentContext:
        return AgentContext(agent_id=agent_id, role=role, team=None)

    app.dependency_overrides[get_db] = _override_db
    app.dependency_overrides[get_agent_context] = _override_agent
    return app


@pytest_asyncio.fixture
async def ceo_client(db_session: AsyncSession) -> AsyncIterator[AsyncClient]:
    app = _build_app(db_session, AgentRole.CEO, uuid4())
    transport = ASGITransport(app=app)
    async with AsyncClient(transport=transport, base_url="http://test") as client:
        yield client
    app.dependency_overrides.clear()


_LOCKED = (
    patch.object(HeartbeatMutex, "acquire", AsyncMock(return_value="tok")),
    patch.object(HeartbeatMutex, "release", AsyncMock(return_value=None)),
)


@pytest.mark.asyncio
async def test_request_video_opens_authoring_task(
    db_session: AsyncSession, ceo_client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
    await _seed(db_session)
    monkeypatch.setattr(cfg, "video_engine_enabled", True)
    monkeypatch.setattr(cfg, "video_max_open_posts", 5)
    project = (
        await db_session.execute(select(ProjectTable).where(ProjectTable.slug == SLUG))
    ).scalar_one()
    resp = await ceo_client.post(
        "/api/video/request",
        json={
            "occasion": "CEO on-demand: launch teaser",
            "brief": "A short teaser for the new dashboard",
            "platforms": ["x", "tiktok"],
            "project_id": str(project.id),
        },
    )
    assert resp.status_code == HTTPStatus.OK
    body = resp.json()
    assert body["status"] == "opened"
    assert body["task_id"] is not None
    try:
        # The route commits (mirrors the X route), so identity/field checks on
        # the specific created row — not a global open-list count — keep this
        # robust against other committed rows in the shared session-scoped
        # test DB.
        task = await db_session.get(TaskTable, UUID(body["task_id"]))
        assert task is not None
        assert task.source == VIDEO_SOURCE
        assert task.status == TaskStatus.PENDING
        assert task.project_id == project.id
    finally:
        # The route's commit durably persists this task past this test's own
        # rollback teardown — a non-terminal source=video row left behind
        # pollutes every later test in this session that counts open video
        # tasks (test_video_engine.py / test_video_render_loop.py), so it
        # must be deleted explicitly, not just rolled back.
        await db_session.execute(
            delete(TaskTable).where(TaskTable.id == UUID(body["task_id"]))
        )
        await db_session.commit()


@pytest.mark.asyncio
async def test_request_video_disabled_returns_clear_response(
    db_session: AsyncSession, ceo_client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
    await _seed(db_session)
    monkeypatch.setattr(cfg, "video_engine_enabled", False)
    before = len(await get_task_service(db_session).list_open_video_posts())
    resp = await ceo_client.post(
        "/api/video/request",
        json={
            "occasion": "occ-disabled",
            "brief": "brief",
            "platforms": ["x"],
            "project_id": str(uuid4()),
        },
    )
    assert resp.status_code == HTTPStatus.OK
    body = resp.json()
    assert body["status"] == "disabled"
    assert body["task_id"] is None
    after = len(await get_task_service(db_session).list_open_video_posts())
    assert after == before  # nothing new was opened


@pytest.mark.asyncio
async def test_request_video_404s_on_unresolvable_project_id(
    db_session: AsyncSession, ceo_client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
    """A project_id that doesn't resolve to any project 404s — no fabricated
    task, no silent not_opened."""
    await _seed(db_session)
    monkeypatch.setattr(cfg, "video_engine_enabled", True)
    before = len(await get_task_service(db_session).list_open_video_posts())
    resp = await ceo_client.post(
        "/api/video/request",
        json={
            "occasion": "occ-unresolvable",
            "brief": "brief",
            "platforms": ["x"],
            "project_id": str(uuid4()),
        },
    )
    assert resp.status_code == HTTPStatus.NOT_FOUND
    after = len(await get_task_service(db_session).list_open_video_posts())
    assert after == before  # nothing new was opened


@pytest.mark.asyncio
async def test_request_video_404s_on_non_opted_in_project_id(
    db_session: AsyncSession, ceo_client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
    """A project that resolves but hasn't flipped video_engine_enabled also
    404s, distinct from the unresolvable-project case above."""
    await _seed(db_session)
    monkeypatch.setattr(cfg, "video_engine_enabled", True)
    project = ProjectTable(
        id=uuid4(),
        name="Not Opted In",
        slug=f"not-opted-{uuid4().hex[:6]}",
        git_url="https://example.com/notopted.git",
        assigned_cell=Team.BACKEND,
        created_by=SYSTEM_UUID,
        video_engine_enabled=False,
    )
    db_session.add(project)
    await db_session.flush()
    resp = await ceo_client.post(
        "/api/video/request",
        json={
            "occasion": "occ-not-opted",
            "brief": "brief",
            "platforms": ["x"],
            "project_id": str(project.id),
        },
    )
    assert resp.status_code == HTTPStatus.NOT_FOUND


@pytest.mark.asyncio
async def test_request_video_not_opened_on_duplicate_occasion(
    db_session: AsyncSession, ceo_client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
    """A valid, opted-in project_id still no-ops (not a 404) for a duplicate
    occasion — the open-cap/dedup reasons stay a clear 200 not_opened."""
    await _seed(db_session)
    monkeypatch.setattr(cfg, "video_engine_enabled", True)
    monkeypatch.setattr(cfg, "video_max_open_posts", 5)
    project = (
        await db_session.execute(select(ProjectTable).where(ProjectTable.slug == SLUG))
    ).scalar_one()
    payload = {
        "occasion": "occ-dupe",
        "brief": "brief",
        "platforms": ["x"],
        "project_id": str(project.id),
    }
    first = await ceo_client.post("/api/video/request", json=payload)
    assert first.status_code == HTTPStatus.OK
    assert first.json()["status"] == "opened"
    task_id = first.json()["task_id"]
    try:
        second = await ceo_client.post("/api/video/request", json=payload)
        assert second.status_code == HTTPStatus.OK
        assert second.json()["status"] == "not_opened"
        assert second.json()["task_id"] is None
    finally:
        await db_session.execute(delete(TaskTable).where(TaskTable.id == UUID(task_id)))
        await db_session.commit()


@pytest.mark.asyncio
async def test_list_posts_returns_open_draft(
    db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
    task = await _seed_draft(db_session)
    resp = await ceo_client.get("/api/video/posts")
    assert resp.status_code == HTTPStatus.OK
    body = resp.json()
    assert len(body) == 1
    assert body[0]["task_id"] == str(task.id)
    assert body[0]["occasion"] == "release 1.0"
    assert body[0]["platforms"] == ["x"]
    assert body[0]["mp4_paths"] == {
        "square": "/render/out/1-square.mp4",
        "vertical": "/render/out/1-vertical.mp4",
    }


@pytest.mark.asyncio
async def test_list_posts_includes_source_task_id(
    db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
    """source_task_id round-trips from the marker to the response — the
    panel's basis for a future draft->authoring-task deep link."""
    source_task_id = uuid4()
    task = await _seed_draft(db_session)
    draft = markers.get_video_draft(task) or {}
    markers.set_video_draft(task, {**draft, "source_task_id": str(source_task_id)})
    await db_session.flush()
    resp = await ceo_client.get("/api/video/posts")
    assert resp.status_code == HTTPStatus.OK
    body = resp.json()
    assert body[0]["source_task_id"] == str(source_task_id)


@pytest.mark.asyncio
async def test_history_includes_source_task_id(
    db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
    source_task_id = uuid4()
    task = await _seed_draft(db_session)
    draft = markers.get_video_draft(task) or {}
    markers.set_video_draft(task, {**draft, "source_task_id": str(source_task_id)})
    await db_session.flush()
    with _LOCKED[0], _LOCKED[1]:
        await ceo_client.post(
            f"/api/video/posts/{task.id}/reject", json={"reason": "off-brand"}
        )
    resp = await ceo_client.get("/api/video/posts/history")
    body = resp.json()
    row = next(r for r in body if r["task_id"] == str(task.id))
    assert row["source_task_id"] == str(source_task_id)


# --- pipeline strip (task 1, 2026-07-09) --------------------------------------


@pytest.mark.asyncio
async def test_pipeline_lists_non_terminal_authoring_task(
    db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
    task = await _seed_authoring_task(db_session, status=TaskStatus.IN_PROGRESS)
    resp = await ceo_client.get("/api/video/pipeline")
    assert resp.status_code == HTTPStatus.OK
    body = resp.json()
    row = next(r for r in body if r["task_id"] == str(task.id))
    assert row["status"] == "in_progress"
    assert row["occasion"] == "launch teaser"
    assert row["render_status"] is None
    assert row["render_attempts"] == 0
    assert row["max_attempts"] == markers.MAX_VIDEO_RENDER_ATTEMPTS
    assert row["render_error"] is None


@pytest.mark.asyncio
async def test_pipeline_shows_completed_unrendered_with_attempts(
    db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
    """A COMPLETED authoring task the render loop hasn't finished with
    (render_status unset) stays visible with its retry count."""
    task = await _seed_authoring_task(
        db_session,
        status=TaskStatus.COMPLETED,
        draft_extra={"composition_id": "Intro", "render_attempts": RETRY_ATTEMPTS},
    )
    resp = await ceo_client.get("/api/video/pipeline")
    body = resp.json()
    row = next(r for r in body if r["task_id"] == str(task.id))
    assert row["render_attempts"] == RETRY_ATTEMPTS
    assert row["render_status"] is None
    assert row["composition_id"] == "Intro"


@pytest.mark.asyncio
async def test_pipeline_shows_failed_render_with_error(
    db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
    task = await _seed_authoring_task(
        db_session,
        status=TaskStatus.COMPLETED,
        draft_extra={
            "composition_id": "Intro",
            "render_status": "failed",
            "render_attempts": markers.MAX_VIDEO_RENDER_ATTEMPTS,
            "render_error": "sidecar timeout",
        },
    )
    resp = await ceo_client.get("/api/video/pipeline")
    body = resp.json()
    row = next(r for r in body if r["task_id"] == str(task.id))
    assert row["render_status"] == "failed"
    assert row["render_attempts"] == markers.MAX_VIDEO_RENDER_ATTEMPTS
    assert row["render_error"] == "sidecar timeout"


@pytest.mark.asyncio
async def test_pipeline_excludes_rendered_completed_task(
    db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
    """A rendered task already materialized its video_post draft — it must
    not double-appear in the pipeline strip."""
    task = await _seed_authoring_task(
        db_session,
        status=TaskStatus.COMPLETED,
        draft_extra={"composition_id": "Intro", "render_status": "rendered"},
    )
    resp = await ceo_client.get("/api/video/pipeline")
    ids = [row["task_id"] for row in resp.json()]
    assert str(task.id) not in ids


@pytest.mark.asyncio
async def test_pipeline_excludes_cancelled_task(
    db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
    task = await _seed_authoring_task(db_session, status=TaskStatus.CANCELLED)
    resp = await ceo_client.get("/api/video/pipeline")
    ids = [row["task_id"] for row in resp.json()]
    assert str(task.id) not in ids


@pytest.mark.asyncio
async def test_pipeline_non_ceo_is_forbidden(db_session: AsyncSession) -> None:
    app = _build_app(db_session, AgentRole.DEVELOPER, uuid4())
    transport = ASGITransport(app=app)
    async with AsyncClient(transport=transport, base_url="http://test") as client:
        resp = await client.get("/api/video/pipeline")
    assert resp.status_code == HTTPStatus.FORBIDDEN
    app.dependency_overrides.clear()


@pytest.mark.asyncio
async def test_media_returns_the_rendered_cut(
    db_session: AsyncSession,
    ceo_client: AsyncClient,
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    monkeypatch.setattr(cfg, "video_output_dir", str(tmp_path))
    vertical = tmp_path / "clip-vertical.mp4"
    vertical.write_bytes(b"fake-mp4-bytes-vertical")
    task = await _seed_draft(
        db_session,
        mp4_paths={"vertical": str(vertical), "square": str(tmp_path / "missing.mp4")},
    )
    resp = await ceo_client.get(f"/api/video/posts/{task.id}/media?cut=vertical")
    assert resp.status_code == HTTPStatus.OK
    assert resp.headers["content-type"] == "video/mp4"
    assert resp.content == b"fake-mp4-bytes-vertical"


@pytest.mark.asyncio
async def test_media_outside_output_dir_is_404(
    db_session: AsyncSession,
    ceo_client: AsyncClient,
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """A mp4_paths entry that resolves outside video_output_dir is refused
    even though the file exists on disk — defense-in-depth against any
    future writer of mp4_paths."""
    outside = tmp_path / "outside" / "clip-vertical.mp4"
    outside.parent.mkdir(parents=True)
    outside.write_bytes(b"fake-mp4-bytes")
    monkeypatch.setattr(cfg, "video_output_dir", str(tmp_path / "confined"))
    task = await _seed_draft(db_session, mp4_paths={"vertical": str(outside)})
    resp = await ceo_client.get(f"/api/video/posts/{task.id}/media?cut=vertical")
    assert resp.status_code == HTTPStatus.NOT_FOUND


@pytest.mark.asyncio
async def test_media_bad_cut_is_400(
    db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
    task = await _seed_draft(db_session)
    resp = await ceo_client.get(f"/api/video/posts/{task.id}/media?cut=diagonal")
    assert resp.status_code == HTTPStatus.BAD_REQUEST


@pytest.mark.asyncio
async def test_media_missing_task_is_404(ceo_client: AsyncClient) -> None:
    resp = await ceo_client.get(f"/api/video/posts/{uuid4()}/media?cut=vertical")
    assert resp.status_code == HTTPStatus.NOT_FOUND


@pytest.mark.asyncio
async def test_media_unrendered_cut_is_404(
    db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
    """The seeded draft's paths never exist on disk — a 404, not a crash."""
    task = await _seed_draft(db_session)
    resp = await ceo_client.get(f"/api/video/posts/{task.id}/media?cut=square")
    assert resp.status_code == HTTPStatus.NOT_FOUND


@pytest.mark.asyncio
async def test_approve_without_credentials_fails_gracefully(
    db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
    """No X/TikTok credentials configured in this test DB: the route still
    builds real (Null) posters and the approve completes without raising —
    just with nothing posted."""
    task = await _seed_draft(db_session, platforms=["x", "tiktok"])
    try:
        with _LOCKED[0], _LOCKED[1]:
            resp = await ceo_client.post(f"/api/video/posts/{task.id}/approve", json={})
        assert resp.status_code == HTTPStatus.OK
        body = resp.json()
        assert body["status"] == "post_failed"
        assert body["posted"] == {}
        await db_session.refresh(task)
        assert task.status == TaskStatus.PENDING  # never advanced without a real post
    finally:
        # The approve route commits durably even on a post_failed outcome, so
        # this non-terminal source=video_post row survives this test's own
        # rollback teardown — left behind, it pollutes every later test in
        # this session that counts open video tasks.
        await db_session.execute(delete(TaskTable).where(TaskTable.id == task.id))
        await db_session.commit()


@pytest.mark.asyncio
async def test_approve_with_credentials_posts_via_the_real_poster_wiring(
    db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
    """Once X credentials are configured, the route builds a LiveXVideoPoster
    (not the Null default) — the network call itself is mocked here; the
    real HTTP sequence is covered by test_x_video_client.py.

    The approve route commits durably (mirrors the X-post pattern), so the
    x_credentials singleton row must be cleared afterward — left behind, it
    leaks into any later test in this shared session-scoped test DB that
    asserts a fresh "unset" state (e.g. test_x_credentials_service.py)."""
    task = await _seed_draft(db_session, platforms=["x"])
    creds_svc = get_x_credentials_service(db_session)
    await creds_svc.set_credentials(
        api_key="ak", api_secret="as", access_token="at", access_token_secret="ats"
    )
    try:
        with (
            _LOCKED[0],
            _LOCKED[1],
            patch.object(
                LiveXVideoPoster,
                "post_video",
                AsyncMock(
                    return_value=XVideoPostResult(
                        posted=True, video_id="xid1", detail="posted"
                    )
                ),
            ),
        ):
            resp = await ceo_client.post(f"/api/video/posts/{task.id}/approve", json={})
        assert resp.status_code == HTTPStatus.OK
        body = resp.json()
        assert body["status"] == "posted"
        assert body["posted"] == {"x": "xid1"}
        await db_session.refresh(task)
        assert task.status == TaskStatus.COMPLETED
    finally:
        await creds_svc.set_credentials(
            api_key="", api_secret="", access_token="", access_token_secret=""
        )
        await db_session.commit()


@pytest.mark.asyncio
async def test_history_returns_posted_and_rejected_newest_first(
    db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
    rejected = await _seed_draft(db_session, platforms=["x"])
    with _LOCKED[0], _LOCKED[1]:
        await ceo_client.post(
            f"/api/video/posts/{rejected.id}/reject",
            json={"reason": "wrong occasion"},
        )
    posted = await _seed_draft(db_session, platforms=["x"])
    creds_svc = get_x_credentials_service(db_session)
    await creds_svc.set_credentials(
        api_key="ak", api_secret="as", access_token="at", access_token_secret="ats"
    )
    try:
        with (
            _LOCKED[0],
            _LOCKED[1],
            patch.object(
                LiveXVideoPoster,
                "post_video",
                AsyncMock(
                    return_value=XVideoPostResult(
                        posted=True, video_id="xid42", detail="posted"
                    )
                ),
            ),
        ):
            await ceo_client.post(f"/api/video/posts/{posted.id}/approve", json={})

        resp = await ceo_client.get("/api/video/posts/history")
        assert resp.status_code == HTTPStatus.OK
        body = resp.json()
        ids = [row["task_id"] for row in body]
        assert str(posted.id) in ids
        assert str(rejected.id) in ids
        assert ids.index(str(posted.id)) < ids.index(str(rejected.id))
        posted_row = next(row for row in body if row["task_id"] == str(posted.id))
        assert posted_row["status"] == "completed"
        assert posted_row["posted"] == {"x": "xid42"}
        rejected_row = next(row for row in body if row["task_id"] == str(rejected.id))
        assert rejected_row["status"] == "cancelled"
        assert rejected_row["reject_reason"] == "wrong occasion"
    finally:
        await creds_svc.set_credentials(
            api_key="", api_secret="", access_token="", access_token_secret=""
        )
        await db_session.commit()


@pytest.mark.asyncio
async def test_history_excludes_open_drafts(
    db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
    """Every approve/reject route in this file commits durably (the route
    always calls db.commit()), so other tests' posted/rejected rows persist
    in this shared-DB test session — history is never provably empty. Assert
    identity instead: THIS still-open draft must not appear."""
    open_task = await _seed_draft(db_session)
    resp = await ceo_client.get("/api/video/posts/history")
    assert resp.status_code == HTTPStatus.OK
    ids = [row["task_id"] for row in resp.json()]
    assert str(open_task.id) not in ids


@pytest.mark.asyncio
async def test_history_respects_limit(
    db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
    for _ in range(3):
        t = await _seed_draft(db_session)
        with _LOCKED[0], _LOCKED[1]:
            await ceo_client.post(
                f"/api/video/posts/{t.id}/reject", json={"reason": "not relevant"}
            )
    resp = await ceo_client.get(
        "/api/video/posts/history", params={"limit": HISTORY_LIMIT}
    )
    assert resp.status_code == HTTPStatus.OK
    assert len(resp.json()) == HISTORY_LIMIT


@pytest.mark.asyncio
async def test_history_non_ceo_is_forbidden(db_session: AsyncSession) -> None:
    app = _build_app(db_session, AgentRole.DEVELOPER, uuid4())
    transport = ASGITransport(app=app)
    async with AsyncClient(transport=transport, base_url="http://test") as client:
        resp = await client.get("/api/video/posts/history")
    assert resp.status_code == HTTPStatus.FORBIDDEN
    app.dependency_overrides.clear()


@pytest.mark.asyncio
async def test_approve_edited_x_caption_over_limit_is_422(
    db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
    task = await _seed_draft(db_session)
    resp = await ceo_client.post(
        f"/api/video/posts/{task.id}/approve", json={"x_caption": "x" * 281}
    )
    assert resp.status_code == HTTPStatus.UNPROCESSABLE_ENTITY


@pytest.mark.asyncio
async def test_approve_missing_task_is_404(ceo_client: AsyncClient) -> None:
    resp = await ceo_client.post(f"/api/video/posts/{uuid4()}/approve", json={})
    assert resp.status_code == HTTPStatus.NOT_FOUND


@pytest.mark.asyncio
async def test_reject_cancels_and_records_reason(
    db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
    task = await _seed_draft(db_session)
    with _LOCKED[0], _LOCKED[1]:
        resp = await ceo_client.post(
            f"/api/video/posts/{task.id}/reject", json={"reason": "Not our voice"}
        )
    assert resp.status_code == HTTPStatus.OK
    assert resp.json()["reject_reason"] == "Not our voice"
    refreshed = await db_session.get(TaskTable, task.id)
    assert refreshed is not None
    assert refreshed.status == TaskStatus.CANCELLED


@pytest.mark.asyncio
async def test_reject_missing_task_is_404(ceo_client: AsyncClient) -> None:
    resp = await ceo_client.post(
        f"/api/video/posts/{uuid4()}/reject", json={"reason": "not relevant here"}
    )
    assert resp.status_code == HTTPStatus.NOT_FOUND


@pytest.mark.asyncio
async def test_tiktok_credentials_default_is_unset(ceo_client: AsyncClient) -> None:
    resp = await ceo_client.get("/api/tiktok/credentials")
    assert resp.status_code == HTTPStatus.OK
    assert resp.json()["has_credentials"] is False


@pytest.mark.asyncio
async def test_set_tiktok_credentials_reports_status_never_plaintext(
    db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
    """The route commits durably, so the tiktok_credentials singleton row is
    cleared afterward — left behind, it leaks into any later test in this
    shared session-scoped test DB (e.g. test_tiktok_credentials_service.py's
    "unset" assertions)."""
    try:
        resp = await ceo_client.post(
            "/api/tiktok/credentials",
            json={
                "client_key": "secret-key-value",
                "client_secret": "secret-clientsecret-value",
                "access_token": "secret-token-value",
                "refresh_token": "secret-refresh-value",
            },
        )
        assert resp.status_code == HTTPStatus.OK
        assert resp.json() == {"has_credentials": True}
        assert "secret-key-value" not in resp.text
        assert "secret-clientsecret-value" not in resp.text
        assert "secret-token-value" not in resp.text
        assert "secret-refresh-value" not in resp.text

        status_resp = await ceo_client.get("/api/tiktok/credentials")
        assert status_resp.json()["has_credentials"] is True
    finally:
        await get_tiktok_credentials_service(db_session).set_credentials(
            client_key="", client_secret="", access_token="", refresh_token=""
        )
        await db_session.commit()


@pytest.mark.asyncio
async def test_set_tiktok_credentials_partial_is_400(ceo_client: AsyncClient) -> None:
    resp = await ceo_client.post(
        "/api/tiktok/credentials",
        json={
            "client_key": "only-one",
            "client_secret": "",
            "access_token": "",
            "refresh_token": "",
        },
    )
    assert resp.status_code == HTTPStatus.BAD_REQUEST


@pytest.mark.asyncio
async def test_non_ceo_is_forbidden(db_session: AsyncSession) -> None:
    await _seed(db_session)
    task = await _seed_draft(db_session)
    app = _build_app(db_session, AgentRole.DEVELOPER, uuid4())
    transport = ASGITransport(app=app)
    async with AsyncClient(transport=transport, base_url="http://test") as client:
        request_resp = await client.post(
            "/api/video/request",
            json={
                "occasion": "occ",
                "brief": "brief",
                "platforms": ["x"],
                "project_id": str(uuid4()),
            },
        )
        list_resp = await client.get("/api/video/posts")
        media_resp = await client.get(f"/api/video/posts/{task.id}/media?cut=vertical")
        creds_resp = await client.get("/api/tiktok/credentials")
    assert request_resp.status_code == HTTPStatus.FORBIDDEN
    assert list_resp.status_code == HTTPStatus.FORBIDDEN
    assert media_resp.status_code == HTTPStatus.FORBIDDEN
    assert creds_resp.status_code == HTTPStatus.FORBIDDEN
    app.dependency_overrides.clear()


# --- MinIO serve path (chunk 4) — unit-style, no DB / no real MinIO ------------
# These two tests monkeypatch ``get_task_service`` in the video routes module
# so they run without postgres (the ``db_session``-based tests above are
# skipped when Postgres is unreachable). Mocks only — no testcontainers.


def _stub_task_service_factory(task: object) -> object:
    """A ``get_task_service``-shaped stub (the real one is a sync factory
    returning a service with an async ``.get``). Patched in place of
    ``video_module.get_task_service`` so the route runs without postgres."""

    class _Svc:
        async def get(self, _task_id: UUID) -> object:
            return task

    return _Svc()


def _make_task(mp4_path: str, task_id: UUID) -> SimpleNamespace:
    """A minimal task-shaped stub carrying the video_draft marker the route
    reads — enough for the media route, no DB row needed."""
    return SimpleNamespace(
        id=task_id,
        source=VIDEO_POST_SOURCE,
        orchestration_markers={"video_draft": {"mp4_paths": {"vertical": mp4_path}}},
    )


def _patch_task_service(monkeypatch: pytest.MonkeyPatch, task: object) -> None:
    monkeypatch.setattr(
        video_module,
        "get_task_service",
        lambda _db: _stub_task_service_factory(task),
    )


def _minio_stream(_key: str) -> object:
    """Stub ``get_object_stream`` yielding fixed bytes for ``StreamingResponse``."""
    return iter([b"minio-stream-bytes"])


@pytest.mark.asyncio
async def test_media_serves_from_minio_when_configured(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """Configured serve path: when MinIO is configured, the media route streams
    the object via ``minio_client.get_object_stream`` (key = basename) and the
    panel-preview URL/headers stay identical. ``_require_ceo`` still 403s a
    non-CEO agent. No DB / no real MinIO — ``get_task_service`` is stubbed so
    the route runs without postgres."""
    # A real local file so the route's is_file() + confinement checks pass.
    # The served bytes come from the stubbed MinIO stream below, NOT this
    # file — that's what proves the MinIO path was taken rather than the
    # FileResponse fallback.
    monkeypatch.setattr(cfg, "video_output_dir", str(tmp_path))
    vertical = tmp_path / "clip-vertical.mp4"
    vertical.write_bytes(b"local-file-bytes")
    task_id = uuid4()
    _patch_task_service(monkeypatch, _make_task(str(vertical), task_id))
    # non-None sentinel so the route takes the MinIO branch.
    monkeypatch.setattr(minio_client, "get_client", lambda: True)
    # The route probes stat_object eagerly before streaming; stub it to pass.
    monkeypatch.setattr(minio_client, "stat_object", lambda _key: None)
    monkeypatch.setattr(minio_client, "get_object_stream", _minio_stream)

    # CEO 200 — streamed from MinIO.
    app = _build_app(None, AgentRole.CEO, uuid4())
    transport = ASGITransport(app=app)
    async with AsyncClient(transport=transport, base_url="http://test") as client:
        resp = await client.get(f"/api/video/posts/{task_id}/media?cut=vertical")
        assert resp.status_code == HTTPStatus.OK
        assert resp.headers["content-type"] == "video/mp4"
        assert resp.content == b"minio-stream-bytes"
    app.dependency_overrides.clear()

    # Non-CEO 403 — _require_ceo still gates end-to-end (no presigned URL).
    app = _build_app(None, AgentRole.DEVELOPER, uuid4())
    transport = ASGITransport(app=app)
    async with AsyncClient(transport=transport, base_url="http://test") as client:
        resp = await client.get(f"/api/video/posts/{task_id}/media?cut=vertical")
        assert resp.status_code == HTTPStatus.FORBIDDEN
    app.dependency_overrides.clear()


@pytest.mark.asyncio
async def test_media_falls_back_to_local_file_when_minio_unconfigured(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """Unconfigured fallback: with ``get_client`` returning None
    (``minio_endpoint`` empty), the media route serves the local file via
    ``FileResponse`` — the body equals the local file's bytes. No DB / no
    real MinIO."""
    monkeypatch.setattr(cfg, "video_output_dir", str(tmp_path))
    vertical = tmp_path / "clip-vertical.mp4"
    vertical.write_bytes(b"local-file-bytes")
    task_id = uuid4()
    _patch_task_service(monkeypatch, _make_task(str(vertical), task_id))
    monkeypatch.setattr(minio_client, "get_client", lambda: None)

    app = _build_app(None, AgentRole.CEO, uuid4())
    transport = ASGITransport(app=app)
    async with AsyncClient(transport=transport, base_url="http://test") as client:
        resp = await client.get(f"/api/video/posts/{task_id}/media?cut=vertical")
        assert resp.status_code == HTTPStatus.OK
        assert resp.headers["content-type"] == "video/mp4"
        assert resp.content == b"local-file-bytes"
    app.dependency_overrides.clear()


@pytest.mark.asyncio
async def test_media_falls_back_to_local_file_when_minio_missing(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """S3Error fallback: when MinIO is configured but the object is missing
    (NoSuchKey — an old render not yet in MinIO) or MinIO is down, the route's
    eager ``stat_object`` probe raises, the ``try/except`` catches it, and the
    route serves the local file via ``FileResponse``. ``get_object_stream`` is
    never called. No DB / no real MinIO."""
    monkeypatch.setattr(cfg, "video_output_dir", str(tmp_path))
    vertical = tmp_path / "clip-vertical.mp4"
    vertical.write_bytes(b"local-file-bytes")
    task_id = uuid4()
    _patch_task_service(monkeypatch, _make_task(str(vertical), task_id))
    monkeypatch.setattr(minio_client, "get_client", lambda: True)

    def _stat_raises(_key: str) -> None:
        raise RuntimeError("minio NoSuchKey / down")

    monkeypatch.setattr(minio_client, "stat_object", _stat_raises)

    # If the route wrongly takes the MinIO stream branch, this would be called
    # and the assertion below would fail — guard against a regression.
    def _stream_must_not_be_called(_key: str) -> object:
        pytest.fail("get_object_stream must not be called when stat_object raises")

    monkeypatch.setattr(minio_client, "get_object_stream", _stream_must_not_be_called)

    app = _build_app(None, AgentRole.CEO, uuid4())
    transport = ASGITransport(app=app)
    async with AsyncClient(transport=transport, base_url="http://test") as client:
        resp = await client.get(f"/api/video/posts/{task_id}/media?cut=vertical")
        assert resp.status_code == HTTPStatus.OK
        assert resp.headers["content-type"] == "video/mp4"
        assert resp.content == b"local-file-bytes"  # FileResponse fallback, not MinIO
    app.dependency_overrides.clear()


# --- re-render (task 3, 2026-07-10) -------------------------------------------


@pytest.mark.asyncio
async def test_rerender_clears_state_and_returns_pipeline_item(
    db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
    task = await _seed_authoring_task(
        db_session,
        status=TaskStatus.COMPLETED,
        draft_extra={
            "composition_id": "Intro",
            "render_status": "failed",
            "render_attempts": markers.MAX_VIDEO_RENDER_ATTEMPTS,
            "render_error": "sidecar timeout",
        },
    )
    resp = await ceo_client.post(f"/api/video/pipeline/{task.id}/rerender")
    assert resp.status_code == HTTPStatus.OK
    body = resp.json()
    assert body["task_id"] == str(task.id)
    assert body["render_status"] is None
    assert body["render_attempts"] == 0
    assert body["render_error"] is None
    draft = markers.get_video_draft(task)
    assert draft is not None
    assert "render_status" not in draft
    assert "render_attempts" not in draft
    assert "render_error" not in draft
    assert draft["composition_id"] == "Intro"  # everything else preserved


@pytest.mark.asyncio
async def test_rerender_missing_task_is_404(ceo_client: AsyncClient) -> None:
    resp = await ceo_client.post(f"/api/video/pipeline/{uuid4()}/rerender")
    assert resp.status_code == HTTPStatus.NOT_FOUND


@pytest.mark.asyncio
async def test_rerender_non_completed_task_is_404(
    db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
    task = await _seed_authoring_task(
        db_session,
        status=TaskStatus.IN_PROGRESS,
        draft_extra={"composition_id": "Intro"},
    )
    resp = await ceo_client.post(f"/api/video/pipeline/{task.id}/rerender")
    assert resp.status_code == HTTPStatus.NOT_FOUND


@pytest.mark.asyncio
async def test_rerender_without_composition_id_is_404(
    db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
    task = await _seed_authoring_task(db_session, status=TaskStatus.COMPLETED)
    resp = await ceo_client.post(f"/api/video/pipeline/{task.id}/rerender")
    assert resp.status_code == HTTPStatus.NOT_FOUND


@pytest.mark.asyncio
async def test_rerender_non_ceo_is_forbidden(db_session: AsyncSession) -> None:
    task = await _seed_authoring_task(
        db_session,
        status=TaskStatus.COMPLETED,
        draft_extra={"composition_id": "Intro"},
    )
    app = _build_app(db_session, AgentRole.DEVELOPER, uuid4())
    transport = ASGITransport(app=app)
    async with AsyncClient(transport=transport, base_url="http://test") as client:
        resp = await client.post(f"/api/video/pipeline/{task.id}/rerender")
    assert resp.status_code == HTTPStatus.FORBIDDEN
    app.dependency_overrides.clear()


# --- preview proxy (task 3, 2026-07-10) ---------------------------------------


def _fake_workspace_service(root: Path) -> object:
    """A ``get_workspace_service``-shaped stub whose ``ensure_read_clone``
    returns a fixed local dir — no real git clone touched."""

    class _Svc:
        async def ensure_read_clone(self, _slug: str, *, force: bool = False) -> Path:
            _ = force
            return root

    return _Svc()


def test_resolve_preview_path_serves_file_inside_root(tmp_path: Path) -> None:
    root = (tmp_path / "clone").resolve()
    (root / "motion" / "compositions" / "Intro").mkdir(parents=True)
    target = root / "motion" / "compositions" / "Intro" / "vertical.html"
    target.write_text("<html></html>")
    resolved = video_module._resolve_preview_path(
        root, "motion/compositions/Intro/vertical.html"
    )
    assert resolved == target.resolve()


def test_resolve_preview_path_blocks_dot_dot_traversal(tmp_path: Path) -> None:
    root = (tmp_path / "clone").resolve()
    (root / "motion").mkdir(parents=True)
    secret = tmp_path / "secret.txt"
    secret.write_text("nope")
    assert video_module._resolve_preview_path(root, "../secret.txt") is None
    assert video_module._resolve_preview_path(root, "motion/../../secret.txt") is None


def test_resolve_preview_path_blocks_absolute_path_override(tmp_path: Path) -> None:
    """A leading '/' in file_path would otherwise let pathlib's ``/``
    operator discard ``root`` entirely and resolve straight to the absolute
    path — the ``lstrip("/")`` guard neutralizes that."""
    root = (tmp_path / "clone").resolve()
    root.mkdir()
    outside = tmp_path / "outside.txt"
    outside.write_text("nope")
    assert video_module._resolve_preview_path(root, str(outside)) is None


def test_resolve_preview_path_missing_file_is_none(tmp_path: Path) -> None:
    root = (tmp_path / "clone").resolve()
    root.mkdir()
    assert video_module._resolve_preview_path(root, "motion/nope.html") is None


@pytest.mark.asyncio
async def test_preview_serves_composition_html_and_sibling_kit_asset(
    db_session: AsyncSession,
    ceo_client: AsyncClient,
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    comp_dir = tmp_path / "motion" / "compositions" / "Intro"
    comp_dir.mkdir(parents=True)
    (comp_dir / "vertical.html").write_text("<html>intro</html>")
    kit_dir = tmp_path / "motion" / "kit"
    kit_dir.mkdir(parents=True)
    (kit_dir / "kit.css").write_text("body{}")
    monkeypatch.setattr(
        video_module,
        "get_workspace_service",
        lambda _db: _fake_workspace_service(tmp_path),
    )
    task = await _seed_authoring_task(
        db_session,
        status=TaskStatus.IN_PROGRESS,
        draft_extra={"composition_id": "Intro"},
    )
    resp = await ceo_client.get(
        f"/api/video/preview/{task.id}/motion/compositions/Intro/vertical.html"
    )
    assert resp.status_code == HTTPStatus.OK
    assert resp.text == "<html>intro</html>"
    assert resp.headers.get("x-frame-options") == "SAMEORIGIN"
    assert "frame-ancestors" in resp.headers.get("content-security-policy", "")

    kit_resp = await ceo_client.get(f"/api/video/preview/{task.id}/motion/kit/kit.css")
    assert kit_resp.status_code == HTTPStatus.OK
    assert kit_resp.text == "body{}"


@pytest.mark.asyncio
async def test_preview_missing_file_is_404(
    db_session: AsyncSession,
    ceo_client: AsyncClient,
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    (tmp_path / "motion").mkdir()
    monkeypatch.setattr(
        video_module,
        "get_workspace_service",
        lambda _db: _fake_workspace_service(tmp_path),
    )
    task = await _seed_authoring_task(
        db_session,
        status=TaskStatus.IN_PROGRESS,
        draft_extra={"composition_id": "Intro"},
    )
    resp = await ceo_client.get(
        f"/api/video/preview/{task.id}/motion/compositions/Intro/vertical.html"
    )
    assert resp.status_code == HTTPStatus.NOT_FOUND


@pytest.mark.asyncio
async def test_preview_missing_task_is_404(ceo_client: AsyncClient) -> None:
    resp = await ceo_client.get(f"/api/video/preview/{uuid4()}/vertical.html")
    assert resp.status_code == HTTPStatus.NOT_FOUND


@pytest.mark.asyncio
async def test_preview_non_video_task_is_404(
    db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
    task = await _seed_draft(db_session)  # source=video_post, not video
    resp = await ceo_client.get(f"/api/video/preview/{task.id}/vertical.html")
    assert resp.status_code == HTTPStatus.NOT_FOUND


@pytest.mark.asyncio
async def test_preview_non_ceo_is_forbidden(db_session: AsyncSession) -> None:
    task = await _seed_authoring_task(
        db_session,
        status=TaskStatus.IN_PROGRESS,
        draft_extra={"composition_id": "Intro"},
    )
    app = _build_app(db_session, AgentRole.DEVELOPER, uuid4())
    transport = ASGITransport(app=app)
    async with AsyncClient(transport=transport, base_url="http://test") as client:
        resp = await client.get(f"/api/video/preview/{task.id}/vertical.html")
    assert resp.status_code == HTTPStatus.FORBIDDEN
    app.dependency_overrides.clear()
