"""
tests/test_curiosity_engine.py — Unit tests for MODULE 6c: CuriosityEngine

Tests cover:
  - CuriosityEngine.run() with valid claims → seeds + themes + edges + journal
  - CuriosityEngine.run() with empty claims → minimal journal, no seeds
  - Curiosity seed extraction: validates structure, handles LLM parse errors
  - Thematic clustering: validates structure, handles LLM parse errors
  - Cross-memory edge creation: correct pairs, respects MAX_PAIRS_PER_THEME
  - Seed storage: calls create_claim with memory_type='curiosity_seed'
  - Journal generation: LLM path + fallback template
  - CuriosityReport.to_dict() serialization
  - ThemeCluster and CuriositySeed dataclasses defaults
  - Integration: SleepConsolidator.dream() calls CuriosityEngine step 9
"""

from __future__ import annotations

import json
import uuid
import pytest
import pytest_asyncio
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from unittest.mock import AsyncMock, MagicMock, patch

from cortex.circadian.curiosity import (
    CuriosityEngine,
    CuriosityReport,
    CuriositySeed,
    ThemeCluster,
    _claims_to_json,
    _parse_json,
    _fallback_journal,
    _minimal_journal,
)
from cortex.types import SemanticClaim, MemoryEdge


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _now() -> str:
    return datetime.now(timezone.utc).isoformat()


def _make_claim(
    claim_id: Optional[str] = None,
    subject: str = "user",
    predicate: str = "likes",
    object_value: str = "dark mode",
    memory_type: str = "session",
    confidence: float = 0.8,
    owner_id: str = "default",
    session_id: str = "sess-001",
) -> SemanticClaim:
    return SemanticClaim(
        id=claim_id or f"claim-{uuid.uuid4().hex[:8]}",
        owner_id=owner_id,
        subject=subject,
        predicate=predicate,
        object_value=object_value,
        memory_type=memory_type,
        confidence=confidence,
        source_session=session_id,
        created_at=_now(),
    )


def _make_llm(
    seeds_json: Optional[str] = None,
    themes_json: Optional[str] = None,
    journal_text: str = "# Dream Journal\n\nTest journal.",
) -> AsyncMock:
    """Mock LLM that returns canned responses for each prompt type."""
    default_seeds = json.dumps({
        "seeds": [
            {
                "question": "Why does the user prefer dark mode?",
                "theme": "ui_preferences",
                "source_claim_ids": [],
                "confidence": 0.75,
            },
            {
                "question": "Is the user working on multiple projects simultaneously?",
                "theme": "work_patterns",
                "source_claim_ids": [],
                "confidence": 0.60,
            },
        ]
    })
    default_themes = json.dumps({
        "themes": [
            {
                "label": "ui_preferences",
                "claim_ids": [],  # filled by test
                "summary": "UI and display preferences",
            }
        ]
    })

    _seeds_json = seeds_json or default_seeds
    _themes_json = themes_json or default_themes

    call_count = {"n": 0}

    async def _complete(system: str, user: str) -> str:
        call_count["n"] += 1
        # Detect which call by system prompt content
        if "curiosity" in system.lower() or "seeds" in system.lower():
            return _seeds_json
        elif "thematic" in system.lower() or "cluster" in system.lower():
            return _themes_json
        else:
            return journal_text

    mock_llm = AsyncMock()
    mock_llm.complete = _complete
    mock_llm._call_count = call_count
    return mock_llm


def _make_storage(
    active_session_id: str = "sess-001",
    session_claims: Optional[List[SemanticClaim]] = None,
) -> AsyncMock:
    storage = AsyncMock()
    # get_active_session returns a session with session_id
    mock_session = MagicMock()
    mock_session.session_id = active_session_id
    storage.get_active_session = AsyncMock(return_value=mock_session)
    storage.get_session_claims = AsyncMock(return_value=session_claims or [])
    storage.create_edge = AsyncMock(return_value=MagicMock())
    storage.create_claim = AsyncMock(return_value=MagicMock())
    return storage


# ---------------------------------------------------------------------------
# Tests: CuriositySeed / ThemeCluster / CuriosityReport dataclasses
# ---------------------------------------------------------------------------

