import _ from 'lodash';
import {logger, util} from '@appium/support';
import axios from 'axios';
import {getSummaryByCode} from '../jsonwp-status/status';
import {
  errors,
  isErrorType,
  errorFromMJSONWPStatusCode,
  errorFromW3CJsonCode,
  getResponseForW3CError,
} from '../protocol/errors';
import {isSessionCommand, routeToCommandName} from '../protocol';
import {MAX_LOG_BODY_LENGTH, DEFAULT_BASE_PATH, PROTOCOLS} from '../constants';
import ProtocolConverter from './protocol-converter';
import {formatResponseValue, formatStatus} from '../protocol/helpers';
import http from 'http';
import https from 'https';
import { match as pathToRegexMatch } from 'path-to-regexp';
import nodeUrl from 'node:url';


const DEFAULT_LOG = logger.getLogger('WD Proxy');
const DEFAULT_REQUEST_TIMEOUT = 240000;
const COMMAND_WITH_SESSION_ID_MATCHER = pathToRegexMatch('/session/:sessionId{/*command}');

const {MJSONWP, W3C} = PROTOCOLS;

const ALLOWED_OPTS = [
  'scheme',
  'server',
  'port',
  'base',
  'reqBasePath',
  'sessionId',
  'timeout',
  'log',
  'keepAlive',
];

class JWProxy {
  /** @type {string} */
  scheme;
  /** @type {string} */
  server;
  /** @type {number} */
  port;
  /** @type {string} */
  base;
  /** @type {string} */
  reqBasePath;
  /** @type {string?} */
  sessionId;
  /** @type {number} */
  timeout;

  constructor(opts = {}) {
    opts = _.pick(opts, ALLOWED_OPTS);

    // omit 'log' in the defaults assignment here because 'log' is a getter and we are going to set
    // it to this._log (which lies behind the getter) further down
    const options = _.defaults(_.omit(opts, 'log'), {
      scheme: 'http',
      server: 'localhost',
      port: 4444,
      base: DEFAULT_BASE_PATH,
      reqBasePath: DEFAULT_BASE_PATH,
      sessionId: null,
      timeout: DEFAULT_REQUEST_TIMEOUT,
    });
    options.scheme = options.scheme.toLowerCase();
    Object.assign(this, options);

    this._activeRequests = [];
    this._downstreamProtocol = null;
    const agentOpts = {
      keepAlive: opts.keepAlive ?? true,
      maxSockets: 10,
      maxFreeSockets: 5,
    };
    this.httpAgent = new http.Agent(agentOpts);
    this.httpsAgent = new https.Agent(agentOpts);
    this.protocolConverter = new ProtocolConverter(this.proxy.bind(this), opts.log);
    this._log = opts.log;
  }

  get log() {
    return this._log ?? DEFAULT_LOG;
  }

  /**
   * Performs requests to the downstream server
   *
   * @private - Do not call this method directly,
   * it uses client-specific arguments and responses!
   *
   * @param {import('axios').RawAxiosRequestConfig} requestConfig
   * @returns {Promise<import('axios').AxiosResponse>}
   */
  async request(requestConfig) {
    const reqPromise = axios(requestConfig);
    this._activeRequests.push(reqPromise);
    try {
      return await reqPromise;
    } finally {
      _.pull(this._activeRequests, reqPromise);
    }
  }

  getActiveRequestsCount() {
    return this._activeRequests.length;
  }

  cancelActiveRequests() {
    this._activeRequests = [];
  }

  set downstreamProtocol(value) {
    this._downstreamProtocol = value;
    this.protocolConverter.downstreamProtocol = value;
  }

  get downstreamProtocol() {
    return this._downstreamProtocol;
  }

  /**
   *
   * @param {string} url
   * @param {string} [method]
   * @returns {string}
   */
  getUrlForProxy(url, method) {
    const parsedUrl = nodeUrl.parse(url || '/');
    if (
      !parsedUrl.href || !parsedUrl.pathname
      || (parsedUrl.protocol && !['http:', 'https:'].includes(parsedUrl.protocol))
    ) {
      throw new Error(`Did not know how to proxy the url '${url}'`);
    }
    const pathname = this.reqBasePath && parsedUrl.pathname.startsWith(this.reqBasePath)
      ? parsedUrl.pathname.replace(this.reqBasePath, '')
      : parsedUrl.pathname;
    const match = COMMAND_WITH_SESSION_ID_MATCHER(pathname);
    const normalizedPathname = _.trimEnd(
      match && _.isArray(match.params?.command)
        ? `/${match.params.command.join('/')}`
        : pathname,
      '/'
    );
    const commandName = normalizedPathname
      ? routeToCommandName(
        normalizedPathname,
        /** @type {import('@appium/types').HTTPMethod | undefined} */ (method)
      )
      : '';
    const requiresSessionId = !commandName || (commandName && isSessionCommand(commandName));
    const proxyPrefix = `${this.scheme}://${this.server}:${this.port}${this.base}`;
    let proxySuffix = normalizedPathname ? `/${_.trimStart(normalizedPathname, '/')}` : '';
    if (parsedUrl.search) {
      proxySuffix += parsedUrl.search;
    }
    if (!requiresSessionId) {
      return `${proxyPrefix}${proxySuffix}`;
    }
    if (!this.sessionId) {
      throw new ReferenceError(`Session ID is not set, but saw a URL that requires it (${url})`);
    }
    return `${proxyPrefix}/session/${this.sessionId}${proxySuffix}`;
  }

