import { css } from '@emotion/css';
import cx from 'classnames';
import { memo, type MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import useMeasure from 'react-use/lib/useMeasure';

import { type DataFrame, type GrafanaTheme2, type LinkModel } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { Icon, RadioButtonGroup, Spinner, useStyles2 } from '@grafana/ui';

import { Edge } from './Edge';
import { EdgeLabel } from './EdgeLabel';
import { Legend } from './Legend';
import { Marker } from './Marker';
import { Node } from './Node';
import { ViewControls } from './ViewControls';
import { type Config, defaultConfig, useLayout, type LayoutCache } from './layout';
import { LayoutAlgorithm, type ZoomMode } from './panelcfg.gen';
import { type EdgeDatumLayout, type NodeDatum, type NodesMarker } from './types';
import { useCategorizeFrames } from './useCategorizeFrames';
import { useContextMenu } from './useContextMenu';
import { useFocusPositionOnLayout } from './useFocusPositionOnLayout';
import { useHighlight } from './useHighlight';
import { usePanning } from './usePanning';
import { useZoom } from './useZoom';
import { processNodes, type Bounds, findConnectedNodesForEdge, findConnectedNodesForNode } from './utils';

const getStyles = (theme: GrafanaTheme2) => ({
  wrapper: css({
    label: 'wrapper',
    height: '100%',
    width: '100%',
    overflow: 'hidden',
    position: 'relative',
  }),

  svg: css({
    label: 'svg',
    height: '100%',
    width: '100%',
    overflow: 'visible',
    fontSize: '10px',
    cursor: 'move',
  }),

  svgPanning: css({
    label: 'svgPanning',
    userSelect: 'none',
  }),

  noDataMsg: css({
    height: '100%',
    width: '100%',
    display: 'grid',
    placeItems: 'center',
    fontSize: theme.typography.h4.fontSize,
    color: theme.colors.text.secondary,
  }),

  mainGroup: css({
    label: 'mainGroup',
    willChange: 'transform',
  }),

  viewControls: css({
    label: 'viewControls',
    position: 'absolute',
    left: '2px',
    bottom: '3px',
    right: 0,
    display: 'flex',
    alignItems: 'flex-end',
    justifyContent: 'space-between',
    pointerEvents: 'none',
  }),
  layoutAlgorithm: css({
    label: 'layoutAlgorithm',
    pointerEvents: 'all',
    position: 'absolute',
    top: '8px',
    right: '8px',
    zIndex: 1,
  }),
  legend: css({
    label: 'legend',
    background: theme.colors.background.secondary,
    boxShadow: theme.shadows.z1,
    paddingBottom: '5px',
    marginRight: '10px',
  }),
  viewControlsWrapper: css({
    marginLeft: 'auto',
  }),
  alert: css({
    label: 'alert',
    padding: '5px 8px',
    fontSize: '10px',
    textShadow: '0 1px 0 rgba(0, 0, 0, 0.2)',
    borderRadius: theme.shape.radius.default,
    alignItems: 'center',
    position: 'absolute',
    right: 0,
    background: theme.colors.warning.main,
    color: theme.colors.warning.contrastText,
  }),
  loadingWrapper: css({
    label: 'loadingWrapper',
    height: '100%',
    display: 'flex',
    alignItems: 'center',
    justifyContent: 'center',
  }),
});

// Limits the number of visible nodes, mainly for performance reasons. Nodes above the limit are accessible by expanding
// parts of the graph. The specific number is arbitrary but should be a number of nodes where panning, zooming and other
// interactions will be without any lag for most users.
const defaultNodeCountLimit = 200;

export const layeredLayoutThreshold = 500;

interface Props {
  dataFrames: DataFrame[];
  getLinks: (dataFrame: DataFrame, rowIndex: number) => LinkModel[];
  nodeLimit?: number;
  panelId?: string;
  zoomMode?: ZoomMode;
  layoutAlgorithm?: LayoutAlgorithm;
}
export function NodeGraph({ getLinks, dataFrames, nodeLimit, panelId, zoomMode, layoutAlgorithm }: Props) {
  const nodeCountLimit = nodeLimit || defaultNodeCountLimit;
  const { edges: edgesDataFrames, nodes: nodesDataFrames } = useCategorizeFrames(dataFrames);

  const [measureRef, { width, height }] = useMeasure();
  const [config, setConfig] = useState<Config>(defaultConfig);

  // Layout cache to avoid recalculating layouts
  const layoutCacheRef = useRef<LayoutCache>({});

  // Update the config when layoutAlgorithm changes via the panel options
  useEffect(() => {
    if (layoutAlgorithm) {
      setConfig((prevConfig) => {
        return {
          ...prevConfig,
          gridLayout: layoutAlgorithm === LayoutAlgorithm.Grid,
          layoutAlgorithm,
        };
      });
    }
  }, [layoutAlgorithm]);

  const firstNodesDataFrame = nodesDataFrames[0];
  const firstEdgesDataFrame = edgesDataFrames[0];

  // Ensure we use unique IDs for the marker tip elements, since IDs are global
  // in the entire HTML document. This prevents hidden tips when an earlier
  // occurence is hidden (editor is open in front of an existing node graph
  // panel) or when the earlier tips have different properties (color, size, or
  // shape for example).
  const svgIdNamespace = panelId || 'nodegraphpanel';

  // TODO we should be able to allow multiple dataframes for both edges and nodes, could be issue with node ids which in
  //  that case should be unique or figure a way to link edges and nodes dataframes together.
  const processed = useMemo(
    () => processNodes(firstNodesDataFrame, firstEdgesDataFrame),
    [firstEdgesDataFrame, firstNodesDataFrame]
  );

  // We need hover state here because for nodes we also highlight edges and for edges have labels separate to make
  // sure they are visible on top of everything else
  const { nodeHover, setNodeHover, clearNodeHover, edgeHover, setEdgeHover, clearEdgeHover } = useHover();
  const [hoveringIds, setHoveringIds] = useState<string[]>([]);
  useEffect(() => {
    let linked: string[] = [];
    if (nodeHover) {
      linked = findConnectedNodesForNode(processed.nodes, processed.edges, nodeHover);
    } else if (edgeHover) {
      linked = findConnectedNodesForEdge(processed.nodes, processed.edges, edgeHover);
    }
    setHoveringIds(linked);
  }, [nodeHover, edgeHover, processed]);

  // This is used for navigation from grid to graph view. This node will be centered and briefly highlighted.
  const [focusedNodeId, setFocusedNodeId] = useState<string>();
  const setFocused = useCallback((e: MouseEvent, m: NodesMarker) => setFocusedNodeId(m.node.id), [setFocusedNodeId]);

  // May seem weird that we do layout first and then limit the nodes shown but the problem is we want to keep the node
  // position stable which means we need the full layout first and then just visually hide the nodes. As hiding/showing
  // nodes should not have effect on layout it should not be recalculated.
  const { nodes, edges, markers, bounds, hiddenNodesCount, loading } = useLayout(
    processed.nodes,
    processed.edges,
    config,
    nodeCountLimit,
    width,
    focusedNodeId,
    processed.hasFixedPositions,
    layoutCacheRef.current
  );

  // If we move from grid to graph layout, and we have focused node lets get its position to center there. We want to
  // do it specifically only in that case.
  const focusPosition = useFocusPositionOnLayout(config, nodes, focusedNodeId);
  const { panRef, zoomRef, onStepUp, onStepDown, isPanning, position, scale, isMaxZoom, isMinZoom } = usePanAndZoom(
    bounds,
    focusPosition,
    zoomMode
  );

  const { onEdgeOpen, onNodeOpen, MenuComponent } = useContextMenu(
    getLinks,
    firstNodesDataFrame,
    firstEdgesDataFrame,
    config,
    setConfig,
    setFocusedNodeId
  );
  const styles = useStyles2(getStyles);

  // This cannot be inline func, or it will create infinite render cycle.
  const topLevelRef = useCallback(
    (r: HTMLDivElement) => {
      measureRef(r);
      zoomRef.current = r;
    },
    [measureRef, zoomRef]
  );

  const highlightId = useHighlight(focusedNodeId);

  const handleLayoutChange = (cfg: Config) => {
    if (cfg.layoutAlgorithm !== config.layoutAlgorithm) {
      setFocusedNodeId(undefined);
    }
    setConfig(cfg);
  };

  // Clear the layout cache when data changes
  useEffect(() => {
    layoutCacheRef.current = {};
  }, [firstNodesDataFrame, firstEdgesDataFrame]);

  return (
    <div ref={topLevelRef} className={styles.wrapper}>
      {loading ? (
        <div className={styles.loadingWrapper}>
          <Trans i18nKey="nodeGraph.node-graph.computing-layout">Computing layout</Trans>&nbsp;
          <Spinner />
        </div>
      ) : null}

      {!panelId && (
        <div className={styles.layoutAlgorithm}>
          <RadioButtonGroup
            size="sm"
            options={[
              { label: 'Layered', value: LayoutAlgorithm.Layered },
              { label: 'Force', value: LayoutAlgorithm.Force },
              { label: 'Grid', value: LayoutAlgorithm.Grid },
            ]}
            value={config.gridLayout ? LayoutAlgorithm.Grid : config.layoutAlgorithm}
            onChange={(value) => {
              handleLayoutChange({
                ...config,
                gridLayout: value === LayoutAlgorithm.Grid,
                layoutAlgorithm: value,
              });
            }}
          />
        </div>
      )}

      {dataFrames.length && processed.nodes.length ? (
        <svg
          ref={panRef}
          viewBox={`${-(width / 2)} ${-(height / 2)} ${width} ${height}`}
          className={cx(styles.svg, isPanning && styles.svgPanning)}
        >
          <g
            className={styles.mainGroup}
            style={{ transform: `scale(${scale}) translate(${Math.floor(position.x)}px, ${Math.floor(position.y)}px)` }}
          >
            {!config.gridLayout && (
              <Edges
                edges={edges}
                nodeHoveringId={nodeHover}
                edgeHoveringId={edgeHover}
                onClick={onEdgeOpen}
                onMouseEnter={setEdgeHover}
                onMouseLeave={clearEdgeHover}
                svgIdNamespace={svgIdNamespace}
                processedNodesLength={processed.nodes.length}
                processedEdgesLength={processed.edges.length}
              />
            )}
            <Nodes
              nodes={nodes}
              onMouseEnter={setNodeHover}
              onMouseLeave={clearNodeHover}
              onClick={onNodeOpen}
              hoveringIds={hoveringIds || [highlightId]}
            />

            <Markers markers={markers || []} onClick={setFocused} />
            {/*We split the labels from edges so that they are shown on top of everything else*/}
            {!config.gridLayout && <EdgeLabels edges={edges} nodeHoveringId={nodeHover} edgeHoveringId={edgeHover} />}
          </g>
        </svg>
      ) : (
        <div className={styles.noDataMsg}>
          <Trans i18nKey="nodeGraph.node-graph.no-data">No data</Trans>
        </div>
      )}

      <div className={styles.viewControls}>
        {nodes.length ? (
          <div className={styles.legend}>
            <Legend
              sortable={config.gridLayout}
              nodes={nodes}
              sort={config.sort}
              onSort={(sort) => {
                setConfig({
                  ...config,
                  sort: sort,
                });
              }}
            />
          </div>
        ) : null}

        <div className={styles.viewControlsWrapper}>
          <ViewControls<Config>
            config={config}
            onConfigChange={handleLayoutChange}
            onMinus={onStepDown}
            onPlus={onStepUp}
            scale={scale}
            disableZoomIn={isMaxZoom}
            disableZoomOut={isMinZoom}
          />
        </div>
      </div>

      {hiddenNodesCount > 0 && (
        <div
          className={styles.alert}
          style={{ top: panelId ? '0px' : '40px' }} // panelId is undefined in Explore
          aria-label={t('nodeGraph.node-graph.aria-label-nodes-hidden-warning', 'Nodes hidden warning')}
        >
          <Trans i18nKey="nodeGraph.node-graph.hidden-nodes" count={hiddenNodesCount}>
            <Icon size="sm" name={'info-circle'} /> {'{{count}}'} nodes are hidden for performance reasons.
          </Trans>
        </div>
      )}

      {config.layoutAlgorithm === LayoutAlgorithm.Layered && processed.nodes.length > layeredLayoutThreshold && (
        <div
          className={styles.alert}
          style={{ top: panelId ? '30px' : '70px' }}
          aria-label={t(
            'nodeGraph.node-graph.aria-label-layered-layout-performance-warning',
            'Layered layout performance warning'
          )}
        >
          <Trans i18nKey="nodeGraph.node-graph.processed-nodes" count={processed.nodes.length}>
            <Icon size="sm" name={'exclamation-triangle'} /> Layered layout may be slow with {'{{count}}'} nodes.
          </Trans>
        </div>
      )}

      {MenuComponent}
    </div>
  );
}

// Active -> emphasized, inactive -> de-emphasized, and default -> normal styling
export type HoverState = 'active' | 'inactive' | 'default';

// These components are here as a perf optimisation to prevent going through all nodes and edges on every pan/zoom.
interface NodesProps {
  nodes: NodeDatum[];
  onMouseEnter: (id: string) => void;
  onMouseLeave: (id: string) => void;
  onClick: (event: MouseEvent<SVGElement>, node: NodeDatum) => void;
  hoveringIds?: string[];
}
const Nodes = memo(function Nodes(props: NodesProps) {
  return (
    <>
      {props.nodes.map((n) => (
        <Node
          key={n.id}
          node={n}
          onMouseEnter={props.onMouseEnter}
          onMouseLeave={props.onMouseLeave}
          onClick={props.onClick}
          hovering={
            !props.hoveringIds || props.hoveringIds.length === 0
              ? 'default'
              : props.hoveringIds?.includes(n.id)
                ? 'active'
                : 'inactive'
          }
        />
      ))}
    </>
  );
});

interface MarkersProps {
  markers: NodesMarker[];
  onClick: (event: MouseEvent<SVGElement>, marker: NodesMarker) => void;
}
const Markers = memo(function Nodes(props: MarkersProps) {
  return (
    <>
      {props.markers.map((m) => (
        <Marker key={'marker-' + m.node.id} marker={m} onClick={props.onClick} />
      ))}
    </>
  );
});

interface EdgesProps {
  edges: EdgeDatumLayout[];
  nodeHoveringId?: string;
  edgeHoveringId?: string;
  svgIdNamespace: string;
  onClick: (event: MouseEvent<SVGElement>, link: EdgeDatumLayout) => void;
  onMouseEnter: (id: string) => void;
  onMouseLeave: (id: string) => void;
  processedNodesLength: number;
  processedEdgesLength: number;
}
const Edges = memo(function Edges(props: EdgesProps) {
  return (
    <>
      {props.edges.map((e, index) => {
        return (
          <Edge
            key={`${e.id}-${e.source.y ?? ''}-${props.processedNodesLength}-${props.processedEdgesLength}-${index}`}
            edge={e}
            hovering={
              (e.source as NodeDatum).id === props.nodeHoveringId ||
              (e.target as NodeDatum).id === props.nodeHoveringId ||
              props.edgeHoveringId === e.id
            }
            onClick={props.onClick}
            onMouseEnter={props.onMouseEnter}
            onMouseLeave={props.onMouseLeave}
            svgIdNamespace={props.svgIdNamespace}
          />
        );
      })}
    </>
  );
});

interface EdgeLabelsProps {
  edges: EdgeDatumLayout[];
  nodeHoveringId?: string;
  edgeHoveringId?: string;
}
const EdgeLabels = memo(function EdgeLabels(props: EdgeLabelsProps) {
  return (
    <>
      {props.edges.map((e) => {
        // We show the edge label in case user hovers over the edge directly or if they hover over node edge is
        // connected to.
        const shouldShow =
          (e.source as NodeDatum).id === props.nodeHoveringId ||
          (e.target as NodeDatum).id === props.nodeHoveringId ||
          props.edgeHoveringId === e.id;

        const hasStats = e.mainStat || e.secondaryStat;
        return shouldShow && hasStats && <EdgeLabel key={e.id} edge={e} />;
      })}
    </>
  );
});

function usePanAndZoom(bounds: Bounds, focus?: { x: number; y: number }, zoomMode?: ZoomMode) {
  const { scale, onStepDown, onStepUp, ref, isMax, isMin } = useZoom({ zoomMode });
  const { state: panningState, ref: panRef } = usePanning<SVGSVGElement>({
    scale,
    bounds,
    focus,
  });
  const { position, isPanning } = panningState;
  return { zoomRef: ref, panRef, position, isPanning, scale, onStepDown, onStepUp, isMaxZoom: isMax, isMinZoom: isMin };
}

function useHover() {
  const [nodeHover, setNodeHover] = useState<string | undefined>(undefined);
  const clearNodeHover = useCallback(() => setNodeHover(undefined), [setNodeHover]);
  const [edgeHover, setEdgeHover] = useState<string | undefined>(undefined);
  const clearEdgeHover = useCallback(() => setEdgeHover(undefined), [setEdgeHover]);

  return { nodeHover, setNodeHover, clearNodeHover, edgeHover, setEdgeHover, clearEdgeHover };
}
