"""JobManager lifecycle tests with fake pipeline components and a real SQLite repo."""

from __future__ import annotations

import asyncio
import time
from types import SimpleNamespace
from typing import Any

import pytest

from app.database.models import Job, JobStatus, Video, VideoStatus
from app.errors import (
    DuplicateJobError,
    InvalidMediaError,
    JobStateError,
    MediaNotFoundError,
    UploadUncertainError,
)
from app.services.crawler import CrawlResult
from app.services.downloader import PreparedMedia
from app.services.job_manager import JobManager
from app.services.uploader import Destination, UploadResult
from tests.helpers import MP4_HEADER


class FakeGuard:
    def __init__(self) -> None:
        self.calls: list[tuple[str, bool]] = []

    async def validate(self, raw_url: str, *, resolve_dns: bool = True) -> Any:
        self.calls.append((raw_url, resolve_dns))
        return SimpleNamespace(url=raw_url.strip())


class FakeCrawler:
    def __init__(
        self,
        repo,
        *,
        videos: int = 1,
        add_videos: bool = True,
        fail: BaseException | None = None,
        block: bool = False,
    ) -> None:
        self.repo = repo
        self.guard = FakeGuard()
        self.videos = videos
        self.add_videos = add_videos
        self.fail = fail
        self.block = block
        self.started = asyncio.Event()
        self.release = asyncio.Event()
        self.calls: list[str] = []

    async def crawl(self, job: Job, cancel_event: asyncio.Event, checkpoint) -> CrawlResult:
        self.calls.append(job.id)
        self.started.set()
        if self.block:
            while not self.release.is_set():
                await checkpoint()
                await asyncio.sleep(0.01)
        if self.fail is not None:
            raise self.fail
        created = 0
        if self.add_videos:
            for index in range(self.videos):
                video = Video.create(
                    job_id=job.id,
                    source_url=f"https://example.com/video-{job.short_id}-{index}.mp4",
                    page_url=job.source_url,
                    title=f"Video {index}",
                    caption=f"Caption {index}",
                    media_type="video/mp4",
                    media_url=f"https://example.com/video-{job.short_id}-{index}.mp4",
                )
                _, was_created = await self.repo.add_video(video)
                created += int(was_created)
        await self.repo.refresh_job_counts(job.id)
        return CrawlResult(
            pagination_pages=1,
            detail_pages=0,
            videos_found=created,
            duplicate_videos=0,
            stop_reason="exhausted",
        )


class FakeDownloader:
    def __init__(
        self,
        settings,
        *,
        errors: list[BaseException] | None = None,
        duration: float | None = 5.5,
    ) -> None:
        self.settings = settings
        self.errors = list(errors or [])
        self.duration = duration
        self.calls: list[str] = []

    async def prepare(self, video: Video, cancel_event: asyncio.Event) -> PreparedMedia:
        self.calls.append(video.id)
        if self.errors:
            raise self.errors.pop(0)
        return PreparedMedia(
            media_url=video.media_url or "",
            final_url=video.media_url or "",
            mime_type="video/mp4",
            extension=".mp4",
            size=len(MP4_HEADER) + 32,
            duration_seconds=self.duration,
            method="stream",
        )


class FakeUploader:
    def __init__(self, errors: list[BaseException] | None = None) -> None:
        self.errors = list(errors or [])
        self.calls: list[str] = []
        self.destinations: list[Destination] = []
        self._message_id = 900

    async def resolve_destination(self) -> Destination:
        destination = Destination(chat_id=-100123, chat_type="channel", title="Test channel")
        self.destinations.append(destination)
        return destination

    async def upload(
        self, video: Video, destination: Destination, cancel_event: asyncio.Event
    ) -> UploadResult:
        self.calls.append(video.id)
        if self.errors:
            raise self.errors.pop(0)
        self._message_id += 1
        return UploadResult(self._message_id, destination, 0)


def _manager(
    settings,
    repository,
    *,
    crawler: FakeCrawler | None = None,
    downloader: FakeDownloader | None = None,
    uploader: FakeUploader | None = None,
    notifier=None,
) -> tuple[JobManager, FakeCrawler, FakeDownloader, FakeUploader]:
    crawler = crawler or FakeCrawler(repository)
    downloader = downloader or FakeDownloader(settings)
    uploader = uploader or FakeUploader()
    manager = JobManager(
        settings,
        repository,
        crawler,
        downloader,
        uploader,
        notifier=notifier,
    )
    return manager, crawler, downloader, uploader


