# SPDX-License-Identifier: AGPL-3.0-only
# Copyright (c) 2026 sol pbc

"""Tests for curation app routes."""

from __future__ import annotations

import json
from pathlib import Path
from typing import Any

import numpy as np

import solstone.think.curation as curation
from solstone.apps.curation.copy import CUR_EMPTY_STATE, CUR_HEADING
from solstone.think.entities import EntityAmbiguityError
from solstone.think.entities.journal import load_journal_entity, save_journal_entity
from solstone.think.entities.review_candidates import (
    load_candidates as load_entity_candidates,
)
from solstone.think.entities.review_candidates import (
    save_candidates as save_entity_candidates,
)
from solstone.think.facet_review_candidates import (
    load_candidates as load_facet_candidates,
)
from solstone.think.facet_review_candidates import record_facet_candidate
from solstone.think.journal_io import LockTimeout
from solstone.think.speaker_candidate_pair_review_candidates import (
    candidate_key as pair_candidate_key,
)
from solstone.think.speaker_candidate_pair_review_candidates import (
    load_candidates as load_pair_candidates,
)
from solstone.think.speaker_candidate_pair_review_candidates import (
    record_candidate_pair,
)
from solstone.think.speaker_review_candidates import (
    candidate_key as speaker_candidate_key,
)
from solstone.think.speaker_review_candidates import (
    load_candidates as load_speaker_candidates,
)
from solstone.think.speaker_review_candidates import record_name_variant_candidate


def _seed_facet_candidate(count: int = 3) -> None:
    record_facet_candidate(
        "Home Reno",
        "home reno",
        count,
        14,
        [{"day": "20260602", "stream": "archon", "segment": "090000_300"}],
        "20260602",
    )


def _seed_entities() -> None:
    save_journal_entity(
        {
            "id": "kognova_inc",
            "name": "Kognova Inc",
            "type": "Company",
            "aka": ["Kognova Incorporated"],
        }
    )
    save_journal_entity(
        {
            "id": "kognova",
            "name": "Kognova",
            "type": "Company",
            "aka": [],
        }
    )


def _entity_candidate_row(
    source_slug: str = "kognova_inc",
    target_slug: str = "kognova",
    *,
    source: str = "Kognova Inc",
    target: str = "Kognova",
    status: str = "open",
    detection_count: int = 4,
) -> dict[str, Any]:
    return {
        "facet": "work",
        "source": source,
        "source_slug": source_slug,
        "target": target,
        "target_slug": target_slug,
        "status": status,
        "evidence": {
            "basis": "name-variant",
            "summary": f"{source} / {target}",
            "detection_count": detection_count,
            "needs": 0,
        },
    }


def _seed_entity_candidates(rows: list[dict[str, Any]]) -> None:
    save_entity_candidates(rows)


def _seed_entity_candidate(status: str = "open", detection_count: int = 4) -> None:
    _seed_entity_candidates(
        [_entity_candidate_row(status=status, detection_count=detection_count)]
    )


def _seed_entity_pair(
    source_slug: str,
    source_name: str,
    target_slug: str,
    target_name: str,
) -> None:
    save_journal_entity(
        {
            "id": source_slug,
            "name": source_name,
            "type": "Company",
            "aka": [],
        }
    )
    save_journal_entity(
        {
            "id": target_slug,
            "name": target_name,
            "type": "Company",
            "aka": [],
        }
    )


def _assert_folded(source_slug: str, target_slug: str, source_name: str) -> None:
    assert load_journal_entity(source_slug) is None
    target = load_journal_entity(target_slug)
    assert target is not None
    assert source_name in target["aka"]


def _entity_payload() -> dict[str, str]:
    return {
        "facet": "work",
        "source_slug": "kognova_inc",
        "target_slug": "kognova",
    }


