"""API tests for the human-review endpoints and review-aware job states."""

import json
import os
import threading

from fastapi.testclient import TestClient

import backend.jobs
from backend.main import app
from backend.review.store import ReviewStore


class _SyncThread:
    """Drop-in threading.Thread replacement that runs the target inline."""

    def __init__(self, target=None, args=(), **kwargs):
        self._target = target
        self._args = args

    def start(self):
        self._target(*self._args)


def _queue_item(item_id: str) -> dict:
    return {"review_item_id": item_id, "kind": "finding",
            "blocking": True, "reasons": ["high_severity"], "payload": {}}


def test_review_queue_and_decision_save(monkeypatch, tmp_path):
    store = ReviewStore(str(tmp_path))
    store.write_queue([_queue_item("finding:AGENT-0001")])
    client = TestClient(app)
    monkeypatch.setattr("backend.main.get_job", lambda job_id: {"job_id": job_id, "status": "needs_review", "report": {"summary": {}}, "out_dir": str(tmp_path)})
    queue_response = client.get("/jobs/job1/review")
    assert queue_response.status_code == 200
    decision_response = client.post("/jobs/job1/review-decisions", json={"decisions": [{"review_item_id": "finding:AGENT-0001", "decision": "confirm"}]})
    assert decision_response.status_code == 200


def test_review_decision_emits_feedback_label(monkeypatch, tmp_path):
    """Every saved decision appends one feedback label under review/."""
    store = ReviewStore(str(tmp_path))
    store.write_queue([_queue_item("finding:AGENT-0001")])
    client = TestClient(app)
    monkeypatch.setattr("backend.main.get_job", lambda job_id: {"job_id": job_id, "status": "needs_review", "report": {"summary": {}}, "out_dir": str(tmp_path)})
    response = client.post("/jobs/job1/review-decisions", json={"decisions": [{"review_item_id": "finding:AGENT-0001", "decision": "confirm"}]})
    assert response.status_code == 200
    path = os.path.join(str(tmp_path), "review", "feedback_labels.jsonl")
    with open(path, encoding="utf-8") as f:
        labels = [json.loads(line) for line in f if line.strip()]
    assert len(labels) == 1
    assert labels[0]["review_item_id"] == "finding:AGENT-0001"
    assert labels[0]["decision"] == "confirm"


def test_review_endpoints_404_for_unknown_job(monkeypatch, tmp_path):
    monkeypatch.setattr("backend.config.OUTPUT_DIR", str(tmp_path))
    client = TestClient(app)
    assert client.get("/jobs/nope/review").status_code == 404
    assert client.post("/jobs/nope/review-decisions", json={"decisions": []}).status_code == 404


def test_review_decision_invalid_returns_422(monkeypatch, tmp_path):
    client = TestClient(app)
    monkeypatch.setattr("backend.main.get_job", lambda job_id: {"job_id": job_id, "status": "needs_review", "report": {"summary": {}}, "out_dir": str(tmp_path)})
    response = client.post("/jobs/job1/review-decisions", json={"decisions": [{"review_item_id": "finding:AGENT-0001", "decision": "bogus"}]})
    assert response.status_code == 422


def test_partial_review_moves_job_to_reviewing(monkeypatch, tmp_path):
    """Saving some but not all required decisions flips needs_review -> reviewing."""
    store = ReviewStore(str(tmp_path))
    store.write_queue([_queue_item("finding:AGENT-0001"), _queue_item("finding:AGENT-0002")])
    job_id = "jobreviewing"
    backend.jobs._jobs[job_id] = {
        "job_id": job_id,
        "status": "needs_review",
        "out_dir": str(tmp_path),
        "report": {"summary": {}},
    }
    try:
        client = TestClient(app)
        response = client.post(f"/jobs/{job_id}/review-decisions", json={
            "decisions": [{"review_item_id": "finding:AGENT-0001", "decision": "confirm"}],
        })
        assert response.status_code == 200
        assert response.json()["progress"]["remaining"] == 1
        assert backend.jobs.get_job(job_id)["status"] == "reviewing"
    finally:
        backend.jobs._jobs.pop(job_id, None)


