import {
  DirectiveNode,
  FieldNode,
  IntValueNode,
  FloatValueNode,
  StringValueNode,
  BooleanValueNode,
  ObjectValueNode,
  ListValueNode,
  EnumValueNode,
  NullValueNode,
  VariableNode,
  InlineFragmentNode,
  ValueNode,
  SelectionNode,
  NameNode,
} from 'graphql';

import stringify from 'fast-json-stable-stringify';
import { InvariantError } from 'ts-invariant';

export interface IdValue {
  type: 'id';
  id: string;
  generated: boolean;
  typename: string | undefined;
}

export interface JsonValue {
  type: 'json';
  json: any;
}

export type ListValue = Array<null | IdValue>;

export type StoreValue =
  | number
  | string
  | string[]
  | IdValue
  | ListValue
  | JsonValue
  | null
  | undefined
  | void
  | Object;

export type ScalarValue = StringValueNode | BooleanValueNode | EnumValueNode;

export function isScalarValue(value: ValueNode): value is ScalarValue {
  return ['StringValue', 'BooleanValue', 'EnumValue'].indexOf(value.kind) > -1;
}

export type NumberValue = IntValueNode | FloatValueNode;

export function isNumberValue(value: ValueNode): value is NumberValue {
  return ['IntValue', 'FloatValue'].indexOf(value.kind) > -1;
}

function isStringValue(value: ValueNode): value is StringValueNode {
  return value.kind === 'StringValue';
}

function isBooleanValue(value: ValueNode): value is BooleanValueNode {
  return value.kind === 'BooleanValue';
}

function isIntValue(value: ValueNode): value is IntValueNode {
  return value.kind === 'IntValue';
}

function isFloatValue(value: ValueNode): value is FloatValueNode {
  return value.kind === 'FloatValue';
}

function isVariable(value: ValueNode): value is VariableNode {
  return value.kind === 'Variable';
}

function isObjectValue(value: ValueNode): value is ObjectValueNode {
  return value.kind === 'ObjectValue';
}

function isListValue(value: ValueNode): value is ListValueNode {
  return value.kind === 'ListValue';
}

function isEnumValue(value: ValueNode): value is EnumValueNode {
  return value.kind === 'EnumValue';
}

function isNullValue(value: ValueNode): value is NullValueNode {
  return value.kind === 'NullValue';
}

export function valueToObjectRepresentation(
  argObj: any,
  name: NameNode,
  value: ValueNode,
  variables?: Object,
) {
  if (isIntValue(value) || isFloatValue(value)) {
    argObj[name.value] = Number(value.value);
  } else if (isBooleanValue(value) || isStringValue(value)) {
    argObj[name.value] = value.value;
  } else if (isObjectValue(value)) {
    const nestedArgObj = {};
    value.fields.map(obj =>
      valueToObjectRepresentation(nestedArgObj, obj.name, obj.value, variables),
    );
    argObj[name.value] = nestedArgObj;
  } else if (isVariable(value)) {
    const variableValue = (variables || ({} as any))[value.name.value];
    argObj[name.value] = variableValue;
  } else if (isListValue(value)) {
    argObj[name.value] = value.values.map(listValue => {
      const nestedArgArrayObj = {};
      valueToObjectRepresentation(
        nestedArgArrayObj,
        name,
        listValue,
        variables,
      );
      return (nestedArgArrayObj as any)[name.value];
    });
  } else if (isEnumValue(value)) {
    argObj[name.value] = (value as EnumValueNode).value;
  } else if (isNullValue(value)) {
    argObj[name.value] = null;
  } else {
    throw new InvariantError(
      `The inline argument "${name.value}" of kind "${(value as any).kind}"` +
        'is not supported. Use variables instead of inline arguments to ' +
        'overcome this limitation.',
    );
  }
}

export function storeKeyNameFromField(
  field: FieldNode,
  variables?: Object,
): string {
  let directivesObj: any = null;
  if (field.directives) {
    directivesObj = {};
    field.directives.forEach(directive => {
      directivesObj[directive.name.value] = {};

      if (directive.arguments) {
        directive.arguments.forEach(({ name, value }) =>
          valueToObjectRepresentation(
            directivesObj[directive.name.value],
            name,
            value,
            variables,
          ),
        );
      }
    });
  }

  let argObj: any = null;
  if (field.arguments && field.arguments.length) {
    argObj = {};
    field.arguments.forEach(({ name, value }) =>
      valueToObjectRepresentation(argObj, name, value, variables),
    );
  }

  return getStoreKeyName(field.name.value, argObj, directivesObj);
}

