/**
 * Copyright (c) HashiCorp, Inc.
 * SPDX-License-Identifier: MPL-2.0
 */

import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action, set } from '@ember/object';
import { inject as service } from '@ember/service';
import { next } from '@ember/runloop';
import { scaleLinear } from 'd3-scale';
import { extent, deviation, mean } from 'd3-array';
import { line, curveBasis } from 'd3-shape';
import styleStringProperty from '../utils/properties/style-string';

export default class TopoViz extends Component {
  @service system;

  @tracked element = null;
  @tracked topology = { datacenters: [] };

  @tracked activeNode = null;
  @tracked activeAllocation = null;
  @tracked activeEdges = [];
  @tracked edgeOffset = { x: 0, y: 0 };
  @tracked viewportColumns = 2;

  @tracked highlightAllocation = null;
  @tracked tooltipProps = {};

  @styleStringProperty('tooltipProps') tooltipStyle;

  get isSingleColumn() {
    if (this.topology.datacenters.length <= 1 || this.viewportColumns === 1)
      return true;

    // Compute the coefficient of variance to determine if it would be
    // better to stack datacenters or place them in columns
    const nodeCounts = this.topology.datacenters.map(
      (datacenter) => datacenter.nodes.length
    );
    const variationCoefficient = deviation(nodeCounts) / mean(nodeCounts);

    // The point at which the varation is too extreme for a two column layout
    const threshold = 0.5;
    if (variationCoefficient > threshold) return true;
    return false;
  }

  get datacenterIsSingleColumn() {
    // If there are enough nodes, use two columns of nodes within
    // a single column layout of datacenters to increase density.
    if (this.viewportColumns === 1) return true;
    return (
      !this.isSingleColumn ||
      (this.isSingleColumn && this.args.nodes.length <= 20)
    );
  }

  // Once a cluster is large enough, the exact details of a node are
  // typically irrelevant and a waste of space.
  get isDense() {
    return this.args.nodes.length > 50;
  }

  dataForNode(node) {
    return {
      node,
      datacenter: node.datacenter,
      memory: node.resources.memory,
      cpu: node.resources.cpu,
      allocations: [],
      isSelected: false,
    };
  }

  dataForAllocation(allocation, node) {
    const jobId = allocation.belongsTo('job').id();
    return {
      allocation,
      node,
      jobId,
      groupKey: JSON.stringify([jobId, allocation.taskGroupName]),
      memory: allocation.allocatedResources.memory,
      cpu: allocation.allocatedResources.cpu,
      memoryPercent: allocation.allocatedResources.memory / node.memory,
      cpuPercent: allocation.allocatedResources.cpu / node.cpu,
      isSelected: false,
    };
  }

  @action
  buildTopology() {
    const nodes = this.args.nodes;
    const allocations = this.args.allocations;

    // Nodes may not have a resources property due to having an old Nomad agent version.
    const badNodes = [];

    // Wrap nodes in a topo viz specific data structure and build an index to speed up allocation assignment
    const nodeContainers = [];
    const nodeIndex = {};
    nodes.forEach((node) => {
      if (!node.resources) {
        badNodes.push(node);
        return;
      }

      const container = this.dataForNode(node);
      nodeContainers.push(container);
      nodeIndex[node.id] = container;
    });

    // Wrap allocations in a topo viz specific data structure, assign allocations to nodes, and build an allocation
    // index keyed off of job and task group
    const allocationIndex = {};
    allocations.forEach((allocation) => {
      const nodeId = allocation.belongsTo('node').id();
      const nodeContainer = nodeIndex[nodeId];

      // Ignore orphaned allocations and allocations on nodes with an old Nomad agent version.
      if (!nodeContainer) return;

      const allocationContainer = this.dataForAllocation(
        allocation,
        nodeContainer
      );
      nodeContainer.allocations.push(allocationContainer);

      const key = allocationContainer.groupKey;
      if (!allocationIndex[key]) allocationIndex[key] = [];
      allocationIndex[key].push(allocationContainer);
    });

    // Group nodes into datacenters
    const datacentersMap = nodeContainers.reduce(
      (datacenters, nodeContainer) => {
        if (!datacenters[nodeContainer.datacenter])
          datacenters[nodeContainer.datacenter] = [];
        datacenters[nodeContainer.datacenter].push(nodeContainer);
        return datacenters;
      },
      {}
    );

    // Turn hash of datacenters into a sorted array
    const datacenters = Object.keys(datacentersMap)
      .map((key) => ({ name: key, nodes: datacentersMap[key] }))
      .sortBy('name');

    const topology = {
      datacenters,
      allocationIndex,
      selectedKey: null,
      heightScale: scaleLinear()
        .range([15, 40])
        .domain(extent(nodeContainers.mapBy('memory'))),
    };
    this.topology = topology;

    if (badNodes.length && this.args.onDataError) {
      this.args.onDataError([
        {
          type: 'filtered-nodes',
          context: badNodes,
        },
      ]);
    }
  }

  @action
  captureElement(element) {
    this.element = element;
    this.determineViewportColumns();
  }

  @action
  showNodeDetails(node) {
    if (this.activeNode) {
      set(this.activeNode, 'isSelected', false);
    }

    this.activeNode = this.activeNode === node ? null : node;

    if (this.activeNode) {
      set(this.activeNode, 'isSelected', true);
    }

    if (this.args.onNodeSelect) this.args.onNodeSelect(this.activeNode);
  }