def _speaker_payload(
    source_id: str = "alice",
    target_id: str = "alice_johnson",
) -> dict[str, str]:
    return {
        "key": speaker_candidate_key(source_id, target_id),
        "source_id": source_id,
        "target_id": target_id,
    }


def _pair_anchors() -> tuple[str, str]:
    return (
        '["20260101","090000_300","test","mic_audio",1]',
        '["20260102","090000_300","test","mic_audio",2]',
    )


def _speaker_candidate_pair_payload(
    anchor_a: str | None = None,
    anchor_b: str | None = None,
) -> dict[str, str]:
    left, right = _pair_anchors()
    anchor_a = anchor_a or left
    anchor_b = anchor_b or right
    return {
        "key": pair_candidate_key(anchor_a, anchor_b),
        "anchor_a": anchor_a,
        "anchor_b": anchor_b,
    }


def _seed_speaker_candidate_pair() -> tuple[str, str]:
    anchor_a, anchor_b = _pair_anchors()
    sample = {
        "day": "20260101",
        "stream": "test",
        "segment_key": "090000_300",
        "source": "mic_audio",
        "cluster_label": 1,
        "audio_url": "/app/speakers/api/serve_audio/20260101/test/090000_300/mic_audio.flac",
    }
    record_candidate_pair(
        source_anchor=anchor_a,
        target_anchor=anchor_b,
        source_anchors={anchor_a},
        target_anchors={anchor_b},
        similarity=0.62,
        source_intervals=31,
        target_intervals=35,
        source_samples=[sample],
        target_samples=[],
    )
    return anchor_a, anchor_b


def _embedding(vector: list[float]) -> np.ndarray:
    embedding = np.array(vector + [0.0] * (256 - len(vector)), dtype=np.float32)
    return embedding / np.linalg.norm(embedding)


def _write_voiceprints(
    entity_dir: Path,
    embedding: np.ndarray,
    *,
    offset: int = 0,
) -> None:
    embeddings = np.tile(embedding.reshape(1, -1), (5, 1))
    metadata = np.array(
        [
            json.dumps(
                {
                    "day": "20240101",
                    "segment_key": "143022_300",
                    "source": "mic_audio",
                    "sentence_id": i + offset,
                    "added_at": 1700000000000,
                }
            )
            for i in range(5)
        ],
        dtype=str,
    )
    np.savez_compressed(
        entity_dir / "voiceprints.npz",
        embeddings=embeddings,
        metadata=metadata,
    )


def _write_speaker_labels(env) -> Path:
    labels_path = (
        env.journal
        / "chronicle"
        / "20240101"
        / "test"
        / "143022_300"
        / "talents"
        / "speaker_labels.json"
    )
    labels_path.parent.mkdir(parents=True, exist_ok=True)
    labels_path.write_text(
        json.dumps(
            {
                "labels": [
                    {
                        "sentence_id": 1,
                        "speaker": "alice",
                        "confidence": "high",
                        "method": "voiceprint",
                    }
                ]
            }
        ),
        encoding="utf-8",
    )
    return labels_path


def _seed_speaker_entities(env) -> Path:
    save_journal_entity(
        {
            "id": "alice",
            "name": "Alice",
            "type": "Person",
            "aka": [],
        }
    )
    save_journal_entity(
        {
            "id": "alice_johnson",
            "name": "Alice Johnson",
            "type": "Person",
            "aka": [],
        }
    )
    embedding = _embedding([1.0, 0.0, 0.0])
    _write_voiceprints(env.journal / "entities" / "alice", embedding)
    _write_voiceprints(
        env.journal / "entities" / "alice_johnson",
        embedding,
        offset=10,
    )
    labels_path = _write_speaker_labels(env)
    record_name_variant_candidate(
        source_id="alice",
        source_label="Alice",
        target_id="alice_johnson",
        target_label="Alice Johnson",
        similarity=0.99,
    )
    return labels_path


