import fs from 'node:fs';
import { difference } from 'lodash-es';
import ReadLines from 'n-readlines';
import strtotime from 'locutus/php/datetime/strtotime.js';
import { Tokenizer } from '@sciactive/tokenizer';

import type Nymph from '../Nymph.js';
import type { Selector, Options, FormattedSelector } from '../Nymph.types.js';
import type { EntityInstanceType, EntityObjectType } from '../Entity.js';
import type {
  EntityConstructor,
  EntityData,
  EntityInterface,
  EntityReference,
  SerializedEntityData,
} from '../Entity.types.js';
import {
  InvalidParametersError,
  UnableToConnectError,
} from '../errors/index.js';
import { xor } from '../utils.js';

// from: https://stackoverflow.com/a/6969486/664915
function escapeRegExp(string: string): string {
  return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
}

/**
 * A Nymph database driver.
 */
export default abstract class NymphDriver {
  protected nymph: Nymph = new Proxy({} as Nymph, {
    get() {
      throw new Error(
        'Attempted access of Nymph instance before driver initialization!',
      );
    },
  });

  protected tokenizer = new Tokenizer();

  /**
   * A cache to make entity retrieval faster.
   */
  protected entityCache: {
    [k: string]: {
      cdate: number;
      mdate: number;
      tags: string[];
      data: EntityData;
      sdata: SerializedEntityData;
    };
  } = {};
  /**
   * A counter for the entity cache to determine the most accessed entities.
   */
  protected entityCount: { [k: string]: number } = {};
  /**
   * Protect against infinite loops.
   */
  private putDataCounter = 0;

  /**
   * This is used internally by Nymph. Don't call it yourself.
   *
   * @returns A clone of this instance.
   */
  abstract clone(): NymphDriver;

  /**
   * Connect to the data store.
   */
  abstract connect(): Promise<boolean>;

  abstract isConnected(): boolean;
  protected abstract internalTransaction(name: string): Promise<any>;
  abstract startTransaction(name: string): Promise<Nymph>;
  abstract commit(name: string): Promise<boolean>;
  abstract rollback(name: string): Promise<boolean>;
  abstract inTransaction(): Promise<boolean>;
  abstract deleteEntityByID(
    guid: string,
    classConstructor: EntityConstructor,
  ): Promise<boolean>;
  abstract deleteEntityByID(guid: string, className?: string): Promise<boolean>;
  abstract deleteUID(name: string): Promise<boolean>;

  /**
   * Disconnect from the data store.
   */
  abstract disconnect(): Promise<boolean>;

  abstract needsMigration(): Promise<
    'json' | 'tokens' | 'tilmeldColumns' | false
  >;
  abstract liveMigration(
    migrationType: 'tokenTables' | 'tilmeldColumns' | 'tilmeldRemoveOldRows',
  ): Promise<void>;

  abstract getEtypes(): Promise<string[]>;
  abstract getIndexes(etype: string): Promise<
    {
      scope: 'data' | 'references' | 'tokens';
      name: string;
      property: string;
    }[]
  >;
  abstract addIndex(
    etype: string,
    definition: {
      scope: 'data' | 'references' | 'tokens';
      name: string;
      property: string;
    },
  ): Promise<boolean>;
  abstract deleteIndex(
    etype: string,
    scope: 'data' | 'references' | 'tokens',
    name: string,
  ): Promise<boolean>;

  abstract exportDataIterator(): AsyncGenerator<
    { type: 'comment' | 'uid' | 'entity'; content: string },
    void,
    undefined | false
  >;
  abstract getEntities<T extends EntityConstructor = EntityConstructor>(
    options: Options<T> & { return: 'count' },
    ...selectors: Selector[]
  ): Promise<number>;
  abstract getEntities<T extends EntityConstructor = EntityConstructor>(
    options: Options<T> & { return: 'guid' },
    ...selectors: Selector[]
  ): Promise<string[]>;
  abstract getEntities<T extends EntityConstructor = EntityConstructor>(
    options: Options<T> & { return: 'object' },
    ...selectors: Selector[]
  ): Promise<EntityObjectType<T>[]>;
  abstract getEntities<T extends EntityConstructor = EntityConstructor>(
    options?: Options<T>,
    ...selectors: Selector[]
  ): Promise<EntityInstanceType<T>[]>;
  abstract getEntities<T extends EntityConstructor = EntityConstructor>(
    options?: Options<T>,
    ...selectors: Selector[]
  ): Promise<
    EntityInstanceType<T>[] | EntityObjectType<T>[] | string[] | number
  >;
  abstract getUID(name: string): Promise<number | null>;
  abstract importEntity(entity: {
    guid: string;
    cdate: number;
    mdate: number;
    tags: string[];
    sdata: SerializedEntityData;
    etype: string;
  }): Promise<void>;
  abstract importEntityTokens(entity: {
    guid: string;
    cdate: number;
    mdate: number;
    tags: string[];
    sdata: SerializedEntityData;
    etype: string;
  }): Promise<void>;
  abstract importEntityTilmeldAC(entity: {
    guid: string;
    cdate: number;
    mdate: number;
    tags: string[];
    sdata: SerializedEntityData;
    etype: string;
  }): Promise<void>;
  abstract importUID(uid: { name: string; value: number }): Promise<void>;
  abstract newUID(name: string): Promise<number | null>;
  abstract renameUID(oldName: string, newName: string): Promise<boolean>;
  abstract saveEntity(entity: EntityInterface): Promise<boolean>;
  abstract setUID(name: string, value: number): Promise<boolean>;

  /**
   * Initialize the Nymph driver.
   *
   * This is meant to be called internally by Nymph. Don't call this directly.
   *
   * @param nymph The Nymph instance.
   */
  public init(nymph: Nymph) {
    this.nymph = nymph;
  }

