"""Tests for moralstack.sdk.wrapper — govern(), GovernedClient, GovernedCompletions."""

from __future__ import annotations

from typing import Any
from unittest.mock import MagicMock, patch

import pytest

from moralstack.orchestration.contract import DeveloperContract
from moralstack.sdk.config import GovernanceConfig
from moralstack.sdk.response import GovernedResponse
from moralstack.sdk.wrapper import (
    GovernedClient,
    GovernedCompletions,
    _build_safe_complete_user_turn,
    _extract_developer_contract,
    _extract_last_user_message,
    _messages_to_turns,
    govern,
)


@pytest.fixture(autouse=True)
def disable_observability_for_wrapper_tests(monkeypatch: pytest.MonkeyPatch) -> None:
    """
    Session autouse conftest sets MORALSTACK_DB_PATH=:memory:, which makes
    get_observability_mode() default to db_only and triggers SQLite init on
    every GovernedClient. Wrapper tests use broad MagicMock orchestrator
    results; finalize_governance_audit then runs emit_request_meta_updated
    -> _json_safe (very expensive on MagicMocks), and get_obs().flush() can
    block on the write queue.

    For this module only: no observability DB path, file_only routing, no-op
    service emits/flushes, and no-op finalize_governance_audit. Tests that
    patch get_obs / config / sqlite_sink for specific behaviour still override
    these bindings inside their own ``with patch(...)`` blocks.
    """
    monkeypatch.setattr("moralstack.observability.config.get_db_path", lambda: None)
    monkeypatch.setattr(
        "moralstack.observability.config.get_observability_mode",
        lambda: "file_only",
    )
    monkeypatch.setattr("moralstack.observability.sinks.sqlite_sink.get_db_path", lambda: None)
    monkeypatch.setattr(
        "moralstack.observability.sinks.sqlite_sink.get_observability_mode",
        lambda: "file_only",
    )
    monkeypatch.setattr(
        "moralstack.observability.router.get_observability_mode",
        lambda: "file_only",
    )

    def _noop_emit(self: object, *_a: object, **_kw: object) -> None:
        return None

    def _noop_emit_batch(self: object, *_a: object, **_kw: object) -> None:
        return None

    def _noop_flush(self: object, *_a: object, **_kw: object) -> None:
        return None

    monkeypatch.setattr(
        "moralstack.observability.service.ObservabilityService.emit",
        _noop_emit,
    )
    monkeypatch.setattr(
        "moralstack.observability.service.ObservabilityService.emit_batch",
        _noop_emit_batch,
    )
    monkeypatch.setattr(
        "moralstack.observability.service.ObservabilityService.flush",
        _noop_flush,
    )
    monkeypatch.setattr(
        "moralstack.observability.governance_audit.finalize_governance_audit",
        lambda **kwargs: {},
    )


# =============================================================================
# Helpers
# =============================================================================


def _make_orchestrator(final_action: str = "NORMAL_COMPLETE", content: str = "OK") -> Any:
    """Return a mock Orchestrator whose process() returns a preset result."""
    orchestrator = MagicMock()
    result = MagicMock()
    result.response.content = content
    result.response.metadata.final_action = final_action
    result.response.metadata.risk_score = 0.1
    result.response.metadata.risk_category = "CLEARLY_BENIGN"
    result.response.metadata.path = "FAST_PATH"
    result.response.metadata.domain_overlay = None
    result.response.metadata.reason_codes = []
    result.response.metadata.winning_rule = "low_risk"
    result.response.metadata.decision_reason = "Benign"
    result.response.metadata.processing_time_ms = 100
    result.response.metadata.deliberation_cycles = 0
    result.response.metadata.triggered_principles = []
    result.response.metadata.why_not_refuse = ""
    result.response.metadata.why_not_safe_complete = ""
    result.conversation_id = "conv-test"
    result.turn_index = 0
    result.conversation_governance_state_out = None
    orchestrator.process.return_value = result
    return orchestrator


