import _ from "lodash";
import {
  DATA_BIND_REGEX,
  DATA_BIND_REGEX_GLOBAL,
} from "constants/BindingsConstants";
import ValidationFactory from "./ValidationFactory";
import JSExecutionManagerSingleton, {
  JSExecutorResult,
} from "jsExecution/JSExecutionManagerSingleton";
import unescapeJS from "unescape-js";
import toposort from "toposort";
import {
  DataTree,
  DataTreeEntity,
  DataTreeWidget,
  ENTITY_TYPE,
} from "entities/DataTree/dataTreeFactory";
import * as log from "loglevel";
import equal from "fast-deep-equal/es6";
import WidgetFactory from "utils/WidgetFactory";
import { AppToaster } from "components/editorComponents/ToastComponent";
import { ToastType } from "react-toastify";
import { Action } from "entities/Action";

export const removeBindingsFromActionObject = (obj: Action) => {
  const string = JSON.stringify(obj);
  const withBindings = string.replace(DATA_BIND_REGEX_GLOBAL, "{{ }}");
  return JSON.parse(withBindings);
};
// referencing DATA_BIND_REGEX fails for the value "{{Table1.tableData[Table1.selectedRowIndex]}}" if you run it multiple times and don't recreate
export const isDynamicValue = (value: string): boolean =>
  DATA_BIND_REGEX.test(value);

//{{}}{{}}}
export function getDynamicStringSegments(dynamicString: string): string[] {
  let stringSegments = [];
  const indexOfDoubleParanStart = dynamicString.indexOf("{{");
  if (indexOfDoubleParanStart === -1) {
    return [dynamicString];
  }
  //{{}}{{}}}
  const firstString = dynamicString.substring(0, indexOfDoubleParanStart);
  firstString && stringSegments.push(firstString);
  let rest = dynamicString.substring(
    indexOfDoubleParanStart,
    dynamicString.length,
  );
  //{{}}{{}}}
  let sum = 0;
  for (let i = 0; i <= rest.length - 1; i++) {
    const char = rest[i];
    const prevChar = rest[i - 1];

    if (char === "{") {
      sum++;
    } else if (char === "}") {
      sum--;
      if (prevChar === "}" && sum === 0) {
        stringSegments.push(rest.substring(0, i + 1));
        rest = rest.substring(i + 1, rest.length);
        if (rest) {
          stringSegments = stringSegments.concat(
            getDynamicStringSegments(rest),
          );
          break;
        }
      }
    }
  }
  if (sum !== 0 && dynamicString !== "") {
    return [dynamicString];
  }
  return stringSegments;
}

const getAllPaths = (
  tree: Record<string, any>,
  prefix = "",
): Record<string, true> => {
  return Object.keys(tree).reduce((res: Record<string, true>, el): Record<
    string,
    true
  > => {
    if (Array.isArray(tree[el])) {
      const key = `${prefix}${el}`;
      return { ...res, [key]: true };
    } else if (typeof tree[el] === "object" && tree[el] !== null) {
      const key = `${prefix}${el}`;
      return { ...res, [key]: true, ...getAllPaths(tree[el], `${key}.`) };
    } else {
      const key = `${prefix}${el}`;
      return { ...res, [key]: true };
    }
  }, {});
};

export const getDynamicBindings = (
  dynamicString: string,
): { stringSegments: string[]; jsSnippets: string[] } => {
  // Protect against bad string parse
  if (!dynamicString || !_.isString(dynamicString)) {
    return { stringSegments: [], jsSnippets: [] };
  }
  const sanitisedString = dynamicString.trim();
  // Get the {{binding}} bound values
  const stringSegments = getDynamicStringSegments(sanitisedString);
  // Get the "binding" path values
  const paths = stringSegments.map(segment => {
    const length = segment.length;
    const matches = isDynamicValue(segment);
    if (matches) {
      return segment.substring(2, length - 2);
    }
    return "";
  });
  return { stringSegments: stringSegments, jsSnippets: paths };
};

