"""Knowledge graph - NetworkX wiki-link graph operations."""

import contextlib
import json
import logging
import os
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Literal

import networkx as nx

from lithos.config import LithosConfig, get_config
from lithos.knowledge import KnowledgeDocument

logger = logging.getLogger(__name__)

# Increment when the JSON cache schema changes in a backward-incompatible way.
# Existing graph.pickle files are silently ignored (the new cache path is
# graph.json); the graph will be rebuilt from source documents on next startup.
GRAPH_CACHE_VERSION = 1


@dataclass
class LinkedDocument:
    """A linked document reference."""

    id: str
    title: str


@dataclass
class LinkInfo:
    """Information about links for a document."""

    outgoing: list[LinkedDocument]
    incoming: list[LinkedDocument]


class KnowledgeGraph:
    """NetworkX-based knowledge graph for wiki-links."""

    def __init__(self, config: LithosConfig | None = None):
        """Initialize knowledge graph.

        Args:
            config: Configuration. Uses global config if not provided.
        """
        self._config = config
        self._graph: nx.DiGraph | None = None
        # Lookup tables for link resolution
        self._id_to_node: dict[str, str] = {}  # doc_id -> node_id
        self._path_to_node: dict[str, str] = {}  # relative_path -> node_id
        self._filename_to_nodes: dict[str, list[str]] = {}  # filename -> [node_ids]
        self._alias_to_node: dict[str, str] = {}  # alias -> node_id

    @property
    def config(self) -> LithosConfig:
        """Get configuration."""
        return self._config or get_config()

    @property
    def graph_cache_path(self) -> Path:
        """Get path to graph cache file."""
        return self.config.storage.graph_path / "graph.json"

    @property
    def graph(self) -> nx.DiGraph:
        """Get graph, creating if needed."""
        if self._graph is None:
            self._graph = nx.DiGraph()
        return self._graph

    def load_cache(self) -> bool:
        """Load graph from cache.

        Returns:
            True if cache was loaded, False otherwise
        """
        cache_path = self.graph_cache_path
        if not cache_path.exists():
            return False

        try:
            with open(cache_path) as f:
                data = json.load(f)
            cached_version = data.get("version")
            if cached_version != GRAPH_CACHE_VERSION:
                logger.warning(
                    "Graph cache version mismatch (expected %s, got %s) — rebuilding",
                    GRAPH_CACHE_VERSION,
                    cached_version,
                )
                return False
            graph_data = data.get("graph")
            if graph_data and "nodes" in graph_data and "links" in graph_data:
                self._graph = nx.node_link_graph(graph_data, edges="links")
            else:
                self._graph = nx.DiGraph()
            self._id_to_node = data.get("id_to_node", {})
            self._path_to_node = data.get("path_to_node", {})
            self._filename_to_nodes = data.get("filename_to_nodes", {})
            self._alias_to_node = data.get("alias_to_node", {})
            return True
        except Exception:
            logger.exception("Failed to load graph cache")
            return False

    def save_cache(self) -> None:
        """Save graph to cache atomically (write to temp file, then rename)."""
        cache_path = self.graph_cache_path
        cache_path.parent.mkdir(parents=True, exist_ok=True)

        graph_data = (
            nx.node_link_data(self._graph, edges="links") if self._graph is not None else {}
        )
        data = {
            "version": GRAPH_CACHE_VERSION,
            "graph": graph_data,
            "id_to_node": self._id_to_node,
            "path_to_node": self._path_to_node,
            "filename_to_nodes": self._filename_to_nodes,
            "alias_to_node": self._alias_to_node,
        }

        tmp_fd, tmp_path = tempfile.mkstemp(dir=cache_path.parent, suffix=".tmp")
        try:
            with os.fdopen(tmp_fd, "w") as f:
                json.dump(data, f)
            os.replace(tmp_path, cache_path)
        except Exception:
            with contextlib.suppress(OSError):
                os.unlink(tmp_path)
            raise

    def add_document(self, doc: KnowledgeDocument) -> None:
        """Add or update a document in the graph.

        Args:
            doc: Document to add
        """
        node_id = doc.id

        # Remove existing node if present (to update)
        if node_id in self.graph:
            self._remove_node_lookups(node_id)
            self.graph.remove_node(node_id)

        # Add node with attributes
        self.graph.add_node(
            node_id,
            title=doc.title,
            path=str(doc.path),
            aliases=doc.metadata.aliases,
        )

        # Update lookup tables
        self._id_to_node[doc.id] = node_id
        self._path_to_node[str(doc.path)] = node_id

        # Filename lookup (without extension)
        filename = doc.path.stem
        if filename not in self._filename_to_nodes:
            self._filename_to_nodes[filename] = []
        if node_id not in self._filename_to_nodes[filename]:
            self._filename_to_nodes[filename].append(node_id)

        # Alias lookups
        for alias in doc.metadata.aliases:
            self._alias_to_node[alias.lower()] = node_id

        # Add edges for wiki-links
        for link in doc.links:
            target_node = self._resolve_link(link.target)
            if target_node:
                self.graph.add_edge(node_id, target_node, link_text=link.target)
            else:
                # Store unresolved link as edge to placeholder
                placeholder = f"__unresolved__{link.target}"
                if placeholder not in self.graph:
                    self.graph.add_node(placeholder, unresolved=True, link_text=link.target)
                self.graph.add_edge(node_id, placeholder, link_text=link.target)

        # Resolve any previously unresolved links that now point to this document
        self._resolve_pending_links(doc)

    def _remove_node_lookups(self, node_id: str) -> None:
        """Remove a node from lookup tables."""
        # Remove from id lookup
        for doc_id, nid in list(self._id_to_node.items()):
            if nid == node_id:
                del self._id_to_node[doc_id]
                break

        # Remove from path lookup
        for path, nid in list(self._path_to_node.items()):
            if nid == node_id:
                del self._path_to_node[path]
                break

        # Remove from filename lookup
        for filename, nodes in list(self._filename_to_nodes.items()):
            if node_id in nodes:
                nodes.remove(node_id)
                if not nodes:
                    del self._filename_to_nodes[filename]
                break

        # Remove from alias lookup
        for alias, nid in list(self._alias_to_node.items()):
            if nid == node_id:
                del self._alias_to_node[alias]

    def _resolve_pending_links(self, doc: KnowledgeDocument) -> None:
        """Resolve any unresolved links that now point to this document.

        Args:
            doc: The newly added document
        """
        # Possible targets that could match this document
        possible_targets = [
            doc.path.stem,  # filename without extension
            str(doc.path),  # full path
            doc.id,  # document ID
            doc.title.lower().replace(" ", "-"),  # slugified title
        ]
        # Add aliases
        possible_targets.extend([a.lower() for a in doc.metadata.aliases])

        # Find matching unresolved placeholders
        placeholders_to_resolve = []
        for node in list(self.graph.nodes()):
            if node.startswith("__unresolved__"):
                link_text = node.replace("__unresolved__", "")
                # Check if this unresolved link matches our document
                if link_text.lower() in [
                    t.lower() for t in possible_targets
                ] or link_text.lower().replace(" ", "-") in [t.lower() for t in possible_targets]:
                    placeholders_to_resolve.append((node, link_text))

        # Resolve each placeholder
        for placeholder, _link_text in placeholders_to_resolve:
            # Get all edges pointing to this placeholder
            predecessors = list(self.graph.predecessors(placeholder))

            # Redirect edges to the real node
            for pred in predecessors:
                edge_data = self.graph.edges[pred, placeholder]
                self.graph.remove_edge(pred, placeholder)
                self.graph.add_edge(pred, doc.id, **edge_data)

            # Remove the placeholder node
            self.graph.remove_node(placeholder)

    def remove_document(self, doc_id: str) -> None:
        """Remove a document from the graph.

        Args:
            doc_id: Document ID to remove
        """
        node_id = self._id_to_node.get(doc_id)
        if node_id and node_id in self.graph:
            self._remove_node_lookups(node_id)
            self.graph.remove_node(node_id)

    def _resolve_link(self, target: str) -> str | None:
        """Resolve a wiki-link target to a node ID.

        Resolution precedence:
        1. Exact path: [[folder/note]] -> folder/note.md
        2. Filename: [[note]] -> */note.md (error if ambiguous)
        3. UUID: [[uuid]] -> file with that id
        4. Alias: [[alias]] -> file with that alias

        Args:
            target: Link target string

        Returns:
            Node ID if resolved, None otherwise
        """
        # 1. Exact path (with or without .md extension)
        path_target = target if target.endswith(".md") else f"{target}.md"
        if path_target in self._path_to_node:
            return self._path_to_node[path_target]

        # Also try without .md for paths that might include it
        if target in self._path_to_node:
            return self._path_to_node[target]

        # 2. Filename match
        filename = target.split("/")[-1]  # Get last component
        if filename.endswith(".md"):
            filename = filename[:-3]
        if filename in self._filename_to_nodes:
            nodes = self._filename_to_nodes[filename]
            if len(nodes) == 1:
                return nodes[0]
            # Ambiguous - return None (could raise error)
            return None

        # 3. UUID match
        if target in self._id_to_node:
            return self._id_to_node[target]

        # 4. Alias match
        if target.lower() in self._alias_to_node:
            return self._alias_to_node[target.lower()]

        return None

    def get_links(
        self,
        doc_id: str,
        direction: Literal["outgoing", "incoming", "both"] = "both",
        depth: int = 1,
    ) -> LinkInfo:
        """Get links for a document.

        Args:
            doc_id: Document ID
            direction: Link direction to retrieve
            depth: Traversal depth (1-3)

        Returns:
            LinkInfo with outgoing and incoming links
        """
        depth = max(1, min(3, depth))  # Clamp to 1-3
        node_id = self._id_to_node.get(doc_id)

        if not node_id or node_id not in self.graph:
            return LinkInfo(outgoing=[], incoming=[])

        outgoing: list[LinkedDocument] = []
        incoming: list[LinkedDocument] = []

        if direction in ("outgoing", "both"):
            outgoing = self._get_reachable_nodes(node_id, depth, forward=True)

        if direction in ("incoming", "both"):
            incoming = self._get_reachable_nodes(node_id, depth, forward=False)

        return LinkInfo(outgoing=outgoing, incoming=incoming)

    def _get_reachable_nodes(
        self,
        start_node: str,
        depth: int,
        forward: bool = True,
    ) -> list[LinkedDocument]:
        """Get all reachable nodes within depth.

        Args:
            start_node: Starting node ID
            depth: Maximum traversal depth
            forward: True for outgoing, False for incoming

        Returns:
            List of linked documents (deduplicated)
        """
        visited: set[str] = {start_node}
        current_level: set[str] = {start_node}
        result: list[LinkedDocument] = []

        for _ in range(depth):
            next_level: set[str] = set()
            for node in current_level:
                if forward:
                    neighbors = self.graph.successors(node)
                else:
                    neighbors = self.graph.predecessors(node)

                for neighbor in neighbors:
                    if neighbor not in visited:
                        visited.add(neighbor)
                        next_level.add(neighbor)

                        # Skip unresolved placeholder nodes
                        if neighbor.startswith("__unresolved__"):
                            continue

                        node_data = self.graph.nodes.get(neighbor, {})
                        if not node_data.get("unresolved"):
                            result.append(
                                LinkedDocument(
                                    id=neighbor,
                                    title=node_data.get("title", ""),
                                )
                            )

            current_level = next_level
            if not current_level:
                break

        return result

    def get_broken_links(self) -> list[tuple[str, str, str]]:
        """Get all broken/unresolved links.

        Returns:
            List of (source_id, source_title, link_target) tuples
        """
        broken: list[tuple[str, str, str]] = []

        for node in self.graph.nodes():
            if node.startswith("__unresolved__"):
                continue

            node_data = self.graph.nodes[node]
            for _, target, edge_data in self.graph.out_edges(node, data=True):
                if target.startswith("__unresolved__"):
                    broken.append(
                        (
                            node,
                            node_data.get("title", ""),
                            edge_data.get("link_text", target.replace("__unresolved__", "")),
                        )
                    )

        return broken

    def get_ambiguous_links(self) -> list[tuple[str, list[str]]]:
        """Get all ambiguous link targets.

        Returns:
            List of (filename, [matching_paths]) tuples
        """
        ambiguous: list[tuple[str, list[str]]] = []

        for filename, nodes in self._filename_to_nodes.items():
            if len(nodes) > 1:
                paths = [self.graph.nodes[n].get("path", n) for n in nodes if n in self.graph.nodes]
                ambiguous.append((filename, paths))

        return ambiguous

    def clear(self) -> None:
        """Clear the graph."""
        self._graph = nx.DiGraph()
        self._id_to_node.clear()
        self._path_to_node.clear()
        self._filename_to_nodes.clear()
        self._alias_to_node.clear()

    def get_doc_ids(self) -> set[str]:
        """Return the set of doc_ids tracked by the graph."""
        return set(self._id_to_node.keys())

    def node_count(self) -> int:
        """Get number of document nodes (excluding unresolved)."""
        return sum(1 for n in self.graph.nodes() if not n.startswith("__unresolved__"))

    def edge_count(self) -> int:
        """Get number of edges."""
        return self.graph.number_of_edges()

    def has_node(self, node_id: str) -> bool:
        """Check if a node exists in the graph.

        Args:
            node_id: Node/document ID to check

        Returns:
            True if node exists, False otherwise
        """
        return node_id in self.graph

    def has_edge(self, source_id: str, target_id: str) -> bool:
        """Check if an edge exists between two nodes.

        Args:
            source_id: Source node ID
            target_id: Target node ID

        Returns:
            True if edge exists, False otherwise
        """
        return self.graph.has_edge(source_id, target_id)

    def get_outgoing_links(self, doc_id: str) -> list[dict]:
        """Get outgoing links from a document.

        Args:
            doc_id: Document ID

        Returns:
            List of linked document dicts with 'id' and 'title' keys
        """
        links = self.get_links(doc_id, direction="outgoing", depth=1).outgoing
        return [{"id": link.id, "title": link.title} for link in links]

    def get_incoming_links(self, doc_id: str) -> list[dict]:
        """Get incoming links to a document.

        Args:
            doc_id: Document ID

        Returns:
            List of linked document dicts with 'id' and 'title' keys
        """
        links = self.get_links(doc_id, direction="incoming", depth=1).incoming
        return [{"id": link.id, "title": link.title} for link in links]

    def get_neighbors(self, doc_id: str) -> list[dict]:
        """Get all neighbors (both incoming and outgoing) of a document.

        Args:
            doc_id: Document ID

        Returns:
            List of linked document dicts (deduplicated)
        """
        link_info = self.get_links(doc_id, direction="both", depth=1)
        # Deduplicate by ID
        seen: set[str] = set()
        result: list[dict] = []
        for doc in link_info.outgoing + link_info.incoming:
            if doc.id not in seen:
                seen.add(doc.id)
                result.append({"id": doc.id, "title": doc.title})
        return result

    def find_path(self, source_id: str, target_id: str) -> list[str] | None:
        """Find shortest path between two documents.

        Args:
            source_id: Source document ID
            target_id: Target document ID

        Returns:
            List of node IDs in path, or None if no path exists
        """
        if source_id not in self.graph or target_id not in self.graph:
            return None

        try:
            path = nx.shortest_path(self.graph, source_id, target_id)
            return list(path)
        except nx.NetworkXNoPath:
            return None

    def find_orphans(self) -> list[str]:
        """Find documents with no incoming or outgoing links.

        Returns:
            List of orphan document IDs
        """
        orphans: list[str] = []

        for node in self.graph.nodes():
            # Skip unresolved placeholder nodes
            if node.startswith("__unresolved__"):
                continue

            # Check if node has any edges (in or out)
            in_degree = self.graph.in_degree(node)
            out_degree = self.graph.out_degree(node)

            if in_degree == 0 and out_degree == 0:
                orphans.append(node)

        return orphans

    def get_stats(self) -> dict:
        """Get graph statistics.

        Returns:
            Dictionary with graph statistics
        """
        real_nodes = [n for n in self.graph.nodes() if not n.startswith("__unresolved__")]
        unresolved_nodes = [n for n in self.graph.nodes() if n.startswith("__unresolved__")]

        return {
            "nodes": len(real_nodes),
            "edges": self.graph.number_of_edges(),
            "unresolved_links": len(unresolved_nodes),
            "orphans": len(self.find_orphans()),
            "density": nx.density(self.graph) if len(real_nodes) > 1 else 0.0,
        }

    def get_most_linked(self, limit: int = 10) -> list[dict]:
        """Get documents with most incoming links.

        Args:
            limit: Maximum number of results

        Returns:
            List of dicts with 'id', 'title', and 'incoming_count' keys, sorted by link count descending
        """
        results: list[dict] = []

        for node in self.graph.nodes():
            if node.startswith("__unresolved__"):
                continue

            in_degree = self.graph.in_degree(node)
            node_data = self.graph.nodes[node]
            results.append(
                {
                    "id": node,
                    "title": node_data.get("title", ""),
                    "incoming_count": in_degree,
                }
            )

        # Sort by incoming count descending
        results.sort(key=lambda x: x["incoming_count"], reverse=True)
        return results[:limit]

    def get_unresolved_links(self) -> list[tuple[str, str]]:
        """Get all unresolved link targets.

        Returns:
            List of (source_id, target_text) tuples
        """
        unresolved: list[tuple[str, str]] = []

        for node in self.graph.nodes():
            if node.startswith("__unresolved__"):
                continue

            for _, target, edge_data in self.graph.out_edges(node, data=True):
                if target.startswith("__unresolved__"):
                    link_text = edge_data.get("link_text", target.replace("__unresolved__", ""))
                    unresolved.append((node, link_text))

        return unresolved
