"""Tests for graph query layer (memory_relations CRUD + traversal + timeline)."""

from __future__ import annotations

import json
import sqlite3
from datetime import UTC, datetime, timedelta

import pytest

from claude_engram.graph import (
    MEASUREMENT_METHODS,
    RELATION_TYPES,
    VALID_MEASUREMENT_METHODS,
    add_relation,
    decision_chain,
    get_relations,
    record_outcome,
    remove_relation,
    timeline,
    traverse,
)


@pytest.fixture()
def graph_db(tmp_path) -> sqlite3.Connection:
    """Create a v8 database with memories and memory_relations tables."""
    db_path = tmp_path / "graph_test.db"
    conn = sqlite3.connect(str(db_path))
    conn.row_factory = sqlite3.Row
    conn.executescript("""
        CREATE TABLE memories (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            content TEXT NOT NULL,
            category TEXT DEFAULT 'general',
            metadata TEXT DEFAULT '{}',
            created_at TEXT NOT NULL,
            updated_at TEXT NOT NULL,
            proj_tag TEXT DEFAULT '',
            summary TEXT DEFAULT NULL,
            is_active INTEGER DEFAULT 1,
            superseded_by INTEGER DEFAULT NULL,
            valid_from TEXT DEFAULT NULL,
            valid_to TEXT DEFAULT NULL,
            token_count INTEGER DEFAULT NULL
        );
        CREATE TABLE memory_relations (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            source_id INTEGER NOT NULL,
            target_id INTEGER NOT NULL,
            relation_type TEXT NOT NULL,
            metadata TEXT DEFAULT '{}',
            created_at TEXT NOT NULL,
            valid_from TEXT DEFAULT NULL,
            valid_to TEXT DEFAULT NULL,
            invalidated_at TEXT DEFAULT NULL,
            UNIQUE(source_id, target_id, relation_type)
        );
        CREATE INDEX idx_relations_source ON memory_relations(source_id, relation_type);
        CREATE INDEX idx_relations_target ON memory_relations(target_id, relation_type);
    """)

    # Insert a chain of memories for testing traversal
    base_time = datetime(2025, 1, 1, tzinfo=UTC)
    for i in range(1, 8):
        t = (base_time + timedelta(hours=i)).isoformat()
        conn.execute(
            "INSERT INTO memories (content, category, created_at, updated_at, "
            "summary, is_active, valid_from) VALUES (?, ?, ?, ?, ?, 1, ?)",
            (
                f"Memory {i} content about topic {chr(64 + i)}",
                "decision" if i % 2 == 0 else "lesson",
                t,
                t,
                f"Summary of memory {i}",
                t,
            ),
        )
    conn.commit()
    return conn


class TestAddRelation:
    def test_creates_edge(self, graph_db: sqlite3.Connection) -> None:
        rel_id = add_relation(graph_db, 1, 2, "supersedes")
        assert rel_id is not None
        row = graph_db.execute("SELECT * FROM memory_relations WHERE id = ?", (rel_id,)).fetchone()
        assert row["source_id"] == 1
        assert row["target_id"] == 2
        assert row["relation_type"] == "supersedes"

    def test_idempotent_same_edge(self, graph_db: sqlite3.Connection) -> None:
        id1 = add_relation(graph_db, 1, 2, "supersedes")
        id2 = add_relation(graph_db, 1, 2, "supersedes")
        assert id1 is not None
        assert id2 is None  # Already existed
        count = graph_db.execute("SELECT COUNT(*) FROM memory_relations").fetchone()[0]
        assert count == 1

    def test_different_types_allowed(self, graph_db: sqlite3.Connection) -> None:
        add_relation(graph_db, 1, 2, "supersedes")
        add_relation(graph_db, 1, 2, "related_to")
        count = graph_db.execute("SELECT COUNT(*) FROM memory_relations").fetchone()[0]
        assert count == 2

    def test_invalid_relation_type_raises(self, graph_db: sqlite3.Connection) -> None:
        with pytest.raises(ValueError, match="Invalid relation_type"):
            add_relation(graph_db, 1, 2, "invented_type")

    def test_self_loop_raises(self, graph_db: sqlite3.Connection) -> None:
        with pytest.raises(ValueError, match="Self-loops"):
            add_relation(graph_db, 1, 1, "related_to")

    def test_metadata_stored_as_json(self, graph_db: sqlite3.Connection) -> None:
        meta = {"confidence": 0.9, "method": "explicit"}
        add_relation(graph_db, 1, 2, "resulted_in", metadata=meta)
        row = graph_db.execute("SELECT metadata FROM memory_relations").fetchone()
        assert json.loads(row["metadata"]) == meta

    def test_all_relation_types_valid(self, graph_db: sqlite3.Connection) -> None:
        for i, rtype in enumerate(sorted(RELATION_TYPES), start=2):
            add_relation(graph_db, 1, i, rtype)
        count = graph_db.execute("SELECT COUNT(*) FROM memory_relations").fetchone()[0]
        assert count == len(RELATION_TYPES)


