"""Tests for durable dependency edges (#784, umbrella #628 scope item 6)."""

from __future__ import annotations

import json
import os
import sqlite3
import tempfile
import unittest
from unittest.mock import patch

import allocator_dependencies
import dependency_graph
import gitea_mcp_server as srv
import mcp_tool_inventory
from control_plane_db import SCHEMA_VERSION, ControlPlaneDB, ControlPlaneError

ISSUE = dependency_graph.WORK_KIND_ISSUE
PR = dependency_graph.WORK_KIND_PR
EDGE_BLOCKED = dependency_graph.EDGE_ISSUE_BLOCKED_BY_ISSUE

# Schema as it stood before this change, used to prove a real v3 → v4 migration
# rather than a fresh-database creation dressed up as one.
_V3_SCHEMA = """
CREATE TABLE schema_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE sessions (
    session_id TEXT PRIMARY KEY,
    role TEXT NOT NULL,
    profile TEXT,
    namespace TEXT,
    pid INTEGER,
    started_at TEXT NOT NULL,
    last_heartbeat_at TEXT NOT NULL,
    status TEXT NOT NULL DEFAULT 'active'
);
CREATE TABLE work_items (
    work_item_id INTEGER PRIMARY KEY AUTOINCREMENT,
    remote TEXT NOT NULL,
    org TEXT NOT NULL,
    repo TEXT NOT NULL,
    kind TEXT NOT NULL CHECK (kind IN ('issue', 'pr')),
    number INTEGER NOT NULL,
    state TEXT NOT NULL DEFAULT 'open',
    priority INTEGER NOT NULL DEFAULT 0,
    current_head_sha TEXT,
    updated_at TEXT NOT NULL,
    UNIQUE (remote, org, repo, kind, number)
);
CREATE TABLE leases (
    lease_id TEXT PRIMARY KEY,
    work_item_id INTEGER NOT NULL REFERENCES work_items(work_item_id),
    session_id TEXT NOT NULL REFERENCES sessions(session_id),
    role TEXT NOT NULL,
    phase TEXT NOT NULL DEFAULT 'claimed',
    expires_at TEXT NOT NULL,
    heartbeat_at TEXT NOT NULL,
    status TEXT NOT NULL DEFAULT 'active'
);
CREATE TABLE assignments (
    assignment_id TEXT PRIMARY KEY,
    work_item_id INTEGER NOT NULL REFERENCES work_items(work_item_id),
    session_id TEXT NOT NULL REFERENCES sessions(session_id),
    lease_id TEXT NOT NULL REFERENCES leases(lease_id),
    allowed_actions TEXT NOT NULL,
    forbidden_actions TEXT NOT NULL,
    expected_head_sha TEXT,
    role TEXT NOT NULL,
    status TEXT NOT NULL DEFAULT 'active',
    created_at TEXT NOT NULL
);
CREATE TABLE terminal_locks (
    terminal_lock_id INTEGER PRIMARY KEY AUTOINCREMENT,
    remote TEXT NOT NULL,
    org TEXT NOT NULL,
    repo TEXT NOT NULL,
    terminal_pr INTEGER NOT NULL,
    review_id TEXT,
    decision TEXT,
    status TEXT NOT NULL DEFAULT 'active',
    cleanup_state TEXT,
    created_at TEXT NOT NULL,
    UNIQUE (remote, org, repo, terminal_pr)
);
CREATE TABLE events (
    event_id INTEGER PRIMARY KEY AUTOINCREMENT,
    work_item_id INTEGER REFERENCES work_items(work_item_id),
    event_type TEXT NOT NULL,
    message TEXT NOT NULL,
    created_at TEXT NOT NULL
);
CREATE TABLE incident_links (
    link_id INTEGER PRIMARY KEY AUTOINCREMENT,
    provider TEXT NOT NULL,
    provider_base_url TEXT NOT NULL DEFAULT '',
    provider_org TEXT NOT NULL DEFAULT '',
    provider_project TEXT NOT NULL DEFAULT '',
    provider_issue_id TEXT NOT NULL,
    provider_short_id TEXT,
    provider_permalink TEXT,
    fingerprint TEXT,
    gitea_org TEXT NOT NULL,
    gitea_repo TEXT NOT NULL,
    gitea_issue_number INTEGER NOT NULL,
    linked_pr_numbers TEXT,
    first_seen TEXT,
    last_seen TEXT,
    event_count INTEGER,
    status TEXT NOT NULL DEFAULT 'open',
    release_resolved_at TEXT,
    last_sync_at TEXT,
    UNIQUE (provider, provider_base_url, provider_org, provider_project,
            provider_issue_id)
);
"""


