"""
Manifest data structures for Seeknal 2.0.

The manifest is the serialized representation of the entire DAG,
including all nodes, edges, and metadata. It is generated by
`seeknal parse` and stored as manifest.json.
"""
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from pathlib import Path
from typing import Any, Optional
import json

from seeknal.utils.path_security import warn_if_insecure_path


class NodeType(Enum):
    """Types of nodes in the DAG."""
    SOURCE = "source"
    TRANSFORM = "transform"
    FEATURE_GROUP = "feature_group"
    MODEL = "model"
    PROFILE = "profile"
    RULE = "rule"
    AGGREGATION = "aggregation"
    SECOND_ORDER_AGGREGATION = "second_order_aggregation"
    EXPOSURE = "exposure"
    PYTHON = "python"  # For nodes that need special Python execution
    SEMANTIC_MODEL = "semantic_model"
    METRIC = "metric"


@dataclass(slots=True)
class ManifestMetadata:
    """Metadata about the manifest."""
    project: str
    seeknal_version: str = "2.0.0"
    generated_at: str = field(default_factory=lambda: datetime.now().isoformat())

    def to_dict(self) -> dict[str, Any]:
        """Convert to dictionary."""
        return {
            "project": self.project,
            "seeknal_version": self.seeknal_version,
            "generated_at": self.generated_at,
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> "ManifestMetadata":
        """Create from dictionary."""
        return cls(
            project=data["project"],
            seeknal_version=data.get("seeknal_version", "2.0.0"),
            generated_at=data.get("generated_at", datetime.now().isoformat()),
        )


@dataclass(slots=True)
class Node:
    """A node in the DAG."""
    id: str
    name: str
    node_type: NodeType
    description: Optional[str] = None
    owner: Optional[str] = None
    tags: list[str] = field(default_factory=list)
    columns: dict[str, str] = field(default_factory=dict)
    config: dict[str, Any] = field(default_factory=dict)
    file_path: Optional[str] = None

    def to_dict(self) -> dict[str, Any]:
        """Convert to dictionary."""
        return {
            "id": self.id,
            "name": self.name,
            "type": self.node_type.value,
            "description": self.description,
            "owner": self.owner,
            "tags": self.tags,
            "columns": self.columns,
            "config": self.config,
            "file_path": self.file_path,
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> "Node":
        """Create from dictionary."""
        return cls(
            id=data["id"],
            name=data["name"],
            node_type=NodeType(data["type"]),
            description=data.get("description"),
            owner=data.get("owner"),
            tags=data.get("tags", []),
            columns=data.get("columns", {}),
            config=data.get("config", {}),
            file_path=data.get("file_path"),
        )


@dataclass(slots=True)
class Edge:
    """An edge (dependency) in the DAG."""
    from_node: str
    to_node: str
    edge_type: str = "dependency"

    def to_dict(self) -> dict[str, Any]:
        """Convert to dictionary."""
        return {
            "from": self.from_node,
            "to": self.to_node,
            "type": self.edge_type,
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> "Edge":
        """Create from dictionary."""
        return cls(
            from_node=data["from"],
            to_node=data["to"],
            edge_type=data.get("type", "dependency"),
        )


class Manifest:
    """
    The complete DAG manifest.

    Contains all nodes, edges, and metadata for the project.
    This is a regular class (not dataclass) because it requires
    custom initialization logic with caches.
    """

    __slots__ = ('metadata', 'nodes', 'edges', '_upstream_cache', '_downstream_cache')

    def __init__(self, project: str = "", seeknal_version: str = "2.0.0"):
        self.metadata = ManifestMetadata(
            project=project,
            seeknal_version=seeknal_version,
        )
        self.nodes: dict[str, Node] = {}
        self.edges: list[Edge] = []
        self._upstream_cache: dict[str, set[str]] = {}
        self._downstream_cache: dict[str, set[str]] = {}

    def add_node(self, node: Node) -> None:
        """Add a node to the manifest."""
        self.nodes[node.id] = node
        self._invalidate_node_cache(node.id)

    def add_edge(self, from_node: str, to_node: str, edge_type: str = "dependency") -> None:
        """Add an edge to the manifest."""
        edge = Edge(from_node=from_node, to_node=to_node, edge_type=edge_type)
        self.edges.append(edge)
        # Only invalidate affected nodes
        self._invalidate_node_cache(from_node)
        self._invalidate_node_cache(to_node)

    def get_node(self, node_id: str) -> Optional[Node]:
        """Get a node by ID."""
        return self.nodes.get(node_id)

    def _invalidate_node_cache(self, node_id: str) -> None:
        """Invalidate cache for nodes affected by a change to node_id.

        Clears caches for the node and all its upstream and downstream neighbors,
        providing more efficient cache management than clearing all caches.

        Args:
            node_id: The ID of the node that changed.
        """
        # Collect all affected nodes: the node itself and all its neighbors
        affected_nodes = {node_id}
        affected_nodes.update(self.get_upstream_nodes(node_id))
        affected_nodes.update(self.get_downstream_nodes(node_id))

        # Clear caches only for affected nodes
        for node in affected_nodes:
            self._upstream_cache.pop(node, None)
            self._downstream_cache.pop(node, None)

    def _build_adjacency_lists(self) -> tuple[dict[str, set[str]], dict[str, set[str]]]:
        """Build upstream and downstream adjacency lists."""
        upstream: dict[str, set[str]] = {node_id: set() for node_id in self.nodes}
        downstream: dict[str, set[str]] = {node_id: set() for node_id in self.nodes}

        for edge in self.edges:
            if edge.to_node in upstream:
                upstream[edge.to_node].add(edge.from_node)
            if edge.from_node in downstream:
                downstream[edge.from_node].add(edge.to_node)

        return upstream, downstream

    def get_upstream_nodes(self, node_id: str) -> set[str]:
        """Get immediate upstream dependencies."""
        if not self._upstream_cache:
            self._upstream_cache, self._downstream_cache = self._build_adjacency_lists()
        return self._upstream_cache.get(node_id, set())

    def get_downstream_nodes(self, node_id: str) -> set[str]:
        """Get immediate downstream dependents."""
        if not self._downstream_cache:
            self._upstream_cache, self._downstream_cache = self._build_adjacency_lists()
        return self._downstream_cache.get(node_id, set())

    def detect_cycles(self) -> tuple[bool, list[str]]:
        """
        Detect if there are cycles in the DAG.

        Returns:
            Tuple of (has_cycle, cycle_path)
        """
        visited: set[str] = set()
        rec_stack: set[str] = set()
        cycle_path: list[str] = []
        # Track position of each node in path for O(1) lookup
        path_positions: dict[str, int] = {}

        def dfs(node: str, path: list[str]) -> bool:
            visited.add(node)
            rec_stack.add(node)
            path_positions[node] = len(path)
            path.append(node)

            for neighbor in self.get_downstream_nodes(node):
                if neighbor not in visited:
                    if dfs(neighbor, path):
                        return True
                elif neighbor in rec_stack:
                    # Found cycle - use dict for O(1) position lookup
                    cycle_start = path_positions[neighbor]
                    cycle_path.extend(path[cycle_start:])
                    cycle_path.append(neighbor)
                    return True

            path.pop()
            del path_positions[node]
            rec_stack.remove(node)
            return False

        for node_id in self.nodes:
            if node_id not in visited:
                if dfs(node_id, []):
                    return True, cycle_path

        return False, []

    def to_dict(self) -> dict[str, Any]:
        """Convert to dictionary."""
        return {
            "metadata": self.metadata.to_dict(),
            "nodes": {
                node_id: node.to_dict()
                for node_id, node in self.nodes.items()
            },
            "edges": [edge.to_dict() for edge in self.edges],
        }

    def to_json(self, indent: int = 2) -> str:
        """Convert to JSON string."""
        return json.dumps(self.to_dict(), indent=indent)

    @classmethod
    def _validate_manifest_structure(cls, data: dict[str, Any]) -> None:
        """Validate manifest JSON structure.

        Args:
            data: Parsed JSON data to validate.

        Raises:
            ValueError: If the structure is invalid.
        """
        if not isinstance(data, dict):
            raise ValueError("Manifest must be a JSON object")

        if "metadata" not in data:
            raise ValueError("Manifest missing required 'metadata' field")

        metadata = data["metadata"]
        if not isinstance(metadata, dict):
            raise ValueError("Manifest 'metadata' must be an object")
        if "project" not in metadata:
            raise ValueError("Manifest metadata missing required 'project' field")

        if "nodes" in data and not isinstance(data["nodes"], dict):
            raise ValueError("Manifest 'nodes' must be an object")

        if "edges" in data and not isinstance(data["edges"], list):
            raise ValueError("Manifest 'edges' must be an array")

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> "Manifest":
        """Create from dictionary.

        Args:
            data: Dictionary representation of manifest.

        Returns:
            Manifest instance.

        Raises:
            ValueError: If the data structure is invalid.
        """
        cls._validate_manifest_structure(data)

        manifest = cls.__new__(cls)
        manifest.metadata = ManifestMetadata.from_dict(data["metadata"])
        manifest.nodes = {
            node_id: Node.from_dict(node_data)
            for node_id, node_data in data.get("nodes", {}).items()
        }
        manifest.edges = [
            Edge.from_dict(edge_data)
            for edge_data in data.get("edges", [])
        ]
        manifest._upstream_cache = {}
        manifest._downstream_cache = {}
        return manifest

    @classmethod
    def from_json(cls, json_str: str) -> "Manifest":
        """Create from JSON string.

        Args:
            json_str: JSON string to parse.

        Returns:
            Manifest instance.

        Raises:
            ValueError: If the JSON structure is invalid.
            json.JSONDecodeError: If the JSON is malformed.
        """
        return cls.from_dict(json.loads(json_str))

    def save(self, path: str) -> None:
        """Save manifest to file.

        Args:
            path: Path to save the manifest to.

        Raises:
            ValueError: If the path contains path traversal sequences.
        """
        # Validate path to prevent path traversal attacks
        resolved = Path(path).resolve()
        if ".." in Path(path).parts:
            raise ValueError("Invalid path: path traversal not allowed")

        # Warn about insecure paths
        warn_if_insecure_path(str(resolved), context="manifest file")

        # Ensure parent directory exists
        resolved.parent.mkdir(parents=True, exist_ok=True)

        with open(resolved, "w", encoding="utf-8") as f:
            f.write(self.to_json())

    @classmethod
    def load(cls, path: str) -> "Manifest":
        """Load manifest from file.

        Args:
            path: Path to load the manifest from.

        Returns:
            Loaded Manifest instance.

        Raises:
            ValueError: If the path contains path traversal sequences.
            FileNotFoundError: If the file does not exist.
        """
        # Validate path to prevent path traversal attacks
        resolved = Path(path).resolve()
        if ".." in Path(path).parts:
            raise ValueError("Invalid path: path traversal not allowed")

        with open(resolved, "r", encoding="utf-8") as f:
            return cls.from_json(f.read())
