"""Tests for execution/providers/sync_so.py (CP-8 Phase 2).

All transport is mocked via the `transport=` kwarg. NO live API calls.
Real temp files (`tmp_path`) are used for video_path/audio_path inputs so
the .is_file() pre-flight check passes.
"""

from __future__ import annotations

import json
import urllib.error
from typing import Optional

import pytest

from recoil.execution.providers import sync_so as syncso
from recoil.execution.providers.sync_so import (
    lipsync_video,
    LipSyncResult,
    AuthError,
    QuotaError,
    PayloadError,
    JobFailedError,
    JobTimeoutError,
)


# --------------------------------------------------------------------------
# Mock transport plumbing
# --------------------------------------------------------------------------

class _MockResponse:
    """Mimics the urlopen-returned context-managed response object."""

    def __init__(self, *, status: int = 200, body: bytes = b"",
                 headers: Optional[dict] = None):
        self.status = status
        self._body = body
        self.headers = headers or {}

    def read(self) -> bytes:
        return self._body

    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc, tb):
        return False


class _MockTransport:
    """Records calls; returns queued responses or raises queued exceptions."""

    def __init__(self, *responses):
        self._responses = list(responses)
        self.calls: list[dict] = []
        self.call_count = 0

    def __call__(self, url, *, headers, body, method="GET", timeout=60.0):
        self.call_count += 1
        self.calls.append({
            "url": url,
            "headers": dict(headers),
            "body": body,
            "method": method,
            "timeout": timeout,
        })
        if not self._responses:
            raise AssertionError(f"MockTransport ran out of responses at call {self.call_count} ({method} {url})")
        nxt = self._responses.pop(0)
        if isinstance(nxt, BaseException):
            raise nxt
        return nxt


def _json_resp(status: int, payload: dict) -> _MockResponse:
    return _MockResponse(status=status, body=json.dumps(payload).encode("utf-8"))


def _bytes_resp(data: bytes) -> _MockResponse:
    return _MockResponse(status=200, body=data)


@pytest.fixture(autouse=True)
def _set_api_key(monkeypatch):
    monkeypatch.setenv("SYNC_SO_API_KEY", "test-key-456")


@pytest.fixture(autouse=True)
def _no_sleep(monkeypatch):
    """Skip backoff and poll-interval sleeps."""
    monkeypatch.setattr(syncso.time, "sleep", lambda *_: None)


@pytest.fixture
def video_file(tmp_path):
    p = tmp_path / "input.mp4"
    p.write_bytes(b"FAKE_MP4_BYTES")
    return p


@pytest.fixture
def audio_file(tmp_path):
    p = tmp_path / "input.wav"
    p.write_bytes(b"FAKE_WAV_BYTES")
    return p


# --------------------------------------------------------------------------
# Tests
# --------------------------------------------------------------------------

def test_happy_path_full_protocol(tmp_path, video_file, audio_file):
    """upload×2 → submit → poll(PROCESSING) → poll(COMPLETED) → download."""
    final_bytes = b"FINAL_LIPSYNC_VIDEO"
    transport = _MockTransport(
        _json_resp(200, {"url": "https://cdn.sync.so/uploaded/video.mp4"}),
        _json_resp(200, {"url": "https://cdn.sync.so/uploaded/audio.wav"}),
        _json_resp(201, {"id": "job_xyz_999"}),
        _json_resp(200, {"status": "PROCESSING"}),
        _json_resp(200, {
            "status": "COMPLETED",
            "outputUrl": "https://cdn.sync.so/done/result.mp4",
            "duration_s": 12.5,
        }),
        _bytes_resp(final_bytes),
    )
    out_dir = tmp_path / "out"
    result = lipsync_video(
        video_path=video_file,
        audio_path=audio_file,
        output_dir=out_dir,
        file_stem="lipsynced",
        transport=transport,
    )
    assert isinstance(result, LipSyncResult)
    assert result.output_path == out_dir / "lipsynced.mp4"
    assert result.output_path.read_bytes() == final_bytes
    assert result.job_id == "job_xyz_999"
    assert result.duration_s == 12.5
    assert result.model == "lipsync-2.0"
    # 6 transport calls: upload, upload, submit, poll, poll, download
    assert transport.call_count == 6
    # Method/URL spot-checks
    assert transport.calls[0]["url"] == syncso.UPLOAD_URL
    assert transport.calls[0]["method"] == "POST"
    assert transport.calls[1]["url"] == syncso.UPLOAD_URL
    assert transport.calls[2]["url"] == syncso.GENERATE_URL
    assert transport.calls[2]["method"] == "POST"
    assert transport.calls[3]["url"] == f"{syncso.GENERATE_URL}/job_xyz_999"
    assert transport.calls[3]["method"] == "GET"
    assert transport.calls[4]["url"] == f"{syncso.GENERATE_URL}/job_xyz_999"
    assert transport.calls[5]["url"] == "https://cdn.sync.so/done/result.mp4"
    assert transport.calls[5]["method"] == "GET"


