"""Graph query layer for memory relations.

Provides CRUD operations on the memory_relations adjacency list
and recursive CTE traversal for multi-hop graph queries.

The graph module operates on raw sqlite3 connections — it does not
manage connections itself. The MemoryStorage class passes its
thread-local connection to these functions.
"""

from __future__ import annotations

import json
import sqlite3
from datetime import UTC, datetime
from typing import Any

# Valid relation types for memory edges
RELATION_TYPES = frozenset(
    {
        "updates",
        "supersedes",
        "supports",
        "contradicts",
        "resulted_in",
        "related_to",
    }
)

# Maximum traversal depth to prevent combinatorial explosion
MAX_TRAVERSE_DEPTH = 5

# Valid measurement methods for decision→outcome confidence calibration.
# Each maps to a default confidence range: (min, max).
MEASUREMENT_METHODS: dict[str, tuple[float, float]] = {
    "user_confirmed": (0.90, 0.95),
    "direct_observation": (0.70, 0.80),
    "statistical_inference": (0.45, 0.55),
    "temporal_proximity": (0.25, 0.35),
    "combined_evidence": (0.30, 0.95),
}
VALID_MEASUREMENT_METHODS = frozenset(MEASUREMENT_METHODS.keys())


def add_relation(
    conn: sqlite3.Connection,
    source_id: int,
    target_id: int,
    relation_type: str,
    metadata: dict[str, Any] | None = None,
    valid_from: str | None = None,
) -> int | None:
    """Create a directed edge between two memories.

    Uses INSERT OR IGNORE for idempotency — calling with the same
    (source_id, target_id, relation_type) is a no-op.

    Args:
        conn: SQLite connection with memory_relations table.
        source_id: Origin memory ID.
        target_id: Destination memory ID.
        relation_type: Edge type (must be in RELATION_TYPES).
        metadata: Optional JSON-serializable metadata for the edge.
        valid_from: Optional ISO 8601 timestamp for edge validity start.

    Returns:
        The relation ID if created, None if edge already exists.

    Raises:
        ValueError: If relation_type is not in RELATION_TYPES.
        ValueError: If source_id == target_id (self-loops not allowed).
    """
    if relation_type not in RELATION_TYPES:
        raise ValueError(
            f"Invalid relation_type '{relation_type}'. Must be one of: {sorted(RELATION_TYPES)}"
        )
    if source_id == target_id:
        raise ValueError("Self-loops are not allowed (source_id == target_id)")

    now = datetime.now(tz=UTC).isoformat()
    cursor = conn.execute(
        "INSERT OR IGNORE INTO memory_relations "
        "(source_id, target_id, relation_type, metadata, created_at, valid_from) "
        "VALUES (?, ?, ?, ?, ?, ?)",
        (
            source_id,
            target_id,
            relation_type,
            json.dumps(metadata or {}),
            now,
            valid_from or now,
        ),
    )
    conn.commit()
    if cursor.rowcount == 0:
        return None  # Edge already existed
    return cursor.lastrowid


