import { assertNotNull, assertNotUndefined } from '../utils/assert';
import { AstMatcher, ParseError, TokenList } from './types';

type TokenName = string;
type Token = [TokenName, RegExp];

const tokenList: Token[] = [
  ['fuzz', /^~(?:\d+(\.\d+)?|\.\d+)/],
  ['boost', /^\^[-+]?\d+(\.\d+)?/],
  ['quoted_lit', /^\s*"(?:[^"]|\\")+"/],
  ['lparen', /^\s*\(\s*/],
  ['rparen', /^\s*\)\s*/],
  ['and_op', /^\s*(?:&&|AND)\s+/],
  ['and_op', /^\s*,\s*/],
  ['or_op', /^\s*(?:\|\||OR)\s+/],
  ['not_op', /^\s*NOT(?:\s+|(?=\())/],
  ['not_op', /^\s*[!-]\s*/],
  ['space', /^\s+/],
  ['word', /^(?:\\[\s,()^~]|[^\s,()^~])+/],
  ['word', /^(?:\\[\s,()]|[^\s,()])+/],
];

export type ParseTerm = (term: string, fuzz: number, boost: number) => AstMatcher;

export type Range = [number, number];
export type TermContext = [Range, string];

export interface LexResult {
  tokenList: TokenList;
  termContexts: TermContext[];
  error: ParseError | null;
}

export function generateLexResult(searchStr: string, parseTerm: ParseTerm): LexResult {
  const opQueue: string[] = [];
  const groupNegate: boolean[] = [];

  let searchTerm: string | null = null;
  let boostFuzzStr = '';
  let localSearchStr: string = searchStr;
  let negate = false;
  let boost = 1;
  let fuzz = 0;
  let lparenCtr = 0;

  let termIndex = 0;
  let index = 0;

  const ret: LexResult = {
    tokenList: [],
    termContexts: [],
    error: null,
  };

  const beginTerm = (token: string) => {
    searchTerm = token;
    termIndex = index;
  };

  const endTerm = () => {
    if (searchTerm !== null) {
      // Push to stack.
      ret.tokenList.push(parseTerm(searchTerm, fuzz, boost));
      ret.termContexts.push([[termIndex, termIndex + searchTerm.length], searchTerm]);
      // Reset term and options data.
      boost = 1;
      fuzz = 0;
      searchTerm = null;
      boostFuzzStr = '';
      lparenCtr = 0;
    }

    if (negate) {
      ret.tokenList.push('not_op');
      negate = false;
    }
  };

  while (localSearchStr.length > 0) {
    for (const [tokenName, tokenRe] of tokenList) {
      const match = tokenRe.exec(localSearchStr);

      if (!match) {
        continue;
      }

      const token = match[0];
      const tokenIsBinaryOp = ['and_op', 'or_op'].indexOf(tokenName) !== -1;
      const tokenIsGroupStart = tokenName === 'rparen' && lparenCtr === 0;

      if (searchTerm !== null && (tokenIsBinaryOp || tokenIsGroupStart)) {
        endTerm();
      }

      switch (tokenName) {
        case 'and_op':
          while (opQueue[0] === 'and_op') {
            ret.tokenList.push(assertNotUndefined(opQueue.shift()));
          }
          opQueue.unshift('and_op');
          break;
        case 'or_op':
          while (opQueue[0] === 'and_op' || opQueue[0] === 'or_op') {
            ret.tokenList.push(assertNotUndefined(opQueue.shift()));
          }
          opQueue.unshift('or_op');
          break;
        case 'not_op':
          if (searchTerm) {
            // We're already inside a search term, so it does not apply, obv.
            searchTerm += token;
          } else {
            negate = !negate;
          }
          break;
        case 'lparen':
          if (searchTerm) {
            // If we are inside the search term, do not error out just yet;
            // instead, consider it as part of the search term, as a user convenience.
            searchTerm += token;
            lparenCtr += 1;
          } else {
            opQueue.unshift('lparen');
            groupNegate.push(negate);
            negate = false;
          }
          break;
        case 'rparen':
          if (lparenCtr > 0) {
            searchTerm = assertNotNull(searchTerm) + token;
            lparenCtr -= 1;
          } else {
            while (opQueue.length > 0) {
              const op = assertNotUndefined(opQueue.shift());
              if (op === 'lparen') {
                break;
              }
              ret.tokenList.push(op);
            }
            if (groupNegate.length > 0 && groupNegate.pop()) {
              ret.tokenList.push('not_op');
            }
          }
          break;
        case 'fuzz':
          if (searchTerm) {
            // For this and boost operations, we store the current match so far
            // to a temporary string in case this is actually inside the term.
            fuzz = parseFloat(token.substring(1));
            boostFuzzStr += token;
          } else {
            beginTerm(token);
          }
          break;
        case 'boost':
          if (searchTerm) {
            boost = parseFloat(token.substring(1));
            boostFuzzStr += token;
          } else {
            beginTerm(token);
          }
          break;
        case 'quoted_lit':
          if (searchTerm) {
            searchTerm += token;
          } else {
            beginTerm(token);
          }
          break;
        case 'word':
          if (searchTerm) {
            if (fuzz !== 0 || boost !== 1) {
              boost = 1;
              fuzz = 0;
              searchTerm += boostFuzzStr;
              boostFuzzStr = '';
            }
            searchTerm += token;
          } else {
            beginTerm(token);
          }
          break;
        default:
          // Append extra spaces within search terms.
          if (searchTerm) {
            searchTerm += token;
          }
      }

      // Truncate string and restart the token tests.
      localSearchStr = localSearchStr.substring(token.length);
      index += token.length;

      // Break since we have found a match.
      break;
    }
  }

  // Append final tokens to the stack.
  endTerm();

  if (opQueue.indexOf('rparen') !== -1 || opQueue.indexOf('lparen') !== -1) {
    ret.error = new ParseError('Mismatched parentheses.');
  }

  // Concatenate remaining operators to the token stack.
  ret.tokenList.push(...opQueue);

  return ret;
}

export function generateLexArray(searchStr: string, parseTerm: ParseTerm): TokenList {
  const ret = generateLexResult(searchStr, parseTerm);

  if (ret.error) {
    throw ret.error;
  }

  return ret.tokenList;
}
