import { YHTTPError } from 'yhttperror';
import { parse as parseContentType } from 'content-type';
// @ts-expect-error No types 🤷
import preferredCharsets from 'negotiator/lib/charset.js';
// @ts-expect-error No types 🤷
import preferredMediaType from 'negotiator/lib/encoding.js';
import { YError } from 'yerror';
import { pickFirstHeaderValue, pickAllHeaderValues } from './headers.js';
import { ensureResolvedObject } from 'ya-open-api-types';
import {
  type WhookOpenAPI,
  type WhookOpenAPIOperation,
} from '../types/openapi.js';
import { type WhookRequest, type WhookResponse } from '../types/http.js';
import {
  type WhookRouteHandler,
  type WhookRouteDefinition,
  type WhookRouteHandlerParameters,
} from '../types/routes.js';
import {
  type WhookRequestBodySpec,
  type WhookResponseBodySpec,
} from './body.js';

export async function extractConsumableMediaTypes(
  API: WhookOpenAPI,
  operation: WhookOpenAPIOperation,
): Promise<string[]> {
  if (!('requestBody' in operation && operation.requestBody)) {
    return [];
  }

  const requestBody = await ensureResolvedObject(API, operation.requestBody);

  if (!('content' in requestBody && requestBody.content)) {
    return [];
  }

  // Per spec contents, the `content` property should always
  // be present so not checking before using it
  // https://swagger.io/specification/#requestBodyObject

  return Object.keys(requestBody.content);
}

export async function extractProduceableMediaTypes(
  API: WhookOpenAPI,
  operation: WhookOpenAPIOperation,
): Promise<string[]> {
  if (!('responses' in operation && operation.responses)) {
    return [];
  }

  const produceableMediaTypes: string[] = [];

  for (const response of Object.values(operation.responses)) {
    if (!(typeof response === 'object' && response)) {
      continue;
    }

    const resolvedResponse = await ensureResolvedObject(API, response);

    if (!('content' in resolvedResponse && resolvedResponse.content)) {
      continue;
    }

    produceableMediaTypes.push(...Object.keys(resolvedResponse.content || {}));
  }

  return [...new Set(produceableMediaTypes)];
}

