import is from '@sindresorhus/is';
import { logger } from '../logger';
import * as memCache from '../util/cache/memory';
import { maskToken } from '../util/mask';
import { regEx } from '../util/regex';
import { addSecretForSanitizing } from '../util/sanitize';
import { ensureTrailingSlash } from '../util/url';
import { tryDecryptKbPgp } from './decrypt/kbpgp';
import {
  tryDecryptPublicKeyDefault,
  tryDecryptPublicKeyPKCS1,
} from './decrypt/legacy';
import { tryDecryptOpenPgp } from './decrypt/openpgp';
import { GlobalConfig } from './global';
import { DecryptedObject } from './schema';
import type { RenovateConfig } from './types';

export async function tryDecrypt(
  privateKey: string,
  encryptedStr: string,
  repository: string,
  keyName: string,
): Promise<string | null> {
  let decryptedStr: string | null = null;
  if (privateKey?.startsWith('-----BEGIN PGP PRIVATE KEY BLOCK-----')) {
    const decryptedObjStr =
      process.env.RENOVATE_X_USE_OPENPGP === 'true'
        ? await tryDecryptOpenPgp(privateKey, encryptedStr)
        : await tryDecryptKbPgp(privateKey, encryptedStr);
    if (decryptedObjStr) {
      decryptedStr = validateDecryptedValue(decryptedObjStr, repository);
    }
  } else {
    decryptedStr = tryDecryptPublicKeyDefault(privateKey, encryptedStr);
    if (is.string(decryptedStr)) {
      logger.warn(
        { keyName },
        'Encrypted value is using deprecated default padding, please change to using PGP encryption.',
      );
    } else {
      decryptedStr = tryDecryptPublicKeyPKCS1(privateKey, encryptedStr);
      // istanbul ignore if
      if (is.string(decryptedStr)) {
        logger.warn(
          { keyName },
          'Encrypted value is using deprecated PKCS1 padding, please change to using PGP encryption.',
        );
      }
    }
  }
  return decryptedStr;
}

function validateDecryptedValue(
  decryptedObjStr: string,
  repository: string,
): string | null {
  try {
    const decryptedObj = DecryptedObject.safeParse(decryptedObjStr);
    // istanbul ignore if
    if (!decryptedObj.success) {
      const error = new Error('config-validation');
      error.validationError = `Could not parse decrypted config.`;
      throw error;
    }

    const { o: org, r: repo, v: value } = decryptedObj.data;
    if (is.nonEmptyString(value)) {
      if (is.nonEmptyString(org)) {
        const orgPrefixes = org
          .split(',')
          .map((o) => o.trim())
          .map((o) => o.toUpperCase())
          .map((o) => ensureTrailingSlash(o));
        if (is.nonEmptyString(repo)) {
          const scopedRepos = orgPrefixes.map((orgPrefix) =>
            `${orgPrefix}${repo}`.toUpperCase(),
          );
          if (scopedRepos.some((r) => r === repository.toUpperCase())) {
            return value;
          } else {
            logger.debug(
              { scopedRepos },
              'Secret is scoped to a different repository',
            );
            const error = new Error('config-validation');
            error.validationError = `Encrypted secret is scoped to a different repository: "${scopedRepos.join(
              ',',
            )}".`;
            throw error;
          }
        } else {
          if (
            orgPrefixes.some((orgPrefix) =>
              repository.toUpperCase().startsWith(orgPrefix),
            )
          ) {
            return value;
          } else {
            logger.debug(
              { orgPrefixes },
              'Secret is scoped to a different org',
            );
            const error = new Error('config-validation');
            error.validationError = `Encrypted secret is scoped to a different org: "${orgPrefixes.join(
              ',',
            )}".`;
            throw error;
          }
        }
      } else {
        const error = new Error('config-validation');
        error.validationError = `Encrypted value in config is missing a scope.`;
        throw error;
      }
    } else {
      const error = new Error('config-validation');
      error.validationError = `Encrypted value in config is missing a value.`;
      throw error;
    }
  } catch (err) {
    logger.warn({ err }, 'Could not parse decrypted string');
  }
  return null;
}

