# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc.
# SPDX-License-Identifier: MIT

"""Integration test: Phoenix OTEL span export (OMN-2734).

Verifies that manifest injection spans are successfully exported to Phoenix
via OTLP HTTP. The test uses an in-process OTLP server to capture spans
without requiring a live Phoenix instance.

Test ID (matches DoD):
    uv run pytest tests/integration/ -k phoenix_otel_export

For CI: this test runs with a mock OTLP server (no Docker required).
For live Phoenix validation: set PHOENIX_INTEGRATION=1 and ensure
Phoenix is running at PHOENIX_OTEL_ENDPOINT (default: http://localhost:6006/v1/traces).
"""

from __future__ import annotations

import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from typing import Any
from unittest.mock import patch

import pytest

# ---------------------------------------------------------------------------
# sys.path setup
# ---------------------------------------------------------------------------
_HOOKS_LIB = str(Path(__file__).parents[2] / "plugins" / "onex" / "hooks" / "lib")
if _HOOKS_LIB not in sys.path:
    sys.path.insert(0, _HOOKS_LIB)

# ---------------------------------------------------------------------------
# OTEL SDK availability check
# ---------------------------------------------------------------------------
try:
    from opentelemetry.sdk.trace import TracerProvider  # noqa: F401

    _OTEL_SDK_AVAILABLE = True
except ImportError:
    _OTEL_SDK_AVAILABLE = False

pytestmark = pytest.mark.skipif(
    not _OTEL_SDK_AVAILABLE,
    reason="opentelemetry SDK not installed; install with: uv pip install opentelemetry-sdk",
)


# ---------------------------------------------------------------------------
# Minimal in-process OTLP HTTP server
# ---------------------------------------------------------------------------


class _RecordingHandler(BaseHTTPRequestHandler):
    """Records all POST /v1/traces requests."""

    def log_message(self, fmt: str, *args: Any) -> None:
        pass  # Suppress access log noise

    def do_POST(self) -> None:  # noqa: N802
        length = int(self.headers.get("Content-Length", 0))
        body = self.rfile.read(length) if length else b""
        server: _OTLPServer = self.server  # type: ignore[assignment]
        server.received_payloads.append(body)
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.end_headers()
        self.wfile.write(b"{}")


class _OTLPServer(HTTPServer):
    def __init__(self, host: str, port: int) -> None:
        super().__init__((host, port), _RecordingHandler)
        self.received_payloads: list[bytes] = []


def _start_otlp_server(port: int = 0) -> tuple[_OTLPServer, int]:
    """Start a local OTLP HTTP server on a random port. Returns (server, port)."""
    server = _OTLPServer("127.0.0.1", port)
    actual_port = server.server_address[1]
    thread = threading.Thread(target=server.serve_forever, daemon=True)
    thread.start()
    return server, actual_port


# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------


