# -*- coding: utf-8 -*-
"""Integration tests for memory, system-prompt-files, running-config,
and inbox event lifecycle.

Sprint 2.3 — Memory & Context.  All tests use the real ``qwenpaw app``
subprocess.  A/B classes are pure HTTP; C class covers inbox event
lifecycle driven entirely by Mock LLM heartbeat runs.

Existing CRUD roundtrip coverage (not duplicated here):
  - ``test_workspace_files.py``  — memory PUT/GET happy path
  - ``test_workspace_agent_settings.py``  — scoped memory + sys-prompt
  - ``test_workspace_running_config.py``  — running-config roundtrip
"""
from __future__ import annotations

import threading
import time
from http.server import HTTPServer
from pathlib import Path

import pytest

from helpers import (
    MOCK_LLM_PROVIDER_ID,
    MockLLMHandler,
    clean_inbox,
    create_agent,
    default_http_timeout,
    delete_agent_quietly,
    register_mock_provider,
    scoped,
    toggle_agent,
    unregister_mock_provider,
)

_HTTP_TIMEOUT = default_http_timeout(15.0)


@pytest.fixture(scope="module")
def mock_llm():
    """Start a module-scoped mock OpenAI server, yield (server, url)."""
    srv = HTTPServer(("127.0.0.1", 0), MockLLMHandler)
    srv.force_error = False
    srv.force_tool_call = False
    port = srv.server_address[1]
    t = threading.Thread(target=srv.serve_forever, daemon=True)
    t.start()
    yield srv, f"http://127.0.0.1:{port}/v1"
    srv.shutdown()


# ================================================================== #
#  A class — Memory file depth (6 tests)
# ================================================================== #


@pytest.mark.integration
@pytest.mark.p2
def test_memory_file_get_nonexistent_returns_404(
    app_server,
) -> None:
    """GET a memory file that does not exist → 404."""
    resp = app_server.api_request(
        "GET",
        scoped("default", "/workspace/memory/nonexistent_integ.md"),
        timeout=_HTTP_TIMEOUT,
    )
    assert resp.status_code == 404, app_server.logs_tail()


@pytest.mark.integration
@pytest.mark.p0
def test_memory_file_cross_agent_isolated(app_server) -> None:
    """A memory file written to agent_a is not visible to agent_b."""
    agent_a = "integ_mc_iso_a"
    agent_b = "integ_mc_iso_b"
    create_agent(app_server, agent_a)
    create_agent(app_server, agent_b)
    try:
        put_resp = app_server.api_request(
            "PUT",
            scoped(agent_a, "/workspace/memory/isolated_note.md"),
            json={"content": "agent_a private data"},
            timeout=_HTTP_TIMEOUT,
        )
        assert put_resp.status_code == 200, app_server.logs_tail()

        get_b = app_server.api_request(
            "GET",
            scoped(agent_b, "/workspace/memory/isolated_note.md"),
            timeout=_HTTP_TIMEOUT,
        )
        assert (
            get_b.status_code == 404
        ), "agent_b must not see agent_a's memory file"

        get_a = app_server.api_request(
            "GET",
            scoped(agent_a, "/workspace/memory/isolated_note.md"),
            timeout=_HTTP_TIMEOUT,
        )
        assert get_a.status_code == 200
        assert "agent_a private data" in get_a.json()["content"]
    finally:
        delete_agent_quietly(app_server, agent_a)
        delete_agent_quietly(app_server, agent_b)


@pytest.mark.integration
@pytest.mark.p1
def test_memory_file_unicode_content_roundtrip(app_server) -> None:
    """Chinese + emoji content survives PUT → GET roundtrip."""
    content = "你好世界 Hello 🌍 — 测试内容"
    resp = app_server.api_request(
        "PUT",
        scoped("default", "/workspace/memory/unicode_test.md"),
        json={"content": content},
        timeout=_HTTP_TIMEOUT,
    )
    assert resp.status_code == 200, app_server.logs_tail()

    get_resp = app_server.api_request(
        "GET",
        scoped("default", "/workspace/memory/unicode_test.md"),
        timeout=_HTTP_TIMEOUT,
    )
    assert get_resp.status_code == 200
    assert get_resp.json()["content"].strip() == content


