from __future__ import annotations

import json
from pathlib import Path
from types import SimpleNamespace
from typing import Any
from uuid import UUID

import pytest
from deepagents.backends.filesystem import FilesystemBackend
from langchain_core.messages import AIMessage, ToolMessage

from decepticon.middleware import opplan as opplan_mod
from decepticon.middleware.filesystem import EngagementFilesystemBackend
from decepticon.middleware.opplan import OPPLANMiddleware
from decepticon.tools.opplan import (
    OPPLAN_FILE_SCHEMA_VERSION,
    OPPLAN_TOOL_NAMES,
    OPPLAN_VIRTUAL_PATH,
    _build_opplan_payload,
    _format_opplan_for_agent,
    _persist_opplan_to_backend,
)
from decepticon_core.types.engagement import OPPLAN, Objective, ObjectivePhase


def _obj_dict(obj_id: str, **overrides: Any) -> dict:
    base = {
        "id": obj_id,
        "title": f"objective {obj_id}",
        "phase": "recon",
        "description": "…",
        "acceptance_criteria": ["criterion"],
        "priority": 1,
        "status": "pending",
        "mitre": [],
        "opsec": "standard",
        "opsec_notes": "",
        "c2_tier": "interactive",
        "concessions": [],
        "blocked_by": [],
        "owner": "",
        "notes": "",
        "parent_id": None,
    }
    base.update(overrides)
    return base


# ── _build_opplan_payload / _persist_opplan_to_backend ─────────────────


def _backend(tmp_path: Path) -> FilesystemBackend:
    return FilesystemBackend(root_dir=tmp_path, virtual_mode=True)


def _opplan_path(tmp_path: Path, workspace_path: str = "/workspace") -> Path:
    rel = workspace_path.removeprefix("/").rstrip("/")
    return tmp_path / rel / "plan" / "opplan.json"


def test_build_opplan_payload_emits_versioned_envelope() -> None:
    opplan = OPPLAN(
        engagement_name="demo",
        threat_profile="apt-x",
        objectives=[
            Objective(
                id="OBJ-002",
                phase=ObjectivePhase.RECON,
                title="b",
                description="…",
                acceptance_criteria=["x"],
                priority=2,
            ),
            Objective(
                id="OBJ-001",
                phase=ObjectivePhase.RECON,
                title="a",
                description="…",
                acceptance_criteria=["x"],
                priority=1,
                status="completed",  # type: ignore[arg-type]
            ),
        ],
    )

    payload = _build_opplan_payload(opplan)

    assert payload["schema_version"] == OPPLAN_FILE_SCHEMA_VERSION
    assert payload["engagement_name"] == "demo"
    assert payload["threat_profile"] == "apt-x"
    # Objectives sorted by id for stable diffs.
    assert [o["id"] for o in payload["objectives"]] == ["OBJ-001", "OBJ-002"]
    # Summary aggregates by status.
    assert payload["summary"]["total"] == 2
    assert payload["summary"]["completed"] == 1
    assert payload["summary"]["pending"] == 1


def test_persist_writes_payload_under_workspace_plan(tmp_path: Path) -> None:
    _persist_opplan_to_backend(
        _backend(tmp_path),
        "/workspace",
        [_obj_dict("OBJ-001", title="t")],
        engagement_name="demo",
        threat_profile="apt-x",
    )

    out = _opplan_path(tmp_path)
    assert out.exists()
    data = json.loads(out.read_text(encoding="utf-8"))
    assert data["schema_version"] == OPPLAN_FILE_SCHEMA_VERSION
    assert data["engagement_name"] == "demo"
    assert [o["id"] for o in data["objectives"]] == ["OBJ-001"]