def _make_governed_client(final_action: str = "NORMAL_COMPLETE") -> tuple[Any, GovernedClient]:
    mock_openai = MagicMock()
    mock_openai.chat.completions.create.return_value = MagicMock(
        choices=[MagicMock(message=MagicMock(content="OpenAI response"))],
        model="gpt-4o",
    )
    orch = _make_orchestrator(final_action)
    config = GovernanceConfig()
    client = GovernedClient(mock_openai, orch, config)
    return mock_openai, client


# =============================================================================
# _extract_last_user_message
# =============================================================================


class TestExtractLastUserMessage:
    def test_single_user_message(self):
        msgs = [{"role": "user", "content": "Hello"}]
        assert _extract_last_user_message(msgs) == "Hello"

    def test_last_user_message_wins(self):
        msgs = [
            {"role": "user", "content": "First"},
            {"role": "assistant", "content": "Reply"},
            {"role": "user", "content": "Second"},
        ]
        assert _extract_last_user_message(msgs) == "Second"

    def test_no_user_message_returns_empty(self):
        msgs = [{"role": "system", "content": "System"}, {"role": "assistant", "content": "Resp"}]
        assert _extract_last_user_message(msgs) == ""

    def test_empty_messages_returns_empty(self):
        assert _extract_last_user_message([]) == ""

    def test_multimodal_content(self):
        msgs = [{"role": "user", "content": [{"type": "text", "text": "Hi"}, {"type": "image_url", "image_url": {}}]}]
        assert _extract_last_user_message(msgs) == "Hi"


# =============================================================================
# _messages_to_turns
# =============================================================================


class TestMessageToTurns:
    def test_user_and_assistant_converted(self):
        msgs = [
            {"role": "system", "content": "sys"},
            {"role": "user", "content": "Hello"},
            {"role": "assistant", "content": "Hi"},
        ]
        turns = _messages_to_turns(msgs)
        assert len(turns) == 2
        assert turns[0].role == "user"
        assert turns[0].content == "Hello"
        assert turns[1].role == "assistant"
        assert turns[1].content == "Hi"

    def test_system_messages_excluded(self):
        msgs = [{"role": "system", "content": "You are helpful"}]
        turns = _messages_to_turns(msgs)
        assert len(turns) == 0

    def test_empty_messages(self):
        assert _messages_to_turns([]) == []


# =============================================================================
# _build_safe_complete_user_turn (Step 10: caveat-as-extra-user-turn)
# =============================================================================


class TestBuildSafeCompleteUserTurn:
    def _make_result_with_content(self, content: str) -> Any:
        result = MagicMock()
        result.response.content = content
        result.response.metadata.triggered_principles = []
        result.response.metadata.reason_codes = []
        result.response.metadata.decision_reason = ""
        return result

    def test_returns_user_dict_with_governance_phrase(self):
        result = self._make_result_with_content("Be careful.")
        turn = _build_safe_complete_user_turn(result)
        assert turn["role"] == "user"
        assert "Be careful." in turn["content"]
        assert "governance" in turn["content"].lower()

    def test_fallback_uses_metadata_when_content_empty(self):
        result = MagicMock()
        result.response.content = ""
        result.response.metadata.triggered_principles = ["p1"]
        result.response.metadata.reason_codes = ["R1"]
        result.response.metadata.decision_reason = ""
        turn = _build_safe_complete_user_turn(result)
        assert turn["role"] == "user"
        assert "p1" in turn["content"] or "R1" in turn["content"]


# =============================================================================
# GovernedClient.__getattr__ passthrough
# =============================================================================


class TestGovernedClientPassthrough:
    def test_non_chat_attributes_delegate_to_original(self):
        mock_openai = MagicMock()
        mock_openai.models = MagicMock(name="models")
        orch = _make_orchestrator()
        client = GovernedClient(mock_openai, orch, GovernanceConfig())

        assert client.models is mock_openai.models

    def test_chat_attribute_is_governed(self):
        _, client = _make_governed_client()
        from moralstack.sdk.wrapper import GovernedChat

        assert isinstance(client.chat, GovernedChat)

    def test_completions_attribute_is_governed(self):
        _, client = _make_governed_client()

        assert isinstance(client.chat.completions, GovernedCompletions)