@pytest.mark.integration
@pytest.mark.p1
def test_memory_file_persists_after_agent_disable_enable(
    app_server,
) -> None:
    """Memory file survives a disable → re-enable cycle."""
    agent_id = "integ_mc_persist"
    create_agent(app_server, agent_id)
    try:
        app_server.api_request(
            "PUT",
            scoped(agent_id, "/workspace/memory/persist_check.md"),
            json={"content": "persist me"},
            timeout=_HTTP_TIMEOUT,
        )

        toggle_agent(app_server, agent_id, False)
        toggle_agent(app_server, agent_id, True)

        get_resp = app_server.api_request(
            "GET",
            scoped(agent_id, "/workspace/memory/persist_check.md"),
            timeout=_HTTP_TIMEOUT,
        )
        assert get_resp.status_code == 200
        assert get_resp.json()["content"].strip() == "persist me"
    finally:
        delete_agent_quietly(app_server, agent_id)


@pytest.mark.integration
@pytest.mark.p1
def test_memory_file_overwrite_preserves_sibling_files(
    app_server,
) -> None:
    """Overwriting one memory file does not affect siblings."""
    app_server.api_request(
        "PUT",
        scoped("default", "/workspace/memory/sibling_a.md"),
        json={"content": "aaa"},
        timeout=_HTTP_TIMEOUT,
    )
    app_server.api_request(
        "PUT",
        scoped("default", "/workspace/memory/sibling_b.md"),
        json={"content": "bbb"},
        timeout=_HTTP_TIMEOUT,
    )

    app_server.api_request(
        "PUT",
        scoped("default", "/workspace/memory/sibling_a.md"),
        json={"content": "aaa_updated"},
        timeout=_HTTP_TIMEOUT,
    )

    get_b = app_server.api_request(
        "GET",
        scoped("default", "/workspace/memory/sibling_b.md"),
        timeout=_HTTP_TIMEOUT,
    )
    assert get_b.status_code == 200
    assert get_b.json()["content"].strip() == "bbb"


@pytest.mark.integration
@pytest.mark.p2
def test_memory_file_list_metadata_fields_complete(
    app_server,
) -> None:
    """MdFileInfo contains all five documented fields with valid types."""
    app_server.api_request(
        "PUT",
        scoped("default", "/workspace/memory/meta_probe.md"),
        json={"content": "metadata test"},
        timeout=_HTTP_TIMEOUT,
    )

    list_resp = app_server.api_request(
        "GET",
        scoped("default", "/workspace/memory"),
        timeout=_HTTP_TIMEOUT,
    )
    assert list_resp.status_code == 200
    files = list_resp.json()
    assert isinstance(files, list)

    target = [f for f in files if f.get("filename") == "meta_probe.md"]
    assert len(target) == 1, f"meta_probe.md not in list: {files}"
    info = target[0]

    assert isinstance(info["filename"], str)
    assert isinstance(info["path"], str)
    assert isinstance(info["size"], int) and info["size"] > 0
    assert isinstance(info["created_time"], str) and info["created_time"]
    assert isinstance(info["modified_time"], str) and info["modified_time"]


# ================================================================== #
#  B class — System-prompt-files + context config depth (5 tests)
# ================================================================== #


@pytest.mark.integration
@pytest.mark.p1
def test_system_prompt_files_global_put_get_roundtrip(
    app_server,
) -> None:
    """PUT/GET system-prompt-files via X-Agent-Id header route."""
    get_before = app_server.api_request(
        "GET",
        "/api/workspace/system-prompt-files",
        headers={"X-Agent-Id": "default"},
        timeout=_HTTP_TIMEOUT,
    )
    assert get_before.status_code == 200, app_server.logs_tail()
    before = get_before.json()
    assert isinstance(before, list)

    reversed_list = list(reversed(before))
    try:
        put_resp = app_server.api_request(
            "PUT",
            "/api/workspace/system-prompt-files",
            json=reversed_list,
            headers={"X-Agent-Id": "default"},
            timeout=_HTTP_TIMEOUT,
        )
        assert put_resp.status_code == 200

        get_after = app_server.api_request(
            "GET",
            "/api/workspace/system-prompt-files",
            headers={"X-Agent-Id": "default"},
            timeout=_HTTP_TIMEOUT,
        )
        assert get_after.status_code == 200
        assert get_after.json() == reversed_list
    finally:
        app_server.api_request(
            "PUT",
            "/api/workspace/system-prompt-files",
            json=before,
            headers={"X-Agent-Id": "default"},
            timeout=_HTTP_TIMEOUT,
        )


