/**
 * Crypto Utilities for DashCaddy
 * Handles encryption/decryption of sensitive credentials
 * Uses AES-256-GCM for authenticated encryption
 */

const crypto = require('crypto');
const fs = require('fs');
const path = require('path');

// Encryption settings
const ALGORITHM = 'aes-256-gcm';
const KEY_LENGTH = 32; // 256 bits
const IV_LENGTH = 16;  // 128 bits for GCM
const AUTH_TAG_LENGTH = 16;
const SALT_LENGTH = 32;

// Resolve encryption key file path — supports both standard install (/app/.encryption-key)
// and custom deployments with consolidated data directory (/app/data/.encryption-key)
function resolveKeyFile() {
  if (process.env.ENCRYPTION_KEY_FILE) {
    return process.env.ENCRYPTION_KEY_FILE;
  }
  const candidates = [
    path.join(__dirname, '.encryption-key'),
    path.join(__dirname, 'data', '.encryption-key'),
  ];
  for (const candidate of candidates) {
    if (fs.existsSync(candidate)) {
      return candidate;
    }
  }
  // No existing file — return standard path so first load creates it there
  return candidates[0];
}

const KEY_FILE = resolveKeyFile();

let encryptionKey = null;

/**
 * Generate a new encryption key
 * @returns {Buffer} 32-byte encryption key
 */
function generateKey() {
  return crypto.randomBytes(KEY_LENGTH);
}

/**
 * Derive a key from a password using PBKDF2 (async, non-blocking)
 * @param {string} password - Password to derive key from
 * @param {Buffer} salt - Salt for key derivation
 * @returns {Promise<Buffer>} Derived key
 */
async function deriveKey(password, salt) {
  return new Promise((resolve, reject) => {
    crypto.pbkdf2(password, salt, 100000, KEY_LENGTH, 'sha512', (err, key) => {
      if (err) reject(err);
      else resolve(key);
    });
  });
}

/**
 * Load or create the encryption key
 * @returns {Buffer} The encryption key
 */
function loadOrCreateKey() {
  if (encryptionKey) {
    return encryptionKey;
  }

  // Check for key in environment variable first
  if (process.env.DASHCADDY_ENCRYPTION_KEY) {
    encryptionKey = Buffer.from(process.env.DASHCADDY_ENCRYPTION_KEY, 'hex');
    console.log('[Crypto] Using encryption key from environment variable');
    return encryptionKey;
  }

  // Try to load from file
  if (fs.existsSync(KEY_FILE)) {
    try {
      const keyData = fs.readFileSync(KEY_FILE, 'utf8').trim();
      if (keyData.length >= 64) {
        encryptionKey = Buffer.from(keyData, 'hex');
        console.log('[Crypto] Loaded encryption key from file');
        // First-run bootstrap: if .bak doesn't exist yet, write the current
        // key to it. This ensures the silent recovery path is available from
        // the very next restart without requiring an explicit rotateKey().
        if (!fs.existsSync(KEY_FILE + '.bak')) {
          try {
            fs.writeFileSync(KEY_FILE + '.bak', keyData, { mode: 0o600 });
            console.log(`[Crypto] Seeded ${KEY_FILE}.bak with current key for future fallback`);
          } catch (e) {
            console.warn('[Crypto] Could not seed .bak key file:', e.message);
          }
        }
        // Try fallback to .bak key if primary can't decrypt existing credentials.
        // This handles the "container recreate rotated the key" case where the
        // backup key on disk is the ORIGINAL key that can still read the
        // bind-mounted /app/data/credentials.json written before the upgrade.
        if (fs.existsSync(KEY_FILE + '.bak')) {
          try {
            const backupData = fs.readFileSync(KEY_FILE + '.bak', 'utf8').trim();
            if (backupData.length >= 64) {
              encryptionKey = tryFallbackToBackupKey(Buffer.from(keyData, 'hex'), Buffer.from(backupData, 'hex'));
            }
          } catch (e) {
            console.warn('[Crypto] Could not check backup key:', e.message);
          }
        }
        return encryptionKey;
      }
      // File exists but key is invalid/empty - will generate new one below
    } catch (error) {
      console.error('[Crypto] Error loading key file:', error.message);
    }
  }

  // Generate new key
  encryptionKey = generateKey();

  try {
    // Save key to file with restricted permissions
    fs.writeFileSync(KEY_FILE, encryptionKey.toString('hex'), { mode: 0o600 });
    console.log('[Crypto] Generated and saved new encryption key');
  } catch (error) {
    console.warn('[Crypto] Could not save key to file:', error.message);
    console.warn('[Crypto] Key will be regenerated on restart - credentials will need to be re-entered');
  }

  return encryptionKey;
}

/**
 * If the primary key fails to decrypt any existing credentials, try the backup
 * key. This is the silent recovery path: if a container recreate replaced
 * .encryption-key with a fresh one but left .encryption-key.bak (the previous
 * key), the old key can still decrypt the bind-mounted credentials.json and
 * the user stays logged in without ever noticing.
 *
 * Called only at startup when both key files exist. Returns the working key
 * (either primary or backup). If neither works, returns the primary (existing
 * behavior — `retrieve()` will surface "unreadable" via credential-manager.diagnose).
 *
 * @param {Buffer} primaryKey - key from .encryption-key
 * @param {Buffer} backupKey  - key from .encryption-key.bak
 * @returns {Buffer} the key that should be used
 */
