import { noop, type LogService } from 'common-services';
import { YError } from 'yerror';
import { YHTTPError } from 'yhttperror';
import Stream from 'node:stream';
import { type ValidateFunction } from 'ajv';
import {
  ensureResolvedObject,
  type OpenAPIParameter,
  type OpenAPIExtension,
  type OpenAPI,
  type OpenAPISecurityScheme,
  type OpenAPIReference,
  type OpenAPISecurityRequirement,
  isValidOpenAPIPath,
  pathItemToOperationMap,
} from 'ya-open-api-types';
import {
  parseArrayOfBooleans,
  parseArrayOfNumbers,
  parseArrayOfStrings,
  parseBoolean,
  parseNumber,
  type WhookCoercionOptions,
} from './coercion.js';
import { JSONSchema, type ExpressiveJSONSchema } from 'ya-json-schema-types';
import { type WhookSchemaValidatorsService } from '../services/schemaValidators.js';
import { type WhookRequestBody } from '../types/http.js';
import {
  type WhookOpenAPIOperation,
  type WhookOpenAPI,
  type WhookSupportedParameter,
} from '../types/openapi.js';
import { identity } from './utils.js';

/* Architecture Note #2.11.2: Validation

For performance reasons, the schemaValidators are
 created once for all at startup from the
 API definition.

One could argue that it would have been
 better for performances to generate
 the code statically. This is true. It
 may be done later but it won't change
 the way it works so, moving fast for
 now but keeping it in mind.

Also, looking closely to Prepack that
 could improve significantly this
 project performances with close to no
 time costs:
 https://github.com/facebook/prepack/issues/522#issuecomment-300706099
*/

export type WhookBodyValidator = (
  operation: WhookOpenAPIOperation,
  contentType: string,
  body: WhookRequestBody | undefined,
) => void;

export async function prepareBodyValidator(
  {
    API,
    schemaValidators,
  }: { API: OpenAPI; schemaValidators: WhookSchemaValidatorsService },
  operation: WhookOpenAPIOperation,
): Promise<WhookBodyValidator> {
  if (!('requestBody' in operation) || !operation.requestBody) {
    return rejectAnyRequestBody;
  }

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

  if (!requestBodyObject.content) {
    return rejectAnyRequestBody;
  }

  const bodyValidators: Record<string, ValidateFunction<unknown>> = {};

  for (const mediaType in requestBodyObject.content) {
    const mediaTypeObject = requestBodyObject.content[mediaType];

    if (!('schema' in mediaTypeObject) || !mediaTypeObject.schema) {
      continue;
    }

    const schema = (await ensureResolvedObject(
      API,
      mediaTypeObject.schema,
    )) as ExpressiveJSONSchema;

    const isBinaryContent =
      typeof schema === 'object' &&
      schema &&
      'type' in schema &&
      schema.type === 'string' &&
      schema.format === 'binary';

    if (isBinaryContent) {
      continue;
    }

    try {
      bodyValidators[mediaType] = schemaValidators(mediaTypeObject.schema);
    } catch (err) {
      throw YError.wrap(err as Error, 'E_BAD_BODY_SCHEMA', [
        operation.operationId,
        mediaType,
      ]);
    }
  }

  return validateRequestBody.bind(
    null,
    bodyValidators,
    !!requestBodyObject.required,
  );
}

function validateRequestBody(
  bodyValidators: Record<string, ValidateFunction>,
  required: boolean,
  operation: WhookOpenAPIOperation,
  contentType: string,
  body: WhookRequestBody | undefined,
): void {
  if ('undefined' === typeof body) {
    if (required) {
      throw new YHTTPError(400, 'E_REQUIRED_REQUEST_BODY', [
        operation.operationId,
        typeof body,
        body,
      ]);
    }
    return;
  }

  // Streamed contents, let it pass
  if (!bodyValidators[contentType]) {
    return;
  }

  if (!bodyValidators[contentType](body)) {
    throw new YHTTPError(400, 'E_BAD_REQUEST_BODY', [
      operation.operationId,
      typeof body,
      (body as WhookRequestBody) instanceof Stream ? 'Stream' : body,
      bodyValidators[contentType].errors,
    ]);
  }
}

function rejectAnyRequestBody(
  operation: WhookOpenAPIOperation,
  _contentType: string,
  body: unknown,
): void {
  if ('undefined' !== typeof body) {
    throw new YHTTPError(400, 'E_NO_REQUEST_BODY', [
      operation.operationId,
      typeof body,
      body instanceof Stream ? 'Stream' : body,
    ]);
  }
}

// Supporting only mainstream schemes
// https://www.iana.org/assignments/http-authschemes/http-authschemes.xhtml
const SUPPORTED_HTTP_SCHEMES = ['basic', 'bearer', 'digest'];