class TestPhoenixOtelExport:
    """Integration tests for Phoenix OTEL span export."""

    def setup_method(self) -> None:
        """Reset phoenix_otel_exporter singleton before each test."""
        import phoenix_otel_exporter as mod

        mod.reset_tracer()

    def teardown_method(self) -> None:
        """Reset after each test to avoid state leakage."""
        import phoenix_otel_exporter as mod

        mod.reset_tracer()

    def test_phoenix_otel_export_span_lands_in_server(self) -> None:
        """Span is exported via OTLP HTTP; server receives a 2xx POST to /v1/traces.

        DoD: span lands in Phoenix, OTLP POST returns 2xx, span queryable via Phoenix API.
        This test uses an in-process mock server for CI (no live Phoenix required).

        pytest ID for DoD: tests/integration/ -k phoenix_otel_export
        """
        import phoenix_otel_exporter as mod

        # Start local OTLP server
        server, port = _start_otlp_server()
        endpoint = f"http://127.0.0.1:{port}/v1/traces"

        try:
            with patch.dict(
                "os.environ",
                {"PHOENIX_OTEL_ENDPOINT": endpoint, "PHOENIX_OTEL_ENABLED": "true"},
            ):
                mod.reset_tracer()

                result = mod.emit_injection_span(
                    session_id="integration-session-001",
                    correlation_id="integration-corr-001",
                    manifest_injected=True,
                    injected_pattern_count=3,
                    agent_matched=True,
                    selected_agent="polymorphic-agent",
                    injection_latency_ms=42.5,
                    cohort="treatment",
                )

                assert result is True, (
                    "emit_injection_span should return True on success"
                )

                # Flush: BatchSpanProcessor may not export immediately
                # Force shutdown to drain the queue
                if mod._tracer_provider is not None:
                    mod._tracer_provider.force_flush(timeout_millis=5000)

                # Give the HTTP server a moment to process
                deadline = time.monotonic() + 5.0
                while not server.received_payloads and time.monotonic() < deadline:
                    time.sleep(0.05)

                assert len(server.received_payloads) > 0, (
                    f"No OTLP payload received at {endpoint}. "
                    "BatchSpanProcessor may not have flushed. "
                    "Check PHOENIX_QUEUE_MAX_SIZE / export batch settings."
                )

        finally:
            server.shutdown()
            mod.reset_tracer()

    def test_phoenix_otel_export_no_live_phoenix_needed(self) -> None:
        """Verifies span emission works even when no Phoenix is running.

        When Phoenix is unreachable, emit_injection_span should return False
        (export fails gracefully) without raising.
        """
        import phoenix_otel_exporter as mod

        # Point to a port that is guaranteed to be closed
        with patch.dict(
            "os.environ",
            {
                "PHOENIX_OTEL_ENDPOINT": "http://127.0.0.1:19999/v1/traces",
                "PHOENIX_OTEL_ENABLED": "true",
                "PHOENIX_EXPORT_TIMEOUT": "1",
            },
        ):
            mod.reset_tracer()

            # Must not raise regardless of connection failure
            result = mod.emit_injection_span(
                session_id="no-phoenix-session",
                correlation_id="no-phoenix-corr",
                manifest_injected=True,
                injected_pattern_count=1,
                agent_matched=False,
                selected_agent="",
                injection_latency_ms=10.0,
                cohort="treatment",
            )

            # Result may be True (queued for async export) or False (immediate fail)
            # Either is acceptable — the key assertion is no exception was raised
            assert isinstance(result, bool)

    def test_context_injection_wrapper_calls_otel_exporter(self) -> None:
        """context_injection_wrapper emits span when OTEL is available."""
        emitted: list[dict[str, Any]] = []

        def _fake_emit(**kwargs: Any) -> bool:
            emitted.append(kwargs)
            return True

        # Patch the exporter into context_injection_wrapper module
        import context_injection_wrapper

        with patch.object(
            context_injection_wrapper, "_emit_injection_span", _fake_emit
        ):
            # Simulate what context_injection_wrapper does after inject_patterns_sync
            result_mock = type(
                "R",
                (),
                {
                    "success": True,
                    "pattern_count": 2,
                    "retrieval_ms": 35,
                    "cohort": "treatment",
                    "injection_id": "inj-001",
                    "context_markdown": "## Patterns",
                    "source": "database",
                    "context_size_bytes": 100,
                },
            )()

            input_json: dict[str, Any] = {
                "session_id": "wrapper-session",
                "correlation_id": "wrapper-corr",
                "agent_name": "polymorphic-agent",
            }

            # Call the OTEL emission block directly (mirrors wrapper logic)
            if _fake_emit is not None:
                _fake_emit(
                    session_id=input_json.get("session_id", ""),
                    correlation_id=input_json.get("correlation_id", ""),
                    manifest_injected=result_mock.success
                    and result_mock.pattern_count > 0,
                    injected_pattern_count=result_mock.pattern_count,
                    agent_matched=bool(input_json.get("agent_name")),
                    selected_agent=str(input_json.get("agent_name") or ""),
                    injection_latency_ms=float(result_mock.retrieval_ms or 0),
                    cohort=result_mock.cohort or "treatment",
                )

        assert len(emitted) == 1
        call = emitted[0]
        assert call["manifest_injected"] is True
        assert call["injected_pattern_count"] == 2
        assert call["cohort"] == "treatment"
        assert call["selected_agent"] == "polymorphic-agent"

    def test_wrapper_main_passes_start_time_to_exporter(self) -> None:
        """context_injection_wrapper captures time_ns before injection and
        passes it as start_time to _emit_injection_span (OMN-3612).

        Strategy: patch the handler module that main() imports from, so the
        lazy import inside main() picks up the fake. Pin time.time_ns to a
        known value and verify it arrives as start_time in the emit call.
        """
        import io
        import json as _json
        import types

        import context_injection_wrapper

        emitted: list[dict[str, Any]] = []

        def _fake_emit(**kwargs: Any) -> bool:
            emitted.append(kwargs)
            return True

        # Minimal fake result from inject_patterns_sync
        _fake_result = type(
            "R",
            (),
            {
                "success": True,
                "pattern_count": 1,
                "retrieval_ms": 10,
                "cohort": "treatment",
                "injection_id": "inj-start-time",
                "context_markdown": "## P",
                "source": "db",
                "context_size_bytes": 4,
            },
        )()

        # Pin time.time_ns to a known value so we can assert passthrough
        pinned_ns = 1_700_000_000_000_000_000

        input_payload = _json.dumps(
            {
                "project": "/tmp/test",
                "domain": "general",
                "session_id": "sess-start-time",
                "correlation_id": "corr-start-time",
            }
        )

        # Build fake modules so that the lazy import inside main() succeeds
        # without needing the real omniclaude package installed.
        fake_handler = types.ModuleType("omniclaude.hooks.handler_context_injection")
        fake_handler.inject_patterns_sync = lambda **kw: _fake_result  # type: ignore[attr-defined]

        fake_config = types.ModuleType("omniclaude.hooks.context_config")
        fake_config.ContextInjectionConfig = lambda **kw: None  # type: ignore[attr-defined]

        fake_models = types.ModuleType("omniclaude.hooks.models_injection_tracking")

        class _FakeEnum:
            USER_PROMPT_SUBMIT = "user_prompt_submit"

        fake_models.EnumInjectionContext = _FakeEnum  # type: ignore[attr-defined]

        with (
            patch.object(context_injection_wrapper, "_emit_injection_span", _fake_emit),
            patch.object(
                context_injection_wrapper,
                "_get_context_mapping",
                return_value={},
            ),
            patch.object(
                context_injection_wrapper,
                "_build_tier_banner",
                return_value="--- TEST ---",
            ),
            patch.dict(
                "sys.modules",
                {
                    "omniclaude.hooks.handler_context_injection": fake_handler,
                    "omniclaude.hooks.context_config": fake_config,
                    "omniclaude.hooks.models_injection_tracking": fake_models,
                },
            ),
            patch("time.time_ns", return_value=pinned_ns),
            patch("sys.stdin", io.StringIO(input_payload)),
            patch("sys.exit") as mock_exit,
        ):
            context_injection_wrapper.main()

        # Verify _emit_injection_span was called with start_time
        assert len(emitted) == 1, f"Expected 1 emit call, got {len(emitted)}"
        assert emitted[0]["start_time"] == pinned_ns, (
            f"start_time should be the pinned time_ns value {pinned_ns}, "
            f"got {emitted[0].get('start_time')}"
        )
        # Confirm exit(0) for hook compat
        mock_exit.assert_called_with(0)