def test_agent_job_needs_review_skips_notify(monkeypatch, tmp_path):
    """Carried finding from Task 4: an agent report needing review must not be emailed."""
    sent = []
    monkeypatch.setattr("backend.config.OUTPUT_DIR", str(tmp_path))
    monkeypatch.setattr("backend.jobs.run_agent_pipeline", lambda pdf_path, **kw: {
        "summary": {"agent_status": "needs_review"}, "conflicts": [],
    })
    monkeypatch.setattr("backend.jobs.send_conflict_report", lambda *a, **kw: sent.append((a, kw)))
    monkeypatch.setattr(threading, "Thread", _SyncThread)

    pdf = tmp_path / "upload.pdf"
    pdf.write_bytes(b"%PDF-1.4 dummy")
    job_id = backend.jobs.create_job(str(pdf), source_filename="set.pdf",
                                     email="arch@example.com", pipeline_mode="agent")
    try:
        job = backend.jobs.get_job(job_id)
        assert job["status"] == "needs_review"
        assert job["report"]["summary"]["agent_status"] == "needs_review"
        assert sent == []
    finally:
        backend.jobs._jobs.pop(job_id, None)


def test_classic_job_still_completes_and_notifies(monkeypatch, tmp_path):
    """Classic pipeline behavior is unchanged: done status + completion email."""
    sent = []
    monkeypatch.setattr("backend.config.OUTPUT_DIR", str(tmp_path))
    monkeypatch.setattr("backend.jobs.run_pipeline", lambda pdf_path, **kw: {
        "summary": {}, "conflicts": [],
    })
    monkeypatch.setattr("backend.jobs.send_conflict_report", lambda *a, **kw: sent.append((a, kw)))
    monkeypatch.setattr(threading, "Thread", _SyncThread)

    pdf = tmp_path / "upload.pdf"
    pdf.write_bytes(b"%PDF-1.4 dummy")
    job_id = backend.jobs.create_job(str(pdf), source_filename="set.pdf",
                                     email="arch@example.com", pipeline_mode="classic")
    try:
        assert backend.jobs.get_job(job_id)["status"] == "done"
        assert len(sent) == 1
    finally:
        backend.jobs._jobs.pop(job_id, None)


def test_job_status_includes_review_progress(monkeypatch, tmp_path):
    """GET /jobs/{id} surfaces report.summary.review for a needs_review job."""
    review = {"required": 1, "completed": 0, "remaining": 1, "total": 1}
    client = TestClient(app)
    monkeypatch.setattr("backend.main.get_job", lambda job_id: {
        "job_id": job_id, "status": "needs_review",
        "report": {"summary": {"agent_status": "needs_review", "review": review}},
        "out_dir": str(tmp_path),
    })
    response = client.get("/jobs/job1")
    assert response.status_code == 200
    body = response.json()
    assert body["status"] == "needs_review"
    assert body["report"]["summary"]["review"] == review


def test_review_response_includes_saved_decisions(monkeypatch, tmp_path):
    """GET /jobs/{id}/review also returns the decisions map for UI pre-population."""
    store = ReviewStore(str(tmp_path))
    store.write_queue([_queue_item("finding:AGENT-0001")])
    client = TestClient(app)
    monkeypatch.setattr("backend.main.get_job", lambda job_id: {"job_id": job_id, "status": "needs_review", "report": {"summary": {}}, "out_dir": str(tmp_path)})
    post = client.post("/jobs/job1/review-decisions", json={"decisions": [
        {"review_item_id": "finding:AGENT-0001", "decision": "confirm", "comment": "looks right"},
    ]})
    assert post.status_code == 200
    get = client.get("/jobs/job1/review")
    assert get.status_code == 200
    decisions = get.json()["decisions"]
    assert decisions["finding:AGENT-0001"]["decision"] == "confirm"
    assert decisions["finding:AGENT-0001"]["comment"] == "looks right"