@pytest.mark.integration
@pytest.mark.p0
def test_system_prompt_files_cross_agent_isolated(
    app_server,
) -> None:
    """Modifying agent_a's prompt-files list does not change agent_b."""
    agent_a = "integ_mc_spf_a"
    agent_b = "integ_mc_spf_b"
    create_agent(app_server, agent_a)
    create_agent(app_server, agent_b)
    try:
        base_a = app_server.api_request(
            "GET",
            scoped(agent_a, "/workspace/system-prompt-files"),
            timeout=_HTTP_TIMEOUT,
        ).json()

        base_b = app_server.api_request(
            "GET",
            scoped(agent_b, "/workspace/system-prompt-files"),
            timeout=_HTTP_TIMEOUT,
        ).json()

        modified = list(base_a) + ["CUSTOM_INTEG.md"]
        app_server.api_request(
            "PUT",
            scoped(agent_a, "/workspace/system-prompt-files"),
            json=modified,
            timeout=_HTTP_TIMEOUT,
        )

        after_b = app_server.api_request(
            "GET",
            scoped(agent_b, "/workspace/system-prompt-files"),
            timeout=_HTTP_TIMEOUT,
        ).json()

        assert (
            after_b == base_b
        ), "agent_b prompt-files changed after agent_a modification"

        after_a = app_server.api_request(
            "GET",
            scoped(agent_a, "/workspace/system-prompt-files"),
            timeout=_HTTP_TIMEOUT,
        ).json()
        assert "CUSTOM_INTEG.md" in after_a
    finally:
        app_server.api_request(
            "PUT",
            scoped(agent_a, "/workspace/system-prompt-files"),
            json=base_a,
            timeout=_HTTP_TIMEOUT,
        )
        delete_agent_quietly(app_server, agent_a)
        delete_agent_quietly(app_server, agent_b)


@pytest.mark.integration
@pytest.mark.p1
def test_running_config_approval_level_writeback_to_profile(
    app_server,
) -> None:
    """PUT running-config with approval_level writes it back to the
    agent profile (workspace.py:932-933)."""
    get_before = app_server.api_request(
        "GET",
        scoped("default", "/workspace/running-config"),
        timeout=_HTTP_TIMEOUT,
    )
    assert get_before.status_code == 200
    before = get_before.json()
    original_level = before.get("approval_level", "AUTO")

    try:
        updated = dict(before)
        updated["approval_level"] = "CONFIRM"
        put_resp = app_server.api_request(
            "PUT",
            scoped("default", "/workspace/running-config"),
            json=updated,
            timeout=_HTTP_TIMEOUT,
        )
        assert put_resp.status_code == 200

        profile_resp = app_server.api_request(
            "GET",
            "/api/agents/default",
            timeout=_HTTP_TIMEOUT,
        )
        assert profile_resp.status_code == 200
        profile = profile_resp.json()
        assert (
            profile.get("approval_level") == "CONFIRM"
        ), "approval_level not written back to agent profile"
    finally:
        restore = dict(before)
        restore["approval_level"] = original_level
        app_server.api_request(
            "PUT",
            scoped("default", "/workspace/running-config"),
            json=restore,
            timeout=_HTTP_TIMEOUT,
        )


