"""
Comprehensive tests for castor.api -- the FastAPI gateway endpoints.

Uses FastAPI's TestClient (starlette) for synchronous endpoint testing.
Mocks AppState internals (brain, driver, fs, channels) so tests run
without hardware, AI providers, or messaging SDKs.
"""

import base64
import collections
import time
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from starlette.testclient import TestClient

from castor.providers.base import Thought

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_SENTINEL = object()


def _make_mock_brain(raw_text="moving forward", action=_SENTINEL):
    """Create a mock brain whose think() returns a predictable Thought.

    Pass action=None explicitly to get a Thought with no action.
    Omitting action gives a default move action.
    """
    brain = MagicMock()
    if action is _SENTINEL:
        action = {"type": "move", "linear": 0.5}
    brain.think.return_value = Thought(raw_text, action)
    return brain


def _make_mock_driver():
    """Create a mock hardware driver with move/stop/close."""
    driver = MagicMock()
    driver.move = MagicMock()
    driver.stop = MagicMock()
    driver.close = MagicMock()
    return driver


def _make_mock_fs():
    """Create a mock CastorFS with all methods used by the API."""
    fs = MagicMock()
    fs.estop = MagicMock()
    fs.clear_estop = MagicMock(return_value=True)
    fs.read = MagicMock(return_value={"some": "data"})
    fs.write = MagicMock(return_value=True)
    fs.exists = MagicMock(return_value=True)
    fs.ls = MagicMock(return_value=["dev", "proc", "var"])
    fs.tree = MagicMock(return_value="/\n|-- dev/\n|-- proc/\n|-- var/")
    fs.proc = MagicMock()
    fs.proc.snapshot = MagicMock(return_value={"uptime_s": 42.0, "loops": 10})
    fs.memory = MagicMock()
    fs.memory.get_episodes = MagicMock(return_value=[])
    fs.memory.list_facts = MagicMock(return_value={})
    fs.memory.list_behaviors = MagicMock(return_value={})
    fs.perms = MagicMock()
    fs.perms.dump = MagicMock(return_value={"acls": {}, "capabilities": {}})
    fs.ns = MagicMock()
    fs.ns.read = MagicMock(return_value=None)
    return fs


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _reset_state_and_env(monkeypatch):
    """Reset application state and relevant env vars before every test.

    We import `state` and `app` fresh and patch the module-level API_TOKEN
    so each test starts with a clean slate.
    """
    # Remove auth-related env vars so tests start in open-access mode
    monkeypatch.delenv("OPENCASTOR_API_TOKEN", raising=False)
    monkeypatch.delenv("OPENCASTOR_JWT_SECRET", raising=False)
    monkeypatch.delenv("OPENCASTOR_CONFIG", raising=False)

    import castor.api as api_mod

    # Reset all mutable state fields
    api_mod.state.config = None
    api_mod.state.brain = None
    api_mod.state.driver = None
    api_mod.state.channels = {}
    api_mod.state.last_thought = None
    api_mod.state.boot_time = time.time()
    api_mod.state.fs = None
    api_mod.state.ruri = None
    api_mod.state.mdns_broadcaster = None
    api_mod.state.mdns_browser = None
    api_mod.state.rcan_router = None
    api_mod.state.capability_registry = None
    api_mod.state.offline_fallback = None
    api_mod.state.thought_history = collections.deque(maxlen=50)
    api_mod.state.learner = None
    api_mod.state.mission_runner = None

    # Reset the module-level API_TOKEN
    api_mod.API_TOKEN = None

    # Clear rate-limiter history so tests don't trip each other's per-IP limits
    api_mod._command_history.clear()
    api_mod._webhook_history.clear()

    yield


@pytest.fixture()
def client():
    """Return a TestClient wired to the FastAPI app.

    We replace the real startup/shutdown lifecycle events to avoid loading
    configs, hardware, channels, or other real infrastructure.

    Handles both legacy @app.on_event handlers and the modern FastAPI
    lifespan context manager pattern.
    """
    import contextlib

    from castor.api import app

    # Save and clear legacy on_event handlers
    original_startup = app.router.on_startup[:]
    original_shutdown = app.router.on_shutdown[:]
    app.router.on_startup.clear()
    app.router.on_shutdown.clear()

    # Save and replace lifespan context manager with a no-op so that
    # real hardware/config initialisation is skipped during tests.
    original_lifespan = app.router.lifespan_context

    @contextlib.asynccontextmanager
    async def _noop_lifespan(app):
        yield

    app.router.lifespan_context = _noop_lifespan

    try:
        with TestClient(app, raise_server_exceptions=False) as c:
            yield c
    finally:
        # Restore original handlers
        app.router.on_startup[:] = original_startup
        app.router.on_shutdown[:] = original_shutdown
        app.router.lifespan_context = original_lifespan


@pytest.fixture()
def api_mod():
    """Return the castor.api module for direct state manipulation."""
    import castor.api as mod

    return mod


# =====================================================================
# /health -- public, no auth required
# =====================================================================
class TestHealthEndpoint:
    def test_health_returns_200(self, client):
        resp = client.get("/health")
        assert resp.status_code == 200
        body = resp.json()
        assert body["status"] == "ok"

    def test_health_contains_uptime(self, client):
        resp = client.get("/health")
        body = resp.json()
        assert "uptime_s" in body
        assert isinstance(body["uptime_s"], (int, float))
        assert body["uptime_s"] >= 0

    def test_health_no_auth_required_when_token_set(self, client, api_mod):
        """Health endpoint must remain accessible even when API auth is on."""
        api_mod.API_TOKEN = "secret-token-123"
        resp = client.get("/health")
        assert resp.status_code == 200

    def test_health_does_not_expose_channels(self, client):
        """/health must not expose internal channel names (use /api/health/detail)."""
        resp = client.get("/health")
        body = resp.json()
        assert "channels" not in body
        assert "brain" not in body
        assert "driver" not in body

    def test_health_detail_requires_auth(self, client, api_mod):
        """/api/health/detail must require a valid token."""
        api_mod.API_TOKEN = "secret-token-123"
        resp = client.get("/api/health/detail")
        assert resp.status_code == 401

    def test_health_detail_returns_full_info(self, client, api_mod):
        """/api/health/detail returns brain/driver/channels when authenticated."""
        api_mod.API_TOKEN = ""  # no auth required in test
        api_mod.state.channels = {"telegram": MagicMock()}
        resp = client.get("/api/health/detail")
        body = resp.json()
        assert "channels" in body
        assert "brain" in body
        assert "driver" in body


