from __future__ import annotations

import asyncio
from pathlib import Path

import pytest

from screenshot import (
    ScreenshotBatchResult,
    ScreenshotCaptureResult,
    ScreenshotJob,
    ScreenshotOptions,
    ScreenshotService,
)
from screenshot._shared.errors import make_error
from screenshot.models import CaptureOptions


class RecordingBackend:
    def __init__(self) -> None:
        self.jobs: list[ScreenshotJob] = []

    async def run_job_async(self, job: ScreenshotJob) -> ScreenshotCaptureResult:
        self.jobs.append(job)
        return ScreenshotCaptureResult(
            requested=1,
            captured=1,
            failed=0,
            metadata_path=None,
            entries=[],
            errors=[],
            job_id=job.job_id,
        )


@pytest.mark.asyncio
async def test_screenshot_service_capture_async(tmp_path: Path) -> None:
    backend = RecordingBackend()
    service = ScreenshotService(backend, concurrency=2)
    jobs = [
        ScreenshotJob(
            job_id=f"job-{index}",
            url=f"https://example.com/{index}",
            output_root=tmp_path,
            options=ScreenshotOptions(capture=CaptureOptions(enabled=True)),
        )
        for index in range(3)
    ]

    result = await service.capture_async(jobs)

    assert isinstance(result, ScreenshotBatchResult)
    assert len(result.results) == 3
    assert len(result.failures) == 0
    assert [job.job_id for job in backend.jobs] == [job.job_id for job in jobs]


def test_screenshot_service_capture_sync(tmp_path: Path) -> None:
    backend = RecordingBackend()
    service = ScreenshotService(backend, concurrency=1)
    job = ScreenshotJob(
        job_id="sync",
        url="https://example.com",
        output_root=tmp_path,
        options=ScreenshotOptions(capture=CaptureOptions(enabled=True)),
    )

    result = service.capture([job])

    assert len(result.results) == 1
    assert result.results[0].job_id == "sync"
    assert backend.jobs[0].job_id == "sync"


@pytest.mark.asyncio
async def test_screenshot_service_capture_sync_rejects_running_event_loop(tmp_path: Path) -> None:
    class ShouldNotRunBackend:
        async def run_job_async(self, job: ScreenshotJob) -> ScreenshotCaptureResult:
            raise AssertionError(f"backend should not run for {job.job_id}")

    service = ScreenshotService(ShouldNotRunBackend(), concurrency=1)
    job = ScreenshotJob(
        job_id="sync-guard",
        url="https://example.com",
        output_root=tmp_path,
        options=ScreenshotOptions(capture=CaptureOptions(enabled=True)),
    )

    with pytest.raises(
        RuntimeError,
        match=r"ScreenshotService\.capture\(\) cannot be called from async code; "
        r"use capture_async\(\) instead",
    ):
        service.capture([job])


def test_screenshot_service_requires_positive_concurrency() -> None:
    backend = RecordingBackend()
    with pytest.raises(ValueError):
        ScreenshotService(backend, concurrency=0)


@pytest.mark.asyncio
async def test_screenshot_service_capture_async_requires_positive_override(
    tmp_path: Path,
) -> None:
    service = ScreenshotService(RecordingBackend(), concurrency=2)
    job = ScreenshotJob(
        job_id="job-1",
        url="https://example.com",
        output_root=tmp_path,
        options=ScreenshotOptions(capture=CaptureOptions(enabled=True)),
    )

    with pytest.raises(ValueError, match="concurrency must be positive"):
        await service.capture_async([job], concurrency=0)

    with pytest.raises(ValueError, match="concurrency must be positive"):
        await service.capture_async([job], concurrency=-1)


class NullJobIdBackend:
    def __init__(self) -> None:
        self.jobs: list[ScreenshotJob] = []

    async def run_job_async(self, job: ScreenshotJob) -> ScreenshotCaptureResult:
        self.jobs.append(job)
        return ScreenshotCaptureResult(
            requested=1,
            captured=0,
            failed=1,
            metadata_path=None,
            entries=[],
            errors=[make_error("runtime", "failed")],
            job_id=None,
        )


@pytest.mark.asyncio
async def test_screenshot_service_backfills_job_id(tmp_path: Path) -> None:
    backend = NullJobIdBackend()
    service = ScreenshotService(backend, concurrency=2)
    job = ScreenshotJob(
        job_id="job-123",
        url="https://example.com",
        output_root=tmp_path,
        options=ScreenshotOptions(capture=CaptureOptions(enabled=True)),
    )

    result = await service.capture_async([job])

    assert result.results[0].job_id == "job-123"
    assert backend.jobs[0].job_id == "job-123"


@pytest.mark.asyncio
async def test_screenshot_service_checks_cancel_token(tmp_path: Path) -> None:
    class CountingToken:
        def __init__(self) -> None:
            self.calls = 0

        def raise_if_cancelled(self) -> None:
            self.calls += 1

    token = CountingToken()
    backend = RecordingBackend()
    service = ScreenshotService(backend, concurrency=1)
    jobs = [
        ScreenshotJob(
            job_id=f"job-{index}",
            url=f"https://example.com/{index}",
            output_root=tmp_path,
            options=ScreenshotOptions(capture=CaptureOptions(enabled=True)),
        )
        for index in range(2)
    ]

    await service.capture_async(jobs, cancel_token=token)

    assert token.calls == len(jobs) * 2


