"""End-to-end integration tests for the coordinator workstream feature.

Tests cover the full create → inspect → list → close lifecycle using
real in-process components:

1. Create + list + detail round-trip via the Starlette TestClient.
2. CoordinatorClient against a MockTransport "server node" stub.
3. list_children storage read flow (kind filtering, parent scoping).
4. Lazy rehydration via GET /v1/api/workstreams/{ws_id}.

Intentionally no real LLM infrastructure — session factories return
MagicMock-backed stubs.  All four tests run in < 2 s total.
"""

from __future__ import annotations

import json
from typing import Any
from unittest.mock import MagicMock

import httpx
import pytest
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.routing import Route
from starlette.testclient import TestClient

from turnstone.console.collector import ClusterCollector
from turnstone.console.coordinator_adapter import CoordinatorAdapter
from turnstone.console.coordinator_client import CoordinatorClient
from turnstone.console.coordinator_ui import ConsoleCoordinatorUI
from turnstone.console.server import (
    _audit_close_coordinator,
    _audit_coordinator_create,
    _coord_create_build_kwargs,
    _coord_create_post_install,
    _coord_create_validate_request,
    _require_admin_coordinator,
    _require_coord_mgr,
)
from turnstone.core.auth import AuthResult
from turnstone.core.session_manager import SessionManager
from turnstone.core.session_routes import (
    SessionEndpointConfig,
    make_close_handler,
    make_create_handler,
    make_detail_handler,
    make_list_handler,
)
from turnstone.core.storage._sqlite import SQLiteBackend

# Per-kind config the lifted handler factories capture by closure.
_coord_endpoint_config = SessionEndpointConfig(
    permission_gate=_require_admin_coordinator,
    manager_lookup=_require_coord_mgr,
    tenant_check=None,
    not_found_label="coordinator not found",
    audit_action_prefix="coordinator",
    create_supports_attachments=True,
    create_supports_user_id_override=False,
    create_validate_request=_coord_create_validate_request,
    create_build_kwargs=_coord_create_build_kwargs,
    create_post_install=_coord_create_post_install,
)


# ---------------------------------------------------------------------------
# Shared auth-injection middleware (mirrors test_coordinator_endpoints.py)
# ---------------------------------------------------------------------------


class _AuthMiddleware(BaseHTTPMiddleware):
    """Inject an ``AuthResult`` from ``X-Test-Perms`` / ``X-Test-User``."""

    async def dispatch(self, request, call_next):
        perms = request.headers.get("X-Test-Perms", "")
        user_id = request.headers.get("X-Test-User", "")
        if perms or user_id:
            request.state.auth_result = AuthResult(
                user_id=user_id,
                scopes=frozenset({"approve"}),
                token_source="test",
                permissions=frozenset(p for p in perms.split(",") if p),
            )
        return await call_next(request)


# ---------------------------------------------------------------------------
# Shared stubs
# ---------------------------------------------------------------------------


class _FakeConfigStore:
    """Minimal ConfigStore stub returning values from a dict."""

    def __init__(self, values: dict[str, Any]) -> None:
        self._values = values

    def get(self, key: str, default: Any = None) -> Any:
        return self._values.get(key, default)


def _fake_registry() -> MagicMock:
    """Registry stub that always succeeds on .resolve() so the 503 gate passes."""
    reg = MagicMock()
    reg.resolve.return_value = (MagicMock(), "gpt-test", MagicMock())
    return reg


def _build_mgr(storage: SQLiteBackend) -> SessionManager:
    """Build a SessionManager(CoordinatorAdapter) backed by stub factories."""

    def _sf(ui, model_alias=None, ws_id=None, **kw):
        s = MagicMock()
        s.ws_id = ws_id
        s.send.return_value = None
        return s

    adapter = CoordinatorAdapter(
        collector=MagicMock(),
        ui_factory=lambda ws: ConsoleCoordinatorUI(ws_id=ws.id, user_id=ws.user_id or ""),
        session_factory=_sf,
    )
    mgr = SessionManager(
        adapter,
        storage=storage,
        max_active=5,
        node_id=ClusterCollector.CONSOLE_PSEUDO_NODE_ID,
        event_emitter=adapter,
    )
    adapter.attach(mgr)
    return mgr


