from __future__ import annotations

import json
from pathlib import Path
from typing import Any


class CLIGraph:
    """In-memory graph state for CLI operations.

    Nodes use short sequential IDs (n1, n2, ...) for easy terminal reference.
    The graph has no dependency on the node registry — it is pure data and
    stores definition IDs, params, and edges. It can export to the format
    expected by the existing execution engine via to_execute_format().

    Optional persistence: pass *persist_path* to persist state to disk on every
    mutation. If the write fails (missing parent dir, permission denied, etc.)
    the in-memory mutation still succeeds — persistence is best-effort, never
    authoritative. Load the persisted state at boot via `load(persist_path)`.
    """

    def __init__(self, persist_path: Path | None = None) -> None:
        self.nodes: dict[str, dict[str, Any]] = {}
        self.edges: list[dict[str, str]] = []
        self._counter: int = 0
        self._persist_path: Path | None = persist_path

    def _maybe_persist(self) -> None:
        """Best-effort save — swallow errors so a hostile filesystem never
        breaks an otherwise-valid mutation. Errors get surfaced via print so
        server operators can diagnose, but the API call succeeds regardless."""
        if self._persist_path is None:
            return
        try:
            self.save(self._persist_path)
        except Exception as exc:
            print(f"[cli_graph] persist failed: {exc}", flush=True)

    # ------------------------------------------------------------------
    # Mutation

    def add_node(
        self,
        definition_id: str,
        params: dict[str, Any],
        position: dict[str, float] | None = None,
        outputs: dict[str, Any] | None = None,
    ) -> str:
        """Add a node and return its short sequential ID (e.g. 'n1').

        *position* and *outputs* are optional carry-ins used by the /api/graph/import
        path so loaded graphs preserve their layout and any previously-generated
        results. Nodes created through `nebula create` omit both and get
        auto-laid-out by the export function.
        """
        self._counter += 1
        short_id = f"n{self._counter}"
        node: dict[str, Any] = {
            "id": short_id,
            "definitionId": definition_id,
            "params": dict(params),
            "outputs": dict(outputs) if outputs else {},
        }
        if position is not None:
            node["position"] = {
                "x": float(position.get("x", 0)),
                "y": float(position.get("y", 0)),
            }
        self.nodes[short_id] = node
        self._maybe_persist()
        return short_id

    def connect(
        self, src_id: str, src_port: str, dst_id: str, dst_port: str
    ) -> dict[str, str]:
        """Connect two nodes by port. Returns the created edge dict."""
        if src_id not in self.nodes:
            raise ValueError(f"Source node '{src_id}' not found")
        if dst_id not in self.nodes:
            raise ValueError(f"Target node '{dst_id}' not found")

        edge: dict[str, str] = {
            "id": f"e{len(self.edges) + 1}",
            "source": src_id,
            "sourceHandle": src_port,
            "target": dst_id,
            "targetHandle": dst_port,
        }
        self.edges.append(edge)
        self._maybe_persist()
        return edge

    def update_params(self, node_id: str, params: dict[str, Any]) -> None:
        """Merge *params* into the node's existing params dict."""
        if node_id not in self.nodes:
            raise ValueError(f"Node '{node_id}' not found")
        self.nodes[node_id]["params"].update(params)
        self._maybe_persist()

    def remove_node(self, node_id: str) -> None:
        """Remove a node and any edges touching it. Raises if node_id is unknown."""
        if node_id not in self.nodes:
            raise ValueError(f"Node '{node_id}' not found")
        del self.nodes[node_id]
        self.edges = [
            e for e in self.edges if e["source"] != node_id and e["target"] != node_id
        ]
        self._maybe_persist()

    def remove_edge(
        self, source: str, source_handle: str, target: str, target_handle: str
    ) -> bool:
        """Remove the edge matching all four endpoints. Returns True if one was removed."""
        for i, e in enumerate(self.edges):
            if (
                e["source"] == source
                and e["sourceHandle"] == source_handle
                and e["target"] == target
                and e["targetHandle"] == target_handle
            ):
                self.edges.pop(i)
                self._maybe_persist()
                return True
        return False

    def clear(self) -> None:
        """Remove all nodes and edges and reset the ID counter."""
        self.nodes.clear()
        self.edges.clear()
        self._counter = 0
        self._maybe_persist()

    # ------------------------------------------------------------------
    # Read / export

    def get_state(self) -> dict[str, Any]:
        """Return a snapshot of the graph as plain dicts."""
        return {
            "nodes": list(self.nodes.values()),
            "edges": list(self.edges),
        }

    def to_execute_format(
        self,
    ) -> tuple[list[dict[str, Any]], list[dict[str, str]]]:
        """Convert to the format expected by execute_graph / ExecuteRequest."""
        nodes = [
            {
                "id": n["id"],
                "definitionId": n["definitionId"],
                "params": n["params"],
                "outputs": {},
            }
            for n in self.nodes.values()
        ]
        edges = [
            {
                "id": e["id"],
                "source": e["source"],
                "sourceHandle": e["sourceHandle"],
                "target": e["target"],
                "targetHandle": e["targetHandle"],
            }
            for e in self.edges
        ]
        return nodes, edges

    # ------------------------------------------------------------------
    # Persistence

    def save(self, path: Path) -> None:
        """Persist the graph (including counter) to a JSON file."""
        data = {
            "nodes": list(self.nodes.values()),
            "edges": list(self.edges),
            "counter": self._counter,
        }
        Path(path).write_text(json.dumps(data, indent=2))

    def load(self, path: Path) -> None:
        """Replace current graph state with contents of a JSON file."""
        data = json.loads(Path(path).read_text())
        self.nodes = {n["id"]: n for n in data["nodes"]}
        self.edges = data["edges"]
        self._counter = data.get("counter", len(self.nodes))
