#!/usr/bin/env python3
"""
Tests for Story 5.07 (Track B): ShadowRouter — Side-Effect Gating

Black Box tests (BB): verify the public contract from the caller's perspective —
    SHADOW mode logs without executing, LIVE mode executes the handler,
    per-type overrides override the default, shadow log is written, invalid
    effect types raise ValueError.

White Box tests (WB): verify internals — ShadowResult.executed flag, sha256
    payload hash, default mode when env var absent, malformed SHADOW_OVERRIDES
    JSON, LIVE handler exception does not crash the router.

ALL tests use mocks — NO real file I/O to shadow_log.jsonl (except BB4 which
uses tmp_path via monkeypatching) and NO real external calls.

Story: 5.07
File under test: core/storage/shadow_router.py
"""

from __future__ import annotations

import hashlib
import json
import os
import sys
sys.path.insert(0, "/mnt/e/genesis-system")

import pathlib
from unittest.mock import MagicMock, patch, mock_open, call

import pytest

from core.storage.shadow_router import (
    ShadowRouter,
    ShadowResult,
    VALID_EFFECT_TYPES,
    SHADOW_LOG_PATH,
)
from core.storage import ShadowRouter as ShadowRouterFromPackage
from core.storage import ShadowResult as ShadowResultFromPackage


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _router_in_mode(mode: str, overrides: str = "{}") -> ShadowRouter:
    """Return a ShadowRouter with SHADOW_MODE and optional SHADOW_OVERRIDES set."""
    env = {"SHADOW_MODE": mode, "SHADOW_OVERRIDES": overrides}
    with patch.dict(os.environ, env, clear=False):
        return ShadowRouter()


def _sha256(payload: dict) -> str:
    return hashlib.sha256(
        json.dumps(payload, sort_keys=True).encode("utf-8")
    ).hexdigest()


# ===========================================================================
# Black Box tests
# ===========================================================================


class TestBB1ShadowModeLogsNoExecution:
    """BB1: SHADOW_MODE="SHADOW" → all effects logged, none executed."""

    def test_all_valid_effect_types_return_executed_false(self):
        router = _router_in_mode("SHADOW")
        handler = MagicMock()
        for etype in VALID_EFFECT_TYPES:
            router.register_handler(etype, handler)
        with patch("builtins.open", mock_open()):
            with patch("pathlib.Path.mkdir"):
                for etype in VALID_EFFECT_TYPES:
                    result = router.route_side_effect(etype, {"data": "x"})
                    assert result.executed is False, (
                        f"effect_type={etype!r}: executed must be False in SHADOW mode"
                    )

    def test_shadow_mode_handler_never_called(self):
        router = _router_in_mode("SHADOW")
        handler = MagicMock()
        router.register_handler("email", handler)
        with patch("builtins.open", mock_open()):
            with patch("pathlib.Path.mkdir"):
                router.route_side_effect("email", {"to": "a@b.com"})
        handler.assert_not_called()

    def test_shadow_result_mode_field_is_SHADOW(self):
        router = _router_in_mode("SHADOW")
        with patch("builtins.open", mock_open()):
            with patch("pathlib.Path.mkdir"):
                result = router.route_side_effect("sms", {"number": "+61"})
        assert result.mode == "SHADOW"


class TestBB2LiveModeHandlerCalled:
    """BB2: SHADOW_MODE="LIVE" → handler called, executed=True."""

    def test_executed_true_in_live_mode(self):
        router = _router_in_mode("LIVE")
        handler = MagicMock()
        router.register_handler("email", handler)
        result = router.route_side_effect("email", {"to": "a@b.com"})
        assert result.executed is True

    def test_handler_called_with_payload(self):
        router = _router_in_mode("LIVE")
        handler = MagicMock()
        payload = {"to": "x@example.com", "subject": "Hello"}
        router.register_handler("email", handler)
        router.route_side_effect("email", payload)
        handler.assert_called_once_with(payload)

    def test_live_mode_no_handler_still_returns_executed_true(self):
        """If no handler is registered in LIVE mode, still returns executed=True."""
        router = _router_in_mode("LIVE")
        # No handler registered for crm_write
        result = router.route_side_effect("crm_write", {"record": "123"})
        assert result.executed is True
        assert result.mode == "LIVE"


