import type { EntityConstructor, Options, Selector } from '@nymphjs/client';
import { splitn } from '@sciactive/splitn';

export type BareQueryHandler = (
  input: string,
  entityClass?: EntityConstructor,
  defaultFields?: string[],
) => Partial<Selector>;

export type QRefMap = {
  [k: string]: { class: EntityConstructor; defaultFields?: string[] };
};

export default function queryParser<
  T extends EntityConstructor = EntityConstructor,
>({
  query,
  entityClass,
  defaultFields = ['name'],
  qrefMap = {},
  bareHandler = (input, _class, defaultFields = ['name']) => {
    if (!input.match(/[_%]/)) {
      input = `%${input}%`;
    }
    if (defaultFields.length) {
      return {
        type: '|',
        ilike: defaultFields.map((field) => [field, input]) as [
          string,
          string,
        ][],
      };
    }
    return {};
  },
}: {
  query: string;
  entityClass: T;
  defaultFields?: string[];
  qrefMap?: QRefMap;
  bareHandler?: BareQueryHandler;
}): [Options<T>, ...Selector[]] {
  const options: Options<T> = { class: entityClass };
  return [
    options,
    ...selectorsParser({
      query,
      entityClass,
      type: '&',
      defaultFields,
      qrefMap,
      options,
      bareHandler,
    }),
  ];
}

function selectorsParser({
  query,
  entityClass,
  type,
  defaultFields,
  qrefMap,
  options,
  bareHandler,
}: {
  query: string;
  entityClass: EntityConstructor;
  type: '&' | '|' | '!&' | '!|';
  defaultFields: string[];
  qrefMap: QRefMap;
  options?: Options;
  bareHandler: BareQueryHandler;
}): Selector[] {
  const selector: Selector = { type };
  let curQuery = query;

  // Look for top level selectors inside parens.
  const subSelectorPairs: [number, number][] = [];
  let inQuote = false;
  let nesting = 0;
  let currentStart: number | null = null;
  for (let i = 0; i < curQuery.length; i++) {
    if (curQuery[i] === '"') {
      if (!inQuote) {
        inQuote = true;
      } else if (curQuery[i - 1] !== '\\') {
        inQuote = false;
      }
    } else if (inQuote) {
      continue;
    } else if (curQuery[i] === '(') {
      if (currentStart == null) {
        currentStart = i;
      } else {
        nesting++;
      }
    } else if (curQuery[i] === ')') {
      if (nesting === 0) {
        if (currentStart == null) {
          // mismatched parens
        } else {
          subSelectorPairs.push([currentStart, i + 1]);
          currentStart = null;
        }
      } else {
        nesting--;
      }
    }
  }

  if (subSelectorPairs.length) {
    selector.selector = [];
    // Reverse order so we can take them out back to front.
    subSelectorPairs.reverse();
    for (let pair of subSelectorPairs) {
      // Slice out in between the parens.
      let selectorQuery = curQuery.slice(pair[0] + 1, pair[1] - 1);
      // Cut the selector out of the query.
      curQuery = curQuery.slice(0, pair[0]) + curQuery.slice(pair[1]);

      // First char inside parens determines type of selector.
      let type: '&' | '|' | '!&' | '!|' = '&';
      if (selectorQuery.startsWith('&')) {
        selectorQuery = selectorQuery.slice(1);
      } else if (selectorQuery.startsWith('!&')) {
        type = '!&';
        selectorQuery = selectorQuery.slice(2);
      } else if (selectorQuery.startsWith('|')) {
        type = '|';
        selectorQuery = selectorQuery.slice(1);
      } else if (selectorQuery.startsWith('!|')) {
        type = '!|';
        selectorQuery = selectorQuery.slice(2);
      } else if (selectorQuery.startsWith('!')) {
        type = '!&';
        selectorQuery = selectorQuery.slice(1);
      }
      selector.selector.push(
        ...selectorsParser({
          query: selectorQuery,
          entityClass,
          type,
          defaultFields,
          qrefMap,
          bareHandler,
        }),
      );
    }
  }

  curQuery = selectorParser({
    query: curQuery,
    selector,
    qrefMap,
    bareHandler,
  });

  if (options) {
    const limitRegex = /(?: |^)limit:(\d+)(?= |$)/;
    const limitMatch = curQuery.match(limitRegex);
    if (limitMatch) {
      options.limit = Number(limitMatch[1]);
    }
    curQuery = curQuery.replace(limitRegex, '');

    const offsetRegex = /(?: |^)offset:(\d+)(?= |$)/;
    const offsetMatch = curQuery.match(offsetRegex);
    if (offsetMatch) {
      options.offset = Number(offsetMatch[1]);
    }
    curQuery = curQuery.replace(offsetRegex, '');

    // JavaScript variable names are ridiculously infeasable to check
    // thoroughly, so this is a "best attempt".
    const sortRegex =
      /(?: |^)sort:([_$a-zA-Z\xA0-\uFFFF][_$a-zA-Z0-9\xA0-\uFFFF]*)(?= |$)/;
    const sortMatch = curQuery.match(sortRegex);
    if (sortMatch) {
      options.sort = sortMatch[1];
    }
    curQuery = curQuery.replace(sortRegex, '');

    const reverseRegex = /(?: |^)reverse:(true|false|1|0)(?= |$)/;
    const reverseMatch = curQuery.match(reverseRegex);
    if (reverseMatch) {
      options.reverse = reverseMatch[1] === 'true' || reverseMatch[1] === '1';
    }
    curQuery = curQuery.replace(reverseRegex, '');
  }

  curQuery = curQuery.trim();

  if (curQuery.length) {
    const bareSelector = bareHandler(curQuery, entityClass, defaultFields);

    if (Object.keys(bareSelector).length) {
      return [
        ...(Object.keys(selector).length > 1 ? [selector] : []),
        {
          type: '|',
          ...bareSelector,
        },
      ];
    }
  }

  if (
    'selector' in selector &&
    (selector.selector as Selector[]).length === 1 &&
    Object.keys(selector).length === 2 &&
    (selector.type === '&' || selector.type === '|')
  ) {
    // There is only one subselector, and this selector is a positive match
    // type, so just return it as the selector.
    return selector.selector as Selector[];
  }

  return [selector];
}

