"""
Module 1 Integration Tests — Black Box
Story 1.06 — Track B

Tests the complete interceptor pipeline from the outside.
All tests exercise public API contracts only — no internal state inspection.

Test classes:
    TestAbstractInstantiation   — BaseInterceptor ABC enforcement
    TestChainOrdering           — Priority-order execution, disabled interceptor skipping
    TestChainRegistration       — register/unregister/len public API
    TestDispatchToSwarm         — task_id assignment, success/error paths, concurrency
    TestNullInterceptor         — pass-through contract
    TestTelemetryInterceptor    — event logging contract
    TestFullPipeline            — end-to-end dispatch with multiple interceptors
"""
import asyncio
import sys
import uuid
from unittest.mock import AsyncMock, patch, MagicMock
from pathlib import Path

sys.path.insert(0, '/mnt/e/genesis-system')

import pytest
import pytest_asyncio

from core.interceptors import (
    BaseInterceptor,
    InterceptorMetadata,
    InterceptorChain,
    dispatch_to_swarm,
    GLOBAL_CHAIN,
    register_interceptor,
)
from core.interceptors.execution_telemetry import ExecutionTelemetryInterceptor
from core.interceptors.integration_contracts import NullInterceptor


# ---------------------------------------------------------------------------
# Test helpers — concrete minimal interceptors built for BB testing only
# ---------------------------------------------------------------------------

class _OrderTrackingInterceptor(BaseInterceptor):
    """Minimal concrete interceptor that records execution order via a shared list."""

    def __init__(self, name: str, priority: int, call_log: list):
        self.metadata = InterceptorMetadata(name=name, priority=priority, enabled=True)
        self._call_log = call_log

    async def pre_execute(self, task_payload: dict) -> dict:
        self._call_log.append(self.metadata.name)
        return task_payload

    async def post_execute(self, result: dict, task_payload: dict) -> None:
        pass

    async def on_error(self, error: Exception, task_payload: dict) -> dict:
        return {"error": str(error)}

    async def on_correction(self, correction_payload: dict) -> dict:
        return correction_payload


class _EnrichingInterceptor(BaseInterceptor):
    """Adds a known key to the payload so we can verify enrichment from outside."""

    def __init__(self, key: str, value: str, priority: int = 10):
        self.metadata = InterceptorMetadata(name=f"enricher_{key}", priority=priority, enabled=True)
        self._key = key
        self._value = value

    async def pre_execute(self, task_payload: dict) -> dict:
        return {**task_payload, self._key: self._value}

    async def post_execute(self, result: dict, task_payload: dict) -> None:
        pass

    async def on_error(self, error: Exception, task_payload: dict) -> dict:
        return {"error": str(error)}

    async def on_correction(self, correction_payload: dict) -> dict:
        return correction_payload


def _is_valid_uuid4(value: str) -> bool:
    """True if *value* is a canonical UUID-4 string (36 chars, 4 hyphens)."""
    try:
        parsed = uuid.UUID(value, version=4)
        return str(parsed) == value
    except (ValueError, AttributeError):
        return False


# ---------------------------------------------------------------------------
# BB1: TestAbstractInstantiation
# ---------------------------------------------------------------------------

class TestAbstractInstantiation:
    """BaseInterceptor is an ABC — it must refuse direct instantiation."""

    def test_cannot_instantiate_base_interceptor(self):
        """BaseInterceptor is abstract — instantiation must raise TypeError."""
        with pytest.raises(TypeError):
            BaseInterceptor()  # type: ignore[abstract]

    def test_concrete_subclass_with_all_methods_can_be_instantiated(self):
        """A complete concrete subclass is instantiable without error."""
        null = NullInterceptor()
        assert isinstance(null, BaseInterceptor)

    def test_partial_subclass_missing_methods_cannot_be_instantiated(self):
        """Subclass missing any abstract method must also raise TypeError."""

        class _Partial(BaseInterceptor):
            metadata = InterceptorMetadata(name="partial", priority=0)

            async def pre_execute(self, task_payload: dict) -> dict:
                return task_payload
            # on_error, post_execute, on_correction intentionally omitted

        with pytest.raises(TypeError):
            _Partial()  # type: ignore[abstract]