def test_index_renders_empty_state(curation_env):
    env = curation_env()

    resp = env.client.get("/app/curation/api/state")

    assert resp.status_code == 200
    data = resp.get_json()
    assert data["facet_items"] == []
    assert data["entity_items"] == []
    assert data["speaker_items"] == []
    assert data["speaker_candidate_pair_items"] == []
    assert data["copy"]["CUR_HEADING"] == CUR_HEADING
    assert data["copy"]["CUR_EMPTY_STATE"] == CUR_EMPTY_STATE


def test_index_renders_facet_and_entity_candidates(curation_env):
    env = curation_env()
    _seed_facet_candidate()
    _seed_entity_candidate()

    resp = env.client.get("/app/curation/api/state")

    assert resp.status_code == 200
    data = resp.get_json()
    assert data["facet_items"][0]["name"] == "Home Reno"
    assert data["entity_items"][0]["source"] == "Kognova Inc"
    assert data["entity_items"][0]["target"] == "Kognova"


def test_index_renders_speaker_candidate(curation_env):
    env = curation_env()
    record_name_variant_candidate(
        source_id="alice",
        source_label="Alice",
        target_id="alice_johnson",
        target_label="Alice Johnson",
        similarity=0.99,
    )

    resp = env.client.get("/app/curation/api/state")

    assert resp.status_code == 200
    data = resp.get_json()
    assert data["speaker_items"][0]["kind"] == "speaker_name_variant"
    assert data["speaker_items"][0]["source"] == "Alice"
    assert data["speaker_items"][0]["target"] == "Alice Johnson"


def test_index_renders_speaker_candidate_pair_bucket(curation_env):
    env = curation_env()
    _seed_speaker_candidate_pair()

    resp = env.client.get("/app/curation/api/state")

    assert resp.status_code == 200
    data = resp.get_json()
    assert data["speaker_candidate_pair_items"][0]["kind"] == "speaker_candidate_pair"
    assert data["speaker_candidate_pair_items"][0]["evidence"]["similarity"] == 0.62
    assert (
        data["speaker_candidate_pair_items"][0]["evidence"]["source_samples"][0][
            "segment_key"
        ]
        == "090000_300"
    )
    assert (
        "segment"
        not in data["speaker_candidate_pair_items"][0]["evidence"]["source_samples"][0]
    )


def test_facet_accept_creates_facet_and_flips_status(curation_env):
    env = curation_env()
    _seed_facet_candidate()

    resp = env.client.post(
        "/app/curation/api/facet/accept",
        json={"name_key": "home reno"},
    )

    assert resp.status_code == 200
    assert resp.get_json()["status"] == "accepted"
    assert (env.journal / "facets" / "home-reno" / "facet.json").exists()
    assert load_facet_candidates()[0]["status"] == "accepted"


def test_facet_dismiss_sets_watermark(curation_env):
    env = curation_env()
    _seed_facet_candidate(count=5)

    resp = env.client.post(
        "/app/curation/api/facet/dismiss",
        json={"name_key": "home reno"},
    )

    assert resp.status_code == 200
    row = load_facet_candidates()[0]
    assert row["status"] == "dismissed"
    assert row["dismissed_count"] == 5


def test_entity_preview_returns_summary_and_keeps_status_open(curation_env):
    env = curation_env()
    _seed_entities()
    _seed_entity_candidate()

    resp = env.client.post("/app/curation/api/entity/preview", json=_entity_payload())

    assert resp.status_code == 200
    data = resp.get_json()
    assert data["status"] == "preview"
    assert data["merge"]["would_identity"]["akas_added"] == [
        "Kognova Inc",
        "Kognova Incorporated",
    ]
    assert data["preview"]["akas_added"] == [
        "Kognova Inc",
        "Kognova Incorporated",
    ]
    assert load_entity_candidates()[0]["status"] == "open"


