import { create } from 'zustand';
import type { GraphEdge, GraphNode, StickyNote, ValidationReport, WorkflowGraph } from '@/types';

/**
 * Local editor state: the graph being edited, selection, and undo history.
 *
 * Server state deliberately lives in TanStack Query instead. The split is what
 * keeps "what the user is currently dragging" from being confused with "what is
 * saved", which is the distinction the dirty flag depends on.
 *
 * No workflow business logic lives here. The store moves nodes and edges
 * around; whether the resulting graph is legal is the server's answer, arriving
 * as a ValidationReport.
 */

/** How many undo steps to keep. Deep enough to be useful, bounded so a long
 * session cannot grow the heap without limit. */
const HISTORY_LIMIT = 50;

interface Snapshot {
  nodes: GraphNode[];
  edges: GraphEdge[];
  notes: StickyNote[];
}

interface EditorState {
  nodes: GraphNode[];
  edges: GraphEdge[];
  /** Sticky notes: part of the saved graph and of undo, never of a run. */
  notes: StickyNote[];
  selectedNodeId: string | null;
  dirty: boolean;
  validation: ValidationReport | null;

  past: Snapshot[];
  future: Snapshot[];

  /** The graph as it was when the current drag began; null when not dragging. */
  dragOrigin: Snapshot | null;

  /** Replace the whole graph without recording history (used on load). */
  load: (graph: WorkflowGraph) => void;
  /** Current graph in the shape the API expects. */
  toGraph: () => WorkflowGraph;

  addNode: (node: GraphNode) => void;
  updateNodeConfig: (id: string, config: Record<string, unknown>) => void;
  renameNode: (id: string, label: string) => void;
  /** Remember where everything was, so the whole drag is one undo step. */
  beginDrag: () => void;
  /** Apply a frame of a drag: no history, no dirty flag. */
  dragNodes: (moves: Array<{ id: string; x: number; y: number }>) => void;
  /** Commit the drag as a single history entry, if anything actually moved. */
  endDrag: () => void;
  /** Move many nodes at once as one undoable step — Tidy up. */
  arrange: (positions: Record<string, { x: number; y: number }>) => void;
  removeNode: (id: string) => void;
  addNote: (note: StickyNote) => void;
  /** Text or colour, as one undoable change. */
  updateNote: (id: string, patch: Partial<Pick<StickyNote, 'text' | 'color'>>) => void;
  /**
   * A frame of a resize, applied without history; `commit` on release records
   * the whole resize as one undo step, as a drag does.
   */
  resizeNote: (id: string, box: { x: number; y: number; width: number; height: number }, commit: boolean) => void;
  removeNote: (id: string) => void;
  /** Place a node and wire it to an output, as one undoable step — the + on a port. */
  addConnected: (node: GraphNode, edge: GraphEdge) => void;
  duplicateNode: (id: string) => void;
  toggleDisabled: (id: string) => void;

  connect: (edge: GraphEdge) => void;
  removeEdge: (id: string) => void;

  select: (id: string | null) => void;

  /**
   * The quick-add picker a port's + opened: which output it adds after, and
   * where on screen it was clicked. Interface state, so not in history.
   */
  quickAdd: QuickAddTarget | null;
  openQuickAdd: (target: QuickAddTarget) => void;
  closeQuickAdd: () => void;
  setValidation: (report: ValidationReport | null) => void;
  markSaved: () => void;
  /**
   * Say the graph differs from what is stored.
   *
   * For a graph that arrives whole rather than by editing — one the AI
   * changed — where load() would otherwise leave the editor looking saved and
   * the author would walk away from work that was never written down.
   */
  markDirty: () => void;

  undo: () => void;
  redo: () => void;
  canUndo: () => boolean;
  canRedo: () => boolean;
}

export interface QuickAddTarget {
  source: string;
  port: string;
  /** Client coordinates of the + that was clicked. */
  x: number;
  y: number;
}

function snapshot(state: EditorState): Snapshot {
  return { nodes: state.nodes, edges: state.edges, notes: state.notes };
}

/**
 * Record the pre-change graph, then apply the change.
 *
 * Every mutation goes through this so history can never miss one. Redo is
 * cleared on any new change — branching history is a feature nobody asked for
 * and a source of "my redo did something I never did" bugs.
 */