export async function decryptConfig(
  config: RenovateConfig,
  repository: string,
  existingPath = '$',
): Promise<RenovateConfig> {
  logger.trace({ config }, 'decryptConfig()');
  const decryptedConfig = { ...config };
  const privateKey = GlobalConfig.get('privateKey');
  const privateKeyOld = GlobalConfig.get('privateKeyOld');
  for (const [key, val] of Object.entries(config)) {
    if (key === 'encrypted' && is.object(val)) {
      const path = `${existingPath}.${key}`;
      logger.debug({ config: val }, `Found encrypted config in ${path}`);

      const encryptedWarning = GlobalConfig.get('encryptedWarning');
      if (is.string(encryptedWarning)) {
        logger.once.warn(encryptedWarning);
      }

      if (privateKey) {
        for (const [eKey, eVal] of Object.entries(val)) {
          logger.debug(`Trying to decrypt ${eKey} in ${path}`);
          let decryptedStr = await tryDecrypt(
            privateKey,
            eVal,
            repository,
            eKey,
          );
          if (privateKeyOld && !is.nonEmptyString(decryptedStr)) {
            logger.debug(`Trying to decrypt with old private key`);
            decryptedStr = await tryDecrypt(
              privateKeyOld,
              eVal,
              repository,
              eKey,
            );
          }
          if (!is.nonEmptyString(decryptedStr)) {
            const error = new Error('config-validation');
            error.validationError = `Failed to decrypt field ${eKey}. Please re-encrypt and try again.`;
            throw error;
          }
          logger.debug(`Decrypted ${eKey} in ${path}`);
          if (eKey === 'npmToken') {
            const token = decryptedStr.replace(regEx(/\n$/), '');
            addSecretForSanitizing(token);
            logger.debug(
              { decryptedToken: maskToken(token) },
              'Migrating npmToken to npmrc',
            );
            if (is.string(decryptedConfig.npmrc)) {
              /* eslint-disable no-template-curly-in-string */
              if (decryptedConfig.npmrc.includes('${NPM_TOKEN}')) {
                logger.debug('Replacing ${NPM_TOKEN} with decrypted token');
                decryptedConfig.npmrc = decryptedConfig.npmrc.replace(
                  regEx(/\${NPM_TOKEN}/g),
                  token,
                );
              } else {
                logger.debug('Appending _authToken= to end of existing npmrc');
                decryptedConfig.npmrc = decryptedConfig.npmrc.replace(
                  regEx(/\n?$/),
                  `\n_authToken=${token}\n`,
                );
              }
              /* eslint-enable no-template-curly-in-string */
            } else {
              logger.debug('Adding npmrc to config');
              decryptedConfig.npmrc = `//registry.npmjs.org/:_authToken=${token}\n`;
            }
          } else {
            decryptedConfig[eKey] = decryptedStr;
            addSecretForSanitizing(decryptedStr);
          }
        }
      } else {
        logger.error('Found encrypted data but no privateKey');
      }
      delete decryptedConfig.encrypted;
    } else if (is.array(val)) {
      decryptedConfig[key] = [];
      for (const [index, item] of val.entries()) {
        if (is.object(item) && !is.array(item)) {
          const path = `${existingPath}.${key}[${index}]`;
          (decryptedConfig[key] as RenovateConfig[]).push(
            await decryptConfig(item as RenovateConfig, repository, path),
          );
        } else {
          (decryptedConfig[key] as unknown[]).push(item);
        }
      }
    } else if (is.object(val) && key !== 'content') {
      const path = `${existingPath}.${key}`;
      decryptedConfig[key] = await decryptConfig(
        val as RenovateConfig,
        repository,
        path,
      );
    }
  }
  delete decryptedConfig.encrypted;
  logger.trace({ config: decryptedConfig }, 'decryptedConfig');
  return decryptedConfig;
}