from __future__ import annotations

import os
import shutil
import time
from pathlib import Path

import pytest

from bootstrap.config_loader import load_codex_config
from bootstrap.orchestrator import BootstrapConfig, BootstrapOrchestrator
from bootstrap.payload import BootstrapPayload
from bootstrap.app_server import AppServerStdioClient


@pytest.mark.skipif(
    os.environ.get("RUN_REAL_CODEX_APP_SERVER_TESTS") != "1",
    reason="Set RUN_REAL_CODEX_APP_SERVER_TESTS=1 to run real Codex app-server integration tests.",
)
def test_real_codex_app_server_smoke(tmp_path: Path) -> None:
    """Local-only smoke test against a real `codex app-server`.

    This is intentionally skipped in CI:
    - it requires a local Codex binary
    - it may require auth / an API key
    - it can incur token costs
    """

    codex_bin = os.environ.get("CODEX_BIN") or shutil.which("codex")
    if not codex_bin:
        pytest.skip("Missing CODEX_BIN and codex is not on PATH")

    if not os.environ.get("OPENAI_API_KEY"):
        pytest.skip("Missing OPENAI_API_KEY")

    notes: list[dict[str, object]] = []

    def on_notification(msg: dict[str, object]) -> None:
        notes.append(dict(msg))

    client = AppServerStdioClient(
        argv=[str(codex_bin), "app-server"],
        env=os.environ.copy(),
        cwd=tmp_path,
        on_notification=on_notification,
    )
    try:
        _init = client.initialize(
            client_info={"name": "flywheel-local-test", "version": "0.0"},
        )
        thread_result = client.request(
            method="thread/start",
            params={"cwd": str(tmp_path), "experimentalRawEvents": False},
            timeout_seconds=60,
        )
        thread = thread_result.get("thread")
        thread_id = thread.get("id") if isinstance(thread, dict) else None
        if not isinstance(thread_id, str) or not thread_id:
            raise AssertionError(f"thread/start missing thread id: {thread_result!r}")

        result = client.request(
            method="turn/start",
            params={
                "threadId": thread_id,
                "input": [{"type": "text", "text": "Reply with exactly: ok"}],
            },
            timeout_seconds=60,
        )
        turn = result.get("turn")
        turn_id = turn.get("id") if isinstance(turn, dict) else None
        if not isinstance(turn_id, str) or not turn_id:
            raise AssertionError(f"turn/start missing turn id: {result!r}")

        deadline = time.time() + 120
        saw_completed = False
        while time.time() < deadline:
            for note in notes:
                if note.get("method") == "turn/completed":
                    params = note.get("params")
                    if isinstance(params, dict):
                        raw_turn_id = params.get("turnId") or (
                            params.get("turn", {}).get("id")
                            if isinstance(params.get("turn"), dict)
                            else None
                        )
                        if raw_turn_id == turn_id or raw_turn_id is None:
                            saw_completed = True
                            break
            if saw_completed:
                break
            time.sleep(0.05)

        assert saw_completed, "did not observe turn/completed notification"

        # We expect some kind of agent output deltas. Exact sequencing/semantics
        # can change between Codex versions, so keep this assertion minimal.
        saw_delta = any(
            isinstance(note, dict)
            and note.get("method")
            in {
                "item/agentMessage/delta",
                "item/commandExecution/outputDelta",
                "item/fileChange/outputDelta",
            }
            for note in notes
        )
        assert saw_delta, "did not observe any delta notifications"
    finally:
        client.close()