# =====================================================================
# Auth enforcement
# =====================================================================
class TestAuthEnforcement:
    def test_open_access_when_no_token_configured(self, client):
        """When API_TOKEN is None, protected endpoints are accessible."""
        resp = client.get("/api/status")
        assert resp.status_code == 200

    def test_401_when_token_required_but_missing(self, client, api_mod):
        api_mod.API_TOKEN = "secret"
        resp = client.get("/api/status")
        assert resp.status_code == 401

    def test_401_when_token_wrong(self, client, api_mod):
        api_mod.API_TOKEN = "correct-token"
        resp = client.get(
            "/api/status",
            headers={"Authorization": "Bearer wrong-token"},
        )
        assert resp.status_code == 401

    def test_200_when_token_correct(self, client, api_mod):
        api_mod.API_TOKEN = "correct-token"
        resp = client.get(
            "/api/status",
            headers={"Authorization": "Bearer correct-token"},
        )
        assert resp.status_code == 200

    def test_401_missing_bearer_prefix(self, client, api_mod):
        api_mod.API_TOKEN = "tok"
        resp = client.get(
            "/api/status",
            headers={"Authorization": "tok"},
        )
        assert resp.status_code == 401

    def test_multiple_protected_endpoints_require_auth(self, client, api_mod):
        """All protected endpoints should return 401 when token is set."""
        api_mod.API_TOKEN = "secret"
        protected = [
            ("GET", "/api/status"),
            ("POST", "/api/command"),
            ("POST", "/api/action"),
            ("POST", "/api/stop"),
            ("POST", "/api/estop/clear"),
            ("POST", "/api/fs/read"),
            ("POST", "/api/fs/write"),
            ("GET", "/api/fs/ls"),
            ("GET", "/api/fs/tree"),
            ("GET", "/api/fs/proc"),
            ("GET", "/api/fs/memory"),
            ("POST", "/api/auth/token"),
            ("GET", "/api/auth/whoami"),
            ("GET", "/api/rcan/peers"),
            ("POST", "/rcan"),
            ("GET", "/cap/status"),
            ("POST", "/cap/teleop"),
            ("POST", "/cap/chat"),
            ("GET", "/cap/vision"),
            ("GET", "/api/roles"),
            ("GET", "/api/fs/permissions"),
            ("GET", "/api/whatsapp/status"),
        ]
        for method, path in protected:
            if method == "GET":
                resp = client.get(path)
            else:
                resp = client.post(path, json={})
            assert resp.status_code == 401, (
                f"{method} {path} returned {resp.status_code}, expected 401"
            )


# =====================================================================
# GET /api/status
# =====================================================================
class TestStatusEndpoint:
    def test_status_fields(self, client):
        resp = client.get("/api/status")
        body = resp.json()
        assert "config_loaded" in body
        assert "providers" in body
        assert "channels_available" in body
        assert "channels_active" in body
        assert "last_thought" in body
        assert "ruri" in body

    def test_status_config_not_loaded(self, client):
        resp = client.get("/api/status")
        assert resp.json()["config_loaded"] is False
        assert resp.json()["robot_name"] is None

    def test_status_config_loaded(self, client, api_mod):
        api_mod.state.config = {"metadata": {"robot_name": "TestBot"}}
        resp = client.get("/api/status")
        body = resp.json()
        assert body["config_loaded"] is True
        assert body["robot_name"] == "TestBot"

    def test_status_includes_ruri(self, client, api_mod):
        api_mod.state.ruri = "rcan://opencastor.testbot.12345678"
        resp = client.get("/api/status")
        assert resp.json()["ruri"] == "rcan://opencastor.testbot.12345678"

    def test_status_last_thought(self, client, api_mod):
        api_mod.state.last_thought = {
            "raw_text": "hello",
            "action": {"type": "stop"},
            "timestamp": 1234567890.0,
        }
        resp = client.get("/api/status")
        assert resp.json()["last_thought"]["raw_text"] == "hello"

    def test_status_includes_security_posture_when_fs_available(self, client, api_mod):
        api_mod.state.fs = _make_mock_fs()
        api_mod.state.fs.ns.read.side_effect = lambda path: (
            {"mode": "degraded", "verified": False} if path == "/proc/safety" else None
        )
        body = client.get("/api/status").json()
        assert body["security_posture"]["mode"] == "degraded"


# =====================================================================
# POST /api/command
# =====================================================================
class TestCommandEndpoint:
    def test_command_503_when_brain_not_loaded(self, client):
        resp = client.post("/api/command", json={"instruction": "go forward"})
        assert resp.status_code == 503
        assert "Brain not initialized" in resp.json()["error"]

    def test_command_success(self, client, api_mod):
        api_mod.state.brain = _make_mock_brain("turning left", {"type": "move", "angular": -0.5})
        resp = client.post("/api/command", json={"instruction": "turn left"})
        assert resp.status_code == 200
        body = resp.json()
        assert body["raw_text"] == "turning left"
        assert body["action"]["type"] == "move"
        assert body["action"]["angular"] == -0.5

    def test_command_updates_last_thought(self, client, api_mod):
        api_mod.state.brain = _make_mock_brain("done", {"type": "stop"})
        client.post("/api/command", json={"instruction": "stop"})
        assert api_mod.state.last_thought is not None
        assert api_mod.state.last_thought["raw_text"] == "done"

    def test_command_with_image_base64(self, client, api_mod):
        api_mod.state.brain = _make_mock_brain("vision reply", {"type": "wait", "duration_ms": 1})
        img_b64 = base64.b64encode(b"\x89PNG fake image").decode()
        resp = client.post(
            "/api/command",
            json={"instruction": "what do you see?", "image_base64": img_b64},
        )
        assert resp.status_code == 200
        # Verify brain.think was called with the decoded bytes
        call_args = api_mod.state.brain.think.call_args
        assert call_args[0][0] == base64.b64decode(img_b64)

    def test_command_executes_action_on_driver(self, client, api_mod):
        api_mod.state.brain = _make_mock_brain("go", {"type": "move", "linear": 0.8})
        api_mod.state.driver = _make_mock_driver()
        resp = client.post("/api/command", json={"instruction": "forward"})
        assert resp.status_code == 200
        api_mod.state.driver.move.assert_called_once_with(0.8, 0.0)

    def test_command_no_driver_still_succeeds(self, client, api_mod):
        """Command should succeed even without a driver -- just no motor output."""
        api_mod.state.brain = _make_mock_brain("thinking", {"type": "move", "linear": 0.3})
        # state.driver is None
        resp = client.post("/api/command", json={"instruction": "think"})
        assert resp.status_code == 200

    def test_command_stop_action_on_driver(self, client, api_mod):
        api_mod.state.brain = _make_mock_brain("stopping", {"type": "stop"})
        api_mod.state.driver = _make_mock_driver()
        resp = client.post("/api/command", json={"instruction": "halt"})
        assert resp.status_code == 200
        api_mod.state.driver.stop.assert_called_once()

    def test_command_requires_instruction_field(self, client, api_mod):
        api_mod.state.brain = _make_mock_brain()
        resp = client.post("/api/command", json={})
        assert resp.status_code == 422  # Pydantic validation error

    def test_command_none_action_from_brain(self, client, api_mod):
        api_mod.state.brain = _make_mock_brain("confused", None)
        resp = client.post("/api/command", json={"instruction": "do nothing"})
        assert resp.status_code == 200
        assert resp.json()["action"] is None


# =====================================================================
# POST /api/action
# =====================================================================
class TestActionEndpoint:
    def test_action_503_when_no_driver(self, client):
        resp = client.post("/api/action", json={"type": "move", "linear": 0.5})
        assert resp.status_code == 503
        assert "No hardware driver active" in resp.json()["error"]

    def test_action_move(self, client, api_mod):
        api_mod.state.driver = _make_mock_driver()
        resp = client.post(
            "/api/action",
            json={"type": "move", "linear": 0.5, "angular": -0.2},
        )
        assert resp.status_code == 200
        body = resp.json()
        assert body["status"] == "executed"
        assert body["action"]["type"] == "move"
        api_mod.state.driver.move.assert_called_once_with(0.5, -0.2)

    def test_action_stop(self, client, api_mod):
        api_mod.state.driver = _make_mock_driver()
        resp = client.post("/api/action", json={"type": "stop"})
        assert resp.status_code == 200
        api_mod.state.driver.stop.assert_called_once()

    def test_action_requires_type_field(self, client, api_mod):
        api_mod.state.driver = _make_mock_driver()
        resp = client.post("/api/action", json={"linear": 0.5})
        assert resp.status_code == 422

    def test_action_grip(self, client, api_mod):
        api_mod.state.driver = _make_mock_driver()
        resp = client.post(
            "/api/action",
            json={"type": "grip", "state": "close"},
        )
        assert resp.status_code == 200
        assert resp.json()["action"]["state"] == "close"

    def test_action_wait(self, client, api_mod):
        api_mod.state.driver = _make_mock_driver()
        resp = client.post(
            "/api/action",
            json={"type": "wait", "duration_ms": 500},
        )
        assert resp.status_code == 200
        assert resp.json()["action"]["duration_ms"] == 500


