"""Phase 19 — mutation_routes tests.

Verifies:
  • All eight mutation routes return {"ok": true}.
  • Approve/reject/defer remove proposals from /api/queue.pending.
  • Each route emits an EventBus event with the right severity + scope.
  • Take mutations against fixture ids return ok=true even when no shot
    file exists on disk (graceful KeyError swallow).
  • mark-circled toggles _acted state on every call.
"""
from __future__ import annotations

import json
import tempfile
from pathlib import Path

import pytest
from fastapi.testclient import TestClient

from recoil.api.eventbus import BUS
from recoil.api.main import app
from recoil.api.adapters import beats as beats_adapter
from recoil.api.adapters import memory as memory_adapter
from recoil.api.stub_routes import _reset_acted_for_tests


@pytest.fixture
def client():
    """TestClient as a context manager — invokes lifespan so BUS binds."""
    _reset_acted_for_tests()
    BUS._reset_for_tests()
    memory_adapter._reset_overlay_for_tests()
    with TestClient(app) as c:
        yield c


# ── Proposals ───────────────────────────────────────────────────────────────


def test_approve_proposal_returns_ok_true(client: TestClient) -> None:
    r = client.post("/api/proposals/prop_001/approve")
    assert r.status_code == 200
    assert r.json() == {"ok": True}


def test_approve_proposal_emits_eventbus_event(client: TestClient) -> None:
    client.post("/api/proposals/prop_001/approve")
    history = BUS.history()
    assert len(history) == 1
    ev = history[0]
    assert ev.severity == "success"
    assert ev.scope == "engine/proposals"
    assert "prop_001" in ev.summary
    assert ev.payload["proposal_id"] == "prop_001"
    assert ev.payload["decision"] == "approved"
    # All eight kinds are deferred today (no engine glue yet).
    assert ev.payload["deferred_execution"] is True


def test_approve_proposal_removes_it_from_pending(client: TestClient) -> None:
    pre = client.get("/api/queue").json()["pending"]
    pre_ids = {p["id"] for p in pre}
    assert "prop_002" in pre_ids
    client.post("/api/proposals/prop_002/approve")
    post_ids = {p["id"] for p in client.get("/api/queue").json()["pending"]}
    assert "prop_002" not in post_ids


def test_reject_proposal_emits_info_event(client: TestClient) -> None:
    client.post("/api/proposals/prop_003/reject")
    history = BUS.history()
    ev = history[-1]
    assert ev.severity == "info"
    assert ev.payload["decision"] == "rejected"


def test_defer_proposal_emits_event_and_removes_from_pending(client: TestClient) -> None:
    client.post("/api/proposals/prop_004/defer")
    ev = BUS.history()[-1]
    assert ev.payload["decision"] == "deferred"
    post_ids = {p["id"] for p in client.get("/api/queue").json()["pending"]}
    assert "prop_004" not in post_ids


# ── Takes ───────────────────────────────────────────────────────────────────


def test_mark_primary_take_returns_ok_for_unknown_id(client: TestClient) -> None:
    # Fixture id — no shot file on disk. Adapter raises KeyError; route swallows
    # (preserves the fixture demo) but per Phase 2 (console-v2-fix) emits the
    # ``take_id_not_on_disk`` fallback so severity flips to "fallback" on both
    # the fallback event AND the take-action event. Body still {"ok": True}.
    r = client.post("/api/takes/b5_t9/mark-primary")
    assert r.status_code == 200
    assert r.json() == {"ok": True}
    history = BUS.history()
    # The take-action event is the last one emitted; severity now reflects
    # the disk-fallback path (was "success" pre-Phase-2 — Anti-Pattern 2).
    take_ev = history[-1]
    assert take_ev.severity == "fallback"
    assert take_ev.payload["action"] == "marked_primary"


