import { convert as convertApiFormat } from 'api-spec-converter';
import * as ct from 'content-type';
import * as express from 'express';
import { Response } from 'express';
import * as fs from 'fs';
import * as http from 'http';
import * as yaml from 'js-yaml';
import { Request as OasRequest } from 'openapi-backend';
import * as path from 'path';
import * as qs from 'qs';
import * as waitOn from 'wait-on';
import { ResponseParsingError } from '../types/errors';
import { ValidationResults } from '../types/validation';
import * as rp from 'request-promise-native';

function isSwaggerV2(apiDoc: any): boolean {
  return apiDoc.swagger === '2.0';
}

function isOpenAPIv3(apiDoc: any): boolean {
  return typeof apiDoc.openapi === 'string' && apiDoc.openapi.startsWith('3.');
}

/**
 * Provides the format of the OpenAPI document.
 * @param apiDoc A parsed OpenAPI document as a plain Object.
 */
export function getAPIDocFormat(
  apiDoc: any,
): ('openapi-2.0' | 'openapi-3.0') | null {
  const validators: { [key: string]: (apiDoc: any) => boolean } = {
    'openapi-2.0': isSwaggerV2,
    'openapi-3.0': isOpenAPIv3,
  };

  for (const format in validators) {
    if (!Object.prototype.hasOwnProperty.call(validators, format)) continue;
    const validator = validators[format];
    if (validator(apiDoc)) return format as 'openapi-2.0' | 'openapi-3.0';
  }

  return null;
}

export function parseJsonOrYaml(filePath: string, data: string): any {
  switch (path.extname(filePath)) {
    case '.json':
      return JSON.parse(data);
    case '.yaml':
    case '.yml':
      return yaml.safeLoad(data);
    case '.':
      throw new Error('Will not read a file that has no extension.');
    default:
      throw new Error('Wrong file extension.');
  }
}

export function readJsonOrYamlSync(filePath: string): any {
  return parseJsonOrYaml(filePath, fs.readFileSync(filePath, 'utf8'));
}

export function readFileSync(filePath: string): any {
  return readJsonOrYamlSync(filePath);
}

export async function fetchAndReadFile(uri: string): Promise<any> {
  return rp(uri).then(responseBody => parseJsonOrYaml(uri, responseBody));
}

/**
 * Converts a OpenAPI document to v3. It detects the filetype of the document
 * and returns the contents as an Object. Returns the contents of the
 * unmodified file when no conversion is necessary.
 */
export async function convertToOpenApiV3(
  apiDoc: any,
  filePath: string,
): Promise<any> {
  switch (getAPIDocFormat(apiDoc)) {
    case 'openapi-2.0': {
      const apiDocTarget = await convertApiFormat({
        from: 'swagger_2',
        to: 'openapi_3',
        source: filePath,
      });
      return apiDocTarget.spec;
    }
    case 'openapi-3.0':
      // Return unmodified OpenAPI document
      return apiDoc;
    default:
      throw new Error('Unsupported API document format');
  }
}

/**
 * Parses a request depending on the 'Content-Type' header. Supports JSON and
 * URL-encoded formats. The request body should be a Buffer.
 */
export function parseRequest(req: express.Request): any {
  const contentTypeString = req.get('content-type');
  if (!contentTypeString) {
    throw new Error('Received request with an empty Content-Type header.');
  }
  if (!(req.body instanceof Buffer)) {
    throw new Error('Can not parse a request body which is not a Buffer.');
  }
  const contentType = ct.parse(contentTypeString);
  const charset = contentType.parameters['charset'] || 'utf-8';
  if (req.is('application/json') || req.is('json')) {
    return JSON.parse(req.body.toString(charset));
  }
  if (req.is('application/x-www-form-urlencoded')) {
    return qs.parse(req.body.toString(charset));
  }
  throw new Error(`No parser available for content type '${contentType}'.`);
}

