"""
tests/test_context_compiler.py — Tests for ContextCompiler (Issue #363).

Covers:
  - Item classification (each type correctly classified)
  - Budget allocation (weights respected, total = budget)
  - Compression within bundles (items compressed to fit)
  - Priority ordering (bundles in correct injection order)
  - Over-budget handling (compress → drop lowest priority)
  - Empty input → empty output
  - Single item → single bundle
  - Task mode influence on classification
  - Total token cap never exceeded
"""

from __future__ import annotations

from datetime import datetime, timedelta, timezone

import pytest

from cortex.core.context_compiler import (
    Bundle,
    BundleType,
    CompiledContext,
    ContextCompiler,
    DEFAULT_BUDGET_WEIGHTS,
)
from cortex.core.task_mode import TaskMode
from cortex.core.token_budget import TokenBudgetManager
from cortex.types import CompressionLevel


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------

@pytest.fixture
def manager() -> TokenBudgetManager:
    """Default token budget manager."""
    return TokenBudgetManager()


@pytest.fixture
def compiler(manager: TokenBudgetManager) -> ContextCompiler:
    """Default context compiler."""
    return ContextCompiler(token_budget=manager)


def _make_item(
    content: str = "test content",
    type: str = "",
    source: str = "",
    relevance_score: float = 0.0,
    task_mode: str = "",
    stability: str = "",
    created_at: str = "",
    session_id: str = "",
) -> dict:
    """Helper to create a memory item dict."""
    return {
        "content": content,
        "type": type,
        "source": source,
        "relevance_score": relevance_score,
        "task_mode": task_mode,
        "stability": stability,
        "created_at": created_at,
        "session_id": session_id,
    }