def test_entity_accept_flips_status(curation_env):
    env = curation_env()
    _seed_entities()
    _seed_entity_candidate()

    resp = env.client.post("/app/curation/api/entity/accept", json=_entity_payload())

    assert resp.status_code == 200
    assert resp.get_json()["status"] == "accepted"
    assert load_entity_candidates()[0]["status"] == "accepted"


def test_entity_dismiss_sets_watermark(curation_env):
    env = curation_env()
    _seed_entity_candidate(detection_count=6)

    resp = env.client.post("/app/curation/api/entity/dismiss", json=_entity_payload())

    assert resp.status_code == 200
    row = load_entity_candidates()[0]
    assert row["status"] == "dismissed"
    assert row["dismissed_detection_count"] == 6


def test_entity_accept_batch_merges_all(curation_env):
    env = curation_env()
    _seed_entity_pair("kognova_inc", "Kognova Inc", "kognova", "Kognova")
    _seed_entity_pair("octo_labs_inc", "Octo Labs Inc", "octo_labs", "Octo Labs")
    _seed_entity_candidates(
        [
            _entity_candidate_row(),
            _entity_candidate_row(
                "octo_labs_inc",
                "octo_labs",
                source="Octo Labs Inc",
                target="Octo Labs",
            ),
        ]
    )

    resp = env.client.post(
        "/app/curation/api/entity/accept-batch",
        json={
            "items": [
                _entity_payload(),
                {
                    "facet": "work",
                    "source_slug": "octo_labs_inc",
                    "target_slug": "octo_labs",
                },
            ]
        },
    )

    assert resp.status_code == 200
    data = resp.get_json()
    assert data["accepted"] == 2
    assert data["failed"] == 0
    assert len(data["results"]) == 2
    _assert_folded("kognova_inc", "kognova", "Kognova Inc")
    _assert_folded("octo_labs_inc", "octo_labs", "Octo Labs Inc")


def test_entity_accept_batch_partial_failure_first_still_merges_later(curation_env):
    env = curation_env()
    _seed_entities()
    _seed_entity_candidates(
        [
            _entity_candidate_row(
                "missing_inc",
                "missing",
                source="Missing Inc",
                target="Missing",
            ),
            _entity_candidate_row(),
        ]
    )

    resp = env.client.post(
        "/app/curation/api/entity/accept-batch",
        json={
            "items": [
                {
                    "facet": "work",
                    "source_slug": "missing_inc",
                    "target_slug": "missing",
                },
                _entity_payload(),
            ]
        },
    )

    assert resp.status_code == 200
    data = resp.get_json()
    assert len(data["results"]) == 2
    assert data["results"][0]["status"] == "error"
    assert data["results"][0]["error"]
    assert data["failed"] == 1
    assert data["accepted"] == 1
    _assert_folded("kognova_inc", "kognova", "Kognova Inc")


def test_entity_accept_batch_counts_already_accepted(curation_env):
    env = curation_env()
    _seed_entities()
    _seed_entity_candidates(
        [
            _entity_candidate_row(
                "accepted_inc",
                "accepted",
                source="Accepted Inc",
                target="Accepted",
                status="accepted",
            ),
            _entity_candidate_row(),
        ]
    )

    resp = env.client.post(
        "/app/curation/api/entity/accept-batch",
        json={
            "items": [
                {
                    "facet": "work",
                    "source_slug": "accepted_inc",
                    "target_slug": "accepted",
                },
                _entity_payload(),
            ]
        },
    )

    assert resp.status_code == 200
    data = resp.get_json()
    assert data["accepted"] == 2
    assert data["failed"] == 0
    assert data["results"][0]["status"] == "already_accepted"
    _assert_folded("kognova_inc", "kognova", "Kognova Inc")


