/**
 * Tests for layout strategies - positioning algorithms for workflow nodes
 */

import {
  processFlowLayout,
  convertPositionsToCoordinates,
  groupNodesByRow,
  optimizeRowAlignment,
} from '../layoutStrategies';
import { Node, Edge } from '../../store/store';
import { GraphData, LAYOUT_CONFIG } from '../layoutHelpers';

// Helper to create a mock node
const createNode = (id: string, x = 0, y = 0, type = 'element', locked = false): Node => ({
  id,
  type,
  position: { x, y },
  data: { label: `Node ${id}`, locked, nodeType: type },
});

// Helper to create a mock edge
const createEdge = (id: string, source: string, target: string): Edge => ({
  id,
  source,
  target,
});

// Helper to create GraphData from nodes and edges
const createGraphData = (nodes: Node[], edges: Edge[]): GraphData => {
  const adjacencyMap = new Map<string, string[]>();
  const inDegree = new Map<string, number>();
  const outDegree = new Map<string, number>();

  nodes.forEach(node => {
    adjacencyMap.set(node.id, []);
    inDegree.set(node.id, 0);
    outDegree.set(node.id, 0);
  });

  edges.forEach(edge => {
    const children = adjacencyMap.get(edge.source) || [];
    children.push(edge.target);
    adjacencyMap.set(edge.source, children);

    inDegree.set(edge.target, (inDegree.get(edge.target) || 0) + 1);
    outDegree.set(edge.source, (outDegree.get(edge.source) || 0) + 1);
  });

  return { adjacencyMap, inDegree, outDegree };
};