  protected posixRegexMatch(
    pattern: string,
    subject: string,
    caseInsensitive = false,
  ) {
    const posixClasses = [
      // '[[:<:]]',
      // '[[:>:]]',
      '[:alnum:]',
      '[:alpha:]',
      '[:ascii:]',
      '[:blank:]',
      '[:cntrl:]',
      '[:digit:]',
      '[:graph:]',
      '[:lower:]',
      '[:print:]',
      '[:punct:]',
      '[:space:]',
      '[:upper:]',
      '[:word:]',
      '[:xdigit:]',
    ];
    const jsClasses = [
      // '\b(?=\w)',
      // '(?<=\w)\b',
      '[A-Za-z0-9]',
      '[A-Za-z]',
      '[\x00-\x7F]',
      '[ \t]', // '\s',
      '[\x00-\x1F\x7F]', // '[\000\001\002\003\004\005\006\007\008\009\010\011\012\013\014'.
      //   '\015\016\017\018\019\020\021\022\023\024\025\026\027\028\029'.
      //   '\030\031\032\033\034\035\036\037\177]',
      '[0-9]', // '\d',
      '[\x21-\x7E]', // '[A-Za-z0-9!"#$%&\'()*+,\-./:;<=>?@[\\\]^_`{|}\~]',
      '[a-z]',
      '[\x20-\x7E]', // '[A-Za-z0-9!"#$%&\'()*+,\-./:;<=>?@[\\\]^_`{|}\~]',
      '[!"#$%&\'()*+,-./:;<=>?@[\\\\\\]^_‘{|}~]', // '[!"#$%&\'()*+,\-./:;<=>?@[\\\]^_`{|}\~]',
      '[ \t\r\n\v\f]', // '[\t\n\x0B\f\r ]',
      '[A-Z]',
      '[A-Za-z0-9_]',
      '[0-9A-Fa-f]',
    ];

    let newPattern = pattern;
    for (let i = 0; i < posixClasses.length; i++) {
      newPattern = newPattern.replace(posixClasses[i], () => jsClasses[i]);
    }

    let re = new RegExp(newPattern, caseInsensitive ? 'mi' : 'm');

    return re.test(subject);
  }

  public checkIndexName(name: string) {
    if (name.match(/[^a-z0-9_]/)) {
      throw new Error(
        'Index names can only contain lowercase letters, numbers, and underscores.',
      );
    }
  }

  public async export(filename: string) {
    try {
      const fhandle = fs.openSync(filename, 'w');
      if (!fhandle) {
        throw new InvalidParametersError('Provided filename is not writeable.');
      }
      for await (let entry of this.exportDataIterator()) {
        fs.writeSync(fhandle, entry.content);
      }
      fs.closeSync(fhandle);
    } catch (e: any) {
      return false;
    }
    return true;
  }

  public async exportPrint() {
    for await (let entry of this.exportDataIterator()) {
      console.log(entry.content);
    }
    return true;
  }

  public async importDataIterator(
    lines: Iterable<string>,
    transaction?: boolean,
  ) {
    const first = lines[Symbol.iterator]().next();

    if (first.done || first.value !== '#nex2') {
      throw new Error('Tried to import a file that is not a NEX v2 file.');
    }

    if (transaction) {
      await this.internalTransaction('nymph-import');
    }
    try {
      let guid: string | null = null;
      let sdata: SerializedEntityData = {};
      let tags: string[] = [];
      let etype = '__undefined';
      for (let line of lines) {
        if (line.match(/^\s*#/)) {
          continue;
        }
        const entityMatch = line.match(
          /^\s*{([0-9A-Fa-f]+)}<([-\w_]+)>\[([^\]]*)\]\s*$/,
        );
        const propMatch = line.match(/^\s*([^=]+)\s*=\s*(.*\S)\s*$/);
        const uidMatch = line.match(/^\s*<([^>]+)>\[(\d+)\]\s*$/);
        if (uidMatch) {
          // Add the UID.
          await this.importUID({
            name: uidMatch[1],
            value: Number(uidMatch[2]),
          });
        } else if (entityMatch) {
          // Save the current entity.
          if (guid) {
            const cdate = Number(JSON.parse(sdata.cdate));
            delete sdata.cdate;
            const mdate = Number(JSON.parse(sdata.mdate));
            delete sdata.mdate;
            await this.importEntity({ guid, cdate, mdate, tags, sdata, etype });
            guid = null;
            tags = [];
            sdata = {};
            etype = '__undefined';
          }
          // Record the new entity's info.
          guid = entityMatch[1];
          etype = entityMatch[2];
          tags = entityMatch[3].split(',');
        } else if (propMatch) {
          // Add the variable to the new entity.
          if (guid) {
            sdata[propMatch[1]] = propMatch[2];
          }
        }
        // Clear the entity cache.
        this.entityCache = {};
      }
      // Save the last entity.
      if (guid) {
        const cdate = Number(JSON.parse(sdata.cdate));
        delete sdata.cdate;
        const mdate = Number(JSON.parse(sdata.mdate));
        delete sdata.mdate;
        await this.importEntity({ guid, cdate, mdate, tags, sdata, etype });
      }
      if (transaction) {
        await this.commit('nymph-import');
      }
    } catch (e: any) {
      await this.rollback('nymph-import');
      throw e;
    }
    return true;
  }

  public async importData(text: string, transaction?: boolean) {
    return await this.importDataIterator(text.split('\n'), transaction);
  }

  public async import(filename: string, transaction?: boolean) {
    let rl: ReadLines;
    try {
      rl = new ReadLines(filename);
    } catch (e: any) {
      throw new InvalidParametersError('Provided filename is unreadable.');
    }

    const lines = {
      *[Symbol.iterator]() {
        let line: null | Buffer;
        while ((line = rl.next())) {
          yield line.toString('utf8');
        }
      },
    };

    return await this.importDataIterator(lines, transaction);
  }