def test_review_flow_via_disk_fallback(monkeypatch, tmp_path):
    """Smoke: a synthetic on-disk needs_review job served by the REAL get_job
    (disk fallback), with decisions persisting across review GETs."""
    job_id = "jobdisk"
    out_dir = os.path.join(str(tmp_path), job_id)
    os.makedirs(out_dir)
    report = {
        "source": "set.pdf",
        "summary": {
            "agent_status": "needs_review",
            "review": {"required": 1, "completed": 0, "remaining": 1, "total": 1},
        },
        "conflicts": [],
    }
    with open(os.path.join(out_dir, "conflicts.json"), "w", encoding="utf-8") as f:
        json.dump(report, f)
    store = ReviewStore(out_dir)
    store.write_queue([_queue_item("finding:AGENT-0001")])
    monkeypatch.setattr("backend.config.OUTPUT_DIR", str(tmp_path))
    client = TestClient(app)

    job_response = client.get(f"/jobs/{job_id}")
    assert job_response.status_code == 200
    assert job_response.json()["report"]["summary"]["review"]["required"] == 1

    review_response = client.get(f"/jobs/{job_id}/review")
    assert review_response.status_code == 200
    body = review_response.json()
    assert [i["review_item_id"] for i in body["queue"]] == ["finding:AGENT-0001"]
    assert body["progress"]["remaining"] == 1
    assert body["decisions"] == {}

    post = client.post(f"/jobs/{job_id}/review-decisions", json={"decisions": [
        {"review_item_id": "finding:AGENT-0001", "decision": "reject",
         "reason_code": "not_a_contradiction"},
    ]})
    assert post.status_code == 200
    assert post.json()["progress"]["remaining"] == 0

    again = client.get(f"/jobs/{job_id}/review")
    assert again.status_code == 200
    saved = again.json()["decisions"]["finding:AGENT-0001"]
    assert saved["decision"] == "reject"
    assert saved["reason_code"] == "not_a_contradiction"


def _write_restart_job(tmp_path, job_id, email=None):
    """On-disk needs_review job artifacts, as a pre-restart run would leave
    them: candidate report + memory snapshot + review queue (+ job.json)."""
    out_dir = os.path.join(str(tmp_path), job_id)
    os.makedirs(os.path.join(out_dir, "agent"))
    report = {
        "source": "set.pdf",
        "generated_at": "2026-07-28T00:00:00+00:00",
        "summary": {
            "sheets_analyzed": 0, "disciplines": [], "assertions_extracted": 0,
            "clusters_checked": 0, "conflicts_found": 0,
            "by_severity": {"high": 0, "medium": 0, "low": 0}, "by_category": {},
            "pipeline_mode": "agent", "agent_status": "needs_review",
            "review": {"required": 1, "completed": 0, "remaining": 1, "total": 1},
        },
        "conflicts": [], "sheets": [],
        "validated_issues": [{"issue_id": "AGENT-0001", "severity": "high"}],
        "suppressed_issues": [], "rfis": [],
    }
    with open(os.path.join(out_dir, "conflicts.json"), "w", encoding="utf-8") as f:
        json.dump(report, f)
    with open(os.path.join(out_dir, "agent", "memory.json"), "w", encoding="utf-8") as f:
        json.dump({}, f)
    if email is not None:
        with open(os.path.join(out_dir, "job.json"), "w", encoding="utf-8") as f:
            json.dump({"job_id": job_id, "email": email,
                       "pipeline_mode": "agent", "source": "set.pdf"}, f)
    store = ReviewStore(out_dir)
    store.write_queue([_queue_item("finding:AGENT-0001")])
    return out_dir