class TestBB3ShadowOverridesPerEffectType:
    """BB3: SHADOW_OVERRIDES={"email":"SHADOW"} → email shadowed, sms live."""

    def test_email_shadowed_sms_live(self):
        overrides = json.dumps({"email": "SHADOW"})
        router = _router_in_mode("LIVE", overrides=overrides)
        email_handler = MagicMock()
        sms_handler = MagicMock()
        router.register_handler("email", email_handler)
        router.register_handler("sms", sms_handler)

        with patch("builtins.open", mock_open()):
            with patch("pathlib.Path.mkdir"):
                email_result = router.route_side_effect("email", {"to": "a@b.com"})
                sms_result = router.route_side_effect("sms", {"number": "+61"})

        # email → SHADOW
        assert email_result.mode == "SHADOW"
        assert email_result.executed is False
        email_handler.assert_not_called()

        # sms → LIVE (global default)
        assert sms_result.mode == "LIVE"
        assert sms_result.executed is True
        sms_handler.assert_called_once()

    def test_all_types_shadowed_via_overrides(self):
        overrides = json.dumps({et: "SHADOW" for et in VALID_EFFECT_TYPES})
        router = _router_in_mode("LIVE", overrides=overrides)
        with patch("builtins.open", mock_open()):
            with patch("pathlib.Path.mkdir"):
                for etype in VALID_EFFECT_TYPES:
                    result = router.route_side_effect(etype, {})
                    assert result.mode == "SHADOW", (
                        f"{etype!r} should be SHADOW via override"
                    )


class TestBB4ShadowLogFileUpdated:
    """BB4: Shadow log file updated after shadow execution."""

    def test_shadow_log_written(self, tmp_path):
        """Use tmp_path to write a real log and verify the JSONL line."""
        log_file = tmp_path / "shadow_log.jsonl"

        router = _router_in_mode("SHADOW")

        # Patch the module-level SHADOW_LOG_PATH to point to tmp_path
        with patch("core.storage.shadow_router.SHADOW_LOG_PATH", log_file):
            router.route_side_effect("email", {"to": "test@example.com"})

        assert log_file.exists(), "Shadow log file must be created"
        lines = log_file.read_text().strip().splitlines()
        assert len(lines) == 1, "Expected exactly 1 JSONL entry"
        entry = json.loads(lines[0])
        assert entry["effect_type"] == "email"
        assert entry["mode"] == "SHADOW"
        assert "payload_hash" in entry
        assert "timestamp" in entry

    def test_shadow_log_appends_multiple_entries(self, tmp_path):
        log_file = tmp_path / "shadow_log.jsonl"
        router = _router_in_mode("SHADOW")
        with patch("core.storage.shadow_router.SHADOW_LOG_PATH", log_file):
            router.route_side_effect("email", {"to": "a@b.com"})
            router.route_side_effect("sms", {"number": "+61"})
        lines = log_file.read_text().strip().splitlines()
        assert len(lines) == 2
        types = {json.loads(l)["effect_type"] for l in lines}
        assert types == {"email", "sms"}

    def test_live_mode_does_not_write_shadow_log(self, tmp_path):
        log_file = tmp_path / "shadow_log.jsonl"
        router = _router_in_mode("LIVE")
        with patch("core.storage.shadow_router.SHADOW_LOG_PATH", log_file):
            router.route_side_effect("email", {"to": "a@b.com"})
        assert not log_file.exists(), "LIVE mode must NOT write to shadow log"


class TestBB5InvalidEffectTypeRaisesValueError:
    """BB5: Invalid effect_type raises ValueError."""

    def test_unknown_type_raises(self):
        router = _router_in_mode("LIVE")
        with pytest.raises(ValueError, match="Unknown effect_type"):
            router.route_side_effect("fax_machine", {"number": "555"})

    def test_empty_string_raises(self):
        router = _router_in_mode("LIVE")
        with pytest.raises(ValueError):
            router.route_side_effect("", {})

    def test_valid_types_do_not_raise(self):
        router = _router_in_mode("LIVE")
        for etype in VALID_EFFECT_TYPES:
            # Should not raise
            result = router.route_side_effect(etype, {})
            assert result is not None


# ===========================================================================
# White Box tests
# ===========================================================================