def _edge_kwargs(**overrides):
    base = {
        "remote": "prgs",
        "org": "Scaled-Tech-Consulting",
        "repo": "Gitea-Tools",
        "source_kind": ISSUE,
        "source_number": 784,
        "target_kind": ISSUE,
        "target_number": 628,
        "edge_type": EDGE_BLOCKED,
        "state": dependency_graph.STATE_UNMET,
    }
    base.update(overrides)
    return base


class VocabularyTest(unittest.TestCase):
    """AC4, AC5: the edge vocabulary is complete and fails closed."""

    def test_all_seven_umbrella_relationship_types_exist(self) -> None:
        self.assertEqual(len(dependency_graph.EDGE_TYPES), 7)
        for edge_type in (
            dependency_graph.EDGE_ISSUE_BLOCKED_BY_ISSUE,
            dependency_graph.EDGE_PR_WAITING_FOR_REQUESTED_CHANGES,
            dependency_graph.EDGE_MERGE_WAITING_FOR_APPROVAL,
            dependency_graph.EDGE_RECONCILIATION_WAITING_FOR_MERGE,
            dependency_graph.EDGE_DEPLOYMENT_WAITING_FOR_INFRASTRUCTURE,
            dependency_graph.EDGE_ACCEPTANCE_WAITING_FOR_VALIDATION,
            dependency_graph.EDGE_TASK_WAITING_FOR_DEFECT_FIX,
        ):
            self.assertIn(edge_type, dependency_graph.EDGE_TYPES)
            blocking, completion = dependency_graph.default_conditions(edge_type)
            self.assertTrue(blocking and completion)

    def test_states_match_the_resolver_partitions(self) -> None:
        self.assertEqual(
            dependency_graph.EDGE_STATES,
            frozenset({"unmet", "met", "unavailable"}),
        )

    def test_unknown_edge_type_is_rejected(self) -> None:
        with self.assertRaises(dependency_graph.InvalidEdgeTypeError):
            dependency_graph.normalize_edge_type("waits_for_vibes")

    def test_unknown_state_is_rejected(self) -> None:
        with self.assertRaises(dependency_graph.InvalidEdgeStateError):
            dependency_graph.normalize_edge_state("probably_fine")

    def test_non_work_endpoint_kind_is_rejected(self) -> None:
        with self.assertRaises(dependency_graph.InvalidEdgeEndpointError):
            dependency_graph.normalize_work_kind("incident")

    def test_evidence_sanitization_strips_credentials_and_urls(self) -> None:
        clean = dependency_graph.sanitize_evidence(
            {
                "token": "abc123",
                "authorization": "Bearer xyz",
                "note": "fetched from https://gitea.example.invalid/api/v1/x",
                "nested": [{"api_key": "k"}, "plain"],
                "observed_state": "closed",
            }
        )
        self.assertEqual(clean["token"], dependency_graph.REDACTED)
        self.assertEqual(clean["authorization"], dependency_graph.REDACTED)
        self.assertNotIn("https://", clean["note"])
        self.assertEqual(clean["nested"][0]["api_key"], dependency_graph.REDACTED)
        self.assertEqual(clean["observed_state"], "closed")