export type Directives = {
  [directiveName: string]: {
    [argName: string]: any;
  };
};

const KNOWN_DIRECTIVES: string[] = [
  'connection',
  'include',
  'skip',
  'client',
  'rest',
  'export',
];

export function getStoreKeyName(
  fieldName: string,
  args?: Object,
  directives?: Directives,
): string {
  if (
    directives &&
    directives['connection'] &&
    directives['connection']['key']
  ) {
    if (
      directives['connection']['filter'] &&
      (directives['connection']['filter'] as string[]).length > 0
    ) {
      const filterKeys = directives['connection']['filter']
        ? (directives['connection']['filter'] as string[])
        : [];
      filterKeys.sort();

      const queryArgs = args as { [key: string]: any };
      const filteredArgs = {} as { [key: string]: any };
      filterKeys.forEach(key => {
        filteredArgs[key] = queryArgs[key];
      });

      return `${directives['connection']['key']}(${JSON.stringify(
        filteredArgs,
      )})`;
    } else {
      return directives['connection']['key'];
    }
  }

  let completeFieldName: string = fieldName;

  if (args) {
    // We can't use `JSON.stringify` here since it's non-deterministic,
    // and can lead to different store key names being created even though
    // the `args` object used during creation has the same properties/values.
    const stringifiedArgs: string = stringify(args);
    completeFieldName += `(${stringifiedArgs})`;
  }

  if (directives) {
    Object.keys(directives).forEach(key => {
      if (KNOWN_DIRECTIVES.indexOf(key) !== -1) return;
      if (directives[key] && Object.keys(directives[key]).length) {
        completeFieldName += `@${key}(${JSON.stringify(directives[key])})`;
      } else {
        completeFieldName += `@${key}`;
      }
    });
  }

  return completeFieldName;
}

export function argumentsObjectFromField(
  field: FieldNode | DirectiveNode,
  variables: Object,
): Object {
  if (field.arguments && field.arguments.length) {
    const argObj: Object = {};
    field.arguments.forEach(({ name, value }) =>
      valueToObjectRepresentation(argObj, name, value, variables),
    );
    return argObj;
  }

  return null;
}

export function resultKeyNameFromField(field: FieldNode): string {
  return field.alias ? field.alias.value : field.name.value;
}

export function isField(selection: SelectionNode): selection is FieldNode {
  return selection.kind === 'Field';
}

export function isInlineFragment(
  selection: SelectionNode,
): selection is InlineFragmentNode {
  return selection.kind === 'InlineFragment';
}

export function isIdValue(idObject: StoreValue): idObject is IdValue {
  return idObject &&
    (idObject as IdValue | JsonValue).type === 'id' &&
    typeof (idObject as IdValue).generated === 'boolean';
}

export type IdConfig = {
  id: string;
  typename: string | undefined;
};

export function toIdValue(
  idConfig: string | IdConfig,
  generated = false,
): IdValue {
  return {
    type: 'id',
    generated,
    ...(typeof idConfig === 'string'
      ? { id: idConfig, typename: undefined }
      : idConfig),
  };
}

export function isJsonValue(jsonObject: StoreValue): jsonObject is JsonValue {
  return (
    jsonObject != null &&
    typeof jsonObject === 'object' &&
    (jsonObject as IdValue | JsonValue).type === 'json'
  );
}

function defaultValueFromVariable(node: VariableNode) {
  throw new InvariantError(`Variable nodes are not supported by valueFromNode`);
}

export type VariableValue = (node: VariableNode) => any;

/**
 * Evaluate a ValueNode and yield its value in its natural JS form.
 */
export function valueFromNode(
  node: ValueNode,
  onVariable: VariableValue = defaultValueFromVariable,
): any {
  switch (node.kind) {
    case 'Variable':
      return onVariable(node);
    case 'NullValue':
      return null;
    case 'IntValue':
      return parseInt(node.value, 10);
    case 'FloatValue':
      return parseFloat(node.value);
    case 'ListValue':
      return node.values.map(v => valueFromNode(v, onVariable));
    case 'ObjectValue': {
      const value: { [key: string]: any } = {};
      for (const field of node.fields) {
        value[field.name.value] = valueFromNode(field.value, onVariable);
      }
      return value;
    }
    default:
      return node.value;
  }
}