"""
tests/track_b/test_story_7_02.py

Story B-7.02: SemanticMergeInterceptor — Opus Contradiction Resolver

Black Box Tests (BB1–BB5):
    BB1  Non-conflicting deltas → used_opus=False, merged_patch contains all ops
    BB2  Conflicting deltas + mock Opus → used_opus=True, resolved patch returned
    BB3  Opus returns invalid JSON → fallback to fast_merge of non-conflicting deltas
    BB4  Empty deltas → success=True, empty merged_patch, used_opus=False
    BB5  Single delta → fast path, used_opus=False

White Box Tests (WB1–WB4):
    WB1  ConflictDetector.detect called before any Opus invocation
    WB2  used_opus=False in fast-path resolution
    WB3  MergePromptBuilder.build called with correct args when conflicts exist
    WB4  latency_ms > 0 for all paths
"""

from __future__ import annotations

import asyncio
import json
import sys
import os
from datetime import datetime
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch, call

import pytest

# ---------------------------------------------------------------------------
# Path setup
# ---------------------------------------------------------------------------

GENESIS_ROOT = "/mnt/e/genesis-system"
if GENESIS_ROOT not in sys.path:
    sys.path.insert(0, GENESIS_ROOT)

# ---------------------------------------------------------------------------
# Imports under test
# ---------------------------------------------------------------------------

from core.merge.semantic_merge_interceptor import SemanticMergeInterceptor, MergeResult  # noqa: E402
from core.merge.conflict_detector import ConflictDetector, ConflictReport  # noqa: E402
from core.merge.merge_prompt_builder import MergePromptBuilder  # noqa: E402
from core.coherence.state_delta import StateDelta  # noqa: E402


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def make_delta(
    agent_id: str,
    patch: list[dict],
    session_id: str = "sess-001",
    version: int = 1,
) -> StateDelta:
    """Create a StateDelta with the given patch stored as a tuple."""
    return StateDelta(
        agent_id=agent_id,
        session_id=session_id,
        version_at_read=version,
        patch=tuple(patch),
        submitted_at=datetime(2026, 2, 25, 12, 0, 0),
    )


def make_opus_client(resolved_patch: list, rationale: str = "test rationale") -> MagicMock:
    """
    Return a synchronous mock Opus client whose generate_content() method
    returns a response with a ``.text`` attribute containing valid JSON.
    """
    response_json = json.dumps({
        "resolved_patch": resolved_patch,
        "resolution_rationale": rationale,
    })
    mock_response = MagicMock()
    mock_response.text = response_json

    client = MagicMock()
    client.generate_content.return_value = mock_response
    # No generate_content_async — forces sync path
    del client.generate_content_async
    return client


def make_async_opus_client(resolved_patch: list, rationale: str = "async rationale") -> MagicMock:
    """
    Return an async mock Opus client whose generate_content_async() coroutine
    returns a response with a ``.text`` attribute containing valid JSON.
    """
    response_json = json.dumps({
        "resolved_patch": resolved_patch,
        "resolution_rationale": rationale,
    })
    mock_response = MagicMock()
    mock_response.text = response_json

    client = MagicMock()
    client.generate_content_async = AsyncMock(return_value=mock_response)
    return client


SMALL_STATE: dict = {"status": "active", "version": 3}

# Use a temp events path to avoid polluting real observability data
TMP_EVENTS = "/tmp/test_7_02_events.jsonl"


def make_interceptor(opus_client=None) -> SemanticMergeInterceptor:
    """Convenience factory using the temp events path."""
    return SemanticMergeInterceptor(opus_client=opus_client, events_path=TMP_EVENTS)


# ---------------------------------------------------------------------------
# Helper to run coroutines in tests
# ---------------------------------------------------------------------------

def run(coro):
    """Run an async coroutine synchronously (pytest does not auto-await)."""
    return asyncio.get_event_loop().run_until_complete(coro)


# ===========================================================================
# BB Tests — Black Box
# ===========================================================================