@pytest.mark.skipif(
    os.environ.get("RUN_REAL_CODEX_APP_SERVER_TESTS") != "1",
    reason="Set RUN_REAL_CODEX_APP_SERVER_TESTS=1 to run real Codex app-server integration tests.",
)
def test_real_codex_app_server_orchestrator_delta_projection(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """Local-only test: run our orchestrator against real `codex app-server`.

    This validates the wiring we actually depend on:
    - app-server delta notifications are normalized + batched before being forwarded
    - forwarded deltas reconstruct a deterministic long agent message without duplication
    - token usage updates are observed and emitted for billing

    Notes:
    - This test is skipped in CI and may incur token costs.
    - It does not require the backend server; we monkeypatch telemetry to capture payloads.
    """

    codex_bin = os.environ.get("CODEX_BIN") or shutil.which("codex")
    if not codex_bin:
        pytest.skip("Missing CODEX_BIN and codex is not on PATH")

    if not os.environ.get("OPENAI_API_KEY"):
        pytest.skip("Missing OPENAI_API_KEY")

    # Minimal config needed by bootstrap to build the full prompt text.
    config_path = tmp_path / "config.toml"
    config_path.write_text(
        "\n".join(
            [
                "[flywheel]",
                'workspace_instructions = "You are the engineer. Follow the user prompt precisely."',
                "",
            ]
        ),
        encoding="utf-8",
    )

    config = BootstrapConfig(
        run_id="local-real-codex-test",
        capability_token="local",
        config_path=config_path,
        server_url="http://example.invalid",
        run_root=tmp_path,
    )
    orch = BootstrapOrchestrator(config=config)
    orch.workspace = tmp_path
    orch.user_config = load_codex_config(config_path)
    orch.bootstrap_payload = BootstrapPayload(
        prompt=(
            "Write exactly 5 sentences. "
            "The first sentence must be exactly: Inspecting the workspace and environment. "
            "Do not use bullet points. Do not include code."
        )
    )
    orch.codex_executable = Path(codex_bin)

    posted_app_server_events: list[dict[str, object]] = []
    posted_logs: list[dict[str, object]] = []

    def _capture_app_server_event(
        _server_url: str,
        _run_id: str,
        _token: str,
        *,
        method: str,
        params: dict[str, object] | None = None,
    ) -> None:
        posted_app_server_events.append(
            {"method": method, "params": dict(params or {})}
        )

    def _capture_log(
        _server_url: str,
        _run_id: str,
        _token: str,
        level: str,
        message: str,
        extra: dict[str, object] | None = None,
    ) -> None:
        posted_logs.append(
            {"level": level, "message": message, "extra": dict(extra or {})}
        )

    # Avoid any network calls; capture what would be sent.
    import bootstrap.orchestrator as orchestrator_module

    monkeypatch.setattr(
        orchestrator_module, "post_app_server_event", _capture_app_server_event
    )
    monkeypatch.setattr(orchestrator_module, "post_log", _capture_log)
    monkeypatch.setattr(
        orchestrator_module, "post_heartbeat", lambda *_args, **_kwargs: None
    )
    monkeypatch.setattr(
        orchestrator_module, "post_completion", lambda *_args, **_kwargs: None
    )
    monkeypatch.setattr(
        orchestrator_module, "post_error", lambda *_args, **_kwargs: None
    )
    monkeypatch.setattr(
        orchestrator_module, "post_artifacts", lambda *_args, **_kwargs: None
    )

    # Keep the control loop alive but inert.
    monkeypatch.setattr(
        orchestrator_module,
        "poll_run_control",
        lambda *_args, **_kwargs: {"status": "running"},
    )
    monkeypatch.setattr(
        orchestrator_module, "ack_run_control", lambda *_args, **_kwargs: None
    )

    try:
        exit_code = orch._launch_engineer_and_stream()
        assert exit_code == 0
    finally:
        # Mirror BootstrapOrchestrator.run() teardown to avoid leaking threads/processes.
        orch._flush_delta_buffers()
        orch._cancel_active_prompt()
        orch._shutdown_app_server()
        orch._stop_heartbeats.set()
        if orch.heartbeat_thread and orch.heartbeat_thread.is_alive():
            orch.heartbeat_thread.join(timeout=2)
        orch._stop_control.set()
        if orch.control_thread and orch.control_thread.is_alive():
            orch.control_thread.join(timeout=2)
        with orch._interactive_input_lock:
            thread = orch._interactive_input_thread
        if thread and thread.is_alive():
            thread.join(timeout=2)

    # Ensure we never forward ignored delta streams.
    ignored_methods = {
        "item/plan/delta",
        "item/reasoning/summaryTextDelta",
        "item/reasoning/textDelta",
    }
    assert all(e.get("method") not in ignored_methods for e in posted_app_server_events)

    # Billing: we should emit usage totals at least once for the completed turn.
    saw_usage = False
    for log in posted_logs:
        extra = log.get("extra")
        if not isinstance(extra, dict):
            continue
        if extra.get("run_event_kind") != "usage":
            continue
        event = extra.get("event")
        if isinstance(event, dict) and event.get("type") == "turn.completed":
            saw_usage = True
            break
    assert saw_usage, "did not observe emitted Codex usage totals (turn.completed)"

    # Agent message deltas should be forwarded and free of overlap-duplication artifacts.
    agent_deltas: list[str] = []
    for ev in posted_app_server_events:
        if ev.get("method") != "item/agentMessage/delta":
            continue
        params = ev.get("params")
        if not isinstance(params, dict):
            continue
        delta = params.get("delta")
        if isinstance(delta, str) and delta:
            agent_deltas.append(delta)
    agent_text = "".join(agent_deltas)
    assert agent_deltas, "did not observe any forwarded agentMessage deltas"
    assert agent_text.startswith("Inspecting the workspace and environment.")
    # This is the exact class of corruption reported in the UI.
    assert "InspectInspect" not in agent_text
    assert " the the " not in agent_text

    # If we were accidentally forwarding per-token/per-char deltas, this would be huge.
    assert len(agent_deltas) <= 400
