import axios, { AxiosRequestConfig, AxiosResponse, Method } from 'axios';

import { signMessage } from './node-support';
import {
  RestClientOptions,
  serializeParams,
  RestClientType,
  REST_CLIENT_TYPE_ENUM,
  agentSource,
} from './requestUtils';

// axios.interceptors.request.use((request) => {
//   console.log(new Date(), 'Starting Request', JSON.stringify(request, null, 2));
//   return request;
// });

// axios.interceptors.response.use((response) => {
//   console.log(new Date(), 'Response:', JSON.stringify(response, null, 2));
//   return response;
// });

interface SignedRequestContext {
  timestamp: number;
  api_key?: string;
  recv_window?: number;
  // spot is diff from the rest...
  recvWindow?: number;
}

interface SignedRequest<T> {
  originalParams: T & SignedRequestContext;
  paramsWithSign?: T & SignedRequestContext & { sign: string };
  sign: string;
}

export default abstract class BaseRestClient {
  private timeOffset: number | null;
  private syncTimePromise: null | Promise<any>;
  private options: RestClientOptions;
  private baseUrl: string;
  private globalRequestOptions: AxiosRequestConfig;
  private key: string | undefined;
  private secret: string | undefined;
  private clientType: RestClientType;

  /** Function that calls exchange API to query & resolve server time, used by time sync */
  abstract fetchServerTime(): Promise<number>;

  constructor(
    key: string | undefined,
    secret: string | undefined,
    baseUrl: string,
    options: RestClientOptions = {},
    requestOptions: AxiosRequestConfig = {},
    clientType: RestClientType
  ) {
    this.timeOffset = null;
    this.syncTimePromise = null;

    this.clientType = clientType;

    this.options = {
      recv_window: 5000,
      // how often to sync time drift with bybit servers
      sync_interval_ms: 3600000,
      // if true, we'll throw errors if any params are undefined
      strict_param_validation: false,
      ...options,
    };

    this.globalRequestOptions = {
      // in ms == 5 minutes by default
      timeout: 1000 * 60 * 5,
      // custom request options based on axios specs - see: https://github.com/axios/axios#request-config
      ...requestOptions,
      headers: {
        'x-referer': 'bybitapinode',
      },
    };

    this.baseUrl = baseUrl;

    if (key && !secret) {
      throw new Error(
        'API Key & Secret are both required for private enpoints'
      );
    }

    if (this.options.disable_time_sync !== true) {
      this.syncTime();
      setInterval(this.syncTime.bind(this), +this.options.sync_interval_ms!);
    }

    this.key = key;
    this.secret = secret;
  }

  private isSpotClient() {
    return this.clientType === REST_CLIENT_TYPE_ENUM.spot;
  }

  get(endpoint: string, params?: any) {
    return this._call('GET', endpoint, params, true);
  }

  post(endpoint: string, params?: any) {
    return this._call('POST', endpoint, params, true);
  }

  getPrivate(endpoint: string, params?: any) {
    return this._call('GET', endpoint, params, false);
  }

  postPrivate(endpoint: string, params?: any) {
    return this._call('POST', endpoint, params, false);
  }

  deletePrivate(endpoint: string, params?: any) {
    return this._call('DELETE', endpoint, params, false);
  }

  private async prepareSignParams(params?: any, isPublicApi?: boolean) {
    if (isPublicApi) {
      return {
        originalParams: params,
        paramsWithSign: params,
      };
    }

    if (!this.key || !this.secret) {
      throw new Error('Private endpoints require api and private keys set');
    }

    if (this.timeOffset === null) {
      await this.syncTime();
    }

    return this.signRequest(params);
  }

  /**
   * @private Make a HTTP request to a specific endpoint. Private endpoints are automatically signed.
   */
  private async _call(
    method: Method,
    endpoint: string,
    params?: any,
    isPublicApi?: boolean
  ): Promise<any> {
    const options = {
      ...this.globalRequestOptions,
      url: [this.baseUrl, endpoint].join(endpoint.startsWith('/') ? '' : '/'),
      method: method,
      json: true,
    };

    for (const key in params) {
      if (typeof params[key] === 'undefined') {
        delete params[key];
      }
    }

    const signResult = await this.prepareSignParams(params, isPublicApi);

    if (method === 'GET' || this.isSpotClient()) {
      options.params = signResult.paramsWithSign;
      if (options.params?.agentSource) {
        options.data = {
          agentSource: agentSource,
        };
      }
    } else {
      options.data = signResult.paramsWithSign;
    }

    return axios(options)
      .then((response) => {
        if (response.status == 200) {
          return response.data;
        }

        throw response;
      })
      .catch((e) => this.parseException(e));
  }

  /**
   * @private generic handler to parse request exceptions
   */
  parseException(e: any): unknown {
    if (this.options.parse_exceptions === false) {
      throw e;
    }

    // Something happened in setting up the request that triggered an Error
    if (!e.response) {
      if (!e.request) {
        throw e.message;
      }

      // request made but no response received
      throw e;
    }

    // The request was made and the server responded with a status code
    // that falls out of the range of 2xx
    const response: AxiosResponse = e.response;
    throw {
      code: response.status,
      message: response.statusText,
      body: response.data,
      headers: response.headers,
      requestOptions: this.options,
    };
  }

  /**
   * @private sign request and set recv window
   */
  private async signRequest<T extends Object>(
    data: T & SignedRequestContext
  ): Promise<SignedRequest<T>> {
    const res: SignedRequest<T> = {
      originalParams: {
        ...data,
        api_key: this.key,
        timestamp: Date.now() + (this.timeOffset || 0),
      },
      sign: '',
    };

    // Optional, set to 5000 by default. Increase if timestamp/recv_window errors are seen.
    if (this.options.recv_window && !res.originalParams.recv_window) {
      if (this.isSpotClient()) {
        res.originalParams.recvWindow = this.options.recv_window;
      } else {
        res.originalParams.recv_window = this.options.recv_window;
      }
    }

    if (this.key && this.secret) {
      const serializedParams = serializeParams(
        res.originalParams,
        this.options.strict_param_validation
      );
      res.sign = await signMessage(serializedParams, this.secret);
      res.paramsWithSign = {
        ...res.originalParams,
        sign: res.sign,
      };
    }

    return res;
  }

  /**
   * Trigger time sync and store promise
   */
  private syncTime(): Promise<any> {
    if (this.options.disable_time_sync === true) {
      return Promise.resolve(false);
    }

    if (this.syncTimePromise !== null) {
      return this.syncTimePromise;
    }

    this.syncTimePromise = this.fetchTimeOffset().then((offset) => {
      this.timeOffset = offset;
      this.syncTimePromise = null;
    });

    return this.syncTimePromise;
  }

  /**
   * Estimate drift based on client<->server latency
   */
  async fetchTimeOffset(): Promise<number> {
    try {
      const start = Date.now();
      const serverTime = await this.fetchServerTime();

      if (!serverTime || isNaN(serverTime)) {
        throw new Error(
          `fetchServerTime() returned non-number: "${serverTime}" typeof(${typeof serverTime})`
        );
      }

      const end = Date.now();
      const severTimeMs = serverTime * 1000;

      const avgDrift = (end - start) / 2;
      return Math.ceil(severTimeMs - end + avgDrift);
    } catch (e) {
      console.error('Failed to fetch get time offset: ', e);
      return 0;
    }
  }
}