import { difference, intersection, isEqual } from 'lodash-es';
import { guid } from '@nymphjs/guid';

import type Nymph from './Nymph.js';
import type { Options } from './Nymph.types.js';
import type {
  ACProperties,
  EntityConstructor,
  EntityData,
  EntityInterface,
  EntityJson,
  EntityObject,
  EntityPatch,
  EntityReference,
  SerializedEntityData,
} from './Entity.types.js';
import {
  EntityConflictError,
  EntityIsSleepingReferenceError,
  InvalidParametersError,
  InvalidStateError,
} from './errors/index.js';
import {
  entitiesToReferences,
  referencesToEntities,
  uniqueStrings,
} from './utils.js';

export type EntityDataType<T> =
  T extends Entity<infer DataType> ? DataType : never;

export type EntityInstanceType<T extends EntityConstructor> =
  T extends new () => infer E ? E & EntityDataType<E> : never;

export type EntityObjectType<T extends EntityConstructor> =
  T extends new () => infer E ? EntityObject & EntityDataType<E> : never;

/**
 * Database abstraction object.
 *
 * Provides a way to access, manipulate, and store data in Nymph.
 *
 * The GUID is not set until the entity is saved. GUIDs must be unique forever,
 * even after deletion. It's the job of the Nymph DB driver to make sure no two
 * entities ever have the same GUID. This is generally done by using a large
 * randomly generated ID.
 *
 * Each entity class has an etype that determines which table(s) in the database
 * it belongs to. If two entity classes have the same etype, their data will be
 * stored in the same table(s). This isn't a good idea, however, because
 * references to an entity store a class name, not an etype.
 *
 * Tags are used to classify entities. Where an etype is used to separate data
 * by tables, tags can be used to separate entities within a table. You can
 * define specific tags to be protected, meaning they cannot be added/removed
 * from the client. It can be useful to allow user defined tags, such as for
 * blog posts.
 *
 * Simply calling $delete() will not unset the entity. It will still take up
 * memory. Likewise, simply calling unset will not delete the entity from the
 * DB.
 *
 * Some notes about $equals() and $is(), the replacements for "==":
 *
 * The == operator will likely not give you the result you want, since two
 * instances of the same entity will fail that check, even though they represent
 * the same data in the database.
 *
 * $equals() performs a more strict comparison of the entity to another. Use
 * $equals() instead of the == operator when you want to check both the entities
 * they represent, and the data inside them. In order to return true for
 * $equals(), the entity and object must meet the following criteria:
 *
 * - They must be entities.
 * - They must have equal GUIDs, or both must have no GUID.
 * - Their data and tags must be equal.
 *
 * $is() performs a less strict comparison of the entity to another. Use $is()
 * instead of the == operator when the entity's data may have been changed, but
 * you only care if they represent the same entity. In order to return true, the
 * entity and object must meet the following criteria:
 *
 * - They must be entities.
 * - They must have equal GUIDs, or both must have no GUID.
 * - If they have no GUIDs, their data and tags must be equal.
 *
 * Some notes about saving entities in other entity's properties:
 *
 * Entities use references in the DB to store an entity in their properties. The
 * reference is stored as an array with the values:
 *
 * - 0 => The string 'nymph_entity_reference'
 * - 1 => The referenced entity's GUID.
 * - 2 => The referenced entity's class name.
 *
 * Since the referenced entity's class name (meaning the `class` static
 * property, not the name of the class itself) is stored in the reference on the
 * parent entity, if you change the class name in an update, you need to
 * reassign all referenced entities of that class and resave.
 *
 * When an entity is loaded, it does not request its referenced entities from
 * Nymph. Instead, it creates instances without data called sleeping references.
 * When you first access an entity's data, if it is a sleeping reference, it
 * will fill its data from the DB. You can call clearCache() to turn all the
 * entities back into sleeping references.
 */
export default class Entity<
  T extends EntityData = EntityData,
