import Client from './client.js';
import { State, headStateFactory, HeadState, isState } from './state/index.js';
import { resolve } from './util/uri.js';
import { FollowPromiseOne, FollowPromiseMany } from './follow-promise.js';
import { Link, LinkNotFound, LinkVariables } from './link.js';
import { EventEmitter } from '#events';
import { GetRequestOptions, PostRequestOptions, PatchRequestOptions, PutRequestOptions, HeadRequestOptions } from './types.js';
import { needsJsonStringify } from '#fetch-body-helper';

/**
 * A 'resource' represents an endpoint on a server.
 *
 * A resource has a uri, methods that correspond to HTTP methods,
 * and events to subscribe to state changes.
 */
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
export class Resource<T = any> extends EventEmitter {

  /**
   * URI of the current resource
   */
  uri: string;

  /**
   * Reference to the Client that created the resource
   */
  client: Client;

  /**
   * This object tracks all in-flight requests.
   *
   * When 2 identical requests are made in quick succession, this object is
   * used to de-duplicate the requests.
   */
  private readonly activeRefresh: Map<string, Promise<State<T>>> = new Map<string, Promise<State<T>>>();

  /**
   * Create the resource.
   *
   * This is usually done by the Client.
   */
  constructor(client: Client, uri: string) {
    super();
    this.client = client;
    this.uri = uri;
    this.setMaxListeners(500);

  }

  /**
   * Gets the current state of the resource.
   *
   * This function will return a State object.
   */
  get(getOptions?: GetRequestOptions): Promise<State<T>> {

    const state = this.getCache();
    if (state) {
      return Promise.resolve(state);
    }

    const params = optionsToRequestInit('GET', getOptions);
    const uri = this.uri;

    const hash = requestHash(this.uri, getOptions);

    if (!this.activeRefresh.has(hash)) {
      this.activeRefresh.set(hash, (async (): Promise<State<T>> => {
        try {
          const response = await this.fetchOrThrow(params);
          const state = await this.client.getStateForResponse(uri, response);
          this.updateCache(state);
          return state;
        } finally {
          this.activeRefresh.delete(hash);
        }
      })());
    }

    return this.activeRefresh.get(hash)!;
  }

  /**
   * Does a HEAD request and returns a HeadState object.
   *
   * If there was a valid existing cache for a GET request, it will
   * still return that.
   */
  async head(headOptions?: HeadRequestOptions): Promise<HeadState> {

    let state: State|HeadState|null = this.client.cache.get(this.uri);
    if (state) {
      return state;
    }

    const response = await this.fetchOrThrow(
      optionsToRequestInit('HEAD', headOptions)
    );

    state = await headStateFactory(this.client, this.uri, response);
    return state;

  }


  /**
   * Gets the current state of the resource, skipping
   * the cache.
   *
   * This function will return a State object.
   */
  refresh(getOptions?: GetRequestOptions): Promise<State<T>> {

    const params = optionsToRequestInit('GET', getOptions);
    params.cache = 'no-cache';
    const uri = this.uri;

    const hash = requestHash(this.uri, getOptions);

    if (!this.activeRefresh.has(hash)) {
      this.activeRefresh.set(hash, (async (): Promise<State<T>> => {
        try {
          const response = await this.fetchOrThrow(params);
          const state = await this.client.getStateForResponse(uri, response);
          this.updateCache(state);
          return state;
        } finally {
          this.activeRefresh.delete(hash);
        }
      })());
    }

    return this.activeRefresh.get(hash)!;
  }

  /**
   * Updates the server state with a PUT request
   */
  async put(options: PutRequestOptions<T> | State): Promise<void> {

    const requestInit = optionsToRequestInit('PUT', options);

    /**
     * If we got a 'State' object passed, it means we don't need to emit a
     * stale event, as the passed object is the new
     * state.
     *
     * We're gonna track that with a custom header that will be removed
     * later in the fetch pipeline.
     */
    if (isState(options)) {
      requestInit.headers.set('X-KETTING-NO-STALE', '1');
    }

    await this.fetchOrThrow(requestInit);

    if (isState(options)) {
      this.updateCache(options);

    }

  }

  /**
   * Deletes the resource
   */
  async delete(): Promise<void> {

    await this.fetchOrThrow(
      { method: 'DELETE' }
    );

  }