function selectorParser({
  query,
  selector,
  qrefMap,
  bareHandler,
}: {
  query: string;
  selector: Selector;
  qrefMap: QRefMap;
  bareHandler: BareQueryHandler;
}): string {
  let curQuery = query;

  // eg. user<{User name="Hunter"}> or user!<{User name="Hunter"}>
  const qrefRegex = /(?: |^)(\w+)!?<\{(\w+) (.*?[^\\])\}>(?= |$)/g;
  const qrefMatch = curQuery.match(qrefRegex);
  if (qrefMatch) {
    selector.qref = [];
    selector['!qref'] = [];
    for (let match of qrefMatch) {
      try {
        let [name, value] = splitn(match.trim().slice(0, -1), '<', 2);
        value = unQuoteCurlies(value.slice(1, -1));
        let [className, qrefQuery] = splitn(value, ' ', 2);
        const EntityClass = qrefMap[className].class;
        if (EntityClass == null) {
          continue;
        }
        const qref = queryParser({
          query: qrefQuery,
          entityClass: EntityClass,
          defaultFields: qrefMap[className].defaultFields,
          qrefMap,
          bareHandler,
        });
        if (name.endsWith('!')) {
          selector['!qref'].push([name.slice(0, -1), qref]);
        } else {
          selector.qref.push([name, qref]);
        }
      } catch (e: any) {
        continue;
      }
    }
    if (!selector.qref.length) {
      delete selector.qref;
    }
    if (!selector['!qref'].length) {
      delete selector['!qref'];
    }
  }
  curQuery = curQuery.replace(qrefRegex, '');

  // eg. name=Marty or name="Marty McFly" or enabled=true or someArray=[1,2]
  const equalRegex = /(?: |^)(\w+)!?=(""|".*?[^\\]"|[^ ]+)(?= |$)/g;
  const equalMatch = curQuery.match(equalRegex);
  if (equalMatch) {
    selector.equal = [];
    selector['!equal'] = [];
    for (let match of equalMatch) {
      try {
        let [name, value] = splitn(match.trim(), '=', 2);
        try {
          if (name.endsWith('!')) {
            selector['!equal'].push([name.slice(0, -1), JSON.parse(value)]);
          } else {
            selector.equal.push([name, JSON.parse(value)]);
          }
        } catch (e: any) {
          if (name.endsWith('!')) {
            selector['!equal'].push([name.slice(0, -1), unQuoteString(value)]);
          } else {
            selector.equal.push([name, unQuoteString(value)]);
          }
        }
      } catch (e: any) {
        continue;
      }
    }
    if (!selector.equal.length) {
      delete selector.equal;
    }
    if (!selector['!equal'].length) {
      delete selector['!equal'];
    }
  }
  curQuery = curQuery.replace(equalRegex, '');

  // eg. user<{790274347f9b3a018c2cedee}> or user!<{790274347f9b3a018c2cedee}>
  const refRegex = /(?: |^)(\w+)!?<\{([0-9a-f]{24})\}>(?= |$)/g;
  const refMatch = curQuery.match(refRegex);
  if (refMatch) {
    selector.ref = [];
    selector['!ref'] = [];
    for (let match of refMatch) {
      try {
        let [name, value] = splitn(match.trim().slice(0, -1), '<', 2);
        if (name.endsWith('!')) {
          selector['!ref'].push([name.slice(0, -1), value.slice(1, -1)]);
        } else {
          selector.ref.push([name, value.slice(1, -1)]);
        }
      } catch (e: any) {
        continue;
      }
    }
    if (!selector.ref.length) {
      delete selector.ref;
    }
    if (!selector['!ref'].length) {
      delete selector['!ref'];
    }
  }
  curQuery = curQuery.replace(refRegex, '');

  // eg. someArrayOfNumbers<10> or someObject!<"some string">
  const containRegex = /(?: |^)(\w+)!?(<.*?[^\\]>)(?= |$)/g;
  const containMatch = curQuery.match(containRegex);
  if (containMatch) {
    selector.contain = [];
    selector['!contain'] = [];
    for (let match of containMatch) {
      try {
        let [name, value] = splitn(match.trim().slice(0, -1), '<', 2);
        try {
          if (name.endsWith('!')) {
            selector['!contain'].push([
              name.slice(0, -1),
              JSON.parse(unQuoteAngles(value)),
            ]);
          } else {
            selector.contain.push([name, JSON.parse(unQuoteAngles(value))]);
          }
        } catch (e: any) {
          if (name.endsWith('!')) {
            selector['!contain'].push([
              name.slice(0, -1),
              unQuoteAngles(value),
            ]);
          } else {
            selector.contain.push([name, unQuoteAngles(value)]);
          }
        }
      } catch (e: any) {
        continue;
      }
    }
    if (!selector.contain.length) {
      delete selector.contain;
    }
    if (!selector['!contain'].length) {
      delete selector['!contain'];
    }
  }
  curQuery = curQuery.replace(containRegex, '');

  // eg. name~/Hunter/ or name!~/hunter/i
  const posixRegex = /(?: |^)(\w+)!?~(\/\/|\/.*?[^\\]\/)i?(?= |$)/g;
  const posixMatch = curQuery.match(posixRegex);
  if (posixMatch) {
    selector.match = [];
    selector['!match'] = [];
    selector.imatch = [];
    selector['!imatch'] = [];
    for (let match of posixMatch) {
      try {
        let [name, value] = splitn(match.trim(), '~', 2);
        if (name.endsWith('!')) {
          if (value.endsWith('i')) {
            selector['!imatch'].push([
              name.slice(0, -1),
              value.replace(/^\/|\/i$/g, ''),
            ]);
          } else {
            selector['!match'].push([
              name.slice(0, -1),
              value.replace(/^\/|\/$/g, ''),
            ]);
          }
        } else {
          if (value.endsWith('i')) {
            selector.imatch.push([name, value.replace(/^\/|\/i$/g, '')]);
          } else {
            selector.match.push([name, value.replace(/^\/|\/$/g, '')]);
          }
        }
      } catch (e: any) {
        continue;
      }
    }
    if (!selector.match.length) {
      delete selector.match;
    }
    if (!selector['!match'].length) {
      delete selector['!match'];
    }
    if (!selector.imatch.length) {
      delete selector.imatch;
    }
    if (!selector['!imatch'].length) {
      delete selector['!imatch'];
    }
  }
  curQuery = curQuery.replace(posixRegex, '');

  // eg. name~Hunter or name!~"hunter"i
  const likeRegex = /(?: |^)(\w+)!?~(""i?|".*?[^\\]"i?|[^ ]+)(?= |$)/g;
  const likeMatch = curQuery.match(likeRegex);
  if (likeMatch) {
    selector.like = [];
    selector['!like'] = [];
    selector.ilike = [];
    selector['!ilike'] = [];
    for (let match of likeMatch) {
      try {
        let [name, value] = splitn(match.trim(), '~', 2);
        if (name.endsWith('!')) {
          if (value.endsWith('"i')) {
            selector['!ilike'].push([
              name.slice(0, -1),
              unQuoteString(value.slice(0, -1)),
            ]);
          } else {
            selector['!like'].push([name.slice(0, -1), unQuoteString(value)]);
          }
        } else {
          if (value.endsWith('"i')) {
            selector.ilike.push([name, unQuoteString(value.slice(0, -1))]);
          } else {
            selector.like.push([name, unQuoteString(value)]);
          }
        }
      } catch (e: any) {
        continue;
      }
    }
    if (!selector.like.length) {
      delete selector.like;
    }
    if (!selector['!like'].length) {
      delete selector['!like'];
    }
    if (!selector.ilike.length) {
      delete selector.ilike;
    }
    if (!selector['!ilike'].length) {
      delete selector['!ilike'];
    }
  }
  curQuery = curQuery.replace(likeRegex, '');

  // eg. {790274347f9b3a018c2cedee} or {!790274347f9b3a018c2cedee}
  const guidRegex = /(?: |^)\{!?([0-9a-f]{24})\}(?= |$)/g;
  const guidMatch = curQuery.match(guidRegex);
  if (guidMatch) {
    selector.guid = [];
    selector['!guid'] = [];
    for (let match of guidMatch) {
      try {
        let guid = match.trim().replace(/^\{|\}$/g, '');
        if (guid.startsWith('!')) {
          selector['!guid'].push(guid.slice(1));
        } else {
          selector.guid.push(guid);
        }
      } catch (e: any) {
        continue;
      }
    }
    if (!selector.guid.length) {
      delete selector.guid;
    }
    if (!selector['!guid'].length) {
      delete selector['!guid'];
    }
  }
  curQuery = curQuery.replace(guidRegex, '');

  // eg. [enabled] or [!defaultPrimaryGroup]
  const truthyRegex = /(?: |^)\[(!?\w+)\](?= |$)/g;
  const truthyMatch = curQuery.match(truthyRegex);
  if (truthyMatch) {
    selector.truthy = [];
    selector['!truthy'] = [];
    for (let match of truthyMatch) {
      try {
        let name = match.trim().replace(/^\[|\]$/g, '');
        if (name.startsWith('!')) {
          selector['!truthy'].push(name.slice(1));
        } else {
          selector.truthy.push(name);
        }
      } catch (e: any) {
        continue;
      }
    }
    if (!selector.truthy.length) {
      delete selector.truthy;
    }
    if (!selector['!truthy'].length) {
      delete selector['!truthy'];
    }
  }
  curQuery = curQuery.replace(truthyRegex, '');

  // eg. <archived> or <!archived>
  const tagRegex = /(?: |^)<(!?\w+)>(?= |$)/g;
  const tagMatch = curQuery.match(tagRegex);
  if (tagMatch) {
    selector.tag = [];
    selector['!tag'] = [];
    for (let match of tagMatch) {
      try {
        let name = match.trim().replace(/^<|>$/g, '');
        if (name.startsWith('!')) {
          selector['!tag'].push(name.slice(1));
        } else {
          selector.tag.push(name);
        }
      } catch (e: any) {
        continue;
      }
    }
    if (!selector.tag.length) {
      delete selector.tag;
    }
    if (!selector['!tag'].length) {
      delete selector['!tag'];
    }
  }
  curQuery = curQuery.replace(tagRegex, '');

  // eg. cdate>15
  const gtRegex = /(?: |^)(\w+)>(-?\d+(?:\.\d+)?)(?= |$)/g;
  const gtMatch = curQuery.match(gtRegex);
  if (gtMatch) {
    selector.gt = [];
    for (let match of gtMatch) {
      try {
        let [name, value] = splitn(match.trim(), '>', 2);
        selector.gt.push([name, Number(value)]);
      } catch (e: any) {
        continue;
      }
    }
    if (!selector.gt.length) {
      delete selector.gt;
    }
  }
  curQuery = curQuery.replace(gtRegex, '');

  // eg. cdate>yesterday or cdate>"2 days ago"
  const gtRelativeRegex = /(?: |^)(\w+)>(\w+|"[^"]+")(?= |$)/g;
  const gtRelativeMatch = curQuery.match(gtRelativeRegex);
  if (gtRelativeMatch) {
    if (selector.gt == null) {
      selector.gt = [];
    }
    for (let match of gtRelativeMatch) {
      try {
        let [name, value] = splitn(match.trim(), '>', 2);
        (selector.gt as [string, null, string][]).push([
          name,
          null,
          value.replace(/"/g, ''),
        ]);
      } catch (e: any) {
        continue;
      }
    }
    if (!selector.gt.length) {
      delete selector.gt;
    }
  }
  curQuery = curQuery.replace(gtRelativeRegex, '');

  // eg. cdate>=15
  const gteRegex = /(?: |^)(\w+)>=(-?\d+(?:\.\d+)?)(?= |$)/g;
  const gteMatch = curQuery.match(gteRegex);
  if (gteMatch) {
    selector.gte = [];
    for (let match of gteMatch) {
      try {
        let [name, value] = splitn(match.trim(), '>=', 2);
        selector.gte.push([name, Number(value)]);
      } catch (e: any) {
        continue;
      }
    }
    if (!selector.gte.length) {
      delete selector.gte;
    }
  }
  curQuery = curQuery.replace(gteRegex, '');

  // eg. cdate>=yesterday or cdate>="2 days ago"
  const gteRelativeRegex = /(?: |^)(\w+)>=(\w+|"[^"]+")(?= |$)/g;
  const gteRelativeMatch = curQuery.match(gteRelativeRegex);
  if (gteRelativeMatch) {
    if (selector.gte == null) {
      selector.gte = [];
    }
    for (let match of gteRelativeMatch) {
      try {
        let [name, value] = splitn(match.trim(), '>=', 2);
        (selector.gte as [string, null, string][]).push([
          name,
          null,
          value.replace(/"/g, ''),
        ]);
      } catch (e: any) {
        continue;
      }
    }
    if (!selector.gte.length) {
      delete selector.gte;
    }
  }
  curQuery = curQuery.replace(gteRelativeRegex, '');

  // eg. cdate<15
  const ltRegex = /(?: |^)(\w+)<(-?\d+(?:\.\d+)?)(?= |$)/g;
  const ltMatch = curQuery.match(ltRegex);
  if (ltMatch) {
    selector.lt = [];
    for (let match of ltMatch) {
      try {
        let [name, value] = splitn(match.trim(), '<', 2);
        selector.lt.push([name, Number(value)]);
      } catch (e: any) {
        continue;
      }
    }
    if (!selector.lt.length) {
      delete selector.lt;
    }
  }
  curQuery = curQuery.replace(ltRegex, '');

  // eg. cdate<yesterday or cdate<"2 days ago"
  const ltRelativeRegex = /(?: |^)(\w+)<(\w+|"[^"]+")(?= |$)/g;
  const ltRelativeMatch = curQuery.match(ltRelativeRegex);
  if (ltRelativeMatch) {
    if (selector.lt == null) {
      selector.lt = [];
    }
    for (let match of ltRelativeMatch) {
      try {
        let [name, value] = splitn(match.trim(), '<', 2);
        (selector.lt as [string, null, string][]).push([
          name,
          null,
          value.replace(/"/g, ''),
        ]);
      } catch (e: any) {
        continue;
      }
    }
    if (!selector.lt.length) {
      delete selector.lt;
    }
  }
  curQuery = curQuery.replace(ltRelativeRegex, '');

  // eg. cdate<=15
  const lteRegex = /(?: |^)(\w+)<=(-?\d+(?:\.\d+)?)(?= |$)/g;
  const lteMatch = curQuery.match(lteRegex);
  if (lteMatch) {
    selector.lte = [];
    for (let match of lteMatch) {
      try {
        let [name, value] = splitn(match.trim(), '<=', 2);
        selector.lte.push([name, Number(value)]);
      } catch (e: any) {
        continue;
      }
    }
    if (!selector.lte.length) {
      delete selector.lte;
    }
  }
  curQuery = curQuery.replace(lteRegex, '');

  // eg. cdate<=yesterday or cdate<="2 days ago"
  const lteRelativeRegex = /(?: |^)(\w+)<=(\w+|"[^"]+")(?= |$)/g;
  const lteRelativeMatch = curQuery.match(lteRelativeRegex);
  if (lteRelativeMatch) {
    if (selector.lte == null) {
      selector.lte = [];
    }
    for (let match of lteRelativeMatch) {
      try {
        let [name, value] = splitn(match.trim(), '<=', 2);
        (selector.lte as [string, null, string][]).push([
          name,
          null,
          value.replace(/"/g, ''),
        ]);
      } catch (e: any) {
        continue;
      }
    }
    if (!selector.lte.length) {
      delete selector.lte;
    }
  }
  curQuery = curQuery.replace(lteRelativeRegex, '');

  return curQuery.trim();
}

function unQuoteString(input: string): string {
  if (input.match(/^".*?[^\\]"$/)) {
    return input.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\');
  }
  return input;
}

function unQuoteAngles(input: string): string {
  return input.replace(/\\</g, '<').replace(/\\>/g, '>').replace(/\\\\/g, '\\');
}

function unQuoteCurlies(input: string): string {
  return input
    .replace(/\\\{/g, '{')
    .replace(/\\\}/g, '}')
    .replace(/\\\\/g, '\\');
}