async def _wait_for_status(
    repository,
    job_id: str,
    statuses: set[JobStatus],
    *,
    wait_seconds: float = 5.0,
) -> Job:
    deadline = time.monotonic() + wait_seconds
    last = None
    while time.monotonic() < deadline:
        last = await repository.get_job(job_id)
        if last is not None and last.status in statuses:
            return last
        await asyncio.sleep(0.02)
    raise AssertionError(f"job did not reach {statuses}; last status={last.status if last else None}")


@pytest.mark.asyncio
async def test_start_stop_and_double_start(settings, repository) -> None:
    manager, _, _, _ = _manager(settings, repository)
    await manager.stop()  # no-op before start
    await manager.start()
    await manager.start()
    assert await manager.wait_until_idle(1.0) is True
    await manager.stop()
    await manager.stop()


@pytest.mark.asyncio
async def test_create_job_validates_url_and_notifies(settings, repository) -> None:
    notifications: list[tuple[int, str]] = []

    async def notifier(chat_id: int, text: str) -> None:
        notifications.append((chat_id, text))

    manager, crawler, _, _ = _manager(settings, repository, notifier=notifier)
    job = await manager.create_job("  https://example.com/list  ", owner_id=7, owner_chat_id=8)

    assert job.source_url == "https://example.com/list"
    assert crawler.guard.calls == [("  https://example.com/list  ", True)]
    assert notifications and notifications[0][0] == 8
    assert job.short_id in notifications[0][1]
    assert await manager.active_jobs(owner_id=7) == [job]
    assert await manager.active_jobs(owner_id=9) == []


@pytest.mark.asyncio
async def test_duplicate_active_job_is_rejected_and_cancelled_allows_retry(
    settings, repository
) -> None:
    manager, _, _, _ = _manager(settings, repository)
    job = await manager.create_job("https://example.com/list", 1, 1)
    with pytest.raises(DuplicateJobError):
        await manager.create_job("https://example.com/list", 1, 1)

    await manager.cancel_job(job.id)
    replacement = await manager.create_job("https://example.com/list", 1, 1)
    assert replacement.id != job.id
    assert replacement.status == JobStatus.PENDING


@pytest.mark.asyncio
async def test_full_pipeline_downloads_and_uploads_every_video(settings, repository) -> None:
    manager, crawler, downloader, uploader = _manager(settings, repository)
    uploader._message_id = 1000
    await manager.start()
    try:
        job = await manager.create_job("https://example.com/list", 1, 2)
        finished = await _wait_for_status(
            repository, job.id, {JobStatus.COMPLETED, JobStatus.FAILED}
        )
    finally:
        await manager.stop()

    assert finished.status == JobStatus.COMPLETED
    assert finished.total_videos == 1
    assert finished.uploaded_videos == 1
    assert finished.failed_videos == 0
    assert finished.current_stage == "done"
    assert finished.finished_at is not None
    videos = await repository.list_videos(job.id)
    assert videos[0].status == VideoStatus.UPLOADED
    assert videos[0].telegram_message_id == 1001
    assert videos[0].local_path is None
    assert downloader.calls == uploader.calls
    assert crawler.calls == [job.id]
    assert not (settings.database_path.parent / "downloads").exists()


@pytest.mark.asyncio
async def test_pause_and_resume_while_crawling(settings, repository) -> None:
    crawler = FakeCrawler(repository, block=True)
    manager, _, _, _ = _manager(settings, repository, crawler=crawler)
    await manager.start()
    try:
        job = await manager.create_job("https://example.com/list", 1, 2)
        await asyncio.wait_for(crawler.started.wait(), timeout=3)
        paused = await manager.pause_job(job.id)
        assert paused.status == JobStatus.PAUSED
        assert await manager.wait_until_idle(0.15) is False

        crawler.release.set()
        resumed = await manager.resume_job(job.id)
        assert resumed.status in {JobStatus.RUNNING, JobStatus.PENDING}
        finished = await _wait_for_status(repository, job.id, {JobStatus.COMPLETED})
        assert finished.status == JobStatus.COMPLETED
    finally:
        await manager.stop()


@pytest.mark.asyncio
async def test_cancel_while_crawling(settings, repository) -> None:
    crawler = FakeCrawler(repository, block=True)
    manager, _, _, _ = _manager(settings, repository, crawler=crawler)
    await manager.start()
    try:
        job = await manager.create_job("https://example.com/list", 1, 2)
        await asyncio.wait_for(crawler.started.wait(), timeout=3)
        cancelled = await manager.cancel_job(job.id)
        assert cancelled.status == JobStatus.CANCELLED
        finished = await _wait_for_status(repository, job.id, {JobStatus.CANCELLED})
        assert finished.status == JobStatus.CANCELLED
        assert finished.current_stage == "done"
    finally:
        await manager.stop()


