/**
 * Representation and compilation of predicate formulas.
 *
 * An example of a predicate formula is: "rec.office == 'Seattle' and user.email in ['sally@', 'xie@']".
 * These formulas are parsed in Python into a tree with nodes of the form [NODE_TYPE, ...args].
 * See sandbox/grist/predicate_formula.py for details.
 *
 * This module includes typings for the nodes, and the compilePredicateFormula() function that
 * turns such trees into actual predicate functions.
 */
import {CellValue, RowRecord} from 'app/common/DocActions';
import {ErrorWithCode} from 'app/common/ErrorWithCode';
import {InfoView} from 'app/common/RecordView';
import {UserInfo} from 'app/common/User';
import {decodeObject} from 'app/plugin/objtypes';
import constant = require('lodash/constant');

/**
 * Representation of a parsed predicate formula.
 */
export type PrimitiveCellValue = number|string|boolean|null;
export type ParsedPredicateFormula = [string, ...(ParsedPredicateFormula|PrimitiveCellValue)[]];

/**
 * Inputs to a predicate formula function.
 */
export interface PredicateFormulaInput {
  user?: UserInfo;
  rec?: RowRecord|InfoView;
  newRec?: InfoView;
  docId?: string;
  choice?: string|RowRecord|InfoView;
}

/**
 * The result of compiling ParsedPredicateFormula.
 */
export type CompiledPredicateFormula = (input: PredicateFormulaInput) => boolean;

const GRIST_CONSTANTS: Record<string, string> = {
  EDITOR: 'editors',
  OWNER: 'owners',
  VIEWER: 'viewers',
};

/**
 * An intermediate predicate formula returned during compilation, which may return
 * a non-boolean value.
 */
type IntermediatePredicateFormula = (input: PredicateFormulaInput) => any;

export interface CompilePredicateFormulaOptions {
  /** Defaults to `'acl'`. */
  variant?: 'acl'|'dropdown-condition';
}

/**
 * Compiles a parsed predicate formula and returns it.
 */
export function compilePredicateFormula(
  parsedPredicateFormula: ParsedPredicateFormula,
  options: CompilePredicateFormulaOptions = {}
): CompiledPredicateFormula {
  const {variant = 'acl'} = options;

  function compileNode(node: ParsedPredicateFormula): IntermediatePredicateFormula {
    const rawArgs = node.slice(1);
    const args = rawArgs as ParsedPredicateFormula[];
    switch (node[0]) {
      case 'And':   { const parts = args.map(compileNode); return (input) => parts.every(p => p(input)); }
      case 'Or':    { const parts = args.map(compileNode); return (input) => parts.some(p => p(input)); }
      case 'Add':   return compileAndCombine(args, ([a, b]) => a + b);
      case 'Sub':   return compileAndCombine(args, ([a, b]) => a - b);
      case 'Mult':  return compileAndCombine(args, ([a, b]) => a * b);
      case 'Div':   return compileAndCombine(args, ([a, b]) => a / b);
      case 'Mod':   return compileAndCombine(args, ([a, b]) => a % b);
      case 'Not':   return compileAndCombine(args, ([a]) => !a);
      case 'Eq':    return compileAndCombine(args, ([a, b]) => a === b);
      case 'NotEq': return compileAndCombine(args, ([a, b]) => a !== b);
      case 'Lt':    return compileAndCombine(args, ([a, b]) => a < b);
      case 'LtE':   return compileAndCombine(args, ([a, b]) => a <= b);
      case 'Gt':    return compileAndCombine(args, ([a, b]) => a > b);
      case 'GtE':   return compileAndCombine(args, ([a, b]) => a >= b);
      case 'Is':    return compileAndCombine(args, ([a, b]) => a === b);
      case 'IsNot': return compileAndCombine(args, ([a, b]) => a !== b);
      case 'In':    return compileAndCombine(args, ([a, b]) => Boolean(b?.includes(a)));
      case 'NotIn': return compileAndCombine(args, ([a, b]) => !b?.includes(a));
      case 'List':  return compileAndCombine(args, (values) => values);
      case 'Const': return constant(node[1] as CellValue);
      case 'Name': {
        const name = rawArgs[0] as keyof PredicateFormulaInput;
        if (GRIST_CONSTANTS[name]) { return constant(GRIST_CONSTANTS[name]); }

        let validNames: string[];
        switch (variant) {
          case 'acl': {
            validNames = ['newRec', 'rec', 'user'];
            break;
          }
          case 'dropdown-condition': {
            validNames = ['rec', 'choice', 'user'];
            break;
          }
        }
        if (!validNames.includes(name)) { throw new Error(`Unknown variable '${name}'`); }

        return (input) => input[name];
      }
      case 'Attr': {
        const attrName = rawArgs[1] as string;
        return compileAndCombine([args[0]], ([value]) => getAttr(value, attrName, args[0]));
      }
      case 'Comment': return compileNode(args[0]);
    }
    throw new Error(`Unknown node type '${node[0]}'`);
  }

  /**
   * Helper for operators: compile a list of nodes, then when evaluating, evaluate them all and
   * combine the array of results using the given combine() function.
   */
  function compileAndCombine(
    args: ParsedPredicateFormula[],
    combine: (values: any[]) => any
  ): IntermediatePredicateFormula {
    const compiled = args.map(compileNode);
    return (input: PredicateFormulaInput) => combine(compiled.map(c => c(input)));
  }

  const compiledPredicateFormula = compileNode(parsedPredicateFormula);
  return (input) => Boolean(compiledPredicateFormula(input));
}