class SchemaTest(unittest.TestCase):
    """AC1-AC3: schema creation, migration, and idempotence."""

    def setUp(self) -> None:
        self._tmp = tempfile.TemporaryDirectory()
        self.db_path = os.path.join(self._tmp.name, "cp.sqlite3")

    def tearDown(self) -> None:
        self._tmp.cleanup()

    def _tables(self) -> set[str]:
        conn = sqlite3.connect(self.db_path)
        try:
            return {
                row[0]
                for row in conn.execute(
                    "SELECT name FROM sqlite_master WHERE type = 'table'"
                ).fetchall()
            }
        finally:
            conn.close()

    def _schema_version(self) -> str:
        conn = sqlite3.connect(self.db_path)
        try:
            row = conn.execute(
                "SELECT value FROM schema_meta WHERE key = 'schema_version'"
            ).fetchone()
        finally:
            conn.close()
        return str(row[0]) if row else ""

    def test_fresh_database_is_v4_with_the_edge_table(self) -> None:
        ControlPlaneDB(self.db_path)
        self.assertEqual(SCHEMA_VERSION, 4)
        self.assertEqual(self._schema_version(), "4")
        self.assertIn("dependency_edges", self._tables())

    def _seed_v3(self) -> None:
        conn = sqlite3.connect(self.db_path)
        try:
            conn.executescript(_V3_SCHEMA)
            conn.execute(
                "INSERT INTO schema_meta(key, value) VALUES ('schema_version', '3')"
            )
            conn.execute(
                """
                INSERT INTO work_items(
                    remote, org, repo, kind, number, state, priority, updated_at
                ) VALUES ('prgs', 'O', 'R', 'issue', 601, 'open', 20,
                          '2026-07-01T00:00:00Z')
                """
            )
            conn.execute(
                """
                INSERT INTO sessions(session_id, role, started_at, last_heartbeat_at)
                VALUES ('legacy-session', 'author', '2026-07-01T00:00:00Z',
                        '2026-07-01T00:00:00Z')
                """
            )
            conn.execute(
                """
                INSERT INTO events(work_item_id, event_type, message, created_at)
                VALUES (1, 'legacy', 'kept', '2026-07-01T00:00:00Z')
                """
            )
            conn.commit()
        finally:
            conn.close()

    def test_v3_database_migrates_in_place_without_losing_rows(self) -> None:
        self._seed_v3()
        self.assertNotIn("dependency_edges", self._tables())

        ControlPlaneDB(self.db_path)

        self.assertEqual(self._schema_version(), "4")
        self.assertIn("dependency_edges", self._tables())
        conn = sqlite3.connect(self.db_path)
        try:
            self.assertEqual(
                conn.execute("SELECT COUNT(*) FROM work_items").fetchone()[0], 1
            )
            self.assertEqual(
                conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0], 1
            )
            self.assertEqual(
                conn.execute(
                    "SELECT message FROM events WHERE event_type = 'legacy'"
                ).fetchone()[0],
                "kept",
            )
            for table in ("leases", "assignments", "terminal_locks", "incident_links"):
                conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()
        finally:
            conn.close()

    def test_migration_is_idempotent(self) -> None:
        self._seed_v3()
        ControlPlaneDB(self.db_path)
        db = ControlPlaneDB(self.db_path)  # second open re-runs the migration
        ControlPlaneDB(self.db_path)

        self.assertEqual(self._schema_version(), "4")
        conn = sqlite3.connect(self.db_path)
        try:
            tables = conn.execute(
                "SELECT name FROM sqlite_master WHERE type = 'table' "
                "AND name = 'dependency_edges'"
            ).fetchall()
        finally:
            conn.close()
        self.assertEqual(len(tables), 1)
        self.assertEqual(db.list_dependency_edges(), [])


