"""GPU-job HTTP API (#114): bearer auth + lease/submit round-trip + backfill."""
from datetime import UTC, datetime, timedelta

import pytest
from sqlalchemy import func, select

from backend.app.models import GpuJob, ImageRecord
from backend.app.services.ml.gpu_jobs import GpuJobService
from backend.app.services.ml.regions import RegionService

pytestmark = pytest.mark.integration


async def _img(db, sha) -> ImageRecord:
    img = ImageRecord(
        path=f"/images/{sha}.jpg", sha256=sha, size_bytes=1, mime="image/jpeg",
        width=1, height=1, origin="imported_filesystem", integrity_status="unknown",
    )
    db.add(img)
    await db.flush()
    return img


@pytest.mark.asyncio
async def test_agent_endpoints_require_bearer(client, db):
    resp = await client.post("/api/gpu/jobs/lease", json={"agent_id": "a1"})
    assert resp.status_code == 401
    # A wrong token is also rejected.
    await (await client.post("/api/gpu/token/rotate")).get_json()
    bad = await client.post(
        "/api/gpu/jobs/lease", json={"agent_id": "a1"},
        headers={"Authorization": "Bearer nope"},
    )
    assert bad.status_code == 401


@pytest.mark.asyncio
async def test_lease_submit_round_trip(client, db):
    img = await _img(db, "a" * 64)
    await GpuJobService(db).enqueue(img.id, "ccip")
    await db.commit()

    token = (await (await client.post("/api/gpu/token/rotate")).get_json())["token"]
    hdr = {"Authorization": f"Bearer {token}"}

    leased = await client.post(
        "/api/gpu/jobs/lease", json={"agent_id": "a1", "batch_size": 5}, headers=hdr,
    )
    assert leased.status_code == 200
    jobs = (await leased.get_json())["jobs"]
    assert len(jobs) == 1
    j = jobs[0]
    assert j["image_id"] == img.id and j["task"] == "ccip"
    assert j["image_url"].startswith("/images/")

    submitted = await client.post("/api/gpu/jobs/submit", json={
        "agent_id": "a1", "job_id": j["job_id"],
        "regions": [{
            "kind": "figure", "bbox": [0.1, 0.1, 0.4, 0.4],
            "ccip_embedding": [0.1] * 768, "embedding_version": "ccip-test",
        }],
    }, headers=hdr)
    assert submitted.status_code == 200
    assert (await submitted.get_json())["stored"] == 1

    # Job closed (read on the app's own connection via the status endpoint).
    st = await (await client.get("/api/gpu/status")).get_json()
    assert st["done"] == 1 and st["pending"] == 0 and st["leased"] == 0

    # Region persisted with its CCIP vector.
    regs = await RegionService(db).get_regions(img.id, kinds=["figure"])
    assert len(regs) == 1 and len(list(regs[0].ccip_embedding)) == 768


@pytest.mark.asyncio
async def test_lease_announces_embed_model_then_submit_embedding(client, db):
    # Whole-image GPU re-embed (#1190): the lease announces the embedder model so
    # the agent loads the right one, and submit_embedding writes it back onto
    # image_record with its version stamp.
    img = await _img(db, "b" * 64)
    await GpuJobService(db).enqueue(img.id, "embed")
    await db.commit()

    token = (await (await client.post("/api/gpu/token/rotate")).get_json())["token"]
    hdr = {"Authorization": f"Bearer {token}"}

    leased = await client.post(
        "/api/gpu/jobs/lease", json={"agent_id": "a1", "batch_size": 5}, headers=hdr,
    )
    j = (await leased.get_json())["jobs"][0]
    assert j["task"] == "embed"
    assert j["embed_model_name"] and j["embed_version"]   # server-announced model

    submitted = await client.post("/api/gpu/jobs/submit_embedding", json={
        "agent_id": "a1", "job_id": j["job_id"],
        "embedding": [0.2] * 1152, "embedding_version": "siglip2-test-v9",
    }, headers=hdr)
    assert submitted.status_code == 200

    st = await (await client.get("/api/gpu/status")).get_json()
    assert st["done"] == 1 and st["leased"] == 0

    await db.refresh(img)
    assert img.siglip_model_version == "siglip2-test-v9"
    assert img.siglip_embedding is not None and len(list(img.siglip_embedding)) == 1152


