/**
 * Component: Credential Recovery Script
 * Documentation: documentation/admin-features/credential-recovery.md
 *
 * Interactive recovery for lost CONFIG_ENCRYPTION_KEY or forgotten local admin password.
 * Run inside the container with: docker exec -it <container> npm run rmab:recover
 *
 * Hard rules:
 * - No CLI arguments accepted. All input via interactive prompts.
 * - Never log password or key values.
 * - All DB mutations inside a single transaction.
 * - File writes happen only after DB commit succeeds.
 */

'use strict';

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

const SECRETS_FILE = '/app/config/.secrets';
const ENVIRONMENT_FILE = '/etc/environment';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 16;
const KEY_LENGTH = 32;
const ENCRYPTED_CONFIG_KEYS_FOR_PROBE = [
  'plex_token',
  'prowlarr_api_key',
  'audiobookshelf.api_token',
  'oidc.client_secret',
];

// ---------------------------------------------------------------------------
// Env loading
// ---------------------------------------------------------------------------
// docker exec doesn't inherit runtime-generated env vars, and /etc/environment
// can drift from what the running app process is actually using (e.g. if
// .secrets was regenerated on a restart while the existing pg_user kept its
// original password). The source of truth is the live node process's
// /proc/<pid>/environ — read that first, then fall back to files.
// ---------------------------------------------------------------------------

const WANTED_ENV_KEYS = [
  'DATABASE_URL',
  'CONFIG_ENCRYPTION_KEY',
  'POSTGRES_PASSWORD',
  'POSTGRES_USER',
  'POSTGRES_DB',
  'ALLOW_WEAK_PASSWORD',
];

const envSource = {}; // key -> short label of where it came from

// The dockerfile bakes ENV DATABASE_URL=<this> at build time so prisma generate
// has a valid URL; the entrypoint overrides at runtime. But if the override
// didn't propagate to the child process inheriting via docker exec, we see
// this exact dummy value. Never trust it.
const DUMMY_DB_URL = 'postgresql://dummy:dummy@localhost:5432/dummy?schema=public';

function isUsableValue(key, value) {
  if (value == null || value === '') return false;
  if (key === 'DATABASE_URL' && value === DUMMY_DB_URL) return false;
  if (key === 'DATABASE_URL' && /^postgresql:\/\/dummy:dummy@/.test(value)) return false;
  return true;
}

function setIfMissing(key, value, sourceLabel) {
  if (!isUsableValue(key, value)) return;
  if (!isUsableValue(key, process.env[key])) {
    process.env[key] = value;
    envSource[key] = sourceLabel;
  }
}

// Wipe inherited dummy URL up front so file/proc sources have a clean slate.
if (process.env.DATABASE_URL && !isUsableValue('DATABASE_URL', process.env.DATABASE_URL)) {
  delete process.env.DATABASE_URL;
}

function loadEnvFromFile(filePath, sourceLabel) {
  if (!fs.existsSync(filePath)) return;
  let contents;
  try {
    contents = fs.readFileSync(filePath, 'utf8');
  } catch (_err) {
    return;
  }
  for (const rawLine of contents.split('\n')) {
    const line = rawLine.trim();
    if (!line || line.startsWith('#')) continue;
    const eq = line.indexOf('=');
    if (eq === -1) continue;
    const key = line.slice(0, eq).trim();
    let value = line.slice(eq + 1).trim();
    if (
      (value.startsWith('"') && value.endsWith('"')) ||
      (value.startsWith("'") && value.endsWith("'"))
    ) {
      value = value.slice(1, -1);
    }
    setIfMissing(key, value, sourceLabel);
  }
}