# =============================================================================
# GovernedCompletions.create() — routing
# =============================================================================


MESSAGES = [{"role": "user", "content": "Tell me about quantum physics"}]


class TestGovernedCompletionsRouting:
    def test_normal_complete_calls_openai(self):
        mock_openai, client = _make_governed_client("NORMAL_COMPLETE")
        resp = client.chat.completions.create(model="gpt-4o", messages=MESSAGES)

        mock_openai.chat.completions.create.assert_called_once()
        assert isinstance(resp, GovernedResponse)
        assert resp.governance_metadata.final_action == "NORMAL_COMPLETE"

    def test_refuse_does_not_call_openai(self):
        mock_openai, client = _make_governed_client("REFUSE")
        # Override content
        client._orchestrator.process.return_value.response.content = "I cannot help."
        resp = client.chat.completions.create(model="gpt-4o", messages=MESSAGES)

        mock_openai.chat.completions.create.assert_not_called()
        assert isinstance(resp, GovernedResponse)
        assert resp.governance_metadata.final_action == "REFUSE"
        assert "I cannot help." in resp.content

    def test_safe_complete_calls_openai_with_appended_user_turn(self):
        mock_openai, client = _make_governed_client("SAFE_COMPLETE")
        client._orchestrator.process.return_value.response.content = "Use caution."
        msgs = [
            {"role": "system", "content": "You are helpful."},
            {"role": "user", "content": "Hello"},
        ]
        client.chat.completions.create(model="gpt-4o", messages=msgs)

        mock_openai.chat.completions.create.assert_called_once()
        call_kwargs = mock_openai.chat.completions.create.call_args[1]
        out_msgs = call_kwargs.get("messages", [])
        assert out_msgs[-1]["role"] == "user"
        assert "governance" in out_msgs[-1]["content"].lower()
        sys_msgs = [m for m in out_msgs if m.get("role") == "system"]
        assert len(sys_msgs) == 1
        assert sys_msgs[0]["content"] == "You are helpful."

    def test_session_turn_index_increments(self):
        _, client = _make_governed_client("NORMAL_COMPLETE")
        client.chat.completions.create(model="gpt-4o", messages=MESSAGES)
        client.chat.completions.create(model="gpt-4o", messages=MESSAGES)
        # After 2 calls, counter must be 2
        assert client._session._turn_counter == 2

    def test_pipeline_failure_refuse_policy(self):
        mock_openai = MagicMock()
        orch = _make_orchestrator()
        orch.process.side_effect = RuntimeError("Pipeline down")
        config = GovernanceConfig(failure_policy="refuse")
        client = GovernedClient(mock_openai, orch, config)

        resp = client.chat.completions.create(model="gpt-4o", messages=MESSAGES)
        mock_openai.chat.completions.create.assert_not_called()
        assert isinstance(resp, GovernedResponse)
        assert resp.governance_metadata.final_action == "REFUSE"

    def test_pipeline_failure_passthrough_policy(self):
        mock_openai = MagicMock()
        mock_openai.chat.completions.create.return_value = MagicMock(
            choices=[MagicMock(message=MagicMock(content="Fallback"))]
        )
        orch = _make_orchestrator()
        orch.process.side_effect = RuntimeError("Pipeline down")
        config = GovernanceConfig(failure_policy="passthrough")
        client = GovernedClient(mock_openai, orch, config)

        resp = client.chat.completions.create(model="gpt-4o", messages=MESSAGES)
        mock_openai.chat.completions.create.assert_called_once()
        assert isinstance(resp, GovernedResponse)
        assert resp.is_passthrough is True


# =============================================================================
# govern() factory
# =============================================================================