function tryFallbackToBackupKey(primaryKey, backupKey) {
  const CREDENTIALS_FILE = process.env.CREDENTIALS_FILE ||
    require('path').join(__dirname, 'credentials.json');
  if (!fs.existsSync(CREDENTIALS_FILE)) return primaryKey;

  let credentials;
  try {
    credentials = JSON.parse(fs.readFileSync(CREDENTIALS_FILE, 'utf8'));
  } catch {
    return primaryKey;
  }

  // Find the first encrypted entry to probe
  const probeEntry = Object.values(credentials).find(v => v && v.value && isEncrypted(v.value));
  if (!probeEntry) return primaryKey;

  const tryDecrypt = (key) => {
    const parts = probeEntry.value.split(':');
    if (parts.length !== 3) return false;
    try {
      const iv = Buffer.from(parts[0], 'base64');
      const tag = Buffer.from(parts[1], 'base64');
      const ct = Buffer.from(parts[2], 'base64');
      const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
      decipher.setAuthTag(tag);
      Buffer.concat([decipher.update(ct), decipher.final()]);
      return true;
    } catch { return false; }
  };

  if (tryDecrypt(primaryKey)) return primaryKey;
  if (tryDecrypt(backupKey)) {
    console.warn(
      '[Crypto] Primary encryption key failed to decrypt credentials; ' +
      'fell back to .encryption-key.bak. The current primary key was set ' +
      'without preserving the original. Consider rotating the key explicitly ' +
      'via the credential-manager API to avoid this warning next restart.'
    );
    return backupKey;
  }
  return primaryKey; // neither works — credential-manager.diagnose() will report 'unreadable'
}

/**
 * Encrypt sensitive data
 * @param {string|object} data - Data to encrypt (strings or objects)
 * @returns {string} Encrypted data as base64 string with format: iv:authTag:ciphertext
 */
function encrypt(data) {
  const key = loadOrCreateKey();
  const iv = crypto.randomBytes(IV_LENGTH);

  // Convert object to string if needed
  const plaintext = typeof data === 'object' ? JSON.stringify(data) : String(data);

  const cipher = crypto.createCipheriv(ALGORITHM, key, iv);

  let encrypted = cipher.update(plaintext, 'utf8', 'base64');
  encrypted += cipher.final('base64');

  const authTag = cipher.getAuthTag();

  // Return format: iv:authTag:ciphertext (all base64)
  return `${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted}`;
}

/**
 * Decrypt encrypted data
 * @param {string} encryptedData - Encrypted string in format iv:authTag:ciphertext
 * @returns {string} Decrypted plaintext
 */
function decrypt(encryptedData) {
  const key = loadOrCreateKey();

  const parts = encryptedData.split(':');
  if (parts.length !== 3) {
    throw new Error('Invalid encrypted data format');
  }

  const iv = Buffer.from(parts[0], 'base64');
  const authTag = Buffer.from(parts[1], 'base64');
  const ciphertext = parts[2];

  const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
  decipher.setAuthTag(authTag);

  let decrypted = decipher.update(ciphertext, 'base64', 'utf8');
  decrypted += decipher.final('utf8');

  return decrypted;
}

/**
 * Check if a string is encrypted (has our format)
 * @param {string} data - Data to check
 * @returns {boolean} True if data appears to be encrypted
 */
function isEncrypted(data) {
  if (typeof data !== 'string') return false;
  const parts = data.split(':');
  if (parts.length !== 3) return false;

  // Check if parts look like base64
  try {
    Buffer.from(parts[0], 'base64');
    Buffer.from(parts[1], 'base64');
    return true;
  } catch {
    return false;
  }
}

/**
 * Encrypt specific fields in an object
 * @param {object} obj - Object with fields to encrypt
 * @param {string[]} fields - Array of field names to encrypt
 * @returns {object} Object with specified fields encrypted
 */
function encryptFields(obj, fields) {
  const result = { ...obj };
  for (const field of fields) {
    if (result[field] !== undefined && result[field] !== null) {
      // Don't double-encrypt
      if (!isEncrypted(result[field])) {
        result[field] = encrypt(result[field]);
      }
    }
  }
  result._encrypted = true; // Mark as encrypted
  result._encryptedFields = fields;
  return result;
}

/**
 * Decrypt specific fields in an object
 * @param {object} obj - Object with encrypted fields
 * @param {string[]} fields - Array of field names to decrypt (optional, uses _encryptedFields if available)
 * @returns {object} Object with specified fields decrypted
 */