def test_mark_circled_toggles_acted_set(client: TestClient) -> None:
    from recoil.api import stub_routes
    assert "b5_t3" not in stub_routes._acted["circled_takes"]
    r1 = client.post("/api/takes/b5_t3/mark-circled")
    assert r1.json() == {"ok": True}
    assert "b5_t3" in stub_routes._acted["circled_takes"]
    r2 = client.post("/api/takes/b5_t3/mark-circled")
    assert r2.json() == {"ok": True}
    assert "b5_t3" not in stub_routes._acted["circled_takes"]


def test_reject_take_emits_event(client: TestClient) -> None:
    client.post("/api/takes/b5_t7/reject")
    ev = BUS.history()[-1]
    assert ev.payload["take_id"] == "b5_t7"
    assert ev.payload["action"] == "rejected"


def test_take_mutation_writes_disk_when_resolvable(
    client: TestClient, monkeypatch, tmp_path: Path
) -> None:
    """When the take id resolves to a shot file, set_primary writes it."""
    project = "phase19_test"
    shots = tmp_path / project / "_pipeline" / "state" / "visual" / "shots"
    shots.mkdir(parents=True)
    shot = {
        "shot_id": "EP001_SH99",
        "episode_id": "EP001",
        "takes": [
            {"take_id": "T_alpha", "file_path": "/x/a.mp4"},
            {"take_id": "T_beta", "file_path": "/x/b.mp4"},
        ],
    }
    (shots / "EP001_SH99.json").write_text(json.dumps(shot))
    (tmp_path / ".recoil-data-root").write_text("recoil-data-root\n")
    monkeypatch.setenv("RECOIL_PROJECTS_ROOT", str(tmp_path))

    r = client.post("/api/takes/T_beta/mark-primary")
    assert r.status_code == 200
    on_disk = json.loads((shots / "EP001_SH99.json").read_text())
    assert on_disk["primary_take_id"] == "T_beta"
    # primary booleans flipped on the takes themselves.
    primaries = {t["take_id"]: t.get("primary") for t in on_disk["takes"]}
    assert primaries == {"T_alpha": False, "T_beta": True}


def test_take_circled_toggle_round_trips_through_disk(
    client: TestClient, monkeypatch, tmp_path: Path
) -> None:
    project = "phase19_circled"
    shots = tmp_path / project / "_pipeline" / "state" / "visual" / "shots"
    shots.mkdir(parents=True)
    shot = {
        "shot_id": "EP001_SH02",
        "episode_id": "EP001",
        "takes": [{"take_id": "T_disc", "file_path": "/x/d.mp4"}],
    }
    (shots / "EP001_SH02.json").write_text(json.dumps(shot))
    (tmp_path / ".recoil-data-root").write_text("recoil-data-root\n")
    monkeypatch.setenv("RECOIL_PROJECTS_ROOT", str(tmp_path))

    client.post("/api/takes/T_disc/mark-circled")
    on_disk = json.loads((shots / "EP001_SH02.json").read_text())
    assert on_disk["takes"][0].get("circled") is True
    client.post("/api/takes/T_disc/mark-circled")
    on_disk = json.loads((shots / "EP001_SH02.json").read_text())
    assert on_disk["takes"][0].get("circled") is False


# ── Memory ──────────────────────────────────────────────────────────────────


def test_toggle_memory_returns_ok_and_emits_event(client: TestClient) -> None:
    r = client.post("/api/memory/m_001/toggle")
    assert r.json() == {"ok": True}
    ev = BUS.history()[-1]
    assert ev.scope == "engine/memory"
    assert ev.payload["entry_id"] == "m_001"
    assert "on" in ev.payload


# ── Debug R1 idempotent toggle endpoints ────────────────────────────────────