  /**
   *
   * @param {string} url
   * @param {string} method
   * @param {any} body
   * @returns {Promise<any>}
   */
  async proxy(url, method, body = null) {
    method = method.toUpperCase();
    const newUrl = this.getUrlForProxy(url, method);
    const truncateBody = (content) =>
      _.truncate(_.isString(content) ? content : JSON.stringify(content), {
        length: MAX_LOG_BODY_LENGTH,
      });
    /** @type {import('axios').RawAxiosRequestConfig} */
    const reqOpts = {
      url: newUrl,
      method,
      headers: {
        'content-type': 'application/json; charset=utf-8',
        'user-agent': 'appium',
        accept: 'application/json, */*',
      },
      proxy: false,
      timeout: this.timeout,
      httpAgent: this.httpAgent,
      httpsAgent: this.httpsAgent,
    };
    // GET methods shouldn't have any body. Most servers are OK with this, but WebDriverAgent throws 400 errors
    if (util.hasValue(body) && method !== 'GET') {
      if (typeof body !== 'object') {
        try {
          reqOpts.data = JSON.parse(body);
        } catch {
          throw new Error(`Cannot interpret the request body as valid JSON: ${truncateBody(body)}`);
        }
      } else {
        reqOpts.data = body;
      }
    }

    this.log.debug(
      `Proxying [${method} ${url || '/'}] to [${method} ${newUrl}] ` +
        (reqOpts.data ? `with body: ${truncateBody(reqOpts.data)}` : 'with no body')
    );

    const throwProxyError = (error) => {
      const err = /** @type {ProxyError} */ (new Error(`The request to ${url} has failed`));
      err.response = {
        data: error,
        status: 500,
      };
      throw err;
    };
    let isResponseLogged = false;
    try {
      const {data, status, headers} = await this.request(reqOpts);
      // `data` might be really big
      // Be careful while handling it to avoid memory leaks
      if (!_.isPlainObject(data)) {
        // The response should be a valid JSON object
        // If it cannot be coerced to an object then the response is wrong
        throwProxyError(data);
      }
      this.log.debug(`Got response with status ${status}: ${truncateBody(data)}`);
      isResponseLogged = true;
      const isSessionCreationRequest = /\/session$/.test(url) && method === 'POST';
      if (isSessionCreationRequest) {
        if (status === 200) {
          this.sessionId = data.sessionId || (data.value || {}).sessionId;
        }
        this.downstreamProtocol = this.getProtocolFromResBody(data);
        this.log.info(`Determined the downstream protocol as '${this.downstreamProtocol}'`);
      }
      if (_.has(data, 'status') && parseInt(data.status, 10) !== 0) {
        // Some servers, like chromedriver may return response code 200 for non-zero JSONWP statuses
        throwProxyError(data);
      }
      const res = {statusCode: status, headers, body: data};
      return [res, data];
    } catch (e) {
      // We only consider an error unexpected if this was not
      // an async request module error or if the response cannot be cast to
      // a valid JSON
      let proxyErrorMsg = e.message;
      if (util.hasValue(e.response)) {
        if (!isResponseLogged) {
          const error = truncateBody(e.response.data);
          this.log.info(
            util.hasValue(e.response.status)
              ? `Got response with status ${e.response.status}: ${error}`
              : `Got response with unknown status: ${error}`
          );
        }
      } else {
        proxyErrorMsg = `Could not proxy command to the remote server. Original error: ${e.message}`;
        this.log.info(e.message);
      }
      throw new errors.ProxyRequestError(proxyErrorMsg, e.response?.data, e.response?.status);
    }
  }

  getProtocolFromResBody(resObj) {
    if (_.isInteger(resObj.status)) {
      return MJSONWP;
    }
    if (!_.isUndefined(resObj.value)) {
      return W3C;
    }
  }

  /**
   *
   * @param {string} url
   * @param {import('@appium/types').HTTPMethod} method
   * @returns {string|undefined}
   */
  requestToCommandName(url, method) {
    /**
     *
     * @param {RegExp} pattern
     * @returns {string|undefined}
     */
    const extractCommandName = (pattern) => {
      const pathMatch = pattern.exec(url);
      if (pathMatch) {
        return routeToCommandName(pathMatch[1], method, this.reqBasePath);
      }
    };
    let commandName = routeToCommandName(url, method, this.reqBasePath);
    if (!commandName && _.includes(url, `${this.reqBasePath}/session/`)) {
      commandName = extractCommandName(
        new RegExp(`${_.escapeRegExp(this.reqBasePath)}/session/[^/]+(.+)`)
      );
    }
    if (!commandName && _.includes(url, this.reqBasePath)) {
      commandName = extractCommandName(new RegExp(`${_.escapeRegExp(this.reqBasePath)}(/.+)`));
    }
    return commandName;
  }