def record_outcome(
    conn: sqlite3.Connection,
    decision_id: int,
    outcome_id: int,
    confidence: float = 1.0,
    measurement_method: str | None = None,
    impact_score: float | None = None,
    measured_at: str | None = None,
    causation_id: int | None = None,
    correlation_id: str | None = None,
) -> int | None:
    """Record that a decision resulted in a specific outcome.

    Thin wrapper over add_relation() that creates a ``resulted_in`` edge
    with structured metadata for tracking decision quality.

    Args:
        conn: SQLite connection with memory_relations table.
        decision_id: Source memory ID (the decision).
        outcome_id: Target memory ID (the observed result).
        confidence: Certainty of the causal link (0.0–1.0).
        measurement_method: How the outcome was measured. If provided,
            must be one of VALID_MEASUREMENT_METHODS.
        impact_score: Quantified impact (0.0–1.0, optional).
        measured_at: ISO 8601 timestamp when outcome was measured.
        causation_id: Optional pointer back to root decision memory ID.
        correlation_id: Optional group ID for related experiments.

    Returns:
        The relation ID if created, None if edge already exists.

    Raises:
        ValueError: If confidence is outside 0.0–1.0.
        ValueError: If measurement_method is not recognized.
    """
    if not 0.0 <= confidence <= 1.0:
        raise ValueError(f"confidence must be between 0.0 and 1.0, got {confidence}")
    if measurement_method is not None and measurement_method not in VALID_MEASUREMENT_METHODS:
        raise ValueError(
            f"Invalid measurement_method '{measurement_method}'. "
            f"Must be one of: {sorted(VALID_MEASUREMENT_METHODS)}"
        )

    now = datetime.now(tz=UTC).isoformat()
    metadata: dict[str, Any] = {
        "confidence": confidence,
        "measurement_method": measurement_method,
        "impact_score": impact_score,
        "measured_at": measured_at or now,
    }
    if causation_id is not None:
        metadata["causation_id"] = causation_id
    if correlation_id is not None:
        metadata["correlation_id"] = correlation_id

    return add_relation(
        conn,
        decision_id,
        outcome_id,
        "resulted_in",
        metadata=metadata,
        valid_from=measured_at or now,
    )


def remove_relation(
    conn: sqlite3.Connection,
    source_id: int,
    target_id: int,
    relation_type: str,
) -> bool:
    """Remove a directed edge between two memories.

    Args:
        conn: SQLite connection.
        source_id: Origin memory ID.
        target_id: Destination memory ID.
        relation_type: Edge type to remove.

    Returns:
        True if an edge was deleted, False if it didn't exist.
    """
    cursor = conn.execute(
        "DELETE FROM memory_relations WHERE source_id = ? AND target_id = ? AND relation_type = ?",
        (source_id, target_id, relation_type),
    )
    conn.commit()
    return cursor.rowcount > 0


def get_relations(
    conn: sqlite3.Connection,
    memory_id: int,
    direction: str = "both",
    relation_types: list[str] | None = None,
    include_memory_summary: bool = False,
) -> list[dict[str, Any]]:
    """Get all edges connected to a memory.

    Args:
        conn: SQLite connection.
        memory_id: The memory to query edges for.
        direction: "outgoing" (source=memory_id), "incoming" (target=memory_id),
            or "both" (default).
        relation_types: Optional filter to specific edge types.
        include_memory_summary: If True, join with memories table to include
            summary/content snippet of the connected memory.

    Returns:
        List of relation dicts with id, source_id, target_id, relation_type,
        metadata, created_at, and optionally connected_memory_summary.
    """
    conditions: list[str] = []
    params: list[Any] = []

    if direction == "outgoing":
        conditions.append("r.source_id = ?")
        params.append(memory_id)
    elif direction == "incoming":
        conditions.append("r.target_id = ?")
        params.append(memory_id)
    else:  # both
        conditions.append("(r.source_id = ? OR r.target_id = ?)")
        params.extend([memory_id, memory_id])

    if relation_types:
        placeholders = ",".join("?" * len(relation_types))
        conditions.append(f"r.relation_type IN ({placeholders})")  # noqa: S608
        params.extend(relation_types)

    # Only return active edges (not invalidated)
    conditions.append("r.invalidated_at IS NULL")

    where = "WHERE " + " AND ".join(conditions)

    if include_memory_summary:
        # Join to get the "other" memory's summary
        sql = f"""
            SELECT r.id, r.source_id, r.target_id, r.relation_type,
                   r.metadata, r.created_at, r.valid_from, r.valid_to,
                   CASE
                       WHEN r.source_id = ? THEN m.id
                       ELSE ms.id
                   END as connected_id,
                   CASE
                       WHEN r.source_id = ? THEN COALESCE(m.summary, SUBSTR(m.content, 1, 200))
                       ELSE COALESCE(ms.summary, SUBSTR(ms.content, 1, 200))
                   END as connected_summary,
                   CASE
                       WHEN r.source_id = ? THEN m.category
                       ELSE ms.category
                   END as connected_category
            FROM memory_relations r
            LEFT JOIN memories m ON r.target_id = m.id
            LEFT JOIN memories ms ON r.source_id = ms.id
            {where}
            ORDER BY r.created_at DESC
        """
        params = [memory_id, memory_id, memory_id] + params
    else:
        sql = f"""
            SELECT r.id, r.source_id, r.target_id, r.relation_type,
                   r.metadata, r.created_at, r.valid_from, r.valid_to
            FROM memory_relations r
            {where}
            ORDER BY r.created_at DESC
        """

    rows = conn.execute(sql, params).fetchall()

    results = []
    for row in rows:
        rel: dict[str, Any] = {
            "id": row["id"],
            "source_id": row["source_id"],
            "target_id": row["target_id"],
            "relation_type": row["relation_type"],
            "metadata": json.loads(row["metadata"]) if row["metadata"] else {},
            "created_at": row["created_at"],
        }
        if row["valid_from"]:
            rel["valid_from"] = row["valid_from"]
        if row["valid_to"]:
            rel["valid_to"] = row["valid_to"]
        if include_memory_summary:
            rel["connected_id"] = row["connected_id"]
            rel["connected_summary"] = row["connected_summary"]
            rel["connected_category"] = row["connected_category"]
        results.append(rel)

    return results