  public checkData(
    etype: string,
    data: EntityData,
    sdata: SerializedEntityData,
    selectors: Selector[],
    guid: string | null = null,
    tags: string[] | null = null,
  ) {
    try {
      const EntityClass = this.nymph.getEntityClassByEtype(etype);
      const formattedSelectors = this.formatSelectors(selectors).selectors;

      for (const curSelector of formattedSelectors) {
        const type = curSelector.type;
        const typeIsNot = type === '!&' || type === '!|';
        const typeIsOr = type === '|' || type === '!|';
        let pass = !typeIsOr;
        for (const k in curSelector) {
          const key = k as keyof Selector;
          const value = curSelector[key];

          if (key === 'type' || value == null) {
            continue;
          }

          const clauseNot = key.substring(0, 1) === '!';
          if (key === 'selector' || key === '!selector') {
            const tmpArr = (
              Array.isArray(value) ? value : [value]
            ) as Selector[];
            pass = xor(
              this.checkData(etype, data, sdata, tmpArr, guid, tags),
              xor(typeIsNot, clauseNot),
            );
          } else {
            if (key === 'qref' || key === '!qref') {
              throw new Error("Can't use checkData on qref clauses.");
            } else {
              // Check if it doesn't pass any for &, check if it passes any for |.
              for (const curValue of value) {
                if (curValue == null) {
                  continue;
                }

                const propName = (curValue as string[])[0];

                // Unserialize the data for this variable.
                if (propName in sdata) {
                  data[propName] = JSON.parse(sdata[propName]);
                  delete sdata[propName];
                }

                switch (key) {
                  case 'guid':
                  case '!guid':
                    pass = xor(guid == propName, xor(typeIsNot, clauseNot));
                    break;
                  case 'tag':
                  case '!tag':
                    pass = xor(
                      (tags ?? []).indexOf(propName) !== -1,
                      xor(typeIsNot, clauseNot),
                    );
                    break;
                  case 'defined':
                  case '!defined':
                    pass = xor(propName in data, xor(typeIsNot, clauseNot));
                    break;
                  case 'truthy':
                  case '!truthy':
                    pass = xor(
                      propName in data && data[propName],
                      xor(typeIsNot, clauseNot),
                    );
                    break;
                  case 'ref':
                  case '!ref':
                    const testRefValue = (curValue as [string, any])[1] as any;
                    pass = xor(
                      propName in data &&
                        this.entityReferenceSearch(
                          data[propName],
                          testRefValue,
                        ),
                      xor(typeIsNot, clauseNot),
                    );
                    break;
                  case 'equal':
                  case '!equal':
                    const testEqualValue = (
                      curValue as [string, any]
                    )[1] as any;
                    pass = xor(
                      propName in data &&
                        JSON.stringify(data[propName]) ===
                          JSON.stringify(testEqualValue),
                      xor(typeIsNot, clauseNot),
                    );
                    break;
                  case 'contain':
                  case '!contain':
                    const testContainValue = (
                      curValue as [string, any]
                    )[1] as any;
                    pass = xor(
                      propName in data &&
                        JSON.stringify(data[propName]).indexOf(
                          JSON.stringify(testContainValue),
                        ) !== -1,
                      xor(typeIsNot, clauseNot),
                    );
                    break;
                  case 'like':
                  case '!like':
                    const testLikeValue = (
                      curValue as [string, string]
                    )[1] as string;
                    pass = xor(
                      propName in data &&
                        new RegExp(
                          '^' +
                            escapeRegExp(testLikeValue)
                              .replace('%', () => '.*')
                              .replace('_', () => '.') +
                            '$',
                        ).test(data[propName]),
                      xor(typeIsNot, clauseNot),
                    );
                    break;
                  case 'ilike':
                  case '!ilike':
                    const testILikeValue = (
                      curValue as [string, string]
                    )[1] as string;
                    pass = xor(
                      propName in data &&
                        new RegExp(
                          '^' +
                            escapeRegExp(testILikeValue)
                              .replace('%', () => '.*')
                              .replace('_', () => '.') +
                            '$',
                          'i',
                        ).test(data[propName]),
                      xor(typeIsNot, clauseNot),
                    );
                    break;
                  case 'match':
                  case '!match':
                    const testMatchValue = (
                      curValue as [string, string]
                    )[1] as string;
                    // Convert a POSIX regex to a JS regex.
                    pass = xor(
                      propName in data &&
                        this.posixRegexMatch(testMatchValue, data[propName]),
                      xor(typeIsNot, clauseNot),
                    );
                    break;
                  case 'imatch':
                  case '!imatch':
                    const testIMatchValue = (
                      curValue as [string, string]
                    )[1] as string;
                    // Convert a POSIX regex to a JS regex.
                    pass = xor(
                      propName in data &&
                        this.posixRegexMatch(
                          testIMatchValue,
                          data[propName],
                          true,
                        ),
                      xor(typeIsNot, clauseNot),
                    );
                    break;
                  case 'search':
                  case '!search':
                    const testSearchValue = (
                      curValue as [string, string]
                    )[1] as string;
                    const tokenString =
                      propName in data
                        ? (EntityClass.getFTSText(propName, data[propName]) ??
                          '')
                        : '';
                    pass = xor(
                      propName in data &&
                        this.tokenizer.searchString(
                          testSearchValue,
                          tokenString,
                        ),
                      xor(typeIsNot, clauseNot),
                    );
                    break;
                  case 'gt':
                  case '!gt':
                    const testGTValue = (
                      curValue as [string, number]
                    )[1] as number;
                    pass = xor(
                      propName in data && data[propName] > testGTValue,
                      xor(typeIsNot, clauseNot),
                    );
                    break;
                  case 'gte':
                  case '!gte':
                    const testGTEValue = (
                      curValue as [string, number]
                    )[1] as number;
                    pass = xor(
                      propName in data && data[propName] >= testGTEValue,
                      xor(typeIsNot, clauseNot),
                    );
                    break;
                  case 'lt':
                  case '!lt':
                    const testLTValue = (
                      curValue as [string, number]
                    )[1] as number;
                    pass = xor(
                      propName in data && data[propName] < testLTValue,
                      xor(typeIsNot, clauseNot),
                    );
                    break;
                  case 'lte':
                  case '!lte':
                    const testLTEValue = (
                      curValue as [string, number]
                    )[1] as number;
                    pass = xor(
                      propName in data && data[propName] <= testLTEValue,
                      xor(typeIsNot, clauseNot),
                    );
                    break;
                }

                if (!xor(typeIsOr, pass)) {
                  break;
                }
              }
            }
          }
          if (!xor(typeIsOr, pass)) {
            break;
          }
        }
        if (!pass) {
          return false;
        }
      }
      return true;
    } catch (e: any) {
      this.nymph.config.debugError(
        'nymph',
        `Failed to check entity data: ${e}`,
      );
      throw e;
    }
  }