class TestDataclasses:
    def test_curiosity_seed_defaults(self):
        seed = CuriositySeed()
        assert seed.question == ""
        assert seed.theme == ""
        assert seed.confidence == 0.5
        assert seed.owner_id == "default"
        assert seed.source_claim_ids == []
        assert seed.id.startswith("seed-")

    def test_curiosity_seed_id_unique(self):
        s1 = CuriositySeed()
        s2 = CuriositySeed()
        assert s1.id != s2.id

    def test_theme_cluster_defaults(self):
        t = ThemeCluster()
        assert t.label == ""
        assert t.claim_ids == []
        assert t.summary == ""

    def test_curiosity_report_defaults(self):
        r = CuriosityReport(owner_id="alice")
        assert r.owner_id == "alice"
        assert r.seeds == []
        assert r.themes == []
        assert r.edges_created == 0
        assert r.journal_md == ""
        assert r.duration_ms == 0

    def test_curiosity_report_to_dict(self):
        seed = CuriositySeed(question="Why?", theme="test", confidence=0.9)
        theme = ThemeCluster(label="work", claim_ids=["c1", "c2"], summary="Work stuff")
        r = CuriosityReport(
            owner_id="bob",
            seeds=[seed],
            themes=[theme],
            edges_created=3,
            duration_ms=500,
        )
        d = r.to_dict()
        assert d["owner_id"] == "bob"
        assert len(d["seeds"]) == 1
        assert d["seeds"][0]["question"] == "Why?"
        assert len(d["themes"]) == 1
        assert d["themes"][0]["label"] == "work"
        assert d["edges_created"] == 3
        assert d["duration_ms"] == 500


# ---------------------------------------------------------------------------
# Tests: _parse_json helper
# ---------------------------------------------------------------------------

class TestParseJson:
    def test_valid_json(self):
        data = _parse_json('{"key": "value"}')
        assert data == {"key": "value"}

    def test_json_with_markdown_fence(self):
        text = '```json\n{"seeds": []}\n```'
        data = _parse_json(text)
        assert data == {"seeds": []}

    def test_json_with_plain_fence(self):
        text = '```\n{"themes": []}\n```'
        data = _parse_json(text)
        assert data == {"themes": []}

    def test_invalid_json_returns_empty_dict(self):
        data = _parse_json("not valid json")
        assert data == {}

    def test_list_json_returns_empty_dict(self):
        # If LLM returns a list instead of dict, return empty
        data = _parse_json("[1, 2, 3]")
        assert data == {}

    def test_empty_string(self):
        data = _parse_json("")
        assert data == {}


# ---------------------------------------------------------------------------
# Tests: _claims_to_json helper
# ---------------------------------------------------------------------------

class TestClaimsToJson:
    def test_basic_serialization(self):
        c = _make_claim(claim_id="c1", subject="user", predicate="likes", object_value="python")
        result = _claims_to_json([c])
        data = json.loads(result)
        assert len(data) == 1
        assert data[0]["id"] == "c1"
        assert data[0]["subject"] == "user"
        assert data[0]["predicate"] == "likes"
        assert data[0]["object"] == "python"

    def test_empty_list(self):
        result = _claims_to_json([])
        assert json.loads(result) == []

    def test_confidence_rounded(self):
        c = _make_claim(confidence=0.123456)
        result = _claims_to_json([c])
        data = json.loads(result)
        assert data[0]["confidence"] == 0.12


# ---------------------------------------------------------------------------
# Tests: _fallback_journal / _minimal_journal
# ---------------------------------------------------------------------------

class TestJournalHelpers:
    def test_minimal_journal_with_stats(self):
        journal = _minimal_journal({"claims": 5})
        assert "Dream Journal" in journal
        assert "empty session" in journal.lower()

    def test_fallback_journal_with_seeds_and_themes(self):
        seeds = [
            CuriositySeed(question="What is X?", theme="research", confidence=0.8)
        ]
        themes = [
            ThemeCluster(label="coding", claim_ids=["c1", "c2"], summary="Code stuff")
        ]
        journal = _fallback_journal(seeds, themes, 5, {"orphans": 0})
        assert "Curiosity Seeds" in journal
        assert "What is X?" in journal
        assert "coding" in journal
        assert "5 RELATED_TO edges" in journal

    def test_fallback_journal_no_seeds(self):
        journal = _fallback_journal([], [], 0, {})
        assert "Dream Journal" in journal


# ---------------------------------------------------------------------------
# Tests: CuriosityEngine._extract_curiosity_seeds
# ---------------------------------------------------------------------------