def test_mark_circled_idempotent_with_value_true(
    client: TestClient, monkeypatch, tmp_path: Path
) -> None:
    """Two calls with ``{"value": true}`` leave circled=True. Safe to retry."""
    project = "phase19_idempotent"
    shots = tmp_path / project / "_pipeline" / "state" / "visual" / "shots"
    shots.mkdir(parents=True)
    shot = {
        "shot_id": "EP001_SH03",
        "episode_id": "EP001",
        "takes": [{"take_id": "T_idem", "file_path": "/x/i.mp4"}],
    }
    (shots / "EP001_SH03.json").write_text(json.dumps(shot))
    (tmp_path / ".recoil-data-root").write_text("recoil-data-root\n")
    monkeypatch.setenv("RECOIL_PROJECTS_ROOT", str(tmp_path))

    client.post("/api/takes/T_idem/mark-circled", json={"value": True})
    on_disk = json.loads((shots / "EP001_SH03.json").read_text())
    assert on_disk["takes"][0].get("circled") is True

    # Retry — should remain True (NOT flip to False).
    client.post("/api/takes/T_idem/mark-circled", json={"value": True})
    on_disk = json.loads((shots / "EP001_SH03.json").read_text())
    assert on_disk["takes"][0].get("circled") is True


def test_mark_circled_idempotent_with_value_false(
    client: TestClient, monkeypatch, tmp_path: Path
) -> None:
    project = "phase19_idem_false"
    shots = tmp_path / project / "_pipeline" / "state" / "visual" / "shots"
    shots.mkdir(parents=True)
    shot = {
        "shot_id": "EP001_SH04",
        "episode_id": "EP001",
        "takes": [{"take_id": "T_off", "file_path": "/x/o.mp4", "circled": True}],
    }
    (shots / "EP001_SH04.json").write_text(json.dumps(shot))
    (tmp_path / ".recoil-data-root").write_text("recoil-data-root\n")
    monkeypatch.setenv("RECOIL_PROJECTS_ROOT", str(tmp_path))

    client.post("/api/takes/T_off/mark-circled", json={"value": False})
    on_disk = json.loads((shots / "EP001_SH04.json").read_text())
    assert on_disk["takes"][0].get("circled") is False

    client.post("/api/takes/T_off/mark-circled", json={"value": False})
    on_disk = json.loads((shots / "EP001_SH04.json").read_text())
    assert on_disk["takes"][0].get("circled") is False


def test_toggle_memory_idempotent_with_value(client: TestClient) -> None:
    """Two memory toggle calls with same ``value`` are idempotent."""
    client.post("/api/memory/m_idem/toggle", json={"value": True})
    ev1 = BUS.history()[-1]
    assert ev1.payload["on"] is True

    client.post("/api/memory/m_idem/toggle", json={"value": True})
    ev2 = BUS.history()[-1]
    assert ev2.payload["on"] is True  # Stayed on, did NOT flip.


def test_mark_circled_legacy_toggle_still_works(client: TestClient) -> None:
    """No body → legacy toggle behavior preserved (back-compat)."""
    from recoil.api import stub_routes
    assert "T_legacy" not in stub_routes._acted["circled_takes"]
    client.post("/api/takes/T_legacy/mark-circled")
    assert "T_legacy" in stub_routes._acted["circled_takes"]
    client.post("/api/takes/T_legacy/mark-circled")
    assert "T_legacy" not in stub_routes._acted["circled_takes"]


# ── Debug R1 path-traversal in mutation routes ──────────────────────────────


def test_mark_primary_rejects_malformed_take_id_with_slash(
    client: TestClient,
) -> None:
    """Encoded slash → routing-layer 404 (strongest possible block)."""
    r = client.post("/api/takes/..%2fetc%2fpasswd/mark-primary")
    assert r.status_code in (400, 404)
    assert r.status_code != 200


def test_mark_primary_rejects_malformed_take_id_single_segment(
    client: TestClient,
) -> None:
    """Single-segment malformed take id reaches handler → 400."""
    # Spaces, special chars not in HIERARCHY_ID_RE
    r = client.post("/api/takes/has%20spaces/mark-primary")
    assert r.status_code == 400