  /**
   * Remove all copies of an entity from the cache.
   *
   * @param guid The GUID of the entity to remove.
   */
  protected cleanCache(guid: string) {
    delete this.entityCache[guid];
  }

  public async deleteEntity(entity: EntityInterface) {
    const className = (entity.constructor as any).class as string;
    if (entity.guid == null) {
      return false;
    }
    const ret = await this.deleteEntityByID(entity.guid, className);
    if (ret) {
      entity.guid = null;
      entity.cdate = null;
      entity.mdate = null;
    }
    return ret;
  }

  /**
   * Search through a value for an entity reference.
   *
   * @param value Any value to search.
   * @param entity An entity, GUID, or array of either to search for.
   * @returns True if the reference is found, false otherwise.
   */
  protected entityReferenceSearch(
    value: any,
    entity:
      | EntityInterface
      | EntityReference
      | string
      | (EntityInterface | EntityReference | string)[],
  ) {
    // Get the GUID, if the passed $entity is an object.
    const guids: string[] = [];
    if (Array.isArray(entity)) {
      if (entity[0] === 'nymph_entity_reference') {
        guids.push(value[1]);
      } else {
        for (const curEntity of entity) {
          if (typeof curEntity === 'string') {
            guids.push(curEntity);
          } else if (Array.isArray(curEntity)) {
            guids.push(curEntity[1]);
          } else if (typeof curEntity.guid === 'string') {
            guids.push(curEntity.guid);
          }
        }
      }
    } else if (typeof entity === 'string') {
      guids.push(entity);
    } else if (typeof entity.guid === 'string') {
      guids.push(entity.guid);
    }
    if (
      Array.isArray(value) &&
      value[0] === 'nymph_entity_reference' &&
      guids.indexOf(value[1]) !== -1
    ) {
      return true;
    }
    // Search through arrays and objects looking for the reference.
    if (value != null && (Array.isArray(value) || typeof value === 'object')) {
      for (const key in value) {
        if (this.entityReferenceSearch(value[key], guids)) {
          return true;
        }
      }
    }
    return false;
  }

  protected removeAndReturnACValues(
    etype: string,
    data: EntityData,
    sdata: SerializedEntityData,
  ) {
    let user: string | null = null;
    let group: string | null = null;
    let acUser: number | null = null;
    let acGroup: number | null = null;
    let acOther: number | null = null;
    let acRead: string[] | null = null;
    let acWrite: string[] | null = null;
    let acFull: string[] | null = null;

    if (this.nymph.tilmeld != null) {
      // Since Tilmeld is installed, we need to fill the AC vars.
      const userValue =
        'user' in sdata
          ? JSON.parse(sdata.user)
          : 'user' in data
            ? data.user
            : null;
      if (
        Array.isArray(userValue) &&
        userValue[0] === 'nymph_entity_reference' &&
        userValue[2] === 'User' &&
        typeof userValue[1] === 'string' &&
        userValue[1].match(/^[0-9a-f]{24}$/i)
      ) {
        user = userValue[1];
      }

      const groupValue =
        'group' in sdata
          ? JSON.parse(sdata.group)
          : 'group' in data
            ? data.group
            : null;
      if (
        Array.isArray(groupValue) &&
        groupValue[0] === 'nymph_entity_reference' &&
        groupValue[2] === 'Group' &&
        typeof groupValue[1] === 'string' &&
        groupValue[1].match(/^[0-9a-f]{24}$/i)
      ) {
        group = groupValue[1];
      }

      if (etype !== 'tilmeld_user' && etype !== 'tilmeld_group') {
        const acUserValue =
          'acUser' in sdata
            ? JSON.parse(sdata.acUser)
            : 'acUser' in data
              ? data.acUser
              : null;
        if (
          typeof acUserValue === 'number' &&
          !isNaN(acUserValue) &&
          acUserValue >= 0
        ) {
          acUser = acUserValue;
        }

        const acGroupValue =
          'acGroup' in sdata
            ? JSON.parse(sdata.acGroup)
            : 'acGroup' in data
              ? data.acGroup
              : null;
        if (
          typeof acGroupValue === 'number' &&
          !isNaN(acGroupValue) &&
          acGroupValue >= 0
        ) {
          acGroup = acGroupValue;
        }

        const acOtherValue =
          'acOther' in sdata
            ? JSON.parse(sdata.acOther)
            : 'acOther' in data
              ? data.acOther
              : null;
        if (
          typeof acOtherValue === 'number' &&
          !isNaN(acOtherValue) &&
          acOtherValue >= 0
        ) {
          acOther = acOtherValue;
        }

        const acReadValue =
          'acRead' in sdata
            ? JSON.parse(sdata.acRead)
            : 'acRead' in data
              ? data.acRead
              : null;
        if (Array.isArray(acReadValue)) {
          const acReadArray = [];
          for (let acReadEntry of acReadValue) {
            if (
              typeof acReadEntry === 'string' &&
              acReadEntry.match(/^[0-9a-f]{24}$/i)
            ) {
              acReadArray.push(acReadEntry);
            } else if (
              Array.isArray(acReadEntry) &&
              acReadEntry[0] === 'nymph_entity_reference' &&
              (acReadEntry[2] === 'User' || acReadEntry[2] === 'Group') &&
              typeof acReadEntry[1] === 'string' &&
              acReadEntry[1].match(/^[0-9a-f]{24}$/i)
            ) {
              acReadArray.push(acReadEntry[1]);
            }
          }
          if (acReadArray.length) {
            acRead = acReadArray;
          }
        }

        const acWriteValue =
          'acWrite' in sdata
            ? JSON.parse(sdata.acWrite)
            : 'acWrite' in data
              ? data.acWrite
              : null;
        if (Array.isArray(acWriteValue)) {
          const acWriteArray = [];
          for (let acWriteEntry of acWriteValue) {
            if (
              typeof acWriteEntry === 'string' &&
              acWriteEntry.match(/^[0-9a-f]{24}$/i)
            ) {
              acWriteArray.push(acWriteEntry);
            } else if (
              Array.isArray(acWriteEntry) &&
              acWriteEntry[0] === 'nymph_entity_reference' &&
              (acWriteEntry[2] === 'User' || acWriteEntry[2] === 'Group') &&
              typeof acWriteEntry[1] === 'string' &&
              acWriteEntry[1].match(/^[0-9a-f]{24}$/i)
            ) {
              acWriteArray.push(acWriteEntry[1]);
            }
          }
          if (acWriteArray.length) {
            acWrite = acWriteArray;
          }
        }

        const acFullValue =
          'acFull' in sdata
            ? JSON.parse(sdata.acFull)
            : 'acFull' in data
              ? data.acFull
              : null;
        if (Array.isArray(acFullValue)) {
          const acFullArray = [];
          for (let acFullEntry of acFullValue) {
            if (
              typeof acFullEntry === 'string' &&
              acFullEntry.match(/^[0-9a-f]{24}$/i)
            ) {
              acFullArray.push(acFullEntry);
            } else if (
              Array.isArray(acFullEntry) &&
              acFullEntry[0] === 'nymph_entity_reference' &&
              (acFullEntry[2] === 'User' || acFullEntry[2] === 'Group') &&
              typeof acFullEntry[1] === 'string' &&
              acFullEntry[1].match(/^[0-9a-f]{24}$/i)
            ) {
              acFullArray.push(acFullEntry[1]);
            }
          }
          if (acFullArray.length) {
            acFull = acFullArray;
          }
        }
      }

      // Delete all of the data, so it doesn't go into the database as rows in
      // the data tables.
      delete sdata.user;
      delete data.user;
      delete sdata.group;
      delete data.group;
      delete sdata.acUser;
      delete data.acUser;
      delete sdata.acGroup;
      delete data.acGroup;
      delete sdata.acOther;
      delete data.acOther;
      delete sdata.acRead;
      delete data.acRead;
      delete sdata.acWrite;
      delete data.acWrite;
      delete sdata.acFull;
      delete data.acFull;
    }

    return {
      user,
      group,
      acUser,
      acGroup,
      acOther,
      acRead,
      acWrite,
      acFull,
    };
  }