class EdgePersistenceTest(unittest.TestCase):
    """AC5-AC10: storage, uniqueness, lookup, scope, audit, redaction."""

    def setUp(self) -> None:
        self._tmp = tempfile.TemporaryDirectory()
        self.db = ControlPlaneDB(os.path.join(self._tmp.name, "cp.sqlite3"))

    def tearDown(self) -> None:
        self._tmp.cleanup()

    def _events(self) -> list[tuple[str, str]]:
        conn = sqlite3.connect(self.db.db_path)
        try:
            return [
                (str(row[0]), str(row[1]))
                for row in conn.execute(
                    "SELECT event_type, message FROM events"
                ).fetchall()
            ]
        finally:
            conn.close()

    def test_invalid_values_write_nothing(self) -> None:
        with self.assertRaises(dependency_graph.InvalidEdgeTypeError):
            self.db.upsert_dependency_edge(**_edge_kwargs(edge_type="nonsense"))
        with self.assertRaises(dependency_graph.InvalidEdgeStateError):
            self.db.upsert_dependency_edge(**_edge_kwargs(state="maybe"))
        with self.assertRaises(dependency_graph.InvalidEdgeEndpointError):
            self.db.upsert_dependency_edge(**_edge_kwargs(target_kind="incident"))
        self.assertEqual(self.db.list_dependency_edges(), [])

    def test_stored_edge_carries_the_full_contract(self) -> None:
        edge = self.db.upsert_dependency_edge(
            **_edge_kwargs(evidence={"observed_state": "not_closed"})
        )
        self.assertEqual(edge["source_number"], 784)
        self.assertEqual(edge["target_number"], 628)
        self.assertEqual(edge["edge_type"], EDGE_BLOCKED)
        self.assertEqual(edge["state"], "unmet")
        self.assertEqual(edge["blocking_condition"], "target issue is not closed")
        self.assertEqual(edge["completion_condition"], "target issue is closed")
        self.assertEqual(edge["evidence"], {"observed_state": "not_closed"})
        self.assertTrue(edge["created_at"])
        self.assertTrue(edge["last_observed_at"])

    def test_repeated_upsert_updates_one_row(self) -> None:
        first = self.db.upsert_dependency_edge(**_edge_kwargs())
        second = self.db.upsert_dependency_edge(
            **_edge_kwargs(state="met", evidence={"observed_state": "closed"})
        )
        self.assertEqual(first["edge_id"], second["edge_id"])
        edges = self.db.list_dependency_edges()
        self.assertEqual(len(edges), 1)
        self.assertEqual(edges[0]["state"], "met")
        self.assertEqual(edges[0]["evidence"], {"observed_state": "closed"})

    def test_upsert_state_change_is_audited(self) -> None:
        self.db.upsert_dependency_edge(**_edge_kwargs())
        self.db.upsert_dependency_edge(**_edge_kwargs())  # unchanged: no event
        self.assertEqual(self._events(), [])
        self.db.upsert_dependency_edge(**_edge_kwargs(state="met"))
        events = self._events()
        self.assertEqual(len(events), 1)
        self.assertEqual(events[0][0], "dependency_edge_state_change")
        self.assertIn("unmet -> met", events[0][1])

    def test_reverse_lookup_finds_every_waiter(self) -> None:
        self.db.upsert_dependency_edge(**_edge_kwargs(source_number=784))
        self.db.upsert_dependency_edge(**_edge_kwargs(source_number=790))
        self.db.upsert_dependency_edge(
            **_edge_kwargs(
                source_kind=PR,
                source_number=791,
                edge_type=dependency_graph.EDGE_TASK_WAITING_FOR_DEFECT_FIX,
            )
        )
        self.db.upsert_dependency_edge(
            **_edge_kwargs(source_number=792, target_number=999)
        )

        waiters = self.db.list_dependency_edges(target_kind=ISSUE, target_number=628)
        self.assertEqual(
            sorted(edge["source_number"] for edge in waiters), [784, 790, 791]
        )

    def test_forward_lookup_and_state_filter(self) -> None:
        self.db.upsert_dependency_edge(**_edge_kwargs(target_number=628))
        self.db.upsert_dependency_edge(**_edge_kwargs(target_number=603, state="met"))
        blockers = self.db.list_dependency_edges(source_number=784, state="unmet")
        self.assertEqual([edge["target_number"] for edge in blockers], [628])

    def test_scope_isolation(self) -> None:
        self.db.upsert_dependency_edge(**_edge_kwargs())
        self.db.upsert_dependency_edge(**_edge_kwargs(repo="Other-Repo"))
        self.assertEqual(
            len(self.db.list_dependency_edges(remote="prgs", repo="Gitea-Tools")), 1
        )
        self.assertEqual(
            len(self.db.list_dependency_edges(remote="prgs", repo="Other-Repo")), 1
        )
        self.assertEqual(len(self.db.list_dependency_edges(remote="dadeschools")), 0)

    def test_observation_records_transition_with_prior_state(self) -> None:
        edge = self.db.upsert_dependency_edge(**_edge_kwargs())
        updated = self.db.record_dependency_edge_observation(
            edge["edge_id"],
            state="met",
            evidence={"observed_state": "closed"},
            detail="target closed by merge",
        )
        self.assertEqual(updated["prior_state"], "unmet")
        self.assertEqual(updated["state"], "met")
        self.assertTrue(updated["state_changed"])
        events = self._events()
        self.assertEqual(len(events), 1)
        self.assertIn("unmet -> met", events[0][1])
        self.assertIn("target closed by merge", events[0][1])

    def test_observation_on_unknown_edge_fails_closed(self) -> None:
        with self.assertRaises(ControlPlaneError):
            self.db.record_dependency_edge_observation("no-such-edge", state="met")

    def test_evidence_never_persists_a_credential_or_endpoint(self) -> None:
        self.db.upsert_dependency_edge(
            **_edge_kwargs(
                evidence={
                    "token": "super-secret",
                    "source": "GET https://gitea.example.invalid/api/v1/issues/628",
                }
            )
        )
        conn = sqlite3.connect(self.db.db_path)
        try:
            raw = conn.execute("SELECT evidence FROM dependency_edges").fetchone()[0]
        finally:
            conn.close()
        self.assertNotIn("super-secret", raw)
        self.assertNotIn("https://", raw)
        stored = json.loads(raw)
        self.assertEqual(stored["token"], dependency_graph.REDACTED)
        self.assertEqual(
            self.db.list_dependency_edges()[0]["evidence"]["token"],
            dependency_graph.REDACTED,
        )