@pytest.mark.integration
@pytest.mark.p1
def test_running_config_context_compact_fields_roundtrip(
    app_server,
) -> None:
    """Modify light_context_config.context_compact_config fields and
    verify they persist on readback."""
    get_before = app_server.api_request(
        "GET",
        scoped("default", "/workspace/running-config"),
        timeout=_HTTP_TIMEOUT,
    )
    assert get_before.status_code == 200
    before = get_before.json()

    try:
        updated = dict(before)
        lcc = dict(updated.get("light_context_config") or {})
        ccc = dict(lcc.get("context_compact_config") or {})
        ccc["compact_threshold_ratio"] = 0.5
        lcc["context_compact_config"] = ccc
        updated["light_context_config"] = lcc

        put_resp = app_server.api_request(
            "PUT",
            scoped("default", "/workspace/running-config"),
            json=updated,
            timeout=_HTTP_TIMEOUT,
        )
        assert put_resp.status_code == 200

        get_after = app_server.api_request(
            "GET",
            scoped("default", "/workspace/running-config"),
            timeout=_HTTP_TIMEOUT,
        )
        assert get_after.status_code == 200
        after = get_after.json()
        after_ccc = after.get("light_context_config", {}).get(
            "context_compact_config",
            {},
        )
        assert after_ccc.get("compact_threshold_ratio") == 0.5
    finally:
        app_server.api_request(
            "PUT",
            scoped("default", "/workspace/running-config"),
            json=before,
            timeout=_HTTP_TIMEOUT,
        )


@pytest.mark.integration
@pytest.mark.p2
def test_running_config_extra_fields_ignored(
    app_server,
) -> None:
    """PUT with unknown fields succeeds but they are not persisted
    (AgentsRunningConfig uses extra='ignore')."""
    get_before = app_server.api_request(
        "GET",
        scoped("default", "/workspace/running-config"),
        timeout=_HTTP_TIMEOUT,
    )
    assert get_before.status_code == 200
    before = get_before.json()

    try:
        updated = dict(before)
        updated["bogus_field_xyz_integ"] = 42
        put_resp = app_server.api_request(
            "PUT",
            scoped("default", "/workspace/running-config"),
            json=updated,
            timeout=_HTTP_TIMEOUT,
        )
        assert put_resp.status_code == 200

        get_after = app_server.api_request(
            "GET",
            scoped("default", "/workspace/running-config"),
            timeout=_HTTP_TIMEOUT,
        )
        assert get_after.status_code == 200
        assert "bogus_field_xyz_integ" not in get_after.json()
    finally:
        app_server.api_request(
            "PUT",
            scoped("default", "/workspace/running-config"),
            json=before,
            timeout=_HTTP_TIMEOUT,
        )


# ================================================================== #
#  C class — Inbox event lifecycle (5 tests)
# ================================================================== #


def _register_mock_provider(app_server, mock_llm_url):
    return register_mock_provider(app_server, mock_llm_url)


def _unregister_mock_provider(app_server, provider_id):
    unregister_mock_provider(app_server, provider_id)


def _poll_inbox_heartbeat(
    app_server,
    deadline,
    event_type=None,
):
    """Poll inbox until a heartbeat event appears or deadline."""
    while time.time() < deadline:
        resp = app_server.api_request(
            "GET",
            "/api/console/inbox/events",
            params={"source_type": "heartbeat"},
            timeout=_HTTP_TIMEOUT,
        )
        if resp.status_code == 200:
            body = resp.json()
            events = body.get(
                "events",
                body if isinstance(body, list) else [],
            )
            if event_type:
                events = [
                    e for e in events if e.get("event_type") == event_type
                ]
            if events:
                return events
        time.sleep(1.0)
    return []


def _setup_heartbeat(app_server, mock_url):
    """Register provider + write HEARTBEAT.md + configure inbox."""
    working_dir = app_server.working_dir
    clean_inbox(working_dir)
    _unregister_mock_provider(app_server, "integ-mock-llm")

    provider_id = _register_mock_provider(app_server, mock_url)

    hb_before = app_server.api_request(
        "GET",
        scoped("default", "/config/heartbeat"),
        timeout=_HTTP_TIMEOUT,
    ).json()

    ws_dir = Path(working_dir) / "workspaces" / "default"
    hb_file = ws_dir / "HEARTBEAT.md"
    ws_dir.mkdir(parents=True, exist_ok=True)
    hb_original = hb_file.read_text("utf-8") if hb_file.exists() else None

    hb_file.write_text(
        "Integration test heartbeat query",
        encoding="utf-8",
    )

    app_server.api_request(
        "PUT",
        scoped("default", "/config/heartbeat"),
        json={
            "enabled": True,
            "target": "inbox",
            "every": "24h",
        },
        timeout=_HTTP_TIMEOUT,
    )

    return {
        "provider_id": provider_id,
        "hb_before": hb_before,
        "hb_file": hb_file,
        "hb_original": hb_original,
        "working_dir": working_dir,
    }