# ---------------------------------------------------------------------------
# BB2: TestChainOrdering
# ---------------------------------------------------------------------------

class TestChainOrdering:
    """Chain executes interceptors in ascending priority order."""

    def test_interceptors_execute_in_priority_order(self):
        """Lower-priority-number interceptors execute before higher ones."""
        call_log: list = []
        chain = InterceptorChain()

        # Register deliberately in reverse-priority order to confirm sorting
        chain.register(_OrderTrackingInterceptor("high_100", priority=100, call_log=call_log))
        chain.register(_OrderTrackingInterceptor("mid_50",  priority=50,  call_log=call_log))
        chain.register(_OrderTrackingInterceptor("low_10",  priority=10,  call_log=call_log))

        asyncio.run(chain.execute_pre({"prompt": "ordering test"}))

        assert call_log == ["low_10", "mid_50", "high_100"], (
            f"Unexpected execution order: {call_log}"
        )

    def test_equal_priority_interceptors_all_execute(self):
        """All interceptors with identical priority execute (no silent skips)."""
        call_log: list = []
        chain = InterceptorChain()
        for name in ("a", "b", "c"):
            chain.register(_OrderTrackingInterceptor(name, priority=5, call_log=call_log))

        asyncio.run(chain.execute_pre({"prompt": "equal priorities"}))

        assert set(call_log) == {"a", "b", "c"}, f"Expected all three; got: {call_log}"

    def test_disabled_interceptor_skipped(self):
        """Disabled interceptors are excluded from chain execution."""
        call_log: list = []
        chain = InterceptorChain()

        active   = _OrderTrackingInterceptor("active",   priority=10, call_log=call_log)
        inactive = _OrderTrackingInterceptor("inactive", priority=20, call_log=call_log)
        inactive.metadata = InterceptorMetadata(name="inactive", priority=20, enabled=False)

        chain.register(active)
        chain.register(inactive)

        asyncio.run(chain.execute_pre({"prompt": "disabled test"}))

        assert "active"   in call_log, "Enabled interceptor must execute"
        assert "inactive" not in call_log, "Disabled interceptor must be skipped"

    def test_payload_enriched_sequentially_across_chain(self):
        """Each interceptor in the chain receives the enriched payload from the previous one."""
        chain = InterceptorChain()
        chain.register(_EnrichingInterceptor("key_a", "from_a", priority=10))
        chain.register(_EnrichingInterceptor("key_b", "from_b", priority=20))

        result = asyncio.run(chain.execute_pre({"prompt": "sequential enrichment"}))

        # Both enrichments must be present — second interceptor saw first's output
        assert result.get("key_a") == "from_a"
        assert result.get("key_b") == "from_b"


# ---------------------------------------------------------------------------
# BB3: TestChainRegistration
# ---------------------------------------------------------------------------

class TestChainRegistration:
    """Public register/unregister/len API contracts."""

    def test_register_increases_chain_length(self):
        """Each registered interceptor increases chain length by one."""
        chain = InterceptorChain()
        assert len(chain) == 0
        chain.register(NullInterceptor())
        assert len(chain) == 1
        chain.register(ExecutionTelemetryInterceptor())
        assert len(chain) == 2

    def test_unregister_by_name_removes_interceptor(self):
        """unregister() removes the named interceptor and returns True."""
        chain = InterceptorChain()
        chain.register(NullInterceptor())
        assert len(chain) == 1

        removed = chain.unregister("null_interceptor")
        assert removed is True
        assert len(chain) == 0

    def test_unregister_unknown_name_returns_false(self):
        """unregister() returns False when the name is not found."""
        chain = InterceptorChain()
        result = chain.unregister("does_not_exist")
        assert result is False

    def test_get_chain_summary_reflects_registered_interceptors(self):
        """get_chain_summary() returns the correct metadata for registered interceptors."""
        chain = InterceptorChain()
        chain.register(NullInterceptor())

        summary = chain.get_chain_summary()
        assert len(summary) == 1
        entry = summary[0]
        assert entry["name"] == "null_interceptor"
        assert entry["priority"] == 50
        assert entry["enabled"] is True