# ── Debug R2 — id-format guard on proposal + memory routes ──────────────────


def test_approve_proposal_rejects_malformed_proposal_id(
    client: TestClient,
) -> None:
    """Single-segment malformed proposal id → 400 (Debug R2)."""
    r = client.post("/api/proposals/has%20space/approve")
    assert r.status_code == 400


def test_reject_proposal_rejects_malformed_proposal_id(
    client: TestClient,
) -> None:
    r = client.post("/api/proposals/bad-id-with-dash-dot./reject")
    assert r.status_code == 400


def test_defer_proposal_rejects_malformed_proposal_id(
    client: TestClient,
) -> None:
    r = client.post("/api/proposals/has%20space/defer")
    assert r.status_code == 400


def test_toggle_memory_rejects_malformed_entry_id(
    client: TestClient,
) -> None:
    """Memory toggle rejects ids that fail HIERARCHY_ID_RE (Debug R2)."""
    r = client.post("/api/memory/has%20space/toggle")
    assert r.status_code == 400


# ── Debug R2 — bounded _acted state cannot grow unbounded ───────────────────


def test_acted_bucket_is_bounded(client: TestClient) -> None:
    """A flood of approves cannot push _acted past _ACTED_MAX_ENTRIES.

    Drives the cap directly via the helper rather than through 1000+ HTTP
    calls so the test is fast. The route guarantees we go through the
    helper — that's covered by the existing approve/reject/defer tests.
    """
    from recoil.api import stub_routes

    # Lower the cap for the duration of the test so we don't have to insert
    # 1000+ entries.
    original_cap = stub_routes._ACTED_MAX_ENTRIES
    stub_routes._ACTED_MAX_ENTRIES = 5
    try:
        bucket = stub_routes._acted["approved_proposals"]
        bucket.clear()
        for i in range(20):
            stub_routes._acted_add("approved_proposals", f"prop_{i:04d}")
        assert len(bucket) == 5
        # The 5 most-recently-inserted ids survive (FIFO drop on overflow).
        assert list(bucket.keys()) == [
            "prop_0015",
            "prop_0016",
            "prop_0017",
            "prop_0018",
            "prop_0019",
        ]
    finally:
        stub_routes._ACTED_MAX_ENTRIES = original_cap


def test_acted_bucket_idempotent_add_does_not_duplicate(
    client: TestClient,
) -> None:
    """Re-inserting an existing id is a no-op (count stable)."""
    from recoil.api import stub_routes

    bucket = stub_routes._acted["approved_proposals"]
    bucket.clear()
    stub_routes._acted_add("approved_proposals", "prop_dup")
    stub_routes._acted_add("approved_proposals", "prop_dup")
    stub_routes._acted_add("approved_proposals", "prop_dup")
    assert len(bucket) == 1


def test_memory_overlay_is_bounded() -> None:
    """A flood of toggles cannot push _OVERLAY past its cap (Debug R2)."""
    from recoil.api.adapters import memory as memory_adapter

    original_cap = memory_adapter._OVERLAY_MAX_ENTRIES
    memory_adapter._OVERLAY_MAX_ENTRIES = 3
    try:
        memory_adapter._reset_overlay_for_tests()
        for i in range(10):
            memory_adapter.set_entry(f"m_{i:03d}", True)
        assert len(memory_adapter._OVERLAY) == 3
        # The 3 most-recently-set ids survive (FIFO drop).
        assert list(memory_adapter._OVERLAY.keys()) == ["m_007", "m_008", "m_009"]
    finally:
        memory_adapter._OVERLAY_MAX_ENTRIES = original_cap
        memory_adapter._reset_overlay_for_tests()


# ── Debug R7 BUG-6 — repeat proposal action preserves kind ──────────────────


