import Entity, { type EntityInstanceType } from './Entity.js';
import type {
  EntityConstructor,
  EntityInterface,
  EntityJson,
  ServerCallResponse,
  ServerCallStaticResponse,
} from './Entity.types.js';
import EntityWeakCache from './EntityWeakCache.js';
import type { AbortableAsyncIterator } from './HttpRequester.js';
import HttpRequester, { ClientError } from './HttpRequester.js';
import type {
  EventType,
  NymphOptions,
  Options,
  RequestCallback,
  ResponseCallback,
  Selector,
} from './Nymph.types.js';
import type PubSub from './PubSub.js';
import {
  entitiesToReferences,
  entityConstructorsToClassNames,
} from './utils.js';

let requester: HttpRequester;

export default class Nymph {
  /**
   * And optional PubSub client instance.
   */
  public pubsub: PubSub | undefined = undefined;

  /**
   * A simple map of names to Entity classes.
   */
  private entityClasses: { [k: string]: EntityConstructor } = {};

  /**
   * The entity class for this instance of Nymph.
   */
  public Entity: typeof Entity;

  private requestCallbacks: RequestCallback[] = [];
  private responseCallbacks: ResponseCallback[] = [];
  private restUrl: string = '';
  private weakCache = false;
  /**
   * Headers that will be sent with every request.
   *
   * These are used by Tilmeld for authentication.
   */
  public headers: { [k: string]: string } = {};
  /**
   * The entity cache.
   */
  public cache = new EntityWeakCache();
  /**
   * Return `null` or empty array instead of error when entity/ies not found.
   */
  public returnNullOnNotFound = false;

  public constructor(NymphOptions: NymphOptions) {
    this.restUrl = NymphOptions.restUrl;
    // @ts-ignore TS doesn't know about WeakRef.
    this.weakCache = !!NymphOptions.weakCache && typeof WeakRef !== 'undefined';
    if (
      'returnNullOnNotFound' in NymphOptions &&
      NymphOptions.returnNullOnNotFound != null
    ) {
      this.returnNullOnNotFound = NymphOptions.returnNullOnNotFound;
    }

    this.Entity = this.addEntityClass(Entity);

    requester = new HttpRequester(
      'fetch' in NymphOptions ? NymphOptions.fetch : undefined,
    );

    if ('renewTokens' in NymphOptions && !NymphOptions.renewTokens) {
      this.headers['X-Tilmeld-Token-Renewal'] = 'off';
    }

    requester.on('request', (_requester, url, options) => {
      for (let i = 0; i < this.requestCallbacks.length; i++) {
        this.requestCallbacks[i] && this.requestCallbacks[i](url, options);
      }
    });

    requester.on('response', (_requester, response, text) => {
      for (let i = 0; i < this.responseCallbacks.length; i++) {
        this.responseCallbacks[i] && this.responseCallbacks[i](response, text);
      }
    });
  }

  /**
   * Add your class to this instance.
   *
   * This will create a class that extends your class within this instance of
   * Nymph and return it. You can then use this class's constructor and methods,
   * which will use this instance of Nymph.
   *
   * Because this creates a subclass, don't use the class returned from
   * `getEntityClass` to check with `instanceof`. Instead, use the base class
   * that you passed into this method.
   */
  public addEntityClass<T extends EntityConstructor>(entityClass: T): T {
    const nymph = this;
    class NymphEntity extends entityClass {
      static nymph: Nymph = nymph;

      constructor(...args: any[]) {
        super(...args);
      }
    }
    this.entityClasses[entityClass.class] = NymphEntity;
    return NymphEntity;
  }

  public getEntityClass<T extends EntityConstructor>(className: T): T;
  public getEntityClass(className: string): EntityConstructor;
  public getEntityClass<T extends EntityConstructor = EntityConstructor>(
    className: T | string,
  ): T | EntityConstructor {
    let key: string | null = null;
    if (typeof className === 'string') {
      key = className;
    } else {
      key = className.class;
    }
    if (key in this.entityClasses) {
      return this.entityClasses[key];
    }
    throw new ClassNotAvailableError('Tried to use class: ' + key);
  }