def test_entity_accept_batch_lock_timeout_is_per_item(curation_env, monkeypatch):
    env = curation_env()
    _seed_entities()
    _seed_entity_candidates(
        [
            _entity_candidate_row(
                "busy_inc",
                "busy",
                source="Busy Inc",
                target="Busy",
            ),
            _entity_candidate_row(),
        ]
    )
    real_merge = curation.merge_entity

    def raise_busy_for_one(source_id: str, target_id: str, **kwargs):
        if source_id == "busy_inc":
            raise LockTimeout(env.journal / "busy.lock", 0.01)
        return real_merge(source_id, target_id, **kwargs)

    monkeypatch.setattr(curation, "merge_entity", raise_busy_for_one)

    resp = env.client.post(
        "/app/curation/api/entity/accept-batch",
        json={
            "items": [
                {"facet": "work", "source_slug": "busy_inc", "target_slug": "busy"},
                _entity_payload(),
            ]
        },
    )

    assert resp.status_code == 200
    data = resp.get_json()
    assert data["accepted"] == 1
    assert data["failed"] == 1
    assert data["results"][0]["status"] == "error"
    assert data["results"][0]["error"] == "entity merge candidates are busy; try again"
    _assert_folded("kognova_inc", "kognova", "Kognova Inc")


def test_entity_dismiss_batch_dismisses_all(curation_env):
    env = curation_env()
    _seed_entity_candidates(
        [
            _entity_candidate_row(),
            _entity_candidate_row(
                "octo_labs_inc",
                "octo_labs",
                source="Octo Labs Inc",
                target="Octo Labs",
            ),
        ]
    )

    resp = env.client.post(
        "/app/curation/api/entity/dismiss-batch",
        json={
            "items": [
                _entity_payload(),
                {
                    "facet": "work",
                    "source_slug": "octo_labs_inc",
                    "target_slug": "octo_labs",
                },
            ]
        },
    )

    assert resp.status_code == 200
    data = resp.get_json()
    assert data["dismissed"] == 2
    assert data["failed"] == 0
    assert [row["status"] for row in load_entity_candidates()] == [
        "dismissed",
        "dismissed",
    ]


def test_entity_dismiss_batch_partial_failure_first_still_dismisses_later(curation_env):
    env = curation_env()
    _seed_entity_candidates([_entity_candidate_row()])

    resp = env.client.post(
        "/app/curation/api/entity/dismiss-batch",
        json={
            "items": [
                {
                    "facet": "work",
                    "source_slug": "missing_inc",
                    "target_slug": "missing",
                },
                _entity_payload(),
            ]
        },
    )

    assert resp.status_code == 200
    data = resp.get_json()
    assert len(data["results"]) == 2
    assert data["results"][0]["status"] == "error"
    assert data["results"][0]["error"]
    assert data["failed"] == 1
    assert data["dismissed"] == 1
    assert load_entity_candidates()[0]["status"] == "dismissed"


def test_entity_dismiss_batch_counts_already_dismissed(curation_env):
    env = curation_env()
    _seed_entity_candidates(
        [
            _entity_candidate_row(
                "dismissed_inc",
                "dismissed",
                source="Dismissed Inc",
                target="Dismissed",
                status="dismissed",
            ),
            _entity_candidate_row(),
        ]
    )

    resp = env.client.post(
        "/app/curation/api/entity/dismiss-batch",
        json={
            "items": [
                {
                    "facet": "work",
                    "source_slug": "dismissed_inc",
                    "target_slug": "dismissed",
                },
                _entity_payload(),
            ]
        },
    )

    assert resp.status_code == 200
    data = resp.get_json()
    assert data["dismissed"] == 2
    assert data["failed"] == 0
    assert data["results"][0]["status"] == "already_dismissed"


def test_entity_batch_routes_reject_missing_invalid_or_empty_items(curation_env):
    env = curation_env()

    for path in (
        "/app/curation/api/entity/accept-batch",
        "/app/curation/api/entity/dismiss-batch",
    ):
        for payload in ({}, {"items": "not-list"}, {"items": []}):
            resp = env.client.post(path, json=payload)
            assert resp.status_code == 400
            assert resp.get_json()["reason_code"] == "missing_required_field"