  public formatSelectors(
    selectors: Selector[],
    options: Options = {},
  ): {
    selectors: FormattedSelector[];
    qrefs: [Options, ...FormattedSelector[]][];
  } {
    const newSelectors: FormattedSelector[] = [];
    const qrefs: [Options, ...FormattedSelector[]][] = [];

    for (const curSelector of selectors) {
      const newSelector: FormattedSelector = {
        type: curSelector.type,
      };

      for (const k in curSelector) {
        const key = k as keyof Selector;
        const value = curSelector[key];

        if (key === 'type') {
          continue;
        }

        if (value === undefined) {
          continue;
        }

        if (key === 'qref' || key === '!qref') {
          const tmpArr = (
            Array.isArray(((value as Selector['qref']) ?? [])[0])
              ? value
              : [value]
          ) as [string, [Options, ...Selector[]]][];
          const formatArr: [string, [Options, ...FormattedSelector[]]][] = [];
          for (let i = 0; i < tmpArr.length; i++) {
            const name = tmpArr[i][0];
            const [qrefOptions, ...qrefSelectors] = tmpArr[i][1];
            const QrefEntityClass = qrefOptions.class
              ? this.nymph.getEntityClass(qrefOptions.class)
              : this.nymph.getEntityClass('Entity');
            const newOptions = {
              ...qrefOptions,
              class: QrefEntityClass,
              source: options.source,
            };
            const newSelectors = this.formatSelectors(qrefSelectors, options);
            qrefs.push(
              [newOptions, ...newSelectors.selectors],
              ...newSelectors.qrefs,
            );
            formatArr[i] = [name, [newOptions, ...newSelectors.selectors]];
          }
          newSelector[key] = formatArr;
        } else if (key === 'selector' || key === '!selector') {
          const tmpArr = (Array.isArray(value) ? value : [value]) as Selector[];
          const newSelectors = this.formatSelectors(tmpArr, options);
          newSelector[key] = newSelectors.selectors;
          qrefs.push(...newSelectors.qrefs);
        } else if (!Array.isArray(value)) {
          // @ts-ignore: ts doesn't know what value is here.
          newSelector[key] = [[value]];
        } else if (!Array.isArray(value[0])) {
          if (
            value.length === 3 &&
            value[1] == null &&
            typeof value[2] === 'string'
          ) {
            // @ts-ignore: ts doesn't know what value is here.
            newSelector[key] = [[value[0], strtotime(value[2]) * 1000]];
          } else {
            // @ts-ignore: ts doesn't know what value is here.
            newSelector[key] = [value];
          }
        } else {
          // @ts-ignore: ts doesn't know what value is here.
          newSelector[key] = value.map((curValue) => {
            if (
              curValue.length === 3 &&
              curValue[1] == null &&
              typeof curValue[2] === 'string'
            ) {
              return [curValue[0], strtotime(curValue[2]) * 1000];
            }
            return curValue;
          });
        }
      }

      newSelectors.push(newSelector);
    }

    return { selectors: newSelectors, qrefs };
  }

  protected iterateSelectorsForQuery(
    selectors: FormattedSelector[],
    callback: (data: {
      key: string;
      value: any;
      typeIsOr: boolean;
      typeIsNot: boolean;
    }) => string,
  ) {
    const queryParts = [];
    for (const curSelector of selectors) {
      let curSelectorQuery = '';
      let type: string = curSelector.type;
      let typeIsNot: boolean = type === '!&' || type === '!|';
      let typeIsOr: boolean = type === '|' || type === '!|';
      for (const key in curSelector) {
        const value: any = (curSelector as any)[key];
        if (key === 'type') {
          continue;
        }
        let curQuery = callback({ key, value, typeIsOr, typeIsNot });
        if (curQuery) {
          if (curSelectorQuery) {
            curSelectorQuery += typeIsOr ? ' OR ' : ' AND ';
          }
          curSelectorQuery += curQuery;
        }
      }
      if (curSelectorQuery) {
        queryParts.push(curSelectorQuery);
      }
    }

    return queryParts;
  }