  /**
   * Sends a POST request to the resource.
   *
   * See the documentation for PostRequestOptions for more details.
   * This function is used for RPC-like endpoints and form submissions.
   *
   * This function will return the response as a State object.
   */
  async post(options: PostRequestOptions): Promise<State> {

    const response = await this.fetchOrThrow(
      optionsToRequestInit('POST', options)
    );

    return this.client.getStateForResponse(this.uri, response);

  }

  /**
   * Sends a POST request, and follows to the next resource.
   *
   * If a server responds with a 201 Status code and a Location header,
   * it will automatically return the newly created resource.
   *
   * If the server responded with a 204 or 205, this function will return
   * `this`.
   */
  async postFollow(options: PostRequestOptions): Promise<Resource> {

    const response = await this.fetchOrThrow(
      optionsToRequestInit('POST', options)
    );

    switch (response.status) {
      case 201:
        if (response.headers.has('location')) {
          return this.go(response.headers.get('location')!);
        }
        throw new Error('Could not follow after a 201 request, because the server did not reply with a Location header. If you sent a Location header, check if your service is returning "Access-Control-Expose-Headers: Location".');
      case 204 :
      case 205 :
        return this;
      default:
        throw new Error('Did not receive a 201, 204 or 205 status code so we could not follow to the next resource');
    }

  }

  /**
   * Sends a PATCH request to the resource.
   *
   * This function defaults to a application/json content-type header.
   *
   * If the server responds with 200 Status code this will return a State object
   */
  async patch(options: PatchRequestOptions): Promise<undefined | State<T>> {

    const response = await this.fetchOrThrow(
      optionsToRequestInit('PATCH', options)
    );

    if (response.status === 200) {
      return await this.client.getStateForResponse(this.uri, response);
    }
  }

  /**
   * Follows a relationship, based on its reltype. For example, this might be
   * 'alternate', 'item', 'edit' or a custom url-based one.
   *
   * This function can also follow templated uris. You can specify uri
   * variables in the optional variables argument.
   */
  follow<TFollowedResource = any>(rel: string, variables?: LinkVariables): FollowPromiseOne<TFollowedResource> {

    return new FollowPromiseOne(this, rel, variables);

  }

  /**
   * Follows a relationship based on its reltype. This function returns a
   * Promise that resolves to an array of Resource objects.
   *
   * If no resources were found, the array will be empty.
   */
  followAll<TFollowedResource = any>(rel: string): FollowPromiseMany<TFollowedResource> {

    return new FollowPromiseMany(this, rel);

  }

  /**
   * Resolves a new resource based on a relative uri.
   *
   * Use this function to manually get a Resource object via a uri. The uri
   * will be resolved based on the uri of the current resource.
   *
   * This function doesn't do any HTTP requests.
   */
  go<TGoResource = any>(uri: string|Link): Resource<TGoResource> {

    if (typeof uri === 'string') {
      return this.client.go(resolve(this.uri, uri));
    } else {
      return this.client.go(uri);
    }

  }

  /**
   * Does a HTTP request on the current resource URI
   */
  fetch(init?: RequestInit): Promise<Response> {

    return this.client.fetcher.fetch(this.uri, init);

  }

  /**
   * Does a HTTP request on the current resource URI.
   *
   * If the response was a 4XX or 5XX, this function will throw
   * an exception.
   */
  fetchOrThrow(init?: RequestInit): Promise<Response> {

    return this.client.fetcher.fetchOrThrow(this.uri, init);

  }

  /**
   * Updates the state cache, and emits events.
   *
   * This will update the local state but *not* update the server
   */
  updateCache(state: State<T>) {

    if (state.uri !== this.uri) {
      throw new Error('When calling updateCache on a resource, the uri of the State object must match the uri of the Resource');
    }
    this.client.cacheState(state);

  }

  /**
   * Clears the state cache for this resource.
   */
  clearCache(): void {

    this.client.clearResourceCache([this.uri],[]);

  }

  /**
   * Retrieves the current cached resource state, and return `null` if it's
   * not available.
   */
  getCache(): State<T>|null {

    return this.client.cache.get(this.uri);

  }

  /**
   * Returns a Link object, by its REL.
   *
   * If the link does not exist, a LinkNotFound error will be thrown.
   *
   * @deprecated
   */
  async link(rel: string): Promise<Link> {

    const state = await this.get();
    const link = state.links.get(rel);

    if (!link) {
      throw new LinkNotFound(`Link with rel: ${rel} not found on ${this.uri}`);
    }
    return link;

  }

  /**
   * Returns all links defined on this object.
   *
   * @deprecated
   */
  async links(rel?: string): Promise<Link[]> {

    const state = await this.get();
    if (!rel) {
      return state.links.getAll();
    } else {
      return state.links.getMany(rel);
    }

  }