function withHistory(
  state: EditorState,
  next: Partial<Snapshot>,
): Partial<EditorState> {
  return {
    ...next,
    past: [...state.past, snapshot(state)].slice(-HISTORY_LIMIT),
    future: [],
    dirty: true,
  };
}

export const useEditorStore = create<EditorState>((set, get) => ({
  nodes: [],
  edges: [],
  notes: [],
  selectedNodeId: null,
  dirty: false,
  validation: null,
  past: [],
  future: [],
  dragOrigin: null,

  load: (graph) =>
    set({
      nodes: graph.nodes ?? [],
      edges: graph.edges ?? [],
      notes: graph.notes ?? [],
      selectedNodeId: null,
      dirty: false,
      validation: null,
      past: [],
      future: [],
      dragOrigin: null,
    }),

  toGraph: () => {
    const { nodes, edges, notes } = get();

    // Notes only when there are some, so a graph without them saves exactly as before.
    return notes.length > 0 ? { nodes, edges, notes } : { nodes, edges };
  },

  addNode: (node) => set((state) => withHistory(state, { nodes: [...state.nodes, node] })),

  updateNodeConfig: (id, config) =>
    set((state) =>
      withHistory(state, {
        nodes: state.nodes.map((node) => (node.id === id ? { ...node, config } : node)),
      }),
    ),

  renameNode: (id, label) =>
    set((state) =>
      withHistory(state, {
        nodes: state.nodes.map((node) => (node.id === id ? { ...node, label } : node)),
      }),
    ),

  beginDrag: () => set((state) => (state.dragOrigin ? {} : { dragOrigin: snapshot(state) })),

  /**
   * Move nodes without touching history.
   *
   * This runs on every frame of a drag, which is why it deliberately does not
   * go through withHistory: fifty snapshots per gesture would bury the undo
   * stack, and marking the graph dirty before the user has let go would claim
   * an edit they have not made yet.
   */
  dragNodes: (moves) =>
    set((state) => {
      const byId = new Map(moves.map((move) => [move.id, move]));

      const moved = <T extends { id: string; position: { x: number; y: number } }>(item: T): T => {
        const move = byId.get(item.id);

        if (!move || (item.position.x === move.x && item.position.y === move.y)) {
          return item;
        }

        return { ...item, position: { x: move.x, y: move.y } };
      };

      return { nodes: state.nodes.map(moved), notes: state.notes.map(moved) };
    }),

  endDrag: () =>
    set((state) => {
      const origin = state.dragOrigin;

      if (!origin) {
        return {};
      }

      // A click that never moved anything is not an edit. Recording it would
      // put steps in the undo stack that appear to do nothing when replayed.
      const shifted = (before: { id: string; position: { x: number; y: number } }, now: Array<{ id: string; position: { x: number; y: number } }>) => {
        const after = now.find((item) => item.id === before.id);

        return after !== undefined && (after.position.x !== before.position.x || after.position.y !== before.position.y);
      };
      const moved =
        origin.nodes.some((before) => shifted(before, state.nodes)) ||
        origin.notes.some((before) => shifted(before, state.notes));

      if (!moved) {
        return { dragOrigin: null };
      }

      return {
        dragOrigin: null,
        past: [...state.past, origin].slice(-HISTORY_LIMIT),
        future: [],
        dirty: true,
      };
    }),

  arrange: (positions) =>
    set((state) => {
      const moved = state.nodes.some((node) => {
        const to = positions[node.id];

        return to !== undefined && (to.x !== node.position.x || to.y !== node.position.y);
      });

      // Tidying a tidy graph is not an edit, and must not mark it unsaved.
      if (!moved) {
        return {};
      }

      return withHistory(state, {
        nodes: state.nodes.map((node) => {
          const to = positions[node.id];

          return to ? { ...node, position: to } : node;
        }),
      });
    }),

  removeNode: (id) =>
    set((state) => ({
      ...withHistory(state, {
        nodes: state.nodes.filter((node) => node.id !== id),
        // Edges touching a deleted node must go with it, or the graph keeps
        // references the server will reject as dangling.
        edges: state.edges.filter((edge) => edge.source !== id && edge.target !== id),
      }),
      selectedNodeId: state.selectedNodeId === id ? null : state.selectedNodeId,
    })),

  duplicateNode: (id) =>
    set((state) => {
      const source = state.nodes.find((node) => node.id === id);

      if (!source) {
        return {};
      }

      const copy: GraphNode = {
        ...source,
        id: newNodeId(),
        // Offset so the copy is visibly distinct rather than hidden underneath.
        position: { x: source.position.x + 40, y: source.position.y + 40 },
      };

      // The copy is selected, so its settings open ready to change.
      return { ...withHistory(state, { nodes: [...state.nodes, copy] }), selectedNodeId: copy.id };
    }),

  addNote: (note) => set((state) => withHistory(state, { notes: [...state.notes, note] })),

  updateNote: (id, patch) =>
    set((state) => {
      const current = state.notes.find((note) => note.id === id);

      // Leaving a note without changing it is not an edit.
      if (!current || Object.entries(patch).every(([key, value]) => current[key as keyof StickyNote] === value)) {
        return {};
      }

      return withHistory(state, { notes: state.notes.map((note) => (note.id === id ? { ...note, ...patch } : note)) });
    }),

  resizeNote: (id, box, commit) =>
    set((state) => {
      const notes = state.notes.map((note) =>
        note.id === id
          ? {
              ...note,
              position: { x: box.x, y: box.y },
              width: Math.round(box.width),
              height: Math.round(box.height),
            }
          : note,
      );

      if (!commit) {
        // The first frame remembers where the resize began, as a drag does.
        return state.dragOrigin ? { notes } : { notes, dragOrigin: snapshot(state) };
      }

      const origin = state.dragOrigin ?? snapshot(state);

      return {
        notes,
        dragOrigin: null,
        past: [...state.past, origin].slice(-HISTORY_LIMIT),
        future: [],
        dirty: true,
      };
    }),

  removeNote: (id) => set((state) => withHistory(state, { notes: state.notes.filter((note) => note.id !== id) })),

  addConnected: (node, edge) =>
    set((state) => ({
      ...withHistory(state, { nodes: [...state.nodes, node], edges: [...state.edges, edge] }),
      selectedNodeId: node.id,
      quickAdd: null,
    })),

  toggleDisabled: (id) =>
    set((state) =>
      withHistory(state, {
        nodes: state.nodes.map((node) =>
          node.id === id ? { ...node, disabled: !node.disabled } : node,
        ),
      }),
    ),

  connect: (edge) =>
    set((state) => {
      // Reconnecting the same two ports would run the branch twice.
      const exists = state.edges.some(
        (existing) =>
          existing.source === edge.source &&
          existing.target === edge.target &&
          existing.sourceHandle === edge.sourceHandle &&
          existing.targetHandle === edge.targetHandle,
      );

      if (exists) {
        return {};
      }

      return withHistory(state, { edges: [...state.edges, edge] });
    }),

  removeEdge: (id) =>
    set((state) => withHistory(state, { edges: state.edges.filter((edge) => edge.id !== id) })),

  select: (id) => set({ selectedNodeId: id }),

  quickAdd: null,
  openQuickAdd: (target) => set({ quickAdd: target }),
  closeQuickAdd: () => set({ quickAdd: null }),

  setValidation: (report) => set({ validation: report }),

  markSaved: () => set({ dirty: false }),

  markDirty: () => set({ dirty: true }),

  undo: () =>
    set((state) => {
      const previous = state.past[state.past.length - 1];

      if (!previous) {
        return {};
      }

      return {
        nodes: previous.nodes,
        edges: previous.edges,
        notes: previous.notes,
        past: state.past.slice(0, -1),
        future: [snapshot(state), ...state.future].slice(0, HISTORY_LIMIT),
        dirty: true,
      };
    }),

  redo: () =>
    set((state) => {
      const next = state.future[0];

      if (!next) {
        return {};
      }

      return {
        nodes: next.nodes,
        edges: next.edges,
        notes: next.notes,
        past: [...state.past, snapshot(state)].slice(-HISTORY_LIMIT),
        future: state.future.slice(1),
        dirty: true,
      };
    }),

  canUndo: () => get().past.length > 0,
  canRedo: () => get().future.length > 0,
}));

/**
 * A graph-unique node id.
 *
 * Generated client-side so a node can be placed and configured without a round
 * trip. `crypto.randomUUID` is available in every browser WordPress 6.5
 * supports; the fallback covers insecure-context admin over plain HTTP, which
 * is still common on local installs.
 */
export function newNodeId(): string {
  if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
    return `n${crypto.randomUUID().slice(0, 8)}`;
  }

  return `n${Math.random().toString(36).slice(2, 10)}`;
}