> implements EntityInterface {
  /**
   * The instance of Nymph to use for queries.
   */
  public static nymph = {} as Nymph;
  /**
   * A unique name for this type of entity used to separate its data from other
   * types of entities in the database.
   */
  public static ETYPE = 'entity';
  /**
   * The lookup name for this entity.
   *
   * This is used for reference arrays (and sleeping references) and client
   * requests.
   */
  public static class = 'Entity';

  public $nymph: Nymph;
  public guid: string | null = null;
  public cdate: number | null = null;
  public mdate: number | null = null;
  public tags: string[] = [];
  /**
   * The data proxy handler.
   */
  protected $dataHandler: Object;
  /**
   * The actual data store.
   */
  protected $dataStore: T;
  /**
   * The actual sdata store.
   */
  protected $sdata: SerializedEntityData;
  /**
   * The data proxy object.
   */
  protected $data: T;
  /**
   * Whether this instance is a sleeping reference.
   */
  protected $isASleepingReference = false;
  /**
   * The reference to use to wake.
   */
  protected $sleepingReference: EntityReference | null = null;
  /**
   * A promise that resolved when the entity's data is wake.
   */
  protected $wakePromise: Promise<Entity<T>> | null = null;
  /**
   * Properties that will not be serialized into JSON with toJSON(). This
   * can be considered a denylist, because these properties will not be set
   * with incoming JSON.
   *
   * Clients CAN still determine what is in these properties, unless they are
   * also listed in searchRestrictedData.
   */
  protected $privateData: string[] = [];
  /**
   * Whether this entity should publish changes to PubSub servers.
   */
  public static pubSubEnabled = true;
  /**
   * Whether this entity should be accessible on the frontend through the REST
   * server.
   *
   * If this is false, any request from the client that attempts to use this
   * entity will fail.
   */
  public static restEnabled = true;
  /**
   * Properties that will not be searchable from the frontend. If the frontend
   * includes any of these properties in any of their clauses, they will be
   * filtered out before the search is executed.
   */
  public static searchRestrictedData: string[] = [];
  /**
   * Properties that can only be modified by server side code. They will still
   * be visible on the frontend, unlike $privateData, but any changes to them
   * that come from the frontend will be ignored.
   *
   * In addition to what's listed here, all of the access control properties
   * will be included when Tilmeld is being used. These are:
   *
   * - acUser
   * - acGroup
   * - acOther
   * - acRead
   * - acWrite
   * - acFull
   * - user
   * - group
   *
   * You should modify these through client enabled methods or the $save method
   * instead, for safety.
   */
  protected $protectedData: string[] = [];
  /**
   * If this is defined, then it lists the only properties that will be
   * accepted from incoming JSON. Any other properties will be ignored.
   *
   * If you use an allowlist, you don't need to use protectedData, since you
   * can simply leave those entries out of allowlistData.
   */
  protected $allowlistData?: string[];
  /**
   * Tags that can only be added/removed by server side code. They will still be
   * visible on the frontend, but any changes to them that come from the
   * frontend will be ignored.
   */
  protected $protectedTags: string[] = [];
  /**
   * If this is defined, then it lists the only tags that will be accepted from
   * incoming JSON. Any other tags will be ignored.
   */
  protected $allowlistTags?: string[];
  /**
   * The names of methods allowed to be called by the frontend with serverCall.
   */
  protected $clientEnabledMethods: string[] = [];
  /**
   * The names of static methods allowed to be called by the frontend with
   * serverCallStatic.
   */
  public static clientEnabledStaticMethods: string[] = [];
  /**
   * Whether to use "skipAc" when accessing entity references.
   */
  private $skipAc: boolean = false;
  /**
   * The AC properties' values when the entity was loaded.
   */
  private $originalAcValues: ACProperties | null = null;
  /**
   * This is used to hold a generated GUID for a new entity.
   */
  private $guaranteedGUID: string | null = null;

  /**
   * Alter the options for a query for this entity.
   *
   * @param options The current options.
   * @returns The altered options.
   */
  static alterOptions?<T extends Options>(options: T): T;

  /**
   * Initialize an entity.
   */
  public constructor(..._rest: any[]) {
    this.$nymph = (this.constructor as EntityConstructor).nymph;
    this.$dataHandler = {
      has: (data: EntityData, name: string) => {
        this.$check();

        return name in data || name in this.$sdata;
      },

      get: (data: EntityData, name: string) => {
        this.$check();

        if (this.$sdata.hasOwnProperty(name)) {
          data[name] = referencesToEntities(
            JSON.parse(this.$sdata[name]),
            this.$nymph,
            this.$skipAc,
          );
          delete this.$sdata[name];
        }
        if (data.hasOwnProperty(name)) {
          return data[name];
        }
        return undefined;
      },

      set: (data: EntityData, name: string, value: any) => {
        this.$check();

        if (this.$sdata.hasOwnProperty(name)) {
          delete this.$sdata[name];
        }
        data[name] = value;
        return true;
      },

      deleteProperty: (data: EntityData, name: string) => {
        this.$check();

        if (this.$sdata.hasOwnProperty(name)) {
          return delete this.$sdata[name];
        }
        if (data.hasOwnProperty(name)) {
          return delete data[name];
        }
        return true;
      },

      defineProperty: (
        data: EntityData,
        name: string,
        descriptor: PropertyDescriptor,
      ) => {
        this.$check();

        if (this.$sdata.hasOwnProperty(name)) {
          delete this.$sdata[name];
        }
        Object.defineProperty(data, name, descriptor);
        return true;
      },

      getOwnPropertyDescriptor: (data: EntityData, name: string) => {
        this.$check();

        if (this.$sdata.hasOwnProperty(name)) {
          data[name] = referencesToEntities(
            JSON.parse(this.$sdata[name]),
            this.$nymph,
            this.$skipAc,
          );
          delete this.$sdata[name];
        }
        return Object.getOwnPropertyDescriptor(data, name);
      },

      ownKeys: (data: EntityData) => {
        this.$check();

        return Object.getOwnPropertyNames(data).concat(
          Object.getOwnPropertyNames(this.$sdata),
        );
      },
    };
    this.$dataStore = {} as T;
    this.$sdata = {};

    this.$data = new Proxy(this.$dataStore, this.$dataHandler);

    return new Proxy(this, {
      has: (entity: Entity, name: string) => {
        if (
          typeof name !== 'string' ||
          name in entity ||
          name.substring(0, 1) === '$'
        ) {
          return name in entity;
        }

        this.$check();
        return name in entity.$data;
      },

      get: (entity: Entity, name: string) => {
        if (
          typeof name !== 'string' ||
          name in entity ||
          name.substring(0, 1) === '$'
        ) {
          if (name === 'tags' || name === 'cdate' || name === 'mdate') {
            this.$check();
          }
          return (entity as any)[name];
        }

        this.$check();
        if (name in entity.$data) {
          return entity.$data[name];
        }
        return undefined;
      },

      set: (entity: Entity, name: string, value: any) => {
        if (
          typeof name !== 'string' ||
          name in entity ||
          name.substring(0, 1) === '$'
        ) {
          if (name === 'tags' || name === 'cdate' || name === 'mdate') {
            this.$check();
          }
          (entity as any)[name] = value;
        } else {
          this.$check();
          (entity.$data as any)[name] = value;
        }
        return true;
      },

      deleteProperty: (entity: Entity, name: string) => {
        if (name in entity) {
          return delete (entity as any)[name];
        } else if (name in entity.$data) {
          this.$check();
          return delete entity.$data[name];
        }
        return true;
      },

      getPrototypeOf: (entity: Entity) => {
        return entity.constructor.prototype;
      },

      defineProperty: (
        entity: Entity,
        name: string,
        descriptor: PropertyDescriptor,
      ) => {
        if (
          typeof name !== 'string' ||
          name in entity ||
          name.substring(0, 1) === '$'
        ) {
          if (name === 'tags' || name === 'cdate' || name === 'mdate') {
            this.$check();
          }
          Object.defineProperty(entity, name, descriptor);
        } else {
          this.$check();
          Object.defineProperty(entity.$data, name, descriptor);
        }
        return true;
      },

      getOwnPropertyDescriptor: (entity: Entity, name: string) => {
        if (
          typeof name !== 'string' ||
          name in entity ||
          name.substring(0, 1) === '$'
        ) {
          if (name === 'tags' || name === 'cdate' || name === 'mdate') {
            this.$check();
          }
          return Object.getOwnPropertyDescriptor(entity, name);
        } else {
          this.$check();
          return Object.getOwnPropertyDescriptor(entity.$data, name);
        }
      },

      ownKeys: (entity: Entity) => {
        this.$check();
        return Object.getOwnPropertyNames(entity).concat(
          Object.getOwnPropertyNames(entity.$data),
        );
      },
    }) as Entity<T>;
  }

  /**
   * Create or retrieve a new entity instance.
   *
   * Note that this will always return an entity, even if the GUID is not found.
   *
   * @param guid An optional GUID to retrieve.
   */
  public static async factory<E extends Entity>(
    this: {
      new (): E;
    },
    guid?: string,
  ): Promise<E & EntityDataType<E>> {
    const entity = new this();
    if (guid != null) {
      const entity = await (
        this as unknown as EntityConstructor
      ).nymph.getEntity(
        {
          class: this as unknown as EntityConstructor,
        },
        { type: '&', guid },
      );
      if (entity != null) {
        return entity as E & EntityDataType<E>;
      }
    }
    return entity as E & EntityDataType<E>;
  }

  /**
   * Create a new entity instance.
   */
  public static factorySync<E extends Entity>(this: {
    new (): E;
  }): E & EntityDataType<E> {
    return new this() as E & EntityDataType<E>;
  }

  /**
   * Create a new sleeping reference instance.
   *
   * Sleeping references won't retrieve their data from the database until they
   * are readied with `$wake()` or a parent's `$wakeAll()`.
   *
   * @param reference The Nymph Entity Reference to use to wake.
   * @returns The new instance.
   */
  public static factoryReference<E extends Entity>(
    this: {
      new (): E;
    },
    reference: EntityReference,
  ): E & EntityDataType<E> {
    const entity = new this();
    entity.$referenceSleep(reference);
    return entity as E & EntityDataType<E>;
  }

  /**
   * Get an array of strings that **must** be unique across the current etype.
   *
   * When you try to save another entity with any of the same unique strings,
   * Nymph will throw an error.
   *
   * The default implementation of this static method instantiates the entity,
   * assigns all of the given data, then calls `$getUniques` and returns its
   * output. This can have a performance impact if a lot of extra processing
   * happens during any of these steps. You can override this method to
   * calculate the unique strings faster, but you must return the same strings
   * that would be returned by `$getUniques`.
   *
   * @returns Resolves to an array of entity's unique constraint strings.
   */
  public static async getUniques({
    guid,
    cdate,
    mdate,
    tags,
    data,
    sdata,
  }: {
    guid?: string;
    cdate?: number;
    mdate?: number;
    tags: string[];
    data: EntityData;
    sdata?: SerializedEntityData;
  }) {
    const entity = new this();
    if (guid != null) {
      entity.guid = guid;
    }
    if (cdate != null) {
      entity.cdate = cdate;
    }
    if (mdate != null) {
      entity.mdate = mdate;
    }
    entity.tags = tags;
    entity.$putData(data, sdata);
    return await entity.$getUniques();
  }

  /**
   * Get a string for full text search for one of an entity's properties.
   *
   * The result will be tokenized and stored as the full text search index for
   * use with "search" clauses.
   *
   * Return null to not include any tokens in the full text search storage.
   *
   * You shouldn't use any other entity data to transform the text, as it won't
   * always be available. This function is meant for things like stripping HTML
   * tags.
   *
   * @returns By default, returns the value if it is a string.
   */
  public static getFTSText(_name: string, value: any) {
    if (typeof value === 'string') {
      return value;
    }
    return null;
  }

  public toJSON() {
    if (this.$isASleepingReference) {
      return this.$sleepingReference;
    }
    const obj: EntityJson = {
      class: (this.constructor as any).class as string,
      guid: this.guid,
      cdate: this.cdate,
      mdate: this.mdate,
      tags: [...this.tags],
      data: {},
    };
    for (const key of [
      ...Object.keys(this.$dataStore),
      ...Object.keys(this.$sdata),
    ]) {
      if (this.$privateData.indexOf(key) === -1) {
        obj.data[key] = entitiesToReferences(this.$data[key], true);
      }
    }
    return obj;
  }

  public $getGuaranteedGUID() {
    if (this.guid !== null) {
      return this.guid;
    }
    if (this.$guaranteedGUID == null) {
      this.$guaranteedGUID = guid();
    }
    return this.$guaranteedGUID;
  }

  public $addTag(...tags: string[]) {
    this.$check();

    if (tags.length < 1) {
      return;
    }
    this.tags = uniqueStrings([...this.tags, ...tags]);
  }

  public $arraySearch(array: any[], strict = false) {
    this.$check();

    if (!Array.isArray(array)) {
      return -1;
    }
    for (let i = 0; i < array.length; i++) {
      const curEntity = array[i];
      if (strict ? this.$equals(curEntity) : this.$is(curEntity)) {
        return i;
      }
    }
    return -1;
  }

  public $clearCache() {
    this.$check();

    this.$putData(this.$getData(false, true), this.$getSData());
  }

  public $getClientEnabledMethods() {
    return this.$clientEnabledMethods;
  }

  public async $delete(): Promise<boolean> {
    this.$check();

    return await this.$nymph.deleteEntity(this);
  }

  public $equals(object: any) {
    this.$check();

    if (!(object instanceof Entity)) {
      return false;
    }
    if (this.guid || object.guid) {
      if (this.guid !== object.guid) {
        return false;
      }
    }
    if (object.cdate !== this.cdate) {
      return false;
    }
    if (object.mdate !== this.mdate) {
      return false;
    }
    const obTags = [...object.tags].sort();
    const myTags = [...this.tags].sort();
    if (!isEqual(obTags, myTags)) {
      return false;
    }
    const obData = object.$getData(true, true);
    const myData = this.$getData(true, true);
    return isEqual(obData, myData);
  }

  public $getData(includeSData = false, referenceOnlyExisting?: boolean) {
    this.$check();

    if (includeSData) {
      // Access all the serialized properties to initialize them.
      for (const key in this.$sdata) {
        const _unused: any = (this as any)[key];
      }
    }
    return entitiesToReferences({ ...this.$dataStore }, referenceOnlyExisting);
  }

  public $getSData() {
    this.$check();

    return this.$sdata;
  }

  public async $getUniques(): Promise<string[]> {
    return [];
  }

  public $getOriginalAcValues(): ACProperties {
    this.$check();

    return this.$originalAcValues ?? this.$getCurrentAcValues();
  }

  public $getCurrentAcValues(): ACProperties {
    this.$check();

    return {
      user: this.$getAcUid(),
      group: this.$getAcGid(),
      acUser: this.$data.acUser ?? null,
      acGroup: this.$data.acGroup ?? null,
      acOther: this.$data.acOther ?? null,
      acRead: this.$getAcReadIds(),
      acWrite: this.$getAcWriteIds(),
      acFull: this.$getAcFullIds(),
    };
  }

  public $getAcUid() {
    if ('user' in this.$sdata) {
      const userValue = JSON.parse(this.$sdata.user);
      if (
        Array.isArray(userValue) &&
        userValue[0] === 'nymph_entity_reference'
      ) {
        return userValue[1];
      }
    }
    return (this.$data.user?.guid ?? null) as string | null;
  }
  public $getAcGid() {
    if ('group' in this.$sdata) {
      const groupValue = JSON.parse(this.$sdata.group);
      if (
        Array.isArray(groupValue) &&
        groupValue[0] === 'nymph_entity_reference'
      ) {
        return groupValue[1];
      }
    }
    return (this.$data.group?.guid ?? null) as string | null;
  }
  public $getAcReadIds() {
    return (this.$data.acRead?.map((entity: EntityInterface | string) =>
      typeof entity === 'string' ? entity : entity.guid,
    ) ?? null) as (string | null)[] | null;
  }
  public $getAcWriteIds() {
    return (this.$data.acWrite?.map((entity: EntityInterface | string) =>
      typeof entity === 'string' ? entity : entity.guid,
    ) ?? null) as (string | null)[] | null;
  }
  public $getAcFullIds() {
    return (this.$data.acFull?.map((entity: EntityInterface | string) =>
      typeof entity === 'string' ? entity : entity.guid,
    ) ?? null) as (string | null)[] | null;
  }

  public $getValidatable() {
    this.$check();

    // Access all the serialized properties to initialize them.
    for (const key in this.$sdata) {
      const _unused: any = (this as any)[key];
    }
    return {
      guid: this.guid,
      cdate: this.cdate,
      mdate: this.mdate,
      tags: this.tags,
      ...referencesToEntities(this.$dataStore, this.$nymph, this.$skipAc),
    };
  }

  public $getTags() {
    this.$check();

    return this.tags;
  }

  public $hasTag(...tags: string[]) {
    this.$check();

    if (!tags.length) {
      return false;
    }
    for (const tag of tags) {
      if (this.tags.indexOf(tag) === -1) {
        return false;
      }
    }
    return true;
  }

  public $inArray(array: any[], strict = false) {
    return this.$arraySearch(array, strict) !== -1;
  }

  public $is(object: any) {
    if (!(object instanceof Entity)) {
      return false;
    }

    if (this === object) {
      return true;
    }

    if (this.guid != null || object.guid != null) {
      return this.guid === object.guid;
    }

    this.$check();
    if (typeof object.$getData !== 'function') {
      return false;
    } else {
      const obTags = [...object.tags].sort();
      const myTags = [...this.tags].sort();
      if (!isEqual(obTags, myTags)) {
        return false;
      }
      const obData = object.$getData(true, true);
      const myData = this.$getData(true, true);
      return isEqual(obData, myData);
    }
  }

  public $jsonAcceptData(input: EntityJson, allowConflict = false) {
    this.$check();

    if (this.guid != input.guid) {
      throw new EntityConflictError(
        'Tried to accept JSON input for the wrong entity.',
      );
    }

    // Accept the modified date.
    const mdate = input.mdate ?? 0;
    const thismdate = this.mdate ?? 0;
    if (mdate < thismdate && !allowConflict) {
      throw new EntityConflictError('This entity is newer than JSON input.');
    }
    this.mdate = input.mdate;

    // Accept the tags.
    const currentTags = this.$getTags();
    const protectedTags = intersection(this.$protectedTags, currentTags);
    let tags = difference(input.tags, this.$protectedTags);

    if (this.$allowlistTags != null) {
      tags = intersection(tags, this.$allowlistTags);
    }

    this.$removeTag(...currentTags);
    this.$addTag(...protectedTags, ...tags);

    // Accept the data.
    const data = { ...input.data };
    const privateData: EntityData = {};
    for (const name of this.$privateData) {
      if (name in this.$data) {
        privateData[name] = this.$data[name];
      }
      if (name in data) {
        delete data[name];
      }
    }

    const protectedData: EntityData = {};
    const protectedProps = [...this.$protectedData];
    if (this.$nymph.tilmeld) {
      protectedProps.push('acUser');
      protectedProps.push('acGroup');
      protectedProps.push('acOther');
      protectedProps.push('acRead');
      protectedProps.push('acWrite');
      protectedProps.push('acFull');
      if (
        ((this.constructor as EntityConstructor).class !== 'User' &&
          (this.constructor as EntityConstructor).class !== 'Group') ||
        !(this.$nymph.tilmeld as any).currentUser ||
        (!(this.$nymph.tilmeld as any).currentUser.abilities?.includes(
          'tilmeld/admin',
        ) &&
          !(this.$nymph.tilmeld as any).currentUser.abilities?.includes(
            'system/admin',
          ))
      ) {
        protectedProps.push('user');
        protectedProps.push('group');
      }
    }
    for (const name of protectedProps) {
      if (name in this.$data) {
        protectedData[name] = this.$data[name];
      }
      if (name in data) {
        delete data[name];
      }
    }

    let nonAllowlistData: EntityData = {};
    if (this.$allowlistData != null) {
      nonAllowlistData = { ...this.$getData(true, true) };
      for (const name of this.$allowlistData) {
        delete nonAllowlistData[name];
      }
      for (const name in data) {
        if (this.$allowlistData.indexOf(name) === -1) {
          delete data[name];
        }
      }
    }

    this.$putData({
      ...nonAllowlistData,
      ...data,
      ...protectedData,
      ...privateData,
    });
  }

  public $jsonAcceptPatch(patch: EntityPatch, allowConflict = false) {
    this.$check();

    if (this.guid != patch.guid) {
      throw new EntityConflictError(
        'Tried to accept JSON patch for the wrong entity.',
      );
    }

    // Accept the modified date.
    const mdate = patch.mdate ?? 0;
    const thismdate = this.mdate ?? 0;
    if (mdate < thismdate && !allowConflict) {
      throw new EntityConflictError('This entity is newer than JSON patch.');
    }
    this.mdate = patch.mdate;

    const protectedProps = [...this.$protectedData];
    if (this.$nymph.tilmeld) {
      protectedProps.push('acUser');
      protectedProps.push('acGroup');
      protectedProps.push('acOther');
      protectedProps.push('acRead');
      protectedProps.push('acWrite');
      protectedProps.push('acFull');
      if (
        ((this.constructor as EntityConstructor).class !== 'User' &&
          (this.constructor as EntityConstructor).class !== 'Group') ||
        !(this.$nymph.tilmeld as any).currentUser ||
        (!(this.$nymph.tilmeld as any).currentUser.abilities?.includes(
          'tilmeld/admin',
        ) &&
          !(this.$nymph.tilmeld as any).currentUser.abilities?.includes(
            'system/admin',
          ))
      ) {
        protectedProps.push('user');
        protectedProps.push('group');
      }
    }

    for (const name in patch.set) {
      if (
        (this.$allowlistData != null &&
          this.$allowlistData.indexOf(name) === -1) ||
        protectedProps.indexOf(name) !== -1 ||
        this.$privateData.indexOf(name) !== -1
      ) {
        continue;
      }
      (this.$data as any)[name] = referencesToEntities(
        patch.set[name],
        this.$nymph,
        this.$skipAc,
      );
    }

    for (const name of patch.unset) {
      if (
        (this.$allowlistData != null &&
          this.$allowlistData.indexOf(name) === -1) ||
        protectedProps.indexOf(name) !== -1 ||
        this.$privateData.indexOf(name) !== -1
      ) {
        continue;
      }
      delete this.$data[name];
    }

    for (const tag of patch.addTags) {
      if (
        (this.$allowlistTags != null &&
          this.$allowlistTags.indexOf(tag) === -1) ||
        this.$protectedTags.indexOf(tag) !== -1
      ) {
        continue;
      }
      this.$addTag(tag);
    }

    for (const tag of patch.removeTags) {
      if (
        (this.$allowlistTags != null &&
          this.$allowlistTags.indexOf(tag) === -1) ||
        this.$protectedTags.indexOf(tag) !== -1
      ) {
        continue;
      }
      this.$removeTag(tag);
    }
  }

  public $putData(
    data: EntityData,
    sdata?: SerializedEntityData,
    _source?: 'server',
  ) {
    this.$check();

    const mySdata = sdata ?? this.$getSData();
    for (const name in data) {
      delete mySdata[name];
    }
    for (const name in this.$dataStore) {
      delete this.$dataStore[name];
    }
    for (const name in data) {
      (this.$dataStore as any)[name] = referencesToEntities(
        data[name],
        this.$nymph,
        this.$skipAc,
      );
    }
    this.$sdata = mySdata;
    // Set original AC values if not set..
    if (this.$originalAcValues == null) {
      this.$originalAcValues = this.$getCurrentAcValues();
    }
  }

  /**
   * Set up a sleeping reference.
   * @param array $reference The reference to use to wake.
   */
  protected $referenceSleep(reference: EntityReference) {
    if (
      reference.length !== 3 ||
      reference[0] !== 'nymph_entity_reference' ||
      typeof reference[1] !== 'string' ||
      typeof reference[2] !== 'string'
    ) {
      throw new InvalidParametersError(
        'referenceSleep expects parameter 1 to be a valid Nymph entity ' +
          'reference.',
      );
    }
    const thisClass: string = (this.constructor as any).class;
    if (reference[2] !== thisClass) {
      throw new InvalidParametersError(
        'referenceSleep can only be called with an entity reference of the ' +
          `same class. Given class: ${reference[2]}; this class: ${thisClass}.`,
      );
    }
    this.$isASleepingReference = true;
    this.guid = reference[1];
    this.$sleepingReference = reference;
  }

  /**
   * Check if this is a sleeping reference and throw an error if so.
   */
  protected $check() {
    if (this.$isASleepingReference || this.$sleepingReference != null) {
      throw new EntityIsSleepingReferenceError(
        'This entity is in a sleeping reference state. You must use .$wake() to wake it.',
      );
    }
  }

  /**
   * Check if this is a sleeping reference.
   */
  public $asleep() {
    return this.$isASleepingReference || this.$sleepingReference != null;
  }

  /**
   * Wake from a sleeping reference.
   */
  public $wake() {
    if (!this.$isASleepingReference || this.$sleepingReference == null) {
      this.$wakePromise = null;
      return Promise.resolve(this);
    }
    if (this.$sleepingReference?.[1] == null) {
      throw new InvalidStateError(
        'Tried to wake a sleeping reference with no GUID.',
      );
    }
    if (!this.$wakePromise) {
      const EntityClass = this.$nymph.getEntityClass(
        this.$sleepingReference[2],
      );
      this.$wakePromise = this.$nymph
        .getEntity(
          {
            class: EntityClass,
            skipAc: this.$skipAc,
          },
          { type: '&', guid: this.$sleepingReference[1] },
        )
        .then((entity) => {
          if (entity == null || entity.guid == null) {
            return Promise.reject(
              new InvalidStateError(
                'The sleeping reference could not be retrieved.',
              ),
            );
          }
          this.$isASleepingReference = false;
          this.$sleepingReference = null;
          this.guid = entity.guid;
          this.tags = entity.tags;
          this.cdate = entity.cdate;
          this.mdate = entity.mdate;
          this.$putData(
            entity.$getData(false, true),
            entity.$getSData(),
            'server',
          );

          return this;
        })
        .finally(() => {
          this.$wakePromise = null;
        });
    }
    return this.$wakePromise;
  }

  public $wakeAll(level?: number) {
    return new Promise((resolve, reject) => {
      // Run this once this entity is wake.
      const wakeProps = () => {
        let newLevel;
        // If level is undefined, keep going forever, otherwise, stop once we've
        // gone deep enough.
        if (level !== undefined) {
          newLevel = level - 1;
        }
        if (newLevel !== undefined && newLevel < 0) {
          resolve(this);
          return;
        }
        const promises = [];
        // Go through data looking for entities to wake.
        for (let [key, value] of Object.entries(this.$data)) {
          if (value instanceof Entity && value.$isASleepingReference) {
            promises.push(value.$wakeAll(newLevel));
          } else if (Array.isArray(value)) {
            for (let i = 0; i < value.length; i++) {
              if (
                value[i] instanceof Entity &&
                value[i].$isASleepingReference
              ) {
                promises.push(value[i].$wakeAll(newLevel));
              }
            }
          }
        }
        if (promises.length) {
          Promise.all(promises).then(
            () => resolve(this),
            (errObj) => reject(errObj),
          );
        } else {
          resolve(this);
        }
      };

      if (this.$isASleepingReference) {
        this.$wake().then(wakeProps, (errObj) => reject(errObj));
      } else {
        wakeProps();
      }
    }) as Promise<Entity<T>>;
  }

  public async $refresh() {
    await this.$wake();

    if (this.guid == null) {
      return false;
    }
    const refresh = await this.$nymph.getEntity(
      {
        class: this.$nymph.getEntityClass(
          this.constructor as EntityConstructor,
        ),
        skipCache: true,
        skipAc: this.$skipAc,
      },
      { type: '&', guid: this.guid },
    );
    if (refresh == null) {
      return 0;
    }
    this.tags = refresh.tags;
    this.cdate = refresh.cdate;
    this.mdate = refresh.mdate;
    this.$putData(refresh.$getData(false, true), refresh.$getSData(), 'server');
    return true;
  }

  public $removeTag(...tags: string[]) {
    this.$check();

    this.tags = difference(this.tags, tags);
  }

  public async $save(): Promise<boolean> {
    await this.$wake();

    return await this.$nymph.saveEntity(this);
  }

  public $toReference(existingOnly?: boolean) {
    if (this.$isASleepingReference && this.$sleepingReference != null) {
      return this.$sleepingReference;
    }
    if (existingOnly && this.guid == null) {
      return this;
    }
    return [
      'nymph_entity_reference',
      this.$getGuaranteedGUID(),
      (this.constructor as any).class as string,
    ] as EntityReference;
  }

  public $useSkipAc(skipAc: boolean) {
    this.$skipAc = !!skipAc;
  }
}
