"use client";

import "@xyflow/react/dist/style.css";

import {
  Background,
  MiniMap,
  type Node,
  ReactFlow,
  ReactFlowProvider,
  type Rect,
  useReactFlow,
} from "@xyflow/react";
import { useTheme } from "next-themes";
import {
  type MouseEvent,
  type Ref,
  useEffect,
  useImperativeHandle,
  useLayoutEffect,
  useRef,
  useState,
} from "react";

import { cn } from "@/lib/utils";
import type { AttackPathGraphData, GraphNode } from "@/types/attack-paths";

import {
  computeFilteredSubgraph,
  getNodeBorderColor,
  getNodeColor,
  getPathEdges,
  GRAPH_EDGE_HIGHLIGHT_COLOR,
  resolveHiddenFindingIds,
} from "../../_lib";
import { isFindingNode, layoutWithDagre } from "../../_lib/layout";
import { FindingNode } from "./nodes/finding-node";
import { InternetNode } from "./nodes/internet-node";
import { ResourceNode } from "./nodes/resource-node";

// --- Types ---

export interface GraphHandle {
  zoomIn: () => void;
  zoomOut: () => void;
  resetZoom: () => void;
  getZoomLevel: () => number;
  getContainerElement: () => HTMLDivElement | null;
  getNodesBounds: () => Rect | null;
}

interface AttackPathGraphProps {
  data: AttackPathGraphData;
  selectedNodeId?: string | null;
  isFilteredView?: boolean;
  initialNodeId?: string;
  expandedResources?: Set<string>;
  onNodeClick?: (node: GraphNode) => void;
  onInitialFilter?: (filteredData: AttackPathGraphData) => void;
  ref?: Ref<GraphHandle>;
  className?: string;
}

const EMPTY_EXPANDED: ReadonlySet<string> = new Set();

// --- Node type registry (stable reference) ---

const NODE_TYPES = {
  finding: FindingNode,
  internet: InternetNode,
  resource: ResourceNode,
} as const;

// --- CSS for animated dashed edges, selected node pulse, and edge highlight ---

const GRAPH_STYLES = `
  @keyframes dash {
    to { stroke-dashoffset: -20; }
  }
  .react-flow .finding-edge .react-flow__edge-path {
    stroke-dasharray: 8 6;
    animation: dash 1s linear infinite;
  }
  @keyframes selectedPulse {
    0%, 100% { stroke-opacity: 1; }
    50% { stroke-opacity: 0.6; }
  }
  .selected-node {
    animation: selectedPulse 1.2s ease-in-out infinite;
  }
  .react-flow .highlighted .react-flow__edge-path {
    stroke: ${GRAPH_EDGE_HIGHLIGHT_COLOR};
    stroke-width: 3;
    filter: drop-shadow(0 0 4px ${GRAPH_EDGE_HIGHLIGHT_COLOR});
  }
`;

// --- SVG filter color constants ---

const GRAPH_FINDING_GLOW_COLOR = "#ef4444";
const GRAPH_SELECTED_GLOW_COLOR = GRAPH_EDGE_HIGHLIGHT_COLOR;

// --- SVG filter defs (shared by all node components) ---

const GraphDefs = () => (
  <svg width={0} height={0} className="absolute" aria-hidden="true">
    <defs>
      {/* Glow filter for finding nodes */}
      <filter id="glow">
        <feGaussianBlur stdDeviation="3" result="coloredBlur" />
        <feMerge>
          <feMergeNode in="coloredBlur" />
          <feMergeNode in="SourceGraphic" />
        </feMerge>
      </filter>
      {/* Red glow for resources with findings */}
      <filter id="redGlow">
        <feDropShadow
          dx="0"
          dy="0"
          stdDeviation="4"
          floodColor={GRAPH_FINDING_GLOW_COLOR}
          floodOpacity="0.6"
        />
      </filter>
      {/* Prowler green glow for selected nodes */}
      <filter id="selectedGlow">
        <feDropShadow
          dx="0"
          dy="0"
          stdDeviation="6"
          floodColor={GRAPH_SELECTED_GLOW_COLOR}
          floodOpacity="0.8"
        />
      </filter>
    </defs>
  </svg>
);