export function extractBodySpec(
  request: WhookRequest,
  consumableMediaTypes: string[],
  consumableCharsets: string[],
): WhookRequestBodySpec {
  const contentLengthValues = pickAllHeaderValues(
    'content-length',
    request.headers,
  );
  const bodySpec: WhookRequestBodySpec = {
    contentType: '',
    contentLength: contentLengthValues.length
      ? Number(contentLengthValues[0])
      : 0,
    charset: 'utf-8',
  };

  if (request.headers['content-type']) {
    const baseContentType = pickFirstHeaderValue(
      'content-type',
      request.headers,
    );

    if (typeof baseContentType === 'string') {
      const parsedContentType = parseContentType(baseContentType);

      // See https://stackoverflow.com/questions/25201083/regex-to-match-and-validate-internet-media-type
      if (
        !parsedContentType.type.match(
          /(application|audio|font|example|image|message|model|multipart|text|video|x-(?:[0-9A-Za-z!#$%&'*+.^_`|~-]+))\/([0-9A-Za-z!#$%&'*+.^_`|~-]+)/,
        )
      ) {
        throw new YHTTPError(400, 'E_BAD_CONTENT_TYPE', [
          request.headers['content-type'],
        ]);
      }

      bodySpec.contentType = parsedContentType.type;
      if (
        parsedContentType.parameters &&
        parsedContentType.parameters.charset
      ) {
        if (
          ['utf-8', 'utf8'].includes(
            parsedContentType.parameters.charset.toLowerCase(),
          )
        ) {
          bodySpec.charset = 'utf-8';
        } else {
          throw new YError('E_UNSUPPORTED_CHARSET', [
            parsedContentType.parameters.charset,
          ]);
        }
      }
      if (
        parsedContentType.parameters &&
        parsedContentType.parameters.boundary
      ) {
        bodySpec.boundary = parsedContentType.parameters.boundary;
      }
    } else {
      throw new YError('E_EMPTY_CONTENT_TYPE');
    }
  }

  checkBodyCharset(bodySpec, consumableCharsets);
  checkBodyMediaType(bodySpec, consumableMediaTypes);

  return bodySpec;
}

export function checkBodyCharset(
  bodySpec: WhookRequestBodySpec,
  consumableCharsets: string[],
): void {
  if (
    bodySpec.contentLength &&
    bodySpec.charset &&
    !consumableCharsets.includes(bodySpec.charset)
  ) {
    throw new YHTTPError(406, 'E_UNSUPPORTED_CHARSET', [
      bodySpec.charset,
      consumableCharsets,
    ]);
  }
}

export function checkBodyMediaType(
  bodySpec: WhookRequestBodySpec,
  consumableMediaTypes: string[],
): void {
  if (
    bodySpec.contentLength &&
    bodySpec.contentType &&
    !consumableMediaTypes.includes(bodySpec.contentType)
  ) {
    throw new YHTTPError(415, 'E_UNSUPPORTED_MEDIA_TYPE', [
      bodySpec.contentType,
      consumableMediaTypes,
    ]);
  }
}

export function extractResponseSpec(
  operation: WhookOpenAPIOperation,
  request: WhookRequest,
  supportedMediaTypes: string[],
  supportedCharsets: string[],
): WhookResponseBodySpec {
  const accept = pickFirstHeaderValue('accept', request.headers) || '*';
  const responseSpec: WhookResponseBodySpec = {
    charsets: request.headers['accept-charset']
      ? preferredCharsets(
          pickFirstHeaderValue('accept-charset', request.headers),
          supportedCharsets,
        )
      : supportedCharsets,
    contentTypes: preferredMediaType(
      accept.replace(/(^|,)\*\/\*($|,|;)/g, '$1*$2'),
      supportedMediaTypes,
    ),
  };

  return responseSpec;
}

export function checkResponseMediaType(
  request: WhookRequest,
  responseSpec: WhookResponseBodySpec,
  produceableMediaTypes: string[],
): void {
  if (0 === responseSpec.contentTypes.length) {
    throw new YHTTPError(406, 'E_UNACCEPTABLE_MEDIA_TYPE', [
      request.headers.accept,
      produceableMediaTypes,
    ]);
  }
}

export function checkResponseCharset(
  request: WhookRequest,
  responseSpec: WhookResponseBodySpec,
  produceableCharsets: string[],
): void {
  if (0 === responseSpec.charsets.length) {
    throw new YHTTPError(406, 'E_UNACCEPTABLE_CHARSET', [
      request.headers['accept-charset'],
      responseSpec.charsets,
      produceableCharsets,
    ]);
  }
}

export async function executeHandler(
  definition: WhookRouteDefinition,
  handler: WhookRouteHandler,
  parameters: WhookRouteHandlerParameters,
): Promise<WhookResponse> {
  const responsePromise = handler(parameters, definition);

  if (!(responsePromise && responsePromise.then)) {
    throw new YHTTPError(500, 'E_NO_RESPONSE_PROMISE', [
      definition.operation.operationId,
      definition.method,
      definition.path,
    ]);
  }

  const response = await responsePromise;

  if (!response) {
    throw new YHTTPError(500, 'E_NO_RESPONSE', [
      definition.operation.operationId,
      definition.method,
      definition.path,
    ]);
  }
  if ('undefined' === typeof response.status) {
    throw new YHTTPError(500, 'E_NO_RESPONSE_STATUS', [
      definition.operation.operationId,
      definition.method,
      definition.path,
    ]);
  }
  if ('number' !== typeof response.status) {
    throw new YHTTPError(500, 'E_NON_NUMERIC_STATUS', [
      definition.operation.operationId,
      definition.method,
      definition.path,
    ]);
  }

  response.headers = response.headers || {};
  return response;
}