def test_missing_api_key_raises_auth_error(tmp_path, video_file, audio_file, monkeypatch):
    monkeypatch.delenv("SYNC_SO_API_KEY", raising=False)
    transport = _MockTransport()
    with pytest.raises(AuthError):
        lipsync_video(
            video_path=video_file, audio_path=audio_file,
            output_dir=tmp_path, file_stem="x", transport=transport,
        )
    assert transport.call_count == 0


def test_missing_video_path_raises_payload_error(tmp_path, audio_file):
    transport = _MockTransport()
    with pytest.raises(PayloadError):
        lipsync_video(
            video_path=tmp_path / "nope.mp4",
            audio_path=audio_file,
            output_dir=tmp_path, file_stem="x", transport=transport,
        )
    assert transport.call_count == 0


def test_missing_audio_path_raises_payload_error(tmp_path, video_file):
    transport = _MockTransport()
    with pytest.raises(PayloadError):
        lipsync_video(
            video_path=video_file,
            audio_path=tmp_path / "nope.wav",
            output_dir=tmp_path, file_stem="x", transport=transport,
        )
    assert transport.call_count == 0


def test_upload_401_fail_fast(tmp_path, video_file, audio_file):
    err = urllib.error.HTTPError(url="x", code=401, msg="auth", hdrs=None, fp=None)
    transport = _MockTransport(err)
    with pytest.raises(AuthError):
        lipsync_video(
            video_path=video_file, audio_path=audio_file,
            output_dir=tmp_path, file_stem="x", transport=transport,
        )
    assert transport.call_count == 1


def test_submit_402_fail_fast(tmp_path, video_file, audio_file):
    err = urllib.error.HTTPError(url="x", code=402, msg="quota", hdrs=None, fp=None)
    transport = _MockTransport(
        _json_resp(200, {"url": "https://u/v.mp4"}),
        _json_resp(200, {"url": "https://u/a.wav"}),
        err,
    )
    with pytest.raises(QuotaError):
        lipsync_video(
            video_path=video_file, audio_path=audio_file,
            output_dir=tmp_path, file_stem="x", transport=transport,
        )
    # 2 uploads + 1 submit attempt that fails fast
    assert transport.call_count == 3


def test_submit_422_fail_fast(tmp_path, video_file, audio_file):
    err = urllib.error.HTTPError(url="x", code=422, msg="bad", hdrs=None, fp=None)
    transport = _MockTransport(
        _json_resp(200, {"url": "https://u/v.mp4"}),
        _json_resp(200, {"url": "https://u/a.wav"}),
        err,
    )
    with pytest.raises(PayloadError):
        lipsync_video(
            video_path=video_file, audio_path=audio_file,
            output_dir=tmp_path, file_stem="x", transport=transport,
        )
    assert transport.call_count == 3


def test_poll_failed_raises_job_failed_error(tmp_path, video_file, audio_file):
    transport = _MockTransport(
        _json_resp(200, {"url": "https://u/v.mp4"}),
        _json_resp(200, {"url": "https://u/a.wav"}),
        _json_resp(201, {"id": "job_1"}),
        _json_resp(200, {"status": "FAILED", "error": "model_disagreement"}),
    )
    with pytest.raises(JobFailedError) as exc_info:
        lipsync_video(
            video_path=video_file, audio_path=audio_file,
            output_dir=tmp_path, file_stem="x", transport=transport,
        )
    assert "model_disagreement" in str(exc_info.value)
    assert transport.call_count == 4