  /**
   *
   * @param {string} url
   * @param {import('@appium/types').HTTPMethod} method
   * @param {any?} body
   */
  async proxyCommand(url, method, body = null) {
    const commandName = this.requestToCommandName(url, method);
    if (!commandName) {
      return await this.proxy(url, method, body);
    }
    this.log.debug(`Matched '${url}' to command name '${commandName}'`);

    return await this.protocolConverter.convertAndProxy(commandName, url, method, body);
  }

  /**
   *
   * @param {string} url
   * @param {import('@appium/types').HTTPMethod} method
   * @param {any?} body
   * @returns {Promise<unknown>}
   */
  async command(url, method, body = null) {
    let response;
    let resBodyObj;
    try {
      [response, resBodyObj] = await this.proxyCommand(url, method, body);
    } catch (err) {
      if (isErrorType(err, errors.ProxyRequestError)) {
        throw err.getActualError();
      }
      throw new errors.UnknownError(err.message);
    }
    const protocol = this.getProtocolFromResBody(resBodyObj);
    if (protocol === MJSONWP) {
      // Got response in MJSONWP format
      if (response.statusCode === 200 && resBodyObj.status === 0) {
        return resBodyObj.value;
      }
      const status = parseInt(resBodyObj.status, 10);
      if (!isNaN(status) && status !== 0) {
        let message = resBodyObj.value;
        if (_.has(message, 'message')) {
          message = message.message;
        }
        throw errorFromMJSONWPStatusCode(
          status,
          _.isEmpty(message) ? getSummaryByCode(status) : message
        );
      }
    } else if (protocol === W3C) {
      // Got response in W3C format
      if (response.statusCode < 300) {
        return resBodyObj.value;
      }
      if (_.isPlainObject(resBodyObj.value) && resBodyObj.value.error) {
        throw errorFromW3CJsonCode(
          resBodyObj.value.error,
          resBodyObj.value.message,
          resBodyObj.value.stacktrace
        );
      }
    } else if (response.statusCode === 200) {
      // Unknown protocol. Keeping it because of the backward compatibility
      return resBodyObj;
    }
    throw new errors.UnknownError(
      `Did not know what to do with response code '${response.statusCode}' ` +
        `and response body '${_.truncate(JSON.stringify(resBodyObj), {
          length: 300,
        })}'`
    );
  }

  getSessionIdFromUrl(url) {
    const match = url.match(/\/session\/([^/]+)/);
    return match ? match[1] : null;
  }

  async proxyReqRes(req, res) {
    // ! this method must not throw any exceptions
    // ! make sure to call res.send before return
    let statusCode;
    let resBodyObj;
    try {
      let response;
      [response, resBodyObj] = await this.proxyCommand(req.originalUrl, req.method, req.body);
      res.headers = response.headers;
      statusCode = response.statusCode;
    } catch (err) {
      [statusCode, resBodyObj] = getResponseForW3CError(
        isErrorType(err, errors.ProxyRequestError) ? err.getActualError() : err
      );
    }
    res.set('content-type', 'application/json; charset=utf-8');
    if (!_.isPlainObject(resBodyObj)) {
      const error = new errors.UnknownError(
        `The downstream server response with the status code ${statusCode} is not a valid JSON object: ` +
          _.truncate(`${resBodyObj}`, {length: 300})
      );
      [statusCode, resBodyObj] = getResponseForW3CError(error);
    }

    // if the proxied response contains a sessionId that the downstream
    // driver has generated, we don't want to return that to the client.
    // Instead, return the id from the request or from current session
    if (_.has(resBodyObj, 'sessionId')) {
      const reqSessionId = this.getSessionIdFromUrl(req.originalUrl);
      if (reqSessionId) {
        this.log.info(`Replacing sessionId ${resBodyObj.sessionId} with ${reqSessionId}`);
        resBodyObj.sessionId = reqSessionId;
      } else if (this.sessionId) {
        this.log.info(`Replacing sessionId ${resBodyObj.sessionId} with ${this.sessionId}`);
        resBodyObj.sessionId = this.sessionId;
      }
    }
    resBodyObj.value = formatResponseValue(resBodyObj.value);
    res.status(statusCode).send(JSON.stringify(formatStatus(resBodyObj)));
  }
}

export {JWProxy};
export default JWProxy;

/**
 * @typedef {Error & {response: {data: import('type-fest').JsonObject, status: import('http-status-codes').StatusCodes}}} ProxyError
 */
