/*
 * Copyright (C) 2018-2022 Open StApps
 * This program is free software: you can redistribute it and/or modify it
 * under the terms of the GNU General Public License as published by the Free
 * Software Foundation, version 3.
 *
 * This program is distributed in the hope that it will be useful, but WITHOUT
 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
 * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
 * more details.
 *
 * You should have received a copy of the GNU General Public License along with
 * this program. If not, see <https://www.gnu.org/licenses/>.
 */
import {
  SCAbstractRoute,
  SCErrorResponse,
  SCFeatureConfiguration,
  SCFeatureConfigurationPlugin,
  SCIndexRequest,
  SCIndexResponse,
  SCIndexRoute,
  SCInternalServerErrorResponse,
  SCMultiSearchRequest,
  SCMultiSearchResponse,
  SCMultiSearchRoute,
  SCNotFoundErrorResponse,
  SCRequests,
  SCSearchRequest,
  SCSearchResponse,
  SCSearchRoute,
  SCThings,
} from '@openstapps/core';
import {ApiError, CoreVersionIncompatibleError, OutOfRangeError, PluginNotAvailableError} from './errors.js';
import {HttpClientHeaders, HttpClientInterface} from './http-client-interface.js';

/**
 * StApps-API client
 */
export class Client {
  /**
   * Instance of index route
   */
  private readonly indexRoute = new SCIndexRoute();

  /**
   * Instance of multi search request route
   */
  private readonly multiSearchRoute = new SCMultiSearchRoute();

  /**
   * Instance of search request route
   */
  private readonly searchRoute = new SCSearchRoute();

  /**
   * Features supported by backend
   */
  private supportedFeatures?: SCFeatureConfiguration = undefined;

  /**
   * Default headers
   *
   * TODO: remove headers
   */
  protected readonly headers: HttpClientHeaders = {};

  /**
   * Create a new search request with altered pagination parameters to move to the next result window
   * @param searchRequest Last search request
   * @param searchResponse Search response for supplied search request
   * @throws OutOfRangeError Throws an error if the next window is beyond the total number of results
   */
  static nextWindow(searchRequest: SCSearchRequest, searchResponse: SCSearchResponse): SCSearchRequest {
    // calculate next from
    const from = searchResponse.pagination.offset + searchResponse.pagination.count;

    // throw an error if the next window is beyond the total number of results
    if (from >= searchResponse.pagination.total) {
      throw new OutOfRangeError(searchRequest);
    }

    // return a search request with the next window
    return {
      ...searchRequest,
      from,
    };
  }

  /**
   * Instantiate a new StApps-API client to communicate with a StApps-backend.
   * @param httpClient HTTP client to use
   * @param url URL of the backend
   * @param version App version to use when requesting data *(only necessary if URL is ambiguous)*
   *
   * TODO: remove headers/version
   */
  constructor(protected httpClient: HttpClientInterface, protected url: string, protected version?: string) {
    // cut trailing slash if needed
    this.url = this.url.replace(/\/$/, '');

    this.headers = {
      'Content-Type': 'application/json',
    };

    if (typeof version === 'string') {
      this.headers['X-StApps-Version'] = this.version;
    }
  }

  /**
   * Get a thing by its UID
   * @param uid UID of the thing to fetch
   */
  async getThing(uid: string): Promise<SCThings> {
    const response = await this.search({
      filter: {
        arguments: {
          field: 'uid',
          value: uid,
        },
        type: 'value',
      },
      size: 1,
    });

    if (response.data.length === 1 && response.data[0].uid === uid) {
      return response.data[0];
    }

    throw new SCInternalServerErrorResponse(new SCNotFoundErrorResponse(true), true);
  }

  /**
   * Make a handshake with the backend and check StAppsCore version
   * @param coreVersion StAppsCore version to check
   */
  async handshake(coreVersion: string): Promise<SCIndexResponse> {
    const request: SCIndexRequest = {};

    const response = await this.invokeRoute<SCIndexResponse>(this.indexRoute, undefined, request);

    if (response.backend.SCVersion.split('.')[0] !== coreVersion.split('.')[0]) {
      throw new CoreVersionIncompatibleError(coreVersion, response.backend.SCVersion);
    }
    /* istanbul ignore next */
    this.supportedFeatures = response?.app?.features;

    return response;
  }