class ResolutionIngestionTest(unittest.TestCase):
    """AC11, AC12: allocation-run ingestion and write-failure tolerance."""

    def setUp(self) -> None:
        self._tmp = tempfile.TemporaryDirectory()
        self.db = ControlPlaneDB(os.path.join(self._tmp.name, "cp.sqlite3"))

    def tearDown(self) -> None:
        self._tmp.cleanup()

    def _resolution(self):
        # Same call the allocator makes: parse the body, resolve live state.
        body = "* Parent: #628 · Depends: #601, #603, #999 · Related: #613"
        refs = allocator_dependencies.parse_dependency_refs(body)
        live = {601: "closed", 603: "open", 999: None}
        return allocator_dependencies.resolve_dependency_state(
            refs, lambda n: live[n], subject="issue#784"
        )

    def test_one_edge_per_reference_with_matching_state(self) -> None:
        resolution = self._resolution()
        reasons = dependency_graph.record_issue_dependency_edges(
            self.db,
            remote="prgs",
            org="Scaled-Tech-Consulting",
            repo="Gitea-Tools",
            source_number=784,
            resolution=resolution,
            observed_by="prgs-author-1234-abcd",
        )
        self.assertEqual(reasons, [])

        edges = {
            edge["target_number"]: edge
            for edge in self.db.list_dependency_edges(source_number=784)
        }
        self.assertEqual(sorted(edges), [601, 603, 999])
        self.assertEqual(edges[601]["state"], "met")
        self.assertEqual(edges[603]["state"], "unmet")
        self.assertEqual(edges[999]["state"], "unavailable")
        self.assertEqual(edges[999]["evidence"]["observed_state"], "unavailable")
        self.assertEqual(
            edges[603]["evidence"]["observed_by_session"], "prgs-author-1234-abcd"
        )
        self.assertEqual(edges[601]["edge_type"], EDGE_BLOCKED)

    def test_unavailable_evidence_is_never_recorded_as_met(self) -> None:
        resolution = self._resolution()
        dependency_graph.record_issue_dependency_edges(
            self.db,
            remote="prgs",
            org="O",
            repo="R",
            source_number=784,
            resolution=resolution,
        )
        met = self.db.list_dependency_edges(state="met")
        self.assertEqual([edge["target_number"] for edge in met], [601])

    def test_store_write_failure_is_reported_not_raised(self) -> None:
        class BrokenStore:
            def upsert_dependency_edge(self, **_kwargs):
                raise RuntimeError("disk is on fire")

        reasons = dependency_graph.record_issue_dependency_edges(
            BrokenStore(),
            remote="prgs",
            org="O",
            repo="R",
            source_number=784,
            resolution=self._resolution(),
        )
        self.assertEqual(len(reasons), 3)
        self.assertTrue(all("disk is on fire" in reason for reason in reasons))

    def test_no_declared_dependencies_writes_nothing(self) -> None:
        resolution = allocator_dependencies.resolve_dependency_state(
            (), lambda n: "closed", subject="issue#784"
        )
        reasons = dependency_graph.record_issue_dependency_edges(
            self.db,
            remote="prgs",
            org="O",
            repo="R",
            source_number=784,
            resolution=resolution,
        )
        self.assertEqual(reasons, [])
        self.assertEqual(self.db.list_dependency_edges(), [])


