"""LiveTikTokPoster coverage: the inbox-upload sequence (init -> chunked PUT
-> status poll), the asymmetric final chunk via `plan_chunk_ranges`, and the
OAuth2 refresh-token rotation + persistence path.

Uses the real `db_session` fixture (mirrors test_video_post_service.py) since
the refresh path genuinely persists to the `tiktok_credentials` row — a mock
session can't stand in for that.
"""

from __future__ import annotations

import json
from itertools import pairwise
from typing import TYPE_CHECKING

import httpx
import pytest
from roboco.services.tiktok_client import (
    LiveTikTokPoster,
    build_tiktok_poster,
    plan_chunk_ranges,
)
from roboco.services.tiktok_credentials import (
    TikTokCredentialsData,
    get_tiktok_credentials_service,
)
from roboco.services.video_post_service import NullTikTokPoster
from sqlalchemy.ext.asyncio import (
    AsyncSession,
    async_sessionmaker,
    create_async_engine,
)

if TYPE_CHECKING:
    from pathlib import Path

TWO = 2
FOUR = 4

_CREDS = TikTokCredentialsData(
    client_key="ck-test",
    client_secret="cs-test",
    access_token="at-test",
    refresh_token="rt-test",
)


# ---- plan_chunk_ranges (pure) ---------------------------------------------- #


def test_plan_chunk_ranges_single_chunk_when_within_final_ceiling() -> None:
    assert plan_chunk_ranges(50, chunk_size=64, max_final_chunk=128) == [(0, 50)]


def test_plan_chunk_ranges_folds_the_remainder_into_a_larger_final_chunk() -> None:
    """Interior chunks stay a fixed size; the final one absorbs the
    remainder — larger than the interior size, the documented asymmetry."""
    ranges = plan_chunk_ranges(10, chunk_size=4, max_final_chunk=8)
    assert ranges == [(0, 4), (4, 10)]
    final_start, final_end = ranges[-1]
    assert (final_end - final_start) > FOUR  # bigger than the interior chunk size


def test_plan_chunk_ranges_multiple_interior_chunks() -> None:
    total = 300
    ranges = plan_chunk_ranges(total, chunk_size=64, max_final_chunk=128)
    assert ranges == [(0, 64), (64, 128), (128, 192), (192, 300)]
    assert sum(end - start for start, end in ranges) == total


def test_plan_chunk_ranges_is_contiguous_with_no_gaps_or_overlaps() -> None:
    total = 1000
    ranges = plan_chunk_ranges(total, chunk_size=97, max_final_chunk=150)
    assert ranges[0][0] == 0
    assert ranges[-1][1] == total
    for (_, prev_end), (next_start, _) in pairwise(ranges):
        assert prev_end == next_start


def test_plan_chunk_ranges_empty_file() -> None:
    assert plan_chunk_ranges(0, chunk_size=64, max_final_chunk=128) == [(0, 0)]


# ---- build_tiktok_poster branching ----------------------------------------- #


@pytest.mark.asyncio
async def test_build_without_creds_returns_null(db_session: AsyncSession) -> None:
    poster = build_tiktok_poster(None, session=db_session, timeout=5.0)
    assert isinstance(poster, NullTikTokPoster)
    assert poster.configured is False


@pytest.mark.asyncio
async def test_build_with_creds_returns_live(db_session: AsyncSession) -> None:
    poster = build_tiktok_poster(_CREDS, session=db_session, timeout=5.0)
    assert isinstance(poster, LiveTikTokPoster)
    assert poster.configured is True


@pytest.mark.asyncio
async def test_null_poster_upload_is_a_noop() -> None:
    result = await NullTikTokPoster().upload_to_inbox(
        mp4_path="/tmp/x.mp4", caption="hi"
    )
    assert result.uploaded is False
    assert result.publish_id is None


def _write_clip(tmp_path: Path, data: bytes) -> str:
    mp4 = tmp_path / "clip.mp4"
    mp4.write_bytes(data)
    return str(mp4)


async def _no_sleep(_seconds: float) -> None:
    return None


# ---- upload_to_inbox: full sequence + asymmetric chunk over the wire ------- #