# =====================================================================
# POST /api/stop (emergency stop)
# =====================================================================
class TestStopEndpoint:
    def test_stop_without_driver(self, client):
        resp = client.post("/api/stop")
        assert resp.status_code == 200
        assert resp.json()["status"] == "stopped"

    def test_stop_calls_driver_stop(self, client, api_mod):
        api_mod.state.driver = _make_mock_driver()
        resp = client.post("/api/stop")
        assert resp.status_code == 200
        api_mod.state.driver.stop.assert_called_once()

    def test_stop_triggers_fs_estop(self, client, api_mod):
        api_mod.state.fs = _make_mock_fs()
        resp = client.post("/api/stop")
        assert resp.status_code == 200
        api_mod.state.fs.estop.assert_called_once_with(principal="api")

    def test_stop_with_auth(self, client, api_mod):
        api_mod.API_TOKEN = "tok"
        api_mod.state.driver = _make_mock_driver()
        resp = client.post(
            "/api/stop",
            headers={"Authorization": "Bearer tok"},
        )
        assert resp.status_code == 200
        api_mod.state.driver.stop.assert_called_once()


# =====================================================================
# POST /api/estop/clear
# =====================================================================
class TestEStopClearEndpoint:
    def test_clear_estop_no_fs(self, client):
        resp = client.post("/api/estop/clear")
        assert resp.status_code == 200
        assert resp.json()["status"] == "no_fs"

    def test_clear_estop_success(self, client, api_mod):
        api_mod.state.fs = _make_mock_fs()
        api_mod.state.fs.clear_estop.return_value = True
        resp = client.post("/api/estop/clear")
        assert resp.status_code == 200
        assert resp.json()["status"] == "cleared"

    def test_clear_estop_denied(self, client, api_mod):
        api_mod.state.fs = _make_mock_fs()
        api_mod.state.fs.clear_estop.return_value = False
        resp = client.post("/api/estop/clear")
        assert resp.status_code == 403
        assert "Insufficient permissions" in resp.json()["error"]


# =====================================================================
# Virtual Filesystem endpoints
# =====================================================================
class TestFSReadEndpoint:
    def test_fs_read_no_fs(self, client):
        resp = client.post("/api/fs/read", json={"path": "/proc/uptime"})
        assert resp.status_code == 503

    def test_fs_read_success(self, client, api_mod):
        api_mod.state.fs = _make_mock_fs()
        api_mod.state.fs.read.return_value = 42.5
        resp = client.post("/api/fs/read", json={"path": "/proc/uptime"})
        assert resp.status_code == 200
        assert resp.json()["path"] == "/proc/uptime"
        assert resp.json()["data"] == 42.5

    def test_fs_read_not_found(self, client, api_mod):
        api_mod.state.fs = _make_mock_fs()
        api_mod.state.fs.read.return_value = None
        api_mod.state.fs.exists.return_value = False
        resp = client.post("/api/fs/read", json={"path": "/no/such/path"})
        assert resp.status_code == 404

    def test_fs_read_returns_none_but_exists(self, client, api_mod):
        """A path can exist and have None data (e.g., /dev/motor before first write)."""
        api_mod.state.fs = _make_mock_fs()
        api_mod.state.fs.read.return_value = None
        api_mod.state.fs.exists.return_value = True
        resp = client.post("/api/fs/read", json={"path": "/dev/motor"})
        assert resp.status_code == 200
        assert resp.json()["data"] is None


class TestFSWriteEndpoint:
    def test_fs_write_no_fs(self, client):
        resp = client.post("/api/fs/write", json={"path": "/tmp/test", "data": "hello"})
        assert resp.status_code == 503

    def test_fs_write_success(self, client, api_mod):
        api_mod.state.fs = _make_mock_fs()
        resp = client.post("/api/fs/write", json={"path": "/tmp/test", "data": "hello"})
        assert resp.status_code == 200
        assert resp.json()["status"] == "written"
        api_mod.state.fs.write.assert_called_once_with("/tmp/test", "hello", principal="api")

    def test_fs_write_denied(self, client, api_mod):
        api_mod.state.fs = _make_mock_fs()
        api_mod.state.fs.write.return_value = False
        resp = client.post("/api/fs/write", json={"path": "/etc/readonly", "data": "x"})
        assert resp.status_code == 403

    def test_fs_write_complex_data(self, client, api_mod):
        api_mod.state.fs = _make_mock_fs()
        data = {"sensors": [1, 2, 3], "active": True}
        resp = client.post("/api/fs/write", json={"path": "/tmp/sensors", "data": data})
        assert resp.status_code == 200


class TestFSLsEndpoint:
    def test_fs_ls_no_fs(self, client):
        resp = client.get("/api/fs/ls")
        assert resp.status_code == 503

    def test_fs_ls_root(self, client, api_mod):
        api_mod.state.fs = _make_mock_fs()
        resp = client.get("/api/fs/ls")
        assert resp.status_code == 200
        body = resp.json()
        assert body["path"] == "/"
        assert "dev" in body["children"]

    def test_fs_ls_with_path_param(self, client, api_mod):
        api_mod.state.fs = _make_mock_fs()
        api_mod.state.fs.ls.return_value = ["motor", "camera", "speaker"]
        resp = client.get("/api/fs/ls?path=/dev")
        assert resp.status_code == 200
        assert resp.json()["path"] == "/dev"
        assert "motor" in resp.json()["children"]

    def test_fs_ls_not_a_directory(self, client, api_mod):
        api_mod.state.fs = _make_mock_fs()
        api_mod.state.fs.ls.return_value = None
        resp = client.get("/api/fs/ls?path=/dev/motor")
        assert resp.status_code == 404


class TestFSTreeEndpoint:
    def test_fs_tree_no_fs(self, client):
        resp = client.get("/api/fs/tree")
        assert resp.status_code == 503

    def test_fs_tree_success(self, client, api_mod):
        api_mod.state.fs = _make_mock_fs()
        resp = client.get("/api/fs/tree")
        assert resp.status_code == 200
        assert "tree" in resp.json()

    def test_fs_tree_with_depth(self, client, api_mod):
        api_mod.state.fs = _make_mock_fs()
        resp = client.get("/api/fs/tree?depth=1")
        assert resp.status_code == 200
        api_mod.state.fs.tree.assert_called_with("/", depth=1)


class TestFSProcEndpoint:
    def test_fs_proc_no_fs(self, client):
        resp = client.get("/api/fs/proc")
        assert resp.status_code == 503

    def test_fs_proc_success(self, client, api_mod):
        api_mod.state.fs = _make_mock_fs()
        resp = client.get("/api/fs/proc")
        assert resp.status_code == 200
        assert resp.json()["uptime_s"] == 42.0