def test_poll_never_terminal_times_out(tmp_path, video_file, audio_file, monkeypatch):
    # Simulate time advancing past timeout_s on every check.
    times = iter([0.0, 0.05, 0.2, 0.5, 1.0])
    monkeypatch.setattr(syncso.time, "time", lambda: next(times))

    # Lots of PROCESSING responses; the timeout check fires before poll runs again.
    procs = [_json_resp(200, {"status": "PROCESSING"}) for _ in range(10)]
    transport = _MockTransport(
        _json_resp(200, {"url": "https://u/v.mp4"}),
        _json_resp(200, {"url": "https://u/a.wav"}),
        _json_resp(201, {"id": "job_t"}),
        *procs,
    )
    with pytest.raises(JobTimeoutError):
        lipsync_video(
            video_path=video_file, audio_path=audio_file,
            output_dir=tmp_path, file_stem="x",
            timeout_s=0.1,
            transport=transport,
        )


def test_upload_500_retried_then_succeeds(tmp_path, video_file, audio_file):
    err = lambda: urllib.error.HTTPError(url="x", code=500, msg="srv", hdrs=None, fp=None)  # noqa: E731
    transport = _MockTransport(
        err(), err(), err(),
        _json_resp(200, {"url": "https://u/v.mp4"}),  # video upload eventually works
        _json_resp(200, {"url": "https://u/a.wav"}),
        _json_resp(201, {"id": "job_1"}),
        _json_resp(200, {"status": "COMPLETED", "outputUrl": "https://o/r.mp4"}),
        _bytes_resp(b"OUT"),
    )
    result = lipsync_video(
        video_path=video_file, audio_path=audio_file,
        output_dir=tmp_path, file_stem="x", transport=transport,
    )
    assert result.output_path.read_bytes() == b"OUT"
    # 3 failed uploads + 1 success + audio upload + submit + poll + download = 8
    assert transport.call_count == 8


def test_submit_500_retried_then_succeeds(tmp_path, video_file, audio_file):
    err = lambda: urllib.error.HTTPError(url="x", code=500, msg="srv", hdrs=None, fp=None)  # noqa: E731
    transport = _MockTransport(
        _json_resp(200, {"url": "https://u/v.mp4"}),
        _json_resp(200, {"url": "https://u/a.wav"}),
        err(), err(),
        _json_resp(201, {"id": "job_1"}),
        _json_resp(200, {"status": "COMPLETED", "outputUrl": "https://o/r.mp4"}),
        _bytes_resp(b"OUT"),
    )
    result = lipsync_video(
        video_path=video_file, audio_path=audio_file,
        output_dir=tmp_path, file_stem="x", transport=transport,
    )
    assert result.output_path.read_bytes() == b"OUT"
    assert transport.call_count == 7


def test_poll_500_retried_then_succeeds(tmp_path, video_file, audio_file):
    err = lambda: urllib.error.HTTPError(url="x", code=500, msg="srv", hdrs=None, fp=None)  # noqa: E731
    transport = _MockTransport(
        _json_resp(200, {"url": "https://u/v.mp4"}),
        _json_resp(200, {"url": "https://u/a.wav"}),
        _json_resp(201, {"id": "job_1"}),
        err(), err(),
        _json_resp(200, {"status": "COMPLETED", "outputUrl": "https://o/r.mp4"}),
        _bytes_resp(b"OUT"),
    )
    result = lipsync_video(
        video_path=video_file, audio_path=audio_file,
        output_dir=tmp_path, file_stem="x", transport=transport,
    )
    assert result.output_path.read_bytes() == b"OUT"
    assert transport.call_count == 7


def test_download_500_retried_then_succeeds(tmp_path, video_file, audio_file):
    err = lambda: urllib.error.HTTPError(url="x", code=500, msg="srv", hdrs=None, fp=None)  # noqa: E731
    transport = _MockTransport(
        _json_resp(200, {"url": "https://u/v.mp4"}),
        _json_resp(200, {"url": "https://u/a.wav"}),
        _json_resp(201, {"id": "job_1"}),
        _json_resp(200, {"status": "COMPLETED", "outputUrl": "https://o/r.mp4"}),
        err(), err(), err(),
        _bytes_resp(b"OUT"),
    )
    result = lipsync_video(
        video_path=video_file, audio_path=audio_file,
        output_dir=tmp_path, file_stem="x", transport=transport,
    )
    assert result.output_path.read_bytes() == b"OUT"
    assert transport.call_count == 8