  public async newUID(name: string) {
    const data = await requester.POST({
      url: this.restUrl,
      headers: { ...this.headers },
      dataType: 'text',
      data: { action: 'uid', data: name },
    });
    return Number(data);
  }

  public async setUID(name: string, value: number) {
    return await requester.PUT({
      url: this.restUrl,
      headers: { ...this.headers },
      dataType: 'json',
      data: { action: 'uid', data: { name, value } },
    });
  }

  public async getUID(name: string) {
    const data = await requester.GET({
      url: this.restUrl,
      headers: { ...this.headers },
      dataType: 'text',
      data: { action: 'uid', data: name },
    });
    return Number(data);
  }

  public async deleteUID(name: string) {
    return await requester.DELETE({
      url: this.restUrl,
      headers: { ...this.headers },
      dataType: 'text',
      data: { action: 'uid', data: name },
    });
  }

  public async saveEntity(entity: EntityInterface) {
    let method: 'POST' | 'PUT' = entity.guid == null ? 'POST' : 'PUT';
    return await this.requestWithMethod(entity, method, entity, false);
  }

  public async saveEntities(entities: EntityInterface[]) {
    if (!entities.length) {
      return Promise.resolve(false);
    }

    let method: 'POST' | 'PUT' = entities[0].guid == null ? 'POST' : 'PUT';
    entities.forEach((cur) => {
      if (
        (method === 'POST' && cur.guid != null) ||
        (method === 'PUT' && cur.guid == null)
      ) {
        throw new InvalidRequestError(
          'Due to REST restriction, you can only create new entities or ' +
            'update existing entities, not both at the same time.',
        );
      }
    });
    return await this.requestWithMethod(entities, method, entities, true);
  }

  public async patchEntity(entity: EntityInterface) {
    if (entity.guid == null) {
      throw new InvalidRequestError(
        "You can't patch an entity that hasn't yet been saved.",
      );
    }

    let patch = entity.$getPatch();
    return await this.requestWithMethod(entity, 'PATCH', patch, false);
  }

  public async patchEntities(entities: EntityInterface[]) {
    if (!entities.length) {
      return Promise.resolve(false);
    }

    entities.forEach((cur) => {
      if (cur.guid == null) {
        throw new InvalidRequestError(
          'Due to REST restriction, you can only create new entities or ' +
            'update existing entities, not both at the same time.',
        );
      }
    });
    let patch = entities.map((e) => e.$getPatch());
    return await this.requestWithMethod(entities, 'PATCH', patch, true);
  }

  private async requestWithMethod<T extends EntityInterface>(
    entity: T,
    method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
    data: { [k: string]: any },
    plural: false,
  ): Promise<T>;
  private async requestWithMethod<T extends EntityInterface>(
    entity: T[],
    method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
    data: { [k: string]: any },
    plural: true,
  ): Promise<T[]>;
  private async requestWithMethod<T extends EntityInterface>(
    entity: T | T[],
    method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
    data: { [k: string]: any },
    plural: boolean,
  ): Promise<T | T[]> {
    const response = await requester[method]({
      url: this.restUrl,
      headers: { ...this.headers },
      dataType: 'json',
      data: {
        action: plural ? 'entities' : 'entity',
        data,
      },
    });
    if (plural && Array.isArray(entity) && entity.length === response.length) {
      return entity.map((e, i) =>
        response[i] &&
        typeof response[i].guid !== 'undefined' &&
        (e.guid == null || e.guid === response[i].guid)
          ? e.$init(response[i])
          : e,
      ) as T[];
    } else if (!Array.isArray(entity) && typeof response.guid !== 'undefined') {
      return entity.$init(response) as T;
    }
    throw new Error('Server error');
  }