  protected getEntitiesRowLike<T extends EntityConstructor = EntityConstructor>(
    options: Options<T> & { return: 'count' },
    selectors: Selector[],
    performQueryCallback: (data: {
      options: Options<T>;
      selectors: FormattedSelector[];
      etype: string;
    }) => {
      result: any;
    },
    rowFetchCallback: () => any,
    freeResultCallback: () => void,
    getCountCallback: (row: any) => number,
    getGUIDCallback: (row: any) => string,
    getTagsAndDatesCallback: (row: any) => {
      tags: string[];
      cdate: number;
      mdate: number;
    },
    getDataNameAndSValueCallback: (row: any) => {
      name: string;
      svalue: string;
    },
  ): { result: any; process: () => number | Error };
  protected getEntitiesRowLike<T extends EntityConstructor = EntityConstructor>(
    options: Options<T> & { return: 'guid' },
    selectors: Selector[],
    performQueryCallback: (data: {
      options: Options<T>;
      selectors: FormattedSelector[];
      etype: string;
    }) => {
      result: any;
    },
    rowFetchCallback: () => any,
    freeResultCallback: () => void,
    getCountCallback: (row: any) => number,
    getGUIDCallback: (row: any) => string,
    getTagsAndDatesCallback: (row: any) => {
      tags: string[];
      cdate: number;
      mdate: number;
    },
    getDataNameAndSValueCallback: (row: any) => {
      name: string;
      svalue: string;
    },
  ): { result: any; process: () => string[] | Error };
  protected getEntitiesRowLike<T extends EntityConstructor = EntityConstructor>(
    options: Options<T> & { return: 'object' },
    selectors: Selector[],
    performQueryCallback: (data: {
      options: Options<T>;
      selectors: FormattedSelector[];
      etype: string;
    }) => {
      result: any;
    },
    rowFetchCallback: () => any,
    freeResultCallback: () => void,
    getCountCallback: (row: any) => number,
    getGUIDCallback: (row: any) => string,
    getTagsAndDatesCallback: (row: any) => {
      tags: string[];
      cdate: number;
      mdate: number;
    },
    getDataNameAndSValueCallback: (row: any) => {
      name: string;
      svalue: string;
    },
  ): { result: any; process: () => EntityObjectType<T>[] | Error };
  protected getEntitiesRowLike<T extends EntityConstructor = EntityConstructor>(
    options: Options<T>,
    selectors: Selector[],
    performQueryCallback: (data: {
      options: Options<T>;
      selectors: FormattedSelector[];
      etype: string;
    }) => {
      result: any;
    },
    rowFetchCallback: () => any,
    freeResultCallback: () => void,
    getCountCallback: (row: any) => number,
    getGUIDCallback: (row: any) => string,
    getTagsAndDatesCallback: (row: any) => {
      tags: string[];
      cdate: number;
      mdate: number;
    },
    getDataNameAndSValueCallback: (row: any) => {
      name: string;
      svalue: string;
    },
  ): {
    result: any;
    process: () => EntityInstanceType<T>[] | Error;
  };
  protected getEntitiesRowLike<T extends EntityConstructor = EntityConstructor>(
    options: Options<T>,
    selectors: Selector[],
    performQueryCallback: (data: {
      options: Options<T>;
      selectors: FormattedSelector[];
      etype: string;
    }) => {
      result: any;
    },
    rowFetchCallback: () => any,
    freeResultCallback: () => void,
    getCountCallback: (row: any) => number,
    getGUIDCallback: (row: any) => string,
    getTagsAndDatesCallback: (row: any) => {
      tags: string[];
      cdate: number;
      mdate: number;
      user?: string;
      group?: string;
      acUser?: number;
      acGroup?: number;
      acOther?: number;
      acRead?: string[];
      acWrite?: string[];
      acFull?: string[];
    },
    getDataNameAndSValueCallback: (row: any) => {
      name: string;
      svalue: string;
    },
  ): {
    result: any;
    process: () =>
      | EntityInstanceType<T>[]
      | EntityObjectType<T>[]
      | string[]
      | number
      | Error;
  } {
    if (!this.isConnected()) {
      throw new UnableToConnectError('not connected to DB');
    }
    for (const selector of selectors) {
      if (
        !selector ||
        Object.keys(selector).length === 1 ||
        !('type' in selector) ||
        ['&', '!&', '|', '!|'].indexOf(selector.type) === -1
      ) {
        throw new InvalidParametersError(
          'Invalid query selector passed: ' + JSON.stringify(selector),
        );
      }
    }

    let entities: EntityInstanceType<T>[] | EntityObjectType<T>[] | string[] =
      [];
    let count = 0;
    const EntityClass =
      options.class ?? (this.nymph.getEntityClass('Entity') as T);
    const etype = EntityClass.ETYPE;

    if (options.source === 'client' && options.return === 'object') {
      throw new InvalidParametersError(
        'Object return type not allowed from client.',
      );
    }

    // Check if the requested entity is cached.
    if (
      this.nymph.config.cache &&
      !options.skipCache &&
      selectors.length &&
      'guid' in selectors[0] &&
      typeof selectors[0].guid === 'number'
    ) {
      // Only safe to use the cache option with no other selectors than a GUID
      // and tags.
      if (
        selectors.length === 1 &&
        selectors[0].type === '&' &&
        (Object.keys(selectors[0]).length === 2 ||
          (Object.keys(selectors[0]).length === 3 && 'tag' in selectors[0]))
      ) {
        const cacheEntity = this.pullCache(selectors[0]['guid']);

        if (
          cacheEntity != null &&
          cacheEntity.guid != null &&
          (!('tag' in selectors[0]) ||
            !(
              Array.isArray(selectors[0].tag)
                ? selectors[0].tag
                : [selectors[0].tag]
            ).find((tag) => tag == null || !cacheEntity.tags.includes(tag)))
        ) {
          // Return the value from the cache.
          const { guid, cdate, mdate, tags, data, sdata } = cacheEntity;

          if (options.return === 'count') {
            return {
              result: Promise.resolve(null),
              process: () => 1,
            };
          } else if (options.return === 'guid') {
            return {
              result: Promise.resolve(null),
              process: () => [guid],
            };
          } else if (options.return === 'object') {
            return {
              result: Promise.resolve(null),
              process: () => [
                {
                  guid,
                  cdate,
                  mdate,
                  tags,
                  ...data,
                  ...Object.fromEntries(
                    Object.entries(sdata).map(([name, value]) => [
                      name,
                      JSON.parse(value),
                    ]),
                  ),
                } as EntityObjectType<T>,
              ],
            };
          } else {
            const entity = this.nymph
              .getEntityClass(EntityClass.class)
              .factorySync() as EntityInstanceType<T>;
            if (entity) {
              entity.$nymph = this.nymph;
              if (options.skipAc != null) {
                entity.$useSkipAc(!!options.skipAc);
              }
              entity.guid = guid;
              entity.cdate = cdate;
              entity.mdate = mdate;
              entity.tags = tags;
              entity.$putData(data, sdata, 'server');
              return {
                result: Promise.resolve(null),
                process: () => [entity],
              };
            }

            return {
              result: Promise.resolve(null),
              process: () => [],
            };
          }
        }
      }
    }

    try {
      const formattedSelectors = this.formatSelectors(selectors, options);
      this.nymph.runQueryCallbacks(options, formattedSelectors.selectors);
      for (let i = 0; i < formattedSelectors.qrefs.length; i++) {
        const [options, ...selectors] = formattedSelectors.qrefs[i];
        this.nymph.runQueryCallbacks(options, selectors);
        formattedSelectors.qrefs[i] = [options, ...selectors];
      }
      const { result } = performQueryCallback({
        options,
        selectors: formattedSelectors.selectors,
        etype,
      });

      return {
        result,
        process: () => {
          let row = rowFetchCallback();
          if (options.return === 'count') {
            while (row != null) {
              count += getCountCallback(row);
              row = rowFetchCallback();
            }
          } else if (options.return === 'guid') {
            while (row != null) {
              (entities as string[]).push(getGUIDCallback(row));
              row = rowFetchCallback();
            }
          } else {
            while (row != null) {
              const guid = getGUIDCallback(row);
              const tagsAndDates = getTagsAndDatesCallback(row);
              const tags = tagsAndDates.tags;
              const cdate = tagsAndDates.cdate;
              const mdate = tagsAndDates.mdate;
              let dataNameAndSValue = getDataNameAndSValueCallback(row);
              // Data.
              const data: EntityData = {};
              // Serialized data.
              const sdata: SerializedEntityData = {};
              if (dataNameAndSValue.name !== '') {
                // This do will keep going and adding the data until the
                // next entity is reached. $row will end on the next entity.
                do {
                  dataNameAndSValue = getDataNameAndSValueCallback(row);
                  sdata[dataNameAndSValue.name] = dataNameAndSValue.svalue;
                  row = rowFetchCallback();
                } while (row != null && getGUIDCallback(row) === guid);
              } else {
                // Make sure that $row is incremented :)
                row = rowFetchCallback();
              }

              if (this.nymph.tilmeld) {
                if ('user' in tagsAndDates && tagsAndDates.user != null) {
                  data.user = [
                    'nymph_entity_reference',
                    tagsAndDates.user,
                    'User',
                  ];
                  delete sdata.user;
                }
                if ('group' in tagsAndDates && tagsAndDates.group != null) {
                  data.group = [
                    'nymph_entity_reference',
                    tagsAndDates.group,
                    'Group',
                  ];
                  delete sdata.group;
                }
                if ('acUser' in tagsAndDates && tagsAndDates.acUser != null) {
                  data.acUser = tagsAndDates.acUser;
                  delete sdata.acUser;
                }
                if ('acGroup' in tagsAndDates && tagsAndDates.acGroup != null) {
                  data.acGroup = tagsAndDates.acGroup;
                  delete sdata.acGroup;
                }
                if ('acOther' in tagsAndDates && tagsAndDates.acOther != null) {
                  data.acOther = tagsAndDates.acOther;
                  delete sdata.acOther;
                }
                if ('acRead' in tagsAndDates && tagsAndDates.acRead != null) {
                  data.acRead = tagsAndDates.acRead;
                  delete sdata.acRead;
                }
                if ('acWrite' in tagsAndDates && tagsAndDates.acWrite != null) {
                  data.acWrite = tagsAndDates.acWrite;
                  delete sdata.acWrite;
                }
                if ('acFull' in tagsAndDates && tagsAndDates.acFull != null) {
                  data.acFull = tagsAndDates.acFull;
                  delete sdata.acFull;
                }
              }

              if (options.return === 'object') {
                (entities as EntityObjectType<T>[]).push({
                  guid,
                  cdate,
                  mdate,
                  tags,
                  ...data,
                  ...Object.fromEntries(
                    Object.entries(sdata).map(([name, value]) => [
                      name,
                      JSON.parse(value),
                    ]),
                  ),
                } as EntityObjectType<T>);
              } else {
                const entity =
                  EntityClass.factorySync() as EntityInstanceType<T>;
                entity.$nymph = this.nymph;
                if (options.skipAc != null) {
                  entity.$useSkipAc(!!options.skipAc);
                }
                entity.guid = guid;
                entity.cdate = cdate;
                entity.mdate = mdate;
                entity.tags = tags;
                this.putDataCounter++;
                if (this.putDataCounter == 100) {
                  throw new Error('Infinite loop detected in Entity loading.');
                }
                entity.$putData(data, sdata, 'server');
                this.putDataCounter--;
                entities.push(entity);
              }
              if (this.nymph.config.cache) {
                this.pushCache(guid, cdate, mdate, tags, data, sdata);
              }
            }
          }

          freeResultCallback();

          return options.return === 'count' ? count : entities;
        },
      };
    } catch (e: any) {
      return {
        result: Promise.resolve(e),
        process: () => {
          return e;
        },
      };
    }
  }