def test_submit_body_shape(tmp_path, video_file, audio_file):
    transport = _MockTransport(
        _json_resp(200, {"url": "https://u/v.mp4"}),
        _json_resp(200, {"url": "https://u/a.wav"}),
        _json_resp(201, {"id": "j"}),
        _json_resp(200, {"status": "COMPLETED", "outputUrl": "https://o/r.mp4"}),
        _bytes_resp(b"X"),
    )
    lipsync_video(
        video_path=video_file, audio_path=audio_file,
        model_id="lipsync-2.0",
        output_dir=tmp_path, file_stem="x",
        sync_mode="bounce", fps=30,
        transport=transport,
    )
    submit_call = transport.calls[2]
    body = json.loads(submit_call["body"].decode("utf-8"))
    assert body["model"] == "lipsync-2.0"
    assert isinstance(body["input"], list)
    assert {b["type"] for b in body["input"]} == {"video", "audio"}
    assert body["input"][0]["url"] == "https://u/v.mp4"
    assert body["input"][1]["url"] == "https://u/a.wav"
    assert body["options"]["sync_mode"] == "bounce"
    assert body["options"]["fps"] == 30


def test_submit_body_omits_none_fps(tmp_path, video_file, audio_file):
    transport = _MockTransport(
        _json_resp(200, {"url": "https://u/v.mp4"}),
        _json_resp(200, {"url": "https://u/a.wav"}),
        _json_resp(201, {"id": "j"}),
        _json_resp(200, {"status": "COMPLETED", "outputUrl": "https://o/r.mp4"}),
        _bytes_resp(b"X"),
    )
    lipsync_video(
        video_path=video_file, audio_path=audio_file,
        output_dir=tmp_path, file_stem="x",
        fps=None,
        transport=transport,
    )
    body = json.loads(transport.calls[2]["body"].decode("utf-8"))
    assert "fps" not in body["options"]
    assert "sync_mode" in body["options"]


def test_sync_mode_default_loop(tmp_path, video_file, audio_file):
    transport = _MockTransport(
        _json_resp(200, {"url": "https://u/v.mp4"}),
        _json_resp(200, {"url": "https://u/a.wav"}),
        _json_resp(201, {"id": "j"}),
        _json_resp(200, {"status": "COMPLETED", "outputUrl": "https://o/r.mp4"}),
        _bytes_resp(b"X"),
    )
    lipsync_video(
        video_path=video_file, audio_path=audio_file,
        output_dir=tmp_path, file_stem="x",
        transport=transport,
    )
    body = json.loads(transport.calls[2]["body"].decode("utf-8"))
    assert body["options"]["sync_mode"] == "loop"


def test_output_path_extension_matches_output_format(tmp_path, video_file, audio_file):
    transport = _MockTransport(
        _json_resp(200, {"url": "https://u/v.mp4"}),
        _json_resp(200, {"url": "https://u/a.wav"}),
        _json_resp(201, {"id": "j"}),
        _json_resp(200, {"status": "COMPLETED", "outputUrl": "https://o/r.mov"}),
        _bytes_resp(b"X"),
    )
    result = lipsync_video(
        video_path=video_file, audio_path=audio_file,
        output_dir=tmp_path, file_stem="x",
        output_format="mov",
        transport=transport,
    )
    assert result.output_path.suffix == ".mov"


def test_cost_compute_from_duration(tmp_path, video_file, audio_file, monkeypatch):
    import sys
    import types
    fake = types.ModuleType("recoil.core.model_profiles")

    def fake_get_profile(model_id):
        assert model_id == "lipsync-2.0"
        return {"cost_per_second": 0.10}

    fake.get_profile = fake_get_profile
    monkeypatch.setitem(sys.modules, "recoil.core.model_profiles", fake)

    transport = _MockTransport(
        _json_resp(200, {"url": "https://u/v.mp4"}),
        _json_resp(200, {"url": "https://u/a.wav"}),
        _json_resp(201, {"id": "j"}),
        _json_resp(200, {
            "status": "COMPLETED",
            "outputUrl": "https://o/r.mp4",
            "duration_s": 7.0,
        }),
        _bytes_resp(b"X"),
    )
    result = lipsync_video(
        video_path=video_file, audio_path=audio_file,
        output_dir=tmp_path, file_stem="x", transport=transport,
    )
    assert result.cost_usd == pytest.approx(0.70)