// Paths are expected to have "{name}.{path}" signature
// Also returns any action triggers found after evaluating value
export const evaluateDynamicBoundValue = (
  data: DataTree,
  path: string,
  callbackData?: any,
): JSExecutorResult => {
  const unescapedInput = unescapeJS(path);
  return JSExecutionManagerSingleton.evaluateSync(
    unescapedInput,
    data,
    callbackData,
  );
};

// For creating a final value where bindings could be in a template format
export const createDynamicValueString = (
  binding: string,
  subBindings: string[],
  subValues: string[],
): string => {
  // Replace the string with the data tree values
  let finalValue = binding;
  subBindings.forEach((b, i) => {
    let value = subValues[i];
    if (Array.isArray(value) || _.isObject(value)) {
      value = JSON.stringify(value);
    }
    try {
      if (JSON.parse(value)) {
        value = value.replace(/\\([\s\S])|(")/g, "\\$1$2");
      }
    } catch (e) {
      // do nothing
    }
    finalValue = finalValue.replace(b, value);
  });
  return finalValue;
};

export const getDynamicValue = (
  dynamicBinding: string,
  data: DataTree,
  callBackData?: any,
  includeTriggers = false,
): JSExecutorResult => {
  // Get the {{binding}} bound values
  const { stringSegments, jsSnippets } = getDynamicBindings(dynamicBinding);
  if (stringSegments.length) {
    // Get the Data Tree value of those "binding "paths
    const values = jsSnippets.map((jsSnippet, index) => {
      if (jsSnippet) {
        const result = evaluateDynamicBoundValue(data, jsSnippet, callBackData);
        if (includeTriggers) {
          return result;
        } else {
          return { result: result.result };
        }
      } else {
        return { result: stringSegments[index], triggers: [] };
      }
    });

    // if it is just one binding, no need to create template string
    if (stringSegments.length === 1) return values[0];
    // else return a string template with bindings
    const templateString = createDynamicValueString(
      dynamicBinding,
      stringSegments,
      values.map(v => v.result),
    );
    return {
      result: templateString,
    };
  }
  return { result: undefined, triggers: [] };
};

export const getValidatedTree = (tree: any) => {
  return Object.keys(tree).reduce((tree, entityKey: string) => {
    const entity = tree[entityKey];
    if (entity && entity.type) {
      const parsedEntity = { ...entity };
      Object.keys(entity).forEach((property: string) => {
        const hasEvaluatedValue = _.has(
          parsedEntity,
          `evaluatedValues.${property}`,
        );
        const hasValidation = _.has(parsedEntity, `invalidProps.${property}`);
        const isSpecialField = [
          "dynamicBindings",
          "dynamicTriggers",
          "dynamicProperties",
          "evaluatedValues",
          "invalidProps",
          "validationMessages",
        ].includes(property);
        const isDynamicField =
          _.has(parsedEntity, `dynamicBindings.${property}`) ||
          _.has(parsedEntity, `dynamicTriggers.${property}`);

        if (
          !isSpecialField &&
          !isDynamicField &&
          (!hasValidation || !hasEvaluatedValue)
        ) {
          const value = entity[property];
          // Pass it through parse
          const {
            parsed,
            isValid,
            message,
            transformed,
          } = ValidationFactory.validateWidgetProperty(
            entity.type,
            property,
            value,
            entity,
            tree,
          );
          parsedEntity[property] = parsed;
          if (!hasEvaluatedValue) {
            const evaluatedValue = _.isUndefined(transformed)
              ? value
              : transformed;
            _.set(parsedEntity, `evaluatedValues.${property}`, evaluatedValue);
          }

          const hasValidation = _.has(parsedEntity, `invalidProps.${property}`);
          if (!hasValidation && !isValid) {
            _.set(parsedEntity, `invalidProps.${property}`, true);
            _.set(parsedEntity, `validationMessages.${property}`, message);
          }
        }
      });
      return { ...tree, [entityKey]: parsedEntity };
    }
    return tree;
  }, tree);
};

let dependencyTreeCache: any = {};
let cachedDataTreeString = "";

export function getEvaluatedDataTree(dataTree: DataTree): DataTree {
  const totalStart = performance.now();
  // Create Dependencies DAG
  const createDepsStart = performance.now();
  const dataTreeString = JSON.stringify(dataTree);
  if (!equal(dataTreeString, cachedDataTreeString)) {
    cachedDataTreeString = dataTreeString;
    dependencyTreeCache = createDependencyTree(dataTree);
  }
  const createDepsEnd = performance.now();
  const {
    dependencyMap,
    sortedDependencies,
    dependencyTree,
  } = dependencyTreeCache;

  // Evaluate Tree
  const evaluatedTreeStart = performance.now();
  const evaluatedTree = dependencySortedEvaluateDataTree(
    dataTree,
    dependencyMap,
    sortedDependencies,
  );
  const evaluatedTreeEnd = performance.now();

  // Set Loading Widgets
  const loadingTreeStart = performance.now();
  const treeWithLoading = setTreeLoading(evaluatedTree, dependencyTree);
  const loadingTreeEnd = performance.now();

  // Validate Widgets
  const validated = getValidatedTree(treeWithLoading);

  // End counting total time
  const endStart = performance.now();

  // Log time taken and count
  const timeTaken = {
    total: (endStart - totalStart).toFixed(2),
    createDeps: (createDepsEnd - createDepsStart).toFixed(2),
    evaluate: (evaluatedTreeEnd - evaluatedTreeStart).toFixed(2),
    loading: (loadingTreeEnd - loadingTreeStart).toFixed(2),
  };
  log.debug("data tree evaluated");
  log.debug(timeTaken);
  // dataTreeCache = validated;
  return validated;
}

type DynamicDependencyMap = Record<string, Array<string>>;
export const createDependencyTree = (
  dataTree: DataTree,
): {
  sortedDependencies: Array<string>;
  dependencyTree: Array<[string, string]>;
  dependencyMap: DynamicDependencyMap;
} => {
  const dependencyMap: DynamicDependencyMap = {};
  const allKeys = getAllPaths(dataTree);
  Object.keys(dataTree).forEach(entityKey => {
    const entity = dataTree[entityKey];
    if (entity && "ENTITY_TYPE" in entity) {
      if (entity.ENTITY_TYPE === ENTITY_TYPE.WIDGET) {
        // Set default property dependency
        const defaultProperties = WidgetFactory.getWidgetDefaultPropertiesMap(
          entity.type,
        );
        Object.keys(defaultProperties).forEach(property => {
          dependencyMap[`${entityKey}.${property}`] = [
            `${entityKey}.${defaultProperties[property]}`,
          ];
        });
        if (entity.dynamicBindings) {
          Object.keys(entity.dynamicBindings).forEach(prop => {
            const { jsSnippets } = getDynamicBindings(_.get(entity, prop));
            const existingDeps = dependencyMap[`${entityKey}.${prop}`] || [];
            dependencyMap[`${entityKey}.${prop}`] = existingDeps.concat(
              jsSnippets.filter(jsSnippet => !!jsSnippet),
            );
          });
        }
        if (entity.dynamicTriggers) {
          Object.keys(entity.dynamicTriggers).forEach(prop => {
            dependencyMap[`${entityKey}.${prop}`] = [];
          });
        }
      }
      if (entity.ENTITY_TYPE === ENTITY_TYPE.ACTION) {
        if (entity.dynamicBindingPathList.length) {
          entity.dynamicBindingPathList.forEach(prop => {
            const { jsSnippets } = getDynamicBindings(_.get(entity, prop.key));
            const existingDeps =
              dependencyMap[`${entityKey}.${prop.key}`] || [];
            dependencyMap[`${entityKey}.${prop.key}`] = existingDeps.concat(
              jsSnippets.filter(jsSnippet => !!jsSnippet),
            );
          });
        }
      }
    }
  });
  Object.keys(dependencyMap).forEach(key => {
    dependencyMap[key] = _.flatten(
      dependencyMap[key].map(path => calculateSubDependencies(path, allKeys)),
    );
  });
  const dependencyTree: Array<[string, string]> = [];
  Object.keys(dependencyMap).forEach((key: string) => {
    if (dependencyMap[key].length) {
      dependencyMap[key].forEach(dep => dependencyTree.push([key, dep]));
    } else {
      // Set no dependency
      dependencyTree.push([key, ""]);
    }
  });

  try {
    // sort dependencies and remove empty dependencies
    const sortedDependencies = toposort(dependencyTree)
      .reverse()
      .filter(d => !!d);

    return { sortedDependencies, dependencyMap, dependencyTree };
  } catch (e) {
    console.error(e);
    AppToaster.show({
      message: e.message,
      type: ToastType.ERROR,
    });
    return { sortedDependencies: [], dependencyMap: {}, dependencyTree: [] };
  }
};

const calculateSubDependencies = (
  path: string,
  all: Record<string, true>,
): Array<string> => {
  const subDeps: Array<string> = [];
  const identifiers = path.match(/[a-zA-Z_$][a-zA-Z_$0-9.]*/g) || [path];
  identifiers.forEach((identifier: string) => {
    if (identifier in all) {
      subDeps.push(identifier);
    } else {
      const subIdentifiers =
        identifier.match(/[a-zA-Z_$][a-zA-Z_$0-9]*/g) || [];
      let current = "";
      for (let i = 0; i < subIdentifiers.length; i++) {
        const key = `${current}${current ? "." : ""}${subIdentifiers[i]}`;
        if (key in all) {
          current = key;
        } else {
          break;
        }
      }
      if (current && current.includes(".")) subDeps.push(current);
    }
  });
  return _.uniq(subDeps);
};

export const setTreeLoading = (
  dataTree: DataTree,
  dependencyMap: Array<[string, string]>,
) => {
  const widgets: string[] = [];
  const isLoadingActions: string[] = [];

  // Fetch all actions that are in loading state
  Object.keys(dataTree).forEach(e => {
    const entity = dataTree[e];
    if (entity && "ENTITY_TYPE" in entity) {
      if (entity.ENTITY_TYPE === ENTITY_TYPE.WIDGET) {
        widgets.push(e);
      } else if (
        entity.ENTITY_TYPE === ENTITY_TYPE.ACTION &&
        entity.isLoading
      ) {
        isLoadingActions.push(e);
      }
    }
  });

  // get all widget dependencies of those actions
  isLoadingActions
    .reduce(
      (allEntities: string[], curr) =>
        allEntities.concat(getEntityDependencies(dependencyMap, curr, widgets)),
      [],
    )
    // set loading to true for those widgets
    .forEach(w => {
      const entity = dataTree[w] as DataTreeWidget;
      entity.isLoading = true;
    });
  return dataTree;
};

export const getEntityDependencies = (
  dependencyMap: Array<[string, string]>,
  entity: string,
  entities: string[],
): Array<string> => {
  const entityDeps: Record<string, string[]> = dependencyMap
    .map(d => [d[1].split(".")[0], d[0].split(".")[0]])
    .filter(d => d[0] !== d[1])
    .reduce((deps: Record<string, string[]>, dep) => {
      const key: string = dep[0];
      const value: string = dep[1];
      return {
        ...deps,
        [key]: deps[key] ? deps[key].concat(value) : [value],
      };
    }, {});

  if (entity in entityDeps) {
    const recFind = (
      keys: Array<string>,
      deps: Record<string, string[]>,
    ): Array<string> => {
      let allDeps: string[] = [];
      keys
        .filter(k => entities.includes(k))
        .forEach(e => {
          allDeps = allDeps.concat([e]);
          if (e in deps) {
            allDeps = allDeps.concat([...recFind(deps[e], deps)]);
          }
        });
      return allDeps;
    };
    return recFind(entityDeps[entity], entityDeps);
  }
  return [];
};

const dynamicPropValueCache: Map<
  string,
  {
    unEvaluated: any;
    evaluated: any;
  }
> = new Map();

const parsedValueCache: Map<
  string,
  {
    value: any;
    version: number;
  }
> = new Map();

const getDynamicPropValueCache = (propertyPath: string) =>
  dynamicPropValueCache.get(propertyPath);

const getParsedValueCache = (propertyPath: string) =>
  parsedValueCache.get(propertyPath) || {
    value: undefined,
    version: 0,
  };

export const clearPropertyCache = (propertyPath: string) =>
  parsedValueCache.delete(propertyPath);

const dependencyCache: Map<string, any[]> = new Map();

export const clearCaches = () => {
  dynamicPropValueCache.clear();
  dependencyCache.clear();
  parsedValueCache.clear();
};

function getCurrentDependencyValues(
  propertyDependencies: Array<string>,
  currentTree: DataTree,
  currentPropertyPath: string,
): Array<string> {
  return propertyDependencies
    ? propertyDependencies
        .map((path: string) => {
          //*** Remove current path from data tree because cached value contains evaluated version while this contains unevaluated version */
          const cleanDataTree = _.omit(currentTree, [currentPropertyPath]);
          return _.get(cleanDataTree, path);
        })
        .filter((data: any) => {
          return data !== undefined;
        })
    : [];
}

function evaluateDynamicProperty(
  propertyPath: string,
  currentTree: DataTree,
  unEvalPropertyValue: any,
  currentDependencyValues: Array<string>,
  cachedDependencyValues?: Array<string>,
): any {
  const cacheObj = getDynamicPropValueCache(propertyPath);
  const isCacheHit =
    cacheObj &&
    equal(cacheObj.unEvaluated, unEvalPropertyValue) &&
    cachedDependencyValues !== undefined &&
    equal(currentDependencyValues, cachedDependencyValues);
  if (isCacheHit && cacheObj) {
    return cacheObj.evaluated;
  } else {
    log.debug("eval " + propertyPath);
    const dynamicResult = getDynamicValue(unEvalPropertyValue, currentTree);
    dynamicPropValueCache.set(propertyPath, {
      evaluated: dynamicResult.result,
      unEvaluated: unEvalPropertyValue,
    });
    dependencyCache.set(propertyPath, currentDependencyValues);
    return dynamicResult.result;
  }
}

function validateAndParseWidgetProperty(
  propertyPath: string,
  widget: DataTreeWidget,
  currentTree: DataTree,
  evalPropertyValue: any,
  unEvalPropertyValue: string,
  currentDependencyValues: Array<string>,
  cachedDependencyValues?: Array<string>,
): any {
  const propertyName = propertyPath.split(".")[1];
  let valueToValidate = evalPropertyValue;
  if (widget.dynamicTriggers && propertyName in widget.dynamicTriggers) {
    const { triggers } = getDynamicValue(
      unEvalPropertyValue,
      currentTree,
      undefined,
      true,
    );
    valueToValidate = triggers;
  }
  const {
    parsed,
    isValid,
    message,
    transformed,
  } = ValidationFactory.validateWidgetProperty(
    widget.type,
    propertyName,
    valueToValidate,
    widget,
    currentTree,
  );
  const evaluatedValue = _.isUndefined(transformed)
    ? evalPropertyValue
    : transformed;
  _.set(widget, `evaluatedValues.${propertyName}`, evaluatedValue);
  if (!isValid) {
    _.set(widget, `invalidProps.${propertyName}`, true);
    _.set(widget, `validationMessages.${propertyName}`, message);
  }
  if (widget.dynamicTriggers && propertyName in widget.dynamicTriggers) {
    return unEvalPropertyValue;
  } else {
    const parsedCache = getParsedValueCache(propertyPath);
    if (
      !equal(parsedCache.value, parsed) ||
      (cachedDependencyValues !== undefined &&
        !equal(currentDependencyValues, cachedDependencyValues))
    ) {
      parsedValueCache.set(propertyPath, {
        value: parsed,
        version: Date.now(),
      });
    }
    return parsed;
  }
}

function isWidget(entity: DataTreeEntity): boolean {
  return "ENTITY_TYPE" in entity && entity.ENTITY_TYPE === ENTITY_TYPE.WIDGET;
}

export function dependencySortedEvaluateDataTree(
  dataTree: DataTree,
  dependencyMap: DynamicDependencyMap,
  sortedDependencies: Array<string>,
): DataTree {
  const tree = _.cloneDeep(dataTree);
  try {
    return sortedDependencies.reduce(
      (currentTree: DataTree, propertyPath: string) => {
        const entityName = propertyPath.split(".")[0];
        const entity: DataTreeEntity = currentTree[entityName];
        const unEvalPropertyValue = _.get(currentTree as any, propertyPath);
        let evalPropertyValue;
        const propertyDependencies = dependencyMap[propertyPath];
        const currentDependencyValues = getCurrentDependencyValues(
          propertyDependencies,
          currentTree,
          propertyPath,
        );
        const cachedDependencyValues = dependencyCache.get(propertyPath);
        const requiresEval = isDynamicValue(unEvalPropertyValue);
        if (requiresEval) {
          try {
            evalPropertyValue = evaluateDynamicProperty(
              propertyPath,
              currentTree,
              unEvalPropertyValue,
              currentDependencyValues,
              cachedDependencyValues,
            );
          } catch (e) {
            console.error(e);
            evalPropertyValue = undefined;
          }
        } else {
          evalPropertyValue = unEvalPropertyValue;
          // If we have stored any previous dependency cache, clear it
          // since it is no longer a binding
          if (cachedDependencyValues && cachedDependencyValues.length) {
            dependencyCache.set(propertyPath, []);
          }
        }
        if (isWidget(entity)) {
          const widgetEntity: DataTreeWidget = entity as DataTreeWidget;
          const propertyName = propertyPath.split(".")[1];
          if (propertyName) {
            let parsedValue = validateAndParseWidgetProperty(
              propertyPath,
              widgetEntity,
              currentTree,
              evalPropertyValue,
              unEvalPropertyValue,
              currentDependencyValues,
              cachedDependencyValues,
            );
            const defaultPropertyMap = WidgetFactory.getWidgetDefaultPropertiesMap(
              widgetEntity.type,
            );
            const hasDefaultProperty = propertyName in defaultPropertyMap;
            if (hasDefaultProperty) {
              const defaultProperty = defaultPropertyMap[propertyName];
              parsedValue = overwriteDefaultDependentProps(
                defaultProperty,
                parsedValue,
                propertyPath,
                widgetEntity,
              );
            }
            return _.set(currentTree, propertyPath, parsedValue);
          }
          return _.set(currentTree, propertyPath, evalPropertyValue);
        } else {
          return _.set(currentTree, propertyPath, evalPropertyValue);
        }
      },
      tree,
    );
  } catch (e) {
    console.error(e);
    return tree;
  }
}

const overwriteDefaultDependentProps = (
  defaultProperty: string,
  propertyValue: any,
  propertyPath: string,
  entity: DataTreeWidget,
) => {
  const defaultPropertyCache = getParsedValueCache(
    `${entity.widgetName}.${defaultProperty}`,
  );
  const propertyCache = getParsedValueCache(propertyPath);
  if (
    propertyValue === undefined ||
    propertyCache.version < defaultPropertyCache.version
  ) {
    return defaultPropertyCache.value;
  }
  return propertyValue;
};

/*

  Need to evaluated values
  Need to validate widget values
  Need to replace with default values

 */