class TestFSMemoryEndpoint:
    def test_fs_memory_no_fs(self, client):
        resp = client.get("/api/fs/memory")
        assert resp.status_code == 503

    def test_fs_memory_all_tiers(self, client, api_mod):
        api_mod.state.fs = _make_mock_fs()
        resp = client.get("/api/fs/memory")
        assert resp.status_code == 200
        body = resp.json()
        assert "episodic" in body
        assert "semantic" in body
        assert "procedural" in body

    def test_fs_memory_episodic_only(self, client, api_mod):
        api_mod.state.fs = _make_mock_fs()
        resp = client.get("/api/fs/memory?tier=episodic")
        assert resp.status_code == 200
        body = resp.json()
        assert "episodic" in body
        assert "semantic" not in body

    def test_fs_memory_semantic_only(self, client, api_mod):
        api_mod.state.fs = _make_mock_fs()
        resp = client.get("/api/fs/memory?tier=semantic")
        assert resp.status_code == 200
        body = resp.json()
        assert "semantic" in body
        assert "episodic" not in body

    def test_fs_memory_procedural_only(self, client, api_mod):
        api_mod.state.fs = _make_mock_fs()
        resp = client.get("/api/fs/memory?tier=procedural")
        assert resp.status_code == 200
        body = resp.json()
        assert "procedural" in body
        assert "episodic" not in body

    def test_fs_memory_limit(self, client, api_mod):
        api_mod.state.fs = _make_mock_fs()
        resp = client.get("/api/fs/memory?tier=episodic&limit=5")
        assert resp.status_code == 200
        api_mod.state.fs.memory.get_episodes.assert_called_with(limit=5)


class TestFSPermissionsEndpoint:
    def test_fs_permissions_no_fs(self, client):
        resp = client.get("/api/fs/permissions")
        assert resp.status_code == 503

    def test_fs_permissions_success(self, client, api_mod):
        api_mod.state.fs = _make_mock_fs()
        resp = client.get("/api/fs/permissions")
        assert resp.status_code == 200
        body = resp.json()
        assert "acls" in body
        assert "capabilities" in body


# =====================================================================
# Auth token endpoints
# =====================================================================
class TestAuthTokenEndpoint:
    def test_issue_token_no_jwt_secret(self, client):
        resp = client.post(
            "/api/auth/token",
            json={"subject": "user1", "role": "GUEST"},
        )
        assert resp.status_code == 501
        assert "JWT not configured" in resp.json()["error"]

    def test_issue_token_invalid_role(self, client, monkeypatch):
        monkeypatch.setenv("OPENCASTOR_JWT_SECRET", "testsecret")
        # This will fail if the RCAN RBAC module raises KeyError for invalid role
        resp = client.post(
            "/api/auth/token",
            json={"subject": "user1", "role": "NONEXISTENT_ROLE"},
        )
        # Should be 400 (invalid role) or 500 (import error) but not 200
        assert resp.status_code in (400, 500)


class TestWhoamiEndpoint:
    def test_whoami_anonymous(self, client):
        resp = client.get("/api/auth/whoami")
        assert resp.status_code == 200
        body = resp.json()
        assert body["name"] == "anonymous"
        assert body["role"] == "GUEST"
        assert body["auth_method"] == "none"

    def test_whoami_with_bearer_token(self, client, api_mod):
        api_mod.API_TOKEN = "my-token"
        resp = client.get(
            "/api/auth/whoami",
            headers={"Authorization": "Bearer my-token"},
        )
        assert resp.status_code == 200
        body = resp.json()
        assert body["name"] == "api"
        assert body["role"] == "LEASEE"
        assert body["auth_method"] == "bearer_token"


# =====================================================================
# GET /api/rcan/peers
# =====================================================================
class TestRCANPeersEndpoint:
    def test_peers_no_mdns(self, client):
        resp = client.get("/api/rcan/peers")
        assert resp.status_code == 200
        body = resp.json()
        assert body["peers"] == []
        assert "note" in body

    def test_peers_with_mdns(self, client, api_mod):
        browser = MagicMock()
        browser.peers = {
            "bot1": {"ruri": "rcan://opencastor.bot1.11111111", "port": 8000},
            "bot2": {"ruri": "rcan://opencastor.bot2.22222222", "port": 8001},
        }
        api_mod.state.mdns_browser = browser
        resp = client.get("/api/rcan/peers")
        assert resp.status_code == 200
        assert len(resp.json()["peers"]) == 2


# =====================================================================
# POST /rcan (RCAN message endpoint)
# =====================================================================
class TestRCANMessageEndpoint:
    def test_rcan_message_no_router(self, client):
        resp = client.post("/rcan", json={"type": "command", "payload": {}})
        assert resp.status_code == 501
        assert "RCAN router not initialized" in resp.json()["error"]

    def test_rcan_message_invalid_body(self, client, api_mod):
        router = MagicMock()
        api_mod.state.rcan_router = router
        # Provide a body that will trigger an exception when RCANMessage.from_dict is called
        resp = client.post("/rcan", json={"invalid": "structure"})
        assert resp.status_code == 400


# =====================================================================
# Capability endpoints
# =====================================================================
class TestCapStatusEndpoint:
    def test_cap_status_basic(self, client, api_mod):
        resp = client.get("/cap/status")
        assert resp.status_code == 200
        body = resp.json()
        assert "ruri" in body
        assert "uptime_s" in body
        assert "brain" in body
        assert "driver" in body
        assert "channels_active" in body
        assert "capabilities" in body

    def test_cap_status_with_registry(self, client, api_mod):
        registry = MagicMock()
        registry.names = ["status", "chat", "teleop"]
        api_mod.state.capability_registry = registry
        resp = client.get("/cap/status")
        assert resp.json()["capabilities"] == ["status", "chat", "teleop"]

    def test_cap_status_includes_proc_when_fs_available(self, client, api_mod):
        api_mod.state.fs = _make_mock_fs()
        resp = client.get("/cap/status")
        assert "proc" in resp.json()


class TestCapTeleopEndpoint:
    def test_teleop_no_driver(self, client):
        resp = client.post("/cap/teleop", json={"type": "move", "linear": 0.5})
        assert resp.status_code == 503

    def test_teleop_success(self, client, api_mod):
        api_mod.state.driver = _make_mock_driver()
        resp = client.post(
            "/cap/teleop",
            json={"type": "move", "linear": 0.7, "angular": -0.1},
        )
        assert resp.status_code == 200
        assert resp.json()["status"] == "executed"
        api_mod.state.driver.move.assert_called_once_with(0.7, -0.1)


class TestCapChatEndpoint:
    def test_chat_no_brain(self, client):
        resp = client.post("/cap/chat", json={"instruction": "hello"})
        assert resp.status_code == 503

    def test_chat_success(self, client, api_mod):
        api_mod.state.brain = _make_mock_brain("hi there", {"type": "wait", "duration_ms": 100})
        resp = client.post("/cap/chat", json={"instruction": "hello"})
        assert resp.status_code == 200
        body = resp.json()
        assert body["raw_text"] == "hi there"
        assert body["action"]["type"] == "wait"

    def test_chat_with_image(self, client, api_mod):
        api_mod.state.brain = _make_mock_brain("i see stuff", None)
        img = base64.b64encode(b"fakejpeg").decode()
        resp = client.post(
            "/cap/chat",
            json={"instruction": "describe", "image_base64": img},
        )
        assert resp.status_code == 200
        # Verify decoded image was passed to brain
        call_args = api_mod.state.brain.think.call_args
        assert call_args[0][0] == base64.b64decode(img)