def _recent_ts() -> str:
    """Return an ISO timestamp from 1 hour ago (within 24h window)."""
    return (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()


def _old_ts() -> str:
    """Return an ISO timestamp from 48 hours ago (outside 24h window)."""
    return (datetime.now(timezone.utc) - timedelta(hours=48)).isoformat()


# ---------------------------------------------------------------------------
# BundleType enum
# ---------------------------------------------------------------------------

class TestBundleType:
    def test_values(self):
        assert BundleType.BOOT.value == "boot"
        assert BundleType.OPEN_LOOPS.value == "open_loops"
        assert BundleType.TASK.value == "task"
        assert BundleType.PROCEDURES.value == "procedures"
        assert BundleType.RECENT_HISTORY.value == "recent_history"
        assert BundleType.DEFAULTS.value == "defaults"
        assert BundleType.EVIDENCE.value == "evidence"

    def test_priority_order(self):
        ordered = BundleType.by_priority()
        assert ordered[0] == BundleType.BOOT
        assert ordered[-1] == BundleType.EVIDENCE

    def test_reverse_priority(self):
        rev = BundleType.reverse_priority()
        assert rev[0] == BundleType.EVIDENCE
        assert rev[-1] == BundleType.BOOT

    def test_all_seven_types(self):
        assert len(BundleType) == 7

    def test_budget_weights_sum_to_one(self):
        total = sum(DEFAULT_BUDGET_WEIGHTS.values())
        assert abs(total - 1.0) < 0.01


# ---------------------------------------------------------------------------
# Item Classification
# ---------------------------------------------------------------------------

class TestItemClassification:
    def test_handoff_source_is_boot(self, compiler: ContextCompiler):
        item = _make_item(source="handoff")
        assert compiler._classify_item(item) == BundleType.BOOT

    def test_identity_type_is_boot(self, compiler: ContextCompiler):
        item = _make_item(type="identity")
        assert compiler._classify_item(item) == BundleType.BOOT

    def test_commitment_is_open_loops(self, compiler: ContextCompiler):
        item = _make_item(type="commitment")
        assert compiler._classify_item(item) == BundleType.OPEN_LOOPS

    def test_open_loop_type_is_open_loops(self, compiler: ContextCompiler):
        item = _make_item(type="open_loop")
        assert compiler._classify_item(item) == BundleType.OPEN_LOOPS

    def test_procedure_is_procedures(self, compiler: ContextCompiler):
        item = _make_item(type="procedure")
        assert compiler._classify_item(item) == BundleType.PROCEDURES

    def test_runbook_is_procedures(self, compiler: ContextCompiler):
        item = _make_item(type="runbook")
        assert compiler._classify_item(item) == BundleType.PROCEDURES

    def test_high_relevance_with_task_mode_is_task(self, compiler: ContextCompiler):
        item = _make_item(relevance_score=0.9, task_mode="debug")
        assert compiler._classify_item(item, TaskMode.DEBUG) == BundleType.TASK

    def test_high_relevance_without_task_mode_is_not_task(self, compiler: ContextCompiler):
        item = _make_item(relevance_score=0.9)
        # Without task_mode param, should not classify as TASK
        assert compiler._classify_item(item, None) != BundleType.TASK

    def test_low_relevance_with_task_mode_is_not_task(self, compiler: ContextCompiler):
        item = _make_item(relevance_score=0.3, task_mode="debug")
        result = compiler._classify_item(item, TaskMode.DEBUG)
        assert result != BundleType.TASK

    def test_recent_item_is_recent_history(self, compiler: ContextCompiler):
        item = _make_item(created_at=_recent_ts())
        assert compiler._classify_item(item) == BundleType.RECENT_HISTORY

    def test_old_item_is_not_recent_history(self, compiler: ContextCompiler):
        item = _make_item(created_at=_old_ts())
        assert compiler._classify_item(item) != BundleType.RECENT_HISTORY

    def test_preference_is_defaults(self, compiler: ContextCompiler):
        item = _make_item(type="preference", created_at=_old_ts())
        assert compiler._classify_item(item) == BundleType.DEFAULTS

    def test_stable_fact_is_defaults(self, compiler: ContextCompiler):
        item = _make_item(type="fact", stability="stable", created_at=_old_ts())
        assert compiler._classify_item(item) == BundleType.DEFAULTS

    def test_unstable_fact_is_evidence(self, compiler: ContextCompiler):
        item = _make_item(type="fact", stability="volatile", created_at=_old_ts())
        assert compiler._classify_item(item) == BundleType.EVIDENCE

    def test_unknown_type_is_evidence(self, compiler: ContextCompiler):
        item = _make_item(type="unknown", created_at=_old_ts())
        assert compiler._classify_item(item) == BundleType.EVIDENCE

    def test_empty_item_is_evidence(self, compiler: ContextCompiler):
        item = _make_item(created_at=_old_ts())
        assert compiler._classify_item(item) == BundleType.EVIDENCE


# ---------------------------------------------------------------------------
# Budget Allocation
# ---------------------------------------------------------------------------

class TestBudgetAllocation:
    def test_allocation_sums_to_budget(self, compiler: ContextCompiler):
        classified = {
            BundleType.BOOT: [_make_item()],
            BundleType.TASK: [_make_item()],
            BundleType.EVIDENCE: [_make_item()],
        }
        allocation = compiler._allocate_budget(classified, 1000)
        total = sum(allocation.values())
        assert total == 1000

    def test_weights_proportional(self, compiler: ContextCompiler):
        # All bundles populated — weights should be proportional
        classified = {bt: [_make_item()] for bt in BundleType}
        allocation = compiler._allocate_budget(classified, 1000)

        # Task (25%) should get more than procedures (10%)
        assert allocation[BundleType.TASK] > allocation[BundleType.PROCEDURES]

    def test_empty_bundles_excluded(self, compiler: ContextCompiler):
        classified = {
            BundleType.BOOT: [_make_item()],
        }
        allocation = compiler._allocate_budget(classified, 1000)
        assert BundleType.BOOT in allocation
        assert BundleType.EVIDENCE not in allocation
        # Single bundle gets full budget
        assert allocation[BundleType.BOOT] == 1000

    def test_zero_budget(self, compiler: ContextCompiler):
        classified = {BundleType.BOOT: [_make_item()]}
        allocation = compiler._allocate_budget(classified, 0)
        assert allocation == {}

    def test_empty_classified(self, compiler: ContextCompiler):
        allocation = compiler._allocate_budget({}, 1000)
        assert allocation == {}


# ---------------------------------------------------------------------------
# Compilation
# ---------------------------------------------------------------------------

class TestCompile:
    def test_empty_input_returns_empty(self, compiler: ContextCompiler):
        result = compiler.compile([], 1000)
        assert result.bundles == []
        assert result.total_tokens == 0
        assert result.budget_remaining == 1000

    def test_zero_budget_returns_empty(self, compiler: ContextCompiler):
        items = [_make_item(source="handoff")]
        result = compiler.compile(items, 0)
        assert result.bundles == []
        assert result.total_tokens == 0

    def test_single_item_single_bundle(self, compiler: ContextCompiler):
        items = [_make_item(content="I am Eva", source="handoff")]
        result = compiler.compile(items, 1000)
        assert len(result.bundles) == 1
        assert result.bundles[0].bundle_type == BundleType.BOOT
        assert result.total_tokens > 0
        assert result.total_tokens <= 1000

    def test_multiple_types_create_multiple_bundles(self, compiler: ContextCompiler):
        items = [
            _make_item(content="identity info", source="handoff"),
            _make_item(content="deploy to prod", type="procedure"),
            _make_item(content="old memory detail", created_at=_old_ts()),
        ]
        result = compiler.compile(items, 2000)
        assert len(result.bundles) >= 2
        bundle_types = {b.bundle_type for b in result.bundles}
        assert BundleType.BOOT in bundle_types

    def test_bundles_ordered_by_priority(self, compiler: ContextCompiler):
        items = [
            _make_item(content="evidence stuff", created_at=_old_ts()),
            _make_item(content="identity", source="handoff"),
            _make_item(content="commit X", type="commitment"),
        ]
        result = compiler.compile(items, 2000)
        priorities = [b.bundle_type.priority for b in result.bundles]
        assert priorities == sorted(priorities)

    def test_total_tokens_never_exceeds_budget(self, compiler: ContextCompiler):
        # Generate many items to stress the budget
        items = [
            _make_item(content=f"This is a long memory item number {i} with lots of content to fill up tokens", created_at=_old_ts())
            for i in range(50)
        ]
        budget = 200
        result = compiler.compile(items, budget)
        assert result.total_tokens <= budget
        assert result.budget_remaining >= 0
        assert result.total_tokens + result.budget_remaining == budget

    def test_task_mode_influences_classification(self, compiler: ContextCompiler):
        items = [
            _make_item(
                content="debugging the auth module",
                relevance_score=0.9,
                task_mode="debug",
            ),
        ]
        result = compiler.compile(items, 1000, task_mode=TaskMode.DEBUG)
        assert len(result.bundles) >= 1
        bundle_types = {b.bundle_type for b in result.bundles}
        assert BundleType.TASK in bundle_types

    def test_compression_within_bundles(self, compiler: ContextCompiler):
        # Many items in one bundle with small budget — should compress
        items = [
            _make_item(
                content=f"Identity fact {i}: " + "x" * 100,
                source="handoff",
            )
            for i in range(10)
        ]
        result = compiler.compile(items, 100)
        assert result.total_tokens <= 100
        if result.bundles:
            boot = result.bundles[0]
            assert boot.bundle_type == BundleType.BOOT

    def test_over_budget_compress_then_drop(self, compiler: ContextCompiler):
        """When budget is very tight, lowest-priority bundles get dropped."""
        items = [
            _make_item(content="boot info " * 10, source="handoff"),
            _make_item(content="evidence " * 50, created_at=_old_ts()),
        ]
        # Very small budget — evidence should get compressed or dropped
        result = compiler.compile(items, 50)
        assert result.total_tokens <= 50

    def test_metadata_present(self, compiler: ContextCompiler):
        items = [_make_item(content="hello", source="handoff")]
        result = compiler.compile(items, 1000)
        assert "items_input" in result.metadata
        assert "items_output" in result.metadata
        assert result.metadata["items_input"] == 1

    def test_negative_budget_returns_empty(self, compiler: ContextCompiler):
        items = [_make_item(content="hello")]
        result = compiler.compile(items, -10)
        assert result.bundles == []
        assert result.total_tokens == 0


# ---------------------------------------------------------------------------
# Bundle compression
# ---------------------------------------------------------------------------

class TestBundleCompression:
    def test_compress_bundle_respects_budget(self, compiler: ContextCompiler):
        items = [_make_item(content="word " * 100)]
        bundle = compiler._compress_bundle(BundleType.EVIDENCE, items, 50)
        assert bundle.token_count <= 50

    def test_compress_bundle_empty_items(self, compiler: ContextCompiler):
        bundle = compiler._compress_bundle(BundleType.BOOT, [], 100)
        assert bundle.items == []
        assert bundle.token_count == 0

    def test_compress_bundle_zero_budget(self, compiler: ContextCompiler):
        items = [_make_item(content="hello")]
        bundle = compiler._compress_bundle(BundleType.BOOT, items, 0)
        assert bundle.items == []
        assert bundle.token_count == 0


# ---------------------------------------------------------------------------
# Hard budget invariant stress test
# ---------------------------------------------------------------------------

class TestBudgetInvariant:
    """Verify that total_tokens <= budget is NEVER violated."""

    @pytest.mark.parametrize("budget", [10, 50, 100, 500, 1000, 5000])
    def test_various_budgets(self, compiler: ContextCompiler, budget: int):
        items = [
            _make_item(content="boot identity " * 5, source="handoff"),
            _make_item(content="commit to ship " * 5, type="commitment"),
            _make_item(content="deploy procedure " * 10, type="procedure"),
            _make_item(content="recent chat " * 5, created_at=_recent_ts()),
            _make_item(content="stable preference", type="preference", created_at=_old_ts()),
            _make_item(content="evidence detail " * 20, created_at=_old_ts()),
        ]
        result = compiler.compile(items, budget)
        assert result.total_tokens <= budget, (
            f"Budget violated: {result.total_tokens} > {budget}"
        )