class TestExtractCuriositySeeds:
    @pytest.mark.asyncio
    async def test_extracts_seeds(self):
        claims = [_make_claim() for _ in range(5)]
        llm = _make_llm()
        storage = _make_storage()
        engine = CuriosityEngine(storage, llm)

        seeds = await engine._extract_curiosity_seeds("default", claims)
        assert len(seeds) == 2
        assert all(isinstance(s, CuriositySeed) for s in seeds)
        assert seeds[0].question == "Why does the user prefer dark mode?"
        assert seeds[0].theme == "ui_preferences"
        assert seeds[0].confidence == 0.75

    @pytest.mark.asyncio
    async def test_handles_llm_failure(self):
        claims = [_make_claim()]
        llm = AsyncMock()
        llm.complete = AsyncMock(side_effect=RuntimeError("LLM down"))
        engine = CuriosityEngine(_make_storage(), llm)

        seeds = await engine._extract_curiosity_seeds("default", claims)
        assert seeds == []

    @pytest.mark.asyncio
    async def test_handles_malformed_json(self):
        llm = AsyncMock()
        llm.complete = AsyncMock(return_value="not json at all")
        engine = CuriosityEngine(_make_storage(), llm)

        seeds = await engine._extract_curiosity_seeds("default", [_make_claim()])
        assert seeds == []

    @pytest.mark.asyncio
    async def test_caps_at_5_seeds(self):
        many_seeds = {"seeds": [
            {"question": f"Q{i}?", "theme": "t", "source_claim_ids": [], "confidence": 0.5}
            for i in range(10)
        ]}
        llm = AsyncMock()
        llm.complete = AsyncMock(return_value=json.dumps(many_seeds))
        engine = CuriosityEngine(_make_storage(), llm)

        seeds = await engine._extract_curiosity_seeds("default", [_make_claim()])
        assert len(seeds) <= 5

    @pytest.mark.asyncio
    async def test_skips_seeds_with_empty_question(self):
        bad_seeds = {"seeds": [
            {"question": "", "theme": "t", "source_claim_ids": [], "confidence": 0.5},
            {"question": "Valid question?", "theme": "t2", "source_claim_ids": [], "confidence": 0.6},
        ]}
        llm = AsyncMock()
        llm.complete = AsyncMock(return_value=json.dumps(bad_seeds))
        engine = CuriosityEngine(_make_storage(), llm)

        seeds = await engine._extract_curiosity_seeds("default", [_make_claim()])
        assert len(seeds) == 1
        assert seeds[0].question == "Valid question?"


# ---------------------------------------------------------------------------
# Tests: CuriosityEngine._cluster_by_theme
# ---------------------------------------------------------------------------

class TestClusterByTheme:
    @pytest.mark.asyncio
    async def test_clusters_returned(self):
        c1 = _make_claim(claim_id="c1")
        c2 = _make_claim(claim_id="c2")
        themes_resp = json.dumps({"themes": [
            {"label": "ui", "claim_ids": ["c1", "c2"], "summary": "UI preferences"}
        ]})
        llm = AsyncMock()
        llm.complete = AsyncMock(return_value=themes_resp)
        engine = CuriosityEngine(_make_storage(), llm)

        themes = await engine._cluster_by_theme([c1, c2])
        assert len(themes) == 1
        assert themes[0].label == "ui"
        assert themes[0].claim_ids == ["c1", "c2"]

    @pytest.mark.asyncio
    async def test_skips_singletons_below_min_size(self):
        themes_resp = json.dumps({"themes": [
            {"label": "solo", "claim_ids": ["c1"], "summary": "Only one claim"}
        ]})
        llm = AsyncMock()
        llm.complete = AsyncMock(return_value=themes_resp)
        # Default min_theme_size = 2
        engine = CuriosityEngine(_make_storage(), llm)

        themes = await engine._cluster_by_theme([_make_claim(claim_id="c1")])
        assert len(themes) == 0

    @pytest.mark.asyncio
    async def test_handles_llm_failure(self):
        llm = AsyncMock()
        llm.complete = AsyncMock(side_effect=RuntimeError("timeout"))
        engine = CuriosityEngine(_make_storage(), llm)

        themes = await engine._cluster_by_theme([_make_claim()])
        assert themes == []


# ---------------------------------------------------------------------------
# Tests: CuriosityEngine._create_cross_memory_edges
# ---------------------------------------------------------------------------