class TestCapVisionEndpoint:
    def test_vision_no_fs(self, client):
        resp = client.get("/cap/vision")
        assert resp.status_code == 200
        assert resp.json()["camera"]["status"] == "offline"

    def test_vision_with_fs_no_frame(self, client, api_mod):
        api_mod.state.fs = _make_mock_fs()
        api_mod.state.fs.ns.read.return_value = None
        resp = client.get("/cap/vision")
        assert resp.status_code == 200
        assert resp.json()["camera"]["status"] == "no_frame"

    def test_vision_with_fs_has_frame(self, client, api_mod):
        api_mod.state.fs = _make_mock_fs()
        api_mod.state.fs.ns.read.return_value = {"resolution": "640x480", "ts": 1234567890}
        resp = client.get("/cap/vision")
        assert resp.status_code == 200
        assert resp.json()["camera"]["resolution"] == "640x480"


# =====================================================================
# GET /api/roles
# =====================================================================
class TestRolesEndpoint:
    def test_roles_returns_200(self, client):
        resp = client.get("/api/roles")
        # May succeed if castor.rcan.rbac is available, or 500 if not
        assert resp.status_code in (200, 500)

    def test_roles_structure_when_available(self, client):
        resp = client.get("/api/roles")
        if resp.status_code == 200:
            body = resp.json()
            assert "roles" in body
            assert "principals" in body


# =====================================================================
# Webhook endpoints
# =====================================================================
class TestWhatsAppWebhook:
    def test_whatsapp_webhook_no_channel(self, client):
        resp = client.post("/webhooks/whatsapp", data={"Body": "hello"})
        assert resp.status_code == 503

    def test_whatsapp_webhook_with_channel(self, client, api_mod):
        channel = MagicMock()
        channel.handle_webhook = AsyncMock(return_value="reply text")
        api_mod.state.channels["whatsapp_twilio"] = channel
        resp = client.post(
            "/webhooks/whatsapp",
            data={"Body": "hello", "From": "whatsapp:+1234567890"},
        )
        assert resp.status_code == 200
        assert resp.json()["reply"] == "reply text"


class TestSlackWebhook:
    def test_slack_url_verification(self, client):
        resp = client.post(
            "/webhooks/slack",
            json={"type": "url_verification", "challenge": "abc123xyz"},
        )
        assert resp.status_code == 200
        assert resp.json()["challenge"] == "abc123xyz"

    def test_slack_event(self, client):
        resp = client.post(
            "/webhooks/slack",
            json={"type": "event_callback", "event": {"text": "hello"}},
        )
        assert resp.status_code == 200
        assert resp.json()["ok"] is True


class TestWhatsAppStatus:
    def test_whatsapp_status_not_configured(self, client):
        resp = client.get("/api/whatsapp/status")
        assert resp.status_code == 200
        assert resp.json()["status"] == "not_configured"

    def test_whatsapp_status_connected(self, client, api_mod):
        channel = MagicMock()
        channel.connected = True
        api_mod.state.channels["whatsapp"] = channel
        resp = client.get("/api/whatsapp/status")
        assert resp.status_code == 200
        assert resp.json()["status"] == "connected"

    def test_whatsapp_status_disconnected(self, client, api_mod):
        channel = MagicMock()
        channel.connected = False
        api_mod.state.channels["whatsapp"] = channel
        resp = client.get("/api/whatsapp/status")
        assert resp.status_code == 200
        assert resp.json()["status"] == "disconnected"


# =====================================================================
# CORS
# =====================================================================
class TestCORS:
    def test_cors_preflight_allowed_origin(self, client):
        """Preflight from the dashboard origin (localhost:8501) must be allowed."""
        resp = client.options(
            "/api/status",
            headers={
                "Origin": "http://localhost:8501",
                "Access-Control-Request-Method": "GET",
                "Access-Control-Request-Headers": "Authorization",
            },
        )
        assert resp.status_code == 200
        assert "access-control-allow-origin" in resp.headers

    def test_cors_header_on_response_allowed_origin(self, client):
        """Requests from the dashboard origin must include CORS headers."""
        resp = client.get(
            "/health",
            headers={"Origin": "http://localhost:8501"},
        )
        assert resp.status_code == 200
        assert "access-control-allow-origin" in resp.headers

    def test_cors_wildcard_not_default(self, client):
        """Default CORS must NOT be wildcard (*) — only specific origins allowed."""
        import castor.api as api_mod

        assert api_mod._cors_origins_stripped != ["*"]


# =====================================================================
# Edge cases and integration scenarios
# =====================================================================
class TestEdgeCases:
    def test_invalid_json_body(self, client, api_mod):
        api_mod.state.brain = _make_mock_brain()
        resp = client.post(
            "/api/command",
            content=b"this is not json",
            headers={"Content-Type": "application/json"},
        )
        assert resp.status_code == 422

    def test_empty_instruction(self, client, api_mod):
        api_mod.state.brain = _make_mock_brain()
        resp = client.post("/api/command", json={"instruction": ""})
        assert resp.status_code == 200  # Empty string is still valid

    def test_very_long_instruction(self, client, api_mod):
        api_mod.state.brain = _make_mock_brain()
        resp = client.post(
            "/api/command",
            json={"instruction": "go " * 10000},
        )
        assert resp.status_code == 200

    def test_concurrent_state_changes(self, client, api_mod):
        """Ensure /api/health/detail reflects latest state after mutation."""
        api_mod.API_TOKEN = ""  # open access for test
        assert client.get("/api/health/detail").json()["brain"] is False
        api_mod.state.brain = _make_mock_brain()
        assert client.get("/api/health/detail").json()["brain"] is True
        api_mod.state.brain = None
        assert client.get("/api/health/detail").json()["brain"] is False

    def test_action_exclude_none_fields(self, client, api_mod):
        """ActionRequest should exclude None fields in response."""
        api_mod.state.driver = _make_mock_driver()
        resp = client.post("/api/action", json={"type": "stop"})
        action = resp.json()["action"]
        # None fields should be absent from the response
        assert "linear" not in action
        assert "angular" not in action
        assert "state" not in action
        assert "duration_ms" not in action

    def test_health_uptime_increases(self, client, api_mod):
        """Uptime should be a positive number that reflects time since boot."""
        api_mod.state.boot_time = time.time() - 100
        resp = client.get("/health")
        assert resp.json()["uptime_s"] >= 99

    def test_fs_write_null_data(self, client, api_mod):
        """Writing None/null data should be valid."""
        api_mod.state.fs = _make_mock_fs()
        resp = client.post("/api/fs/write", json={"path": "/tmp/null"})
        assert resp.status_code == 200

    def test_fs_tree_default_params(self, client, api_mod):
        api_mod.state.fs = _make_mock_fs()
        resp = client.get("/api/fs/tree")
        assert resp.status_code == 200
        api_mod.state.fs.tree.assert_called_with("/", depth=3)