function decryptFields(obj, fields = null) {
  if (!obj._encrypted) {
    return obj; // Not encrypted, return as-is
  }

  const fieldsToDecrypt = fields || obj._encryptedFields || [];
  const result = { ...obj };

  for (const field of fieldsToDecrypt) {
    if (result[field] !== undefined && isEncrypted(result[field])) {
      try {
        result[field] = decrypt(result[field]);
      } catch (error) {
        console.error(`[Crypto] Failed to decrypt field '${field}':`, error.message);
        // Leave the field as-is if decryption fails
      }
    }
  }

  // Remove encryption markers from result
  delete result._encrypted;
  delete result._encryptedFields;

  return result;
}

/**
 * Migrate plaintext credentials to encrypted format
 * @param {object} credentials - Credentials object that may or may not be encrypted
 * @param {string[]} sensitiveFields - Fields that should be encrypted
 * @returns {object} Encrypted credentials object
 */
function migrateToEncrypted(credentials, sensitiveFields) {
  if (credentials._encrypted) {
    return credentials; // Already encrypted
  }

  console.log('[Crypto] Migrating plaintext credentials to encrypted format');
  return encryptFields(credentials, sensitiveFields);
}

/**
 * Read and decrypt a credentials file
 * @param {string} filePath - Path to credentials file
 * @param {string[]} sensitiveFields - Fields that are encrypted
 * @returns {object|null} Decrypted credentials or null if file doesn't exist
 */
function readEncryptedFile(filePath, sensitiveFields = ['password', 'token', 'apiKey', 'secret']) {
  if (!fs.existsSync(filePath)) {
    return null;
  }

  try {
    const data = fs.readFileSync(filePath, 'utf8');
    const parsed = JSON.parse(data);

    // Check if this is encrypted data
    if (parsed._encrypted) {
      return decryptFields(parsed, sensitiveFields);
    }

    // Plain text data - migrate it
    console.log(`[Crypto] Found plaintext data in ${filePath}, will encrypt on next save`);
    return parsed;
  } catch (error) {
    console.error(`[Crypto] Error reading ${filePath}:`, error.message);
    return null;
  }
}

/**
 * Encrypt and write credentials to a file
 * @param {string} filePath - Path to credentials file
 * @param {object} credentials - Credentials to save
 * @param {string[]} sensitiveFields - Fields to encrypt
 */
function writeEncryptedFile(filePath, credentials, sensitiveFields = ['password', 'token', 'apiKey', 'secret']) {
  const encrypted = encryptFields(credentials, sensitiveFields);
  fs.writeFileSync(filePath, JSON.stringify(encrypted, null, 2), 'utf8');
  console.log(`[Crypto] Saved encrypted credentials to ${filePath}`);
}

/**
 * Rotate the encryption key — generates a new key and returns both old and new
 * @returns {{ oldKey: Buffer, newKey: Buffer }} Old and new key pair
 * @throws {Error} If new key cannot be saved to disk
 */
function rotateKey() {
  const oldKey = loadOrCreateKey(); // Ensure we have the current key loaded
  const newKey = generateKey();

  // Save the OLD key to .bak BEFORE swapping the primary. This gives the
  // startup-time fallback a way to recover the previous key if a future
  // restart loses the new one (e.g. another accidental recreate). The .bak
  // file is overwritten on each rotate so it always holds the previous key,
  // not an ever-accumulating history.
  try {
    fs.writeFileSync(KEY_FILE + '.bak', oldKey.toString('hex'), { mode: 0o600 });
  } catch (error) {
    console.warn(`[Crypto] Could not save backup key to ${KEY_FILE}.bak:`, error.message);
  }

  try {
    fs.writeFileSync(KEY_FILE, newKey.toString('hex'), { mode: 0o600 });
  } catch (error) {
    throw new Error(`Failed to save new encryption key: ${error.message}`);
  }

  // Only update the cached key after file write succeeds
  encryptionKey = newKey;
  return { oldKey, newKey };
}

/**
 * Decrypt data using a specific key (for key rotation)
 * @param {string} encryptedData - Encrypted string in format iv:authTag:ciphertext
 * @param {Buffer} key - The key to decrypt with
 * @returns {string} Decrypted plaintext
 */
function decryptWithKey(encryptedData, key) {
  const parts = encryptedData.split(':');
  if (parts.length !== 3) {
    throw new Error('Invalid encrypted data format');
  }

  const iv = Buffer.from(parts[0], 'base64');
  const authTag = Buffer.from(parts[1], 'base64');
  const ciphertext = parts[2];

  const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
  decipher.setAuthTag(authTag);

  let decrypted = decipher.update(ciphertext, 'base64', 'utf8');
  decrypted += decipher.final('utf8');

  return decrypted;
}

// Lazy-initialize: key is loaded on first encrypt/decrypt call.
// Do NOT call loadOrCreateKey() here — during Docker build, it would generate
// a key baked into the image that conflicts with the mounted production key.

/**
 * Clear the cached encryption key so it reloads from file on next use.
 * Called after restoring an encryption key from backup.
 */
function clearCachedKey() {
  encryptionKey = null;
}

module.exports = {
  encrypt,
  decrypt,
  isEncrypted,
  encryptFields,
  decryptFields,
  migrateToEncrypted,
  readEncryptedFile,
  writeEncryptedFile,
  loadOrCreateKey,
  deriveKey,
  rotateKey,
  decryptWithKey,
  clearCachedKey
};
