"""XPostService coverage: approve posts (idempotent), reject cancels.

Mirrors the release-proposal service tests. The Redis lock helpers are
patched (no live Redis in tests, matching the project's ``_no_live_redis``
fixture) so approve exercises the real post + status-transition path.
"""

from __future__ import annotations

from typing import TYPE_CHECKING, cast
from unittest.mock import AsyncMock, patch
from uuid import uuid4

import pytest
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.base import (
    AgentRole,
    AgentStatus,
    Complexity,
    Team,
)
from roboco.models.base import TaskNature as TN
from roboco.models.base import TaskStatus as TS
from roboco.models.base import TaskType as TT
from roboco.services.task import X_FEATURE_SOURCE, X_POST_SOURCE, X_REPLY_SOURCE
from roboco.services.x_client import XClient, XMention, XPostResult
from roboco.services.x_post_service import (
    TaskAlreadyCompletedError,
    XPostBodyTooLongError,
    XPostExecuteResult,
    XPostService,
    get_x_post_service,
)

if TYPE_CHECKING:
    from uuid import UUID

    from sqlalchemy.ext.asyncio import AsyncSession

SYSTEM_UUID = _foundation.AGENTS["system"].uuid
SECRETARY_UUID = _foundation.AGENTS["secretary-1"].uuid
ONE = 1
TWO = 2


class _StubClient(XClient):
    def __init__(self, *, posted: bool = True, tweet_id: str = "999") -> None:
        self._posted = posted
        self._tweet_id = tweet_id
        self.calls: list[str] = []

    @property
    def configured(self) -> bool:
        return True

    async def post_tweet(self, text: str) -> XPostResult:
        self.calls.append(text)
        if not self._posted:
            return XPostResult(posted=False, tweet_id=None, detail="rejected by X")
        return XPostResult(posted=True, tweet_id=self._tweet_id, detail="posted")

    async def fetch_mentions(
        self, since_id: str | None, max_results: int
    ) -> list[XMention]:
        _ = (since_id, max_results)
        return []


async def _seed_draft(
    session: AsyncSession, *, source: str = X_POST_SOURCE, body: str = "Draft body"
) -> TaskTable:
    for uuid, slug, role in (
        (SYSTEM_UUID, "system", AgentRole.SYSTEM),
        (SECRETARY_UUID, "secretary-1", AgentRole.SECRETARY),
    ):
        if await session.get(AgentTable, uuid) is None:
            session.add(
                AgentTable(
                    id=uuid,
                    name=slug,
                    slug=slug,
                    role=role,
                    team=None,
                    status=AgentStatus.ACTIVE,
                    model_config={},
                    system_prompt="x",
                    capabilities=[],
                    permissions={},
                    metrics={},
                )
            )
    await session.flush()
    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_UUID,
    )
    session.add(project)
    await session.flush()
    task = TaskTable(
        id=uuid4(),
        title="X draft",
        description=body,
        acceptance_criteria=["CEO approves or rejects"],
        status=TS.PENDING,
        priority=2,
        task_type=TT.ADMINISTRATIVE,
        nature=TN.NON_TECHNICAL,
        estimated_complexity=Complexity.LOW,
        project_id=project.id,
        created_by=SYSTEM_UUID,
        assigned_to=SECRETARY_UUID,
        team=Team.MAIN_PM,
        source=source,
        confirmed_by_human=False,
    )
    session.add(task)
    await session.flush()
    markers.set_x_draft_body(task, body)
    await session.flush()
    return task


_FEATURE_SLUG = "org-memory"
_FEATURE_TITLE = "Organizational Memory Loop"