class TestRemoveRelation:
    def test_removes_existing_edge(self, graph_db: sqlite3.Connection) -> None:
        add_relation(graph_db, 1, 2, "supersedes")
        removed = remove_relation(graph_db, 1, 2, "supersedes")
        assert removed is True
        count = graph_db.execute("SELECT COUNT(*) FROM memory_relations").fetchone()[0]
        assert count == 0

    def test_returns_false_for_missing(self, graph_db: sqlite3.Connection) -> None:
        removed = remove_relation(graph_db, 999, 998, "supersedes")
        assert removed is False


class TestGetRelations:
    def test_outgoing_direction(self, graph_db: sqlite3.Connection) -> None:
        add_relation(graph_db, 1, 2, "supersedes")
        add_relation(graph_db, 1, 3, "supports")
        add_relation(graph_db, 4, 1, "contradicts")  # incoming, should be excluded
        rels = get_relations(graph_db, 1, direction="outgoing")
        assert len(rels) == 2
        assert all(r["source_id"] == 1 for r in rels)

    def test_incoming_direction(self, graph_db: sqlite3.Connection) -> None:
        add_relation(graph_db, 1, 2, "supersedes")
        add_relation(graph_db, 3, 2, "supports")
        rels = get_relations(graph_db, 2, direction="incoming")
        assert len(rels) == 2
        assert all(r["target_id"] == 2 for r in rels)

    def test_both_direction(self, graph_db: sqlite3.Connection) -> None:
        add_relation(graph_db, 1, 2, "supersedes")
        add_relation(graph_db, 3, 1, "supports")
        rels = get_relations(graph_db, 1, direction="both")
        assert len(rels) == 2

    def test_filter_by_relation_types(self, graph_db: sqlite3.Connection) -> None:
        add_relation(graph_db, 1, 2, "supersedes")
        add_relation(graph_db, 1, 3, "supports")
        add_relation(graph_db, 1, 4, "contradicts")
        rels = get_relations(graph_db, 1, relation_types=["supersedes", "supports"])
        assert len(rels) == 2
        types = {r["relation_type"] for r in rels}
        assert types == {"supersedes", "supports"}

    def test_include_memory_summary(self, graph_db: sqlite3.Connection) -> None:
        add_relation(graph_db, 1, 2, "supersedes")
        rels = get_relations(graph_db, 1, direction="outgoing", include_memory_summary=True)
        assert len(rels) == 1
        assert "connected_summary" in rels[0]
        assert "connected_category" in rels[0]

    def test_excludes_invalidated_edges(self, graph_db: sqlite3.Connection) -> None:
        add_relation(graph_db, 1, 2, "supersedes")
        graph_db.execute(
            "UPDATE memory_relations SET invalidated_at = ? WHERE source_id = 1",
            (datetime.now(tz=UTC).isoformat(),),
        )
        graph_db.commit()
        rels = get_relations(graph_db, 1)
        assert len(rels) == 0