def _make_client(
    storage: SQLiteBackend,
    *,
    coord_mgr: SessionManager | None = None,
    alias: str = "my-model",
    registry: Any = None,
) -> TestClient:
    """Build a Starlette TestClient exposing the coordinator routes."""
    app = Starlette(
        routes=[
            Route(
                "/v1/api/workstreams/new",
                make_create_handler(_coord_endpoint_config, audit_emit=_audit_coordinator_create),
                methods=["POST"],
            ),
            Route(
                "/v1/api/workstreams",
                make_list_handler(_coord_endpoint_config),
                methods=["GET"],
            ),
            Route(
                "/v1/api/workstreams/{ws_id}/close",
                make_close_handler(
                    _coord_endpoint_config,
                    audit_emit=_audit_close_coordinator,
                    supports_close_reason=False,
                ),
                methods=["POST"],
            ),
            Route(
                "/v1/api/workstreams/{ws_id}",
                make_detail_handler(_coord_endpoint_config),
                methods=["GET"],
            ),
        ],
        middleware=[Middleware(_AuthMiddleware)],
    )
    app.state.coord_mgr = coord_mgr
    app.state.coord_adapter = coord_mgr._adapter if coord_mgr is not None else None
    app.state.config_store = _FakeConfigStore({"coordinator.model_alias": alias})
    app.state.coord_registry = registry
    app.state.coord_registry_error = "" if coord_mgr else "registry missing"
    app.state.auth_storage = storage
    app.state.jwt_secret = "x" * 64
    return TestClient(app)


# ---------------------------------------------------------------------------
# Test 1 — Create + list + detail round-trip
# ---------------------------------------------------------------------------

_COORD_HEADERS = {"X-Test-User": "user-1", "X-Test-Perms": "admin.coordinator"}


def test_create_list_detail_lifecycle(tmp_path):
    """POST /new → appears in GET / → GET /{ws_id} returns correct detail."""
    storage = SQLiteBackend(str(tmp_path / "coord.db"))
    mgr = _build_mgr(storage)
    client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())

    # --- Create ---
    resp = client.post(
        "/v1/api/workstreams/new",
        json={"name": "e2e-coord"},
        headers=_COORD_HEADERS,
    )
    assert resp.status_code == 200, resp.text
    body = resp.json()
    ws_id = body["ws_id"]
    assert ws_id
    assert "e2e-coord" in body["name"]

    # --- List: caller sees their own coordinator ---
    resp = client.get("/v1/api/workstreams", headers=_COORD_HEADERS)
    assert resp.status_code == 200, resp.text
    coordinators = resp.json()["workstreams"]
    ids = {c["ws_id"] for c in coordinators}
    assert ws_id in ids

    # Trusted-team visibility: every ``admin.coordinator`` caller sees
    # every active coordinator regardless of owner.
    mgr.create(user_id="other-user", name="not-mine")
    resp = client.get("/v1/api/workstreams", headers=_COORD_HEADERS)
    assert resp.status_code == 200
    names = {c["name"] for c in resp.json()["workstreams"]}
    assert "not-mine" in names

    # --- Detail ---
    resp = client.get(f"/v1/api/workstreams/{ws_id}", headers=_COORD_HEADERS)
    assert resp.status_code == 200, resp.text
    detail = resp.json()
    assert detail["ws_id"] == ws_id
    assert detail["kind"] == "coordinator"
    assert detail["user_id"] == "user-1"

    # --- Close ---
    resp = client.post(f"/v1/api/workstreams/{ws_id}/close", headers=_COORD_HEADERS)
    assert resp.status_code == 200

    # Manager no longer tracks it after close.
    assert mgr.get(ws_id) is None

    # Storage row reflects closed state.
    row = storage.get_workstream(ws_id)
    assert row is not None
    assert row["state"] == "closed"

    # Detail endpoint returns 404 after close (not in memory, not rehydratable
    # from a "closed" row — well, the manager would rehydrate it but let's verify
    # the row is gone from the in-memory index).
    assert mgr.get(ws_id) is None


# ---------------------------------------------------------------------------
# Test 2 — CoordinatorClient against a MockTransport "server node" stub
# ---------------------------------------------------------------------------