def test_missing_required_field_returns_standard_error(curation_env):
    env = curation_env()

    resp = env.client.post("/app/curation/api/facet/accept", json={})

    assert resp.status_code == 400
    assert resp.get_json()["reason_code"] == "missing_required_field"


def test_entity_preview_error_returns_400_without_flipping(curation_env):
    env = curation_env()
    _seed_entity_candidate()

    resp = env.client.post("/app/curation/api/entity/preview", json=_entity_payload())

    assert resp.status_code == 400
    assert resp.get_json()["status"] == "error"
    assert load_entity_candidates()[0]["status"] == "open"


def test_speaker_preview_returns_summary_and_keeps_state_open(curation_env):
    env = curation_env()
    labels_path = _seed_speaker_entities(env)
    source_before = load_journal_entity("alice")
    target_before = load_journal_entity("alice_johnson")
    source_voiceprints_before = (
        env.journal / "entities" / "alice" / "voiceprints.npz"
    ).read_bytes()
    target_voiceprints_before = (
        env.journal / "entities" / "alice_johnson" / "voiceprints.npz"
    ).read_bytes()
    labels_before = labels_path.read_text(encoding="utf-8")

    resp = env.client.post("/app/curation/api/speaker/preview", json=_speaker_payload())

    assert resp.status_code == 200
    data = resp.get_json()
    assert data["status"] == "preview"
    assert data["merge"]["would_identity"]["akas_added"] == ["Alice"]
    assert data["preview"]["akas_added"] == ["Alice"]
    assert data["preview"]["voiceprints_added"] == 5
    assert load_speaker_candidates()[0]["status"] == "open"
    assert load_journal_entity("alice") == source_before
    assert load_journal_entity("alice_johnson") == target_before
    assert (
        env.journal / "entities" / "alice" / "voiceprints.npz"
    ).read_bytes() == source_voiceprints_before
    assert (
        env.journal / "entities" / "alice_johnson" / "voiceprints.npz"
    ).read_bytes() == target_voiceprints_before
    assert labels_path.read_text(encoding="utf-8") == labels_before


def test_speaker_accept_merges_and_marks_candidate_accepted(curation_env):
    env = curation_env()
    labels_path = _seed_speaker_entities(env)

    resp = env.client.post("/app/curation/api/speaker/accept", json=_speaker_payload())

    assert resp.status_code == 200
    data = resp.get_json()
    assert data["status"] == "accepted"
    assert data["merge"]["merged"] is True
    rows = load_speaker_candidates()
    assert len(rows) == 1
    assert rows[0]["status"] == "accepted"
    assert [row for row in rows if row["status"] == "open"] == []
    assert load_journal_entity("alice") is None
    target = load_journal_entity("alice_johnson")
    assert target is not None
    assert "Alice" in target["aka"]
    labels = json.loads(labels_path.read_text(encoding="utf-8"))["labels"]
    assert labels[0]["speaker"] == "alice_johnson"


def test_speaker_accept_merge_error_keeps_candidate_open(curation_env):
    env = curation_env()
    record_name_variant_candidate(
        source_id="alice",
        source_label="Alice",
        target_id="alice_johnson",
        target_label="Alice Johnson",
        similarity=0.99,
    )

    resp = env.client.post("/app/curation/api/speaker/accept", json=_speaker_payload())

    assert resp.status_code == 400
    assert resp.get_json()["status"] == "error"
    assert "Source entity not found" in resp.get_json()["error"]
    assert load_speaker_candidates()[0]["status"] == "open"


def test_speaker_accept_rejects_swapped_direction(curation_env):
    env = curation_env()
    labels_path = _seed_speaker_entities(env)
    payload = _speaker_payload(source_id="alice_johnson", target_id="alice")

    resp = env.client.post("/app/curation/api/speaker/accept", json=payload)

    assert resp.status_code == 400
    data = resp.get_json()
    assert data["status"] == "error"
    assert data["error"] == "candidate direction mismatch"
    assert load_speaker_candidates()[0]["status"] == "open"
    assert load_journal_entity("alice") is not None
    assert load_journal_entity("alice_johnson") is not None
    labels = json.loads(labels_path.read_text(encoding="utf-8"))["labels"]
    assert labels[0]["speaker"] == "alice"