  protected async saveEntityRowLike(
    entity: EntityInterface,
    saveNewEntityCallback: (data: {
      entity: EntityInterface;
      guid: string;
      tags: string[];
      data: EntityData;
      sdata: SerializedEntityData;
      uniques: string[];
      cdate: number;
      etype: string;
    }) => Promise<boolean>,
    saveExistingEntityCallback: (data: {
      entity: EntityInterface;
      guid: string;
      tags: string[];
      data: EntityData;
      sdata: SerializedEntityData;
      uniques: string[];
      mdate: number;
      etype: string;
    }) => Promise<boolean>,
    startTransactionCallback: (() => Promise<void>) | null = null,
    commitTransactionCallback:
      | ((success: boolean) => Promise<boolean>)
      | null = null,
  ) {
    const originalGuid = entity.guid;
    const originalCdate = entity.cdate;
    const originalMdate = entity.mdate;
    // Get a modified date.
    let mdate = Date.now();
    if (entity.mdate != null) {
      if (this.nymph.config.updateMDate === false) {
        mdate = entity.mdate;
      } else if (typeof this.nymph.config.updateMDate === 'number') {
        mdate = entity.mdate + this.nymph.config.updateMDate;
      } else if (entity.mdate >= mdate) {
        // If we're saving too fast (within 1 millisecond), increment mdate to
        // make sure it doesn't conflict in the DB.
        mdate = entity.mdate + 1;
      }
    }
    const tags = difference(entity.tags, ['']);
    const data = entity.$getData();
    const sdata = entity.$getSData();
    const uniques = await entity.$getUniques();
    const EntityClass = entity.constructor as EntityConstructor;
    const etype = EntityClass.ETYPE;
    if (startTransactionCallback) {
      await startTransactionCallback();
    }
    let success = false;
    try {
      if (entity.guid == null) {
        const cdate = mdate;
        const newId = entity.$getGuaranteedGUID();
        success = await saveNewEntityCallback({
          entity,
          guid: newId,
          tags,
          data,
          sdata,
          uniques,
          cdate,
          etype,
        });
        if (success) {
          entity.guid = newId;
          entity.cdate = cdate;
          entity.mdate = mdate;
        }
      } else {
        // Removed any cached versions of this entity.
        if (this.nymph.config.cache) {
          this.cleanCache(entity.guid);
        }
        success = await saveExistingEntityCallback({
          entity,
          guid: entity.guid,
          tags,
          data,
          sdata,
          uniques,
          mdate,
          etype,
        });
        if (success) {
          entity.mdate = mdate;
        }
      }
      if (commitTransactionCallback) {
        success = await commitTransactionCallback(success);
      }
      // Cache the entity.
      if (success && this.nymph.config.cache) {
        this.pushCache(
          entity.guid as string,
          entity.cdate as number,
          entity.mdate as number,
          entity.tags,
          data,
          sdata,
        );
      }
    } catch (e: any) {
      entity.guid = originalGuid;
      entity.cdate = originalCdate;
      entity.mdate = originalMdate;
      throw e;
    }
    if (!success) {
      entity.guid = originalGuid;
      entity.cdate = originalCdate;
      entity.mdate = originalMdate;
    }
    return success;
  }