function loadEnvFromRunningProcess() {
  // Walk every readable /proc/<pid>/environ. Pick the first process whose
  // environ contains a non-empty DATABASE_URL. Do NOT filter by comm name —
  // the app may run under gosu, npm, next-server, etc.
  let procDir;
  try {
    procDir = fs.readdirSync('/proc');
  } catch (_err) {
    return null;
  }
  const ownPid = String(process.pid);
  for (const entry of procDir) {
    if (!/^\d+$/.test(entry)) continue;
    if (entry === ownPid) continue;
    let environBuf;
    try {
      environBuf = fs.readFileSync(`/proc/${entry}/environ`);
    } catch (_err) {
      // environ may be mode 400 owned by another user; skip silently.
      continue;
    }
    if (!environBuf || environBuf.length === 0) continue;
    const pairs = environBuf.toString('utf8').split('\u0000');
    const collected = {};
    for (const p of pairs) {
      const eq = p.indexOf('=');
      if (eq === -1) continue;
      collected[p.slice(0, eq)] = p.slice(eq + 1);
    }
    if (!collected.DATABASE_URL) continue;
    let comm = '';
    try {
      comm = fs.readFileSync(`/proc/${entry}/comm`, 'utf8').trim();
    } catch (_e) {}
    const label = `pid ${entry}${comm ? ` (${comm})` : ''}`;
    for (const k of WANTED_ENV_KEYS) {
      if (collected[k]) setIfMissing(k, collected[k], label);
    }
    return label;
  }
  return null;
}

// Priority order: /etc/environment (entrypoint's persisted authoritative state)
// > /app/config/.secrets (persisted keys) > /proc/<pid>/environ (running process).
// The inherited docker-exec env was already wiped of the dummy URL above.
loadEnvFromFile(ENVIRONMENT_FILE, '/etc/environment');
loadEnvFromFile(SECRETS_FILE, '/app/config/.secrets');
const liveProcPid = loadEnvFromRunningProcess();

// Last resort: construct DATABASE_URL from POSTGRES_PASSWORD + sensible defaults,
// mirroring what entrypoint.sh does. Works as long as POSTGRES_PASSWORD was
// recoverable from .secrets or another source.
function urlEncodePassword(s) {
  // Match entrypoint.sh urlencode(): everything except [-_.~a-zA-Z0-9] is %xx.
  return Array.from(s).map((c) => {
    if (/[-_.~a-zA-Z0-9]/.test(c)) return c;
    return '%' + c.charCodeAt(0).toString(16).padStart(2, '0');
  }).join('');
}
if (!isUsableValue('DATABASE_URL', process.env.DATABASE_URL) && process.env.POSTGRES_PASSWORD) {
  const user = process.env.POSTGRES_USER || 'readmeabook';
  const db = process.env.POSTGRES_DB || 'readmeabook';
  const host = '127.0.0.1';
  const port = '5432';
  const encoded = urlEncodePassword(process.env.POSTGRES_PASSWORD);
  process.env.DATABASE_URL = `postgresql://${user}:${encoded}@${host}:${port}/${db}`;
  envSource.DATABASE_URL = 'constructed from POSTGRES_PASSWORD + defaults';
}

// ---------------------------------------------------------------------------
// Encryption helpers (mirrors src/lib/services/encryption.service.ts)
// ---------------------------------------------------------------------------
function deriveKey(rawKey) {
  if (!rawKey) {
    throw new Error('CONFIG_ENCRYPTION_KEY is not set');
  }
  if (rawKey.length < KEY_LENGTH) {
    const buf = Buffer.alloc(KEY_LENGTH);
    Buffer.from(rawKey).copy(buf);
    return buf;
  }
  if (rawKey.length > KEY_LENGTH) {
    return Buffer.from(rawKey).subarray(0, KEY_LENGTH);
  }
  return Buffer.from(rawKey);
}