class TestTraverse:
    def _setup_chain(self, graph_db: sqlite3.Connection) -> None:
        """Create a chain: 1→2→3→4."""
        add_relation(graph_db, 1, 2, "updates")
        add_relation(graph_db, 2, 3, "updates")
        add_relation(graph_db, 3, 4, "updates")

    def test_basic_traversal(self, graph_db: sqlite3.Connection) -> None:
        self._setup_chain(graph_db)
        nodes = traverse(graph_db, 1, depth=3, direction="outgoing")
        ids = [n["memory_id"] for n in nodes]
        assert 2 in ids
        assert 3 in ids
        assert 4 in ids

    def test_depth_limit(self, graph_db: sqlite3.Connection) -> None:
        self._setup_chain(graph_db)
        nodes = traverse(graph_db, 1, depth=1, direction="outgoing")
        ids = [n["memory_id"] for n in nodes]
        assert ids == [2]

    def test_depth_clamped_to_max(self, graph_db: sqlite3.Connection) -> None:
        self._setup_chain(graph_db)
        # depth=100 should be clamped to MAX_TRAVERSE_DEPTH
        nodes = traverse(graph_db, 1, depth=100, direction="outgoing")
        assert all(n["depth"] <= 5 for n in nodes)

    def test_cycle_prevention(self, graph_db: sqlite3.Connection) -> None:
        """Cycles should not cause infinite recursion."""
        add_relation(graph_db, 1, 2, "related_to")
        add_relation(graph_db, 2, 3, "related_to")
        add_relation(graph_db, 3, 1, "related_to")  # Cycle!
        nodes = traverse(graph_db, 1, depth=5, direction="outgoing")
        # Should visit 2 and 3, but not revisit 1
        ids = [n["memory_id"] for n in nodes]
        assert 1 not in ids
        assert 2 in ids
        assert 3 in ids

    def test_cycle_prevention_no_substring_collision(self, graph_db: sqlite3.Connection) -> None:
        """ID 1 must not collide with ID 10/12/123 in cycle detection path."""
        # Chain: 10 → 12 → 123 → 1 (all distinct IDs, no cycle)
        add_relation(graph_db, 10, 12, "related_to")
        add_relation(graph_db, 12, 123, "related_to")
        add_relation(graph_db, 123, 1, "related_to")
        nodes = traverse(graph_db, 10, depth=5, direction="outgoing")
        ids = [n["memory_id"] for n in nodes]
        assert ids == [12, 123, 1], f"Expected [12, 123, 1] but got {ids}"

    def test_incoming_traversal(self, graph_db: sqlite3.Connection) -> None:
        self._setup_chain(graph_db)
        nodes = traverse(graph_db, 4, depth=3, direction="incoming")
        ids = [n["memory_id"] for n in nodes]
        assert 3 in ids
        assert 2 in ids
        assert 1 in ids

    def test_bidirectional_traversal(self, graph_db: sqlite3.Connection) -> None:
        add_relation(graph_db, 1, 2, "updates")
        add_relation(graph_db, 3, 2, "supports")
        nodes = traverse(graph_db, 2, depth=1, direction="both")
        ids = {n["memory_id"] for n in nodes}
        assert ids == {1, 3}

    def test_filter_relation_types(self, graph_db: sqlite3.Connection) -> None:
        add_relation(graph_db, 1, 2, "updates")
        add_relation(graph_db, 1, 3, "contradicts")
        nodes = traverse(graph_db, 1, depth=1, relation_types=["updates"])
        ids = [n["memory_id"] for n in nodes]
        assert 2 in ids
        assert 3 not in ids

    def test_returns_summary(self, graph_db: sqlite3.Connection) -> None:
        add_relation(graph_db, 1, 2, "updates")
        nodes = traverse(graph_db, 1, depth=1)
        assert len(nodes) == 1
        assert "summary" in nodes[0]
        assert nodes[0]["summary"] is not None

    def test_no_edges_returns_empty(self, graph_db: sqlite3.Connection) -> None:
        nodes = traverse(graph_db, 1, depth=3)
        assert nodes == []

    def test_excludes_invalidated_edges(self, graph_db: sqlite3.Connection) -> None:
        add_relation(graph_db, 1, 2, "updates")
        graph_db.execute(
            "UPDATE memory_relations SET invalidated_at = ?",
            (datetime.now(tz=UTC).isoformat(),),
        )
        graph_db.commit()
        nodes = traverse(graph_db, 1, depth=1)
        assert nodes == []