class TestGovernFactory:
    def test_govern_returns_governed_client(self):
        mock_openai = MagicMock()
        mock_openai.chat = MagicMock()

        with patch("moralstack.sdk.wrapper._bootstrap_pipeline") as mock_bootstrap:
            mock_bootstrap.return_value = _make_orchestrator()
            client = govern(mock_openai, config=GovernanceConfig(api_key="sk-test"))

        assert isinstance(client, GovernedClient)

    def test_govern_uses_default_config_when_none(self):
        mock_openai = MagicMock()
        mock_openai.chat = MagicMock()

        with patch("moralstack.sdk.wrapper._bootstrap_pipeline") as mock_bootstrap:
            mock_bootstrap.return_value = _make_orchestrator()
            client = govern(mock_openai)

        assert client._config is not None
        assert isinstance(client._config, GovernanceConfig)

    def test_govern_passes_config_to_bootstrap(self):
        mock_openai = MagicMock()
        mock_openai.chat = MagicMock()
        config = GovernanceConfig(api_key="sk-test", domain_overlay="legal")

        with patch("moralstack.sdk.wrapper._bootstrap_pipeline") as mock_bootstrap:
            mock_bootstrap.return_value = _make_orchestrator()
            govern(mock_openai, config=config)

        mock_bootstrap.assert_called_once_with(config)


# =============================================================================
# GovernedClient — auto run_id initialisation
# =============================================================================


class TestGovernedClientRunId:
    def test_run_id_set_in_context_after_init(self):
        """GovernedClient.__init__ must register a non-empty run_id in the observability context."""
        from moralstack.observability.context import get_current_run_id

        mock_openai = MagicMock()
        orch = _make_orchestrator()
        GovernedClient(mock_openai, orch, GovernanceConfig())

        run_id = get_current_run_id()
        assert run_id is not None
        assert len(run_id) > 0

    def test_run_id_stored_on_instance(self):
        """_run_id attribute on GovernedClient must be a valid UUID string."""
        import uuid

        mock_openai = MagicMock()
        orch = _make_orchestrator()
        client = GovernedClient(mock_openai, orch, GovernanceConfig())

        # Must not raise — UUID4 format
        parsed = uuid.UUID(client._run_id)
        assert parsed.version == 4

    def test_different_clients_get_different_run_ids(self):
        """Each GovernedClient instance must receive a unique run_id."""
        mock_openai = MagicMock()
        orch = _make_orchestrator()
        c1 = GovernedClient(mock_openai, orch, GovernanceConfig())
        c2 = GovernedClient(mock_openai, orch, GovernanceConfig())

        assert c1._run_id != c2._run_id

    def test_db_init_skipped_in_file_only_mode(self):
        """In file_only mode no DB calls must be made."""
        from moralstack.sdk.wrapper import GovernedClient

        mock_openai = MagicMock()
        orch = _make_orchestrator()

        with (
            patch("moralstack.observability.config.get_observability_mode", return_value="file_only"),
            patch("moralstack.observability.sinks.sqlite_sink.init_db") as mock_init,
            patch("moralstack.observability.sinks.sqlite_sink.create_run") as mock_create,
        ):
            GovernedClient(mock_openai, orch, GovernanceConfig())

        mock_init.assert_not_called()
        mock_create.assert_not_called()

    def test_db_init_called_in_db_only_mode(self):
        """In db_only mode, init_db and create_run must be called when DB path is configured."""
        from moralstack.sdk.wrapper import GovernedClient

        mock_openai = MagicMock()
        orch = _make_orchestrator()

        with (
            patch(
                "moralstack.sdk.wrapper.GovernedClient._init_run_context",
                wraps=GovernedClient._init_run_context,
            ),
            patch("moralstack.observability.config.get_observability_mode", return_value="db_only"),
            patch("moralstack.observability.config.get_db_path", return_value="/tmp/test.db"),
            patch("moralstack.observability.sinks.sqlite_sink.init_db", return_value=True) as mock_init,
            patch("moralstack.observability.sinks.sqlite_sink.create_run", return_value=True) as mock_create,
        ):
            GovernedClient(mock_openai, orch, GovernanceConfig())

        mock_init.assert_called_once_with("/tmp/test.db")
        mock_create.assert_called_once()