def test_restart_recovered_needs_review_job_finalizes(monkeypatch, tmp_path):
    """CRITICAL: after a restart, a needs_review job recovered from disk keeps
    its status (not "done"), hydrates the in-memory registry, and the whole
    decide -> finalize flow completes to done via the real get_job."""
    job_id = "jobrestart"
    _write_restart_job(tmp_path, job_id)
    monkeypatch.setattr("backend.config.OUTPUT_DIR", str(tmp_path))
    monkeypatch.setattr("backend.review.finalizer._draft_rfis", lambda kept: [])
    monkeypatch.setattr("backend.jobs._notify", lambda *a, **kw: None)
    monkeypatch.setattr(threading, "Thread", _SyncThread)
    try:
        # Real disk fallback: simulates a fresh post-restart process.
        job = backend.jobs.get_job(job_id)
        assert job["status"] == "needs_review"
        assert job_id in backend.jobs._jobs  # hydrated for _set() transitions

        client = TestClient(app)
        post = client.post(f"/jobs/{job_id}/review-decisions", json={"decisions": [
            {"review_item_id": "finding:AGENT-0001", "decision": "confirm"},
        ]})
        assert post.status_code == 200

        fin = client.post(f"/jobs/{job_id}/finalize-review")
        assert fin.status_code == 200
        job = backend.jobs.get_job(job_id)
        assert job["status"] == "done"
        assert job["report"]["summary"]["agent_status"] == "complete"
    finally:
        backend.jobs._jobs.pop(job_id, None)


def test_restart_recovered_job_final_email_uses_job_json(monkeypatch, tmp_path):
    """CRITICAL: job.json (written at job start) restores the recipient email
    after a restart, so finalization still fires the final report email."""
    job_id = "jobemail"
    _write_restart_job(tmp_path, job_id, email="arch@example.com")
    monkeypatch.setattr("backend.config.OUTPUT_DIR", str(tmp_path))
    monkeypatch.setattr("backend.review.finalizer._draft_rfis", lambda kept: [])
    sent = []
    monkeypatch.setattr("backend.jobs.send_conflict_report",
                        lambda email, report, **kw: sent.append(email))
    monkeypatch.setattr(threading, "Thread", _SyncThread)
    try:
        job = backend.jobs.get_job(job_id)
        assert job["status"] == "needs_review"
        assert job["email"] == "arch@example.com"

        client = TestClient(app)
        post = client.post(f"/jobs/{job_id}/review-decisions", json={"decisions": [
            {"review_item_id": "finding:AGENT-0001", "decision": "confirm"},
        ]})
        assert post.status_code == 200
        fin = client.post(f"/jobs/{job_id}/finalize-review")
        assert fin.status_code == 200
        assert backend.jobs.get_job(job_id)["status"] == "done"
        assert sent == ["arch@example.com"]
    finally:
        backend.jobs._jobs.pop(job_id, None)


def test_review_decisions_409_for_non_review_job(monkeypatch, tmp_path):
    """Positive state guard: only needs_review/reviewing jobs accept decisions."""
    client = TestClient(app)
    monkeypatch.setattr("backend.main.get_job", lambda job_id: {
        "job_id": job_id, "status": "done", "out_dir": str(tmp_path),
    })
    response = client.post("/jobs/job1/review-decisions", json={"decisions": [
        {"review_item_id": "finding:AGENT-0001", "decision": "confirm"}]})
    assert response.status_code == 409
    assert "done" in response.json()["detail"]["detail"]


def test_review_queue_get_does_not_create_review_dir(monkeypatch, tmp_path):
    """The read-only GET endpoint must not create review/ dirs on read."""
    client = TestClient(app)
    monkeypatch.setattr("backend.main.get_job", lambda job_id: {
        "job_id": job_id, "status": "needs_review",
        "report": {"summary": {}}, "out_dir": str(tmp_path),
    })
    response = client.get("/jobs/job1/review")
    assert response.status_code == 200
    assert response.json()["queue"] == []
    assert response.json()["decisions"] == {}
    assert not os.path.exists(os.path.join(str(tmp_path), "review"))


def _write_finalizable_job(tmp_path, decisions):
    """Minimal review-mode artifacts: candidate report + queue + decisions."""
    out_dir = str(tmp_path)
    os.makedirs(os.path.join(out_dir, "agent"), exist_ok=True)
    report = {
        "source": "set.pdf",
        "generated_at": "2026-07-28T00:00:00+00:00",
        "summary": {
            "sheets_analyzed": 0, "disciplines": [], "assertions_extracted": 0,
            "clusters_checked": 0, "conflicts_found": 0,
            "by_severity": {"high": 0, "medium": 0, "low": 0}, "by_category": {},
            "pipeline_mode": "agent", "agent_status": "needs_review",
            "review": {"required": 1, "completed": 0, "remaining": 1, "total": 1},
        },
        "conflicts": [], "sheets": [],
        "validated_issues": [{"issue_id": "AGENT-0001", "severity": "high"}],
        "suppressed_issues": [], "rfis": [],
    }
    with open(os.path.join(out_dir, "conflicts.json"), "w", encoding="utf-8") as f:
        json.dump(report, f)
    with open(os.path.join(out_dir, "agent", "memory.json"), "w", encoding="utf-8") as f:
        json.dump({}, f)
    store = ReviewStore(out_dir)
    store.write_queue([_queue_item("finding:AGENT-0001")])
    for decision in decisions:
        store.append_decision(decision)
    return out_dir