def test_speaker_dismiss_sets_watermark_and_removes_from_open_list(curation_env):
    env = curation_env()
    record_name_variant_candidate(
        source_id="alice",
        source_label="Alice",
        target_id="alice_johnson",
        target_label="Alice Johnson",
        similarity=0.99,
    )

    resp = env.client.post("/app/curation/api/speaker/dismiss", json=_speaker_payload())

    assert resp.status_code == 200
    row = load_speaker_candidates()[0]
    assert row["status"] == "dismissed"
    assert row["dismissed_detection_count"] == 1
    index_resp = env.client.get("/app/curation/api/state")
    assert index_resp.status_code == 200
    assert index_resp.get_json()["speaker_items"] == []


def test_speaker_payload_missing_field_returns_400(curation_env):
    env = curation_env()

    resp = env.client.post(
        "/app/curation/api/speaker/preview",
        json={"key": speaker_candidate_key("alice", "alice_johnson")},
    )

    assert resp.status_code == 400
    assert resp.get_json()["reason_code"] == "missing_required_field"


def test_speaker_payload_key_mismatch_returns_400(curation_env):
    env = curation_env()

    resp = env.client.post(
        "/app/curation/api/speaker/preview",
        json={
            "key": "wrong|key",
            "source_id": "alice",
            "target_id": "alice_johnson",
            "source_label": "Ignored",
            "target_label": "Also Ignored",
        },
    )

    assert resp.status_code == 400
    assert resp.get_json()["reason_code"] == "invalid_request_value"


def test_speaker_candidate_pair_dismiss_sets_status_and_removes_open_item(curation_env):
    env = curation_env()
    _seed_speaker_candidate_pair()

    resp = env.client.post(
        "/app/curation/api/speaker-candidate-pair/dismiss",
        json=_speaker_candidate_pair_payload(),
    )

    assert resp.status_code == 200
    assert resp.get_json()["status"] == "dismissed"
    assert load_pair_candidates()[0]["status"] == "dismissed"
    state = env.client.get("/app/curation/api/state").get_json()
    assert state["speaker_candidate_pair_items"] == []


def test_speaker_candidate_pair_payload_key_mismatch_returns_400(curation_env):
    env = curation_env()

    resp = env.client.post(
        "/app/curation/api/speaker-candidate-pair/accept",
        json={
            "key": "wrong",
            "anchor_a": _pair_anchors()[0],
            "anchor_b": _pair_anchors()[1],
        },
    )

    assert resp.status_code == 400
    assert resp.get_json()["reason_code"] == "invalid_request_value"


def test_speaker_candidate_pair_accept_lock_timeout_returns_busy(
    curation_env,
    monkeypatch,
):
    env = curation_env()
    _seed_speaker_candidate_pair()
    from solstone.apps.curation import routes

    def raise_busy(anchor_a: str, anchor_b: str) -> dict[str, Any]:
        raise LockTimeout(path=env.journal / "speakers" / "busy.jsonl", timeout=0.0)

    monkeypatch.setattr(routes, "accept_speaker_candidate_pair", raise_busy)

    resp = env.client.post(
        "/app/curation/api/speaker-candidate-pair/accept",
        json=_speaker_candidate_pair_payload(),
    )

    assert resp.status_code == 503
    assert resp.get_json()["reason_code"] == "entity_busy"


def test_rendered_payload_matches_copy_source(curation_env):
    env = curation_env()

    resp = env.client.get("/app/curation/api/state")

    assert resp.status_code == 200
    assert resp.get_json()["copy"]["CUR_HEADING"] == CUR_HEADING


