import { DataLinkBuiltInVars } from '@grafana/data';
import { Graph } from 'app/core/utils/dag';
import { mapSet } from 'app/core/utils/set';
import { stringifyPanelModel } from 'app/features/dashboard/state/PanelModel';

import { safeStringifyValue } from '../../../core/utils/explore';
import { DashboardModel } from '../../dashboard/state/DashboardModel';
import { PanelModel } from '../../dashboard/state/PanelModel';
import { variableAdapters } from '../adapters';
import { isAdHoc } from '../guard';
import { VariableModel } from '../types';
import { containsVariable, variableRegex, variableRegexExec } from '../utils';

export interface GraphNode {
  id: string;
  label: string;
}

export interface GraphEdge {
  from: string;
  to: string;
}

export const createDependencyNodes = (variables: VariableModel[]): GraphNode[] => {
  const nodes: GraphNode[] = [];

  for (const variable of variables) {
    nodes.push({ id: variable.id, label: `${variable.id}` });
  }

  return nodes;
};

export const filterNodesWithDependencies = (nodes: GraphNode[], edges: GraphEdge[]): GraphNode[] => {
  return nodes.filter((node) => edges.some((edge) => edge.from === node.id || edge.to === node.id));
};

export const createDependencyEdges = (variables: VariableModel[]): GraphEdge[] => {
  const edges: GraphEdge[] = [];

  for (const variable of variables) {
    for (const other of variables) {
      if (variable === other) {
        continue;
      }

      const dependsOn = variableAdapters.get(variable.type).dependsOn(variable, other);

      if (dependsOn) {
        edges.push({ from: variable.id, to: other.id });
      }
    }
  }

  return edges;
};

export function getVariableName(expression: string) {
  const match = variableRegexExec(expression);
  if (!match) {
    return undefined;
  }
  const variableName = match.slice(1).find((match) => match !== undefined);

  // ignore variables that match inherited object prop names
  if (variableName! in {}) {
    return undefined;
  }

  return variableName;
}

export const getUnknownVariableStrings = (variables: VariableModel[], model: DashboardModel) => {
  variableRegex.lastIndex = 0;
  const unknownVariableNames: string[] = [];
  const modelAsString = safeStringifyValue(model, 2);
  const matches = modelAsString.match(variableRegex);

  if (!matches) {
    return unknownVariableNames;
  }

  for (const match of matches) {
    if (!match) {
      continue;
    }

    if (match.indexOf('$__') !== -1) {
      // ignore builtin variables
      continue;
    }

    if (match.indexOf('${__') !== -1) {
      // ignore builtin variables
      continue;
    }

    if (match.indexOf('$hashKey') !== -1) {
      // ignore Angular props
      continue;
    }

    const variableName = getVariableName(match);

    if (variables.some((variable) => variable.id === variableName)) {
      // ignore defined variables
      continue;
    }

    if (unknownVariableNames.find((name) => name === variableName)) {
      continue;
    }

    if (variableName) {
      unknownVariableNames.push(variableName);
    }
  }

  return unknownVariableNames;
};

const validVariableNames: Record<string, RegExp[]> = {
  alias: [/^m$/, /^measurement$/, /^col$/, /^tag_(\w+|\d+)$/],
  query: [/^timeFilter$/],
};

export const getPropsWithVariable = (variableId: string, parent: { key: string; value: any }, result: any) => {
  const stringValues = Object.keys(parent.value).reduce<Record<string, any>>((all, key) => {
    const value = parent.value[key];
    if (!value || typeof value !== 'string') {
      return all;
    }

    const isValidName = validVariableNames[key]
      ? validVariableNames[key].find((regex: RegExp) => regex.test(variableId))
      : undefined;

    let hasVariable = containsVariable(value, variableId);
    if (key === 'repeat' && value === variableId) {
      // repeat stores value without variable format
      hasVariable = true;
    }

    if (!isValidName && hasVariable) {
      all = {
        ...all,
        [key]: value,
      };
    }

    return all;
  }, {});

  const objectValues = Object.keys(parent.value).reduce<Record<string, any>>((all, key) => {
    const value = parent.value[key];
    if (value && typeof value === 'object' && Object.keys(value).length) {
      let id = value.title || value.name || value.id || key;
      if (Array.isArray(parent.value) && parent.key === 'panels') {
        id = `${id}[${value.id}]`;
      }

      const newResult = getPropsWithVariable(variableId, { key, value }, {});

      if (Object.keys(newResult).length) {
        all = {
          ...all,
          [id]: newResult,
        };
      }
    }

    return all;
  }, {});

  if (Object.keys(stringValues).length || Object.keys(objectValues).length) {
    result = {
      ...result,
      ...stringValues,
      ...objectValues,
    };
  }

  return result;
};

export interface VariableUsageTree {
  variable: VariableModel;
  tree: any;
}

export interface VariableUsages {
  unUsed: VariableModel[];
  usages: VariableUsageTree[];
}

export const createUsagesNetwork = (variables: VariableModel[], dashboard: DashboardModel | null): VariableUsages => {
  if (!dashboard) {
    return { unUsed: [], usages: [] };
  }

  const unUsed: VariableModel[] = [];
  let usages: VariableUsageTree[] = [];
  const model = dashboard.getSaveModelCloneOld();

  for (const variable of variables) {
    const variableId = variable.id;
    const props = getPropsWithVariable(variableId, { key: 'model', value: model }, {});
    if (!Object.keys(props).length && !isAdHoc(variable)) {
      unUsed.push(variable);
    }

    if (Object.keys(props).length) {
      usages.push({ variable, tree: props });
    }
  }

  return { unUsed, usages };
};