def test_cost_compute_returns_zero_when_no_duration(tmp_path, video_file, audio_file):
    transport = _MockTransport(
        _json_resp(200, {"url": "https://u/v.mp4"}),
        _json_resp(200, {"url": "https://u/a.wav"}),
        _json_resp(201, {"id": "j"}),
        _json_resp(200, {"status": "COMPLETED", "outputUrl": "https://o/r.mp4"}),
        _bytes_resp(b"X"),
    )
    result = lipsync_video(
        video_path=video_file, audio_path=audio_file,
        output_dir=tmp_path, file_stem="x", transport=transport,
    )
    assert result.duration_s is None
    assert result.cost_usd == 0.0


def test_upload_presigned_url_alt_key(tmp_path, video_file, audio_file):
    """data has key 'presigned_url' instead of 'url' — still extracted."""
    transport = _MockTransport(
        _json_resp(200, {"presigned_url": "https://u/v.mp4"}),
        _json_resp(200, {"presigned_url": "https://u/a.wav"}),
        _json_resp(201, {"id": "j"}),
        _json_resp(200, {"status": "COMPLETED", "outputUrl": "https://o/r.mp4"}),
        _bytes_resp(b"X"),
    )
    result = lipsync_video(
        video_path=video_file, audio_path=audio_file,
        output_dir=tmp_path, file_stem="x", transport=transport,
    )
    submit_body = json.loads(transport.calls[2]["body"].decode("utf-8"))
    assert submit_body["input"][0]["url"] == "https://u/v.mp4"
    assert submit_body["input"][1]["url"] == "https://u/a.wav"
    assert isinstance(result, LipSyncResult)


def test_submit_job_id_alt_key(tmp_path, video_file, audio_file):
    """submit returns 'job_id' instead of 'id' — still extracted."""
    transport = _MockTransport(
        _json_resp(200, {"url": "https://u/v.mp4"}),
        _json_resp(200, {"url": "https://u/a.wav"}),
        _json_resp(201, {"job_id": "alt_id_88"}),
        _json_resp(200, {"status": "COMPLETED", "outputUrl": "https://o/r.mp4"}),
        _bytes_resp(b"X"),
    )
    result = lipsync_video(
        video_path=video_file, audio_path=audio_file,
        output_dir=tmp_path, file_stem="x", transport=transport,
    )
    assert result.job_id == "alt_id_88"
    # Poll URL must have used the alt id.
    assert transport.calls[3]["url"] == f"{syncso.GENERATE_URL}/alt_id_88"


def test_output_url_alt_key(tmp_path, video_file, audio_file):
    """status returns 'output_url' instead of 'outputUrl' — still extracted."""
    transport = _MockTransport(
        _json_resp(200, {"url": "https://u/v.mp4"}),
        _json_resp(200, {"url": "https://u/a.wav"}),
        _json_resp(201, {"id": "j"}),
        _json_resp(200, {"status": "COMPLETED", "output_url": "https://o/alt.mp4"}),
        _bytes_resp(b"ALT"),
    )
    result = lipsync_video(
        video_path=video_file, audio_path=audio_file,
        output_dir=tmp_path, file_stem="x", transport=transport,
    )
    assert result.output_path.read_bytes() == b"ALT"
    # Order: upload(0), upload(1), submit(2), poll(3), download(4)
    assert transport.calls[4]["url"] == "https://o/alt.mp4"


def test_poll_interval_honored(tmp_path, video_file, audio_file, monkeypatch):
    """Count poll calls — 4 PROCESSING then COMPLETED -> 5 polls."""
    sleep_calls: list[float] = []
    monkeypatch.setattr(syncso.time, "sleep", lambda s: sleep_calls.append(s))

    transport = _MockTransport(
        _json_resp(200, {"url": "https://u/v.mp4"}),
        _json_resp(200, {"url": "https://u/a.wav"}),
        _json_resp(201, {"id": "j"}),
        _json_resp(200, {"status": "PROCESSING"}),
        _json_resp(200, {"status": "PROCESSING"}),
        _json_resp(200, {"status": "PROCESSING"}),
        _json_resp(200, {"status": "PROCESSING"}),
        _json_resp(200, {"status": "COMPLETED", "outputUrl": "https://o/r.mp4"}),
        _bytes_resp(b"X"),
    )
    result = lipsync_video(
        video_path=video_file, audio_path=audio_file,
        output_dir=tmp_path, file_stem="x",
        poll_interval_s=0.001,
        transport=transport,
    )
    assert isinstance(result, LipSyncResult)
    # 4 sleeps between the 4 non-terminal polls (the 5th poll is COMPLETED -> break).
    assert sleep_calls.count(0.001) == 4
