"""T4.1 长任务 `jobs` 写侧 repo 单测（ARCH §7.4）。

纯内存 fake（镜像 `SqlJobRepo` 语义），无 DB：断言状态流转 queued→running→done/failed、
progress 夹取、result/error 落位、`reap_zombies` 把 running→failed、frozen View 不可变。
真实 DB 路径（含 `reap_zombies` 自 commit）由 T4.5 E2E 覆盖。
"""

from __future__ import annotations

import uuid
from dataclasses import dataclass, field
from typing import Any

import pytest
from pydantic import ValidationError
from ww_core.domain.job_repo import (
    PROGRESS_COMPLETE,
    STATUS_AWAITING,
    STATUS_DONE,
    STATUS_FAILED,
    STATUS_QUEUED,
    STATUS_RUNNING,
    JobRepo,
    JobView,
)

KIND = "style_learn"
PROJECT = uuid.UUID("00000000-0000-0000-0000-000000000001")


@dataclass
class _Row:
    id: uuid.UUID
    kind: str
    status: str
    progress: int
    result: dict[str, Any] | None
    error: str | None


def _view(row: _Row) -> JobView:
    return JobView(
        id=row.id,
        kind=row.kind,
        status=row.status,
        progress=row.progress,
        result=row.result,
        error=row.error,
    )


def _clamp(pct: int) -> int:
    return max(0, min(PROGRESS_COMPLETE, pct))


@dataclass
class FakeJobRepo:
    """内存替身——镜像 SqlJobRepo 语义（状态流转 + 进度夹取 + 僵尸回收）。"""

    rows: list[_Row] = field(default_factory=list)

    def _require(self, job_id: uuid.UUID) -> _Row:
        row = next((r for r in self.rows if r.id == job_id), None)
        if row is None:
            raise LookupError(f"job not found: {job_id}")
        return row

    async def create(self, project_id: uuid.UUID | None, kind: str) -> JobView:
        row = _Row(
            id=uuid.uuid4(),
            kind=kind,
            status=STATUS_QUEUED,
            progress=0,
            result=None,
            error=None,
        )
        self.rows.append(row)
        return _view(row)

    async def set_running(self, job_id: uuid.UUID) -> JobView:
        row = self._require(job_id)
        row.status = STATUS_RUNNING
        return _view(row)

    async def set_progress(self, job_id: uuid.UUID, pct: int) -> JobView:
        row = self._require(job_id)
        row.progress = _clamp(pct)
        return _view(row)

    async def complete(self, job_id: uuid.UUID, result: dict[str, Any]) -> JobView:
        row = self._require(job_id)
        row.status = STATUS_DONE
        row.progress = PROGRESS_COMPLETE
        row.result = dict(result)
        return _view(row)

    async def set_awaiting(self, job_id: uuid.UUID, result: dict[str, Any]) -> JobView:
        row = self._require(job_id)
        row.status = STATUS_AWAITING
        row.result = dict(result)
        return _view(row)

    async def claim_awaiting_to_running(self, job_id: uuid.UUID) -> JobView | None:
        """原子抢占 awaiting→running（内存替身：仅当现态 awaiting 才抢到，否则 None）。"""
        row = next((r for r in self.rows if r.id == job_id), None)
        if row is None or row.status != STATUS_AWAITING:
            return None
        row.status = STATUS_RUNNING
        return _view(row)

    async def fail(self, job_id: uuid.UUID, error: str) -> JobView:
        row = self._require(job_id)
        row.status = STATUS_FAILED
        row.error = error
        return _view(row)

    async def get(self, job_id: uuid.UUID) -> JobView | None:
        row = next((r for r in self.rows if r.id == job_id), None)
        return _view(row) if row is not None else None

    async def reap_zombies(self) -> int:
        changed = 0
        for r in self.rows:
            if r.status == STATUS_RUNNING:
                r.status = STATUS_FAILED
                r.error = "job interrupted (process restart)"
                changed += 1
        return changed


def _repo() -> FakeJobRepo:
    return FakeJobRepo()


# ---- create ----


async def test_create_starts_queued() -> None:
    repo: JobRepo = _repo()
    view = await repo.create(PROJECT, KIND)
    assert isinstance(view, JobView)
    assert view.kind == KIND
    assert view.status == STATUS_QUEUED
    assert view.progress == 0
    assert view.result is None
    assert view.error is None


async def test_create_allows_null_project() -> None:
    repo: JobRepo = _repo()
    view = await repo.create(None, KIND)
    assert view.status == STATUS_QUEUED


# ---- state transitions ----