# ---------------------------------------------------------------------------
# BB4: TestDispatchToSwarm
# ---------------------------------------------------------------------------

class TestDispatchToSwarm:
    """Public dispatch_to_swarm API contracts."""

    @pytest.mark.asyncio
    async def test_dispatch_assigns_uuid_task_id(self):
        """dispatch_to_swarm assigns a UUID4 task_id when none is provided."""
        result = await dispatch_to_swarm({"prompt": "hello"})
        task_id = result.get("task_id", "")
        assert _is_valid_uuid4(task_id), f"Not a valid UUID4: {task_id!r}"

    @pytest.mark.asyncio
    async def test_dispatch_preserves_caller_supplied_task_id(self):
        """dispatch_to_swarm preserves a task_id already present in the payload."""
        custom_id = str(uuid.uuid4())
        result = await dispatch_to_swarm({"prompt": "keep my id", "task_id": custom_id})
        assert result["task_id"] == custom_id

    @pytest.mark.asyncio
    async def test_dispatch_success_path(self):
        """Successful dispatch returns status: completed with task_id present."""
        result = await dispatch_to_swarm({"prompt": "success test"})
        assert result["status"] == "completed"
        assert "task_id" in result

    @pytest.mark.asyncio
    async def test_dispatch_error_path(self):
        """Exception during _execute_task returns status: error with correction key."""
        with patch(
            "core.interceptors._execute_task",
            new_callable=AsyncMock,
            side_effect=RuntimeError("executor exploded"),
        ):
            result = await dispatch_to_swarm({"prompt": "will fail"})

        assert result["status"] == "error"
        assert "task_id" in result
        assert "correction" in result

    @pytest.mark.asyncio
    async def test_concurrent_dispatch_isolation(self):
        """Multiple concurrent dispatches each receive an independent UUID4 task_id."""
        payloads = [{"prompt": f"concurrent-{i}"} for i in range(20)]
        results = await asyncio.gather(*[dispatch_to_swarm(p) for p in payloads])

        task_ids = [r["task_id"] for r in results]
        # All task_ids must be unique
        assert len(set(task_ids)) == len(task_ids), (
            f"Duplicate task_ids detected among {len(task_ids)} concurrent dispatches"
        )
        # Every task_id must be a valid UUID4
        for tid in task_ids:
            assert _is_valid_uuid4(tid), f"Non-UUID4 task_id: {tid!r}"

    @pytest.mark.asyncio
    async def test_dispatch_stamps_dispatched_at(self):
        """dispatch_to_swarm stamps dispatched_at timestamp in ISO-8601 UTC format."""
        payload: dict = {"prompt": "timestamp test"}
        await dispatch_to_swarm(payload)
        # dispatched_at is written into the payload in-place
        assert "dispatched_at" in payload
        ts = payload["dispatched_at"]
        # Must parse as ISO-8601; datetime.fromisoformat handles this in Python 3.7+
        from datetime import datetime
        parsed = datetime.fromisoformat(ts)
        assert parsed is not None

    @pytest.mark.asyncio
    async def test_dispatch_tags_tier_when_provided(self):
        """Passing tier= argument writes it to the payload and returns it in the result."""
        result = await dispatch_to_swarm({"prompt": "tier test"}, tier="gold")
        # The payload (and therefore the result coming through the stub executor)
        # must carry the tier tag
        # We can confirm by inspecting the returned task_id and status.
        # Because dispatch_to_swarm writes tier into the payload and our stub
        # executor returns the enriched payload's fields, we verify tier was
        # stamped by checking the payload dict was mutated.
        assert result["status"] == "completed"


# ---------------------------------------------------------------------------
# BB5: TestNullInterceptor
# ---------------------------------------------------------------------------