def test_bb1_non_conflicting_deltas_no_opus():
    """
    BB1: Two deltas writing to different paths → no conflict detected.
    Opus must NOT be called; merged_patch must include ops from both deltas.
    """
    delta_a = make_delta("agent-A", [{"op": "add", "path": "/foo", "value": 1}])
    delta_b = make_delta("agent-B", [{"op": "add", "path": "/bar", "value": 2}])

    opus_client = MagicMock()
    interceptor = make_interceptor(opus_client=opus_client)

    result = run(interceptor.merge([delta_a, delta_b], SMALL_STATE, version=1))

    assert result.success is True
    assert result.used_opus is False, "Opus must NOT be called when there are no conflicts"

    # Both ops must be present in the merged patch
    paths = {op["path"] for op in result.merged_patch}
    assert "/foo" in paths
    assert "/bar" in paths

    # Confirm Opus client was never touched
    opus_client.generate_content.assert_not_called()


def test_bb2_conflicting_deltas_opus_called():
    """
    BB2: Two deltas conflict on the same path → Opus is invoked.
    The returned resolved_patch and rationale must be propagated.
    """
    delta_a = make_delta("agent-A", [{"op": "replace", "path": "/status", "value": "active"}])
    delta_b = make_delta("agent-B", [{"op": "replace", "path": "/status", "value": "inactive"}])

    resolved = [{"op": "replace", "path": "/status", "value": "active"}]
    opus_client = make_opus_client(resolved_patch=resolved, rationale="agent-A wins")
    interceptor = make_interceptor(opus_client=opus_client)

    result = run(interceptor.merge([delta_a, delta_b], SMALL_STATE, version=2))

    assert result.success is True
    assert result.used_opus is True
    assert result.merged_patch == resolved
    assert result.resolution_rationale == "agent-A wins"
    opus_client.generate_content.assert_called_once()


def test_bb3_opus_invalid_json_fallback():
    """
    BB3: Opus returns malformed JSON → SemanticMergeInterceptor falls back to
    fast_merge of non-conflicting deltas only (partial merge).
    used_opus must be False (Opus never successfully resolved).
    """
    delta_conflict_a = make_delta("agent-A", [{"op": "replace", "path": "/x", "value": "X"}])
    delta_conflict_b = make_delta("agent-B", [{"op": "replace", "path": "/x", "value": "Y"}])
    delta_clean      = make_delta("agent-C", [{"op": "add", "path": "/safe", "value": 42}])

    # Opus returns garbage text (not valid JSON)
    bad_response = MagicMock()
    bad_response.text = "NOT_JSON {"
    opus_client = MagicMock()
    opus_client.generate_content.return_value = bad_response
    del opus_client.generate_content_async

    interceptor = make_interceptor(opus_client=opus_client)
    result = run(interceptor.merge(
        [delta_conflict_a, delta_conflict_b, delta_clean],
        SMALL_STATE,
        version=1,
    ))

    assert result.success is True
    assert result.used_opus is False  # fallback path, Opus did not succeed

    # The clean delta's op should survive in the partial merge
    paths = {op["path"] for op in result.merged_patch}
    assert "/safe" in paths

    # The conflicting path must NOT appear (those deltas were excluded)
    assert "/x" not in paths


def test_bb4_empty_deltas():
    """
    BB4: Passing an empty list → success=True, empty merged_patch, used_opus=False.
    """
    interceptor = make_interceptor()
    result = run(interceptor.merge([], SMALL_STATE, version=0))

    assert result.success is True
    assert result.used_opus is False
    assert result.merged_patch == []


def test_bb5_single_delta_fast_path():
    """
    BB5: A single delta can never conflict with itself → fast path, used_opus=False.
    """
    delta = make_delta("agent-X", [{"op": "add", "path": "/solo", "value": "only"}])
    interceptor = make_interceptor()

    result = run(interceptor.merge([delta], SMALL_STATE, version=1))

    assert result.success is True
    assert result.used_opus is False
    paths = {op["path"] for op in result.merged_patch}
    assert "/solo" in paths


# ===========================================================================
# WB Tests — White Box
# ===========================================================================