@pytest.mark.asyncio
async def test_upload_to_inbox_full_sequence_with_asymmetric_last_chunk(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch, db_session: AsyncSession
) -> None:
    monkeypatch.setattr("roboco.services.tiktok_client._CHUNK_SIZE_BYTES", 4)
    monkeypatch.setattr("roboco.services.tiktok_client._MAX_FINAL_CHUNK_BYTES", 8)
    monkeypatch.setattr("roboco.services.tiktok_client.asyncio.sleep", _no_sleep)

    put_ranges: list[str] = []
    status_calls = {"count": 0}

    def handler(request: httpx.Request) -> httpx.Response:
        path = request.url.path
        if request.method == "POST" and path.endswith("/inbox/video/init/"):
            assert request.headers["Authorization"] == "Bearer at-test"
            body = json.loads(request.content)["source_info"]
            assert body == {
                "source": "FILE_UPLOAD",
                "video_size": 10,
                "chunk_size": 4,
                "total_chunk_count": TWO,
            }
            return httpx.Response(
                200,
                json={
                    "data": {
                        "publish_id": "pub1",
                        "upload_url": "https://upload.tiktokapis.com/put/pub1",
                    }
                },
            )
        if request.method == "PUT":
            put_ranges.append(request.headers["Content-Range"])
            assert request.headers["Content-Type"] == "video/mp4"
            return httpx.Response(200)
        if request.method == "POST" and path.endswith("/status/fetch/"):
            status_calls["count"] += 1
            state = (
                "PROCESSING_UPLOAD"
                if status_calls["count"] == 1
                else "SEND_TO_USER_INBOX"
            )
            return httpx.Response(200, json={"data": {"status": state}})
        raise AssertionError(f"unexpected request {request.method} {path}")

    transport = httpx.MockTransport(handler)
    http_client = httpx.AsyncClient(transport=transport)
    poster = LiveTikTokPoster(
        _CREDS, session=db_session, timeout=5.0, client=http_client
    )
    result = await poster.upload_to_inbox(
        mp4_path=_write_clip(tmp_path, b"0123456789"), caption="ignored by inbox mode"
    )
    await http_client.aclose()

    assert result.uploaded is True
    assert result.publish_id == "pub1"
    # Interior chunk is 4 bytes; the final chunk absorbs the remainder (6
    # bytes) rather than being sent as its own small trailing chunk.
    assert put_ranges == ["bytes 0-3/10", "bytes 4-9/10"]
    assert status_calls["count"] == TWO  # PROCESSING_UPLOAD once, then terminal


@pytest.mark.asyncio
async def test_upload_publish_failed_status_is_graceful(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch, db_session: AsyncSession
) -> None:
    monkeypatch.setattr("roboco.services.tiktok_client.asyncio.sleep", _no_sleep)

    def handler(request: httpx.Request) -> httpx.Response:
        path = request.url.path
        if path.endswith("/inbox/video/init/"):
            return httpx.Response(
                200, json={"data": {"publish_id": "pub1", "upload_url": "https://u/x"}}
            )
        if request.method == "PUT":
            return httpx.Response(200)
        if path.endswith("/status/fetch/"):
            return httpx.Response(200, json={"data": {"status": "FAILED"}})
        raise AssertionError(f"unexpected request {path}")

    transport = httpx.MockTransport(handler)
    http_client = httpx.AsyncClient(transport=transport)
    poster = LiveTikTokPoster(
        _CREDS, session=db_session, timeout=5.0, client=http_client
    )
    result = await poster.upload_to_inbox(
        mp4_path=_write_clip(tmp_path, b"short"), caption="x"
    )
    await http_client.aclose()

    assert result.uploaded is False
    assert result.publish_id is None
    assert "publish failed" in result.detail


# ---- OAuth2 refresh: rotate + persist -------------------------------------- #


@pytest.mark.asyncio
async def test_upload_refreshes_and_persists_rotated_token_on_401(
    tmp_path: Path, _test_database_url: str, monkeypatch: pytest.MonkeyPatch
) -> None:
    """A 401 on the Bearer-authed init call triggers one refresh + retry; the
    rotated access/refresh token pair must land in the DB row — not just be
    held in the poster's in-memory creds."""
    engine = create_async_engine(_test_database_url, future=True)
    factory = async_sessionmaker(
        bind=engine, class_=AsyncSession, expire_on_commit=False
    )
    monkeypatch.setattr("roboco.db.base.get_session_factory", lambda: factory)

    async with factory() as setup:
        await get_tiktok_credentials_service(setup).set_credentials(
            client_key=_CREDS.client_key,
            client_secret=_CREDS.client_secret,
            access_token=_CREDS.access_token,
            refresh_token=_CREDS.refresh_token,
        )
        await setup.commit()

    init_auth_headers: list[str] = []

    def handler(request: httpx.Request) -> httpx.Response:
        path = request.url.path
        if path.endswith("/oauth/token/"):
            form = dict(httpx.QueryParams(request.content.decode()))
            assert form["grant_type"] == "refresh_token"
            assert form["refresh_token"] == "rt-test"
            return httpx.Response(
                200, json={"access_token": "at-rotated", "refresh_token": "rt-rotated"}
            )
        if path.endswith("/inbox/video/init/"):
            init_auth_headers.append(request.headers["Authorization"])
            if request.headers["Authorization"] == "Bearer at-test":
                return httpx.Response(401, json={"error": "access_token_invalid"})
            return httpx.Response(
                200, json={"data": {"publish_id": "pub2", "upload_url": "https://u/x"}}
            )
        if request.method == "PUT":
            return httpx.Response(200)
        if path.endswith("/status/fetch/"):
            return httpx.Response(200, json={"data": {"status": "SEND_TO_USER_INBOX"}})
        raise AssertionError(f"unexpected request {path}")

    transport = httpx.MockTransport(handler)
    http_client = httpx.AsyncClient(transport=transport)
    async with factory() as caller:
        poster = LiveTikTokPoster(
            _CREDS, session=caller, timeout=5.0, client=http_client
        )
        result = await poster.upload_to_inbox(
            mp4_path=_write_clip(tmp_path, b"short"), caption="x"
        )
    await http_client.aclose()

    assert result.uploaded is True
    assert init_auth_headers == ["Bearer at-test", "Bearer at-rotated"]

    async with factory() as check:
        stored = await get_tiktok_credentials_service(check).get_decrypted()
        assert stored is not None
        assert stored.access_token == "at-rotated"
        assert stored.refresh_token == "rt-rotated"
        assert stored.client_key == _CREDS.client_key  # untouched by the refresh
    await engine.dispose()


