import is from '@sindresorhus/is';
import { supportedDatasources as presetSupportedDatasources } from '../../config/presets/internal/merge-confidence';
import type { AllConfig, UpdateType } from '../../config/types';
import { logger } from '../../logger';
import { ExternalHostError } from '../../types/errors/external-host-error';
import * as packageCache from '../cache/package';
import * as hostRules from '../host-rules';
import { Http } from '../http';
import { memCacheProvider } from '../http/cache/memory-http-cache-provider';
import { regEx } from '../regex';
import { ensureTrailingSlash, joinUrlParts } from '../url';
import { MERGE_CONFIDENCE } from './common';
import type { MergeConfidence } from './types';

const hostType = 'merge-confidence';
const http = new Http(hostType);
let token: string | undefined;
let apiBaseUrl: string | undefined;
let supportedDatasources: string[] = [];

export const confidenceLevels: Record<MergeConfidence, number> = {
  low: -1,
  neutral: 0,
  high: 1,
  'very high': 2,
};

export function initConfig({
  mergeConfidenceEndpoint,
  mergeConfidenceDatasources,
}: AllConfig): void {
  apiBaseUrl = getApiBaseUrl(mergeConfidenceEndpoint);
  token = getApiToken();

  supportedDatasources =
    mergeConfidenceDatasources ?? presetSupportedDatasources;

  if (!is.nullOrUndefined(token)) {
    logger.debug(`Merge confidence token found for ${apiBaseUrl}`);
  }
}

export function resetConfig(): void {
  token = undefined;
  apiBaseUrl = undefined;
  supportedDatasources = [];
}

export function isMergeConfidence(value: string): value is MergeConfidence {
  return MERGE_CONFIDENCE.includes(value as MergeConfidence);
}

export function isActiveConfidenceLevel(confidence: string): boolean {
  return isMergeConfidence(confidence) && confidence !== 'low';
}

export function satisfiesConfidenceLevel(
  confidence: MergeConfidence,
  minimumConfidence: MergeConfidence,
): boolean {
  return confidenceLevels[confidence] >= confidenceLevels[minimumConfidence];
}

const updateTypeConfidenceMapping: Record<UpdateType, MergeConfidence | null> =
  {
    pin: 'high',
    digest: 'neutral',
    pinDigest: 'high',
    bump: 'neutral',
    lockFileMaintenance: 'neutral',
    lockfileUpdate: 'neutral',
    rollback: 'neutral',
    replacement: 'neutral',
    major: null,
    minor: null,
    patch: null,
  };

/**
 * Retrieves the merge confidence of a package update if the merge confidence API is enabled. Otherwise, undefined is returned.
 *
 * @param datasource
 * @param packageName
 * @param currentVersion
 * @param newVersion
 * @param updateType
 *
 * @returns The merge confidence level for the given package release.
 * @throws {ExternalHostError} If a request has been made and an error occurs during the request, such as a timeout, connection reset, authentication failure, or internal server error.
 */
export async function getMergeConfidenceLevel(
  datasource: string,
  packageName: string,
  currentVersion: string,
  newVersion: string,
  updateType: UpdateType,
): Promise<MergeConfidence | undefined> {
  if (is.nullOrUndefined(apiBaseUrl) || is.nullOrUndefined(token)) {
    return undefined;
  }

  if (!supportedDatasources.includes(datasource)) {
    return undefined;
  }

  if (!(currentVersion && newVersion && updateType)) {
    return 'neutral';
  }

  const mappedConfidence = updateTypeConfidenceMapping[updateType];
  if (mappedConfidence) {
    return mappedConfidence;
  }

  return await queryApi(datasource, packageName, currentVersion, newVersion);
}

/**
 * Queries the Merge Confidence API with the given package release information.
 *
 * @param datasource
 * @param packageName
 * @param currentVersion
 * @param newVersion
 *
 * @returns The merge confidence level for the given package release.
 * @throws {ExternalHostError} if a timeout or connection reset error, authentication failure, or internal server error occurs during the request.
 *
 * @remarks
 * Results are cached for 60 minutes to reduce the number of API calls.
 */