def test_repeat_proposal_action_preserves_kind_in_event(
    client: TestClient,
) -> None:
    """Approve, then reject the same proposal id — second event MUST carry
    the proposal kind (not None).

    Bug-fix (Debug R7 BUG-6): _proposal_kind queries _pending_items() which
    filters out already-acted ids. Without the _acted-history fallback, the
    second action's event payload['kind'] degraded to None.
    """
    # First action — approve. _pending_items resolves prop_001's kind.
    r1 = client.post("/api/proposals/prop_001/approve")
    assert r1.status_code == 200
    ev1 = BUS.history()[-1]
    assert ev1.payload["kind"] == "PromptRewriteProposal"
    assert ev1.payload["decision"] == "approved"

    # Second action — reject the SAME id. _pending_items has filtered it
    # out by now; the kind must come from the _acted history.
    r2 = client.post("/api/proposals/prop_001/reject")
    assert r2.status_code == 200
    ev2 = BUS.history()[-1]
    assert ev2.payload["kind"] == "PromptRewriteProposal", (
        "Repeat action should recover kind from _acted history, not degrade "
        "to None."
    )
    assert ev2.payload["decision"] == "rejected"
    # And the labelled summary must reflect the kind, not the generic
    # "Proposal" fallback.
    assert "PromptRewrite" in ev2.summary


def test_acted_helper_stores_and_recovers_kind(client: TestClient) -> None:
    """_acted_add stores the kind; _proposal_kind_in_acted recovers it."""
    from recoil.api import stub_routes

    stub_routes._reset_acted_for_tests()
    stub_routes._acted_add(
        "approved_proposals", "prop_kind_test", "RefSwapProposal"
    )
    assert stub_routes._proposal_kind_in_acted("prop_kind_test") == (
        "RefSwapProposal"
    )
    # Unknown id returns None (not raises).
    assert stub_routes._proposal_kind_in_acted("never_seen") is None


def test_acted_helper_does_not_overwrite_known_kind_with_none(
    client: TestClient,
) -> None:
    """A second _acted_add with kind=None must NOT clobber an existing kind.

    Otherwise: an admin path that doesn't know the kind could erase
    information stored by the route layer. Regression test.
    """
    from recoil.api import stub_routes

    stub_routes._reset_acted_for_tests()
    stub_routes._acted_add(
        "approved_proposals", "prop_clob_test", "BeatInsertionProposal"
    )
    # Re-add with no kind — must not overwrite.
    stub_routes._acted_add("approved_proposals", "prop_clob_test", None)
    assert stub_routes._proposal_kind_in_acted("prop_clob_test") == (
        "BeatInsertionProposal"
    )


def test_acted_helper_upgrades_none_to_known_kind(client: TestClient) -> None:
    """If a None-kind entry is later re-added with a known kind, store it.

    Symmetric with the no-clobber rule above — None upgrades to known.
    """
    from recoil.api import stub_routes

    stub_routes._reset_acted_for_tests()
    # First insertion has no kind information.
    stub_routes._acted_add("rejected_proposals", "prop_upgrade", None)
    assert stub_routes._proposal_kind_in_acted("prop_upgrade") is None
    # Second insertion supplies the kind — should be adopted.
    stub_routes._acted_add(
        "rejected_proposals", "prop_upgrade", "ScriptEditProposal"
    )
    assert stub_routes._proposal_kind_in_acted("prop_upgrade") == (
        "ScriptEditProposal"
    )


# ── Debug R7 BUG-5 — memory id collision dedup ──────────────────────────────