@pytest.mark.asyncio
async def test_graceful_stop_preserves_running_job_for_restart(settings, repository) -> None:
    crawler = FakeCrawler(repository, block=True)
    manager, _, _, _ = _manager(settings, repository, crawler=crawler)
    await manager.start()
    job = await manager.create_job("https://example.com/list", 1, 2)
    await asyncio.wait_for(crawler.started.wait(), timeout=3)

    await manager.stop(grace_seconds=3)

    stored = await repository.get_job(job.id)
    assert stored is not None
    assert stored.status == JobStatus.PENDING
    assert "متوقف شد" in (stored.error_message or "")


@pytest.mark.asyncio
async def test_pause_resume_and_cancel_without_running_worker(settings, repository) -> None:
    manager, _, _, _ = _manager(settings, repository)
    job = await manager.create_job("https://example.com/list", 1, 2)

    paused = await manager.pause_job(job.id)
    assert paused.status == JobStatus.PAUSED
    assert await manager.pause_job(job.id) == paused  # idempotent

    resumed = await manager.resume_job(job.id)
    assert resumed.status == JobStatus.PENDING

    cancelled = await manager.cancel_job(job.id)
    assert cancelled.status == JobStatus.CANCELLED
    with pytest.raises(JobStateError):
        await manager.cancel_job(job.id)


@pytest.mark.asyncio
async def test_resume_requires_a_paused_job(settings, repository) -> None:
    manager, _, _, _ = _manager(settings, repository)
    with pytest.raises(JobStateError):
        await manager.resume_job()
    job = await manager.create_job("https://example.com/list", 1, 2)
    with pytest.raises(JobStateError):
        await manager.resume_job(job.id)


@pytest.mark.asyncio
async def test_select_job_ownership_and_missing(settings, repository) -> None:
    manager, _, _, _ = _manager(settings, repository)
    with pytest.raises(JobStateError, match="هیچ عملیات فعالی"):
        await manager.select_job(None)

    job = await manager.create_job("https://example.com/list", 1, 2)
    assert (await manager.select_job(job.id, owner_id=1)).id == job.id
    with pytest.raises(JobStateError, match="متعلق"):
        await manager.select_job(job.id, owner_id=99)
    with pytest.raises(JobStateError, match="پیدا نشد"):
        await manager.select_job("f" * 32)


@pytest.mark.asyncio
async def test_crawler_failure_marks_job_failed(settings, repository) -> None:
    crawler = FakeCrawler(repository, fail=MediaNotFoundError("none"))
    manager, _, _, _ = _manager(settings, repository, crawler=crawler)
    await manager.start()
    try:
        job = await manager.create_job("https://example.com/list", 1, 2)
        finished = await _wait_for_status(repository, job.id, {JobStatus.FAILED})
    finally:
        await manager.stop()

    assert finished.status == JobStatus.FAILED
    assert "MediaNotFoundError" in (finished.error_message or "")


@pytest.mark.asyncio
async def test_download_failure_marks_video_failed_and_job_failed(settings, repository) -> None:
    downloader = FakeDownloader(settings, errors=[InvalidMediaError("bad")])
    manager, _, _, uploader = _manager(settings, repository, downloader=downloader)
    await manager.start()
    try:
        job = await manager.create_job("https://example.com/list", 1, 2)
        finished = await _wait_for_status(repository, job.id, {JobStatus.FAILED})
    finally:
        await manager.stop()

    assert finished.status == JobStatus.FAILED
    videos = await repository.list_videos(job.id)
    assert videos[0].status == VideoStatus.FAILED
    assert "InvalidMediaError" in (videos[0].error_message or "")
    assert uploader.calls == []


@pytest.mark.asyncio
async def test_one_failed_preparation_does_not_stop_remaining_videos(
    settings, repository
) -> None:
    downloader = FakeDownloader(settings, errors=[InvalidMediaError("bad")])
    crawler = FakeCrawler(repository, videos=2)
    manager, _, _, uploader = _manager(
        settings, repository, crawler=crawler, downloader=downloader
    )
    await manager.start()
    try:
        job = await manager.create_job("https://example.com/list", 1, 2)
        finished = await _wait_for_status(repository, job.id, {JobStatus.COMPLETED})
    finally:
        await manager.stop()

    assert finished.status == JobStatus.COMPLETED
    assert finished.uploaded_videos == 1
    assert finished.failed_videos == 1
    assert len(uploader.calls) == 1
    statuses = {video.status for video in await repository.list_videos(job.id)}
    assert statuses == {VideoStatus.FAILED, VideoStatus.UPLOADED}