def test_coordinator_client_spawn_close_delete(tmp_path):
    """CoordinatorClient.spawn / close_workstream / delete produce correct
    upstream HTTP requests to the mocked server node."""
    storage = SQLiteBackend(str(tmp_path / "client.db"))
    # Register the coordinator + the soon-to-be-spawned child so the
    # client-side tenant guard on close/delete passes.  In production
    # the spawn route adds the child row before the model can call
    # close on it; the test stub doesn't run that side-effect, so we
    # set it up here.
    storage.register_workstream("coord-42", kind="coordinator", user_id="user-1")
    storage.register_workstream(
        "child-99", kind="interactive", parent_ws_id="coord-42", user_id="user-1"
    )
    captured: list[httpx.Request] = []

    def _handler(req: httpx.Request) -> httpx.Response:
        captured.append(req)
        path = req.url.path
        if path == "/v1/api/route/workstreams/new":
            return httpx.Response(
                201,
                json={"ws_id": "child-99", "name": "spawned", "node_id": "node-a"},
            )
        # close and delete both return a generic ok
        return httpx.Response(200, json={"status": "ok"})

    transport = httpx.MockTransport(_handler)
    http = httpx.Client(transport=transport)
    coord_client = CoordinatorClient(
        console_base_url="http://console",
        storage=storage,
        token_factory=lambda: "bearer-test-token",
        coord_ws_id="coord-42",
        user_id="user-1",
        http_client=http,
    )

    # spawn ---------------------------------------------------------------
    result = coord_client.spawn(
        initial_message="analyse data",
        parent_ws_id="coord-42",
        user_id="user-1",
        skill="data-skill",
        target_node="node-a",
    )
    assert result["ws_id"] == "child-99"

    spawn_req = captured[0]
    assert spawn_req.method == "POST"
    assert spawn_req.url.path == "/v1/api/route/workstreams/new"
    assert spawn_req.headers["Authorization"] == "Bearer bearer-test-token"

    spawn_body = json.loads(spawn_req.content)
    assert spawn_body["kind"] == "interactive"
    assert spawn_body["parent_ws_id"] == "coord-42"
    assert spawn_body["user_id"] == "user-1"
    assert spawn_body["initial_message"] == "analyse data"
    assert spawn_body["skill"] == "data-skill"
    assert spawn_body["target_node"] == "node-a"

    # close_workstream ----------------------------------------------------
    captured.clear()
    close_result = coord_client.close_workstream("child-99")
    assert close_result.get("status") in (200, "ok"), close_result

    close_req = captured[0]
    # Path-keyed shape post-#422: ws_id rides in the URL.
    assert close_req.url.path == "/v1/api/route/workstreams/child-99/close"
    close_body = json.loads(close_req.content)
    # Body no longer carries ws_id — the path is authoritative.
    assert "ws_id" not in close_body

    # delete --------------------------------------------------------------
    captured.clear()
    del_result = coord_client.delete("child-99")
    assert del_result.get("status") in (200, "ok"), del_result

    del_req = captured[0]
    assert del_req.url.path == "/v1/api/route/workstreams/delete"
    del_body = json.loads(del_req.content)
    assert del_body["ws_id"] == "child-99"


# ---------------------------------------------------------------------------
# Test 3 — list_children storage read: kind filtering + parent scoping
# ---------------------------------------------------------------------------


@pytest.fixture()
def seeded_storage(tmp_path):
    """SQLiteBackend with a coordinator + 2 interactive children + extras."""
    st = SQLiteBackend(str(tmp_path / "seed.db"))
    # Parent coordinator.
    st.register_workstream("coord-root", kind="coordinator", user_id="user-1")
    # Two interactive children — one idle, one running.  Children inherit
    # the coord's user_id by construction (server-side create gate), which
    # the list_children SQL filter now enforces.
    st.register_workstream(
        "child-idle",
        kind="interactive",
        parent_ws_id="coord-root",
        state="idle",
        skill_id="skill-alpha",
        user_id="user-1",
    )
    st.register_workstream(
        "child-running",
        kind="interactive",
        parent_ws_id="coord-root",
        state="running",
        skill_id="skill-beta",
        user_id="user-1",
    )
    # Coordinator child — MUST be excluded from list_children results.
    st.register_workstream(
        "child-coord",
        kind="coordinator",
        parent_ws_id="coord-root",
        user_id="user-1",
    )
    # Unrelated workstream with no parent — MUST be excluded.
    st.register_workstream("unrelated-ws", kind="interactive", user_id="user-1")
    return st