@pytest.mark.asyncio
async def test_screenshot_service_handles_no_jobs() -> None:
    backend = RecordingBackend()
    service = ScreenshotService(backend, concurrency=1)

    result = await service.capture_async([])

    assert result.results == []


class FailingBackend:
    async def run_job_async(self, job: ScreenshotJob) -> ScreenshotCaptureResult:
        raise RuntimeError(f"boom-{job.job_id}")


@pytest.mark.asyncio
async def test_screenshot_service_converts_backend_error(tmp_path: Path) -> None:
    backend = FailingBackend()
    service = ScreenshotService(backend, concurrency=1)
    job = ScreenshotJob(
        job_id="err",
        url="https://example.com",
        output_root=tmp_path,
        options=ScreenshotOptions(capture=CaptureOptions(enabled=True)),
    )

    result = await service.capture_async([job])

    assert len(result.results) == 1
    failure = result.results[0]
    assert failure.job_id == "err"
    assert failure.failed == 1
    assert failure.errors and "boom-err" in failure.errors[0].message


class CancelledBackend:
    async def run_job_async(
        self, job: ScreenshotJob
    ) -> ScreenshotCaptureResult:  # pragma: no cover
        raise asyncio.CancelledError()


@pytest.mark.asyncio
async def test_screenshot_service_propagates_cancel(tmp_path: Path) -> None:
    backend = CancelledBackend()
    service = ScreenshotService(backend, concurrency=1)
    job = ScreenshotJob(
        job_id="cancel",
        url="https://example.com",
        output_root=tmp_path,
        options=ScreenshotOptions(capture=CaptureOptions(enabled=True)),
    )

    with pytest.raises(asyncio.CancelledError):
        await service.capture_async([job])


@pytest.mark.asyncio
async def test_screenshot_service_preserves_input_order(tmp_path: Path) -> None:
    class DelayedBackend:
        async def run_job_async(self, job: ScreenshotJob) -> ScreenshotCaptureResult:
            if job.job_id.endswith("1"):
                await asyncio.sleep(0.05)
            return ScreenshotCaptureResult(
                requested=1,
                captured=1,
                failed=0,
                metadata_path=None,
                entries=[],
                errors=[],
                job_id=job.job_id,
            )

    jobs = [
        ScreenshotJob(
            job_id=f"job-{index}",
            url=f"https://example.com/{index}",
            output_root=tmp_path,
            options=ScreenshotOptions(capture=CaptureOptions(enabled=True)),
        )
        for index in range(3)
    ]

    result = await ScreenshotService(DelayedBackend(), concurrency=3).capture_async(jobs)

    assert [item.job_id for item in result.results] == [job.job_id for job in jobs]


@pytest.mark.asyncio
async def test_screenshot_service_stops_queued_jobs_after_cancellation(tmp_path: Path) -> None:
    class TokenCancelled(RuntimeError):
        pass

    class ToggleToken:
        def __init__(self) -> None:
            self.cancelled = False

        def raise_if_cancelled(self) -> None:
            if self.cancelled:
                raise TokenCancelled("cancelled")

    started = asyncio.Event()
    release = asyncio.Event()
    ran_jobs: list[str] = []

    class BlockingBackend:
        async def run_job_async(self, job: ScreenshotJob) -> ScreenshotCaptureResult:
            ran_jobs.append(job.job_id)
            if job.job_id == "job-1":
                started.set()
                await release.wait()
            return ScreenshotCaptureResult(
                requested=1,
                captured=1,
                failed=0,
                metadata_path=None,
                entries=[],
                errors=[],
                job_id=job.job_id,
            )

    jobs = [
        ScreenshotJob(
            job_id="job-1",
            url="https://example.com/1",
            output_root=tmp_path,
            options=ScreenshotOptions(capture=CaptureOptions(enabled=True)),
        ),
        ScreenshotJob(
            job_id="job-2",
            url="https://example.com/2",
            output_root=tmp_path,
            options=ScreenshotOptions(capture=CaptureOptions(enabled=True)),
        ),
    ]
    token = ToggleToken()
    service = ScreenshotService(BlockingBackend(), concurrency=1)
    task = asyncio.create_task(service.capture_async(jobs, cancel_token=token))

    await started.wait()
    token.cancelled = True
    release.set()

    with pytest.raises(TokenCancelled):
        await task

    assert ran_jobs == ["job-1"]


@pytest.mark.asyncio
async def test_screenshot_service_composite_cancel_token_exposes_cancelled_state(
    tmp_path: Path,
) -> None:
    class ObservableToken:
        def __init__(self, *, cancelled: bool) -> None:
            self.is_cancelled = cancelled

        def raise_if_cancelled(self) -> None:
            return None

    observed_states: list[bool] = []

    class InspectingBackend:
        async def run_job_async(self, job: ScreenshotJob) -> ScreenshotCaptureResult:
            observed_states.append(bool(getattr(job.cancel_token, "is_cancelled", False)))
            return ScreenshotCaptureResult(
                requested=1,
                captured=1,
                failed=0,
                metadata_path=None,
                entries=[],
                errors=[],
                job_id=job.job_id,
            )

    batch_token = ObservableToken(cancelled=False)
    job_token = ObservableToken(cancelled=True)
    job = ScreenshotJob(
        job_id="job-1",
        url="https://example.com/1",
        output_root=tmp_path,
        options=ScreenshotOptions(capture=CaptureOptions(enabled=True)),
        cancel_token=job_token,
    )

    await ScreenshotService(InspectingBackend(), concurrency=1).capture_async(
        [job],
        cancel_token=batch_token,
    )

    assert observed_states == [True]
