#!/usr/bin/env python3
"""
Tests for Story 4.01 (Track B): TierRouter — Task Classification Router

Black Box tests (BB): verify the public contract from the outside.
White Box tests (WB): verify internal caching mechanics and structural guarantees.

Story: 4.01
File under test: core/routing/tier_router.py
"""

from __future__ import annotations

import hashlib
import json
import os
import sys
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, call

# ── Path bootstrap ────────────────────────────────────────────────────────────
sys.path.insert(0, "/mnt/e/genesis-system")

import pytest

from core.routing.tier_router import TierRouter, CACHE_TTL
from core.routing.tier_classifier import RoutingDecision


# ── Fixtures ──────────────────────────────────────────────────────────────────


@pytest.fixture()
def router_no_redis(tmp_path, monkeypatch):
    """TierRouter with no Redis and events log redirected to a temp file."""
    log_file = tmp_path / "events.jsonl"
    monkeypatch.setenv("EVENTS_LOG_PATH", str(log_file))
    # Re-import so EVENTS_LOG_PATH picks up the env var change
    import importlib
    import core.routing.tier_router as mod
    importlib.reload(mod)
    from core.routing.tier_router import TierRouter as _TR
    yield _TR(redis_client=None), log_file
    # Restore
    importlib.reload(mod)


@pytest.fixture()
def mock_redis():
    """In-memory dict-backed Redis mock with get / setex interface."""
    store: dict = {}

    class FakeRedis:
        def get(self, key):
            return store.get(key)  # Returns None on miss (like real Redis)

        def setex(self, key, ttl, value):
            store[key] = value  # Ignore TTL for tests

        def flush(self):
            store.clear()

        @property
        def store(self):
            return store

    return FakeRedis()


@pytest.fixture()
def router_with_redis(tmp_path, monkeypatch, mock_redis):
    """TierRouter with a mock Redis and events log redirected to a temp file."""
    log_file = tmp_path / "events.jsonl"
    monkeypatch.setenv("EVENTS_LOG_PATH", str(log_file))
    import importlib
    import core.routing.tier_router as mod
    importlib.reload(mod)
    from core.routing.tier_router import TierRouter as _TR
    yield _TR(redis_client=mock_redis), log_file, mock_redis
    importlib.reload(mod)


# ── BB tests ──────────────────────────────────────────────────────────────────


def test_bb1_crud_task_routes_to_t0(router_no_redis):
    """BB1: type='crud_operation' key in payload → T0, model='python_function'."""
    router, _ = router_no_redis
    decision = router.route({"type": "redis_read", "task_id": "t-001"})
    assert decision.tier == "T0"
    assert decision.model == "python_function"
    assert isinstance(decision, RoutingDecision)


def test_bb2_template_task_routes_to_t1(router_no_redis):
    """BB2: type='email_draft' (T1 type) → T1, model='gemini-flash'."""
    router, _ = router_no_redis
    decision = router.route({"type": "email_draft", "task_id": "t-002"})
    assert decision.tier == "T1"
    assert decision.model == "gemini-flash"


def test_bb3_unknown_task_routes_to_t2(router_no_redis):
    """BB3: type='completely_unknown_xyz' → T2, model='claude-opus-4-6'."""
    router, _ = router_no_redis
    decision = router.route({"type": "completely_unknown_xyz", "task_id": "t-003"})
    assert decision.tier == "T2"
    assert decision.model == "claude-opus-4-6"


def test_bb4_no_redis_still_returns_valid_decision(router_no_redis):
    """BB4: No Redis supplied → TierRouter still classifies and returns a valid RoutingDecision."""
    router, _ = router_no_redis
    for payload in [
        {"type": "health_check"},
        {"type": "faq_answer"},
        {"type": "never_seen_before_type"},
    ]:
        decision = router.route(payload)
        assert decision.tier in {"T0", "T1", "T2"}
        assert decision.model in {"python_function", "gemini-flash", "claude-opus-4-6"}
        assert decision.rationale  # non-empty


def test_bb5_same_type_twice_with_redis_second_call_is_cached(router_with_redis):
    """BB5: Same task type routed twice with Redis → second call is_cached=True."""
    router, _, _ = router_with_redis
    payload = {"type": "email_draft", "task_id": "t-cached-test"}

    first = router.route(payload)
    second = router.route(payload)

    assert first.is_cached is False, "First call must NOT be cached"
    assert second.is_cached is True, "Second call MUST be served from cache"
    assert second.tier == first.tier
    assert second.model == first.model


# ── WB tests ──────────────────────────────────────────────────────────────────


def test_wb1_cache_key_uses_hash_of_task_type(router_with_redis):
    """WB1: Cache key is genesis:routing:<md5_12> of task_type (not full payload)."""
    router, _, fake_redis = router_with_redis

    task_type = "email_draft"
    expected_hash = hashlib.md5(task_type.encode()).hexdigest()[:12]
    expected_key = f"genesis:routing:{expected_hash}"

    router.route({"type": task_type, "task_id": "t-wb1", "extra_key": "ignored"})

    # The key with the hash must exist in the Redis store
    assert expected_key in fake_redis.store, (
        f"Expected cache key '{expected_key}' not found in Redis store. "
        f"Found keys: {list(fake_redis.store.keys())}"
    )