def test_wb1_conflict_detector_detect_called_before_opus():
    """
    WB1: ConflictDetector.detect() must be called as the FIRST step, even when
    conflicts exist and Opus is eventually invoked.
    """
    delta_a = make_delta("agent-A", [{"op": "replace", "path": "/k", "value": 1}])
    delta_b = make_delta("agent-B", [{"op": "replace", "path": "/k", "value": 2}])

    resolved = [{"op": "replace", "path": "/k", "value": 1}]
    opus_client = make_opus_client(resolved_patch=resolved)
    interceptor = make_interceptor(opus_client=opus_client)

    call_order: list[str] = []

    original_detect = interceptor.detector.detect

    def patched_detect(deltas):
        call_order.append("detect")
        return original_detect(deltas)

    original_generate = opus_client.generate_content

    def patched_generate(prompt):
        call_order.append("opus")
        return original_generate(prompt)

    interceptor.detector.detect = patched_detect
    opus_client.generate_content = patched_generate

    run(interceptor.merge([delta_a, delta_b], SMALL_STATE, version=1))

    assert call_order[0] == "detect", "detect() must be called before Opus"
    assert "opus" in call_order, "Opus must be called on conflict path"


def test_wb2_used_opus_false_on_fast_path():
    """
    WB2: On a non-conflicting merge, used_opus is explicitly False in the result.
    """
    delta_a = make_delta("agent-A", [{"op": "add", "path": "/p", "value": 1}])
    delta_b = make_delta("agent-B", [{"op": "add", "path": "/q", "value": 2}])

    interceptor = make_interceptor()
    result = run(interceptor.merge([delta_a, delta_b], SMALL_STATE, version=1))

    assert result.used_opus is False


def test_wb3_merge_prompt_builder_called_with_correct_args():
    """
    WB3: When conflicts exist, MergePromptBuilder.build() must receive:
    - the full delta list
    - the ConflictReport produced by detect()
    - current_state
    - version
    """
    delta_a = make_delta("agent-A", [{"op": "replace", "path": "/m", "value": "v1"}])
    delta_b = make_delta("agent-B", [{"op": "replace", "path": "/m", "value": "v2"}])

    resolved = [{"op": "replace", "path": "/m", "value": "v1"}]
    opus_client = make_opus_client(resolved_patch=resolved)
    interceptor = make_interceptor(opus_client=opus_client)

    captured: dict = {}
    original_build = interceptor.prompt_builder.build

    def patched_build(deltas, report, state, version):
        captured["deltas"] = deltas
        captured["report"] = report
        captured["state"] = state
        captured["version"] = version
        return original_build(deltas, report, state, version)

    interceptor.prompt_builder.build = patched_build

    current_state = {"context": "genesis", "ver": 9}
    run(interceptor.merge([delta_a, delta_b], current_state, version=9))

    assert captured["deltas"] == [delta_a, delta_b]
    assert isinstance(captured["report"], ConflictReport)
    assert captured["report"].has_conflicts is True
    assert captured["state"] == current_state
    assert captured["version"] == 9


def test_wb4_latency_ms_positive_all_paths():
    """
    WB4: latency_ms must be > 0 for all code paths (fast, opus, and fallback).
    """
    # --- Fast path ---
    delta_a = make_delta("agent-A", [{"op": "add", "path": "/a", "value": 1}])
    delta_b = make_delta("agent-B", [{"op": "add", "path": "/b", "value": 2}])
    interceptor = make_interceptor()
    result_fast = run(interceptor.merge([delta_a, delta_b], SMALL_STATE, version=1))
    assert result_fast.latency_ms > 0, "Fast path latency_ms must be positive"

    # --- Opus path ---
    delta_c = make_delta("agent-C", [{"op": "replace", "path": "/x", "value": "A"}])
    delta_d = make_delta("agent-D", [{"op": "replace", "path": "/x", "value": "B"}])
    resolved = [{"op": "replace", "path": "/x", "value": "A"}]
    opus_client = make_opus_client(resolved_patch=resolved)
    interceptor_opus = make_interceptor(opus_client=opus_client)
    result_opus = run(interceptor_opus.merge([delta_c, delta_d], SMALL_STATE, version=1))
    assert result_opus.latency_ms > 0, "Opus path latency_ms must be positive"

    # --- Fallback path ---
    bad_response = MagicMock()
    bad_response.text = "INVALID"
    bad_client = MagicMock()
    bad_client.generate_content.return_value = bad_response
    del bad_client.generate_content_async
    interceptor_fallback = make_interceptor(opus_client=bad_client)
    delta_e = make_delta("agent-E", [{"op": "replace", "path": "/y", "value": "E"}])
    delta_f = make_delta("agent-F", [{"op": "replace", "path": "/y", "value": "F"}])
    result_fallback = run(interceptor_fallback.merge([delta_e, delta_f], SMALL_STATE, version=1))
    assert result_fallback.latency_ms > 0, "Fallback path latency_ms must be positive"