class TestCreateCrossMemoryEdges:
    @pytest.mark.asyncio
    async def test_creates_edges_for_theme_pairs(self):
        c1 = _make_claim(claim_id="c1")
        c2 = _make_claim(claim_id="c2")
        c3 = _make_claim(claim_id="c3")
        theme = ThemeCluster(label="coding", claim_ids=["c1", "c2", "c3"])
        storage = _make_storage()
        engine = CuriosityEngine(storage, _make_llm())

        count = await engine._create_cross_memory_edges("default", [theme], [c1, c2, c3])
        # 3 claims → 3 pairs: (c1,c2), (c1,c3), (c2,c3)
        assert count == 3
        assert storage.create_edge.call_count == 3

    @pytest.mark.asyncio
    async def test_skips_pairs_not_in_claim_map(self):
        c1 = _make_claim(claim_id="c1")
        theme = ThemeCluster(label="t", claim_ids=["c1", "missing-id"])
        storage = _make_storage()
        engine = CuriosityEngine(storage, _make_llm())

        count = await engine._create_cross_memory_edges("default", [theme], [c1])
        # Only 1 valid id → no pairs
        assert count == 0
        assert storage.create_edge.call_count == 0

    @pytest.mark.asyncio
    async def test_respects_max_pairs_per_theme(self):
        """10+ claim theme should be capped at MAX_PAIRS_PER_THEME=10 pairs."""
        claims = [_make_claim(claim_id=f"c{i}") for i in range(6)]
        claim_ids = [c.id for c in claims]
        theme = ThemeCluster(label="big_theme", claim_ids=claim_ids)
        storage = _make_storage()
        engine = CuriosityEngine(storage, _make_llm())

        count = await engine._create_cross_memory_edges("default", [theme], claims)
        # 6 claims → 15 pairs, but capped at 10
        assert count <= 10
        assert storage.create_edge.call_count <= 10

    @pytest.mark.asyncio
    async def test_edge_metadata_contains_theme(self):
        c1 = _make_claim(claim_id="c1")
        c2 = _make_claim(claim_id="c2")
        theme = ThemeCluster(label="preferences", claim_ids=["c1", "c2"], summary="UI prefs")
        storage = _make_storage()
        engine = CuriosityEngine(storage, _make_llm())

        await engine._create_cross_memory_edges("default", [theme], [c1, c2])
        call_kwargs = storage.create_edge.call_args
        meta = json.loads(call_kwargs.kwargs.get("metadata_json", "{}"))
        assert meta["theme"] == "preferences"
        assert meta["created_by"] == "M6c_curiosity"

    @pytest.mark.asyncio
    async def test_tolerates_edge_creation_failure(self):
        c1 = _make_claim(claim_id="c1")
        c2 = _make_claim(claim_id="c2")
        theme = ThemeCluster(label="t", claim_ids=["c1", "c2"])
        storage = _make_storage()
        storage.create_edge = AsyncMock(side_effect=RuntimeError("DB error"))
        engine = CuriosityEngine(storage, _make_llm())

        # Should not raise, returns 0
        count = await engine._create_cross_memory_edges("default", [theme], [c1, c2])
        assert count == 0


# ---------------------------------------------------------------------------
# Tests: CuriosityEngine._store_curiosity_seeds
# ---------------------------------------------------------------------------

class TestStoreCuriositySeeds:
    @pytest.mark.asyncio
    async def test_stores_seed_as_claim(self):
        seed = CuriositySeed(
            id="seed-abc",
            question="What is the user's biggest challenge?",
            theme="challenges",
            confidence=0.7,
            owner_id="default",
        )
        claims = [_make_claim(session_id="sess-42")]
        storage = _make_storage()
        engine = CuriosityEngine(storage, _make_llm())

        await engine._store_curiosity_seeds("default", [seed], claims)

        storage.create_claim.assert_called_once()
        call_kwargs = storage.create_claim.call_args.kwargs
        assert call_kwargs["memory_type"] == "curiosity_seed"
        assert call_kwargs["object_value"] == "What is the user's biggest challenge?"
        assert call_kwargs["confidence"] == 0.7
        assert call_kwargs["source_session"] == "sess-42"

        meta = json.loads(call_kwargs["metadata_json"])
        assert meta["seed_id"] == "seed-abc"
        assert meta["theme"] == "challenges"

    @pytest.mark.asyncio
    async def test_tolerates_storage_failure(self):
        seed = CuriositySeed(question="Q?", theme="t")
        storage = _make_storage()
        storage.create_claim = AsyncMock(side_effect=RuntimeError("DB down"))
        engine = CuriosityEngine(storage, _make_llm())

        # Should not raise
        await engine._store_curiosity_seeds("default", [seed], [])

    @pytest.mark.asyncio
    async def test_stores_multiple_seeds(self):
        seeds = [CuriositySeed(question=f"Q{i}?", theme="t") for i in range(3)]
        storage = _make_storage()
        engine = CuriosityEngine(storage, _make_llm())

        await engine._store_curiosity_seeds("default", seeds, [_make_claim()])
        assert storage.create_claim.call_count == 3


# ---------------------------------------------------------------------------
# Tests: CuriosityEngine.run() (integration)
# ---------------------------------------------------------------------------