  /**
   *
   * Returns true or false depending on if a link with the specified relation
   * type exists.
   *
   * @deprecated
   */
  async hasLink(rel: string): Promise<boolean> {

    const state = await this.get();
    return state.links.has(rel);

  }
}

// eslint doesn't like that we have a generic T but not using it.
// eslint-disable-next-line
export declare interface Resource<T = any> {

  /**
   * Subscribe to the 'update' event.
   *
   * This event will get triggered whenever a new State is received
   * from the server, either through a GET request or if it was
   * transcluded.
   *
   * It will also trigger when calling 'PUT' with a full state object,
   * and when updateCache() was used.
   */
  on(event: 'update', listener: (state: State) => void) : this;

  /**
   * Subscribe to the 'stale' event.
   *
   * This event will get triggered whenever an unsafe method was
   * used, such as POST, PUT, PATCH, etc.
   *
   * When any of these methods are used, the local cache is stale.
   */
  on(event: 'stale',  listener: () => void) : this;

  /**
   * Subscribe to the 'delete' event.
   *
   * This event gets triggered when the `DELETE` http method is used.
   */
  on(event: 'delete', listener: () => void) : this;

  /**
   * Subscribe to the 'update' event and unsubscribe after it was
   * emitted the first time.
   */
  once(event: 'update', listener: (state: State) => void) : this;

  /**
   * Subscribe to the 'stale' event and unsubscribe after it was
   * emitted the first time.
   */
  once(event: 'stale',  listener: () => void) : this;

  /**
   * Subscribe to the 'delete' event and unsubscribe after it was
   * emitted the first time.
   */
  once(event: 'delete', listener: () => void) : this;

  /**
   * Unsubscribe from the 'update' event
   */
  off(event: 'update', listener: (state: State) => void) : this;

  /**
   * Unsubscribe from the 'stale' event
   */
  off(event: 'stale',  listener: () => void) : this;

  /**
   * Unsubscribe from the 'delete' event
   */
  off(event: 'delete', listener: () => void) : this;

  /**
   * Emit an 'update' event.
   */
  emit(event: 'update', state: State) : boolean;

  /**
   * Emit a 'stale' event.
   */
  emit(event: 'stale') : boolean;

  /**
   * Emit a 'delete' event.
   */
  emit(event: 'delete') : boolean;

}

export default Resource;

type StrictRequestInit = RequestInit & {
  headers: Headers;
};

/**
 * Convert request options to RequestInit
 *
 * RequestInit is passed to the constructor of fetch(). We have our own 'options' format
 */
function optionsToRequestInit(method: 'GET', options?: GetRequestOptions): StrictRequestInit;
function optionsToRequestInit(method: 'HEAD', options?: HeadRequestOptions): StrictRequestInit;
function optionsToRequestInit(method: 'PATCH', options?: PatchRequestOptions): StrictRequestInit;
function optionsToRequestInit(method: 'POST', options?: PostRequestOptions): StrictRequestInit;
function optionsToRequestInit(method: 'PUT', options?: PutRequestOptions): StrictRequestInit;
function optionsToRequestInit(method: string, options?: GetRequestOptions | PostRequestOptions | PatchRequestOptions | PutRequestOptions): StrictRequestInit {

  if (!options) {
    return {
      method,
      headers: new Headers(),
    };
  }
  let headers;
  if (options.getContentHeaders) {
    headers = new Headers(options.getContentHeaders());
  } else if (options.headers) {
    headers = new Headers(options.headers);
  } else {
    headers = new Headers();
  }
  if (!headers.has('Content-Type')) {
    headers.set('Content-Type', 'application/json');
  }
  let body;
  if ((options as any).serializeBody !== undefined) {
    body = (options as any).serializeBody();
  } else if ((options as any).data) {
    body = (options as any).data;
    if (needsJsonStringify(body)) {
      body = JSON.stringify(body);
    }
  } else {
    body = null;
  }
  return {
    method,
    body,
    headers,
  };

}

function requestHash(uri: string, options: GetRequestOptions | undefined): string {

  const headers: Record<string, string> = {};
  if (options) {
    new Headers(options.getContentHeaders?.() || options.headers)
      .forEach((value, key) => {
        headers[key] = value;
      });
  }

  const headerStr = Object.entries(headers).map( ([name, value]) => {
    return name.toLowerCase() + ':' + value;
  }).join(',');

  return uri + '|' + headerStr;
}