@pytest.mark.asyncio
async def test_lease_announces_detector_config(client, db):
    # #134: the lease carries the crop-proposer config from MLSettings, so the
    # agent builds its detectors from the DB/UI (no restart). Defaults are all-on
    # with the pinned working weights.
    img = await _img(db, "c" * 64)
    await GpuJobService(db).enqueue(img.id, "embed")
    await db.commit()
    token = (await (await client.post("/api/gpu/token/rotate")).get_json())["token"]
    hdr = {"Authorization": f"Bearer {token}"}

    leased = await client.post(
        "/api/gpu/jobs/lease", json={"agent_id": "a1", "batch_size": 5}, headers=hdr,
    )
    det = (await leased.get_json())["jobs"][0]["detectors"]
    assert det["person"]["enabled"]
    assert det["anatomy"]["enabled"]
    assert det["panel"]["enabled"]
    assert "yolov11m_aa22" in det["anatomy"]["weights"]      # booru_yolo default
    assert det["panel"]["weights"].endswith("::best.pt")     # mosesb default
    assert det["max_regions"] == 128
    assert det["dedupe_iou"] == 0.85


@pytest.mark.asyncio
async def test_submit_with_stale_lease_is_409(client, db):
    img = await _img(db, "b" * 64)
    await GpuJobService(db).enqueue(img.id, "ccip")
    await db.commit()
    token = (await (await client.post("/api/gpu/token/rotate")).get_json())["token"]
    hdr = {"Authorization": f"Bearer {token}"}
    j = (await (await client.post(
        "/api/gpu/jobs/lease", json={"agent_id": "a1"}, headers=hdr,
    )).get_json())["jobs"][0]
    # A different agent can't submit someone else's lease.
    resp = await client.post("/api/gpu/jobs/submit", json={
        "agent_id": "other", "job_id": j["job_id"], "regions": [],
    }, headers=hdr)
    assert resp.status_code == 409


@pytest.mark.asyncio
async def test_backfill_enqueues_then_is_idempotent(db):
    await _img(db, "c" * 64)
    await _img(db, "d" * 64)
    await db.commit()
    from backend.app.tasks.gpu_queue import enqueue_gpu_backfill

    n = enqueue_gpu_backfill("ccip")     # sync task, own session
    assert n >= 2
    assert enqueue_gpu_backfill("ccip") == 0   # all already pending


@pytest.mark.asyncio
async def test_release_hands_job_back_to_pending(client, db):
    img = await _img(db, "e" * 64)
    await GpuJobService(db).enqueue(img.id, "ccip")
    await db.commit()
    token = (await (await client.post("/api/gpu/token/rotate")).get_json())["token"]
    hdr = {"Authorization": f"Bearer {token}"}
    j = (await (await client.post(
        "/api/gpu/jobs/lease", json={"agent_id": "a1"}, headers=hdr,
    )).get_json())["jobs"][0]

    resp = await client.post("/api/gpu/jobs/release", json={
        "agent_id": "a1", "job_ids": [j["job_id"]],
    }, headers=hdr)
    assert resp.status_code == 200 and (await resp.get_json())["released"] == 1
    st = await (await client.get("/api/gpu/status")).get_json()
    assert st["pending"] == 1 and st["leased"] == 0


@pytest.mark.asyncio
async def test_retry_errors_requeues_only_errored(client, db):
    """/retry_errors prunes stale tombstones first (older duplicates + rows a
    later success made moot), then resets the SURVIVING errored jobs to pending
    with a fresh retry budget — and leaves done work untouched (NOT /reprocess).
    The prune is what stops one failing file fanning out into duplicate pending
    jobs (the 2026-07-02 tombstone loop minted one error row per hour)."""
    img1 = await _img(db, "1" * 64)
    img2 = await _img(db, "2" * 64)
    svc = GpuJobService(db)
    j_err = await svc.enqueue(img1.id, "ccip")
    j_done = await svc.enqueue(img2.id, "siglip")
    err_id = j_err.id
    done_id = j_done.id
    j_err.status = "error"
    j_err.attempts = 3
    j_err.error = "no frames sampled from video (unprocessable)"
    j_err.updated_at = datetime.now(UTC)
    j_done.status = "done"
    # Loop-era leftovers: an OLDER duplicate error row for img1's ccip, and a
    # tombstone img2's done row makes moot — both pruned, never requeued.
    dup = GpuJob(
        image_record_id=img1.id, task="ccip", status="error",
        error="older duplicate", updated_at=datetime.now(UTC) - timedelta(hours=1),
    )
    moot = GpuJob(
        image_record_id=img2.id, task="siglip", status="error",
        error="superseded by the done row",
    )
    db.add(dup)
    db.add(moot)
    await db.flush()
    dup_id = dup.id
    moot_id = moot.id
    await db.commit()

    resp = await client.post("/api/gpu/retry_errors")
    assert resp.status_code == 200
    body = await resp.get_json()
    assert body["requeued"] == 1
    assert body["pruned"] == 2

    # Column selects, not ORM refresh — the route wrote via Core DML.
    row = (await db.execute(
        select(GpuJob.status, GpuJob.attempts, GpuJob.error)
        .where(GpuJob.id == err_id)
    )).one()
    assert tuple(row) == ("pending", 0, None)
    done_status = await db.scalar(
        select(GpuJob.status).where(GpuJob.id == done_id)
    )
    assert done_status == "done"
    survivors = (await db.execute(
        select(func.count()).select_from(GpuJob)
        .where(GpuJob.id.in_([dup_id, moot_id]))
    )).scalar_one()
    assert survivors == 0

    st = await (await client.get("/api/gpu/status")).get_json()
    assert st["pending"] == 1 and st["error"] == 0