# =====================================================================
# Request model validation
# =====================================================================
class TestRequestValidation:
    def test_command_extra_fields_ignored(self, client, api_mod):
        api_mod.state.brain = _make_mock_brain()
        resp = client.post(
            "/api/command",
            json={"instruction": "go", "extra_field": "should be ignored"},
        )
        assert resp.status_code == 200

    def test_action_type_required(self, client, api_mod):
        api_mod.state.driver = _make_mock_driver()
        resp = client.post("/api/action", json={})
        assert resp.status_code == 422

    def test_fs_read_path_required(self, client, api_mod):
        api_mod.state.fs = _make_mock_fs()
        resp = client.post("/api/fs/read", json={})
        assert resp.status_code == 422

    def test_fs_write_path_required(self, client, api_mod):
        api_mod.state.fs = _make_mock_fs()
        resp = client.post("/api/fs/write", json={"data": "hello"})
        assert resp.status_code == 422

    def test_token_request_subject_required(self, client):
        resp = client.post("/api/auth/token", json={"role": "GUEST"})
        assert resp.status_code == 422

    def test_token_request_defaults(self, client):
        """Verify default values in TokenRequest model."""
        resp = client.post("/api/auth/token", json={"subject": "test"})
        # Will fail with 501 (JWT not configured) but should pass validation
        assert resp.status_code == 501  # Not 422


# =====================================================================
# Response format consistency
# =====================================================================
class TestResponseFormat:
    def test_health_response_keys(self, client):
        """Public /health returns only status, uptime_s, version (no internal state)."""
        body = client.get("/health").json()
        expected_keys = {"status", "uptime_s", "version"}
        assert expected_keys == set(body.keys())

    def test_command_response_keys(self, client, api_mod):
        api_mod.state.brain = _make_mock_brain()
        body = client.post("/api/command", json={"instruction": "go"}).json()
        assert "raw_text" in body
        assert "action" in body

    def test_stop_response_keys(self, client):
        body = client.post("/api/stop").json()
        assert body == {"status": "stopped"}

    def test_action_response_keys(self, client, api_mod):
        api_mod.state.driver = _make_mock_driver()
        body = client.post("/api/action", json={"type": "stop"}).json()
        assert "status" in body
        assert "action" in body
        assert body["status"] == "executed"


# ---------------------------------------------------------------------------
# POST /api/command/stream  (#68)
# ---------------------------------------------------------------------------


class TestStreamCommand:
    def test_no_brain_returns_503(self, client):
        resp = client.post("/api/command/stream", json={"instruction": "go"})
        assert resp.status_code == 503

    def test_streams_ndjson_with_think_stream(self, client, api_mod):
        brain = MagicMock()
        brain.think_stream.return_value = iter(["mov", "ing"])
        brain._clean_json = MagicMock(return_value={"type": "move"})
        api_mod.state.brain = brain

        resp = client.post("/api/command/stream", json={"instruction": "go"})
        assert resp.status_code == 200
        assert "application/x-ndjson" in resp.headers["content-type"]

        import json as _json

        lines = [ln for ln in resp.text.splitlines() if ln.strip()]
        parsed = [_json.loads(ln) for ln in lines]
        # All lines except last have done=False
        assert all(not p["done"] for p in parsed[:-1])
        # Last line has done=True
        assert parsed[-1]["done"] is True
        assert "action" in parsed[-1]

    def test_falls_back_to_think_when_no_think_stream(self, client, api_mod):
        brain = MagicMock(spec=["think", "_clean_json"])
        brain.think.return_value = MagicMock(raw_text="ok", action={"type": "stop"})
        brain._clean_json = MagicMock(return_value={"type": "stop"})
        api_mod.state.brain = brain

        resp = client.post("/api/command/stream", json={"instruction": "halt"})
        assert resp.status_code == 200

        import json as _json

        lines = [ln for ln in resp.text.splitlines() if ln.strip()]
        parsed = [_json.loads(ln) for ln in lines]
        assert len(parsed) >= 2
        assert parsed[-1]["done"] is True


# ---------------------------------------------------------------------------
# GET /api/driver/health  (#69)
# ---------------------------------------------------------------------------


class TestDriverHealth:
    def test_no_driver_returns_503(self, client):
        resp = client.get("/api/driver/health")
        assert resp.status_code == 503

    def test_returns_health_dict_with_driver_type(self, client, api_mod):
        driver = _make_mock_driver()
        driver.health_check = MagicMock(return_value={"ok": True, "mode": "mock", "error": None})
        api_mod.state.driver = driver

        body = client.get("/api/driver/health").json()
        assert body["ok"] is True
        assert body["mode"] == "mock"
        assert "driver_type" in body

    def test_unhealthy_driver_still_returns_200(self, client, api_mod):
        driver = _make_mock_driver()
        driver.health_check = MagicMock(
            return_value={"ok": False, "mode": "hardware", "error": "ping failed"}
        )
        api_mod.state.driver = driver

        body = client.get("/api/driver/health").json()
        assert body["ok"] is False
        assert body["error"] == "ping failed"


# ---------------------------------------------------------------------------
# GET /api/learner/stats  (#70)
# ---------------------------------------------------------------------------


class TestLearnerStats:
    def test_no_learner_returns_available_false(self, client):
        body = client.get("/api/learner/stats").json()
        assert body == {"available": False}

    def test_with_learner_returns_stats(self, client, api_mod):
        from castor.learner.sisyphus import SisyphusStats

        learner = MagicMock()
        stats = SisyphusStats(
            episodes_analyzed=5,
            improvements_applied=3,
            improvements_rejected=1,
            total_duration_ms=500.0,
        )
        learner.stats = stats
        api_mod.state.learner = learner

        body = client.get("/api/learner/stats").json()
        assert body["available"] is True
        assert body["episodes_analyzed"] == 5
        assert body["improvements_applied"] == 3
        assert body["improvements_rejected"] == 1
        assert body["avg_duration_ms"] == pytest.approx(100.0)


# ---------------------------------------------------------------------------
# GET /api/learner/episodes  (#70)
# ---------------------------------------------------------------------------


class TestLearnerEpisodes:
    def test_returns_empty_list_when_no_episodes(self, client, monkeypatch):
        from castor.learner import episode_store as es_mod

        mock_store = MagicMock()
        mock_store.list_recent.return_value = []
        monkeypatch.setattr(es_mod, "EpisodeStore", lambda: mock_store)

        body = client.get("/api/learner/episodes").json()
        assert body["count"] == 0
        assert body["episodes"] == []

    def test_returns_episodes_list(self, client, monkeypatch):
        from castor.learner import episode_store as es_mod
        from castor.learner.episode import Episode

        ep = Episode(goal="navigate", success=True, duration_s=1.5)
        mock_store = MagicMock()
        mock_store.list_recent.return_value = [ep]
        monkeypatch.setattr(es_mod, "EpisodeStore", lambda: mock_store)

        body = client.get("/api/learner/episodes").json()
        assert body["count"] == 1
        assert body["episodes"][0]["goal"] == "navigate"
        assert body["episodes"][0]["success"] is True

    def test_limit_capped_at_100(self, client, monkeypatch):
        from castor.learner import episode_store as es_mod

        mock_store = MagicMock()
        mock_store.list_recent.return_value = []
        monkeypatch.setattr(es_mod, "EpisodeStore", lambda: mock_store)

        client.get("/api/learner/episodes?limit=999")
        mock_store.list_recent.assert_called_once_with(n=100)


# ---------------------------------------------------------------------------
# POST /api/learner/episode  (#74)
# ---------------------------------------------------------------------------