@pytest.mark.asyncio
async def test_refresh_falls_back_to_current_refresh_token_when_response_omits_it(
    tmp_path: Path, _test_database_url: str, monkeypatch: pytest.MonkeyPatch
) -> None:
    """TikTok doesn't always rotate the refresh_token — when the grant
    response omits it, the previous one must be kept, not nulled out."""
    engine = create_async_engine(_test_database_url, future=True)
    factory = async_sessionmaker(
        bind=engine, class_=AsyncSession, expire_on_commit=False
    )
    monkeypatch.setattr("roboco.db.base.get_session_factory", lambda: factory)

    async with factory() as setup:
        await get_tiktok_credentials_service(setup).set_credentials(
            client_key=_CREDS.client_key,
            client_secret=_CREDS.client_secret,
            access_token=_CREDS.access_token,
            refresh_token=_CREDS.refresh_token,
        )
        await setup.commit()

    def handler(request: httpx.Request) -> httpx.Response:
        path = request.url.path
        if path.endswith("/oauth/token/"):
            return httpx.Response(200, json={"access_token": "at-rotated"})
        if path.endswith("/inbox/video/init/"):
            if request.headers["Authorization"] == "Bearer at-test":
                return httpx.Response(401, json={"error": "access_token_invalid"})
            return httpx.Response(
                200, json={"data": {"publish_id": "pub3", "upload_url": "https://u/x"}}
            )
        if request.method == "PUT":
            return httpx.Response(200)
        if path.endswith("/status/fetch/"):
            return httpx.Response(200, json={"data": {"status": "SEND_TO_USER_INBOX"}})
        raise AssertionError(f"unexpected request {path}")

    transport = httpx.MockTransport(handler)
    http_client = httpx.AsyncClient(transport=transport)
    async with factory() as caller:
        poster = LiveTikTokPoster(
            _CREDS, session=caller, timeout=5.0, client=http_client
        )
        result = await poster.upload_to_inbox(
            mp4_path=_write_clip(tmp_path, b"x"), caption="x"
        )
    await http_client.aclose()

    assert result.uploaded is True
    async with factory() as check:
        stored = await get_tiktok_credentials_service(check).get_decrypted()
        assert stored is not None
        assert stored.access_token == "at-rotated"
        assert stored.refresh_token == _CREDS.refresh_token  # unchanged, fallback
    await engine.dispose()


@pytest.mark.asyncio
async def test_refresh_commits_rotated_tokens_independently_of_caller_session(
    _test_database_url: str, monkeypatch: pytest.MonkeyPatch
) -> None:
    """A lock-loss rollback of the caller's session must not discard the
    rotated tokens. TikTok already invalidated the old refresh_token, so a
    rollback that drops the rotation is a permanent credential lockout —
    `_refresh` must commit in an independent session.
    """
    engine = create_async_engine(_test_database_url, future=True)
    factory = async_sessionmaker(
        bind=engine, class_=AsyncSession, expire_on_commit=False
    )
    monkeypatch.setattr("roboco.db.base.get_session_factory", lambda: factory)

    # Seed the singleton row with refresh_token="OLD" and commit so the fresh
    # session opened inside _refresh can see it.
    async with factory() as setup:
        await get_tiktok_credentials_service(setup).set_credentials(
            client_key="ck",
            client_secret="cs",
            access_token="at-old",
            refresh_token="OLD",
        )
        await setup.commit()

    def handler(request: httpx.Request) -> httpx.Response:
        assert request.url.path.endswith("/oauth/token/")
        form = dict(httpx.QueryParams(request.content.decode()))
        assert form["grant_type"] == "refresh_token"
        assert form["refresh_token"] == "OLD"
        return httpx.Response(
            200, json={"access_token": "at-new", "refresh_token": "NEW"}
        )

    http_client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
    # Caller session — separate from the fresh session _refresh commits in.
    async with factory() as caller:
        poster = LiveTikTokPoster(
            TikTokCredentialsData(
                client_key="ck",
                client_secret="cs",
                access_token="at-old",
                refresh_token="OLD",
            ),
            session=caller,
            timeout=5.0,
            client=http_client,
        )
        await poster._refresh(http_client)
        # Simulate the lock-loss rollback in _approve_locked.
        await caller.rollback()
    await http_client.aclose()

    # The persisted row must hold NEW, not OLD — the rollback must not have
    # discarded the rotation.
    async with factory() as check:
        stored = await get_tiktok_credentials_service(check).get_decrypted()
        assert stored is not None
        assert stored.access_token == "at-new"
        assert stored.refresh_token == "NEW"
    await engine.dispose()