def test_wb2_events_jsonl_written_on_every_route_call(router_no_redis):
    """WB2: events.jsonl gets one entry per route() call with correct fields."""
    router, log_file = router_no_redis

    payloads = [
        {"type": "health_check", "task_id": "evt-001"},
        {"type": "email_draft", "task_id": "evt-002"},
        {"type": "novel_task_type", "task_id": "evt-003"},
    ]

    for p in payloads:
        router.route(p)

    assert log_file.exists(), "events.jsonl was not created"
    lines = log_file.read_text().strip().split("\n")
    assert len(lines) == len(payloads), (
        f"Expected {len(payloads)} log entries, got {len(lines)}"
    )

    for line, payload in zip(lines, payloads):
        event = json.loads(line)
        assert event["event"] == "routing_decision"
        assert event["task_id"] == payload["task_id"]
        assert event["task_type"] == payload["type"]
        assert event["tier"] in {"T0", "T1", "T2"}
        assert event["model"] in {"python_function", "gemini-flash", "claude-opus-4-6"}
        assert "timestamp" in event
        assert "is_cached" in event


def test_wb3_cache_ttl_used_with_setex(tmp_path, monkeypatch):
    """WB3: Redis.setex is called with CACHE_TTL=300 as the TTL argument."""
    log_file = tmp_path / "events.jsonl"
    monkeypatch.setenv("EVENTS_LOG_PATH", str(log_file))

    import importlib
    import core.routing.tier_router as mod
    importlib.reload(mod)
    from core.routing.tier_router import TierRouter as _TR, CACHE_TTL as _TTL

    mock_redis = MagicMock()
    mock_redis.get.return_value = None  # Simulate cache miss

    router = _TR(redis_client=mock_redis)
    router.route({"type": "email_draft", "task_id": "t-ttl"})

    # setex must have been called once
    mock_redis.setex.assert_called_once()
    args = mock_redis.setex.call_args[0]  # positional args: (key, ttl, value)
    ttl_arg = args[1]
    assert ttl_arg == _TTL, f"Expected TTL={_TTL}, got {ttl_arg}"
    assert _TTL == 300, "CACHE_TTL constant must be 300"

    importlib.reload(mod)


def test_wb4_redis_read_failure_falls_through_to_classify(tmp_path, monkeypatch):
    """WB4: Redis.get raising an exception → TierRouter still classifies successfully."""
    log_file = tmp_path / "events.jsonl"
    monkeypatch.setenv("EVENTS_LOG_PATH", str(log_file))

    import importlib
    import core.routing.tier_router as mod
    importlib.reload(mod)
    from core.routing.tier_router import TierRouter as _TR

    exploding_redis = MagicMock()
    exploding_redis.get.side_effect = ConnectionError("Redis is down")

    router = _TR(redis_client=exploding_redis)
    decision = router.route({"type": "health_check", "task_id": "t-redis-fail"})

    # Must still return a valid decision despite the Redis error
    assert decision.tier == "T0"
    assert decision.model == "python_function"
    assert decision.is_cached is False

    importlib.reload(mod)


def test_wb5_cached_result_preserves_tier_and_model(router_with_redis):
    """WB5: Cached RoutingDecision has same tier and model as original classification."""
    router, _, _ = router_with_redis

    payload = {"type": "faq_answer", "task_id": "cache-integrity"}
    first = router.route(payload)
    second = router.route(payload)

    assert second.tier == first.tier
    assert second.model == first.model
    assert second.rationale == first.rationale
    assert second.is_cached is True


def test_wb6_no_redis_is_cached_is_always_false(router_no_redis):
    """WB6: Without Redis, is_cached is never True even for repeated calls."""
    router, _ = router_no_redis
    payload = {"type": "email_draft"}

    for _ in range(3):
        decision = router.route(payload)
        assert decision.is_cached is False, "is_cached must be False when Redis is not configured"


# ── Import / export sanity ────────────────────────────────────────────────────


def test_tier_router_importable_from_package():
    """TierRouter is exported from core.routing.__init__."""
    from core.routing import TierRouter as TR
    assert TR is TierRouter


def test_cache_ttl_constant_value():
    """CACHE_TTL constant must equal 300."""
    assert CACHE_TTL == 300


# ── Standalone runner ────────────────────────────────────────────────────────

if __name__ == "__main__":
    """Allow: python tests/track_b/test_story_4_01.py"""
    import importlib
    import core.routing.tier_router as _mod
    importlib.reload(_mod)
    from core.routing.tier_router import TierRouter as _TR

    # Quick smoke test without Redis
    router = _TR()
    cases = [
        ({"type": "redis_read"},             "T0", "python_function"),
        ({"type": "email_draft"},            "T1", "gemini-flash"),
        ({"type": "novel_xyz"},              "T2", "claude-opus-4-6"),
        ({"type": "x", "template": "t"},     "T1", "gemini-flash"),
        ({"type": "x", "crud_operation": 1}, "T0", "python_function"),
    ]

    passed = 0
    for payload, exp_tier, exp_model in cases:
        d = router.route(payload)
        ok = d.tier == exp_tier and d.model == exp_model
        status = "PASS" if ok else "FAIL"
        if ok:
            passed += 1
        print(f"  [{status}] {payload['type']} → tier={d.tier}, model={d.model}")

    print(f"\n{passed}/{len(cases)} smoke tests passed")
    sys.exit(0 if passed == len(cases) else 1)