describe('processFlowLayout', () => {
  it('should position a single start node', () => {
    const startNode = createNode('start_1', 0, 0, 'start');
    const nodes = [startNode];
    const graphData = createGraphData(nodes, []);

    const positions = processFlowLayout([startNode], nodes, graphData);

    expect(positions.has('start_1')).toBe(true);
    expect(positions.get('start_1')).toEqual({ row: 0, column: 0 });
  });

  it('should position nodes in a linear flow', () => {
    const nodes = [
      createNode('event_1', 0, 0, 'start'),
      createNode('action_1', 0, 0, 'element'),
      createNode('action_2', 0, 0, 'element'),
    ];
    const edges = [
      createEdge('e1', 'event_1', 'action_1'),
      createEdge('e2', 'action_1', 'action_2'),
    ];
    const graphData = createGraphData(nodes, edges);

    const positions = processFlowLayout([nodes[0]], nodes, graphData);

    expect(positions.get('event_1')?.row).toBe(0);
    expect(positions.get('action_1')?.row).toBe(1);
    expect(positions.get('action_2')?.row).toBe(2);
  });

  it('should handle gateway nodes with multiple children', () => {
    const nodes = [
      createNode('event_1', 0, 0, 'start'),
      createNode('gateway_1', 0, 0, 'gateway'),
      createNode('action_1', 0, 0, 'element'),
      createNode('action_2', 0, 0, 'element'),
    ];
    const edges = [
      createEdge('e1', 'event_1', 'gateway_1'),
      createEdge('e2', 'gateway_1', 'action_1'),
      createEdge('e3', 'gateway_1', 'action_2'),
    ];
    const graphData = createGraphData(nodes, edges);

    const positions = processFlowLayout([nodes[0]], nodes, graphData);

    expect(positions.get('event_1')?.row).toBe(0);
    expect(positions.get('gateway_1')?.row).toBe(1);
    // Gateway children should be at row + GATEWAY_VERTICAL_OFFSET
    const gatewayOffset = LAYOUT_CONFIG.GATEWAY_VERTICAL_OFFSET;
    expect(positions.get('action_1')?.row).toBe(1 + gatewayOffset);
    expect(positions.get('action_2')?.row).toBe(1 + gatewayOffset);
  });

  it('should handle multiple event flows', () => {
    const nodes = [
      createNode('event_1', 0, 0, 'start'),
      createNode('event_2', 0, 0, 'start'),
      createNode('action_1', 0, 0, 'element'),
      createNode('action_2', 0, 0, 'element'),
    ];
    const edges = [
      createEdge('e1', 'event_1', 'action_1'),
      createEdge('e2', 'event_2', 'action_2'),
    ];
    const graphData = createGraphData(nodes, edges);

    const positions = processFlowLayout([nodes[0], nodes[1]], nodes, graphData);

    // Both events should be at row 0 but different columns
    expect(positions.get('event_1')?.row).toBe(0);
    expect(positions.get('event_2')?.row).toBe(0);
    expect(positions.get('event_1')?.column).not.toBe(positions.get('event_2')?.column);
  });

  it('should handle already positioned nodes (converging flows)', () => {
    const nodes = [
      createNode('event_1', 0, 0, 'start'),
      createNode('event_2', 0, 0, 'start'),
      createNode('action_1', 0, 0, 'element'),
    ];
    // Both events connect to the same action
    const edges = [
      createEdge('e1', 'event_1', 'action_1'),
      createEdge('e2', 'event_2', 'action_1'),
    ];
    const graphData = createGraphData(nodes, edges);

    const positions = processFlowLayout([nodes[0], nodes[1]], nodes, graphData);

    expect(positions.has('action_1')).toBe(true);
    // action_1 should be positioned at the deeper row
    expect(positions.get('action_1')?.row).toBeGreaterThanOrEqual(1);
  });

  it('should handle unpositioned orphan nodes', () => {
    const nodes = [
      createNode('event_1', 0, 0, 'start'),
      createNode('action_1', 0, 0, 'element'),
      createNode('orphan_1', 0, 0, 'element'), // Not connected
    ];
    const edges = [
      createEdge('e1', 'event_1', 'action_1'),
    ];
    const graphData = createGraphData(nodes, edges);

    const positions = processFlowLayout([nodes[0]], nodes, graphData);

    // Orphan should still be positioned
    expect(positions.has('orphan_1')).toBe(true);
    // Orphan should be at maxRow + 1
    const maxRowFromFlow = Math.max(
      positions.get('event_1')?.row || 0,
      positions.get('action_1')?.row || 0
    );
    expect(positions.get('orphan_1')?.row).toBe(maxRowFromFlow + 1);
  });

  it('should skip already positioned start nodes', () => {
    const nodes = [
      createNode('event_1', 0, 0, 'start'),
      createNode('action_1', 0, 0, 'element'),
    ];
    const edges = [
      createEdge('e1', 'event_1', 'action_1'),
    ];
    const graphData = createGraphData(nodes, edges);

    // Process with same start node twice
    const positions = processFlowLayout([nodes[0], nodes[0]], nodes, graphData);

    expect(positions.get('event_1')).toEqual({ row: 0, column: 0 });
  });

  it('should handle nodes with nodeType data property for gateway detection', () => {
    const gatewayNode = createNode('gateway_1', 0, 0, 'element');
    gatewayNode.data.nodeType = 'gateway';

    const nodes = [
      createNode('event_1', 0, 0, 'start'),
      gatewayNode,
      createNode('action_1', 0, 0, 'element'),
    ];
    const edges = [
      createEdge('e1', 'event_1', 'gateway_1'),
      createEdge('e2', 'gateway_1', 'action_1'),
    ];
    const graphData = createGraphData(nodes, edges);

    const positions = processFlowLayout([nodes[0]], nodes, graphData);

    // Gateway child should use gateway offset
    const gatewayRow = positions.get('gateway_1')?.row || 0;
    expect(positions.get('action_1')?.row).toBe(gatewayRow + LAYOUT_CONFIG.GATEWAY_VERTICAL_OFFSET);
  });

  it('should handle normal node with multiple children', () => {
    const nodes = [
      createNode('event_1', 0, 0, 'start'),
      createNode('action_1', 0, 0, 'element'),
      createNode('action_2', 0, 0, 'element'),
      createNode('action_3', 0, 0, 'element'),
    ];
    const edges = [
      createEdge('e1', 'event_1', 'action_1'),
      createEdge('e2', 'event_1', 'action_2'),
      createEdge('e3', 'event_1', 'action_3'),
    ];
    const graphData = createGraphData(nodes, edges);

    const positions = processFlowLayout([nodes[0]], nodes, graphData);

    // All actions should be at row 1 (not using gateway offset)
    expect(positions.get('action_1')?.row).toBe(1);
    expect(positions.get('action_2')?.row).toBe(1);
    expect(positions.get('action_3')?.row).toBe(1);
  });
});