class TestTimeline:
    def test_basic_timeline(self, graph_db: sqlite3.Connection) -> None:
        result = timeline(graph_db, anchor_id=4, depth_before=2, depth_after=2)
        assert "anchor" in result
        assert result["anchor"]["id"] == 4
        assert len(result["before"]) <= 2
        assert len(result["after"]) <= 2

    def test_before_in_chronological_order(self, graph_db: sqlite3.Connection) -> None:
        result = timeline(graph_db, anchor_id=4, depth_before=3, depth_after=0)
        before_ids = [m["id"] for m in result["before"]]
        # Should be in chronological order (oldest first)
        assert before_ids == sorted(before_ids)

    def test_after_in_chronological_order(self, graph_db: sqlite3.Connection) -> None:
        result = timeline(graph_db, anchor_id=4, depth_before=0, depth_after=3)
        after_ids = [m["id"] for m in result["after"]]
        assert after_ids == sorted(after_ids)

    def test_summary_detail_level(self, graph_db: sqlite3.Connection) -> None:
        result = timeline(graph_db, anchor_id=4, detail_level="summary")
        for mem in result["before"] + result["after"]:
            assert "summary" in mem
            assert "content" not in mem

    def test_full_detail_level(self, graph_db: sqlite3.Connection) -> None:
        result = timeline(graph_db, anchor_id=4, detail_level="full")
        for mem in result["before"] + result["after"]:
            assert "content" in mem

    def test_follow_edges(self, graph_db: sqlite3.Connection) -> None:
        add_relation(graph_db, 4, 2, "supersedes")
        result = timeline(graph_db, anchor_id=4, follow_edges=True)
        assert "edges" in result
        assert len(result["edges"]) == 1

    def test_nonexistent_anchor(self, graph_db: sqlite3.Connection) -> None:
        result = timeline(graph_db, anchor_id=999)
        assert "error" in result

    def test_anchor_at_start(self, graph_db: sqlite3.Connection) -> None:
        """First memory should have empty before list."""
        result = timeline(graph_db, anchor_id=1, depth_before=5, depth_after=5)
        assert result["before"] == []
        assert len(result["after"]) > 0

    def test_anchor_at_end(self, graph_db: sqlite3.Connection) -> None:
        """Last memory should have empty after list."""
        result = timeline(graph_db, anchor_id=7, depth_before=5, depth_after=5)
        assert result["after"] == []
        assert len(result["before"]) > 0


class TestMeasurementMethods:
    def test_all_methods_have_ranges(self) -> None:
        """Every method maps to a (low, high) confidence range."""
        for name, bounds in MEASUREMENT_METHODS.items():
            assert len(bounds) == 2
            low, high = bounds
            assert 0.0 <= low <= high <= 1.0, f"{name}: ({low}, {high})"

    def test_frozenset_matches_dict_keys(self) -> None:
        assert frozenset(MEASUREMENT_METHODS.keys()) == VALID_MEASUREMENT_METHODS


class TestRecordOutcome:
    def test_creates_resulted_in_edge(self, graph_db: sqlite3.Connection) -> None:
        rel_id = record_outcome(graph_db, 1, 2)
        assert rel_id is not None
        row = graph_db.execute("SELECT * FROM memory_relations WHERE id = ?", (rel_id,)).fetchone()
        assert row["relation_type"] == "resulted_in"

    def test_metadata_stored(self, graph_db: sqlite3.Connection) -> None:
        record_outcome(graph_db, 1, 2, confidence=0.75, measurement_method="direct_observation")
        row = graph_db.execute(
            "SELECT metadata FROM memory_relations WHERE source_id = 1 AND target_id = 2"
        ).fetchone()
        meta = json.loads(row["metadata"])
        assert meta["confidence"] == 0.75
        assert meta["measurement_method"] == "direct_observation"

    def test_duplicate_returns_none(self, graph_db: sqlite3.Connection) -> None:
        first = record_outcome(graph_db, 1, 2)
        second = record_outcome(graph_db, 1, 2)
        assert first is not None
        assert second is None

    def test_invalid_confidence_too_high(self, graph_db: sqlite3.Connection) -> None:
        with pytest.raises(ValueError, match="confidence must be between"):
            record_outcome(graph_db, 1, 2, confidence=1.5)

    def test_invalid_confidence_negative(self, graph_db: sqlite3.Connection) -> None:
        with pytest.raises(ValueError, match="confidence must be between"):
            record_outcome(graph_db, 1, 2, confidence=-0.1)

    def test_invalid_measurement_method(self, graph_db: sqlite3.Connection) -> None:
        with pytest.raises(ValueError, match="Invalid measurement_method"):
            record_outcome(graph_db, 1, 2, measurement_method="magic_8_ball")

    def test_optional_fields_in_metadata(self, graph_db: sqlite3.Connection) -> None:
        record_outcome(
            graph_db,
            1,
            2,
            causation_id=99,
            correlation_id="exp-001",
            impact_score=0.8,
        )
        row = graph_db.execute(
            "SELECT metadata FROM memory_relations WHERE source_id = 1 AND target_id = 2"
        ).fetchone()
        meta = json.loads(row["metadata"])
        assert meta["causation_id"] == 99
        assert meta["correlation_id"] == "exp-001"
        assert meta["impact_score"] == 0.8

    def test_boundary_confidence_values(self, graph_db: sqlite3.Connection) -> None:
        """0.0 and 1.0 are valid boundary values."""
        r1 = record_outcome(graph_db, 1, 2, confidence=0.0)
        r2 = record_outcome(graph_db, 3, 4, confidence=1.0)
        assert r1 is not None
        assert r2 is not None