# ===========================================================================
# Additional coverage
# ===========================================================================


def test_async_opus_client_interface():
    """
    Extra: async generate_content_async interface is supported.
    """
    delta_a = make_delta("agent-A", [{"op": "replace", "path": "/z", "value": "one"}])
    delta_b = make_delta("agent-B", [{"op": "replace", "path": "/z", "value": "two"}])

    resolved = [{"op": "replace", "path": "/z", "value": "one"}]
    opus_client = make_async_opus_client(resolved_patch=resolved, rationale="async wins")
    interceptor = make_interceptor(opus_client=opus_client)

    result = run(interceptor.merge([delta_a, delta_b], SMALL_STATE, version=1))

    assert result.success is True
    assert result.used_opus is True
    assert result.merged_patch == resolved
    assert result.resolution_rationale == "async wins"
    opus_client.generate_content_async.assert_called_once()


def test_opus_resolved_patch_non_list_triggers_fallback():
    """
    Extra: If Opus returns valid JSON but resolved_patch is not a list → fallback.
    """
    bad_json = json.dumps({
        "resolved_patch": {"op": "replace"},   # dict, not a list
        "resolution_rationale": "oops",
    })
    bad_response = MagicMock()
    bad_response.text = bad_json
    opus_client = MagicMock()
    opus_client.generate_content.return_value = bad_response
    del opus_client.generate_content_async

    delta_a = make_delta("agent-A", [{"op": "replace", "path": "/n", "value": 1}])
    delta_b = make_delta("agent-B", [{"op": "replace", "path": "/n", "value": 2}])

    interceptor = make_interceptor(opus_client=opus_client)
    result = run(interceptor.merge([delta_a, delta_b], SMALL_STATE, version=1))

    # Falls back to partial merge — success but no opus credit
    assert result.success is True
    assert result.used_opus is False


def test_no_opus_client_with_conflicts_triggers_fallback():
    """
    Extra: If no opus_client is provided but conflicts exist → partial merge (no crash).
    """
    delta_a = make_delta("agent-A", [{"op": "replace", "path": "/p", "value": "A"}])
    delta_b = make_delta("agent-B", [{"op": "replace", "path": "/p", "value": "B"}])
    delta_c = make_delta("agent-C", [{"op": "add", "path": "/clean", "value": 99}])

    interceptor = make_interceptor(opus_client=None)
    result = run(interceptor.merge([delta_a, delta_b, delta_c], SMALL_STATE, version=1))

    assert result.success is True
    assert result.used_opus is False
    # Only the clean delta survives partial merge
    paths = {op["path"] for op in result.merged_patch}
    assert "/clean" in paths


def test_events_written_to_jsonl():
    """
    Extra: Events are written to the events_path on each merge call.
    """
    import tempfile

    with tempfile.NamedTemporaryFile(
        mode="w", suffix=".jsonl", delete=False
    ) as tmp:
        tmp_path = tmp.name

    try:
        interceptor = SemanticMergeInterceptor(
            opus_client=None,
            events_path=tmp_path,
        )
        delta_a = make_delta("agent-A", [{"op": "add", "path": "/ev", "value": 1}])
        run(interceptor.merge([delta_a], SMALL_STATE, version=1))

        with open(tmp_path, "r", encoding="utf-8") as fh:
            lines = [line.strip() for line in fh if line.strip()]

        assert len(lines) >= 1
        event = json.loads(lines[0])
        assert event["event"] == "semantic_merge"
        assert "latency_ms" in event
        assert "used_opus" in event
        assert "conflict_count" in event
    finally:
        os.unlink(tmp_path)


def test_package_exports():
    """
    PKG: SemanticMergeInterceptor and MergeResult are exported from core.merge.
    """
    from core.merge import SemanticMergeInterceptor as SMI, MergeResult as MR
    assert SMI is SemanticMergeInterceptor
    assert MR is MergeResult