@pytest.mark.asyncio
async def test_ambiguous_upload_is_not_retried_and_counts_as_failure(settings, repository) -> None:
    uploader = FakeUploader([UploadUncertainError()])
    manager, _, _, _ = _manager(settings, repository, uploader=uploader)
    await manager.start()
    try:
        job = await manager.create_job("https://example.com/list", 1, 2)
        finished = await _wait_for_status(repository, job.id, {JobStatus.FAILED})
    finally:
        await manager.stop()

    assert finished.status == JobStatus.FAILED
    video = (await repository.list_videos(job.id))[0]
    assert video.status == VideoStatus.UPLOAD_UNKNOWN
    assert "UploadUncertainError" in (video.error_message or "")
    assert len(uploader.calls) == 1


@pytest.mark.asyncio
async def test_partial_upload_success_still_completes_with_warning(settings, repository) -> None:
    crawler = FakeCrawler(repository, videos=2)
    uploader = FakeUploader([UploadUncertainError()])
    manager, _, _, _ = _manager(
        settings, repository, crawler=crawler, uploader=uploader
    )
    await manager.start()
    try:
        job = await manager.create_job("https://example.com/list", 1, 2)
        finished = await _wait_for_status(repository, job.id, {JobStatus.COMPLETED})
    finally:
        await manager.stop()

    assert finished.status == JobStatus.COMPLETED
    assert finished.uploaded_videos == 1
    assert finished.failed_videos == 1
    assert "ناموفق" in (finished.error_message or "")
    statuses = {video.status for video in await repository.list_videos(job.id)}
    assert statuses == {VideoStatus.UPLOADED, VideoStatus.UPLOAD_UNKNOWN}


@pytest.mark.asyncio
async def test_recovery_requeues_interrupted_job_and_resets_downloading_video(
    settings, repository
) -> None:
    job = Job.create("https://example.com/list", owner_id=1, owner_chat_id=2)
    await repository.create_job(job)
    video = Video.create(
        job_id=job.id,
        source_url="https://example.com/interrupted.mp4",
        page_url=job.source_url,
        title="Interrupted",
        caption=None,
        media_type="video/mp4",
        media_url="https://example.com/interrupted.mp4",
    )
    video.status = VideoStatus.DOWNLOADING
    await repository.add_video(video)

    crawler = FakeCrawler(repository, add_videos=False)
    manager, _, downloader, _ = _manager(settings, repository, crawler=crawler)
    await manager.start()
    try:
        finished = await _wait_for_status(repository, job.id, {JobStatus.COMPLETED})
    finally:
        await manager.stop()

    assert finished.status == JobStatus.COMPLETED
    assert finished.uploaded_videos == 1
    assert downloader.calls == [video.id]


@pytest.mark.asyncio
async def test_paused_job_is_not_enqueued_until_resumed(settings, repository) -> None:
    manager, _, _, _ = _manager(settings, repository)
    job = await manager.create_job("https://example.com/list", 1, 2)
    await manager.pause_job(job.id)
    await manager.enqueue(job.id)  # non-pending jobs are ignored
    await manager.start()
    try:
        await asyncio.sleep(0.1)
        stored = await repository.get_job(job.id)
        assert stored is not None
        assert stored.status == JobStatus.PAUSED
    finally:
        await manager.stop()


@pytest.mark.asyncio
async def test_wait_until_idle_times_out_with_active_control(settings, repository) -> None:
    crawler = FakeCrawler(repository, block=True)
    manager, _, _, _ = _manager(settings, repository, crawler=crawler)
    await manager.start()
    try:
        job = await manager.create_job("https://example.com/list", 1, 2)
        await asyncio.wait_for(crawler.started.wait(), timeout=3)
        assert await manager.wait_until_idle(0.05) is False
        await manager.cancel_job(job.id)
        assert await manager.wait_until_idle(2.0) is True
    finally:
        await manager.stop()


@pytest.mark.asyncio
async def test_stop_is_idempotent_and_cancels_paused_worker(settings, repository) -> None:
    crawler = FakeCrawler(repository, block=True)
    manager, _, _, _ = _manager(settings, repository, crawler=crawler)
    await manager.start()
    job = await manager.create_job("https://example.com/list", 1, 2)
    await asyncio.wait_for(crawler.started.wait(), timeout=3)
    await manager.pause_job(job.id)
    await manager.stop(grace_seconds=3)
    stored = await repository.get_job(job.id)
    assert stored is not None
    assert stored.status == JobStatus.PAUSED