# =============================================================================
# GovernedCompletions.create() — safe flush
# =============================================================================


class TestGovernedCompletionsFlush:
    def test_flush_called_after_normal_complete(self):
        """obs.flush() must be called after every successful create() regardless of final_action."""
        mock_openai, client = _make_governed_client("NORMAL_COMPLETE")

        with patch("moralstack.observability.service.get_obs") as mock_get_obs:
            mock_obs = MagicMock()
            mock_get_obs.return_value = mock_obs
            client.chat.completions.create(model="gpt-4o", messages=MESSAGES)

        mock_obs.flush.assert_called_once()

    def test_flush_called_after_refuse(self):
        """obs.flush() must be called even when the pipeline returns REFUSE."""
        mock_openai, client = _make_governed_client("REFUSE")
        client._orchestrator.process.return_value.response.content = "No."

        with patch("moralstack.observability.service.get_obs") as mock_get_obs:
            mock_obs = MagicMock()
            mock_get_obs.return_value = mock_obs
            client.chat.completions.create(model="gpt-4o", messages=MESSAGES)

        mock_obs.flush.assert_called_once()

    def test_flush_called_after_pipeline_failure(self):
        """obs.flush() must be called even when the pipeline raises an exception."""
        mock_openai = MagicMock()
        orch = _make_orchestrator()
        orch.process.side_effect = RuntimeError("Pipeline down")
        client = GovernedClient(mock_openai, orch, GovernanceConfig(failure_policy="refuse"))

        with patch("moralstack.observability.service.get_obs") as mock_get_obs:
            mock_obs = MagicMock()
            mock_get_obs.return_value = mock_obs
            client.chat.completions.create(model="gpt-4o", messages=MESSAGES)

        mock_obs.flush.assert_called_once()

    def test_flush_failure_does_not_propagate(self):
        """A flush() error must never surface to the caller."""
        mock_openai, client = _make_governed_client("NORMAL_COMPLETE")

        with patch("moralstack.observability.service.get_obs") as mock_get_obs:
            mock_obs = MagicMock()
            mock_obs.flush.side_effect = RuntimeError("Queue exploded")
            mock_get_obs.return_value = mock_obs
            # Must not raise
            resp = client.chat.completions.create(model="gpt-4o", messages=MESSAGES)

        assert resp is not None


# =============================================================================
# _extract_developer_contract
# =============================================================================