def traverse(
    conn: sqlite3.Connection,
    start_id: int,
    depth: int = 3,
    direction: str = "outgoing",
    relation_types: list[str] | None = None,
) -> list[dict[str, Any]]:
    """Traverse the memory graph using recursive CTE with cycle prevention.

    Returns all reachable memories within the given depth, along with
    the path taken to reach them.

    Args:
        conn: SQLite connection.
        start_id: Starting memory ID.
        depth: Maximum traversal depth (default: 3, max: MAX_TRAVERSE_DEPTH).
        direction: "outgoing" (follow source→target), "incoming" (follow
            target→source), or "both".
        relation_types: Optional filter to traverse only specific edge types.

    Returns:
        List of dicts with: memory_id, depth, relation_type (edge that led here),
        via_id (the memory that connected to this one), and memory summary.
    """
    depth = min(depth, MAX_TRAVERSE_DEPTH)

    # Build direction-specific join conditions for the recursive step
    if direction == "outgoing":
        recursive_join = "r.source_id = g.memory_id"
        next_id = "r.target_id"
    elif direction == "incoming":
        recursive_join = "r.target_id = g.memory_id"
        next_id = "r.source_id"
    else:  # both
        recursive_join = "(r.source_id = g.memory_id OR r.target_id = g.memory_id)"
        next_id = "CASE WHEN r.source_id = g.memory_id THEN r.target_id ELSE r.source_id END"

    # Optional relation type filter
    type_filter = ""
    type_params: list[Any] = []
    if relation_types:
        placeholders = ",".join("?" * len(relation_types))
        type_filter = f"AND r.relation_type IN ({placeholders})"  # noqa: S608
        type_params = list(relation_types)

    # Recursive CTE with cycle prevention via path tracking
    # The path column stores visited IDs as comma-separated string for cycle detection
    sql = f"""
        WITH RECURSIVE graph(memory_id, depth, relation_type, via_id, path) AS (
            -- Base case: start node (delimiter-wrapped path for exact ID matching)
            SELECT ?, 0, NULL, NULL, ',' || CAST(? AS TEXT) || ','

            UNION ALL

            -- Recursive step: follow edges
            SELECT
                {next_id},
                g.depth + 1,
                r.relation_type,
                g.memory_id,
                g.path || CAST({next_id} AS TEXT) || ','
            FROM graph g
            JOIN memory_relations r ON {recursive_join}
            WHERE g.depth < ?
                AND r.invalidated_at IS NULL
                {type_filter}
                -- Cycle prevention: delimiter-wrapped match avoids substring collisions
                AND NOT INSTR(g.path, ',' || CAST({next_id} AS TEXT) || ',')
        )
        SELECT g.memory_id, g.depth, g.relation_type, g.via_id,
               COALESCE(m.summary, SUBSTR(m.content, 1, 200)) as summary,
               m.category, m.created_at
        FROM graph g
        LEFT JOIN memories m ON g.memory_id = m.id
        WHERE g.depth > 0
        ORDER BY g.depth ASC, g.memory_id ASC
    """

    params: list[Any] = [start_id, str(start_id), depth] + type_params
    rows = conn.execute(sql, params).fetchall()

    return [
        {
            "memory_id": row["memory_id"],
            "depth": row["depth"],
            "relation_type": row["relation_type"],
            "via_id": row["via_id"],
            "summary": row["summary"],
            "category": row["category"],
            "created_at": row["created_at"],
        }
        for row in rows
    ]