class AllocationRunIngestionTest(unittest.TestCase):
    """AC11, AC12, AC14: the live allocator path writes edges without changing
    what it selects."""

    def setUp(self) -> None:
        self._tmp = tempfile.TemporaryDirectory()
        self.db = ControlPlaneDB(os.path.join(self._tmp.name, "cp.sqlite3"))

    def tearDown(self) -> None:
        self._tmp.cleanup()

    @staticmethod
    def _issue(number: int, *, body: str = "") -> dict:
        return {
            "number": number,
            "title": f"issue {number}",
            "body": body,
            "labels": [{"name": "status:ready"}],
            "state": "open",
        }

    def _fake_gitea(self, issues, *, closed=()):
        closed_set = set(closed)

        def api_get_all(url, _auth, **_kw):
            if "/pulls" in url:
                return []
            return list(issues)

        def api_request(_method, url, _auth, **_kw):
            number = int(url.rsplit("/", 1)[-1])
            state = "closed" if number in closed_set else "open"
            return {"number": number, "state": state}

        return api_get_all, api_request

    def _allocate(self, issues, *, closed=(), db, **kwargs):
        api_get_all, api_request = self._fake_gitea(issues, closed=closed)
        with patch(
            "gitea_mcp_server._profile_operation_gate", return_value=None
        ), patch(
            "gitea_mcp_server._resolve", return_value=("h", "O", "R")
        ), patch(
            "gitea_mcp_server._auth", return_value="token REDACTED"
        ), patch(
            "gitea_mcp_server.get_profile",
            return_value={"profile_name": "prgs-author", "role": "author"},
        ), patch(
            "gitea_mcp_server._authenticated_username", return_value="jcwalker3"
        ), patch(
            "gitea_mcp_server._control_plane_db_or_error", return_value=(db, [])
        ), patch(
            "gitea_mcp_server.api_get_all", side_effect=api_get_all
        ), patch(
            "gitea_mcp_server.api_request", side_effect=api_request
        ), patch(
            "gitea_mcp_server.sentry_observability.monitor_checkin", return_value=None
        ):
            return srv.gitea_allocate_next_work(
                remote="prgs", org="O", repo="R", role="author", **kwargs
            )

    def test_live_run_persists_one_edge_per_declared_reference(self) -> None:
        issues = [
            self._issue(600, body="* Parent: #900 · Depends: #601, #500"),
            self._issue(601),
            self._issue(602),
        ]
        result = self._allocate(issues, closed={500}, db=self.db)
        self.assertTrue(result["success"])

        edges = self.db.list_dependency_edges(remote="prgs", org="O", repo="R")
        by_target = {edge["target_number"]: edge for edge in edges}
        self.assertEqual(sorted(by_target), [500, 601])
        self.assertEqual(by_target[601]["state"], "unmet")
        self.assertEqual(by_target[500]["state"], "met")
        self.assertEqual(by_target[601]["source_number"], 600)
        self.assertEqual(
            by_target[601]["edge_type"], dependency_graph.EDGE_ISSUE_BLOCKED_BY_ISSUE
        )
        self.assertTrue(by_target[601]["evidence"]["observed_by_session"])

    def test_selection_is_unchanged_by_the_store(self) -> None:
        issues = [
            self._issue(600, body="* Depends: #601"),
            self._issue(601),
            self._issue(602),
        ]

        class DeadStore:
            """Stands in for a control-plane DB whose edge writes all fail."""

            def __init__(self, real):
                self._real = real

            def __getattr__(self, name):
                return getattr(self._real, name)

            def upsert_dependency_edge(self, **_kwargs):
                raise RuntimeError("edge store unavailable")

        healthy = self._allocate(issues, db=self.db)
        broken = self._allocate(issues, db=DeadStore(self.db))

        self.assertEqual(
            healthy["selected"]["number"], broken["selected"]["number"]
        )
        self.assertEqual(
            {s["number"] for s in healthy["skipped"]},
            {s["number"] for s in broken["skipped"]},
        )
        self.assertEqual(healthy["candidate_count"], broken["candidate_count"])
        self.assertTrue(broken["success"])
        warnings = broken.get("inventory_warnings") or []
        self.assertTrue(
            any("edge store unavailable" in str(w) for w in warnings),
            f"write failure must surface in reasons, got {warnings}",
        )

    def test_repeated_runs_do_not_duplicate_edges(self) -> None:
        issues = [self._issue(600, body="* Depends: #601"), self._issue(601)]
        self._allocate(issues, db=self.db)
        self._allocate(issues, db=self.db)
        self.assertEqual(len(self.db.list_dependency_edges()), 1)