  @action showTooltip(allocation, element) {
    const bbox = element.getBoundingClientRect();
    this.highlightAllocation = allocation;
    this.tooltipProps = {
      left: window.scrollX + bbox.left + bbox.width / 2,
      top: window.scrollY + bbox.top,
    };
  }

  @action hideTooltip() {
    this.highlightAllocation = null;
  }

  @action
  associateAllocations(allocation) {
    if (this.activeAllocation === allocation) {
      this.activeAllocation = null;
      this.activeEdges = [];

      if (this.topology.selectedKey) {
        const selectedAllocations =
          this.topology.allocationIndex[this.topology.selectedKey];
        if (selectedAllocations) {
          selectedAllocations.forEach((allocation) => {
            set(allocation, 'isSelected', false);
          });
        }
        set(this.topology, 'selectedKey', null);
      }
    } else {
      if (this.activeNode) {
        set(this.activeNode, 'isSelected', false);
      }
      this.activeNode = null;
      this.activeAllocation = allocation;
      const selectedAllocations =
        this.topology.allocationIndex[this.topology.selectedKey];
      if (selectedAllocations) {
        selectedAllocations.forEach((allocation) => {
          set(allocation, 'isSelected', false);
        });
      }

      set(this.topology, 'selectedKey', allocation.groupKey);
      const newAllocations =
        this.topology.allocationIndex[this.topology.selectedKey];
      if (newAllocations) {
        newAllocations.forEach((allocation) => {
          set(allocation, 'isSelected', true);
        });
      }

      // Only show the lines if the selected allocations are sparse (low count relative to the client count or low count generally).
      if (
        newAllocations.length < 10 ||
        newAllocations.length < this.args.nodes.length * 0.75
      ) {
        this.computedActiveEdges();
      } else {
        this.activeEdges = [];
      }
    }
    if (this.args.onAllocationSelect)
      this.args.onAllocationSelect(
        this.activeAllocation && this.activeAllocation.allocation
      );
    if (this.args.onNodeSelect) this.args.onNodeSelect(this.activeNode);
  }

  @action
  determineViewportColumns() {
    this.viewportColumns = this.element.clientWidth < 900 ? 1 : 2;
  }

  @action
  resizeEdges() {
    if (this.activeEdges.length > 0) {
      this.computedActiveEdges();
    }
  }

  @action
  computedActiveEdges() {
    // Wait a render cycle
    next(() => {
      const path = line().curve(curveBasis);
      // 1. Get the active element
      const allocation = this.activeAllocation.allocation;
      const activeEl = this.element.querySelector(
        `[data-allocation-id="${allocation.id}"]`
      );
      const activePoint = centerOfBBox(activeEl.getBoundingClientRect());

      // 2. Collect the mem and cpu pairs for all selected allocs
      const selectedMem = Array.from(
        this.element.querySelectorAll('.memory .bar.is-selected')
      );
      const selectedPairs = selectedMem.map((mem) => {
        const id = mem.closest('[data-allocation-id]').dataset.allocationId;
        const cpu = mem
          .closest('.topo-viz-node')
          .querySelector(`.cpu .bar[data-allocation-id="${id}"]`);
        return [mem, cpu];
      });
      const selectedPoints = selectedPairs.map((pair) => {
        return pair.map((el) => centerOfBBox(el.getBoundingClientRect()));
      });

      // 3. For each pair, compute the midpoint of the truncated triangle of points [Mem, Cpu, Active]
      selectedPoints.forEach((points) => {
        const d1 = pointBetween(points[0], activePoint, 100, 0.5);
        const d2 = pointBetween(points[1], activePoint, 100, 0.5);
        points.push(midpoint(d1, d2));
      });

      // 4. Generate curves for each active->mem and active->cpu pair going through the bisector
      const curves = [];
      // Steps are used to restrict the range of curves. The closer control points are placed, the less
      // curvature the curve generator will generate.
      const stepsMain = [0, 0.8, 1.0];
      // The second prong the fork does not need to retrace the entire path from the activePoint
      const stepsSecondary = [0.8, 1.0];
      selectedPoints.forEach((points) => {
        curves.push(
          curveFromPoints(
            ...pointsAlongPath(activePoint, points[2], stepsMain),
            points[0]
          ),
          curveFromPoints(
            ...pointsAlongPath(activePoint, points[2], stepsSecondary),
            points[1]
          )
        );
      });

      this.activeEdges = curves.map((curve) => path(curve));
      this.edgeOffset = { x: window.scrollX, y: window.scrollY };
    });
  }
}

function centerOfBBox(bbox) {
  return {
    x: bbox.x + bbox.width / 2,
    y: bbox.y + bbox.height / 2,
  };
}

function dist(p1, p2) {
  return Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2));
}

// Return the point between p1 and p2 at len (or pct if len > dist(p1, p2))
function pointBetween(p1, p2, len, pct) {
  const d = dist(p1, p2);
  const ratio = d < len ? pct : len / d;
  return pointBetweenPct(p1, p2, ratio);
}

function pointBetweenPct(p1, p2, pct) {
  const dx = p2.x - p1.x;
  const dy = p2.y - p1.y;
  return { x: p1.x + dx * pct, y: p1.y + dy * pct };
}

function pointsAlongPath(p1, p2, pcts) {
  return pcts.map((pct) => pointBetweenPct(p1, p2, pct));
}

function midpoint(p1, p2) {
  return pointBetweenPct(p1, p2, 0.5);
}

function curveFromPoints(...points) {
  return points.map((p) => [p.x, p.y]);
}