class TestExtractDeveloperContract:
    """Tests for the _extract_developer_contract helper (Step 2)."""

    def test_no_messages_returns_none(self):
        assert _extract_developer_contract([]) is None

    def test_no_system_message_returns_none(self):
        msgs = [
            {"role": "user", "content": "Hello"},
            {"role": "assistant", "content": "Hi"},
        ]
        assert _extract_developer_contract(msgs) is None

    def test_single_system_message_returns_contract(self):
        msgs = [
            {"role": "system", "content": "You are a careful assistant"},
            {"role": "user", "content": "Hello"},
        ]
        c = _extract_developer_contract(msgs)
        assert isinstance(c, DeveloperContract)
        assert c.raw_text == "You are a careful assistant"
        assert c.mode == "opaque"
        # Hash is deterministic and 16 chars; exact value is asserted in
        # tests/test_developer_contract.py::test_hash_stability_across_versions.
        assert len(c.contract_hash) == 16

    def test_empty_system_content_returns_none(self):
        msgs = [
            {"role": "system", "content": ""},
            {"role": "user", "content": "Hello"},
        ]
        assert _extract_developer_contract(msgs) is None

    def test_whitespace_only_system_content_returns_none(self):
        msgs = [
            {"role": "system", "content": "   \n\t  "},
            {"role": "user", "content": "Hello"},
        ]
        assert _extract_developer_contract(msgs) is None

    def test_multiple_system_messages_last_wins(self):
        msgs = [
            {"role": "system", "content": "First system"},
            {"role": "user", "content": "Hello"},
            {"role": "system", "content": "Second system"},
            {"role": "assistant", "content": "Hi"},
            {"role": "system", "content": "Third system"},
        ]
        c = _extract_developer_contract(msgs)
        assert c is not None
        assert c.raw_text == "Third system"

    def test_multimodal_system_content_text_parts_only(self):
        msgs = [
            {
                "role": "system",
                "content": [
                    {"type": "text", "text": "You are X"},
                    {"type": "image_url", "image_url": {"url": "https://example.com/a.png"}},
                    {"type": "text", "text": "and Y"},
                ],
            },
            {"role": "user", "content": "Hello"},
        ]
        c = _extract_developer_contract(msgs)
        assert c is not None
        assert c.raw_text == "You are X and Y"

    def test_multimodal_system_content_no_text_returns_none(self):
        msgs = [
            {
                "role": "system",
                "content": [
                    {"type": "image_url", "image_url": {"url": "https://example.com/a.png"}},
                ],
            },
            {"role": "user", "content": "Hello"},
        ]
        assert _extract_developer_contract(msgs) is None

    def test_string_content_not_list(self):
        """Sanity check: non-multimodal content (a plain string) is handled."""
        msgs = [{"role": "system", "content": "plain string"}, {"role": "user", "content": "Q"}]
        c = _extract_developer_contract(msgs)
        assert c is not None
        assert c.raw_text == "plain string"

    def test_system_among_other_roles_extracted(self):
        msgs = [
            {"role": "user", "content": "earlier"},
            {"role": "assistant", "content": "earlier reply"},
            {"role": "system", "content": "You are X"},
            {"role": "user", "content": "now"},
        ]
        c = _extract_developer_contract(msgs)
        assert c is not None
        assert c.raw_text == "You are X"

    def test_returned_contract_is_immutable(self):
        """Sanity check: the returned object preserves DeveloperContract frozen semantics."""
        import dataclasses

        msgs = [{"role": "system", "content": "X"}, {"role": "user", "content": "Q"}]
        c = _extract_developer_contract(msgs)
        assert c is not None
        with pytest.raises(dataclasses.FrozenInstanceError):
            c.raw_text = "Y"  # type: ignore[misc]


# =============================================================================
# Integration: _create_inner populates developer_contract
# =============================================================================


class TestCreateInnerPropagatesContract:
    """
    Verifies that GovernedCompletions._create_inner builds a ProcessedRequest
    whose developer_contract reflects the role='system' message in `messages`.

    These tests reuse the existing `_make_governed_client` helper at the top of
    this file. They invoke the wrapper through the public surface
    (`client.chat.completions.create(...)`) and inspect the captured
    `ProcessedRequest` via the MagicMock `call_args` of `orchestrator.process`.

    The orchestrator is configured to return a `REFUSE` result so that
    `_create_inner` exits early without calling the OpenAI client.
    """

    def test_contract_is_none_when_no_system_message(self):
        _, client = _make_governed_client("REFUSE")
        client.chat.completions.create(model="gpt-4o", messages=[{"role": "user", "content": "Hello"}])
        # The captured ProcessedRequest is the first positional argument of orchestrator.process.
        call_args = client._orchestrator.process.call_args
        request = call_args.args[0]
        assert request.developer_contract is None

    def test_contract_is_populated_when_system_present(self):
        _, client = _make_governed_client("REFUSE")
        client.chat.completions.create(
            model="gpt-4o",
            messages=[
                {"role": "system", "content": "You are a careful assistant"},
                {"role": "user", "content": "Hello"},
            ],
        )
        call_args = client._orchestrator.process.call_args
        request = call_args.args[0]
        assert request.developer_contract is not None
        assert request.developer_contract.raw_text == "You are a careful assistant"
        assert request.developer_contract.mode == "opaque"

    def test_contract_is_none_when_system_is_empty(self):
        _, client = _make_governed_client("REFUSE")
        client.chat.completions.create(
            model="gpt-4o",
            messages=[
                {"role": "system", "content": "   "},
                {"role": "user", "content": "Hello"},
            ],
        )
        call_args = client._orchestrator.process.call_args
        request = call_args.args[0]
        assert request.developer_contract is None