class ListDependencyEdgesToolTest(unittest.TestCase):
    """AC13: the read-only tool is gated and never mutates."""

    def setUp(self) -> None:
        self._tmp = tempfile.TemporaryDirectory()
        self.db = ControlPlaneDB(os.path.join(self._tmp.name, "cp.sqlite3"))
        self.db.upsert_dependency_edge(**_edge_kwargs(org="O", repo="R"))

    def tearDown(self) -> None:
        self._tmp.cleanup()

    def _call(self, *, read_block=None, **kwargs):
        with patch(
            "gitea_mcp_server._profile_operation_gate", return_value=read_block
        ), patch(
            "gitea_mcp_server._resolve", return_value=("h", "O", "R")
        ), patch(
            "gitea_mcp_server._permission_block_report", return_value={"blocked": True}
        ), patch(
            "gitea_mcp_server._control_plane_db_or_error", return_value=(self.db, [])
        ):
            return srv.gitea_list_dependency_edges(remote="prgs", **kwargs)

    def test_returns_stored_edges(self) -> None:
        result = self._call()
        self.assertTrue(result["success"])
        self.assertTrue(result["read_only"])
        self.assertEqual(result["count"], 1)
        self.assertEqual(result["edges"][0]["target_number"], 628)
        self.assertEqual(len(result["edge_types"]), 7)

    def test_reverse_lookup_filter(self) -> None:
        self.assertEqual(self._call(target_number=628)["count"], 1)
        self.assertEqual(self._call(target_number=999)["count"], 0)

    def test_without_read_permission_it_fails_closed(self) -> None:
        result = self._call(read_block=["gitea.read not allowed"])
        self.assertFalse(result["success"])
        self.assertEqual(result["edges"], [])
        self.assertIn("permission_report", result)

    def test_invalid_filter_fails_closed(self) -> None:
        result = self._call(edge_type="not_a_real_type")
        self.assertFalse(result["success"])
        self.assertEqual(result["edges"], [])
        self.assertTrue(any("fail closed" in r for r in result["reasons"]))

    def test_tool_is_documented_in_the_inventory(self) -> None:
        """The #781 drift guard requires a registered tool to be documented."""
        repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
        doc = os.path.join(repo_root, mcp_tool_inventory.INVENTORY_DOC_PATH)
        with open(doc, "r", encoding="utf-8") as handle:
            documented = mcp_tool_inventory.parse_documented_inventory(handle.read())
        self.assertIn("gitea_list_dependency_edges", documented)


if __name__ == "__main__":  # pragma: no cover
    unittest.main()