export function extractSecuritySchemesNames(
  requirements: OpenAPISecurityRequirement[],
) {
  return [
    ...new Set(
      requirements.map((requirement) => Object.keys(requirement)).flat(),
    ),
  ].sort();
}

export async function extractAPISchemesKeysCombinations<T extends OpenAPI>({
  API,
}: {
  API: T;
}) {
  const schemesKeysCombinations: string[][] = [];

  if (API.paths) {
    for (const path in API.paths) {
      if (!isValidOpenAPIPath(path)) {
        throw new YError('E_BAD_PATH', [path]);
      }

      const pathItem = API.paths[path];

      for (const [method, operation] of Object.entries(
        pathItemToOperationMap(pathItem) as Record<
          string,
          WhookOpenAPIOperation
        >,
      )) {
        const operationId = operation.operationId;

        if (!operationId) {
          throw new YError('E_NO_OPERATION_ID', [path, method]);
        }

        const schemesKeysCombination =
          await extractOperationSchemesKeysCombination({ API }, operation);

        if (
          schemesKeysCombinations.every(
            (aSchemesKeysCombination) =>
              aSchemesKeysCombination.join('-') !==
              schemesKeysCombination.join('-'),
          )
        ) {
          schemesKeysCombinations.push(schemesKeysCombination);
        }
      }
    }
  }
  return schemesKeysCombinations;
}

export async function extractOperationSchemesKeysCombination<T extends OpenAPI>(
  {
    API,
  }: {
    API: T;
  },
  operation: WhookOpenAPIOperation,
) {
  return [
    ...new Set([
      ...(API.security ? extractSecuritySchemesNames(API.security) : []),
      ...(operation.security
        ? extractSecuritySchemesNames(operation.security)
        : []),
    ]),
  ].sort();
}

export async function extractParametersFromSchemesKeysCombination<
  T extends OpenAPI,
>({ API }: { API: T }, schemesKeysCombination: string[]) {
  if (!schemesKeysCombination.length) {
    return [];
  }

  const securitySchemes =
    (API.components && API.components.securitySchemes) || {};
  const combinationSecuritySchemes: OpenAPISecurityScheme<OpenAPIExtension>[] =
    [];

  for (const schemeKey of schemesKeysCombination) {
    if (!securitySchemes[schemeKey]) {
      throw new YError('E_UNDECLARED_SECURITY_SCHEME', [schemeKey]);
    }
    combinationSecuritySchemes.push(
      await ensureResolvedObject(API, securitySchemes[schemeKey]),
    );
  }

  return extractParametersFromSecuritySchemes(combinationSecuritySchemes);
}

export async function extractOperationSecurityParameters(
  { API }: { API: WhookOpenAPI },
  operation: WhookOpenAPIOperation,
): Promise<WhookSupportedParameter[]> {
  const schemesKeysCombination = await extractOperationSchemesKeysCombination(
    { API },
    operation,
  );

  return extractParametersFromSchemesKeysCombination(
    { API },
    schemesKeysCombination,
  );
}

export async function extractAPISecurityParametersSchemas({
  API,
}: {
  API: WhookOpenAPI;
}) {
  const schemesKeysCombinations = await extractAPISchemesKeysCombinations({
    API,
  });

  return (
    await Promise.all(
      schemesKeysCombinations.map((schemesKeysCombination) =>
        extractParametersFromSchemesKeysCombination(
          { API },
          schemesKeysCombination,
        ),
      ),
    )
  )
    .flat()
    .map((parameter) => parameter.schema);
}