def decision_chain(
    conn: sqlite3.Connection,
    start_id: int,
    depth: int = 3,
    direction: str = "outgoing",
    include_metadata: bool = True,
) -> dict[str, Any]:
    """Reconstruct a decision→outcome chain following ``resulted_in`` edges.

    Specialized traversal that extracts confidence and impact_score from
    the edge metadata via ``json_extract()``.

    Args:
        conn: SQLite connection.
        start_id: Starting memory ID (decision or outcome).
        depth: Maximum chain hops (default: 3, max: MAX_TRAVERSE_DEPTH).
        direction: "outgoing" (decision→outcomes) or "incoming"
            (outcome→decisions).
        include_metadata: If True, join edge metadata for confidence/impact.

    Returns:
        Dict with start_id, outcomes list, and max_depth reached.
    """
    depth = min(depth, MAX_TRAVERSE_DEPTH)

    if direction == "outgoing":
        recursive_join = "r.source_id = g.memory_id"
        next_id = "r.target_id"
    else:  # incoming
        recursive_join = "r.target_id = g.memory_id"
        next_id = "r.source_id"

    sql = f"""
        WITH RECURSIVE graph(memory_id, depth, via_id, path) AS (
            SELECT ?, 0, NULL, ',' || CAST(? AS TEXT) || ','

            UNION ALL

            SELECT
                {next_id},
                g.depth + 1,
                g.memory_id,
                g.path || CAST({next_id} AS TEXT) || ','
            FROM graph g
            JOIN memory_relations r ON {recursive_join}
            WHERE g.depth < ?
                AND r.invalidated_at IS NULL
                AND r.relation_type = 'resulted_in'
                AND NOT INSTR(g.path, ',' || CAST({next_id} AS TEXT) || ',')
        )
        SELECT g.memory_id, g.depth, g.via_id,
               COALESCE(m.summary, SUBSTR(m.content, 1, 200)) as summary,
               m.category, m.created_at
        FROM graph g
        LEFT JOIN memories m ON g.memory_id = m.id
        WHERE g.depth > 0
        ORDER BY g.depth ASC, g.memory_id ASC
    """

    params: list[Any] = [start_id, str(start_id), depth]
    rows = conn.execute(sql, params).fetchall()

    outcomes: list[dict[str, Any]] = []
    max_depth_reached = 0

    for row in rows:
        outcome: dict[str, Any] = {
            "memory_id": row["memory_id"],
            "depth": row["depth"],
            "via_id": row["via_id"],
            "summary": row["summary"],
            "category": row["category"],
            "created_at": row["created_at"],
        }

        if include_metadata and row["via_id"] is not None:
            # Fetch edge metadata for confidence/impact
            if direction == "outgoing":
                edge_row = conn.execute(
                    "SELECT metadata FROM memory_relations "
                    "WHERE source_id = ? AND target_id = ? AND relation_type = 'resulted_in' "
                    "AND invalidated_at IS NULL",
                    (row["via_id"], row["memory_id"]),
                ).fetchone()
            else:
                edge_row = conn.execute(
                    "SELECT metadata FROM memory_relations "
                    "WHERE source_id = ? AND target_id = ? AND relation_type = 'resulted_in' "
                    "AND invalidated_at IS NULL",
                    (row["memory_id"], row["via_id"]),
                ).fetchone()

            if edge_row and edge_row["metadata"]:
                meta = json.loads(edge_row["metadata"])
                outcome["confidence"] = meta.get("confidence")
                outcome["impact_score"] = meta.get("impact_score")
                outcome["measurement_method"] = meta.get("measurement_method")

        if row["depth"] > max_depth_reached:
            max_depth_reached = row["depth"]

        outcomes.append(outcome)

    return {
        "start_id": start_id,
        "outcomes": outcomes,
        "max_depth": max_depth_reached,
    }