  public async getEntity<T extends EntityConstructor = EntityConstructor>(
    options: Options<T> & { return: 'count' },
    ...selectors: Selector[]
  ): Promise<number>;
  public async getEntity<T extends EntityConstructor = EntityConstructor>(
    options: Options<T> & { return: 'guid' },
    ...selectors: Selector[]
  ): Promise<string | null>;
  public async getEntity<T extends EntityConstructor = EntityConstructor>(
    options: Options<T>,
    ...selectors: Selector[]
  ): Promise<EntityInstanceType<T> | null>;
  public async getEntity<T extends EntityConstructor = EntityConstructor>(
    options: Options<T> & { return: 'count' },
    guid: string,
  ): Promise<number>;
  public async getEntity<T extends EntityConstructor = EntityConstructor>(
    options: Options<T> & { return: 'guid' },
    guid: string,
  ): Promise<string | null>;
  public async getEntity<T extends EntityConstructor = EntityConstructor>(
    options: Options<T>,
    guid: string,
  ): Promise<EntityInstanceType<T> | null>;
  public async getEntity<T extends EntityConstructor = EntityConstructor>(
    options: Options<T>,
    ...selectors: Selector[] | string[]
  ): Promise<EntityInstanceType<T> | string | number | null> {
    let data: any = null;
    try {
      // @ts-ignore: Implementation signatures of overloads are not externally visible.
      data = (await this.getEntityData(options, ...selectors)) as
        | EntityJson<T>
        | string
        | number
        | null;
    } catch (e: any) {
      if (
        this.returnNullOnNotFound &&
        e instanceof ClientError &&
        e.status === 404
      ) {
        data = null;
      } else {
        throw e;
      }
    }

    if (options.return && options.return === 'count') {
      return Number(data ?? 0) as number;
    }
    if (data != null) {
      if (options.return && options.return === 'guid') {
        return data as string;
      } else {
        return this.initEntity(data as EntityJson<T>);
      }
    }

    return null;
  }

  public async getEntityData<T extends EntityConstructor = EntityConstructor>(
    options: Options<T> & { return: 'count' },
    ...selectors: Selector[]
  ): Promise<number>;
  public async getEntityData<T extends EntityConstructor = EntityConstructor>(
    options: Options<T> & { return: 'guid' },
    ...selectors: Selector[]
  ): Promise<string | null>;
  public async getEntityData<T extends EntityConstructor = EntityConstructor>(
    options: Options<T>,
    ...selectors: Selector[]
  ): Promise<EntityJson<T> | null>;
  public async getEntityData<T extends EntityConstructor = EntityConstructor>(
    options: Options<T> & { return: 'count' },
    guid: string,
  ): Promise<number>;
  public async getEntityData<T extends EntityConstructor = EntityConstructor>(
    options: Options<T> & { return: 'guid' },
    guid: string,
  ): Promise<string | null>;
  public async getEntityData<T extends EntityConstructor = EntityConstructor>(
    options: Options<T>,
    guid: string,
  ): Promise<EntityJson<T> | null>;
  public async getEntityData<T extends EntityConstructor = EntityConstructor>(
    options: Options<T>,
    ...selectors: Selector[] | string[]
  ): Promise<EntityJson<T> | string | number | null> {
    if (options.class instanceof Entity) {
      throw new InvalidRequestError(
        "You can't make REST requests with the base Entity class.",
      );
    }
    // Set up options and selectors.
    if (typeof selectors[0] === 'string') {
      selectors = [{ type: '&', guid: selectors[0] }];
    }
    const data = await requester.GET({
      url: this.restUrl,
      headers: { ...this.headers },
      dataType: 'json',
      data: {
        action: 'entity',
        data: [
          { ...options, class: options.class.class },
          ...entityConstructorsToClassNames(selectors),
        ],
      },
    });

    if (options.return === 'count' || typeof data.guid !== 'undefined') {
      return data;
    }
    return null;
  }