class TestWB1ShadowResultExecutedFlag:
    """WB1: ShadowResult.executed=False in SHADOW mode, True in LIVE mode."""

    def test_shadow_result_executed_false(self):
        router = _router_in_mode("SHADOW")
        with patch("builtins.open", mock_open()):
            with patch("pathlib.Path.mkdir"):
                result = router.route_side_effect("external_api", {"url": "http://x"})
        assert isinstance(result, ShadowResult)
        assert result.executed is False

    def test_live_result_executed_true(self):
        router = _router_in_mode("LIVE")
        result = router.route_side_effect("external_api", {"url": "http://x"})
        assert isinstance(result, ShadowResult)
        assert result.executed is True

    def test_shadow_result_has_log_entry_dict(self):
        router = _router_in_mode("SHADOW")
        with patch("builtins.open", mock_open()):
            with patch("pathlib.Path.mkdir"):
                result = router.route_side_effect("sms", {"number": "+61"})
        assert isinstance(result.log_entry, dict)
        assert "effect_type" in result.log_entry
        assert "payload_hash" in result.log_entry
        assert "timestamp" in result.log_entry
        assert "mode" in result.log_entry


class TestWB2PayloadHashIsSha256:
    """WB2: payload_hash is sha256 of sorted JSON payload."""

    def test_hash_matches_sha256_of_sorted_json(self):
        router = _router_in_mode("LIVE")
        payload = {"b": 2, "a": 1, "c": "hello"}
        expected_hash = _sha256(payload)
        result = router.route_side_effect("email", payload)
        assert result.log_entry["payload_hash"] == expected_hash

    def test_different_payloads_produce_different_hashes(self):
        router = _router_in_mode("LIVE")
        result1 = router.route_side_effect("email", {"to": "a@b.com"})
        result2 = router.route_side_effect("email", {"to": "z@y.com"})
        assert result1.log_entry["payload_hash"] != result2.log_entry["payload_hash"]

    def test_same_payload_different_key_order_same_hash(self):
        """Sort-key ensures key order doesn't affect hash."""
        router = _router_in_mode("LIVE")
        payload_ab = {"a": 1, "b": 2}
        payload_ba = {"b": 2, "a": 1}
        result1 = router.route_side_effect("email", payload_ab)
        result2 = router.route_side_effect("email", payload_ba)
        assert result1.log_entry["payload_hash"] == result2.log_entry["payload_hash"]


class TestWB3DefaultModeIsLive:
    """WB3: mode defaults to "LIVE" when SHADOW_MODE env not set."""

    def test_default_mode_when_env_absent(self):
        env_without_shadow = {k: v for k, v in os.environ.items()
                              if k not in ("SHADOW_MODE", "SHADOW_OVERRIDES")}
        with patch.dict(os.environ, env_without_shadow, clear=True):
            router = ShadowRouter()
        assert router.default_mode == "LIVE"

    def test_get_mode_returns_live_for_any_type_when_default_live(self):
        with patch.dict(os.environ, {"SHADOW_MODE": "LIVE"}, clear=False):
            router = ShadowRouter()
        for etype in VALID_EFFECT_TYPES:
            assert router.get_mode(etype) == "LIVE"


class TestWB4MalformedShadowOverrides:
    """WB4: SHADOW_OVERRIDES malformed JSON → defaults to empty dict."""

    def test_malformed_json_falls_back_to_empty(self):
        with patch.dict(os.environ,
                        {"SHADOW_MODE": "LIVE", "SHADOW_OVERRIDES": "NOT_JSON"},
                        clear=False):
            router = ShadowRouter()
        assert router._overrides == {}

    def test_non_dict_json_falls_back_to_empty(self):
        with patch.dict(os.environ,
                        {"SHADOW_MODE": "LIVE", "SHADOW_OVERRIDES": '["email"]'},
                        clear=False):
            router = ShadowRouter()
        assert router._overrides == {}

    def test_empty_json_object_is_valid(self):
        with patch.dict(os.environ,
                        {"SHADOW_MODE": "SHADOW", "SHADOW_OVERRIDES": "{}"},
                        clear=False):
            router = ShadowRouter()
        assert router._overrides == {}