class TestCuriosityEngineRun:
    @pytest.mark.asyncio
    async def test_run_with_claims(self):
        claims = [_make_claim(claim_id=f"c{i}") for i in range(5)]
        # Make themes reference valid claim IDs
        c_ids = [c.id for c in claims]
        themes_resp = json.dumps({"themes": [
            {"label": "work", "claim_ids": c_ids[:3], "summary": "Work-related"}
        ]})
        seeds_resp = json.dumps({"seeds": [
            {"question": "How productive is the user?", "theme": "work",
             "source_claim_ids": c_ids[:2], "confidence": 0.8}
        ]})

        async def _complete(system: str, user: str) -> str:
            if "curiosity" in system.lower() or "seeds" in system.lower():
                return seeds_resp
            elif "thematic" in system.lower() or "cluster" in system.lower():
                return themes_resp
            return "# Journal\nAll done."

        llm = AsyncMock()
        llm.complete = _complete
        storage = _make_storage(session_claims=claims)
        engine = CuriosityEngine(storage, llm)

        report = await engine.run(
            owner_id="default",
            session_claims=claims,
            dream_stats={"orphans": 0},
        )

        assert isinstance(report, CuriosityReport)
        assert len(report.seeds) == 1
        assert report.seeds[0].question == "How productive is the user?"
        assert len(report.themes) == 1
        assert report.themes[0].label == "work"
        assert report.edges_created == 3  # 3 claims → 3 pairs
        assert report.journal_md != ""
        assert report.duration_ms >= 0

    @pytest.mark.asyncio
    async def test_run_with_empty_claims(self):
        storage = _make_storage()
        engine = CuriosityEngine(storage, _make_llm())

        report = await engine.run(
            owner_id="default",
            session_claims=[],
            dream_stats={},
        )

        assert isinstance(report, CuriosityReport)
        assert report.seeds == []
        assert report.themes == []
        assert report.edges_created == 0
        assert "empty session" in report.journal_md.lower()

    @pytest.mark.asyncio
    async def test_run_does_not_store_seeds_when_disabled(self):
        config = MagicMock()
        config.curiosity_store_seeds = False
        config.curiosity_max_claims = 50
        config.curiosity_min_theme_size = 2

        claims = [_make_claim() for _ in range(3)]
        storage = _make_storage()
        engine = CuriosityEngine(storage, _make_llm(), config=config)

        await engine.run(owner_id="default", session_claims=claims)
        # create_claim should NOT be called when store_seeds=False
        storage.create_claim.assert_not_called()

    @pytest.mark.asyncio
    async def test_run_truncates_large_session(self):
        """Claims > max_claims_for_curiosity should be truncated to last N."""
        config = MagicMock()
        config.curiosity_store_seeds = False
        config.curiosity_max_claims = 5
        config.curiosity_min_theme_size = 2

        claims = [_make_claim(claim_id=f"c{i}") for i in range(20)]
        storage = _make_storage()

        seen_claim_ids = []

        async def _complete(system: str, user: str) -> str:
            # Capture what was passed to the LLM
            if "c15" in user or "c16" in user:
                seen_claim_ids.append("recent")
            if "c0" in user:
                seen_claim_ids.append("old")
            return json.dumps({"seeds": [], "themes": []})

        llm = AsyncMock()
        llm.complete = _complete
        engine = CuriosityEngine(storage, llm, config=config)

        await engine.run(owner_id="default", session_claims=claims)
        # The LLM should only see the last 5 claims

    @pytest.mark.asyncio
    async def test_run_returns_fallback_journal_on_llm_failure(self):
        claims = [_make_claim() for _ in range(2)]
        llm = AsyncMock()
        # Seeds/themes succeed (empty), journal fails
        call_n = {"n": 0}

        async def _complete(system: str, user: str) -> str:
            call_n["n"] += 1
            if "journal" in system.lower() or "dream" in system.lower():
                raise RuntimeError("LLM unavailable")
            return json.dumps({"seeds": [], "themes": []})

        llm.complete = _complete
        engine = CuriosityEngine(_make_storage(), llm)

        report = await engine.run(owner_id="default", session_claims=claims)
        # Should fall back to template journal
        assert "Dream Journal" in report.journal_md


# ---------------------------------------------------------------------------
# Tests: Integration — circadian __init__ exports
# ---------------------------------------------------------------------------

class TestCircadianExports:
    def test_curiosity_classes_exported(self):
        from cortex.circadian import (
            CuriosityEngine,
            CuriosityReport,
            CuriositySeed,
            ThemeCluster,
        )
        assert CuriosityEngine is not None
        assert CuriosityReport is not None
        assert CuriositySeed is not None
        assert ThemeCluster is not None