@pytest.mark.asyncio
async def test_retry_errors_keeps_triaged_defects(client, db):
    """A probe-confirmed DEFECT is a bad FILE — requeueing it just burns agent
    time re-minting the tombstone, so /retry_errors leaves it for the recovery
    surface and reports it as defects_kept."""
    img1 = await _img(db, "4" * 64)
    img2 = await _img(db, "5" * 64)
    db.add(GpuJob(image_record_id=img1.id, task="ccip", status="error",
                  attempts=3, error="moov atom not found",
                  triage_status="defect"))
    db.add(GpuJob(image_record_id=img2.id, task="ccip", status="error",
                  attempts=3, error="ffmpeg timed out after 1200s"))
    await db.commit()

    body = await (await client.post("/api/gpu/retry_errors")).get_json()
    assert body["requeued"] == 1
    assert body["defects_kept"] == 1

    rows = dict((await db.execute(
        select(GpuJob.image_record_id, GpuJob.status)
    )).all())
    assert rows[img1.id] == "error"       # defect stays tombstoned
    assert rows[img2.id] == "pending"     # operational failure requeued


@pytest.mark.asyncio
async def test_errors_endpoint_reports_triage_view(client, db):
    img = await _img(db, "6" * 64)
    db.add(GpuJob(image_record_id=img.id, task="ccip", status="error",
                  attempts=3,
                  error="no frames sampled from video — moov atom not found"))
    await db.commit()

    resp = await client.get("/api/gpu/errors")
    assert resp.status_code == 200
    body = await resp.get_json()
    assert body["total"] == 1
    assert body["by_class"] == {"truncated_or_corrupt": 1}
    assert body["triage"]["unclassified"] == 1
    item = body["items"][0]
    assert item["image_id"] == img.id
    assert item["task"] == "ccip"
    assert item["reason_class"] == "truncated_or_corrupt"
    assert item["triage_status"] is None
    assert item["image_url"].startswith("/images/")


@pytest.mark.asyncio
async def test_cpu_embed_never_blocks_gpu_crop_backfills(db):
    """B3 invariant (operator 2026-07-02): ccip (detect + character) and
    siglip (concept crops) completion is judged per-pipeline — gpu_job rows and
    image_region state — never inferred from image_record.siglip_embedding. So
    an image the CPU fallback already embedded still gets both crop jobs; only
    the whole-image 'embed' job (the SAME artifact the CPU path produces) is
    satisfied by it."""
    from backend.app.models import MLSettings
    from backend.app.tasks.gpu_queue import enqueue_gpu_backfill

    img = await _img(db, "7" * 64)
    cur = (await db.execute(
        select(MLSettings.embedder_model_version).where(MLSettings.id == 1)
    )).scalar_one()
    # As if the CPU fallback already embedded it under the current model.
    img.siglip_embedding = [0.1] * 1152
    img.siglip_model_version = cur
    await db.commit()

    assert enqueue_gpu_backfill("ccip") == 1      # crops still open
    assert enqueue_gpu_backfill("siglip") == 1    # concept crops still open
    assert enqueue_gpu_backfill("embed") == 0     # same artifact — already done

    tasks = set((await db.execute(
        select(GpuJob.task).where(GpuJob.image_record_id == img.id)
    )).scalars().all())
    assert tasks == {"ccip", "siglip"}