function decryptWithKey(encryptedData, keyBuffer) {
  const parts = String(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 decipher = crypto.createDecipheriv(ALGORITHM, keyBuffer, iv);
  decipher.setAuthTag(authTag);
  let decrypted = decipher.update(parts[2], 'base64', 'utf8');
  decrypted += decipher.final('utf8');
  return decrypted;
}

function encryptWithKey(plaintext, keyBuffer) {
  const iv = crypto.randomBytes(IV_LENGTH);
  const cipher = crypto.createCipheriv(ALGORITHM, keyBuffer, iv);
  let encrypted = cipher.update(plaintext, 'utf8', 'base64');
  encrypted += cipher.final('base64');
  const authTag = cipher.getAuthTag();
  return `${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted}`;
}

function tryDecrypt(encryptedData, keyBuffer) {
  try {
    return { ok: true, value: decryptWithKey(encryptedData, keyBuffer) };
  } catch (err) {
    return { ok: false, error: err };
  }
}

function generateNewKey() {
  return crypto.randomBytes(KEY_LENGTH).toString('base64');
}

// ---------------------------------------------------------------------------
// Prompt helpers
// ---------------------------------------------------------------------------
function ask(rl, question) {
  return new Promise((resolve) => rl.question(question, (answer) => resolve(answer)));
}

function askHidden(question) {
  return new Promise((resolve, reject) => {
    if (!process.stdin.isTTY) {
      reject(new Error('Interactive password input requires a TTY. Run with: docker exec -it ...'));
      return;
    }
    process.stdout.write(question);
    const stdin = process.stdin;
    const wasRaw = stdin.isRaw;
    stdin.setRawMode(true);
    stdin.resume();
    stdin.setEncoding('utf8');

    let buffer = '';
    const onData = (chunk) => {
      for (const ch of chunk) {
        if (ch === '\u0003') {
          // Ctrl+C
          stdin.setRawMode(wasRaw);
          stdin.pause();
          stdin.removeListener('data', onData);
          process.stdout.write('\n');
          reject(new Error('Cancelled by user'));
          return;
        }
        if (ch === '\r' || ch === '\n') {
          stdin.setRawMode(wasRaw);
          stdin.pause();
          stdin.removeListener('data', onData);
          process.stdout.write('\n');
          resolve(buffer);
          return;
        }
        if (ch === '\u007f' || ch === '\b') {
          if (buffer.length > 0) {
            buffer = buffer.slice(0, -1);
            process.stdout.write('\b \b');
          }
          continue;
        }
        if (ch < ' ') continue;
        buffer += ch;
        process.stdout.write('*');
      }
    };
    stdin.on('data', onData);
  });
}

// ---------------------------------------------------------------------------
// .secrets / /etc/environment file updates
// ---------------------------------------------------------------------------
function updateKeyInFile(filePath, keyName, newValue, quoted) {
  if (!fs.existsSync(filePath)) {
    fs.writeFileSync(
      filePath,
      `${keyName}=${quoted ? `"${newValue}"` : newValue}\n`,
      { mode: 0o600 }
    );
    return { created: true, replaced: false };
  }
  const original = fs.readFileSync(filePath, 'utf8');
  const lines = original.split('\n');
  let replaced = false;
  const updated = lines.map((line) => {
    const trimmed = line.trim();
    if (!trimmed || trimmed.startsWith('#')) return line;
    const eq = trimmed.indexOf('=');
    if (eq === -1) return line;
    const name = trimmed.slice(0, eq).trim();
    if (name !== keyName) return line;
    replaced = true;
    return `${keyName}=${quoted ? `"${newValue}"` : newValue}`;
  });
  if (!replaced) {
    if (updated[updated.length - 1] === '') {
      updated[updated.length - 1] = `${keyName}=${quoted ? `"${newValue}"` : newValue}`;
      updated.push('');
    } else {
      updated.push(`${keyName}=${quoted ? `"${newValue}"` : newValue}`);
    }
  }
  fs.writeFileSync(filePath, updated.join('\n'));
  return { created: false, replaced };
}

// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
  // Reject any CLI args by design.
  if (process.argv.length > 2) {
    console.error('This script does not accept CLI arguments. All input is via interactive prompts.');
    console.error('Run: docker exec -it <container> npm run rmab:recover');
    process.exit(2);
  }

  console.log('');
  console.log('================================================================');
  console.log('  ReadMeABook — Credential Recovery');
  console.log('================================================================');
  console.log('');
  console.log('Use when local login fails with "Invalid username or password"');
  console.log('despite known-correct credentials. See:');
  console.log('  documentation/admin-features/credential-recovery.md');
  console.log('');

  // Diagnostic: where did we resolve env vars from?
  const dbSrc = envSource.DATABASE_URL || (process.env.DATABASE_URL ? 'inherited' : 'NOT FOUND');
  const keySrc = envSource.CONFIG_ENCRYPTION_KEY || (process.env.CONFIG_ENCRYPTION_KEY ? 'inherited' : 'NOT FOUND');
  console.log('Environment:');
  console.log(`  Live process w/ DATABASE_URL: ${liveProcPid || 'none found'}`);
  console.log(`  DATABASE_URL source:        ${dbSrc}`);
  console.log(`  CONFIG_ENCRYPTION_KEY src:  ${keySrc}`);
  if (process.env.DATABASE_URL) {
    const redacted = String(process.env.DATABASE_URL).replace(/(:\/\/[^:]+:)[^@]+(@)/, '$1***$2');
    console.log(`  DATABASE_URL (redacted):    ${redacted}`);
  }
  console.log('');

  if (!process.env.DATABASE_URL) {
    console.error('ERROR: DATABASE_URL is not set and could not be loaded from any source.');
    console.error('       Tried: /proc/<pid>/environ of running node process,');
    console.error('              /etc/environment, /app/config/.secrets');
    console.error('       Workaround: docker exec -it -e DATABASE_URL="<your url>" <container> npm run rmab:recover');
    process.exit(1);
  }
  if (!process.env.CONFIG_ENCRYPTION_KEY) {
    console.error('ERROR: CONFIG_ENCRYPTION_KEY is not set and could not be loaded from any source.');
    console.error('       Tried: /proc/<pid>/environ of running node process,');
    console.error('              /etc/environment, /app/config/.secrets');
    process.exit(1);
  }

  const currentKey = deriveKey(process.env.CONFIG_ENCRYPTION_KEY);

  // Load Prisma client (generated in container at src/generated/prisma)
  let PrismaClient;
  try {
    ({ PrismaClient } = require(path.join(__dirname, '..', 'src', 'generated', 'prisma', 'client')));
  } catch (err) {
    try {
      ({ PrismaClient } = require('@prisma/client'));
    } catch (innerErr) {
      console.error('ERROR: Could not load Prisma client. Tried generated path and @prisma/client.');
      console.error('       Generated path error:', err.message);
      console.error('       Package error:       ', innerErr.message);
      process.exit(1);
    }
  }
  const prisma = new PrismaClient();

  try {
    // -------------------------------------------------------------------------
    // Diagnose key health
    // -------------------------------------------------------------------------
    console.log('Step 1/5 — Diagnosing encryption key health...');
    const encryptedRows = await prisma.configuration.findMany({
      where: { encrypted: true },
    });

    let keyWorks = null; // null = unknown (no probe rows)
    let probedKey = null;
    for (const row of encryptedRows) {
      if (!row.value) continue;
      const result = tryDecrypt(row.value, currentKey);
      if (result.ok) {
        keyWorks = true;
        probedKey = row.key;
        break;
      }
      if (keyWorks === null) keyWorks = false;
    }

    if (keyWorks === true) {
      console.log(`  Key works (verified against Configuration row "${probedKey}").`);
    } else if (keyWorks === false) {
      console.log(`  Key DOES NOT work — none of the ${encryptedRows.length} encrypted Configuration rows decrypt.`);
    } else {
      console.log('  No encrypted Configuration rows exist yet — defaulting to password-reset-only mode.');
    }

    // -------------------------------------------------------------------------
    // List local users
    // -------------------------------------------------------------------------
    console.log('');
    console.log('Step 2/5 — Selecting local user to reset...');
    const localUsers = await prisma.user.findMany({
      where: { authProvider: 'local', deletedAt: null },
      select: {
        id: true,
        plexUsername: true,
        plexId: true,
        role: true,
        isSetupAdmin: true,
        authToken: true,
      },
      orderBy: [{ isSetupAdmin: 'desc' }, { plexUsername: 'asc' }],
    });

    if (localUsers.length === 0) {
      console.error('');
      console.error('ERROR: No local users exist in the database.');
      console.error('       Use the setup wizard / registration page to create one instead.');
      process.exit(1);
    }

    console.log('');
    console.log('  Local users:');
    for (const u of localUsers) {
      const tag = [u.role];
      if (u.isSetupAdmin) tag.push('setup-admin');
      console.log(`    - ${u.plexUsername}   [${tag.join(', ')}]`);
    }
    console.log('');

    const rl = readline.createInterface({ input: process.stdin, output: process.stdout });

    let chosenUser = null;
    while (!chosenUser) {
      const typed = (await ask(rl, '  Username to reset: ')).trim().toLowerCase();
      if (!typed) continue;
      chosenUser = localUsers.find((u) => u.plexUsername === typed);
      if (!chosenUser) {
        console.log(`  No local user named "${typed}". Try again, or Ctrl+C to abort.`);
      }
    }

    // -------------------------------------------------------------------------
    // New password
    // -------------------------------------------------------------------------
    console.log('');
    console.log('Step 3/5 — New password...');
    const allowWeak = process.env.ALLOW_WEAK_PASSWORD === 'true';
    const minLen = allowWeak ? 1 : 8;

    let newPassword = null;
    while (!newPassword) {
      rl.pause();
      const a = await askHidden('  New password: ');
      const b = await askHidden('  Confirm new password: ');
      rl.resume();
      if (a !== b) {
        console.log('  Passwords did not match. Try again.');
        continue;
      }
      if (a.length < minLen) {
        console.log(`  Password must be at least ${minLen} character(s). Try again.`);
        continue;
      }
      newPassword = a;
    }

    // -------------------------------------------------------------------------
    // Build the plan
    // -------------------------------------------------------------------------
    console.log('');
    console.log('Step 4/5 — Plan...');
    console.log('');

    const fullRecovery = keyWorks === false;

    if (fullRecovery) {
      console.log('  MODE: FULL RECOVERY (encryption key is unrecoverable)');
      console.log('');
      console.log('  The following will happen, atomically:');
      console.log(`    1. A new CONFIG_ENCRYPTION_KEY will be generated.`);
      console.log(`    2. User "${chosenUser.plexUsername}" will get a new password (bcrypt + new key).`);
      console.log('    3. Every Configuration row with encrypted=true will be tried with the OLD key:');
      console.log('         - If it decrypts: re-encrypted with the new key (preserved).');
      console.log('         - If it cannot decrypt: DELETED (must be re-entered in Settings).');
      console.log('    4. download_clients JSON: each per-client password tried with OLD key:');
      console.log('         - Decryptable: re-encrypted with new key.');
      console.log('         - Not decryptable: blanked. URL, host, name, etc. preserved.');
      console.log('    5. User.authToken for every user tried with OLD key:');
      console.log('         - Decryptable: re-encrypted with new key.');
      console.log('         - Not decryptable: cleared. Plex/OIDC users re-OAuth on next login.');
      console.log('    6. /app/config/.secrets and /etc/environment updated with the new key.');
      console.log('');
      console.log('  Likely to need re-entering in Settings after this completes:');
      console.log('    - Plex auth token (or just re-login with Plex)');
      console.log('    - Audiobookshelf API token (if used)');
      console.log('    - Prowlarr API key');
      console.log('    - OIDC client secret (if used)');
      console.log('    - Download client passwords (per client)');
      console.log('    - Any AI / Hardcover / Goodreads / notification provider secrets');
      console.log('');
      console.log('  Survives untouched:');
      console.log('    - All requests + request history');
      console.log('    - Library mappings, organization templates, schedules');
      console.log('    - User accounts (just credentials cleared)');
      console.log('    - Non-encrypted config (paths, log level, backend mode, etc.)');
      console.log('');
      console.log('  Container restart REQUIRED after this completes.');
    } else {
      console.log('  MODE: PASSWORD RESET ONLY (encryption key is healthy)');
      console.log('');
      console.log(`  Only one change: user "${chosenUser.plexUsername}" gets a new password.`);
      console.log('  Everything else (all credentials, all settings) untouched.');
      console.log('  No container restart needed.');
    }

    console.log('');
    const confirm = (await ask(rl, "  Type 'confirm' to proceed (anything else aborts): ")).trim();
    if (confirm !== 'confirm') {
      console.log('  Aborted. No changes made.');
      rl.close();
      await prisma.$disconnect();
      process.exit(0);
    }
    rl.close();

    // -------------------------------------------------------------------------
    // Execute
    // -------------------------------------------------------------------------
    console.log('');
    console.log('Step 5/5 — Applying changes...');

    let summary;
    let newKeyBase64 = null;
    let newKeyBuffer = currentKey;

    if (fullRecovery) {
      newKeyBase64 = generateNewKey();
      newKeyBuffer = deriveKey(newKeyBase64);

      // Plan mutations in memory using OLD key for reads, NEW key for writes.
      const configUpdates = [];
      const configDeletes = [];
      let downloadClientsUpdate = null;
      const userUpdates = [];

      // Configuration rows
      for (const row of encryptedRows) {
        if (!row.value) {
          configDeletes.push(row.key);
          continue;
        }
        const decrypted = tryDecrypt(row.value, currentKey);
        if (decrypted.ok) {
          configUpdates.push({ key: row.key, value: encryptWithKey(decrypted.value, newKeyBuffer) });
        } else {
          configDeletes.push(row.key);
        }
      }

      // download_clients JSON (not marked encrypted=true at row level)
      const dcRow = await prisma.configuration.findUnique({ where: { key: 'download_clients' } });
      if (dcRow && dcRow.value) {
        try {
          const clients = JSON.parse(dcRow.value);
          let touched = 0;
          let cleared = 0;
          if (Array.isArray(clients)) {
            for (const client of clients) {
              if (!client || !client.password) continue;
              const decrypted = tryDecrypt(client.password, currentKey);
              if (decrypted.ok) {
                client.password = encryptWithKey(decrypted.value, newKeyBuffer);
                touched++;
              } else {
                client.password = '';
                cleared++;
              }
            }
            downloadClientsUpdate = { value: JSON.stringify(clients), touched, cleared };
          }
        } catch (err) {
          console.log(`  WARNING: download_clients JSON unparseable, leaving as-is: ${err.message}`);
        }
      }

      // User auth tokens (except the chosen user, whose token will be overwritten)
      const allUsers = await prisma.user.findMany({
        where: { deletedAt: null },
        select: { id: true, authToken: true, authProvider: true },
      });
      for (const u of allUsers) {
        if (u.id === chosenUser.id) continue;
        if (!u.authToken) continue;
        const decrypted = tryDecrypt(u.authToken, currentKey);
        if (decrypted.ok) {
          userUpdates.push({ id: u.id, authToken: encryptWithKey(decrypted.value, newKeyBuffer) });
        } else {
          userUpdates.push({ id: u.id, authToken: '' });
        }
      }

      // Chosen user — fresh bcrypt encrypted with new key
      const newHash = await bcrypt.hash(newPassword, 10);
      const encryptedHash = encryptWithKey(newHash, newKeyBuffer);

      // Apply atomically
      summary = await prisma.$transaction(async (tx) => {
        const result = {
          configRotated: configUpdates.length,
          configDeleted: configDeletes.length,
          downloadClients: downloadClientsUpdate
            ? { touched: downloadClientsUpdate.touched, cleared: downloadClientsUpdate.cleared }
            : null,
          usersRotated: 0,
          usersCleared: 0,
        };
        for (const u of configUpdates) {
          await tx.configuration.update({ where: { key: u.key }, data: { value: u.value } });
        }
        for (const key of configDeletes) {
          await tx.configuration.delete({ where: { key } });
        }
        if (downloadClientsUpdate) {
          await tx.configuration.update({
            where: { key: 'download_clients' },
            data: { value: downloadClientsUpdate.value },
          });
        }
        for (const u of userUpdates) {
          await tx.user.update({ where: { id: u.id }, data: { authToken: u.authToken } });
          if (u.authToken === '') result.usersCleared++;
          else result.usersRotated++;
        }
        await tx.user.update({
          where: { id: chosenUser.id },
          data: { authToken: encryptedHash, lastLoginAt: null },
        });
        return result;
      });
    } else {
      // Simple password reset, current key preserved
      const newHash = await bcrypt.hash(newPassword, 10);
      const encryptedHash = encryptWithKey(newHash, currentKey);
      await prisma.$transaction(async (tx) => {
        await tx.user.update({
          where: { id: chosenUser.id },
          data: { authToken: encryptedHash, lastLoginAt: null },
        });
      });
      summary = null;
    }

    // -------------------------------------------------------------------------
    // Post-commit: file writes (only on full recovery)
    // -------------------------------------------------------------------------
    let fileWriteFailed = false;
    if (fullRecovery) {
      try {
        updateKeyInFile(SECRETS_FILE, 'CONFIG_ENCRYPTION_KEY', newKeyBase64, true);
      } catch (err) {
        fileWriteFailed = true;
        console.error(`  ERROR writing ${SECRETS_FILE}: ${err.message}`);
      }
      try {
        updateKeyInFile(ENVIRONMENT_FILE, 'CONFIG_ENCRYPTION_KEY', newKeyBase64, false);
      } catch (err) {
        fileWriteFailed = true;
        console.error(`  ERROR writing ${ENVIRONMENT_FILE}: ${err.message}`);
      }
    }

    // -------------------------------------------------------------------------
    // Summary
    // -------------------------------------------------------------------------
    console.log('');
    console.log('================================================================');
    console.log('  Recovery complete.');
    console.log('================================================================');
    console.log('');
    console.log(`  User reset:   ${chosenUser.plexUsername}`);
    if (fullRecovery && summary) {
      console.log(`  Configuration rows re-encrypted: ${summary.configRotated}`);
      console.log(`  Configuration rows deleted:      ${summary.configDeleted}`);
      if (summary.downloadClients) {
        console.log(`  download_clients passwords re-encrypted: ${summary.downloadClients.touched}`);
        console.log(`  download_clients passwords cleared:      ${summary.downloadClients.cleared}`);
      }
      console.log(`  User tokens re-encrypted: ${summary.usersRotated}`);
      console.log(`  User tokens cleared:      ${summary.usersCleared}`);
      console.log('');

      if (fileWriteFailed) {
        console.log('  ⚠️  Could not persist the new key to .secrets / /etc/environment.');
        console.log('  ⚠️  The new key is printed ONCE below. Write it into /app/config/.secrets:');
        console.log('');
        console.log(`        CONFIG_ENCRYPTION_KEY="${newKeyBase64}"`);
        console.log('');
        console.log('  ⚠️  And into /etc/environment (without quotes):');
        console.log('');
        console.log(`        CONFIG_ENCRYPTION_KEY=${newKeyBase64}`);
        console.log('');
      } else {
        console.log('  New CONFIG_ENCRYPTION_KEY persisted to /app/config/.secrets and /etc/environment.');
      }
      console.log('');
      console.log('  NEXT STEPS:');
      console.log('    1. Restart the container.');
      console.log(`    2. Log in as "${chosenUser.plexUsername}" with the new password.`);
      console.log('    3. Re-enter cleared credentials in Settings (Plex, Prowlarr, etc.).');
    } else {
      console.log('  Encryption key was healthy — only the password was reset.');
      console.log(`  Log in as "${chosenUser.plexUsername}" with the new password. No restart needed.`);
    }
    console.log('');
  } catch (err) {
    console.error('');
    console.error('ERROR: Recovery aborted.');
    console.error(`  ${err.message}`);
    console.error('');
    const msg = String(err && err.message ? err.message : '');
    if (
      msg.includes('was denied access') ||
      msg.includes('P1010') ||
      msg.includes('password authentication')
    ) {
      console.error('Diagnosis: Postgres rejected the credentials in DATABASE_URL.');
      console.error('This usually means /etc/environment or .secrets drifted from what the running');
      console.error('app process is actually using (common after a container restart where .secrets');
      console.error('was regenerated but the existing Postgres user kept its original password).');
      console.error('');
      console.error('Try one of:');
      console.error('  1. Restart the container so the entrypoint resyncs all env files, then re-run.');
      console.error('  2. Pass DATABASE_URL explicitly:');
      console.error('       docker exec -it \\');
      console.error("         -e DATABASE_URL=\"$(docker exec <container> cat /proc/1/environ \\");
      console.error("            | tr '\\0' '\\n' | grep ^DATABASE_URL= | cut -d= -f2-)\" \\");
      console.error('         <container> npm run rmab:recover');
    }
    console.error('');
    console.error('No changes have been committed (or the DB transaction was rolled back).');
    process.exitCode = 1;
  } finally {
    try {
      await prisma.$disconnect();
    } catch (_e) {
      // ignore
    }
  }
}

main();