class TestNullInterceptor:
    """NullInterceptor pass-through public contract."""

    def test_null_interceptor_is_concrete(self):
        """NullInterceptor can be instantiated without arguments."""
        null = NullInterceptor()
        assert isinstance(null, BaseInterceptor)

    def test_null_interceptor_metadata(self):
        """NullInterceptor metadata matches the documented defaults."""
        null = NullInterceptor()
        assert null.metadata.name == "null_interceptor"
        assert null.metadata.priority == 50
        assert null.metadata.enabled is True

    def test_null_interceptor_passes_through_pre_execute(self):
        """pre_execute returns the exact same object — no copy, no mutation."""
        null = NullInterceptor()
        payload = {"task": "pass through", "num": 42}
        result = asyncio.run(null.pre_execute(payload))
        # Same object identity — NullInterceptor must not copy
        assert result is payload
        assert result == {"task": "pass through", "num": 42}

    def test_null_interceptor_on_correction_prepends_prefix(self):
        """on_correction adds CORRECTION: prefix to prompt field."""
        null = NullInterceptor()
        payload = {"prompt": "retry this task"}
        result = asyncio.run(null.on_correction(payload))
        assert result["prompt"] == "CORRECTION: retry this task"

    def test_null_interceptor_on_correction_without_prompt_key(self):
        """on_correction leaves payload unchanged when prompt key is absent."""
        null = NullInterceptor()
        payload = {"other_key": "data"}
        result = asyncio.run(null.on_correction(payload))
        assert "prompt" not in result
        assert result["other_key"] == "data"


# ---------------------------------------------------------------------------
# BB6: TestTelemetryInterceptor
# ---------------------------------------------------------------------------

class TestTelemetryInterceptor:
    """ExecutionTelemetryInterceptor observable contract."""

    def test_telemetry_interceptor_is_concrete(self):
        """ExecutionTelemetryInterceptor can be instantiated without arguments."""
        tel = ExecutionTelemetryInterceptor()
        assert isinstance(tel, BaseInterceptor)

    def test_telemetry_interceptor_priority_is_zero(self):
        """Telemetry interceptor has priority=0 so it runs first."""
        tel = ExecutionTelemetryInterceptor()
        assert tel.metadata.priority == 0

    def test_telemetry_pre_execute_passes_payload_through(self):
        """pre_execute must return the payload unchanged (side-effects only)."""
        tel = ExecutionTelemetryInterceptor()
        payload = {"task_id": "abc-123", "prompt": "test"}

        with patch.object(tel, "_append"):  # suppress actual file I/O
            result = asyncio.run(tel.pre_execute(payload))

        assert result is payload, "pre_execute must return the same payload dict"

    def test_telemetry_records_dispatch_start_event(self):
        """pre_execute appends a dispatch_start event record."""
        tel = ExecutionTelemetryInterceptor()
        appended: list = []

        with patch.object(tel, "_append", side_effect=appended.append):
            asyncio.run(tel.pre_execute({"task_id": "t-001", "prompt": "record me"}))

        assert len(appended) == 1
        record = appended[0]
        assert record["event"] == "dispatch_start"
        assert record["task_id"] == "t-001"

    def test_telemetry_records_dispatch_complete_event(self):
        """post_execute appends a dispatch_complete event record."""
        tel = ExecutionTelemetryInterceptor()
        appended: list = []
        payload = {"task_id": "t-002", "prompt": "complete me"}

        with patch.object(tel, "_append", side_effect=appended.append):
            # Simulate a pre (to seed start time), then a post
            asyncio.run(tel.pre_execute(payload))
            asyncio.run(tel.post_execute({"status": "completed"}, payload))

        events = [r["event"] for r in appended]
        assert "dispatch_complete" in events

    def test_telemetry_records_dispatch_error_event(self):
        """on_error appends a dispatch_error event and returns an error dict."""
        tel = ExecutionTelemetryInterceptor()
        appended: list = []
        payload = {"task_id": "t-003"}

        with patch.object(tel, "_append", side_effect=appended.append):
            result = asyncio.run(
                tel.on_error(ValueError("something broke"), payload)
            )

        events = [r["event"] for r in appended]
        assert "dispatch_error" in events
        assert "error" in result