class TestSubmitEpisode:
    def test_missing_goal_returns_422(self, client):
        resp = client.post("/api/learner/episode", json={"success": True})
        assert resp.status_code == 422

    def test_saves_episode_and_returns_id(self, client, monkeypatch):
        from castor.learner import episode_store as es_mod

        mock_store = MagicMock()
        monkeypatch.setattr(es_mod, "EpisodeStore", lambda: mock_store)

        resp = client.post(
            "/api/learner/episode",
            json={"goal": "dock", "success": False, "duration_s": 2.0},
        )
        assert resp.status_code == 200
        body = resp.json()
        assert "episode_id" in body
        assert body["saved"] is True
        mock_store.save.assert_called_once()

    def test_run_improvement_flag_calls_learner(self, client, api_mod, monkeypatch):
        from castor.learner import episode_store as es_mod
        from castor.learner.sisyphus import ImprovementResult

        mock_store = MagicMock()
        monkeypatch.setattr(es_mod, "EpisodeStore", lambda: mock_store)

        learner = MagicMock()
        result = ImprovementResult(episode_id="test-id", applied=False)
        learner.run_episode.return_value = result
        api_mod.state.learner = learner

        resp = client.post(
            "/api/learner/episode?run_improvement=true",
            json={"goal": "spin", "success": True},
        )
        assert resp.status_code == 200
        body = resp.json()
        assert "improvement" in body
        learner.run_episode.assert_called_once()


# ---------------------------------------------------------------------------
# GET /api/command/history  (#75)
# ---------------------------------------------------------------------------


class TestCommandHistory:
    def test_empty_initially(self, client):
        body = client.get("/api/command/history").json()
        assert body["history"] == []
        assert body["count"] == 0

    def test_populated_after_command(self, client, api_mod):
        api_mod.state.brain = _make_mock_brain()
        client.post("/api/command", json={"instruction": "go forward"})

        body = client.get("/api/command/history").json()
        assert body["count"] >= 1
        entry = body["history"][0]
        assert entry["instruction"] == "go forward"
        assert "raw_text" in entry
        assert "action" in entry
        assert "timestamp" in entry

    def test_limit_param(self, client, api_mod):
        api_mod.state.brain = _make_mock_brain()
        for _ in range(5):
            client.post("/api/command", json={"instruction": "test"})

        body = client.get("/api/command/history?limit=3").json()
        assert len(body["history"]) <= 3

    def test_limit_capped_at_50(self, client, api_mod):
        body = client.get("/api/command/history?limit=999").json()
        # Should not raise; count ≤ 50
        assert body["count"] <= 50


# ---------------------------------------------------------------------------
# POST /api/command/stream — rate-limit coverage  (#82)
# ---------------------------------------------------------------------------


class TestStreamCommandRateLimit:
    def test_rate_limit_returns_429_with_think_stream(self, client, api_mod):
        api_mod._COMMAND_RATE_LIMIT = 2
        brain = MagicMock()
        brain.think_stream.return_value = iter(["ok"])
        brain._clean_json = MagicMock(return_value={"type": "stop"})
        api_mod.state.brain = brain
        api_mod._command_history.clear()

        for i in range(3):
            resp = client.post("/api/command/stream", json={"instruction": "cmd"})
            if i < 2:
                assert resp.status_code == 200
            else:
                assert resp.status_code == 429
                body = resp.json()
                # Structured error uses 'error' key (from api_errors.py)
                assert "Rate limit" in body.get("error", body.get("detail", ""))

    def test_rate_limit_returns_429_with_think_fallback(self, client, api_mod):
        api_mod._COMMAND_RATE_LIMIT = 1
        brain = MagicMock(spec=["think", "_clean_json"])
        brain.think.return_value = MagicMock(raw_text="go", action={"type": "move"})
        brain._clean_json = MagicMock(return_value={"type": "move"})
        api_mod.state.brain = brain
        api_mod._command_history.clear()

        resp1 = client.post("/api/command/stream", json={"instruction": "go"})
        assert resp1.status_code == 200

        resp2 = client.post("/api/command/stream", json={"instruction": "go"})
        assert resp2.status_code == 429
        body = resp2.json()
        assert "Rate limit" in body.get("error", body.get("detail", ""))

    def test_rate_limit_clears_after_history_cleared(self, client, api_mod):
        """After clearing the rate-limit history, new requests are accepted."""
        api_mod._COMMAND_RATE_LIMIT = 1
        api_mod.state.brain = _make_mock_brain()
        api_mod._command_history.clear()

        resp1 = client.post("/api/command/stream", json={"instruction": "go"})
        assert resp1.status_code == 200

        resp2 = client.post("/api/command/stream", json={"instruction": "go"})
        assert resp2.status_code == 429

        # Manually clear history (simulates the sliding window expiring)
        api_mod._command_history.clear()

        resp3 = client.post("/api/command/stream", json={"instruction": "go"})
        assert resp3.status_code == 200


# ---------------------------------------------------------------------------
# GET /api/status — offline_fallback field  (#77)
# ---------------------------------------------------------------------------


class TestStatusOfflineFallback:
    def test_status_returns_offline_fallback_disabled_when_none(self, client):
        body = client.get("/api/status").json()
        assert "offline_fallback" in body
        assert body["offline_fallback"]["enabled"] is False

    def test_status_returns_offline_fallback_when_enabled(self, client, api_mod):
        fb = MagicMock()
        fb.is_using_fallback = False
        fb.fallback_ready = True
        fb._config = {"provider": "ollama", "model": "llama3.2:3b"}
        api_mod.state.offline_fallback = fb

        body = client.get("/api/status").json()
        assert body["offline_fallback"]["enabled"] is True
        assert body["offline_fallback"]["using_fallback"] is False
        assert body["offline_fallback"]["fallback_ready"] is True
        assert body["offline_fallback"]["fallback_provider"] == "ollama"
        assert body["offline_fallback"]["fallback_model"] == "llama3.2:3b"

    def test_status_reports_when_using_fallback(self, client, api_mod):
        fb = MagicMock()
        fb.is_using_fallback = True
        fb.fallback_ready = True
        fb._config = {"provider": "llamacpp", "model": "phi-3"}
        api_mod.state.offline_fallback = fb

        body = client.get("/api/status").json()
        assert body["offline_fallback"]["using_fallback"] is True
        assert body["offline_fallback"]["fallback_provider"] == "llamacpp"


# ---------------------------------------------------------------------------
# GET /api/guardian/report  (#81)
# ---------------------------------------------------------------------------


class TestGuardianReport:
    def test_returns_available_false_when_no_fs(self, client):
        body = client.get("/api/guardian/report").json()
        assert body["available"] is False

    def test_returns_available_false_when_fs_has_no_shared_state(self, client, api_mod):
        fs = _make_mock_fs()
        # Explicitly delete the auto-created MagicMock attribute so hasattr() returns False
        del fs._shared_state
        api_mod.state.fs = fs
        body = client.get("/api/guardian/report").json()
        assert body["available"] is False

    def test_returns_report_when_shared_state_has_guardian_data(self, client, api_mod):
        from castor.agents.shared_state import SharedState

        shared = SharedState()
        report = {"estop_active": False, "vetoes": [], "approved": ["move"]}
        shared.set("swarm.guardian_report", report)

        fs = _make_mock_fs()
        fs._shared_state = shared
        api_mod.state.fs = fs

        body = client.get("/api/guardian/report").json()
        assert body["available"] is True
        assert body["report"]["estop_active"] is False
        assert body["report"]["approved"] == ["move"]


# ---------------------------------------------------------------------------
# Audio transcription endpoint (#89)
# ---------------------------------------------------------------------------