# =============================================================================
# Step 14.1 — proxy.request_finalized emission from the SDK code path
# =============================================================================


class TestRequestFinalizedEmission:
    """
    Verify that the SDK emits ``proxy.request_finalized`` after every
    ``create()`` so SDK-driven runs reach the same audit surface as the proxy
    HTTP entry point (the ``proxy_request_events`` table + the matching JSONL
    stream).

    Pattern: the autouse fixture in this module already neuters
    ``finalize_governance_audit`` (no-op) and the ObservabilityService
    emit/flush; we therefore monkey-patch ``emit_proxy_request_finalized``
    directly to capture invocations and stub ``finalize_governance_audit`` to
    return a known meta dict so we can assert downstream field propagation.
    """

    def test_emit_proxy_request_finalized_called_normal_complete(self, monkeypatch: pytest.MonkeyPatch):
        calls: list[dict[str, Any]] = []

        def _capture(**kwargs: Any) -> None:
            calls.append(kwargs)

        def _stub_finalize(**_kwargs: Any) -> dict[str, Any]:
            return {
                "final_action": "NORMAL_COMPLETE",
                "risk_score": 0.10,
                "path": "FAST_PATH",
                "was_cached": False,
                "cached_from_turn": None,
            }

        monkeypatch.setattr(
            "moralstack.observability.conversation_events.emit_proxy_request_finalized",
            _capture,
        )
        monkeypatch.setattr(
            "moralstack.observability.governance_audit.finalize_governance_audit",
            _stub_finalize,
        )

        _, client = _make_governed_client("NORMAL_COMPLETE")
        # _make_governed_client builds a real GovernedClient that registers a
        # run_id via _init_run_context(); without it _finalize_audit
        # short-circuits.
        assert client._run_id

        client.chat.completions.create(
            model="gpt-4o",
            messages=[{"role": "user", "content": "Hello"}],
        )

        assert len(calls) == 1
        payload = calls[0]
        assert payload["run_id"] == client._run_id
        assert payload["request_id"]
        assert payload["conversation_id"] == client._session.conversation_id
        assert payload["turn_index"] == 0
        assert payload["final_action"] == "NORMAL_COMPLETE"
        assert payload["risk_score"] == 0.10
        assert payload["path"] == "FAST_PATH"
        # First turn of a fresh session: no incoming state.
        assert payload["state_provided"] is False
        assert payload["state_in"] is None
        # The SDK never produces X-MoralStack-* headers (those belong to the
        # HTTP proxy): the field must be None, not an empty dict.
        assert payload["headers"] is None
        # final_response_length reflects the upstream OpenAI mock content.
        assert payload["final_response_length"] == len("OpenAI response")
        assert payload["was_cached"] is False
        assert payload["cached_from_turn"] is None

    def test_emit_proxy_request_finalized_called_refuse(self, monkeypatch: pytest.MonkeyPatch):
        """REFUSE path must also emit the canonical envelope (no OpenAI call)."""
        calls: list[dict[str, Any]] = []

        monkeypatch.setattr(
            "moralstack.observability.conversation_events.emit_proxy_request_finalized",
            lambda **kw: calls.append(kw),
        )
        monkeypatch.setattr(
            "moralstack.observability.governance_audit.finalize_governance_audit",
            lambda **_kw: {"final_action": "REFUSE", "risk_score": 0.95},
        )

        mock_openai, client = _make_governed_client("REFUSE")
        client.chat.completions.create(
            model="gpt-4o",
            messages=[{"role": "user", "content": "harmful"}],
        )

        # OpenAI must NOT be called on REFUSE — but the audit envelope is still
        # emitted with the refusal content from result.response.content.
        mock_openai.chat.completions.create.assert_not_called()
        assert len(calls) == 1
        assert calls[0]["final_action"] == "REFUSE"
        assert calls[0]["risk_score"] == 0.95
        # The synthetic mock orchestrator sets content="OK"; the SDK passes the
        # refusal text as final_response_text, so length matches that.
        assert calls[0]["final_response_length"] == len("OK")

    def test_emit_proxy_request_finalized_called_safe_complete(self, monkeypatch: pytest.MonkeyPatch):
        """SAFE_COMPLETE must emit one envelope with the upstream response length."""
        calls: list[dict[str, Any]] = []

        monkeypatch.setattr(
            "moralstack.observability.conversation_events.emit_proxy_request_finalized",
            lambda **kw: calls.append(kw),
        )
        monkeypatch.setattr(
            "moralstack.observability.governance_audit.finalize_governance_audit",
            lambda **_kw: {"final_action": "SAFE_COMPLETE", "risk_score": 0.55},
        )

        mock_openai, client = _make_governed_client("SAFE_COMPLETE")
        client.chat.completions.create(
            model="gpt-4o",
            messages=[{"role": "user", "content": "borderline"}],
        )

        mock_openai.chat.completions.create.assert_called_once()
        assert len(calls) == 1
        assert calls[0]["final_action"] == "SAFE_COMPLETE"
        # On SAFE_COMPLETE the OpenAI mock returns "OpenAI response".
        assert calls[0]["final_response_length"] == len("OpenAI response")

    def test_state_propagation_across_turns(self, monkeypatch: pytest.MonkeyPatch):
        """
        state_in must be the snapshot BEFORE update_from_result on each turn.
        Verifies that turn N+1 sees state_provided=True with the state stored
        at the end of turn N.

        Sequence: configure the orchestrator mock to ALWAYS return a non-None
        state_out, then run two turns. Turn 0 has state_in=None (the session
        store is empty at start). Turn 1 has state_in=<state_out from turn 0>
        because session.update_from_result(result) at the end of turn 0 put it
        in the store, and session.current_state at the top of turn 1 reads it.
        """
        from moralstack.orchestration.conversation_state import (
            ConversationGovernanceState,
        )

        calls: list[dict[str, Any]] = []
        monkeypatch.setattr(
            "moralstack.observability.conversation_events.emit_proxy_request_finalized",
            lambda **kw: calls.append(kw),
        )
        monkeypatch.setattr(
            "moralstack.observability.governance_audit.finalize_governance_audit",
            lambda **_kw: {"final_action": "NORMAL_COMPLETE", "risk_score": 0.1},
        )

        _, client = _make_governed_client("NORMAL_COMPLETE")

        # Configure the orchestrator mock so EVERY call returns a result with a
        # non-None state_out. This must be done BEFORE turn 0 so that
        # session.update_from_result at the end of turn 0 stores it.
        next_state = ConversationGovernanceState(
            conversation_id=client._session.conversation_id,
            turn_index=0,
            active_domain="general",
            active_overlay=None,
            last_governance_posture="NORMAL",
        )
        client._orchestrator.process.return_value.conversation_governance_state_out = next_state
        client._orchestrator.process.return_value.conversation_state_updated = True

        # Turn 0: state_in is None (empty session at start), but session will
        # store the next_state after the call.
        client.chat.completions.create(
            model="gpt-4o",
            messages=[{"role": "user", "content": "Q1"}],
        )
        assert calls[0]["state_provided"] is False
        assert calls[0]["state_in"] is None
        # state_updated must reflect the boolean we set on the result.
        assert calls[0]["state_updated"] is True

        # Turn 1: session.current_state now returns the state stored at turn 0,
        # which the SDK captures as state_in_snapshot.
        client.chat.completions.create(
            model="gpt-4o",
            messages=[
                {"role": "user", "content": "Q1"},
                {"role": "assistant", "content": "A1"},
                {"role": "user", "content": "Q2"},
            ],
        )
        assert calls[1]["state_provided"] is True
        # state_in is a JSON-safe summary dict (state_summary_or_none(state_in)),
        # not the raw frozen dataclass.
        assert isinstance(calls[1]["state_in"], dict)
        assert calls[1]["state_in"].get("active_domain") == "general"
        # posture_in is posture_of(state_in) = state.last_governance_posture.
        assert calls[1]["posture_in"] == "NORMAL"