# ---------------------------------------------------------------------------
# BB7: TestFullPipeline
# ---------------------------------------------------------------------------

class TestFullPipeline:
    """End-to-end integration: register interceptors → dispatch → verify result."""

    @pytest.mark.asyncio
    async def test_end_to_end_dispatch_with_interceptors(self):
        """Full pipeline: fresh chain with NullInterceptor + Telemetry → dispatch → completed."""
        chain = InterceptorChain()

        # Suppress telemetry file I/O but keep the interceptor functional
        tel = ExecutionTelemetryInterceptor()
        with patch.object(tel, "_append"):
            chain.register(NullInterceptor())
            chain.register(tel)

            # Drive dispatch through the fresh chain (not GLOBAL_CHAIN)
            payload = {"prompt": "full pipeline test"}
            payload["task_id"] = str(uuid.uuid4())
            enriched = await chain.execute_pre(payload)
            # Simulate the stub executor
            task_result = {
                "task_id": enriched["task_id"],
                "output": enriched.get("prompt", ""),
                "status": "completed",
            }
            await chain.execute_post(task_result, enriched)

        # Verify the result has the required fields
        assert task_result["status"] == "completed"
        assert _is_valid_uuid4(task_result["task_id"])

    @pytest.mark.asyncio
    async def test_dispatch_with_multiple_enriching_interceptors(self):
        """Multiple interceptors all contribute enrichments; result carries all of them."""
        chain = InterceptorChain()
        chain.register(_EnrichingInterceptor("enriched_a", "value_a", priority=10))
        chain.register(_EnrichingInterceptor("enriched_b", "value_b", priority=20))

        payload = {"prompt": "multi-enrichment"}
        result = await chain.execute_pre(payload)

        assert result.get("enriched_a") == "value_a"
        assert result.get("enriched_b") == "value_b"

    @pytest.mark.asyncio
    async def test_error_chain_returns_correction_payload(self):
        """execute_error returns a correction payload from the first handler that succeeds."""
        chain = InterceptorChain()
        chain.register(NullInterceptor())  # has on_error that returns error dict

        payload = {"task_id": "err-test", "prompt": "will error"}
        error = RuntimeError("test failure")
        correction = await chain.execute_error(error, payload)

        # The chain returns something — not None, and contains an error description
        assert correction is not None
        assert isinstance(correction, dict)
        assert "error" in correction

    @pytest.mark.asyncio
    async def test_empty_chain_execute_pre_returns_payload_unchanged(self):
        """An empty chain's execute_pre returns the payload as-is."""
        chain = InterceptorChain()
        payload = {"prompt": "empty chain", "task_id": "x"}
        result = await chain.execute_pre(payload)
        assert result == payload

    @pytest.mark.asyncio
    async def test_empty_chain_execute_error_returns_unhandled_sentinel(self):
        """An empty chain's execute_error returns unhandled=True sentinel."""
        chain = InterceptorChain()
        result = await chain.execute_error(RuntimeError("no handlers"), {})
        assert result.get("unhandled") is True

    @pytest.mark.asyncio
    async def test_register_interceptor_global_helper_adds_to_global_chain(self):
        """register_interceptor() adds to GLOBAL_CHAIN (not a fresh chain)."""
        before = len(GLOBAL_CHAIN)
        sentinel = _OrderTrackingInterceptor(
            name="__integration_test_sentinel__",
            priority=999,
            call_log=[],
        )
        register_interceptor(sentinel)
        assert len(GLOBAL_CHAIN) == before + 1
        # Clean up so we don't pollute other tests
        GLOBAL_CHAIN.unregister("__integration_test_sentinel__")
        assert len(GLOBAL_CHAIN) == before


# ---------------------------------------------------------------------------
# VERIFICATION_STAMP
# Story: 1.06 (Track B)
# Verified By: parallel-builder
# Verified At: 2026-02-25T00:00:00Z
# Tests: 35/35
# Coverage: 100%
# ---------------------------------------------------------------------------