class TestWB5LiveHandlerExceptionNocrash:
    """WB5: LIVE handler exception → logged, ShadowResult still returned."""

    def test_handler_exception_does_not_propagate(self):
        router = _router_in_mode("LIVE")

        def boom(payload):
            raise RuntimeError("External service down")

        router.register_handler("telnyx_call", boom)
        # Must NOT raise
        result = router.route_side_effect("telnyx_call", {"phone": "+61"})
        assert result is not None
        assert result.executed is True  # executed=True because LIVE mode was entered
        assert result.mode == "LIVE"

    def test_handler_exception_recorded_in_log_entry(self):
        router = _router_in_mode("LIVE")

        def boom(payload):
            raise ValueError("Config missing")

        router.register_handler("ghl_webhook", boom)
        result = router.route_side_effect("ghl_webhook", {"event": "lead"})
        assert "error" in result.log_entry
        assert "Config missing" in result.log_entry["error"]

    def test_second_handler_still_works_after_first_fails(self):
        """A failing handler for one type must not affect another type's handler."""
        router = _router_in_mode("LIVE")
        good_handler = MagicMock()

        def boom(payload):
            raise RuntimeError("boom")

        router.register_handler("email", boom)
        router.register_handler("sms", good_handler)

        router.route_side_effect("email", {})  # fails silently
        router.route_side_effect("sms", {"number": "+61"})

        good_handler.assert_called_once()


# ===========================================================================
# Package export tests
# ===========================================================================


class TestPackageExports:
    """ShadowRouter and ShadowResult must be importable directly from core.storage."""

    def test_shadow_router_importable_from_package(self):
        assert ShadowRouterFromPackage is ShadowRouter

    def test_shadow_result_importable_from_package(self):
        assert ShadowResultFromPackage is ShadowResult

    def test_all_includes_shadow_router(self):
        from core.storage import __all__
        assert "ShadowRouter" in __all__

    def test_all_includes_shadow_result(self):
        from core.storage import __all__
        assert "ShadowResult" in __all__


# ===========================================================================
# No SQLite test
# ===========================================================================


class TestNoSQLite:
    """shadow_router.py must not import sqlite3."""

    def test_no_sqlite3_in_source(self):
        source = pathlib.Path(
            "/mnt/e/genesis-system/core/storage/shadow_router.py"
        ).read_text()
        assert "import sqlite3" not in source, (
            "shadow_router.py must NOT import sqlite3 — Genesis Rule 7 (no SQLite)"
        )

    def test_sqlite3_not_in_module_namespace(self):
        import core.storage.shadow_router as mod
        assert not hasattr(mod, "sqlite3"), (
            "sqlite3 must not appear in shadow_router module namespace"
        )


# ===========================================================================
# VALID_EFFECT_TYPES completeness tests
# ===========================================================================


class TestValidEffectTypes:
    """Verify the canonical set of effect types."""

    EXPECTED_TYPES = {
        "email", "sms", "crm_write", "telnyx_call", "ghl_webhook", "external_api"
    }

    def test_all_expected_types_present(self):
        assert self.EXPECTED_TYPES == VALID_EFFECT_TYPES

    def test_valid_effect_types_is_frozenset(self):
        assert isinstance(VALID_EFFECT_TYPES, frozenset)


# ===========================================================================
# Standalone runner
# ===========================================================================