def _read_client(storage: SQLiteBackend) -> CoordinatorClient:
    """Build a CoordinatorClient whose HTTP transport is a no-op stub."""
    transport = httpx.MockTransport(lambda r: httpx.Response(200))
    http = httpx.Client(transport=transport)
    return CoordinatorClient(
        console_base_url="http://x",
        storage=storage,
        token_factory=lambda: "t",
        coord_ws_id="coord-root",
        user_id="user-1",
        http_client=http,
    )


def test_list_children_excludes_coordinator_and_unrelated_rows(seeded_storage):
    """list_children returns only interactive children of the given parent."""
    client = _read_client(seeded_storage)
    result = client.list_children("coord-root")
    rows = result["children"]

    ws_ids = {r["ws_id"] for r in rows}
    # The two interactive children are present.
    assert ws_ids == {"child-idle", "child-running"}
    # Every returned row must be interactive and linked to coord-root.
    for r in rows:
        assert r["kind"] == "interactive"
        assert r["parent_ws_id"] == "coord-root"

    # Coordinator child and unrelated ws are absent.
    assert "child-coord" not in ws_ids
    assert "unrelated-ws" not in ws_ids
    assert result["truncated"] is False


def test_list_children_state_filter(seeded_storage):
    """list_children(state='running') filters to only running children."""
    client = _read_client(seeded_storage)
    result = client.list_children("coord-root", state="running")
    assert {r["ws_id"] for r in result["children"]} == {"child-running"}


def test_list_children_skill_filter(seeded_storage):
    """list_children(skill='skill-alpha') returns the matching child only."""
    client = _read_client(seeded_storage)
    result = client.list_children("coord-root", skill="skill-alpha")
    rows = result["children"]
    assert {r["ws_id"] for r in rows} == {"child-idle"}
    assert rows[0].get("skill_id") == "skill-alpha"


# ---------------------------------------------------------------------------
# Test 4 — Lazy rehydration via GET /v1/api/workstreams/{ws_id}
# ---------------------------------------------------------------------------


def test_lazy_rehydration_on_detail_get(tmp_path):
    """A persisted coordinator row rehydrates into the manager on GET /{ws_id}.

    Sequence:
    1. Pre-seed storage with a coordinator row (simulating a previous process).
    2. Build a SessionManager (coordinator kind) that doesn't know about it yet.
    3. Hit GET /v1/api/workstreams/{ws_id} — expect 200.
    4. Manager now tracks the rehydrated session.
    5. The response body carries the correct kind / user_id metadata.
    """
    storage = SQLiteBackend(str(tmp_path / "rehydrate.db"))

    # Seed the row directly — the manager has never seen it.
    storage.register_workstream(
        "persisted-coord",
        node_id="console",
        user_id="user-1",
        name="old-coord",
        kind="coordinator",
    )

    mgr = _build_mgr(storage)
    # Confirm: not tracked in memory yet.
    assert mgr.get("persisted-coord") is None

    client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry())
    resp = client.get("/v1/api/workstreams/persisted-coord", headers=_COORD_HEADERS)
    assert resp.status_code == 200, resp.text

    body = resp.json()
    assert body["ws_id"] == "persisted-coord"
    assert body["kind"] == "coordinator"
    assert body["user_id"] == "user-1"

    # The endpoint triggers lazy rehydration — manager now tracks it.
    assert mgr.get("persisted-coord") is not None

    # Trusted-team visibility: any admin.coordinator caller can read
    # the coordinator's detail, regardless of ``user_id``.
    resp_stranger = client.get(
        "/v1/api/workstreams/persisted-coord",
        headers={"X-Test-User": "stranger", "X-Test-Perms": "admin.coordinator"},
    )
    assert resp_stranger.status_code == 200
    assert resp_stranger.json()["user_id"] == "user-1"

    # A workstream with kind='interactive' is not reachable via the coordinator
    # endpoint even when it exists in storage.
    storage.register_workstream("interactive-ws", kind="interactive", user_id="user-1")
    resp_int = client.get("/v1/api/workstreams/interactive-ws", headers=_COORD_HEADERS)
    assert resp_int.status_code == 404