export function extractParametersFromSecuritySchemes(
  securitySchemes: OpenAPISecurityScheme<OpenAPIExtension>[],
): WhookSupportedParameter[] {
  const hasOAuth = securitySchemes.some((securityScheme) =>
    ['oauth2', 'openIdConnect'].includes(securityScheme.type),
  );
  const httpSchemes = [
    ...new Set([
      ...securitySchemes
        .filter((securityScheme) => securityScheme.type === 'http')
        .map((securityScheme) => {
          if (!SUPPORTED_HTTP_SCHEMES.includes(securityScheme.scheme)) {
            throw new YError('E_UNSUPPORTED_HTTP_SCHEME', [
              securityScheme.scheme,
            ]);
          }
          return securityScheme.scheme;
        }),
      ...(hasOAuth ? ['bearer'] : []),
    ]),
  ];
  const hasBearerAuth = httpSchemes.includes('bearer');
  let hasAuthorizationApiKey = false;
  let hasAccessTokenApiKey = false;

  const securityParameters: WhookSupportedParameter[] = securitySchemes
    .filter((securityScheme) => securityScheme.type === 'apiKey')
    .map((securityScheme) => {
      if (securityScheme.in === 'cookie') {
        throw new YError('E_UNSUPPORTED_API_KEY_SOURCE', [
          'cookie',
          securityScheme.name,
        ]);
      }
      // This overlaps with OAuth/HTTP schemes
      if (
        securityScheme.in === 'header' &&
        securityScheme.name.toLowerCase() === 'authorization'
      ) {
        hasAuthorizationApiKey = true;
      }
      // This overlaps with OAuth and BearerAuth schemes
      if (
        securityScheme.in === 'query' &&
        securityScheme.name === 'access_token'
      ) {
        hasAccessTokenApiKey = true;
      }

      return {
        in: securityScheme.in,
        name:
          securityScheme.in === 'header'
            ? securityScheme.name.toLowerCase()
            : securityScheme.name,
        schema: {
          type: 'string',
        } as ExpressiveJSONSchema,
      };
    })
    .concat(
      httpSchemes.length && !hasAuthorizationApiKey
        ? [
            {
              in: 'header',
              name: 'authorization',
              schema: {
                type: 'string',
                pattern: `(${httpSchemes
                  .map(
                    (httpScheme) =>
                      `(${
                        httpScheme[0]
                      }|${httpScheme[0].toUpperCase()})${httpScheme.slice(1)}`,
                  )
                  .join('|')}) .*`,
              } as ExpressiveJSONSchema,
            },
          ]
        : [],
    )
    .concat(
      hasBearerAuth && !hasAccessTokenApiKey
        ? [
            {
              in: 'query',
              name: 'access_token',
              schema: {
                type: 'string',
              },
            },
          ]
        : [],
    );
  return securityParameters;
}

export async function resolveParameters<T extends OpenAPI>(
  {
    API,
    log = noop,
  }: {
    API: T;
    log?: LogService;
  },
  parameters: (
    | OpenAPIParameter<ExpressiveJSONSchema, OpenAPIExtension>
    | OpenAPIReference<OpenAPIParameter<ExpressiveJSONSchema, OpenAPIExtension>>
  )[],
) {
  const resolvedParameters: WhookSupportedParameter[] = [];

  for (const parameter of parameters) {
    const resolvedParameter = await ensureResolvedObject(API, parameter);

    if ('style' in resolvedParameter) {
      log('warning', '⚠️ - Only defaults styles are supported currently!');
      log('debug', JSON.stringify(resolvedParameter));
    }
    if ('string' !== typeof resolvedParameter.name) {
      throw new YError('E_BAD_PARAMETER_NAME', [resolvedParameter]);
    }

    if (resolvedParameter.in === 'querystring') {
      throw new YError('E_UNSUPPORTED_PARAMETER_LOCATION', [
        resolvedParameter.name,
        'querystring',
      ]);
    }

    if ('content' in resolvedParameter) {
      throw new YError('E_UNSUPPORTED_PARAMETER_DEFINITION', [
        resolvedParameter.name,
        'content',
      ]);
    }

    if ('style' in resolvedParameter && 'simple' !== resolvedParameter.style) {
      throw new YError('E_UNSUPPORTED_PARAMETER_DEFINITION', [
        resolvedParameter.name,
        'style',
        resolvedParameter.style,
      ]);
    }

    if (!['query', 'header', 'path'].includes(resolvedParameter.in)) {
      throw new YError('E_UNSUPPORTED_PARAMETER_DEFINITION', [
        resolvedParameter.name,
        'in',
        resolvedParameter.in,
      ]);
    }

    if (!resolvedParameter.schema) {
      throw new YError('E_PARAMETER_WITHOUT_SCHEMA', [resolvedParameter.name]);
    }

    resolvedParameters.push(resolvedParameter);
  }

  return resolvedParameters;
}

export type WhookParameterValue =
  | boolean
  | boolean[]
  | string
  | string[]
  | number
  | number[]
  | undefined;
export type WhookParameterCaster = (str: string) => WhookParameterValue;
export type WhookParameterValidator = (
  str: string | undefined,
) => WhookParameterValue;
export type WhookParametersValidators = Record<
  'query' | 'header' | 'path' | 'cookie' | 'querystring',
  Record<string, WhookParameterValidator>
>;