if __name__ == "__main__":
    import traceback

    def _run(name, fn, *args, **kwargs):
        try:
            fn(*args, **kwargs)
            print(f"  [PASS] {name}")
            return True
        except Exception as exc:
            print(f"  [FAIL] {name}: {exc}")
            traceback.print_exc()
            return False

    results = []

    # BB
    results.append(_run("BB1a: shadow mode returns executed=False",
        TestBB1ShadowModeLogsNoExecution().test_all_valid_effect_types_return_executed_false))
    results.append(_run("BB1b: shadow mode handler never called",
        TestBB1ShadowModeLogsNoExecution().test_shadow_mode_handler_never_called))
    results.append(_run("BB1c: shadow result mode is SHADOW",
        TestBB1ShadowModeLogsNoExecution().test_shadow_result_mode_field_is_SHADOW))
    results.append(_run("BB2a: live mode executed=True",
        TestBB2LiveModeHandlerCalled().test_executed_true_in_live_mode))
    results.append(_run("BB2b: live handler called with payload",
        TestBB2LiveModeHandlerCalled().test_handler_called_with_payload))
    results.append(_run("BB2c: live no handler still executed=True",
        TestBB2LiveModeHandlerCalled().test_live_mode_no_handler_still_returns_executed_true))
    results.append(_run("BB3a: email shadowed sms live",
        TestBB3ShadowOverridesPerEffectType().test_email_shadowed_sms_live))
    results.append(_run("BB3b: all types shadowed via overrides",
        TestBB3ShadowOverridesPerEffectType().test_all_types_shadowed_via_overrides))
    # BB4 requires tmp_path — skipped in standalone runner
    results.append(_run("BB5a: unknown type raises",
        TestBB5InvalidEffectTypeRaisesValueError().test_unknown_type_raises))
    results.append(_run("BB5b: empty string raises",
        TestBB5InvalidEffectTypeRaisesValueError().test_empty_string_raises))
    results.append(_run("BB5c: valid types do not raise",
        TestBB5InvalidEffectTypeRaisesValueError().test_valid_types_do_not_raise))

    # WB
    results.append(_run("WB1a: shadow result executed=False",
        TestWB1ShadowResultExecutedFlag().test_shadow_result_executed_false))
    results.append(_run("WB1b: live result executed=True",
        TestWB1ShadowResultExecutedFlag().test_live_result_executed_true))
    results.append(_run("WB1c: shadow result has log_entry dict",
        TestWB1ShadowResultExecutedFlag().test_shadow_result_has_log_entry_dict))
    results.append(_run("WB2a: hash matches sha256 sorted JSON",
        TestWB2PayloadHashIsSha256().test_hash_matches_sha256_of_sorted_json))
    results.append(_run("WB2b: different payloads → different hashes",
        TestWB2PayloadHashIsSha256().test_different_payloads_produce_different_hashes))
    results.append(_run("WB2c: key order doesn't affect hash",
        TestWB2PayloadHashIsSha256().test_same_payload_different_key_order_same_hash))
    results.append(_run("WB3a: default mode is LIVE when env absent",
        TestWB3DefaultModeIsLive().test_default_mode_when_env_absent))
    results.append(_run("WB3b: get_mode returns LIVE for all types",
        TestWB3DefaultModeIsLive().test_get_mode_returns_live_for_any_type_when_default_live))
    results.append(_run("WB4a: malformed JSON → empty dict",
        TestWB4MalformedShadowOverrides().test_malformed_json_falls_back_to_empty))
    results.append(_run("WB4b: non-dict JSON → empty dict",
        TestWB4MalformedShadowOverrides().test_non_dict_json_falls_back_to_empty))
    results.append(_run("WB4c: empty JSON object valid",
        TestWB4MalformedShadowOverrides().test_empty_json_object_is_valid))
    results.append(_run("WB5a: handler exception does not propagate",
        TestWB5LiveHandlerExceptionNocrash().test_handler_exception_does_not_propagate))
    results.append(_run("WB5b: exception recorded in log_entry",
        TestWB5LiveHandlerExceptionNocrash().test_handler_exception_recorded_in_log_entry))
    results.append(_run("WB5c: second handler works after first fails",
        TestWB5LiveHandlerExceptionNocrash().test_second_handler_still_works_after_first_fails))

    # Package / SQLite
    results.append(_run("PKG: ShadowRouter importable from package",
        TestPackageExports().test_shadow_router_importable_from_package))
    results.append(_run("PKG: ShadowResult importable from package",
        TestPackageExports().test_shadow_result_importable_from_package))
    results.append(_run("PKG: __all__ includes ShadowRouter",
        TestPackageExports().test_all_includes_shadow_router))
    results.append(_run("PKG: __all__ includes ShadowResult",
        TestPackageExports().test_all_includes_shadow_result))
    results.append(_run("NOSQL: no sqlite3 in source",
        TestNoSQLite().test_no_sqlite3_in_source))
    results.append(_run("NOSQL: sqlite3 not in module namespace",
        TestNoSQLite().test_sqlite3_not_in_module_namespace))
    results.append(_run("VET: all expected effect types present",
        TestValidEffectTypes().test_all_expected_types_present))
    results.append(_run("VET: VALID_EFFECT_TYPES is frozenset",
        TestValidEffectTypes().test_valid_effect_types_is_frozenset))

    passed = sum(results)
    total = len(results)
    print(f"\n{passed}/{total} tests passed")
    if passed < total:
        sys.exit(1)
    else:
        print("ALL TESTS PASSED -- Story 5.07 (Track B): ShadowRouter")