export async function getUnknownsNetwork(
  variables: VariableModel[],
  dashboard: DashboardModel | null
): Promise<UsagesToNetwork[]> {
  return new Promise((resolve, reject) => {
    // can be an expensive call so we avoid blocking the main thread
    setTimeout(() => {
      try {
        const unknowns = createUnknownsNetwork(variables, dashboard);
        resolve(transformUsagesToNetwork(unknowns));
      } catch (e) {
        reject(e);
      }
    }, 200);
  });
}

function createUnknownsNetwork(variables: VariableModel[], dashboard: DashboardModel | null): VariableUsageTree[] {
  if (!dashboard) {
    return [];
  }

  let unknown: VariableUsageTree[] = [];
  const model = dashboard.getSaveModelCloneOld();

  const unknownVariables = getUnknownVariableStrings(variables, model);
  for (const unknownVariable of unknownVariables) {
    const props = getPropsWithVariable(unknownVariable, { key: 'model', value: model }, {});
    if (Object.keys(props).length) {
      const variable = { id: unknownVariable, name: unknownVariable } as unknown as VariableModel;
      unknown.push({ variable, tree: props });
    }
  }

  return unknown;
}

/*
  getAllAffectedPanelIdsForVariableChange is a function that extracts all the panel ids that are affected by a single variable
  change. It will traverse all chained variables to identify all cascading changes too.

  This is done entirely by parsing the current dashboard json and doesn't take under consideration a user cancelling
  a variable query or any faulty variable queries.

  This doesn't take circular dependencies in consideration.
 */

export function getAllAffectedPanelIdsForVariableChange(
  variableIds: string[],
  variableGraph: Graph,
  panelsByVar: Record<string, Set<number>>
): Set<number> {
  const allDependencies = mapSet(variableGraph.descendants(variableIds), (n) => n.name);
  allDependencies.add(DataLinkBuiltInVars.includeVars);
  for (const id of variableIds) {
    allDependencies.add(id);
  }

  const affectedPanelIds = getDependentPanels([...allDependencies], panelsByVar);
  return affectedPanelIds;
}

// Return an array of panel IDs depending on variables
export function getDependentPanels(variables: string[], panelsByVarUsage: Record<string, Set<number>>) {
  const thePanels: number[] = [];
  for (const varId of variables) {
    if (panelsByVarUsage[varId]) {
      thePanels.push(...panelsByVarUsage[varId]);
    }
  }

  return new Set(thePanels);
}

export interface UsagesToNetwork {
  variable: VariableModel;
  nodes: GraphNode[];
  edges: GraphEdge[];
  showGraph: boolean;
}

export const traverseTree = (usage: UsagesToNetwork, parent: { id: string; value: any }): UsagesToNetwork => {
  const { id, value } = parent;
  const { nodes, edges } = usage;

  if (value && typeof value === 'string') {
    const leafId = `${parent.id}-${value}`;
    nodes.push({ id: leafId, label: value });
    edges.push({ from: leafId, to: id });

    return usage;
  }

  if (value && typeof value === 'object') {
    const keys = Object.keys(value);
    for (const key of keys) {
      const leafId = `${parent.id}-${key}`;
      nodes.push({ id: leafId, label: key });
      edges.push({ from: leafId, to: id });
      usage = traverseTree(usage, { id: leafId, value: value[key] });
    }

    return usage;
  }

  return usage;
};

export const transformUsagesToNetwork = (usages: VariableUsageTree[]): UsagesToNetwork[] => {
  const results: UsagesToNetwork[] = [];

  for (const usage of usages) {
    const { variable, tree } = usage;
    const result: UsagesToNetwork = {
      variable,
      nodes: [{ id: 'dashboard', label: 'dashboard' }],
      edges: [],
      showGraph: false,
    };
    results.push(traverseTree(result, { id: 'dashboard', value: tree }));
  }

  return results;
};

const countLeaves = (object: object): number => {
  const total = Object.values(object).reduce<number>((count, value) => {
    if (typeof value === 'object') {
      return count + countLeaves(value);
    }

    return count + 1;
  }, 0);

  return total;
};

export const getVariableUsages = (variableId: string, usages: VariableUsageTree[]): number => {
  const usage = usages.find((usage) => usage.variable.id === variableId);
  if (!usage) {
    return 0;
  }

  return countLeaves(usage.tree);
};

export function flattenPanels(panels: PanelModel[]): PanelModel[] {
  const result: PanelModel[] = [];

  for (const panel of panels) {
    result.push(panel);
    if (panel.panels?.length) {
      result.push(...flattenPanels(panel.panels.map((p: PanelModel) => new PanelModel(p))));
    }
  }

  return result;
}

// Accepts an array of panel models, and returns an array of panel IDs paired with
// the names of any template variables found
export function getPanelVars(panels: PanelModel[]) {
  const panelsByVar: Record<string, Set<number>> = {};
  for (const p of panels) {
    const jsonString = stringifyPanelModel(p);
    const repeats = [...jsonString.matchAll(/"repeat":"([^"]+)"/g)].map((m) => m[1]);
    const varRegexMatches = jsonString.match(variableRegex)?.map((m) => getVariableName(m)) ?? [];
    const varNames = [...repeats, ...varRegexMatches];
    for (const varName of varNames) {
      if (varName! in panelsByVar) {
        panelsByVar[varName!].add(p.id);
      } else {
        panelsByVar[varName!] = new Set([p.id]);
      }
    }
  }

  return panelsByVar;
}