async def test_set_running_marks_running() -> None:
    repo: JobRepo = _repo()
    job = await repo.create(PROJECT, KIND)
    running = await repo.set_running(job.id)
    assert running.status == STATUS_RUNNING


async def test_complete_sets_done_full_progress_and_result() -> None:
    repo: JobRepo = _repo()
    job = await repo.create(PROJECT, KIND)
    await repo.set_running(job.id)
    done = await repo.complete(job.id, {"version": 2, "dims_count": 16})
    assert done.status == STATUS_DONE
    assert done.progress == PROGRESS_COMPLETE
    assert done.result == {"version": 2, "dims_count": 16}


async def test_fail_sets_failed_and_error() -> None:
    repo: JobRepo = _repo()
    job = await repo.create(PROJECT, KIND)
    await repo.set_running(job.id)
    failed = await repo.fail(job.id, "boom")
    assert failed.status == STATUS_FAILED
    assert failed.error == "boom"


# ---- 原子抢占 awaiting → running（防并发 resume 竞态，审评 #3）----


async def test_claim_awaiting_to_running_succeeds_once() -> None:
    repo: JobRepo = _repo()
    job = await repo.create(PROJECT, KIND)
    await repo.set_awaiting(job.id, {"awaiting_chapter": 1})
    claimed = await repo.claim_awaiting_to_running(job.id)
    assert claimed is not None
    assert claimed.status == STATUS_RUNNING


async def test_claim_awaiting_second_call_returns_none() -> None:
    repo: JobRepo = _repo()
    job = await repo.create(PROJECT, KIND)
    await repo.set_awaiting(job.id, {"awaiting_chapter": 1})
    first = await repo.claim_awaiting_to_running(job.id)
    second = await repo.claim_awaiting_to_running(job.id)  # 已 running → 抢不到
    assert first is not None
    assert second is None


async def test_claim_non_awaiting_returns_none() -> None:
    repo: JobRepo = _repo()
    job = await repo.create(PROJECT, KIND)  # queued（非 awaiting）
    assert await repo.claim_awaiting_to_running(job.id) is None


async def test_claim_absent_job_returns_none() -> None:
    repo: JobRepo = _repo()
    assert await repo.claim_awaiting_to_running(uuid.uuid4()) is None


# ---- progress (clamped) ----


async def test_set_progress_updates_pct() -> None:
    repo: JobRepo = _repo()
    job = await repo.create(PROJECT, KIND)
    v = await repo.set_progress(job.id, 42)
    assert v.progress == 42


async def test_set_progress_clamps_out_of_range() -> None:
    repo: JobRepo = _repo()
    job = await repo.create(PROJECT, KIND)
    assert (await repo.set_progress(job.id, 250)).progress == PROGRESS_COMPLETE
    assert (await repo.set_progress(job.id, -5)).progress == 0


# ---- get ----


async def test_get_returns_none_when_absent() -> None:
    repo: JobRepo = _repo()
    assert await repo.get(uuid.uuid4()) is None


async def test_get_returns_current_state() -> None:
    repo: JobRepo = _repo()
    job = await repo.create(PROJECT, KIND)
    await repo.complete(job.id, {"ok": True})
    fetched = await repo.get(job.id)
    assert fetched is not None
    assert fetched.status == STATUS_DONE


async def test_missing_job_raises_on_write() -> None:
    repo: JobRepo = _repo()
    with pytest.raises(LookupError):
        await repo.set_running(uuid.uuid4())


# ---- reap_zombies ----


async def test_reap_zombies_marks_running_failed() -> None:
    repo: JobRepo = _repo()
    queued = await repo.create(PROJECT, KIND)
    running = await repo.create(PROJECT, KIND)
    await repo.set_running(running.id)
    done = await repo.create(PROJECT, KIND)
    await repo.complete(done.id, {})

    count = await repo.reap_zombies()

    assert count == 1
    reaped = await repo.get(running.id)
    assert reaped is not None and reaped.status == STATUS_FAILED
    # queued / done 不受影响
    assert (await repo.get(queued.id)).status == STATUS_QUEUED  # type: ignore[union-attr]
    assert (await repo.get(done.id)).status == STATUS_DONE  # type: ignore[union-attr]


async def test_reap_zombies_noop_when_no_running() -> None:
    repo: JobRepo = _repo()
    await repo.create(PROJECT, KIND)
    assert await repo.reap_zombies() == 0


# ---- frozen View ----


async def test_view_is_frozen() -> None:
    repo: JobRepo = _repo()
    v = await repo.create(PROJECT, KIND)
    with pytest.raises(ValidationError):
        v.status = STATUS_DONE