  /**
   * Pull an entity from the cache.
   *
   * @param guid The entity's GUID.
   * @returns The entity's data or null if it's not cached.
   */
  protected pullCache(guid: string): {
    guid: string;
    cdate: number;
    mdate: number;
    tags: string[];
    data: EntityData;
    sdata: SerializedEntityData;
  } | null {
    // Increment the entity access count.
    if (!(guid in this.entityCount)) {
      this.entityCount[guid] = 0;
    }
    this.entityCount[guid]++;
    if (guid in this.entityCache) {
      return {
        guid,
        cdate: this.entityCache[guid]['cdate'],
        mdate: this.entityCache[guid]['mdate'],
        tags: this.entityCache[guid]['tags'],
        data: this.entityCache[guid]['data'],
        sdata: this.entityCache[guid]['sdata'],
      };
    }
    return null;
  }

  /**
   * Push an entity onto the cache.
   *
   * @param guid The entity's GUID.
   * @param cdate The entity's cdate.
   * @param mdate The entity's mdate.
   * @param tags The entity's tags.
   * @param data The entity's data.
   * @param sdata The entity's sdata.
   */
  protected pushCache(
    guid: string,
    cdate: number,
    mdate: number,
    tags: string[],
    data: EntityData,
    sdata: SerializedEntityData,
  ) {
    if (guid == null) {
      return;
    }
    // Increment the entity access count.
    if (!(guid in this.entityCount)) {
      this.entityCount[guid] = 0;
    }
    this.entityCount[guid]++;
    // Check the threshold.
    if (this.entityCount[guid] < this.nymph.config.cacheThreshold) {
      return;
    }
    // Cache the entity.
    this.entityCache[guid] = {
      cdate,
      mdate,
      tags,
      data,
      sdata,
    };
    while (
      this.nymph.config.cacheLimit &&
      Object.keys(this.entityCache).length >= this.nymph.config.cacheLimit
    ) {
      // Find which entity has been accessed the least.
      const least =
        Object.entries(this.entityCount)
          .sort(([_guidA, countA], [_guidB, countB]) => countA - countB)
          .pop()?.[0] ?? null;
      if (least == null) {
        // This should never happen.
        return;
      }
      // Remove it.
      delete this.entityCache[least];
      delete this.entityCount[least];
    }
  }

  protected findReferences(svalue: string): string[] {
    const re = /\["nymph_entity_reference","([0-9a-fA-F]+)",/g;
    const matches = svalue.match(re);
    if (matches == null) {
      return [];
    }
    return matches.map((match) => match.replace(re, '$1'));
  }
}
