/**
 *
 */
import { EventEmitter } from 'events';
import xml2js from 'xml2js';
import { Logger, getLogger } from './util/logger';
import { StreamPromise } from './util/promise';
import Connection from './connection';
import Transport from './transport';
import { parseCSV } from './csv';
import {
  HttpRequest,
  HttpRequestOptions,
  HttpResponse,
  Optional,
  Schema,
} from './types';
import { createLazyStream } from './util/stream';
import { getBodySize } from './util/get-body-size';

/** @private */
function parseJSON(str: string) {
  return JSON.parse(str);
}

/** @private */
async function parseXML(str: string) {
  return xml2js.parseStringPromise(str, { explicitArray: false });
}

/** @private */
function parseText(str: string) {
  return str;
}

/**
 * HTTP based API class with authorization hook
 */
export class HttpApi<S extends Schema> extends EventEmitter {
  static _logger = getLogger('http-api');

  _conn: Connection<S>;
  _logger: Logger;
  _transport: Transport;
  _responseType: string | void;
  _noContentResponse: string | void;
  _options: HttpRequestOptions;

  constructor(conn: Connection<S>, options: any) {
    super();
    this._conn = conn;
    this._logger = conn._logLevel
      ? HttpApi._logger.createInstance(conn._logLevel)
      : HttpApi._logger;
    this._responseType = options.responseType;
    this._transport = options.transport || conn._transport;
    this._noContentResponse = options.noContentResponse;
    this._options = options;
  }

  /**
   * Callout to API endpoint using http
   */
  request<R = unknown>(request: HttpRequest): StreamPromise<R> {
    return StreamPromise.create<R>(() => {
      const { stream, setStream } = createLazyStream();
      const promise = (async () => {
        const refreshDelegate = this.getRefreshDelegate();
        /* TODO decide remove or not this section */
        /*
        // remember previous instance url in case it changes after a refresh
        const lastInstanceUrl = conn.instanceUrl;

        // check to see if the token refresh has changed the instance url
        if(lastInstanceUrl !== conn.instanceUrl){
          // if the instance url has changed
          // then replace the current request urls instance url fragment
          // with the updated instance url
          request.url = request.url.replace(lastInstanceUrl,conn.instanceUrl);
        }
        */
        if (refreshDelegate && refreshDelegate.isRefreshing()) {
          await refreshDelegate.waitRefresh();
          const bodyPromise = this.request(request);
          setStream(bodyPromise.stream());
          const body = await bodyPromise;
          return body;
        }

        // hook before sending
        this.beforeSend(request);

        this.emit('request', request);
        this._logger.debug(
          `<request> method=${request.method}, url=${request.url}`,
        );
        const requestTime = Date.now();
        const requestPromise = this._transport.httpRequest(
          request,
          this._options,
        );

        setStream(requestPromise.stream());

        let response: HttpResponse | void;
        try {
          response = await requestPromise;
        } catch (err) {
          this._logger.error(err);
          throw err;
        } finally {
          const responseTime = Date.now();
          this._logger.debug(
            `elapsed time: ${responseTime - requestTime} msec`,
          );
        }
        if (!response) {
          return;
        }
        this._logger.debug(
          `<response> status=${String(response.statusCode)}, url=${
            request.url
          }`,
        );
        this.emit('response', response);
        // Refresh token if session has been expired and requires authentication
        // when session refresh delegate is available
        if (this.isSessionExpired(response) && refreshDelegate) {
          await refreshDelegate.refresh(requestTime);
          /* remove the `content-length` header after token refresh
           *
           * SOAP requests include the access token their the body,
           * if the first req had an invalid token and jsforce successfully
           * refreshed it we need to remove the `content-length` header
           * so that it get's re-calculated again with the new body.
           *
           * REST request aren't affected by this because the access token
           * is sent via HTTP headers
           *
           * `_message` is only present in SOAP requests
           */
          if (
            '_message' in request &&
            request.headers &&
            'content-length' in request.headers
          ) {
            delete request.headers['content-length'];
          }
          return this.request(request);
        }
        if (this.isErrorResponse(response)) {
          const err = await this.getError(response);
          throw err;
        }
        const body = await this.getResponseBody(response);
        return body;
      })();
      return { stream, promise };
    });
  }

  /**
   * @protected
   */
  getRefreshDelegate() {
    return this._conn._refreshDelegate;
  }

  /**
   * @protected
   */
  beforeSend(request: HttpRequest) {
    /* eslint-disable no-param-reassign */
    const headers = request.headers || {};
    if (this._conn.accessToken) {
      headers.Authorization = `Bearer ${this._conn.accessToken}`;
    }
    if (this._conn._callOptions) {
      const callOptions = [];
      for (const name of Object.keys(this._conn._callOptions)) {
        callOptions.push(`${name}=${this._conn._callOptions[name]}`);
      }
      headers['Sforce-Call-Options'] = callOptions.join(', ');
    }

    const bodySize = getBodySize(request.body, headers);

    const cannotHaveBody = ['GET', 'HEAD', 'OPTIONS'].includes(request.method);

    // Don't set content-length in browsers as it's not allowed
    const isBrowser = 'window' in globalThis || 'self' in globalThis;

    if (
      !isBrowser && // Don't set content-length in browsers as it's not allowed
      !cannotHaveBody &&
      !!request.body &&
      !('transfer-encoding' in headers) &&
      !('content-length' in headers) &&
      !!bodySize
    ) {
      this._logger.debug(
        `missing 'content-length' header, setting it to: ${bodySize}`,
      );
      headers['content-length'] = String(bodySize);
    }
    request.headers = headers;
  }

