/**
 * Encryption Utility
 * AES-256 encryption for storing sensitive API keys (Stripe, Razorpay).
 */

import crypto from 'crypto';

const ALGORITHM = 'aes-256-cbc';
// In production, this MUST come from the environment variable ENCRYPTION_KEY.
const DEFAULT_INSECURE_KEY = 'mengo-encryption-key-32-bytes!!';

/**
 * Resolved per call, never captured at module scope.
 *
 * ES imports are hoisted above `dotenv.config()` in index.ts, so reading
 * `process.env.ENCRYPTION_KEY` while this module is being evaluated returns
 * whatever was set before the .env files were read — which, depending on which
 * module happens to pull this one in first, can be nothing at all. That would
 * pin the process to the fallback key for its whole lifetime even though the
 * real key was moments away from being loaded, and every secret written in that
 * process would be unreadable by a correctly-keyed one. (index.ts carries the
 * same warning about JWT_SECRET, which was bitten by exactly this.)
 */
function resolveKeyMaterial(): string {
  return process.env.ENCRYPTION_KEY || DEFAULT_INSECURE_KEY;
}

let warnedAboutFallbackKey = false;

function getKey(): Buffer {
  const keyMaterial = resolveKeyMaterial();

  // Warn once if the hardcoded fallback is in use — with it, "encrypted at rest" is
  // effectively reversible by anyone with the source, so real secrets (hosting tokens,
  // FTP/SSH passwords, payment keys) are not meaningfully protected. It also means
  // anything written now becomes unreadable as soon as the real key IS present, which
  // is what a "the saved password disappeared after restarting" report usually is.
  if (keyMaterial === DEFAULT_INSECURE_KEY && !warnedAboutFallbackKey) {
    warnedAboutFallbackKey = true;
    console.warn(
      '[encryption] ENCRYPTION_KEY is not set — using an insecure hardcoded key. ' +
        'Secrets saved now will NOT be readable once ENCRYPTION_KEY is set, and vice versa. ' +
        'Set ENCRYPTION_KEY (32+ chars) in the environment to protect stored secrets.',
    );
  }

  return crypto.createHash('sha256').update(keyMaterial).digest();
}

export function encrypt(text: string): string {
  try {
    const key = getKey();
    const iv = crypto.randomBytes(16);
    const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
    let encrypted = cipher.update(text, 'utf8', 'hex');
    encrypted += cipher.final('hex');
    // Prepend IV to the encrypted text so it can be decrypted
    return iv.toString('hex') + ':' + encrypted;
  } catch (error) {
    // Never fall back to storing the raw secret — that would silently persist API tokens
    // and passwords in plaintext. Fail the write instead so the caller surfaces the error.
    console.error('Encryption error:', error);
    throw new Error('Failed to encrypt secret');
  }
}

/** Does this look like a value produced by `encrypt()` — `<iv hex>:<cipher hex>`? */
export function isEncryptedValue(value: unknown): boolean {
  return typeof value === 'string' && /^[0-9a-f]{32}:[0-9a-f]+$/i.test(value);
}

export type DecryptOutcome =
  | { ok: true; value: string }
  /**
   * `wrong-key`  — the value is ciphertext this key cannot open.
   * `not-encrypted` — stored before encryption existed, or written directly;
   *                   the raw value is returned and is usable as-is.
   */
  | { ok: false; reason: 'wrong-key' | 'not-encrypted'; value: string };

/** Message shown wherever an unreadable secret surfaces. */
export const DECRYPTION_KEY_MISMATCH_MESSAGE =
  'The stored secret cannot be decrypted with this server\'s ENCRYPTION_KEY. '
  + 'It was saved by an environment using a different key — re-enter and save it here, '
  + 'or set the same ENCRYPTION_KEY as the environment that saved it.';

/**
 * Decrypt a stored secret and say plainly whether it worked.
 *
 * The failure that matters is a key mismatch, and AES-CBC does not reliably
 * announce one: a wrong key usually trips the padding check and throws, but
 * roughly one time in 256 the padding validates anyway and you get plausible
 * *binary* back. Silently handing that to a provider looks exactly like a wrong
 * password — which is how a re-keyed environment turns into hours spent
 * re-typing credentials that were never wrong.
 *
 * So a result carrying control characters is treated as a failed decrypt: every
 * secret stored here (SMTP passwords, API keys, FTP credentials) is printable
 * text, and none of them legitimately contain a NUL or a backspace.
 */
export function tryDecrypt(encryptedText: string): DecryptOutcome {
  if (typeof encryptedText !== 'string' || encryptedText === '') {
    return { ok: false, reason: 'not-encrypted', value: '' };
  }

  if (!isEncryptedValue(encryptedText)) {
    // Never encrypted (legacy row or a direct write) — usable as it stands.
    return { ok: false, reason: 'not-encrypted', value: encryptedText };
  }

  try {
    const key = getKey();
    const [ivHex, encrypted] = encryptedText.split(':');
    const decipher = crypto.createDecipheriv(ALGORITHM, key, Buffer.from(ivHex, 'hex'));
    let decrypted = decipher.update(encrypted, 'hex', 'utf8');
    decrypted += decipher.final('utf8');

    // eslint-disable-next-line no-control-regex
    if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(decrypted)) {
      return { ok: false, reason: 'wrong-key', value: '' };
    }

    return { ok: true, value: decrypted };
  } catch {
    return { ok: false, reason: 'wrong-key', value: '' };
  }
}

export function decrypt(encryptedText: string): string {
  const outcome = tryDecrypt(encryptedText);
  if (outcome.ok || outcome.reason === 'not-encrypted') return outcome.value;

  // Returning the ciphertext keeps the previous contract (callers get a string
  // and decide for themselves), but the log now names the actual cause instead
  // of leaving an operator to guess at a rejected credential.
  console.error(`[encryption] Could not decrypt a stored secret. ${DECRYPTION_KEY_MISMATCH_MESSAGE}`);
  return encryptedText;
}