async function queryApi(
  datasource: string,
  packageName: string,
  currentVersion: string,
  newVersion: string,
): Promise<MergeConfidence> {
  // istanbul ignore if: defensive, already been validated before calling this function
  if (is.nullOrUndefined(apiBaseUrl) || is.nullOrUndefined(token)) {
    return 'neutral';
  }

  const escapedPackageName = packageName.replace(regEx(/\//g), '%2f');
  const url = joinUrlParts(
    apiBaseUrl,
    'api/mc/json',
    datasource,
    escapedPackageName,
    currentVersion,
    newVersion,
  );
  const cacheKey = `${token}:${url}`;
  const cachedResult = await packageCache.get(hostType, cacheKey);

  // istanbul ignore if
  if (cachedResult) {
    logger.debug(
      {
        datasource,
        packageName,
        currentVersion,
        newVersion,
        cachedResult,
      },
      'using merge confidence cached result',
    );
    return cachedResult;
  }

  let confidence: MergeConfidence = 'neutral';
  try {
    const res = (
      await http.getJsonUnchecked<{ confidence: MergeConfidence }>(url, {
        cacheProvider: memCacheProvider,
      })
    ).body;
    if (isMergeConfidence(res.confidence)) {
      confidence = res.confidence;
    }
  } catch (err) {
    apiErrorHandler(err);
  }

  await packageCache.set(hostType, cacheKey, confidence, 60);
  return confidence;
}

/**
 * Checks the health of the Merge Confidence API by attempting to authenticate with it.
 *
 * @returns Resolves when the API health check is completed successfully.
 *
 * @throws {ExternalHostError} if a timeout, connection reset error, authentication failure, or internal server error occurs during the request.
 *
 * @remarks
 * This function first checks that the API base URL and an authentication bearer token are defined before attempting to
 * authenticate with the API. If either the base URL or token is not defined, it will immediately return
 * without making a request.
 */
export async function initMergeConfidence(config: AllConfig): Promise<void> {
  initConfig(config);

  if (is.nullOrUndefined(apiBaseUrl) || is.nullOrUndefined(token)) {
    logger.trace('merge confidence API usage is disabled');
    return;
  }

  const url = joinUrlParts(apiBaseUrl, 'api/mc/availability');
  try {
    await http.get(url);
  } catch (err) {
    apiErrorHandler(err);
  }

  logger.debug(
    { supportedDatasources },
    'merge confidence API - successfully authenticated',
  );
  return;
}

function getApiBaseUrl(mergeConfidenceEndpoint: string | undefined): string {
  const defaultBaseUrl = 'https://developer.mend.io/';
  const baseFromEnv = mergeConfidenceEndpoint ?? defaultBaseUrl;

  try {
    const parsedBaseUrl = new URL(baseFromEnv).toString();
    logger.trace(
      { baseUrl: parsedBaseUrl },
      'using merge confidence API base found in environment variables',
    );
    return ensureTrailingSlash(parsedBaseUrl);
  } catch (err) {
    logger.warn(
      { err, baseFromEnv },
      'invalid merge confidence API base URL found in environment variables - using default value instead',
    );
    return defaultBaseUrl;
  }
}

export function getApiToken(): string | undefined {
  return hostRules.find({
    url: apiBaseUrl,
    hostType,
  })?.token;
}

/**
 * Handles errors returned by the Merge Confidence API.
 *
 * @param err - The error object returned by the API.
 * @throws {ExternalHostError} if a timeout or connection reset error, authentication failure, or internal server error occurs during the request.
 */
function apiErrorHandler(err: any): void {
  if (err.code === 'ETIMEDOUT' || err.code === 'ECONNRESET') {
    logger.error({ err }, 'merge confidence API request failed - aborting run');
    throw new ExternalHostError(err, hostType);
  }

  if (err.statusCode === 403) {
    logger.error({ err }, 'merge confidence API token rejected - aborting run');
    throw new ExternalHostError(err, hostType);
  }

  if (err.statusCode >= 500 && err.statusCode < 600) {
    logger.error({ err }, 'merge confidence API failure: 5xx - aborting run');
    throw new ExternalHostError(err, hostType);
  }

  logger.warn({ err }, 'error fetching merge confidence data');
}