  /**
   * Detect response content mime-type
   * @protected
   */
  getResponseContentType(response: HttpResponse): Optional<string> {
    return (
      this._responseType ||
      (response.headers && response.headers['content-type'])
    );
  }

  /**
   * @private
   */
  // eslint-disable-next-line @typescript-eslint/require-await
  async parseResponseBody(response: HttpResponse) {
    const contentType = this.getResponseContentType(response) || '';
    const parseBody = /^(text|application)\/xml(;|$)/.test(contentType)
      ? parseXML
      : /^application\/json(;|$)/.test(contentType)
      ? parseJSON
      : /^text\/csv(;|$)/.test(contentType)
      ? parseCSV
      : parseText;
    try {
      return parseBody(response.body);
    } catch (e) {
      // TODO(next major): we could throw a new "invalid response body" error instead.
      this._logger.debug(
        `Failed to parse body of content-type: ${contentType}. Error: ${
          (e as Error).message
        }`,
      );
      return response.body;
    }
  }

  /**
   * Get response body
   * @protected
   */
  async getResponseBody(response: HttpResponse) {
    if (response.statusCode === 204) {
      // No Content
      return this._noContentResponse;
    }
    const body = await this.parseResponseBody(response);
    let err;
    if (this.hasErrorInResponseBody(body)) {
      err = await this.getError(response, body);
      throw err;
    }
    if (response.statusCode === 300) {
      // Multiple Choices
      throw new HttpApiError(
        'Multiple records found',
        'MULTIPLE_CHOICES',
        body,
      );
    }
    return body;
  }

  /**
   * Detect session expiry
   * @protected
   */
  isSessionExpired(response: HttpResponse) {
    // REST API status codes: https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/errorcodes.htm
    //
    // "401 - The session ID or OAuth token used has expired or is invalid. The response body contains the message and errorCode."
    if (response.statusCode === 401) {
      // Known list of 401 responses that shouldn't be considered as "session expired".
      //
      // These usualy come from an CA/ECA, OAuth, IP restriction change in the org that block connections, we need to skip these
      // org jsforce will enter into an infinite loop trying to get a valid token.
      const responsesToSkip = ['Connected app is not attached to Agent', 'This session is not valid for use with the REST API'];
      for (const p of responsesToSkip) {
        if (response.body.includes(p)) return false
      }

      return true
    }

    return false
  }

  /**
   * Detect error response
   * @protected
   */
  isErrorResponse(response: HttpResponse) {
    return response.statusCode >= 400;
  }

  /**
   * Detect error in response body
   * @protected
   */
  hasErrorInResponseBody(_body: Optional<string>) {
    return false;
  }

  /**
   * Parsing error message in response
   * @protected
   */
  parseError(body: any) {
    const errors = body;

    // XML response
    if (errors.Errors) {
      return errors.Errors.Error;
    }

    return errors;
  }

  /**
   * Get error message in response
   * @protected
   */
  async getError(response: HttpResponse, body?: any): Promise<Error> {
    let error;
    try {
      error = this.parseError(body || (await this.parseResponseBody(response)));
    } catch (e) {
      // eslint-disable no-empty
    }

    if (Array.isArray(error)) {
      if (error.length === 1) {
        error = error[0];
      } else {
        return new HttpApiError(
          `Multiple errors returned.
  Check \`error.data\` for the error details`,
          'MULTIPLE_API_ERRORS',
          error,
        );
      }
    }

    error =
      typeof error === 'object' &&
      error !== null &&
      typeof error.message === 'string'
        ? error
        : {
            errorCode: `ERROR_HTTP_${response.statusCode}`,
            message: response.body,
          };

    if (response.headers['content-type'] === 'text/html') {
      this._logger.debug(`html response.body: ${response.body}`);
      return new HttpApiError(
        `HTTP response contains html content.
Check that the org exists and can be reached.

HTTP status code: ${response.statusCode}.
REST API Status Codes and Error Responses:
https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/errorcodes.htm

See \`error.data\` for the full html response.`,
        error.errorCode,
        error.message,
      );
    }

    return error instanceof HttpApiError
      ? error
      : new HttpApiError(error.message, error.errorCode, error);
  }
}

/**
 *
 */
class HttpApiError extends Error {
  /**
   * This contains error-specific details, usually returned from the API.
   */
  data: any;
  errorCode: string;

  constructor(message: string, errorCode?: string | undefined, data?: any) {
    super(message);
    this.name = errorCode || this.name;
    this.errorCode = this.name;
    this.data = data;
  }

  /**
   * This will be removed in the next major (v4)
   *
   * @deprecated use `error.data` instead
   */
  get content() {
    return this.data;
  }
}

export default HttpApi;
export const isBrowser = 'window' in globalThis || 'self' in globalThis;