def test_memory_parser_dedups_same_date_entries(monkeypatch, tmp_path) -> None:
    """Two LEARNINGS.md entries dated the same day get distinct IDs.

    Bug-fix (Debug R7 BUG-5): without dedup, both rows would synthesize
    ``L-2026-03-25`` and toggling one in the Console would mutate the
    overlay for both.
    """
    from recoil.api.adapters import memory as memory_adapter

    md_dir = tmp_path / "memory"
    md_dir.mkdir()
    (md_dir / "LEARNINGS.md").write_text(
        """# Engine Learnings

## Entries

### 2026-03-25 [script]
- **Learning:** First learning, no explicit ID.
- **Status:** provisional

### 2026-03-25 [visual]
- **Learning:** Second learning, also no explicit ID.
- **Status:** provisional

### 2026-04-01 [script]
- **Learning:** Different date — no collision.
- **Status:** provisional
""",
        encoding="utf-8",
    )
    monkeypatch.setenv("RECOIL_ENGINE_MEMORY_DIR", str(md_dir))

    entries = memory_adapter.list_memory()
    ids = [e.id for e in entries]
    # All distinct.
    assert len(ids) == len(set(ids)), f"Duplicate ids: {ids}"
    # Specific shapes — first occurrence keeps base, collisions append -2.
    assert "L-2026-03-25" in ids
    assert "L-2026-03-25-2" in ids
    assert "L-2026-04-01" in ids


def test_memory_parser_does_not_carry_dedup_across_calls(
    monkeypatch, tmp_path,
) -> None:
    """Per-parse seen_ids must reset — a second list_memory() call sees the
    SAME ids, not ``L-…-2`` ``L-…-3`` …"""
    from recoil.api.adapters import memory as memory_adapter

    md_dir = tmp_path / "memory"
    md_dir.mkdir()
    (md_dir / "LEARNINGS.md").write_text(
        """### 2026-03-25 [script]
- **Learning:** Only entry.
- **Status:** provisional
""",
        encoding="utf-8",
    )
    monkeypatch.setenv("RECOIL_ENGINE_MEMORY_DIR", str(md_dir))

    first = [e.id for e in memory_adapter.list_memory()]
    second = [e.id for e in memory_adapter.list_memory()]
    assert first == second == ["L-2026-03-25"]


# ── Phase 2 (console-v2-fix) — severity=fallback on unknown ids ──────────────
#
# Mutations against unknown take_ids / proposal_ids preserve the {"ok": true}
# response body (per JT decision A — keeps the fixture demo working) but the
# SSE event severity now flips from the success-path default to "fallback"
# AND the registered ``take_id_not_on_disk`` / ``proposal_id_not_pending``
# sanctioned fallback fires. Discharges Law 4 + Tenet 6 prong-1.


def test_mark_primary_unknown_take_emits_fallback(client: TestClient) -> None:
    r = client.post("/api/takes/never_existed_take_id/mark-primary")
    assert r.status_code == 200
    assert r.json() == {"ok": True}
    history = BUS.history()
    fallback = [
        e for e in history
        if e.severity == "fallback" and e.summary == "take_id_not_on_disk"
    ]
    assert len(fallback) == 1, (
        f"expected exactly one take_id_not_on_disk fallback event, got "
        f"{[(e.severity, e.summary) for e in history]}"
    )
    assert fallback[0].scope == "api/mutations/mark_primary"
    assert fallback[0].payload is not None
    assert fallback[0].payload["take_id"] == "never_existed_take_id"
    assert fallback[0].payload["action"] == "mark_primary"
    # And the take-action event itself rides at severity="fallback" so the
    # events drawer's severity filter sees a coherent signal.
    take_evs = [e for e in history if e.summary.startswith("Take ")]
    assert len(take_evs) == 1
    assert take_evs[0].severity == "fallback"


def test_mark_circled_unknown_take_emits_fallback(client: TestClient) -> None:
    r = client.post("/api/takes/never_existed_take_id/mark-circled")
    assert r.status_code == 200
    assert r.json() == {"ok": True}
    history = BUS.history()
    fallback = [
        e for e in history
        if e.severity == "fallback" and e.summary == "take_id_not_on_disk"
    ]
    assert len(fallback) == 1
    assert fallback[0].scope == "api/mutations/mark_circled"
    assert fallback[0].payload is not None
    assert fallback[0].payload["take_id"] == "never_existed_take_id"
    assert fallback[0].payload["action"] == "mark_circled"
    take_evs = [e for e in history if e.summary.startswith("Take ")]
    assert len(take_evs) == 1
    assert take_evs[0].severity == "fallback"