  public async getEntities<T extends EntityConstructor = EntityConstructor>(
    options: Options<T> & { return: 'count' },
    ...selectors: Selector[]
  ): Promise<number>;
  public async getEntities<T extends EntityConstructor = EntityConstructor>(
    options: Options<T> & { return: 'guid' },
    ...selectors: Selector[]
  ): Promise<string[]>;
  public async getEntities<T extends EntityConstructor = EntityConstructor>(
    options: Options<T>,
    ...selectors: Selector[]
  ): Promise<EntityInstanceType<T>[]>;
  public async getEntities<T extends EntityConstructor = EntityConstructor>(
    options: Options<T>,
    ...selectors: Selector[]
  ): Promise<EntityInstanceType<T>[] | string[] | number> {
    let data = null;
    try {
      data = await requester.GET({
        url: this.restUrl,
        headers: { ...this.headers },
        dataType: 'json',
        data: {
          action: 'entities',
          data: [
            { ...options, class: options.class.class },
            ...entityConstructorsToClassNames(selectors),
          ],
        },
      });
    } catch (e: any) {
      if (
        this.returnNullOnNotFound &&
        e instanceof ClientError &&
        e.status === 404
      ) {
        data = null;
      } else {
        throw e;
      }
    }

    if (options.return && options.return === 'count') {
      return Number(data ?? 0) as number;
    }
    if (data == null) {
      return [];
    }
    if (options.return && options.return === 'guid') {
      return data;
    }
    return data.map((e: EntityJson<T>) => this.initEntity(e));
  }

  public initEntity<T extends EntityConstructor = EntityConstructor>(
    entityJSON: EntityJson<T>,
  ): EntityInstanceType<T> {
    const EntityClass = this.getEntityClass(entityJSON.class);
    if (!EntityClass) {
      throw new ClassNotAvailableError(
        entityJSON.class + ' class cannot be found.',
      );
    }
    let entity = EntityClass.factorySync();
    if (this.weakCache) {
      // Try to get it from cache.
      const entityFromCache = this.cache.get(
        EntityClass,
        entityJSON.guid || '',
      );
      if (entityFromCache != null) {
        entity = entityFromCache as EntityInstanceType<T>;
      }
    }
    return entity.$init(entityJSON) as EntityInstanceType<T>;
  }

  public getEntityFromCache<T extends EntityConstructor = EntityConstructor>(
    EntityClass: EntityConstructor,
    guid: string,
  ): EntityInstanceType<T> | null {
    if (!this.weakCache) {
      return null;
    }
    return this.cache.get(EntityClass, guid) as EntityInstanceType<T> | null;
  }

  public setEntityToCache(
    EntityClass: EntityConstructor,
    entity: EntityInterface,
  ) {
    if (!this.weakCache) {
      return;
    }
    return this.cache.set(EntityClass, entity);
  }

  public initEntitiesFromData<T extends any>(item: T): T {
    if (Array.isArray(item)) {
      // Recurse into lower arrays.
      return item.map((entry) => this.initEntitiesFromData(entry)) as T;
    } else if (item instanceof Object && !(item instanceof Entity)) {
      if (
        item.hasOwnProperty('class') &&
        item.hasOwnProperty('guid') &&
        item.hasOwnProperty('cdate') &&
        item.hasOwnProperty('mdate') &&
        item.hasOwnProperty('tags') &&
        item.hasOwnProperty('data') &&
        this.getEntityClass((item as any as EntityJson).class)
      ) {
        return this.initEntity(item as any as EntityJson) as T;
      } else {
        for (let [key, value] of Object.entries(item)) {
          // @ts-ignore: Key comes from the object. It's fine.
          item[key] = this.initEntitiesFromData(value);
        }
      }
    }
    // Not an entity or array, just return it.
    return item;
  }