class TestAudioTranscribe:
    def test_returns_text_when_transcription_succeeds(self, client):
        import castor.voice as voice_mod

        with patch.object(voice_mod, "transcribe_bytes", return_value="turn left"):
            with patch.object(voice_mod, "available_engines", return_value=["google"]):
                resp = client.post(
                    "/api/audio/transcribe",
                    files={"file": ("test.ogg", b"\x00" * 512, "audio/ogg")},
                )
        assert resp.status_code == 200
        body = resp.json()
        assert body["text"] == "turn left"
        assert "engine" in body
        assert "duration_ms" in body

    def test_returns_503_when_no_engines_available(self, client):
        import castor.voice as voice_mod

        with patch.object(voice_mod, "available_engines", return_value=[]):
            resp = client.post(
                "/api/audio/transcribe",
                files={"file": ("test.ogg", b"\x00" * 512, "audio/ogg")},
                params={"engine": "auto"},
            )
        assert resp.status_code == 503

    def test_returns_503_when_transcription_returns_none(self, client):
        import castor.voice as voice_mod

        with patch.object(voice_mod, "transcribe_bytes", return_value=None):
            with patch.object(voice_mod, "available_engines", return_value=["google"]):
                resp = client.post(
                    "/api/audio/transcribe",
                    files={"file": ("test.wav", b"\x00" * 512, "audio/wav")},
                )
        assert resp.status_code == 503

    def test_returns_422_for_empty_file(self, client):
        import castor.voice as voice_mod

        with patch.object(voice_mod, "available_engines", return_value=["google"]):
            resp = client.post(
                "/api/audio/transcribe",
                files={"file": ("empty.ogg", b"", "audio/ogg")},
            )
        assert resp.status_code == 422

    def test_engine_param_accepted(self, client):
        import castor.voice as voice_mod

        with patch.object(voice_mod, "transcribe_bytes", return_value="ok") as mock_fn:
            with patch.object(voice_mod, "available_engines", return_value=["google"]):
                resp = client.post(
                    "/api/audio/transcribe",
                    files={"file": ("audio.mp3", b"\x00" * 512, "audio/mp3")},
                    params={"engine": "google"},
                )
        assert resp.status_code == 200
        call_kwargs = mock_fn.call_args
        assert "google" in str(call_kwargs)


class TestIntentEndpoints:
    def test_list_intents_disabled_when_no_orchestrator(self, client, api_mod):
        api_mod.state.brain = object()
        resp = client.get("/api/intents")
        assert resp.status_code == 200
        assert resp.json()["enabled"] is False

    def test_create_and_reprioritize_intent(self, client, api_mod):
        from castor.agents.orchestrator import OrchestratorAgent
        from castor.agents.shared_state import SharedState

        brain = _make_mock_brain()
        brain.orchestrator = OrchestratorAgent(config={}, shared_state=SharedState())
        api_mod.state.brain = brain

        created = client.post(
            "/api/intents", json={"goal": "dock robot", "priority": 1, "owner": "api"}
        )
        assert created.status_code == 200
        iid = created.json()["intent"]["intent_id"]

        reprio = client.post("/api/intents/reprioritize", json={"intent_id": iid, "priority": 9})
        assert reprio.status_code == 200

        listed = client.get("/api/intents")
        assert listed.status_code == 200
        assert listed.json()["intents"][0]["intent_id"] == iid
        assert listed.json()["intents"][0]["priority"] == 9

    def test_pause_intent_not_found(self, client, api_mod):
        from castor.agents.orchestrator import OrchestratorAgent
        from castor.agents.shared_state import SharedState

        brain = _make_mock_brain()
        brain.orchestrator = OrchestratorAgent(config={}, shared_state=SharedState())
        api_mod.state.brain = brain

        resp = client.post("/api/intents/pause", json={"intent_id": "missing", "paused": True})
        assert resp.status_code == 404


class TestSetupV2Endpoints:
    def test_setup_catalog_endpoint(self, client):
        resp = client.get("/setup/api/catalog")
        assert resp.status_code == 200
        payload = resp.json()
        assert "providers" in payload
        assert "stack_profiles" in payload
        assert any(p["key"] == "apple" for p in payload["providers"])

    def test_setup_preflight_non_apple(self, client):
        resp = client.post(
            "/setup/api/preflight",
            json={"provider": "ollama", "model_profile": "llava:13b"},
        )
        assert resp.status_code == 200
        payload = resp.json()
        assert payload["ok"] is True
        assert payload["provider"] == "ollama"

    def test_setup_generate_config(self, client, tmp_path, monkeypatch):
        monkeypatch.chdir(tmp_path)
        resp = client.post(
            "/setup/api/generate-config",
            json={
                "robot_name": "AppleBot",
                "provider": "apple",
                "model": "apple-balanced",
                "preset": "rpi_rc_car",
            },
        )
        assert resp.status_code == 200
        payload = resp.json()
        assert payload["ok"] is True
        assert payload["filename"].endswith(".rcan.yaml")
        assert (tmp_path / payload["filename"]).exists()

    def test_setup_session_lifecycle(self, client):
        start = client.post("/setup/api/session/start", json={"robot_name": "ResumeBot"})
        assert start.status_code == 200
        session = start.json()
        sid = session["session_id"]
        assert session["stage"] == "probe"

        fetched = client.get(f"/setup/api/session/{sid}")
        assert fetched.status_code == 200
        assert fetched.json()["session_id"] == sid

        selected = client.post(
            f"/setup/api/session/{sid}/select",
            json={"stage": "stack", "values": {"stack_id": "ollama_universal_local"}},
        )
        assert selected.status_code == 200
        assert selected.json()["stage"] == "stack"
        assert selected.json()["selections"]["stack_id"] == "ollama_universal_local"

        resumed = client.post(f"/setup/api/session/{sid}/resume")
        assert resumed.status_code == 200
        assert resumed.json()["session_id"] == sid

    def test_setup_preflight_includes_typed_checks(self, client):
        resp = client.post(
            "/setup/api/preflight",
            json={
                "provider": "ollama",
                "stack_id": "ollama_universal_local",
                "model_profile": "llava:13b",
            },
        )
        assert resp.status_code == 200
        payload = resp.json()
        assert "checks" in payload
        assert isinstance(payload["checks"], list)
        assert payload["checks"]
        first = payload["checks"][0]
        for key in ("id", "category", "severity", "ok", "reason_code", "evidence", "retryable"):
            assert key in first

    def test_setup_remediate_requires_consent_for_command_actions(self, client):
        resp = client.post(
            "/setup/api/remediate",
            json={"remediation_id": "install_apple_sdk", "consent": False},
        )
        assert resp.status_code == 200
        payload = resp.json()
        assert payload["ok"] is False
        assert payload["requires_consent"] is True

    def test_setup_verify_config_endpoint(self, client):
        with patch(
            "castor.api.verify_setup_config",
            return_value={"ok": True, "blocking_errors": [], "warnings": [], "checks": []},
        ):
            resp = client.post(
                "/setup/api/verify-config",
                json={
                    "robot_name": "VerifyBot",
                    "provider": "ollama",
                    "model": "llava:13b",
                    "preset": "rpi_rc_car",
                    "stack_id": "ollama_universal_local",
                },
            )
        assert resp.status_code == 200
        assert resp.json()["ok"] is True

    def test_setup_metrics_endpoint(self, client):
        resp = client.get("/setup/api/metrics")
        assert resp.status_code == 200
        payload = resp.json()
        assert "total_runs" in payload
        assert "first_run_success_rate" in payload