function describeNode(node: ParsedPredicateFormula): string {
  if (node[0] === 'Name') {
    return node[1] as string;
  } else if (node[0] === 'Attr') {
    return describeNode(node[1] as ParsedPredicateFormula) + '.' + (node[2] as string);
  } else {
    return 'value';
  }
}

function getAttr(value: any, attrName: string, valueNode: ParsedPredicateFormula): any {
  if (value == null) {
    if (valueNode[0] === 'Name' && (valueNode[1] === 'rec' || valueNode[1] === 'newRec')) {
      // This code is recognized by GranularAccess to know when an ACL rule is row-specific.
      throw new ErrorWithCode('NEED_ROW_DATA', `Missing row data '${valueNode[1]}'`);
    }
    throw new Error(`No value for '${describeNode(valueNode)}'`);
  }
  return typeof value.get === 'function'
    ? decodeObject(value.get(attrName)) // InfoView
    : value[attrName];
}

/**
 * Predicate formula properties.
 */
export interface PredicateFormulaProperties {
  /**
   * List of column ids that are referenced by either `$` or `rec.` notation.
   */
  recColIds?: string[];
  /**
   * List of column ids that are referenced by `choice.` notation.
   *
   * Only applies to the `dropdown-condition` variant of predicate formulas,
   * and only for Reference and Reference List columns.
   */
  choiceColIds?: string[];
}

/**
 * Returns properties about a predicate `formula`.
 *
 * Properties include the list of column ids referenced in the formula.
 * Currently, this information is used for error validation; specifically, to
 * report when invalid column ids are referenced in ACL formulas and dropdown
 * conditions.
 */
export function getPredicateFormulaProperties(
  formula: ParsedPredicateFormula
): PredicateFormulaProperties {
  return {
    recColIds: [...getRecColIds(formula)],
    choiceColIds: [...getChoiceColIds(formula)],
  };
}

function isRecOrNewRec(formula: ParsedPredicateFormula|PrimitiveCellValue): boolean {
  return Array.isArray(formula) &&
    formula[0] === 'Name' &&
    (formula[1] === 'rec' || formula[1] === 'newRec');
}

function getRecColIds(formula: ParsedPredicateFormula): string[] {
  return [...new Set(collectColIds(formula, isRecOrNewRec))];
}

function isChoice(formula: ParsedPredicateFormula|PrimitiveCellValue): boolean {
  return Array.isArray(formula) && formula[0] === 'Name' && formula[1] === 'choice';
}

function getChoiceColIds(formula: ParsedPredicateFormula): string[] {
  return [...new Set(collectColIds(formula, isChoice))];
}

function collectColIds(
  formula: ParsedPredicateFormula,
  isIdentifierWithColIds: (formula: ParsedPredicateFormula|PrimitiveCellValue) => boolean,
): string[] {
  if (!Array.isArray(formula)) { throw new Error('expected a list'); }
  if (formula[0] === 'Attr' && isIdentifierWithColIds(formula[1])) {
    const colId = String(formula[2]);
    return [colId];
  }
  return formula.flatMap(el => Array.isArray(el) ? collectColIds(el, isIdentifierWithColIds) : []);
}