  public async deleteEntity(
    entity: EntityInterface | EntityInterface[],
    _plural = false,
  ) {
    return await requester.DELETE({
      url: this.restUrl,
      headers: { ...this.headers },
      dataType: 'json',
      data: {
        action: _plural ? 'entities' : 'entity',
        data:
          _plural && Array.isArray(entity)
            ? entity.map((e) => ({
                guid: e.guid,
                class: (e.constructor as EntityConstructor).class,
              }))
            : {
                guid: (entity as EntityInterface).guid,
                class: (entity.constructor as EntityConstructor).class,
              },
      },
    });
  }

  public async deleteEntities(entities: EntityInterface[]) {
    return await this.deleteEntity(entities, true);
  }

  public async serverCall(
    entity: EntityInterface,
    method: string,
    params: any[],
    stateless = false,
  ): Promise<ServerCallResponse> {
    const data = await requester.POST({
      url: this.restUrl,
      headers: { ...this.headers },
      dataType: 'json',
      data: {
        action: 'method',
        data: {
          entity,
          stateless,
          method,
          params: entitiesToReferences(entityConstructorsToClassNames(params)),
        },
      },
    });

    return {
      ...data,
      return: this.initEntitiesFromData(data.return),
    };
  }

  public async serverCallStatic(
    className: string,
    method: string,
    params: any[],
  ): Promise<ServerCallStaticResponse> {
    const data = await requester.POST({
      url: this.restUrl,
      headers: { ...this.headers },
      dataType: 'json',
      data: {
        action: 'method',
        data: {
          class: className,
          static: true,
          method: method,
          params: entitiesToReferences(entityConstructorsToClassNames(params)),
        },
      },
    });

    return this.initEntitiesFromData(data);
  }

  public async serverCallStaticIterator(
    className: string,
    method: string,
    params: any[],
  ): Promise<AbortableAsyncIterator<ServerCallStaticResponse>> {
    const iterable = await requester.POST_ITERATOR({
      url: this.restUrl,
      headers: { ...this.headers },
      dataType: 'json',
      data: {
        action: 'method',
        data: {
          class: className,
          static: true,
          method: method,
          iterator: true,
          params: entitiesToReferences(entityConstructorsToClassNames(params)),
        },
      },
    });

    const that = this;
    const iterator: AbortableAsyncIterator = {
      abortController: iterable.abortController,
      async *[Symbol.asyncIterator]() {
        for await (let response of iterable) {
          if (response instanceof Error) {
            yield response;
          } else {
            yield that.initEntitiesFromData(response);
          }
        }
      },
    };

    return iterator;
  }

  public on<T extends EventType>(
    event: T,
    callback: T extends 'request'
      ? RequestCallback
      : T extends 'response'
        ? ResponseCallback
        : never,
  ) {
    const prop = (event + 'Callbacks') as T extends 'request'
      ? 'requestCallbacks'
      : T extends 'request'
        ? 'responseCallbacks'
        : never;
    if (!(prop in this)) {
      throw new Error('Invalid event type.');
    }
    // @ts-ignore: The callback should always be the right type here.
    this[prop].push(callback);
    return () => this.off(event, callback);
  }

  public off<T extends EventType>(
    event: T,
    callback: T extends 'request'
      ? RequestCallback
      : T extends 'response'
        ? ResponseCallback
        : never,
  ) {
    const prop = (event + 'Callbacks') as T extends 'request'
      ? 'requestCallbacks'
      : T extends 'request'
        ? 'responseCallbacks'
        : never;
    if (!(prop in this)) {
      return false;
    }
    // @ts-ignore: The callback should always be the right type here.
    const i = this[prop].indexOf(callback);
    if (i > -1) {
      // @ts-ignore: The callback should always be the right type here.
      this[prop].splice(i, 1);
    }
    return true;
  }
}

export class ClassNotAvailableError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'ClassNotAvailableError';
  }
}

export class InvalidRequestError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'InvalidRequestError';
  }
}