def test_persist_overwrites_existing_opplan_through_backend(tmp_path: Path) -> None:
    backend = _backend(tmp_path)
    _persist_opplan_to_backend(
        backend,
        "/workspace",
        [_obj_dict("OBJ-001", status="pending")],
        engagement_name="demo",
        threat_profile="apt-x",
    )
    _persist_opplan_to_backend(
        backend,
        "/workspace",
        [_obj_dict("OBJ-001", status="completed", notes="evidence saved")],
        engagement_name="demo",
        threat_profile="apt-x",
    )

    data = json.loads(_opplan_path(tmp_path).read_text(encoding="utf-8"))
    assert data["objectives"][0]["status"] == "completed"
    assert data["objectives"][0]["notes"] == "evidence saved"


def test_persist_skips_when_workspace_path_missing(tmp_path: Path) -> None:
    # Should not raise; nothing should be written.
    _persist_opplan_to_backend(
        _backend(tmp_path),
        None,
        [_obj_dict("OBJ-001")],
        engagement_name="demo",
        threat_profile="apt-x",
    )
    _persist_opplan_to_backend(
        _backend(tmp_path),
        "",
        [_obj_dict("OBJ-001")],
        engagement_name="demo",
        threat_profile="apt-x",
    )
    assert list(tmp_path.iterdir()) == []