/** Converts an express.Request to a simplified OpenAPI request. */
export function toOasRequest(req: express.Request): OasRequest {
  const oasRequest: OasRequest = {
    method: req.method,
    path: req.params[0],
    headers: req.headers as {
      [key: string]: string | string[];
    },
    query: req.query,
  };

  // Parse when body is present
  if (typeof req.body !== 'undefined' && req.body instanceof Buffer) {
    try {
      oasRequest.body = parseRequest(req);
    } catch (e) {
      throw new ResponseParsingError('Failed to parse request body. ' + e);
    }
  }

  return oasRequest;
}

/**
 * Parses a response body depending on the 'Content-Type' header. Supports JSON
 * and URL-encoded formats. The response body should be a string.
 */
export function parseResponseBody(
  res: http.IncomingMessage & {
    body: string;
  },
): any {
  const contentTypeString = res.headers['content-type'];
  if (!contentTypeString) {
    throw new Error('Received response with an empty Content-Type header.');
  }
  const contentType = ct.parse(contentTypeString);

  if (!(typeof res.body === 'string')) {
    throw new Error('Can not parse a response body which is not a string.');
  }

  if (contentType.type === 'application/json' || contentType.type === 'json') {
    return JSON.parse(res.body);
  }
  if (contentType.type === 'application/x-www-form-urlencoded') {
    return qs.parse(res.body);
  }
  throw new Error(`No parser available for content type '${contentType}'.`);
}

/**
 * Copies the headers from a source response into a target response,
 * overwriting existing values.
 */
export function copyHeaders(
  sourceResponse: any,
  targetResponse: Response,
): void {
  for (const key in sourceResponse.headers) {
    if (!Object.prototype.hasOwnProperty.call(sourceResponse.headers, key)) {
      continue;
    }
    targetResponse.setHeader(key, sourceResponse.headers[key]);
  }
}

/**
 * Sets a custom openapi-cop validation header ('openapi-cop-validation-result')
 * to the validation results as JSON.
 */
export function setValidationHeader(
  res: Response,
  validationResults: ValidationResults,
): void {
  res.setHeader(
    'openapi-cop-validation-result',
    JSON.stringify(validationResults),
  );
}

/**
 * Sets a custom openapi-cop validation header ('openapi-cop-validation-result')
 * to the validation results as JSON.
 */
export function setSourceRequestHeader(
  res: Response,
  oasRequest: OasRequest,
): void {
  res.setHeader('openapi-cop-source-request', JSON.stringify(oasRequest));
}

/** Closes the server and waits until the port is again free. */
export async function closeServer(server: http.Server): Promise<void> {
  const port = (server.address() as any).port;
  await new Promise<void>((resolve, reject) => {
    server.close(err => {
      if (err) return reject(err);
      resolve();
    });
  });

  await waitOn({ resources: [`http://localhost:${port}`], reverse: true });
}

/**
 * Recursively maps a nested object (JSON) given a mapping function. Maps in
 * depth-first order. If it finds an array it applies the mapping function
 * for object elements.
 *
 * @param obj Object to be mapped on.
 * @param fn Mapping function that returns the new value.
 * @param traversalPath internal parameter used to track the current traversal path
 */
export function mapWalkObject(obj: any, fn: (currentObj: any, traversalPath: Array<string>) => any, traversalPath: Array<string> = []): any {
  let objCopy = Object.assign({}, obj);
  for (const key in obj) {
    if (!Object.prototype.hasOwnProperty.call(obj, key)) continue;
    const value = obj[key];
    if (value.constructor === Object) {
      objCopy[key] = mapWalkObject(value, fn, [...traversalPath, key]);
    } else if (value.constructor === Array) {
      objCopy[key] = objCopy[key].map((e: any) => {
        if (e.constructor === Object) {
          return mapWalkObject(e, fn, [...traversalPath, key]);
        } else {
          return e;
        }
      });
    }
  }
  objCopy = fn(objCopy, traversalPath);
  return objCopy;
}