def test_app_metadata_exists():
    metadata = json.loads(Path("solstone/apps/curation/app.json").read_text())

    assert metadata["label"] == "curation"
    assert metadata["facets"]["disabled"] is True


def test_curation_index_serves_spa_shell(curation_env):
    env = curation_env()

    response = env.client.get("/app/curation/")

    assert response.status_code == 200
    assert b'data-solstone-shell="spa"' in response.data


def test_curation_workspace_is_served_verbatim(curation_env):
    env = curation_env()
    workspace_path = Path(__file__).resolve().parents[1] / "workspace.html"

    response = env.client.get("/app/curation/workspace")

    assert response.status_code == 200
    assert response.data == workspace_path.read_bytes()


def test_curation_api_state_route_resolves(curation_env):
    env = curation_env()
    adapter = env.app.url_map.bind("localhost")

    endpoint, _args = adapter.match("/app/curation/api/state", method="GET")

    assert endpoint == "app:curation.api_state"


def test_curation_static_literal_paths_resolve(curation_env):
    env = curation_env()
    adapter = env.app.url_map.bind("localhost")

    endpoint, _args = adapter.match(
        "/app/curation/static/curation_evidence.js",
        method="GET",
    )
    response = env.client.get("/app/curation/static/curation_evidence.js")

    assert endpoint
    assert response.status_code == 200
    assert b"buildEvidenceDrawerProps" in response.data


def test_curation_state_payload_shape_includes_nested_evidence(curation_env):
    env = curation_env()
    _seed_facet_candidate()

    response = env.client.get("/app/curation/api/state")
    data = response.get_json()

    assert response.status_code == 200
    assert set(data) == {
        "ambiguity_items",
        "copy",
        "entity_items",
        "facet_items",
        "speaker_candidate_pair_items",
        "speaker_items",
    }
    assert data["ambiguity_items"] == []
    assert data["entity_items"] == []
    assert data["speaker_candidate_pair_items"] == []
    assert data["speaker_items"] == []
    assert data["facet_items"][0]["evidence"]["samples"] == [
        {"day": "20260602", "stream": "archon", "segment": "090000_300"}
    ]
    assert data["copy"]["CUR_HEADING"] == CUR_HEADING


def test_curation_state_fails_closed_on_corrupt_ambiguity_store(
    curation_env, monkeypatch
):
    env = curation_env()
    from solstone.apps.curation import routes

    monkeypatch.setattr(
        routes,
        "load_open_items",
        lambda: (_ for _ in ()).throw(EntityAmbiguityError("corrupt ambiguity state")),
    )

    response = env.client.get("/app/curation/api/state")

    assert response.status_code == 500
    assert response.get_json()["reason_code"] == "entity_operation_failed"
    assert response.get_json()["detail"] == "corrupt ambiguity state"


def test_entity_accept_route_preserves_repair_required_state(curation_env, monkeypatch):
    env = curation_env()
    from solstone.apps.curation import routes

    monkeypatch.setattr(
        routes,
        "accept_entity_candidate",
        lambda *args, **kwargs: {
            "status": "error",
            "error": "rollback failed",
            "operation_state": "repair_required",
            "mutation_applied": True,
            "source_state": {"exists": True},
            "target_state": {"exists": True},
            "safe_remediation": "Inspect before retrying.",
        },
    )

    response = env.client.post(
        "/app/curation/api/entity/accept",
        json={
            "facet": "work",
            "source_slug": "source",
            "target_slug": "target",
        },
    )

    assert response.status_code == 400
    assert response.get_json()["operation_state"] == "repair_required"
    assert response.get_json()["mutation_applied"] is True
    assert response.get_json()["safe_remediation"] == "Inspect before retrying."


def test_curation_routes_do_not_use_render_template():
    routes_path = Path(__file__).resolve().parents[1] / "routes.py"

    assert "render_template" not in routes_path.read_text(encoding="utf-8")