def test_finalize_review_409_while_undecided(monkeypatch, tmp_path):
    out_dir = _write_finalizable_job(tmp_path, decisions=[])
    client = TestClient(app)
    monkeypatch.setattr("backend.main.get_job", lambda job_id: {
        "job_id": job_id, "status": "needs_review", "out_dir": out_dir,
    })
    response = client.post("/jobs/job1/finalize-review")
    assert response.status_code == 409
    body = response.json()
    assert body["detail"]["detail"] == "incomplete review"
    assert body["detail"]["progress"]["remaining"] == 1


def test_finalize_review_404_for_unknown_job(monkeypatch, tmp_path):
    monkeypatch.setattr("backend.config.OUTPUT_DIR", str(tmp_path))
    client = TestClient(app)
    assert client.post("/jobs/nope/finalize-review").status_code == 404


def test_finalize_review_409_when_already_done(monkeypatch, tmp_path):
    client = TestClient(app)
    monkeypatch.setattr("backend.main.get_job", lambda job_id: {
        "job_id": job_id, "status": "done", "out_dir": str(tmp_path),
    })
    assert client.post("/jobs/job1/finalize-review").status_code == 409


def test_finalize_review_409_for_job_not_in_review(monkeypatch, tmp_path):
    """A running (or otherwise non-review) job must not be finalizable: no
    finalization thread, no artifact clobbering, no final email."""
    threads = []
    notified = []
    monkeypatch.setattr(threading, "Thread",
                        lambda *a, **kw: threads.append((a, kw)) or _SyncThread(*a, **kw))
    monkeypatch.setattr("backend.jobs._notify",
                        lambda *a, **kw: notified.append(a))
    monkeypatch.setattr("backend.main.get_job", lambda job_id: {
        "job_id": job_id, "status": "running", "out_dir": str(tmp_path),
    })
    client = TestClient(app)
    response = client.post("/jobs/job1/finalize-review")
    assert response.status_code == 409
    assert "running" in response.json()["detail"]["detail"]
    assert threads == []
    assert notified == []
    assert not os.path.exists(os.path.join(str(tmp_path), "conflicts.json"))


def test_finalize_review_happy_path_notifies_once(monkeypatch, tmp_path):
    out_dir = _write_finalizable_job(tmp_path, decisions=[{
        "review_item_id": "finding:AGENT-0001", "decision": "confirm",
    }])
    notified = []
    monkeypatch.setattr("backend.review.finalizer._draft_rfis", lambda kept: [])
    monkeypatch.setattr("backend.jobs._notify",
                        lambda job_id, report, out_dir: notified.append(job_id))
    monkeypatch.setattr(threading, "Thread", _SyncThread)
    job_id = "jobfinalize"
    backend.jobs._jobs[job_id] = {
        "job_id": job_id, "status": "reviewing", "out_dir": out_dir,
        "report": None, "email": "arch@example.com",
    }
    try:
        client = TestClient(app)
        response = client.post(f"/jobs/{job_id}/finalize-review")
        assert response.status_code == 200
        assert response.json() == {"status": "finalizing"}
        job = backend.jobs.get_job(job_id)
        assert job["status"] == "done"
        assert job["report"]["summary"]["agent_status"] == "complete"
        assert notified == [job_id]
        for name in ("conflicts.json", "validated_issues.json",
                     "suppressed_issues.json", "rfis.json", "report.md"):
            assert os.path.isfile(os.path.join(out_dir, name)), name
    finally:
        backend.jobs._jobs.pop(job_id, None)