// --- Inner component: calls useReactFlow(), owns layout derivation ---

type GraphCanvasProps = Omit<AttackPathGraphProps, "className">;

// Mask covers the area NOT currently in view; the cut-out rectangle is the
// viewport. To make the viewport rectangle stand out we darken the mask and
// give its border high contrast against the minimap background.
const MINIMAP_COLORS = {
  light: {
    bg: "#f8fafc",
    mask: "rgba(15, 23, 42, 0.45)",
    maskStroke: "#0f172a",
  },
  dark: {
    bg: "#0f172a",
    mask: "rgba(0, 0, 0, 0.7)",
    maskStroke: "#cbd5e1",
  },
} as const;

const MINIMAP_VIEWPORT_STROKE_WIDTH = 3;

// Animated re-fit shared by every auto-fit trigger. We deliberately do not
// cap maxZoom — for small subgraphs (e.g. a finding's filtered view with 3
// nodes) the user expects the result to fill the canvas; an artificial cap
// looks like a layout error.
//
// Padding is asymmetric: the minimap sits in the bottom-right corner of the
// canvas (default 200×150 panel + offset), so a fit with uniform padding
// can drop nodes underneath it. Generous bottom/right padding keeps the
// fitted graph clear of the minimap.
const AUTO_FIT_OPTIONS = {
  padding: { top: "32px", left: "32px", right: "240px", bottom: "180px" },
  duration: 300,
} as const;

const MEASURED_FIT_MAX_ATTEMPTS = 30;

const scheduleMeasuredFit = (
  isMeasured: () => boolean,
  onMeasured: () => void,
) => {
  let frame = 0;
  let attempts = 0;

  const tryFit = () => {
    if (isMeasured() || attempts >= MEASURED_FIT_MAX_ATTEMPTS) {
      onMeasured();
      return;
    }

    attempts += 1;
    frame = requestAnimationFrame(tryFit);
  };

  frame = requestAnimationFrame(tryFit);

  return () => cancelAnimationFrame(frame);
};