describe('convertPositionsToCoordinates', () => {
  it('should convert row/column positions to x/y coordinates', () => {
    const nodes = [
      createNode('node_1', 0, 0),
      createNode('node_2', 0, 0),
    ];
    const positions = new Map([
      ['node_1', { row: 0, column: 0 }],
      ['node_2', { row: 1, column: 1 }],
    ]);
    const startPos = { x: 100, y: 100 };

    const result = convertPositionsToCoordinates(nodes, positions, startPos, []);

    expect(result[0].position.x).toBe(startPos.x);
    expect(result[0].position.y).toBe(startPos.y);
    expect(result[1].position.x).toBe(startPos.x + LAYOUT_CONFIG.HORIZONTAL_SPACING);
    expect(result[1].position.y).toBe(startPos.y + LAYOUT_CONFIG.VERTICAL_SPACING);
  });

  it('should skip locked nodes', () => {
    const lockedNode = createNode('locked_1', 500, 500, 'element', true);
    const unlockedNode = createNode('unlocked_1', 0, 0);
    const nodes = [lockedNode, unlockedNode];
    const positions = new Map([
      ['locked_1', { row: 0, column: 0 }],
      ['unlocked_1', { row: 1, column: 0 }],
    ]);
    const startPos = { x: 100, y: 100 };

    const result = convertPositionsToCoordinates(nodes, positions, startPos, []);

    // Locked node should keep its original position
    expect(result[0].position).toEqual({ x: 500, y: 500 });
    // Unlocked node should be positioned
    expect(result[1].position.y).toBe(startPos.y + LAYOUT_CONFIG.VERTICAL_SPACING);
  });

  it('should avoid collisions with locked node bounds', () => {
    const nodes = [createNode('node_1', 0, 0)];
    const positions = new Map([['node_1', { row: 0, column: 0 }]]);
    const startPos = { x: 100, y: 100 };
    const lockedBounds = [{
      id: 'locked_1',
      x1: 90,
      y1: 90,
      x2: 110,
      y2: 110,
    }];

    const result = convertPositionsToCoordinates(nodes, positions, startPos, lockedBounds);

    // Node should be shifted to avoid collision
    expect(
      result[0].position.x !== startPos.x || result[0].position.y !== startPos.y
    ).toBe(true);
  });

  it('should handle nodes without positions in the map', () => {
    const nodes = [
      createNode('node_1', 50, 50),
      createNode('node_2', 100, 100),
    ];
    const positions = new Map([
      ['node_1', { row: 0, column: 0 }],
      // node_2 has no position in map
    ]);
    const startPos = { x: 200, y: 200 };

    const result = convertPositionsToCoordinates(nodes, positions, startPos, []);

    // node_2 should keep its original position since it's not in the map
    expect(result[1].position).toEqual({ x: 100, y: 100 });
  });
});

describe('groupNodesByRow', () => {
  it('should group nodes by their row position', () => {
    const nodes = [
      createNode('node_1', 0, 0),
      createNode('node_2', 0, 0),
      createNode('node_3', 0, 0),
    ];
    const positions = new Map([
      ['node_1', { row: 0, column: 0 }],
      ['node_2', { row: 0, column: 1 }],
      ['node_3', { row: 1, column: 0 }],
    ]);

    const result = groupNodesByRow(positions, nodes);

    expect(result.get(0)).toEqual(['node_1', 'node_2']);
    expect(result.get(1)).toEqual(['node_3']);
  });

  it('should exclude locked nodes', () => {
    const nodes = [
      createNode('node_1', 0, 0, 'element', false),
      createNode('node_2', 0, 0, 'element', true), // locked
    ];
    const positions = new Map([
      ['node_1', { row: 0, column: 0 }],
      ['node_2', { row: 0, column: 1 }],
    ]);

    const result = groupNodesByRow(positions, nodes);

    expect(result.get(0)).toEqual(['node_1']);
    expect(result.get(0)).not.toContain('node_2');
  });

  it('should handle nodes not found in nodes array', () => {
    const nodes = [createNode('node_1', 0, 0)];
    const positions = new Map([
      ['node_1', { row: 0, column: 0 }],
      ['missing_node', { row: 1, column: 0 }],
    ]);

    const result = groupNodesByRow(positions, nodes);

    expect(result.get(0)).toEqual(['node_1']);
    expect(result.has(1)).toBe(false);
  });
});