export async function getCasterForSchema(
  {
    API,
    COERCION_OPTIONS,
  }: {
    API: WhookOpenAPI;
    COERCION_OPTIONS: WhookCoercionOptions;
  },
  schema: JSONSchema,
): Promise<WhookParameterCaster> {
  const resolvedSchema = (await ensureResolvedObject(
    API,
    schema,
  )) as ExpressiveJSONSchema;

  if (!('type' in resolvedSchema && resolvedSchema.type)) {
    throw new YError('E_UNSUPPORTED_SCHEMA', [resolvedSchema]);
  }

  if (resolvedSchema.type === 'number') {
    return parseNumber.bind(null, COERCION_OPTIONS);
  } else if (resolvedSchema.type === 'boolean') {
    return parseBoolean;
  } else if (resolvedSchema.type === 'string') {
    return identity;
  } else if (resolvedSchema.type === 'array') {
    if (
      !('items' in resolvedSchema && resolvedSchema.items) ||
      'prefixItems' in resolvedSchema
    ) {
      throw new YError('E_UNSUPPORTED_SCHEMA', [resolvedSchema]);
    }

    const itemSchema = (await ensureResolvedObject(
      API,
      resolvedSchema.items,
    )) as ExpressiveJSONSchema;

    if (!('type' in itemSchema && itemSchema.type)) {
      throw new YError('E_UNSUPPORTED_PARAMETER_SCHEMA', [resolvedSchema]);
    }
    if (itemSchema.type === 'string') {
      return parseArrayOfStrings;
    } else if (itemSchema.type === 'number') {
      return parseArrayOfNumbers.bind(null, COERCION_OPTIONS);
    } else if (itemSchema.type === 'boolean') {
      return parseArrayOfBooleans;
    }
  }
  throw new YError('E_UNSUPPORTED_PARAMETER_SCHEMA', [resolvedSchema]);
}

export async function createParameterValidator(
  {
    API,
    COERCION_OPTIONS,
    schemaValidators,
  }: {
    API: WhookOpenAPI;
    COERCION_OPTIONS: WhookCoercionOptions;
    schemaValidators: WhookSchemaValidatorsService;
  },
  parameter: WhookSupportedParameter,
): Promise<WhookParameterValidator> {
  let validator: ReturnType<typeof schemaValidators>;
  let caster: WhookParameterCaster | undefined = undefined;

  const schema = (await ensureResolvedObject(
    API,
    parameter.schema,
  )) as ExpressiveJSONSchema;

  if (!('type' in schema && schema.type)) {
    throw new YError('E_UNSUPPORTED_PARAMETER_SCHEMA', [parameter]);
  }

  if (schema.type === 'number') {
    caster = parseNumber.bind(null, COERCION_OPTIONS);
  } else if (schema.type === 'boolean') {
    caster = parseBoolean;
  } else if (schema.type === 'array') {
    if (!('items' in schema && schema.items) || 'prefixItems' in schema) {
      throw new YError('E_UNSUPPORTED_PARAMETER_SCHEMA', [parameter]);
    }

    const itemSchema = (await ensureResolvedObject(
      API,
      schema.items,
    )) as ExpressiveJSONSchema;

    if (!('type' in itemSchema && itemSchema.type)) {
      throw new YError('E_UNSUPPORTED_PARAMETER_SCHEMA', [parameter]);
    }
    if (itemSchema.type === 'string') {
      caster = parseArrayOfStrings;
    } else if (itemSchema.type === 'number') {
      caster = parseArrayOfNumbers.bind(null, COERCION_OPTIONS);
    } else if (itemSchema.type === 'boolean') {
      caster = parseArrayOfBooleans;
    }
  }

  try {
    validator = schemaValidators(parameter.schema);
  } catch (err) {
    throw YError.wrap(err as Error, 'E_BAD_PARAMETER_SCHEMA', [parameter.name]);
  }

  return validateParameter.bind(null, parameter, caster, validator);
}

export async function createParametersValidators(
  {
    API,
    COERCION_OPTIONS,
    schemaValidators,
  }: {
    API: WhookOpenAPI;
    COERCION_OPTIONS: WhookCoercionOptions;
    schemaValidators: WhookSchemaValidatorsService;
  },
  parameters: WhookSupportedParameter[],
) {
  const parameterValidators: WhookParametersValidators = {
    query: {},
    header: {},
    path: {},
    cookie: {},
    querystring: {},
  };

  for (const parameter of parameters) {
    parameterValidators[parameter.in][parameter.name] =
      await createParameterValidator(
        {
          API,
          COERCION_OPTIONS,
          schemaValidators,
        },
        parameter,
      );
  }

  return parameterValidators;
}

export function validateParameter(
  parameter: OpenAPIParameter<ExpressiveJSONSchema, OpenAPIExtension>,
  caster: WhookParameterCaster | undefined,
  validator: ValidateFunction,
  str: string | undefined,
): WhookParameterValue | undefined {
  if ('undefined' === typeof str) {
    if (parameter.required) {
      throw new YHTTPError(400, 'E_REQUIRED_PARAMETER', [
        parameter.name,
        typeof str,
        str,
      ]);
    }
    return undefined;
  }

  const value = caster ? caster(str) : str;

  if (!validator(value)) {
    throw new YHTTPError(400, 'E_BAD_PARAMETER', [
      parameter.name,
      typeof value,
      value,
      validator.errors,
    ]);
  }
  return value;
}