def test_reject_take_unknown_take_emits_fallback(client: TestClient) -> None:
    r = client.post("/api/takes/never_existed_take_id/reject")
    assert r.status_code == 200
    assert r.json() == {"ok": True}
    history = BUS.history()
    fallback = [
        e for e in history
        if e.severity == "fallback" and e.summary == "take_id_not_on_disk"
    ]
    assert len(fallback) == 1
    assert fallback[0].scope == "api/mutations/reject_take"
    assert fallback[0].payload is not None
    assert fallback[0].payload["take_id"] == "never_existed_take_id"
    assert fallback[0].payload["action"] == "reject_take"
    take_evs = [e for e in history if e.summary.startswith("Take ")]
    assert len(take_evs) == 1
    assert take_evs[0].severity == "fallback"


def test_approve_unknown_proposal_emits_fallback(client: TestClient) -> None:
    """Approve against an id that was never pending → fallback event +
    severity flip on the decision event. Body is still {"ok": True}."""
    r = client.post("/api/proposals/never_pending/approve")
    assert r.status_code == 200
    assert r.json() == {"ok": True}
    history = BUS.history()
    fallback = [
        e for e in history
        if e.severity == "fallback" and e.summary == "proposal_id_not_pending"
    ]
    assert len(fallback) == 1, (
        f"expected exactly one proposal_id_not_pending fallback event, got "
        f"{[(e.severity, e.summary) for e in history]}"
    )
    assert fallback[0].scope == "api/proposals"
    assert fallback[0].payload is not None
    assert fallback[0].payload["proposal_id"] == "never_pending"
    assert fallback[0].payload["decision"] == "approved"
    # Decision event rides at severity="fallback" too.
    decision_evs = [
        e for e in history if e.scope == "engine/proposals"
    ]
    assert len(decision_evs) == 1
    assert decision_evs[0].severity == "fallback"
    assert decision_evs[0].payload is not None
    assert decision_evs[0].payload["decision"] == "approved"


def test_approve_known_proposal_does_not_emit_fallback(
    client: TestClient,
) -> None:
    """Sanity check — a real fixture id still emits severity=success and
    no proposal_id_not_pending fallback."""
    r = client.post("/api/proposals/prop_001/approve")
    assert r.status_code == 200
    history = BUS.history()
    fallback = [
        e for e in history if e.summary == "proposal_id_not_pending"
    ]
    assert fallback == []
    decision_evs = [e for e in history if e.scope == "engine/proposals"]
    assert len(decision_evs) == 1
    assert decision_evs[0].severity == "success"


def test_memory_parser_explicit_id_wins_over_synthesis(
    monkeypatch, tmp_path,
) -> None:
    """When an explicit ID is provided, it's returned as-is (no dedup)."""
    from recoil.api.adapters import memory as memory_adapter

    md_dir = tmp_path / "memory"
    md_dir.mkdir()
    (md_dir / "LEARNINGS.md").write_text(
        """### 2026-03-25 [script]
- **ID:** L_explicit_001
- **Learning:** Has an explicit ID.
- **Status:** provisional

### 2026-03-25 [visual]
- **Learning:** Same date, no explicit — should synthesize cleanly.
- **Status:** provisional
""",
        encoding="utf-8",
    )
    monkeypatch.setenv("RECOIL_ENGINE_MEMORY_DIR", str(md_dir))

    ids = [e.id for e in memory_adapter.list_memory()]
    assert "L_explicit_001" in ids
    # The explicit-ID entry does NOT consume the L-2026-03-25 base, so the
    # second entry gets the bare base.
    assert "L-2026-03-25" in ids
    assert len(ids) == len(set(ids))