class TestDecisionChain:
    def test_follows_resulted_in_edges(self, graph_db: sqlite3.Connection) -> None:
        record_outcome(graph_db, 1, 2, confidence=0.9)
        record_outcome(graph_db, 2, 3, confidence=0.8)
        result = decision_chain(graph_db, 1, depth=3)
        assert result["start_id"] == 1
        assert len(result["outcomes"]) == 2
        assert result["outcomes"][0]["memory_id"] == 2
        assert result["outcomes"][1]["memory_id"] == 3

    def test_respects_depth_limit(self, graph_db: sqlite3.Connection) -> None:
        record_outcome(graph_db, 1, 2)
        record_outcome(graph_db, 2, 3)
        record_outcome(graph_db, 3, 4)
        result = decision_chain(graph_db, 1, depth=1)
        assert len(result["outcomes"]) == 1

    def test_incoming_direction(self, graph_db: sqlite3.Connection) -> None:
        record_outcome(graph_db, 1, 2)
        record_outcome(graph_db, 2, 3)
        result = decision_chain(graph_db, 3, depth=3, direction="incoming")
        ids = [o["memory_id"] for o in result["outcomes"]]
        assert 2 in ids
        assert 1 in ids

    def test_empty_chain(self, graph_db: sqlite3.Connection) -> None:
        result = decision_chain(graph_db, 1, depth=3)
        assert result["outcomes"] == []
        assert result["max_depth"] == 0

    def test_metadata_extraction(self, graph_db: sqlite3.Connection) -> None:
        record_outcome(graph_db, 1, 2, confidence=0.85, measurement_method="user_confirmed")
        result = decision_chain(graph_db, 1, depth=1, include_metadata=True)
        outcome = result["outcomes"][0]
        assert outcome["confidence"] == 0.85
        assert outcome["measurement_method"] == "user_confirmed"

    def test_no_metadata_when_disabled(self, graph_db: sqlite3.Connection) -> None:
        record_outcome(graph_db, 1, 2, confidence=0.7)
        result = decision_chain(graph_db, 1, depth=1, include_metadata=False)
        outcome = result["outcomes"][0]
        assert "confidence" not in outcome

    def test_ignores_invalidated_edges(self, graph_db: sqlite3.Connection) -> None:
        record_outcome(graph_db, 1, 2)
        graph_db.execute(
            "UPDATE memory_relations SET invalidated_at = '2025-01-01T00:00:00' "
            "WHERE source_id = 1 AND target_id = 2"
        )
        graph_db.commit()
        result = decision_chain(graph_db, 1, depth=3)
        assert result["outcomes"] == []

    def test_ignores_non_resulted_in_edges(self, graph_db: sqlite3.Connection) -> None:
        """Only follows resulted_in, not other relation types."""
        add_relation(graph_db, 1, 2, "supersedes")
        result = decision_chain(graph_db, 1, depth=3)
        assert result["outcomes"] == []

    def test_no_substring_collision_in_chain(self, graph_db: sqlite3.Connection) -> None:
        """ID 1 must not collide with ID 10/100 in cycle detection path."""
        record_outcome(graph_db, 10, 100, confidence=0.9)
        record_outcome(graph_db, 100, 1, confidence=0.8)
        result = decision_chain(graph_db, 10, depth=3)
        ids = [o["memory_id"] for o in result["outcomes"]]
        assert ids == [100, 1], f"Expected [100, 1] but got {ids}"