def _teardown_heartbeat(app_server, ctx):
    """Restore heartbeat config + HEARTBEAT.md + provider + inbox."""
    clean_inbox(ctx["working_dir"])
    hb_file = ctx["hb_file"]
    if ctx["hb_original"] is not None:
        hb_file.write_text(
            ctx["hb_original"],
            encoding="utf-8",
        )
    elif hb_file.exists():
        hb_file.unlink()
    app_server.api_request(
        "PUT",
        scoped("default", "/config/heartbeat"),
        json=ctx["hb_before"],
        timeout=_HTTP_TIMEOUT,
    )
    _unregister_mock_provider(app_server, ctx["provider_id"])


@pytest.mark.integration
@pytest.mark.p0
def test_heartbeat_inbox_end_to_end(  # pylint: disable=redefined-outer-name
    app_server,
    mock_llm,
) -> None:
    """Mock LLM → heartbeat run → inbox event + trace created."""
    srv, mock_url = mock_llm
    srv.force_error = False
    ctx = _setup_heartbeat(app_server, mock_url)
    try:
        run_resp = app_server.api_request(
            "POST",
            scoped("default", "/config/heartbeat/run"),
            timeout=_HTTP_TIMEOUT,
        )
        assert run_resp.status_code == 200
        assert run_resp.json().get("started") is True

        events = _poll_inbox_heartbeat(
            app_server,
            time.time() + 30.0,
            event_type="heartbeat_result",
        )
        assert len(events) >= 1, (
            "No heartbeat inbox event after 30s: " f"{app_server.logs_tail()}"
        )

        event = events[0]
        assert event["source_type"] == "heartbeat"
        assert event["event_type"] == "heartbeat_result"
        assert event["status"] == "success"
        assert event["severity"] == "info"
        assert event["agent_id"] == "default"
        assert isinstance(event.get("payload"), dict)
        assert "run_id" in event["payload"]

        for field in (
            "id",
            "agent_id",
            "source_type",
            "event_type",
            "status",
            "severity",
            "title",
            "read",
            "created_at",
        ):
            assert field in event, f"missing field: {field}"

        run_id = event["payload"]["run_id"]
        trace_resp = app_server.api_request(
            "GET",
            f"/api/console/inbox/traces/{run_id}",
            timeout=_HTTP_TIMEOUT,
        )
        assert trace_resp.status_code == 200
    finally:
        _teardown_heartbeat(app_server, ctx)


@pytest.mark.integration
@pytest.mark.p1
def test_heartbeat_inbox_event_body_contains_response(
    app_server,
    mock_llm,  # pylint: disable=redefined-outer-name
) -> None:
    """Heartbeat event body contains the mock LLM response text."""
    srv, mock_url = mock_llm
    srv.force_error = False
    ctx = _setup_heartbeat(app_server, mock_url)
    try:
        run_resp = app_server.api_request(
            "POST",
            scoped("default", "/config/heartbeat/run"),
            timeout=_HTTP_TIMEOUT,
        )
        assert run_resp.status_code == 200

        events = _poll_inbox_heartbeat(
            app_server,
            time.time() + 30.0,
            event_type="heartbeat_result",
        )
        assert len(events) >= 1, app_server.logs_tail()

        event = events[0]
        assert event["body"], "event body should not be empty"
    finally:
        _teardown_heartbeat(app_server, ctx)


@pytest.mark.integration
@pytest.mark.p1
def test_heartbeat_run_twice_creates_two_events(
    app_server,
    mock_llm,  # pylint: disable=redefined-outer-name
) -> None:
    """Two heartbeat runs produce two distinct inbox events."""
    srv, mock_url = mock_llm
    srv.force_error = False
    ctx = _setup_heartbeat(app_server, mock_url)
    try:
        for _ in range(2):
            run_resp = app_server.api_request(
                "POST",
                scoped("default", "/config/heartbeat/run"),
                timeout=_HTTP_TIMEOUT,
            )
            assert run_resp.status_code == 200
            time.sleep(3.0)

        events = _poll_inbox_heartbeat(
            app_server,
            time.time() + 30.0,
            event_type="heartbeat_result",
        )
        assert len(events) >= 2, (
            f"Expected >=2 events, got {len(events)}: "
            f"{app_server.logs_tail()}"
        )

        ids = {e["id"] for e in events}
        run_ids = {
            e["payload"]["run_id"]
            for e in events
            if "run_id" in e.get("payload", {})
        }
        assert len(ids) >= 2, "events must have unique ids"
        assert len(run_ids) >= 2, "events must have unique run_ids"
    finally:
        _teardown_heartbeat(app_server, ctx)