def timeline(
    conn: sqlite3.Connection,
    anchor_id: int,
    depth_before: int = 5,
    depth_after: int = 5,
    follow_edges: bool = False,
    relation_types: list[str] | None = None,
    detail_level: str = "summary",
) -> dict[str, Any]:
    """Navigate memories chronologically around an anchor point.

    Three-layer progressive disclosure:
        Layer 1: search_memories → compact results
        Layer 2: timeline → chronological context around anchor
        Layer 3: expand_memory → full content on demand

    Args:
        conn: SQLite connection.
        anchor_id: The memory ID to center the timeline on.
        depth_before: Number of memories to show before the anchor (default: 5).
        depth_after: Number of memories to show after the anchor (default: 5).
        follow_edges: If True, include graph-connected memories alongside
            chronological neighbors.
        relation_types: When follow_edges=True, filter to these edge types.
        detail_level: "summary" (default, ~50-100 tokens each) or "full".

    Returns:
        Dict with anchor memory, before/after lists, and optional edges.
    """
    # Get anchor memory
    anchor = conn.execute(
        "SELECT id, content, category, summary, created_at, valid_from, valid_to, "
        "is_active, token_count "
        "FROM memories WHERE id = ?",
        (anchor_id,),
    ).fetchone()

    if anchor is None:
        return {"error": f"Memory {anchor_id} not found"}

    anchor_created = anchor["created_at"]

    def _format_memory(row: sqlite3.Row) -> dict[str, Any]:
        mem: dict[str, Any] = {
            "id": row["id"],
            "category": row["category"],
            "created_at": row["created_at"],
        }
        if detail_level == "full":
            mem["content"] = row["content"]
        else:
            mem["summary"] = row["summary"] or row["content"][:200]
        if row["valid_from"]:
            mem["valid_from"] = row["valid_from"]
        if row["valid_to"]:
            mem["valid_to"] = row["valid_to"]
        return mem

    # Get memories before anchor (chronologically earlier)
    before_rows = conn.execute(
        "SELECT id, content, category, summary, created_at, valid_from, valid_to, "
        "is_active, token_count "
        "FROM memories WHERE created_at < ? AND is_active = 1 "
        "ORDER BY created_at DESC LIMIT ?",
        (anchor_created, depth_before),
    ).fetchall()

    # Get memories after anchor (chronologically later)
    after_rows = conn.execute(
        "SELECT id, content, category, summary, created_at, valid_from, valid_to, "
        "is_active, token_count "
        "FROM memories WHERE created_at > ? AND is_active = 1 "
        "ORDER BY created_at ASC LIMIT ?",
        (anchor_created, depth_after),
    ).fetchall()

    result: dict[str, Any] = {
        "anchor": _format_memory(anchor),
        "before": [_format_memory(r) for r in reversed(before_rows)],
        "after": [_format_memory(r) for r in after_rows],
    }

    # Optional: include graph-connected memories
    if follow_edges:
        edges = get_relations(
            conn,
            anchor_id,
            direction="both",
            relation_types=relation_types,
            include_memory_summary=True,
        )
        result["edges"] = edges

    return result