async def _seed_feature_draft(
    session: AsyncSession,
    *,
    wants_video: bool = True,
    video_script: str = "",
    body: str = "Draft body",
) -> TaskTable:
    """An X_FEATURE_SOURCE draft carrying the x_feature_ref marker
    ``propose_feature_spotlight`` stamps (Task 4, 2026-07-09 pipeline fixes):
    slug/title always, plus wants_video/video_script when a companion video
    was requested at authoring time."""
    task = await _seed_draft(session, source=X_FEATURE_SOURCE, body=body)
    markers.set_x_feature_ref(
        task,
        {
            "slug": _FEATURE_SLUG,
            "title": _FEATURE_TITLE,
            "wants_video": wants_video,
            "video_script": video_script,
        },
    )
    await session.flush()
    return task


def _enable_video(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.setattr(cfg, "video_engine_enabled", True)
    monkeypatch.setattr(cfg, "video_on_spotlight", True)


def _svc(session: AsyncSession) -> XPostService:
    return get_x_post_service(session)


def _id(task: TaskTable) -> UUID:
    """The ORM id typed as stdlib ``uuid.UUID`` for service-call sites."""
    return cast("UUID", task.id)


@pytest.mark.asyncio
async def test_approve_posts_and_completes(db_session: AsyncSession) -> None:
    task = await _seed_draft(db_session)
    client = _StubClient()
    with (
        patch("roboco.services.x_post_service.build_x_client", return_value=client),
        patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
        patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
    ):
        result = await _svc(db_session).approve(_id(task))
    assert result is not None
    assert result.status == "posted"
    assert result.tweet_id == "999"
    assert client.calls == ["Draft body"]
    await db_session.refresh(task)
    assert task.status == TS.COMPLETED
    assert markers.get_x_posted_tweet_id(task) == "999"


@pytest.mark.asyncio
async def test_approve_is_idempotent_second_call_is_noop(
    db_session: AsyncSession,
) -> None:
    task = await _seed_draft(db_session)
    client = _StubClient()
    with (
        patch("roboco.services.x_post_service.build_x_client", return_value=client),
        patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
        patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
    ):
        svc = _svc(db_session)
        first = await svc.approve(_id(task))
        second = await svc.approve(_id(task))
    assert first is not None
    assert first.status == "posted"
    assert second is not None
    assert second.status == "already_posted"
    assert second.tweet_id == "999"
    # The X client was called exactly once — the second approve never re-posts.
    assert client.calls == ["Draft body"]


@pytest.mark.asyncio
async def test_approve_with_edited_body_posts_the_edit(
    db_session: AsyncSession,
) -> None:
    task = await _seed_draft(db_session, body="Original")
    client = _StubClient()
    with (
        patch("roboco.services.x_post_service.build_x_client", return_value=client),
        patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
        patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
    ):
        result = await _svc(db_session).approve(_id(task), "Edited body")
    assert result is not None
    assert result.status == "posted"
    assert client.calls == ["Edited body"]


@pytest.mark.asyncio
async def test_approve_rejects_edited_body_over_280_chars(
    db_session: AsyncSession,
) -> None:
    task = await _seed_draft(db_session)
    with pytest.raises(XPostBodyTooLongError):
        await _svc(db_session).approve(_id(task), "x" * 281)


@pytest.mark.asyncio
async def test_approve_no_credentials_result(db_session: AsyncSession) -> None:
    task = await _seed_draft(db_session)
    no_creds_client = _StubClient()

    class _Null(XClient):
        @property
        def configured(self) -> bool:
            return False

        async def post_tweet(self, text: str) -> XPostResult:
            _ = text
            return XPostResult(posted=False, tweet_id=None, detail="no creds")

        async def fetch_mentions(
            self, since_id: str | None, max_results: int
        ) -> list[XMention]:
            _ = (since_id, max_results)
            return []

    _ = no_creds_client
    with (
        patch("roboco.services.x_post_service.build_x_client", return_value=_Null()),
        patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
        patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
    ):
        result = await _svc(db_session).approve(_id(task))
    assert result is not None
    assert result.status == "no_credentials"
    await db_session.refresh(task)
    assert task.status == TS.PENDING  # never advanced without credentials


@pytest.mark.asyncio
async def test_approve_post_failed_keeps_task_open(db_session: AsyncSession) -> None:
    task = await _seed_draft(db_session)
    client = _StubClient(posted=False)
    with (
        patch("roboco.services.x_post_service.build_x_client", return_value=client),
        patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
        patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
    ):
        result = await _svc(db_session).approve(_id(task))
    assert result is not None
    assert result.status == "post_failed"
    await db_session.refresh(task)
    assert task.status == TS.PENDING


@pytest.mark.asyncio
async def test_approve_rechecks_completed_under_lock_and_never_reposts(
    db_session: AsyncSession,
) -> None:
    """The double-post guard: a concurrent approve wins the lock, posts, and
    commits COMPLETED after our pre-lock read. Once we acquire the lock, the
    in-lock re-read must see COMPLETED and short-circuit — never re-posting."""
    task = await _seed_draft(db_session)
    client = _StubClient()

    async def _win_the_race(_self: XPostService, _key: str) -> str:
        # Simulate the concurrent winner: the row is COMPLETED (posted) by the
        # time we hold the lock, exactly as the in-lock expire()+re-fetch sees.
        markers.set_x_posted_tweet_id(task, "111")
        task.status = TS.COMPLETED
        await db_session.flush()
        return "tok"

    with (
        patch("roboco.services.x_post_service.build_x_client", return_value=client),
        patch.object(XPostService, "_acquire_lock", _win_the_race),
        patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
    ):
        result = await _svc(db_session).approve(_id(task))
    assert result is not None
    assert result.status == "already_posted"
    assert result.tweet_id == "111"
    # The tweet was never posted a second time.
    assert client.calls == []


@pytest.mark.asyncio
async def test_approve_concurrent_lock_held_returns_in_progress(
    db_session: AsyncSession,
) -> None:
    task = await _seed_draft(db_session)
    with patch.object(XPostService, "_acquire_lock", AsyncMock(return_value=None)):
        result = await _svc(db_session).approve(_id(task))
    assert result is not None
    assert result.status == "already_in_progress"


@pytest.mark.asyncio
async def test_approve_unknown_task_returns_none(db_session: AsyncSession) -> None:
    result = await _svc(db_session).approve(uuid4())
    assert result is None


@pytest.mark.asyncio
async def test_reject_records_reason_and_cancels(db_session: AsyncSession) -> None:
    task = await _seed_draft(db_session, source=X_REPLY_SOURCE)
    updated = await _svc(db_session).reject(_id(task), "Tone doesn't match our voice")
    assert updated is not None
    assert updated.status == TS.CANCELLED
    assert markers.get_x_reject_reason(updated) == "Tone doesn't match our voice"


@pytest.mark.asyncio
async def test_list_open_posts_excludes_terminal(db_session: AsyncSession) -> None:
    open_task = await _seed_draft(db_session)
    rejected_task = await _seed_draft(db_session, source=X_REPLY_SOURCE)
    await _svc(db_session).reject(_id(rejected_task), "not relevant")
    open_posts = await _svc(db_session).list_open_posts()
    ids = {t.id for t in open_posts}
    assert open_task.id in ids
    assert rejected_task.id not in ids


@pytest.mark.asyncio
async def test_approve_posts_feature_spotlight_draft(
    db_session: AsyncSession,
) -> None:
    """The feature-spotlight source rides the same generic post path as
    x_post/x_reply; it only branches for the best-effort video hook below
    (a no-op here since this draft carries no x_feature_ref marker)."""
    task = await _seed_draft(db_session, source=X_FEATURE_SOURCE)
    client = _StubClient()
    with (
        patch("roboco.services.x_post_service.build_x_client", return_value=client),
        patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
        patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
    ):
        result = await _svc(db_session).approve(_id(task))
    assert result is not None
    assert result.status == "posted"
    assert result.tweet_id == "999"
    assert client.calls == ["Draft body"]
    await db_session.refresh(task)
    assert task.status == TS.COMPLETED
    assert markers.get_x_posted_tweet_id(task) == "999"


@pytest.mark.asyncio
async def test_list_open_posts_includes_feature_spotlight_source(
    db_session: AsyncSession,
) -> None:
    task = await _seed_draft(db_session, source=X_FEATURE_SOURCE)
    open_posts = await _svc(db_session).list_open_posts()
    ids = {t.id for t in open_posts}
    assert task.id in ids


@pytest.mark.asyncio
async def test_reject_completed_raises(db_session: AsyncSession) -> None:
    """An already-posted (COMPLETED) draft is live on X; rejecting it would
    lie "cancelled (never posted)" while the tweet is public."""
    task = await _seed_draft(db_session)
    markers.set_x_posted_tweet_id(task, "999")
    task.status = TS.COMPLETED
    await db_session.flush()
    with pytest.raises(TaskAlreadyCompletedError):
        await _svc(db_session).reject(_id(task), "nope")


@pytest.mark.asyncio
async def test_list_post_history_excludes_open_drafts(
    db_session: AsyncSession,
) -> None:
    open_task = await _seed_draft(db_session)
    rejected_task = await _seed_draft(db_session, source=X_REPLY_SOURCE)
    await _svc(db_session).reject(_id(rejected_task), "not relevant")
    history = await _svc(db_session).list_post_history()
    ids = {t.id for t in history}
    assert rejected_task.id in ids
    assert open_task.id not in ids


@pytest.mark.asyncio
async def test_list_post_history_newest_acted_first(
    db_session: AsyncSession,
) -> None:
    rejected_task = await _seed_draft(db_session, source=X_REPLY_SOURCE)
    await _svc(db_session).reject(_id(rejected_task), "not relevant")
    posted_task = await _seed_draft(db_session)
    client = _StubClient()
    with (
        patch("roboco.services.x_post_service.build_x_client", return_value=client),
        patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
        patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
    ):
        await _svc(db_session).approve(_id(posted_task))
    history = await _svc(db_session).list_post_history()
    ids = [t.id for t in history]
    assert ids.index(posted_task.id) < ids.index(rejected_task.id)


@pytest.mark.asyncio
async def test_list_post_history_includes_marker_fields(
    db_session: AsyncSession,
) -> None:
    posted_task = await _seed_draft(db_session)
    client = _StubClient(tweet_id="777")
    with (
        patch("roboco.services.x_post_service.build_x_client", return_value=client),
        patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
        patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
    ):
        await _svc(db_session).approve(_id(posted_task))
    rejected_task = await _seed_draft(db_session, source=X_REPLY_SOURCE)
    await _svc(db_session).reject(_id(rejected_task), "off-brand tone")

    history = await _svc(db_session).list_post_history()
    by_id = {t.id: t for t in history}
    assert markers.get_x_posted_tweet_id(by_id[posted_task.id]) == "777"
    assert markers.get_x_reject_reason(by_id[rejected_task.id]) == "off-brand tone"


@pytest.mark.asyncio
async def test_list_post_history_respects_limit(db_session: AsyncSession) -> None:
    tasks = []
    for _ in range(3):
        t = await _seed_draft(db_session, source=X_REPLY_SOURCE)
        await _svc(db_session).reject(_id(t), "not relevant")
        tasks.append(t)
    history = await _svc(db_session).list_post_history(limit=2)
    assert len(history) == TWO
    ids = {t.id for t in history}
    assert tasks[2].id in ids
    assert tasks[1].id in ids
    assert tasks[0].id not in ids


@pytest.mark.asyncio
async def test_approve_does_not_flush_edited_body_before_lock(
    db_session: AsyncSession,
) -> None:
    """The CEO's edit must land on the re-read locked row INSIDE the critical
    section, after the COMPLETED check — never on the pre-lock row. A concurrent
    approve that already posted (locked row COMPLETED) must not have this edit
    overwrite the just-posted task's stored body."""
    task = await _seed_draft(db_session, body="Original")
    original_body = markers.get_x_draft_body(task)

    async def _already_posted_locked(
        _self: XPostService, _task_id: UUID, _task: TaskTable, _trimmed: str | None
    ) -> XPostExecuteResult:
        return XPostExecuteResult(
            status="already_posted",
            tweet_id="111",
            detail="this draft was already posted",
        )

    with (
        patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
        patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
        patch.object(XPostService, "_approve_locked", _already_posted_locked),
    ):
        result = await _svc(db_session).approve(_id(task), "new body")
    assert result is not None
    assert result.status == "already_posted"
    await db_session.refresh(task)
    assert markers.get_x_draft_body(task) == original_body


# --------------------------------------------------------------------------- #
# Spotlight video hook (Task 4, 2026-07-09 pipeline fixes): moved from
# authoring time (propose_feature_spotlight) to this posted-success branch so
# a ux-dev never burns a cycle on a spotlight the CEO then rejects.
# --------------------------------------------------------------------------- #


@pytest.mark.asyncio
async def test_approve_feature_spotlight_with_video_opens_video_task(
    db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
    _enable_video(monkeypatch)
    task = await _seed_feature_draft(db_session, video_script="Custom voiceover script")
    client = _StubClient()
    video_engine = AsyncMock()
    video_engine.open_video_task = AsyncMock(return_value=None)
    with (
        patch("roboco.services.x_post_service.build_x_client", return_value=client),
        patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
        patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
        patch(
            "roboco.services.video_engine.get_video_engine",
            return_value=video_engine,
        ),
    ):
        result = await _svc(db_session).approve(_id(task))
    assert result is not None
    assert result.status == "posted"
    video_engine.open_video_task.assert_awaited_once()
    kwargs = video_engine.open_video_task.call_args.kwargs
    assert kwargs["occasion"] == "spotlight org-memory"
    assert kwargs["platforms"] == ["x", "tiktok"]
    assert kwargs["script"] == "Custom voiceover script"
    assert kwargs["brief"] == "Organizational Memory Loop: Draft body"


@pytest.mark.asyncio
async def test_approve_feature_spotlight_video_falls_back_to_brief_script(
    db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
    """No explicit video_script -> script falls back to the brief, mirroring
    the fallback the authoring-time hook used to do."""
    _enable_video(monkeypatch)
    task = await _seed_feature_draft(db_session)
    client = _StubClient()
    video_engine = AsyncMock()
    video_engine.open_video_task = AsyncMock(return_value=None)
    with (
        patch("roboco.services.x_post_service.build_x_client", return_value=client),
        patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
        patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
        patch(
            "roboco.services.video_engine.get_video_engine",
            return_value=video_engine,
        ),
    ):
        result = await _svc(db_session).approve(_id(task))
    assert result is not None
    assert result.status == "posted"
    kwargs = video_engine.open_video_task.call_args.kwargs
    expected_brief = "Organizational Memory Loop: Draft body"
    assert kwargs["script"] == expected_brief
    assert kwargs["brief"] == expected_brief


@pytest.mark.asyncio
async def test_approve_feature_spotlight_reapprove_does_not_reopen_video(
    db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
    """Idempotent re-approve: the second call short-circuits on the already-
    COMPLETED check before ever reaching _post/_open_spotlight_video again."""
    _enable_video(monkeypatch)
    task = await _seed_feature_draft(db_session)
    client = _StubClient()
    video_engine = AsyncMock()
    video_engine.open_video_task = AsyncMock(return_value=None)
    with (
        patch("roboco.services.x_post_service.build_x_client", return_value=client),
        patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
        patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
        patch(
            "roboco.services.video_engine.get_video_engine",
            return_value=video_engine,
        ),
    ):
        svc = _svc(db_session)
        first = await svc.approve(_id(task))
        second = await svc.approve(_id(task))
    assert first is not None
    assert first.status == "posted"
    assert second is not None
    assert second.status == "already_posted"
    video_engine.open_video_task.assert_awaited_once()


@pytest.mark.asyncio
async def test_approve_plain_x_post_never_opens_video(
    db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
    """A plain x_post draft carries no x_feature_ref, so the source check
    alone keeps the video hook from ever firing for it."""
    _enable_video(monkeypatch)
    task = await _seed_draft(db_session, source=X_POST_SOURCE)
    client = _StubClient()
    video_engine = AsyncMock()
    video_engine.open_video_task = AsyncMock(return_value=None)
    with (
        patch("roboco.services.x_post_service.build_x_client", return_value=client),
        patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
        patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
        patch(
            "roboco.services.video_engine.get_video_engine",
            return_value=video_engine,
        ),
    ):
        result = await _svc(db_session).approve(_id(task))
    assert result is not None
    assert result.status == "posted"
    video_engine.open_video_task.assert_not_called()


@pytest.mark.asyncio
async def test_reject_feature_spotlight_with_wants_video_opens_none(
    db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
    """Rejecting a spotlight draft never posts, so the video hook (which only
    fires from the posted-success branch of _post) never runs either."""
    _enable_video(monkeypatch)
    task = await _seed_feature_draft(db_session)
    video_engine = AsyncMock()
    video_engine.open_video_task = AsyncMock(return_value=None)
    with patch(
        "roboco.services.video_engine.get_video_engine",
        return_value=video_engine,
    ):
        updated = await _svc(db_session).reject(_id(task), "not on-brand")
    assert updated is not None
    assert updated.status == TS.CANCELLED
    video_engine.open_video_task.assert_not_called()


@pytest.mark.asyncio
async def test_approve_feature_spotlight_video_flags_off_skips(
    db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
    monkeypatch.setattr(cfg, "video_engine_enabled", False)
    monkeypatch.setattr(cfg, "video_on_spotlight", False)
    task = await _seed_feature_draft(db_session)
    client = _StubClient()
    video_engine = AsyncMock()
    video_engine.open_video_task = AsyncMock(return_value=None)
    with (
        patch("roboco.services.x_post_service.build_x_client", return_value=client),
        patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
        patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
        patch(
            "roboco.services.video_engine.get_video_engine",
            return_value=video_engine,
        ),
    ):
        result = await _svc(db_session).approve(_id(task))
    assert result is not None
    assert result.status == "posted"
    video_engine.open_video_task.assert_not_called()


@pytest.mark.asyncio
async def test_approve_feature_spotlight_without_wants_video_skips(
    db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
    """Flags on but the draft's author didn't request a video (wants_video
    absent/False on the marker) -> no video task, distinct from the
    flags-off case above."""
    _enable_video(monkeypatch)
    task = await _seed_feature_draft(db_session, wants_video=False)
    client = _StubClient()
    video_engine = AsyncMock()
    video_engine.open_video_task = AsyncMock(return_value=None)
    with (
        patch("roboco.services.x_post_service.build_x_client", return_value=client),
        patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
        patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
        patch(
            "roboco.services.video_engine.get_video_engine",
            return_value=video_engine,
        ),
    ):
        result = await _svc(db_session).approve(_id(task))
    assert result is not None
    assert result.status == "posted"
    video_engine.open_video_task.assert_not_called()


@pytest.mark.asyncio
async def test_approve_feature_spotlight_video_failure_does_not_break_post(
    db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
    """Best-effort: a video-engine blow-up must not affect the already-
    succeeded post."""
    _enable_video(monkeypatch)
    task = await _seed_feature_draft(db_session)
    client = _StubClient()
    with (
        patch("roboco.services.x_post_service.build_x_client", return_value=client),
        patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
        patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
        patch(
            "roboco.services.video_engine.get_video_engine",
            side_effect=RuntimeError("video-engine boom"),
        ),
    ):
        result = await _svc(db_session).approve(_id(task))
    assert result is not None
    assert result.status == "posted"
    await db_session.refresh(task)
    assert task.status == TS.COMPLETED