describe('optimizeRowAlignment', () => {
  it('should skip rows with single node', () => {
    const nodes = [createNode('node_1', 100, 100)];
    const edges: Edge[] = [];
    const rowNodes = new Map([[0, ['node_1']]]);
    const originalX = nodes[0].position.x;

    optimizeRowAlignment(nodes, edges, rowNodes, []);

    // Single node should not be moved
    expect(nodes[0].position.x).toBe(originalX);
  });

  it('should adjust nodes toward connected parents', () => {
    const nodes = [
      createNode('parent_1', 200, 0),
      createNode('child_1', 100, 100),
      createNode('child_2', 300, 100),
    ];
    const edges = [
      createEdge('e1', 'parent_1', 'child_1'),
      createEdge('e2', 'parent_1', 'child_2'),
    ];
    const rowNodes = new Map([[1, ['child_1', 'child_2']]]);

    optimizeRowAlignment(nodes, edges, rowNodes, []);

    // Children should be adjusted (exact values depend on weight)
    expect(typeof nodes[1].position.x).toBe('number');
    expect(typeof nodes[2].position.x).toBe('number');
  });

  it('should not move nodes into locked node bounds', () => {
    const nodes = [
      createNode('node_1', 100, 100),
      createNode('node_2', 200, 100),
    ];
    const edges = [
      createEdge('e1', 'node_1', 'node_2'),
    ];
    const rowNodes = new Map([[0, ['node_1', 'node_2']]]);
    const lockedBounds = [{
      id: 'locked_1',
      x1: 140,
      y1: 90,
      x2: 160,
      y2: 110,
    }];

    const originalX1 = nodes[0].position.x;
    optimizeRowAlignment(nodes, edges, rowNodes, lockedBounds);

    // node_1 should not move into locked bounds
    // The actual behavior depends on collision detection
    expect(nodes[0].position.x).toBeDefined();
    // If adjustment would cause collision, position is unchanged
    if (nodes[0].position.x >= 140 && nodes[0].position.x <= 160) {
      expect(nodes[0].position.x).toBe(originalX1);
    }
  });

  it('should enforce minimum spacing between nodes', () => {
    const nodes = [
      createNode('node_1', 100, 100),
      createNode('node_2', 105, 100), // Very close to node_1
    ];
    const edges: Edge[] = [];
    const rowNodes = new Map([[0, ['node_1', 'node_2']]]);

    optimizeRowAlignment(nodes, edges, rowNodes, []);

    // Nodes should be at least MIN_NODE_DISTANCE apart
    const distance = nodes[1].position.x - nodes[0].position.x;
    expect(distance).toBeGreaterThanOrEqual(LAYOUT_CONFIG.MIN_NODE_DISTANCE);
  });

  it('should handle nodes with undefined positions', () => {
    const node1 = createNode('node_1', 100, 100);
    const node2 = { id: 'node_2', data: {}, position: undefined as any };
    const nodes = [node1, node2];
    const rowNodes = new Map([[0, ['node_1', 'node_2']]]);

    // Should not throw
    expect(() => {
      optimizeRowAlignment(nodes, [], rowNodes, []);
    }).not.toThrow();
  });

  it('should skip adjustment when idealX is null', () => {
    // Node with no connected edges will have null idealX
    const nodes = [
      createNode('node_1', 100, 100),
      createNode('node_2', 200, 100),
    ];
    const edges: Edge[] = []; // No edges
    const rowNodes = new Map([[0, ['node_1', 'node_2']]]);

    optimizeRowAlignment(nodes, edges, rowNodes, []);

    // With no edges, nodes may only be adjusted for minimum spacing
    // They shouldn't be dramatically moved without connection data
    expect(nodes[0].position.x).toBeDefined();
    expect(nodes[1].position.x).toBeDefined();
  });

  it('should shift nodes right when spacing enforcement hits locked bounds', () => {
    const nodes = [
      createNode('node_1', 100, 100),
      createNode('node_2', 110, 100), // Very close, will need spacing
    ];
    const edges: Edge[] = [];
    const rowNodes = new Map([[0, ['node_1', 'node_2']]]);
    // Place locked bounds where node_2 would want to be pushed
    const lockedBounds = [{
      id: 'locked_1',
      x1: 145,
      y1: 90,
      x2: 155,
      y2: 110,
    }];

    optimizeRowAlignment(nodes, edges, rowNodes, lockedBounds);

    // node_2 should be pushed further right to avoid locked bounds
    expect(nodes[1].position.x).toBeGreaterThan(155);
  });
});