  /**
   * Invoke a plugin route
   * @param name name of the plugin
   * @param parameters Parameters for the URL fragment
   * @param body Body for the request
   */
  async invokePlugin<T>(name: string, parameters?: {[k: string]: string}, body?: SCRequests): Promise<T> {
    if (this.supportedFeatures === undefined) {
      const request: SCIndexRequest = {};
      const response = await this.invokeRoute<SCIndexResponse>(this.indexRoute, undefined, request);
      if (response?.app?.features !== undefined) {
        /* istanbul ignore next */
        this.supportedFeatures = response?.app?.features;
      }
    }
    const pluginInfo: SCFeatureConfigurationPlugin | undefined = this.supportedFeatures?.plugins?.[name];
    if (pluginInfo === undefined) {
      throw new PluginNotAvailableError(name);
    }

    const route = new SCIndexRoute();
    route.urlPath = pluginInfo.urlPath;

    return this.invokeRoute<T>(route, parameters, body);
  }

  /**
   * Invoke a route
   * @param route Route to invoke
   * @param parameters Parameters for the URL fragment
   * @param body Body for the request
   */
  async invokeRoute<T>(
    route: SCAbstractRoute,
    parameters?: {[k: string]: string},
    body?: SCRequests,
  ): Promise<T> {
    // make the request
    const response = await this.httpClient.request({
      body: body,
      // TODO: remove headers
      headers: this.headers,
      method: route.method,
      url: new URL(this.url + route.getUrlPath(parameters)),
    });

    if (response.statusCode === route.statusCodeSuccess) {
      return response.body as T;
    }

    throw new ApiError(response.body as SCErrorResponse);
  }

  /**
   * Send a multi search request to the backend
   *
   * All results will be returned for requests where no size is set.
   * @param multiSearchRequest Multi search request
   */
  async multiSearch(multiSearchRequest: SCMultiSearchRequest): Promise<SCMultiSearchResponse> {
    const preFlightRequest: SCMultiSearchRequest = {};
    let preFlightNecessary = false;

    // gather search requests where size is not set
    for (const key of Object.keys(multiSearchRequest)) {
      const searchRequest = multiSearchRequest[key];

      if (searchRequest.size === undefined) {
        preFlightRequest[key] = {
          ...searchRequest,
        };
        preFlightRequest[key].size = 0;
        preFlightNecessary = true;
      }
    }

    let returnMultiSearchRequest = multiSearchRequest;

    if (preFlightNecessary) {
      // copy multi search request
      returnMultiSearchRequest = {
        ...multiSearchRequest,
      };

      // make pre flight request
      const preFlightResponse = await this.invokeRoute<SCMultiSearchResponse>(
        this.multiSearchRoute,
        undefined,
        preFlightRequest,
      );

      // set size for multi search requests that were in pre flight request
      for (const key of Object.keys(preFlightRequest)) {
        returnMultiSearchRequest[key].size = preFlightResponse[key].pagination.total;
      }
    }

    // actually invoke the route
    return this.invokeRoute<SCMultiSearchResponse>(
      this.multiSearchRoute,
      undefined,
      returnMultiSearchRequest,
    );
  }

  /**
   * Send a search request to the backend
   *
   * All results will be returned if no size is set in the request.
   * @param searchRequest Search request
   */
  async search(searchRequest: SCSearchRequest): Promise<SCSearchResponse> {
    let size: number | undefined = searchRequest.size;

    if (size === undefined) {
      const preFlightResponse = await this.invokeRoute<SCSearchResponse>(this.searchRoute, undefined, {
        ...searchRequest,
        size: 0,
      });

      size = preFlightResponse.pagination.total;
    }

    return this.invokeRoute<SCSearchResponse>(this.searchRoute, undefined, {
      ...searchRequest,
      size,
    });
  }

  /**
   * Get the next search results
   * @param searchRequest Last search request
   * @param searchResponse Search response for supplied search request
   */
  async searchNext(
    searchRequest: SCSearchRequest,
    searchResponse: SCSearchResponse,
  ): Promise<{
    /* tslint:disable:completed-docs */
    searchRequest: SCSearchRequest;
    searchResponse: SCSearchResponse;
    /* tslint:enable:completed-docs */
  }> {
    const nextSearchRequest = Client.nextWindow(searchRequest, searchResponse);

    return {
      searchRequest: nextSearchRequest,
      searchResponse: await this.search(nextSearchRequest),
    };
  }
}