def test_persist_swallows_filesystem_error(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
    """An OSError must become a log warning, not an exception."""

    def boom(self: Path, *a: Any, **kw: Any) -> None:
        raise PermissionError("read-only filesystem")

    monkeypatch.setattr(Path, "mkdir", boom)
    # No assertion — just must not raise.
    _persist_opplan_to_backend(
        _backend(tmp_path),
        "/workspace",
        [_obj_dict("OBJ-001")],
        engagement_name="demo",
        threat_profile="apt-x",
    )


def test_loaded_opplan_ignores_persistence_metadata(tmp_path: Path) -> None:
    """``OPPLAN(**data)`` must drop the wrapper fields silently (Pydantic
    default extra-field policy) so ``load_opplan`` can read what we wrote."""
    _persist_opplan_to_backend(
        _backend(tmp_path),
        "/workspace",
        [_obj_dict("OBJ-001")],
        engagement_name="demo",
        threat_profile="apt-x",
    )
    data = json.loads(_opplan_path(tmp_path).read_text(encoding="utf-8"))
    # Sanity: the wrapper fields ARE present in the persisted file.
    assert {"schema_version", "saved_at", "summary"} <= set(data.keys())
    # And OPPLAN(**data) must round-trip without raising.
    opplan = OPPLAN(**data)
    assert opplan.engagement_name == "demo"
    assert [o.id for o in opplan.objectives] == ["OBJ-001"]


def test_load_opplan_reads_through_backend(tmp_path: Path) -> None:
    backend = _backend(tmp_path)
    _persist_opplan_to_backend(
        backend,
        "/workspace",
        [_obj_dict("OBJ-001")],
        engagement_name="demo",
        threat_profile="apt-x",
    )

    cmd = _call(
        "load_opplan",
        {"workspace_path": "/workspace"},
        state={},
        backend=backend,
    )

    assert cmd.update["engagement_name"] == "demo"
    assert cmd.update["workspace_path"] == "/workspace"
    assert cmd.update["objectives"][0]["id"] == "OBJ-001"
    assert cmd.update["plan_revision"] == 1
    assert json.loads(_opplan_path(tmp_path).read_text())["revision"] == 1
    assert OPPLAN_VIRTUAL_PATH in cmd.update["messages"][0].content


def test_load_opplan_rejects_invalid_legacy_graph(tmp_path: Path) -> None:
    backend = _backend(tmp_path)
    _persist_opplan_to_backend(
        backend,
        "/workspace",
        [_obj_dict("OBJ-001", blocked_by=["OBJ-999"])],
        engagement_name="demo",
        threat_profile="apt-x",
    )

    cmd = _call(
        "load_opplan",
        {"workspace_path": "/workspace"},
        state={},
        backend=backend,
    )

    assert "objectives" not in cmd.update
    assert "missing_dependency" in cmd.update["messages"][0].content
    assert cmd.update["messages"][0].status == "error"


def test_load_opplan_binds_workspace_when_plan_is_missing(tmp_path: Path) -> None:
    backend = _backend(tmp_path)

    cmd = _call(
        "load_opplan",
        {"workspace_path": "/workspace"},
        state={},
        backend=backend,
    )

    assert cmd.update["workspace_path"] == "/workspace"
    assert "No opplan.json found" in cmd.update["messages"][0].content
    assert cmd.update["messages"][0].status == "success"

    scoped = EngagementFilesystemBackend(backend, cmd.update["workspace_path"])
    result = scoped.write("/workspace/plan/brief.md", "# Brief\n")
    assert result.error is None


# ── auto-persist on each mutating tool ─────────────────────────────────


def _tool(name: str, backend=None):
    """Look up an OPPLAN tool by name from a fresh middleware instance."""
    tools = OPPLANMiddleware(backend=backend).tools
    return next(t for t in tools if t.name == name)


def _call(name: str, args: dict, state: dict, backend=None):
    """Invoke an OPPLAN tool with the LangChain ToolCall envelope.

    The tools declare ``tool_call_id: Annotated[str, InjectedToolCallId]``
    so they cannot be invoked with a plain kwargs dict — InjectedToolCallId
    must be injected through the ``{"type": "tool_call", "id": ...}`` shape.
    """
    payload = {
        "name": name,
        "type": "tool_call",
        "id": "test-call-id",
        "args": {**args, "state": state},
    }
    return _tool(name, backend=backend).invoke(payload)


def test_commit_opplan_auto_persists(tmp_path: Path) -> None:
    backend = _backend(tmp_path)
    cmd = _call(
        "commit_opplan",
        {
            "objectives": [
                {key: value for key, value in _obj_dict("new", title="scan").items() if key != "id"}
            ],
            "expected_revision": 0,
            "engagement_name": "demo",
            "threat_profile": "apt-x",
        },
        state={"workspace_path": "/workspace"},
        backend=backend,
    )
    assert _opplan_path(tmp_path).exists()
    assert cmd.update["objectives"][0]["title"] == "scan"


def test_commit_opplan_accepts_forward_dependencies_and_rejects_stale_replans(
    tmp_path: Path,
) -> None:
    backend = _backend(tmp_path)
    state = {"workspace_path": "/workspace"}
    rows = [
        {k: v for k, v in _obj_dict("second", blocked_by=[1]).items() if k != "id"},
        {k: v for k, v in _obj_dict("first").items() if k != "id"},
    ]
    first = _call(
        "commit_opplan",
        {
            "objectives": rows,
            "expected_revision": 0,
            "engagement_name": "demo",
            "threat_profile": "apt-x",
        },
        state=state,
        backend=backend,
    )
    assert first.update["plan_revision"] == 1
    first_id, second_id = [row["id"] for row in first.update["objectives"]]
    UUID(first_id)
    UUID(second_id)
    assert first.update["objectives"][0]["blocked_by"] == [second_id]
    assert json.loads(_opplan_path(tmp_path).read_text())["revision"] == 1
    stale = _call(
        "commit_opplan",
        {"objectives": rows, "expected_revision": 0},
        state={**state, **first.update},
        backend=backend,
    )
    assert stale.update["messages"][0].status == "error"


def test_commit_opplan_rejects_client_issued_id_and_invalid_attack_mapping(tmp_path: Path) -> None:
    backend = _backend(tmp_path)
    state = {"workspace_path": "/workspace"}
    forged = _call(
        "commit_opplan",
        {"objectives": [_obj_dict("OBJ-999")], "expected_revision": 0},
        state=state,
        backend=backend,
    )
    assert forged.update["messages"][0].status == "error"
    assert not _opplan_path(tmp_path).exists()

    mismatched = {
        **{k: v for k, v in _obj_dict("new").items() if k != "id"},
        "mitre": ["T1685"],
        "attack_tactic_id": "TA0005",
    }
    rejected = _call(
        "commit_opplan",
        {"objectives": [mismatched], "expected_revision": 0},
        state=state,
        backend=backend,
    )
    assert rejected.update["messages"][0].status == "error"
    assert "TA0112" in rejected.update["messages"][0].content
    assert not _opplan_path(tmp_path).exists()


def test_commit_opplan_canonicalizes_attack_descriptions(tmp_path: Path) -> None:
    backend = _backend(tmp_path)
    row = {
        **{k: v for k, v in _obj_dict("new").items() if k != "id"},
        "mitre": ["T1685"],
        "attack_tactic_id": "TA0112",
        "attack": {"catalog": "forged", "version": "0", "tactic_id": "TA0112"},
    }
    committed = _call(
        "commit_opplan",
        {"objectives": [row], "expected_revision": 0},
        state={"workspace_path": "/workspace"},
        backend=backend,
    )
    objective = committed.update["objectives"][0]
    UUID(objective["id"])
    assert objective["attack"]["version"] == "19.2"
    assert objective["attack"]["tactic_name"] == "Defense Impairment"
    assert objective["attack"]["techniques"][0]["name"] == "Disable or Modify Tools"
    assert objective["attack"]["techniques"][0]["description"]
    detail = _call(
        "get_objective",
        {"objective_id": objective["id"]},
        state={"objectives": committed.update["objectives"]},
        backend=backend,
    )
    assert "TA0112 Defense Impairment" in detail.update["messages"][0].content
    assert "T1685 Disable or Modify Tools" in detail.update["messages"][0].content


def test_middleware_seeds_and_hydrates_one_versioned_plan(tmp_path: Path) -> None:
    backend = _backend(tmp_path)
    seed = {
        **{k: v for k, v in _obj_dict("new").items() if k != "id"},
        "mitre": ["T1190"],
        "attack_tactic_id": "TA0001",
    }
    middleware = OPPLANMiddleware(
        backend=backend,
        initial_objectives=[seed],
        engagement_name="demo",
    )
    runtime = SimpleNamespace(config={})
    first = middleware.before_agent({"workspace_path": "/workspace"}, runtime)
    assert first is not None
    UUID(first["objectives"][0]["id"])
    assert first["plan_revision"] == 1
    assert first["objectives"][0]["attack"]["version"] == "19.2"
    assert json.loads(_opplan_path(tmp_path).read_text())["revision"] == 1

    hydrated = OPPLANMiddleware(backend=backend).before_agent(
        {"workspace_path": "/workspace"}, runtime
    )
    assert hydrated == first


def test_middleware_upgrades_saved_unversioned_plan_without_renaming_ids(tmp_path: Path) -> None:
    backend = _backend(tmp_path)
    _persist_opplan_to_backend(backend, "/workspace", [_obj_dict("OBJ-001")], "demo", "")
    hydrated = OPPLANMiddleware(backend=backend).before_agent(
        {"workspace_path": "/workspace"}, SimpleNamespace(config={})
    )
    assert hydrated is not None
    assert hydrated["plan_revision"] == 1
    assert hydrated["objectives"][0]["id"] == "OBJ-001"
    assert json.loads(_opplan_path(tmp_path).read_text())["revision"] == 1


def test_middleware_upgrades_unversioned_checkpoint_state(tmp_path: Path) -> None:
    backend = _backend(tmp_path)
    hydrated = OPPLANMiddleware(backend=backend).before_agent(
        {"workspace_path": "/workspace", "objectives": [_obj_dict("OBJ-004")], "plan_revision": 0},
        SimpleNamespace(config={}),
    )
    assert hydrated is not None
    assert hydrated["plan_revision"] == 1
    assert hydrated["objectives"][0]["id"] == "OBJ-004"
    assert json.loads(_opplan_path(tmp_path).read_text())["revision"] == 1


def test_versioned_completion_requires_real_evidence_and_separates_no_finding(
    tmp_path: Path,
) -> None:
    backend = _backend(tmp_path)
    committed = _call(
        "commit_opplan",
        {
            "objectives": [{k: v for k, v in _obj_dict("first").items() if k != "id"}],
            "expected_revision": 0,
            "engagement_name": "demo",
        },
        state={"workspace_path": "/workspace"},
        backend=backend,
    )
    state = {**committed.update, "workspace_path": "/workspace"}
    objective_id = state["objectives"][0]["id"]
    started = _call(
        "update_objective",
        {"objective_id": objective_id, "status": "in-progress"},
        state=state,
        backend=backend,
    )
    assert "objectives" in started.update, started.update["messages"][0].content
    state.update(started.update)
    missing = _call(
        "update_objective",
        {
            "objective_id": objective_id,
            "status": "completed",
            "outcome": "no-finding",
            "evidence_refs": ["/workspace/recon/SUMMARY.md"],
        },
        state=state,
        backend=backend,
    )
    assert missing.update["messages"][0].status == "error"
    scoped = EngagementFilesystemBackend(backend, "/workspace")
    write_result = scoped.write("/workspace/recon/SUMMARY.md", "No confirmed vulnerabilities")
    assert write_result.error is None
    completed = _call(
        "update_objective",
        {
            "objective_id": objective_id,
            "status": "completed",
            "outcome": "no-finding",
            "evidence_refs": ["/workspace/recon/SUMMARY.md"],
        },
        state=state,
        backend=backend,
    )
    assert completed.update["objectives"][0]["outcome"] == "no-finding"
    assert completed.update["plan_revision"] == 3


def test_replan_cannot_claim_completion_or_verify_future_fact(tmp_path: Path) -> None:
    backend = _backend(tmp_path)
    initial = _call(
        "commit_opplan",
        {
            "objectives": [{k: v for k, v in _obj_dict("first").items() if k != "id"}],
            "facts": [{"id": "FACT-001", "producer_id": 0, "summary": "Observed service"}],
            "expected_revision": 0,
            "engagement_name": "demo",
        },
        state={"workspace_path": "/workspace"},
        backend=backend,
    )
    state = {**initial.update, "workspace_path": "/workspace"}
    objective_id = state["objectives"][0]["id"]
    status_bypass = _call(
        "commit_opplan",
        {"objectives": [_obj_dict(objective_id, status="completed")], "expected_revision": 1},
        state=state,
        backend=backend,
    )
    assert status_bypass.update["messages"][0].status == "error"
    fact_bypass = _call(
        "commit_opplan",
        {
            "objectives": [_obj_dict(objective_id)],
            "facts": [
                {
                    "id": "FACT-001",
                    "producer_id": objective_id,
                    "summary": "Observed service",
                    "verified": True,
                    "evidence_refs": ["/workspace/recon/SUMMARY.md"],
                }
            ],
            "expected_revision": 1,
        },
        state=state,
        backend=backend,
    )
    assert fact_bypass.update["messages"][0].status == "error"


def test_recorded_fact_unlocks_dependent_objective(tmp_path: Path) -> None:
    backend = _backend(tmp_path)
    rows = [
        {k: v for k, v in _obj_dict("producer").items() if k != "id"},
        {
            k: v
            for k, v in _obj_dict("consumer", required_fact_ids=["FACT-001"]).items()
            if k != "id"
        },
    ]
    committed = _call(
        "commit_opplan",
        {
            "objectives": rows,
            "facts": [{"id": "FACT-001", "producer_id": 0, "summary": "Service identified"}],
            "expected_revision": 0,
            "engagement_name": "demo",
        },
        state={"workspace_path": "/workspace"},
        backend=backend,
    )
    state = {**committed.update, "workspace_path": "/workspace"}
    producer_id, consumer_id = [row["id"] for row in state["objectives"]]
    waiting = _call(
        "update_objective",
        {"objective_id": consumer_id, "status": "in-progress"},
        state=state,
        backend=backend,
    )
    assert waiting.update["messages"][0].status == "error"
    scoped = EngagementFilesystemBackend(backend, "/workspace")
    write_result = scoped.write("/workspace/recon/SUMMARY.md", "Observed service")
    assert write_result.error is None
    started = _call(
        "update_objective",
        {"objective_id": producer_id, "status": "in-progress"},
        state=state,
        backend=backend,
    )
    assert "objectives" in started.update, started.update["messages"][0].content
    state.update(started.update)
    completed = _call(
        "update_objective",
        {
            "objective_id": producer_id,
            "status": "completed",
            "outcome": "objective-met",
            "evidence_refs": ["/workspace/recon/SUMMARY.md"],
        },
        state=state,
        backend=backend,
    )
    assert "objectives" in completed.update, completed.update["messages"][0].content
    state.update(completed.update)
    fact = _call(
        "record_plan_fact",
        {"fact_id": "FACT-001", "evidence_refs": ["/workspace/recon/SUMMARY.md"]},
        state=state,
        backend=backend,
    )
    state.update(fact.update)
    assert state["plan_facts"][0]["verified"] is True, fact.update["messages"][0].content
    unlocked = _call(
        "update_objective",
        {"objective_id": consumer_id, "status": "in-progress"},
        state=state,
        backend=backend,
    )
    assert unlocked.update["objectives"][1]["status"] == "in-progress"


def test_revoked_fact_blocks_completed_dependents_transitively(tmp_path: Path) -> None:
    backend = _backend(tmp_path)
    path = "/workspace/recon/SUMMARY.md"
    scoped = EngagementFilesystemBackend(backend, "/workspace")
    write_result = scoped.write(path, "Observed service")
    assert write_result.error is None
    rows = [
        {k: v for k, v in _obj_dict("one").items() if k != "id"},
        {k: v for k, v in _obj_dict("two", required_fact_ids=["FACT-001"]).items() if k != "id"},
        {k: v for k, v in _obj_dict("three", blocked_by=[1]).items() if k != "id"},
        {k: v for k, v in _obj_dict("four", any_of=[[1, 4]]).items() if k != "id"},
        {k: v for k, v in _obj_dict("five").items() if k != "id"},
    ]
    committed = _call(
        "commit_opplan",
        {
            "objectives": rows,
            "facts": [{"id": "FACT-001", "producer_id": 0, "summary": "Service"}],
            "expected_revision": 0,
            "engagement_name": "demo",
        },
        state={"workspace_path": "/workspace"},
        backend=backend,
    )
    state = {**committed.update, "workspace_path": "/workspace"}
    objective_ids = [row["id"] for row in state["objectives"]]
    for objective_id in (objective_ids[0], objective_ids[4]):
        started = _call(
            "update_objective",
            {"objective_id": objective_id, "status": "in-progress"},
            state=state,
            backend=backend,
        )
        state.update(started.update)
        completed = _call(
            "update_objective",
            {
                "objective_id": objective_id,
                "status": "completed",
                "outcome": "objective-met",
                "evidence_refs": [path],
            },
            state=state,
            backend=backend,
        )
        state.update(completed.update)
    recorded = _call(
        "record_plan_fact",
        {"fact_id": "FACT-001", "evidence_refs": [path]},
        state=state,
        backend=backend,
    )
    state.update(recorded.update)
    for objective_id in (objective_ids[1], objective_ids[2], objective_ids[3]):
        state.update(
            _call(
                "update_objective",
                {"objective_id": objective_id, "status": "in-progress"},
                state=state,
                backend=backend,
            ).update
        )
        state.update(
            _call(
                "update_objective",
                {
                    "objective_id": objective_id,
                    "status": "completed",
                    "outcome": "objective-met",
                    "evidence_refs": [path],
                },
                state=state,
                backend=backend,
            ).update
        )
    revoked = _call(
        "revoke_plan_fact",
        {"fact_id": "FACT-001", "reason": "Evidence disproven"},
        state=state,
        backend=backend,
    )
    assert revoked.update["plan_revision"] == state["plan_revision"] + 1
    assert revoked.update["plan_facts"][0]["verified"] is False
    statuses = {row["id"]: row["status"] for row in revoked.update["objectives"]}
    assert statuses == {
        objective_ids[0]: "completed",
        objective_ids[1]: "blocked",
        objective_ids[2]: "blocked",
        objective_ids[3]: "completed",
        objective_ids[4]: "completed",
    }
    assert (
        json.loads(_opplan_path(tmp_path).read_text())["revision"]
        == revoked.update["plan_revision"]
    )


def test_update_objective_auto_persists(tmp_path: Path) -> None:
    backend = _backend(tmp_path)
    rows = [{key: value for key, value in _obj_dict("new").items() if key != "id"}]
    committed = _call(
        "commit_opplan",
        {"objectives": rows, "expected_revision": 0},
        state={"workspace_path": "/workspace", "objectives": [], "plan_revision": 0},
        backend=backend,
    )
    state = {
        "workspace_path": "/workspace",
        **{key: value for key, value in committed.update.items() if key != "messages"},
    }
    _call(
        "update_objective",
        {"objective_id": state["objectives"][0]["id"], "status": "in-progress"},
        state=state,
        backend=backend,
    )
    out = _opplan_path(tmp_path)
    assert out.exists()
    data = json.loads(out.read_text(encoding="utf-8"))
    assert data["objectives"][0]["status"] == "in-progress"
    assert data["revision"] == 2


def test_versioned_child_cancellation_auto_persists(tmp_path: Path) -> None:
    backend = _backend(tmp_path)
    rows = [
        {key: value for key, value in _obj_dict("new").items() if key != "id"} for _ in range(3)
    ]
    rows[1]["parent_id"] = 0
    rows[2]["parent_id"] = 1
    committed = _call(
        "commit_opplan",
        {"objectives": rows, "expected_revision": 0},
        state={"workspace_path": "/workspace", "objectives": [], "plan_revision": 0},
        backend=backend,
    )
    state = {
        "workspace_path": "/workspace",
        **{key: value for key, value in committed.update.items() if key != "messages"},
    }
    for objective in reversed(state["objectives"][1:]):
        changed = _call(
            "update_objective",
            {"objective_id": objective["id"], "status": "cancelled"},
            state=state,
            backend=backend,
        )
        state.update({key: value for key, value in changed.update.items() if key != "messages"})
    out = _opplan_path(tmp_path)
    assert out.exists()
    data = json.loads(out.read_text(encoding="utf-8"))
    assert data["revision"] == 3
    statuses = {row["id"]: row["status"] for row in data["objectives"]}
    assert statuses[committed.update["objectives"][0]["id"]] == "pending"
    assert all(statuses[row["id"]] == "cancelled" for row in committed.update["objectives"][1:])


# ── cycle protection ───────────────────────────────────────────────────


def test_commit_rejects_parent_id_cycle_before_persisting(tmp_path: Path) -> None:
    backend = _backend(tmp_path)
    rows = [
        {key: value for key, value in _obj_dict("new").items() if key != "id"} for _ in range(2)
    ]
    rows[0]["parent_id"] = 1
    rows[1]["parent_id"] = 0
    cmd = _call(
        "commit_opplan",
        {"objectives": rows, "expected_revision": 0},
        state={"workspace_path": "/workspace", "objectives": [], "plan_revision": 0},
        backend=backend,
    )
    assert cmd.update["messages"][0].status == "error"
    assert "cycle" in cmd.update["messages"][0].content.lower()
    assert not _opplan_path(tmp_path).exists()


def test_format_opplan_for_agent_survives_parent_id_cycle() -> None:
    objectives = [
        _obj_dict("OBJ-A", parent_id="OBJ-B"),
        _obj_dict("OBJ-B", parent_id="OBJ-A"),
    ]
    # Must not infinite-recurse — the call returns in finite time.
    out = _format_opplan_for_agent(objectives, "demo", "apt-x")
    assert "## Task Tree" in out
    # The tree section appears once (we don't double-render the cycle).
    tree_section = out.split("## Task Tree", 1)[1]
    # Tree-line markers like "[ ]" should appear at most twice (once per node)
    # and never more — without the visited-set guard this would be unbounded.
    assert tree_section.count("[ ]") <= 2


# ── after_model strict-sequential ──────────────────────────────────────


def test_after_model_blocks_two_opplan_tools_in_same_step() -> None:
    middleware = OPPLANMiddleware()
    # Read tools must be blocked too (per the unified rule).
    last_ai = AIMessage(
        content="",
        tool_calls=[
            {"id": "tc-x", "name": "list_objectives", "args": {}, "type": "tool_call"},
            {
                "id": "tc-y",
                "name": "get_objective",
                "args": {"objective_id": "OBJ-1"},
                "type": "tool_call",
            },
        ],
    )
    update = middleware.after_model({"messages": [last_ai]}, runtime=None)
    assert update is not None
    msgs = update["messages"]
    # H14: first OPPLAN call is allowed, only 2nd+ are rejected
    assert len(msgs) == 1
    assert isinstance(msgs[0], ToolMessage) and msgs[0].status == "error"
    assert msgs[0].tool_call_id == "tc-y"


def test_after_model_allows_single_opplan_tool() -> None:
    middleware = OPPLANMiddleware()
    last_ai = AIMessage(
        content="",
        tool_calls=[
            {"id": "tc-z", "name": "commit_opplan", "args": {}, "type": "tool_call"},
        ],
    )
    assert middleware.after_model({"messages": [last_ai]}, runtime=None) is None


def test_after_model_allows_opplan_alongside_non_opplan_tool() -> None:
    """One OPPLAN call plus an unrelated tool (e.g. bash) is fine."""
    middleware = OPPLANMiddleware()
    last_ai = AIMessage(
        content="",
        tool_calls=[
            {"id": "tc-1", "name": "commit_opplan", "args": {}, "type": "tool_call"},
            {"id": "tc-2", "name": "bash", "args": {"command": "ls"}, "type": "tool_call"},
        ],
    )
    assert middleware.after_model({"messages": [last_ai]}, runtime=None) is None


def test_after_model_rejects_task_parallel_with_plan_mutation() -> None:
    middleware = OPPLANMiddleware()
    last_ai = AIMessage(
        content="",
        tool_calls=[
            {"id": "tc-plan", "name": "update_objective", "args": {}, "type": "tool_call"},
            {"id": "tc-task", "name": "task", "args": {}, "type": "tool_call"},
        ],
    )

    update = middleware.after_model({"messages": [last_ai]}, runtime=None)

    assert update is not None
    assert len(update["messages"]) == 1
    assert update["messages"][0].tool_call_id == "tc-task"
    assert update["messages"][0].status == "error"


def test_opplan_tool_names_constant_matches_registered_tools() -> None:
    registered = {t.name for t in OPPLANMiddleware().tools}
    assert registered == set(OPPLAN_TOOL_NAMES)


# ── empty objectives renders correctly ─────────────────────────────────


def test_format_opplan_status_empty_does_not_say_all_complete() -> None:
    out = opplan_mod._format_opplan_status([], "demo", "apt-x")
    assert "ALL OBJECTIVES COMPLETE" not in out
    assert "No objectives defined" in out


def test_format_opplan_for_agent_empty_does_not_say_all_complete() -> None:
    """Regression: empty objectives list must not report all-done (vacuous all())."""
    out = _format_opplan_for_agent([], "demo", "apt-x")
    assert "ALL OBJECTIVES COMPLETE" not in out