const GraphCanvas = ({
  data,
  selectedNodeId,
  isFilteredView = false,
  initialNodeId,
  expandedResources,
  onNodeClick,
  onInitialFilter,
  ref,
}: GraphCanvasProps) => {
  const { zoomIn, zoomOut, fitView, getZoom, getNodes, getNodesBounds } =
    useReactFlow();
  const { resolvedTheme } = useTheme();
  const containerRef = useRef<HTMLDivElement>(null);
  const hasInitialized = useRef(false);

  const minimapColors =
    resolvedTheme === "dark" ? MINIMAP_COLORS.dark : MINIMAP_COLORS.light;

  // Path highlight state — local to the canvas, must reset on data swaps
  // (otherwise we'd keep highlighting an edge that no longer exists).
  const [hoveredNodeId, setHoveredNodeId] = useState<string | null>(null);
  useEffect(() => {
    setHoveredNodeId(null);
  }, [data]);

  // Tier 1 expansion is controlled by the parent (zustand store) so it
  // survives the data-swaps that happen on filtered-view enter/exit.
  const expanded = expandedResources ?? EMPTY_EXPANDED;

  // --- initialNodeId: synchronous filtered-view derivation on first render ---
  // Compute the effective data: if initialNodeId is set and valid, derive filtered subgraph
  let effectiveData = data;
  if (
    initialNodeId &&
    !hasInitialized.current &&
    data.nodes.some((n) => n.id === initialNodeId)
  ) {
    effectiveData = computeFilteredSubgraph(data, initialNodeId);
  }

  // Sync store flags via useLayoutEffect (runs before paint)
  useLayoutEffect(() => {
    if (hasInitialized.current) return;
    hasInitialized.current = true;
    if (
      initialNodeId &&
      data.nodes.some((n) => n.id === initialNodeId) &&
      onInitialFilter
    ) {
      onInitialFilter(effectiveData);
    }
  }, []); // eslint-disable-line react-hooks/exhaustive-deps -- one-time init

  // --- Auto-fit triggers ---
  //
  // Filter toggles (finding click → filtered view, "Back to Full View" →
  // full graph) swap the data prop, which leaves the previous viewport
  // pointing at coordinates that no longer contain the new layout. Re-fit
  // once React Flow has applied the new layout (next animation frame).
  //
  // Resource expansion re-fits after newly revealed finding nodes have been
  // measured. This restores the original Show findings behavior: expanding a
  // resource should frame the selected resource plus its findings, not the
  // entire graph. Collapsing re-fits the remaining visible graph so the user
  // does not stay zoomed into empty space after hiding finding children.
  const filteredFitInitRef = useRef(false);
  const previousFilteredRef = useRef(isFilteredView);
  const previousExpandedRef = useRef<ReadonlySet<string>>(expanded);
  // Captured every render so the expand-fit effect can resolve a resource
  // ID to its connected finding IDs without recomputing the lookup.
  // The map itself is built later in this render — see assignment below
  // the layout-derivation block.
  const resourceToFindingsRef = useRef<Map<string, Set<string>>>(new Map());

  useEffect(() => {
    if (!filteredFitInitRef.current) {
      filteredFitInitRef.current = true;
      previousFilteredRef.current = isFilteredView;
      return;
    }
    if (previousFilteredRef.current === isFilteredView) return;
    const wasFilteredView = previousFilteredRef.current;
    previousFilteredRef.current = isFilteredView;
    // React Flow measures node sizes asynchronously via ResizeObserver after
    // the data swap. A single rAF runs while measured.width is still 0, so
    // fitView computes a degenerate bbox and the viewport keeps the user's
    // previous zoom — most visibly when leaving a filtered view in which the
    // user had zoomed in. Poll until visible nodes are measured (or give up
    // after ~500ms so we never block on a stuck observer).
    return scheduleMeasuredFit(
      () => {
        const visibleNodes = getNodes().filter((n) => !n.hidden);
        return (
          visibleNodes.length > 0 &&
          visibleNodes.every((n) => (n.measured?.width ?? 0) > 0)
        );
      },
      () => {
        if (wasFilteredView && !isFilteredView && expanded.size > 0) {
          const contextualNodeIds = new Set<string>();
          for (const resourceId of Array.from(expanded)) {
            contextualNodeIds.add(resourceId);
            resourceToFindingsRef.current
              .get(resourceId)
              ?.forEach((findingId) => contextualNodeIds.add(findingId));
          }

          const contextualNodes = getNodes().filter(
            (node) => !node.hidden && contextualNodeIds.has(node.id),
          );

          if (contextualNodes.length > 0) {
            fitView({ ...AUTO_FIT_OPTIONS, nodes: contextualNodes });
            return;
          }
        }

        fitView(AUTO_FIT_OPTIONS);
      },
    );
  }, [expanded, isFilteredView, fitView, getNodes]);

  useEffect(() => {
    const previous = previousExpandedRef.current;
    previousExpandedRef.current = expanded;
    if (previous === expanded) return;
    const newResourceIds = Array.from(expanded).filter(
      (id) => !previous.has(id),
    );
    const collapsedResourceIds = Array.from(previous).filter(
      (id) => !expanded.has(id),
    );

    if (newResourceIds.length === 0) {
      if (collapsedResourceIds.length === 0) return;

      return scheduleMeasuredFit(
        () => {
          const visibleNodes = getNodes().filter((n) => !n.hidden);
          return (
            visibleNodes.length > 0 &&
            visibleNodes.every((n) => (n.measured?.width ?? 0) > 0)
          );
        },
        () => fitView(AUTO_FIT_OPTIONS),
      );
    }

    const contextualFitNodeIds = new Set<string>(newResourceIds);
    const newFindingIds = new Set<string>();
    for (const resourceId of newResourceIds) {
      const findings = resourceToFindingsRef.current.get(resourceId);
      if (!findings) continue;
      findings.forEach((id) => {
        newFindingIds.add(id);
        contextualFitNodeIds.add(id);
      });
    }
    if (newFindingIds.size === 0) return;

    // Findings transition from hidden to visible on expand, and React Flow
    // measures them asynchronously. Poll before fitting so fitView uses real
    // dimensions instead of the zero-size hidden-node measurements.
    return scheduleMeasuredFit(
      () => {
        const targets = getNodes().filter((n) => newFindingIds.has(n.id));
        return (
          targets.length === newFindingIds.size &&
          targets.every((n) => (n.measured?.width ?? 0) > 0)
        );
      },
      () => {
        const contextualNodes = getNodes().filter((n) =>
          contextualFitNodeIds.has(n.id),
        );
        fitView({ ...AUTO_FIT_OPTIONS, nodes: contextualNodes });
      },
    );
  }, [expanded, fitView, getNodes]);

  const nodes = effectiveData.nodes ?? [];
  const edges = effectiveData.edges ?? [];

  // Pre-compute which resources have findings connected (O(n+e))
  const findingNodeIds = new Set<string>();
  const resourceToFindings = new Map<string, Set<string>>();
  const findingToResources = new Map<string, Set<string>>();

  nodes.forEach((n) => {
    if (isFindingNode(n.labels)) findingNodeIds.add(n.id);
  });

  const resourcesWithFindings = new Set<string>();
  edges.forEach((edge) => {
    const sourceIsFinding = findingNodeIds.has(edge.source);
    const targetIsFinding = findingNodeIds.has(edge.target);

    if (sourceIsFinding) {
      resourcesWithFindings.add(edge.target);
      // Map resource → its findings
      const findings = resourceToFindings.get(edge.target) ?? new Set();
      findings.add(edge.source);
      resourceToFindings.set(edge.target, findings);
      // Map finding → its resources
      const resources = findingToResources.get(edge.source) ?? new Set();
      resources.add(edge.target);
      findingToResources.set(edge.source, resources);
    }
    if (targetIsFinding) {
      resourcesWithFindings.add(edge.source);
      const findings = resourceToFindings.get(edge.source) ?? new Set();
      findings.add(edge.target);
      resourceToFindings.set(edge.source, findings);
      const resources = findingToResources.get(edge.target) ?? new Set();
      resources.add(edge.source);
      findingToResources.set(edge.target, resources);
    }
  });

  // Refresh the expand-fit effect's lookup with the latest mapping.
  resourceToFindingsRef.current = resourceToFindings;

  // Tier 1: compute which finding nodes are hidden (not expanded)
  const hiddenFindingIds = resolveHiddenFindingIds({
    expandedResources: expanded,
    findingNodeIds,
    findingToResources,
    isFilteredView,
  });

  const layoutNodeIds = new Set(
    nodes
      .filter((node) => !hiddenFindingIds.has(node.id))
      .map((node) => node.id),
  );
  const layoutNodes = nodes.filter((node) => layoutNodeIds.has(node.id));
  const layoutEdges = edges.filter(
    (edge) => layoutNodeIds.has(edge.source) && layoutNodeIds.has(edge.target),
  );

  // Derive RF nodes and edges from visible data only. Hidden finding nodes are
  // excluded before Dagre runs so the initial tier-1 graph does not reserve
  // empty space for findings that the user has not expanded yet.
  const { rfNodes, rfEdges } = layoutWithDagre(layoutNodes, layoutEdges);

  // Path highlight: compute highlighted edge IDs
  const activeHighlightNodeId = hoveredNodeId ?? selectedNodeId;
  const highlightedEdgeIds = activeHighlightNodeId
    ? getPathEdges(
        activeHighlightNodeId,
        rfEdges.map((e) => ({ sourceId: e.source, targetId: e.target })),
      )
    : new Set<string>();

  // Enrich nodes with selection, hasFindings, and hidden state
  const enrichedNodes = rfNodes.map((node) => ({
    ...node,
    selected: node.id === selectedNodeId,
    hidden: hiddenFindingIds.has(node.id),
    className: cn(
      node.className,
      isFindingNode(node.data.graphNode.labels) ||
        resourcesWithFindings.has(node.id)
        ? "cursor-pointer"
        : "cursor-default",
    ),
    data: {
      ...node.data,
      hasFindings: resourcesWithFindings.has(node.id),
    },
  }));

  // Enrich edges with hidden state (hide edges to hidden findings) and highlight
  const enrichedEdges = rfEdges.map((edge) => {
    const sourceHidden = hiddenFindingIds.has(edge.source);
    const targetHidden = hiddenFindingIds.has(edge.target);
    const pathKey =
      typeof edge.data === "object" && edge.data && "pathKey" in edge.data
        ? String((edge.data as { pathKey?: string }).pathKey)
        : `${edge.source}-${edge.target}`;
    const isHighlighted =
      highlightedEdgeIds.has(edge.id) || highlightedEdgeIds.has(pathKey);

    return {
      ...edge,
      hidden: sourceHidden || targetHidden,
      className: cn(edge.className, isHighlighted && "highlighted"),
    };
  });

  useImperativeHandle(ref, () => ({
    zoomIn: () => zoomIn({ duration: 300 }),
    zoomOut: () => zoomOut({ duration: 300 }),
    resetZoom: () => fitView({ duration: 300 }),
    getZoomLevel: () => getZoom(),
    getContainerElement: () => containerRef.current,
    getNodesBounds: () => {
      const rfNodes = getNodes();
      if (rfNodes.length === 0) return null;
      return getNodesBounds(rfNodes);
    },
  }));

  const handleNodeClick = (_event: MouseEvent, node: Node) => {
    const graphNode = (node.data as { graphNode: GraphNode }).graphNode;

    // Always fire parent callback (handles selection + Tier 2 filtered view)
    onNodeClick?.(graphNode);
  };

  // Path highlight on hover
  const handleNodeMouseEnter = (_event: MouseEvent, node: Node) => {
    setHoveredNodeId(node.id);
  };

  const handleNodeMouseLeave = () => {
    setHoveredNodeId(null);
  };

  return (
    <div ref={containerRef} className="h-full w-full">
      <ReactFlow
        nodes={enrichedNodes}
        edges={enrichedEdges}
        nodeTypes={NODE_TYPES}
        onNodeClick={handleNodeClick}
        onNodeMouseEnter={handleNodeMouseEnter}
        onNodeMouseLeave={handleNodeMouseLeave}
        fitView
        fitViewOptions={{ ...AUTO_FIT_OPTIONS, includeHiddenNodes: true }}
        // Supported React Flow behavior: wheel over the graph zooms the
        // viewport. The surrounding UX avoids using node details as inline
        // content, so this no longer fights a below-graph details section.
        zoomOnScroll={true}
        zoomOnPinch={true}
        zoomOnDoubleClick={false}
        panOnScroll={false}
        preventScrolling={true}
        minZoom={0.1}
        maxZoom={10}
        proOptions={{ hideAttribution: true }}
      >
        <Background />
        <MiniMap
          pannable
          zoomable
          ariaLabel="Graph minimap"
          bgColor={minimapColors.bg}
          maskColor={minimapColors.mask}
          maskStrokeColor={minimapColors.maskStroke}
          maskStrokeWidth={MINIMAP_VIEWPORT_STROKE_WIDTH}
          nodeColor={(node) => {
            const graphNode = (node.data as { graphNode?: GraphNode })
              .graphNode;
            if (!graphNode) return MINIMAP_COLORS.light.maskStroke;
            return getNodeColor(graphNode.labels, graphNode.properties);
          }}
          nodeStrokeColor={(node) => {
            const graphNode = (node.data as { graphNode?: GraphNode })
              .graphNode;
            if (!graphNode) return "transparent";
            return getNodeBorderColor(graphNode.labels, graphNode.properties);
          }}
        />
      </ReactFlow>
    </div>
  );
};

// --- Outer component: renders ReactFlowProvider ---

export const AttackPathGraph = ({
  data,
  selectedNodeId,
  isFilteredView,
  initialNodeId,
  expandedResources,
  onNodeClick,
  onInitialFilter,
  ref,
  className,
}: AttackPathGraphProps) => {
  return (
    <div
      role="img"
      aria-label="Attack path graph"
      className={cn(
        "dark:bg-bg-neutral-secondary bg-bg-neutral-secondary h-full w-full rounded-lg",
        className,
      )}
    >
      <style>{GRAPH_STYLES}</style>
      <GraphDefs />
      <ReactFlowProvider>
        <GraphCanvas
          ref={ref}
          data={data}
          selectedNodeId={selectedNodeId}
          isFilteredView={isFilteredView}
          initialNodeId={initialNodeId}
          expandedResources={expandedResources}
          onNodeClick={onNodeClick}
          onInitialFilter={onInitialFilter}
        />
      </ReactFlowProvider>
    </div>
  );
};