@pytest.mark.integration
@pytest.mark.p1
def test_heartbeat_inbox_mark_read_via_api(
    app_server,
    mock_llm,  # pylint: disable=redefined-outer-name
) -> None:
    """Heartbeat event → mark read → unread_only excludes it."""
    srv, mock_url = mock_llm
    srv.force_error = False
    ctx = _setup_heartbeat(app_server, mock_url)
    try:
        run_resp = app_server.api_request(
            "POST",
            scoped("default", "/config/heartbeat/run"),
            timeout=_HTTP_TIMEOUT,
        )
        assert run_resp.status_code == 200

        events = _poll_inbox_heartbeat(
            app_server,
            time.time() + 30.0,
            event_type="heartbeat_result",
        )
        assert len(events) >= 1, app_server.logs_tail()
        event = events[0]

        mark_resp = app_server.api_request(
            "POST",
            "/api/console/inbox/read",
            json={"event_ids": [event["id"]], "all": False},
            timeout=_HTTP_TIMEOUT,
        )
        assert mark_resp.status_code == 200
        assert mark_resp.json().get("updated") == 1

        unread_resp = app_server.api_request(
            "GET",
            "/api/console/inbox/events",
            params={
                "unread_only": "true",
                "source_type": "heartbeat",
            },
            timeout=_HTTP_TIMEOUT,
        )
        assert unread_resp.status_code == 200
        body = unread_resp.json()
        unread = body.get(
            "events",
            body if isinstance(body, list) else [],
        )
        unread_ids = {e["id"] for e in unread}
        assert event["id"] not in unread_ids
    finally:
        _teardown_heartbeat(app_server, ctx)


@pytest.mark.integration
@pytest.mark.p1
def test_heartbeat_inbox_delete_cleans_trace(
    app_server,
    mock_llm,  # pylint: disable=redefined-outer-name
) -> None:
    """Heartbeat event+trace → DELETE event → trace also removed."""
    srv, mock_url = mock_llm
    srv.force_error = False
    ctx = _setup_heartbeat(app_server, mock_url)
    try:
        run_resp = app_server.api_request(
            "POST",
            scoped("default", "/config/heartbeat/run"),
            timeout=_HTTP_TIMEOUT,
        )
        assert run_resp.status_code == 200

        events = _poll_inbox_heartbeat(
            app_server,
            time.time() + 30.0,
            event_type="heartbeat_result",
        )
        assert len(events) >= 1, app_server.logs_tail()
        event = events[0]
        run_id = event["payload"]["run_id"]

        del_resp = app_server.api_request(
            "DELETE",
            f"/api/console/inbox/events/{event['id']}",
            timeout=_HTTP_TIMEOUT,
        )
        assert del_resp.status_code == 200
        del_body = del_resp.json()
        assert del_body.get("deleted") is True
        assert del_body.get("trace_deleted") is True

        trace_resp = app_server.api_request(
            "GET",
            f"/api/console/inbox/traces/{run_id}",
            timeout=_HTTP_TIMEOUT,
        )
        assert trace_resp.status_code == 404
    finally:
        _teardown_heartbeat(app_server, ctx)


@pytest.mark.integration
@pytest.mark.p1
def test_heartbeat_resilient_to_llm_error(
    app_server,
    mock_llm,  # pylint: disable=redefined-outer-name
) -> None:
    """Test purpose:
    - Verify that a heartbeat run against a failing LLM (422) does
      not deadlock and still writes a completion event to inbox.

      Since the agentscope 2.0 migration (PR #4846),
      ``Workspace.stream_query`` re-raises upstream exceptions instead
      of swallowing them, so a forced LLM 422 surfaces as a
      ``heartbeat_error`` inbox event (vs. ``heartbeat_result`` on the
      success path). Either flavour proves the heartbeat coroutine
      completed and the scheduler is unblocked, which is the actual
      meaning of "resilient" here.

    Test flow:
    1. Set mock LLM to force 422 errors.
    2. Setup heartbeat with mock provider.
    3. POST heartbeat/run.
    4. Poll inbox for ANY heartbeat completion event
       (heartbeat_result or heartbeat_error).
    5. Assert one was created (heartbeat did not deadlock).
    6. Restore mock LLM to normal.

    API endpoints:
    - POST /api/agents/{agentId}/config/heartbeat/run
    - GET /api/console/inbox/events
    """
    srv, mock_url = mock_llm
    srv.force_error = False
    ctx = _setup_heartbeat(app_server, mock_url)
    srv.force_error = True
    try:
        run_resp = app_server.api_request(
            "POST",
            scoped("default", "/config/heartbeat/run"),
            timeout=_HTTP_TIMEOUT,
        )
        assert run_resp.status_code == 200

        events = _poll_inbox_heartbeat(
            app_server,
            time.time() + 30.0,
        )
        assert len(events) >= 1, (
            "Heartbeat should complete (result or error) even with "
            f"LLM errors: {app_server.logs_tail()}"
        )

        event = events[0]
        assert event["source_type"] == "heartbeat"
        assert event["event_type"] in {
            "heartbeat_result",
            "heartbeat_error",
        }, event
    finally:
        srv.force_error = False
        _teardown_heartbeat(app_server, ctx)


@pytest.mark.integration
@pytest.mark.p2
def test_heartbeat_auto_schedule_fires(
    app_server,
    mock_llm,  # pylint: disable=redefined-outer-name
) -> None:
    """Test purpose:
    - Verify that a heartbeat configured with every=60s fires
      automatically via the scheduler without a manual POST run.

    Test flow:
    1. Setup heartbeat with every=60s.
    2. Wait ~70s for the scheduler to fire.
    3. Poll inbox for heartbeat_result event.
    4. Assert event was created by the scheduler.

    API endpoints:
    - PUT /api/agents/{agentId}/config/heartbeat
    - GET /api/console/inbox/events
    """
    srv, mock_url = mock_llm
    srv.force_error = False
    working_dir = app_server.working_dir
    clean_inbox(working_dir)
    unregister_mock_provider(
        app_server,
        MOCK_LLM_PROVIDER_ID,
    )

    provider_id = register_mock_provider(app_server, mock_url)

    hb_before = app_server.api_request(
        "GET",
        scoped("default", "/config/heartbeat"),
        timeout=_HTTP_TIMEOUT,
    ).json()

    ws_dir = Path(working_dir) / "workspaces" / "default"
    hb_file = ws_dir / "HEARTBEAT.md"
    ws_dir.mkdir(parents=True, exist_ok=True)
    hb_original = hb_file.read_text("utf-8") if hb_file.exists() else None
    hb_file.write_text(
        "Auto-schedule heartbeat test query",
        encoding="utf-8",
    )

    app_server.api_request(
        "PUT",
        scoped("default", "/config/heartbeat"),
        json={
            "enabled": True,
            "target": "inbox",
            "every": "60s",
        },
        timeout=_HTTP_TIMEOUT,
    )

    try:
        events = _poll_inbox_heartbeat(
            app_server,
            time.time() + 80.0,
            event_type="heartbeat_result",
        )
        assert len(events) >= 1, (
            "No auto-scheduled heartbeat event after 80s: "
            f"{app_server.logs_tail()}"
        )
        event = events[0]
        assert event["source_type"] == "heartbeat"
        assert event["event_type"] == "heartbeat_result"
    finally:
        clean_inbox(working_dir)
        if hb_original is not None:
            hb_file.write_text(hb_original, encoding="utf-8")
        elif hb_file.exists():
            hb_file.unlink()
        app_server.api_request(
            "PUT",
            scoped("default", "/config/heartbeat"),
            json=hb_before,
            timeout=_HTTP_TIMEOUT,
        )
        unregister_mock_provider(app_server, provider_id)
