import { createCipheriv, createDecipheriv, randomBytes, scrypt as scryptCb, timingSafeEqual } from 'node:crypto';
import { promisify } from 'node:util';
import { HDKey } from '@scure/bip32';
import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from '@scure/bip39';
import { wordlist } from '@scure/bip39/wordlists/english';
import type { BitcoinNetwork } from './types';
import { AezeedError, DEFAULT_AEZEED_PASSPHRASE, decipherMnemonic, isAezeedPhrase } from './aezeed/cipher-seed';
import { BackendError, WalletLockedError } from './types';

// Seed custody for the wallet sidecar.
//
// THE THREAT MODEL, stated plainly, because it is the whole point of this file:
//
//   Zeus stores its seed phrases as plaintext inside a JSON settings blob and leans entirely on the OS
//   keychain (storage/index.ts + stores/SettingsStore.ts:32-67 — `seedPhrase?: string[]`). A phone has a
//   secure enclave and a screen lock; a server has neither. So none of Zeus's key handling is reusable
//   here and this is written from scratch.
//
//   The seed is protected by TWO independent secrets, and an attacker needs BOTH:
//
//     1. An owner passphrase, which is never persisted anywhere. It derives a KEK via scrypt and that
//        KEK wraps the random per-wallet DEK that actually encrypts the mnemonic.
//     2. the 'wallet' key from the secret store, applied by queries/wallet.ts (../../databases/officer_db)
//        over the already-encrypted envelope before it touches Postgres.
//
//   Consequence: a stolen database dump is useless without .env, a stolen .env is useless without the
//   passphrase, and a full server compromise still cannot spend while the wallet is locked, because a
//   locked wallet holds no key material in memory at all.
//
// WATCH-ONLY WHILE LOCKED. The account xpubs are stored in the clear on purpose. Balances, history and
// receive addresses therefore work with the wallet locked and the passphrase nowhere on the machine —
// unlocking is required only to SIGN. This is the single most important property here: the wallet spends
// almost all of its life locked and still fully readable.
//
// WHAT THIS CANNOT DO. Once unlocked, the root key is in the Bun process's heap and Node gives no way to
// pin or reliably wipe it — GC may have copied it. `zeroize()` scrubs the buffers we own, which shrinks
// the window but does not close it. That is why the unlock TTL is short and defaults tight.

const scrypt = promisify(scryptCb) as (
  password: string | Buffer,
  salt: Buffer,
  keylen: number,
  options: { N: number; r: number; p: number; maxmem: number },
) => Promise<Buffer>;

// N=2^17 / r=8 / p=1 → ~128 MiB and ~1s per attempt on this class of hardware. Deliberately painful:
// this is the only thing standing between a leaked database + .env and the coins. Node's default maxmem
// is 32 MiB, which these parameters blow through, so it must be raised explicitly or scrypt throws.
const SCRYPT_N = 1 << 17;
const SCRYPT_R = 8;
const SCRYPT_P = 1;
const SCRYPT_MAXMEM = 256 * 1024 * 1024;
const KEY_LEN = 32;

/** Bump when the KDF parameters or envelope layout change, so old envelopes can be migrated on unlock. */
// Two seed formats, two envelope versions.
//
//   v1 — BIP39 mnemonic. The master seed is PBKDF2(mnemonic, "mnemonic" + passphrase).
//   v2 — LND aezeed cipher seed (what Zeus's embedded node produces). Its 16-byte entropy IS the BIP32
//        master seed, with no PBKDF2 step at all.
//
// The bump is not cosmetic. Both formats are 24 words from the same wordlist, so a build that predates
// aezeed support would happily read a v2 envelope, run the words through BIP39 derivation, and produce a
// completely different — empty — wallet without erroring. Refusing an unknown version is what makes a
// platform rollback safe.
const ENVELOPE_VERSION_BIP39 = 1;
const ENVELOPE_VERSION_AEZEED = 2;

export type SeedKind = 'bip39' | 'aezeed';

export type SeedEnvelope = {
  v: number;
  /** base64, 16 bytes — scrypt salt for the KEK. */
  salt: string;
  /** base64(iv[12] | tag[16] | ciphertext) — the DEK, wrapped under the passphrase-derived KEK. */
  wrappedDek: string;
  /** base64(iv[12] | tag[16] | ciphertext) — the seed phrase, encrypted under the DEK. */
  seed: string;
  /**
   * Whether a seed passphrase is part of this seed — the BIP39 "25th word", or the aezeed passphrase.
   * Affects derivation, not secrecy.
   */
  hasBip39Passphrase: boolean;
};

// ── AES-256-GCM primitives ───────────────────────────────────────────────────────────────────────

function gcmEncrypt(key: Buffer, plaintext: Buffer): string {
  const iv = randomBytes(12);
  const cipher = createCipheriv('aes-256-gcm', key, iv);
  const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]);
  return Buffer.concat([iv, cipher.getAuthTag(), ct]).toString('base64');
}

function gcmDecrypt(key: Buffer, blob: string): Buffer {
  const buf = Buffer.from(blob, 'base64');
  if (buf.length < 29) throw new BackendError('malformed ciphertext', 500, 'BAD_ENVELOPE');
  const decipher = createDecipheriv('aes-256-gcm', key, buf.subarray(0, 12));
  decipher.setAuthTag(buf.subarray(12, 28));
  return Buffer.concat([decipher.update(buf.subarray(28)), decipher.final()]);
}

/** Best-effort scrub of a buffer we own. See the caveat in the header comment. */
function zeroize(buf: Buffer | null): void {
  if (buf) buf.fill(0);
}

// ── envelope construction ────────────────────────────────────────────────────────────────────────

async function deriveKek(passphrase: string, salt: Buffer): Promise<Buffer> {
  return scrypt(passphrase.normalize('NFKD'), salt, KEY_LEN, {
    N: SCRYPT_N,
    r: SCRYPT_R,
    p: SCRYPT_P,
    maxmem: SCRYPT_MAXMEM,
  });
}

/**
 * Prove an aezeed actually opens before we seal it. Sealing an undecipherable seed would store something
 * permanently unusable behind the owner's passphrase and only surface at unlock — possibly months later,
 * with the original backup long since put away.
 */
async function assertAezeedOpens(words: string[], seedPassphrase: string | undefined): Promise<void> {
  try {
    const seed = await decipherMnemonic(words, seedPassphrase || DEFAULT_AEZEED_PASSPHRASE);
    seed.entropy.fill(0);
  } catch (err) {
    if (err instanceof AezeedError && err.code === 'BAD_PASSPHRASE') {
      throw new BackendError(
        'this is an LND (aezeed) seed and the passphrase is wrong — leave it blank if you never set one',
        400,
        'BAD_SEED_PASSPHRASE',
      );
    }
    throw new BackendError('not a valid aezeed cipher seed', 400, 'BAD_MNEMONIC');
  }
}

/**
 * Wrap a seed phrase into a sealed envelope. The phrase is validated first — importing a typo'd phrase
 * silently produces a valid-but-wrong wallet that shows a zero balance, which is a genuinely awful
 * failure mode to debug.
 *
 * The format is detected from the phrase itself rather than asked of the caller. A BIP39 mnemonic and an
 * LND aezeed are both 24 words drawn from the same wordlist and are indistinguishable by eye, so the
 * owner cannot reasonably be expected to know which one their wallet gave them — but the bits can tell
 * us for certain (SHA-256 checksum vs CRC32-Castagnoli).
 */
export async function sealSeed(
  mnemonic: string,
  ownerPassphrase: string,
  bip39Passphrase?: string,
): Promise<SeedEnvelope> {
  const normalized = mnemonic.trim().replace(/\s+/g, ' ').toLowerCase();
  const words = normalized.split(' ');

  let kind: SeedKind;
  if (validateMnemonic(normalized, wordlist)) {
    kind = 'bip39';
  } else if (isAezeedPhrase(words)) {
    kind = 'aezeed';
    await assertAezeedOpens(words, bip39Passphrase);
  } else {
    throw new BackendError('not a valid BIP39 mnemonic (checksum or wordlist mismatch)', 400, 'BAD_MNEMONIC');
  }

  if (ownerPassphrase.length < 8) {
    throw new BackendError('unlock passphrase must be at least 8 characters', 400, 'WEAK_PASSPHRASE');
  }

  const salt = randomBytes(16);
  const kek = await deriveKek(ownerPassphrase, salt);
  const dek = randomBytes(KEY_LEN);
  // The BIP39 passphrase lives inside the encrypted payload, not beside it: it is as sensitive as the
  // words themselves, since together they are the wallet.
  const payload = Buffer.from(
    JSON.stringify({ mnemonic: normalized, bip39Passphrase: bip39Passphrase ?? '', kind }),
    'utf8',
  );

  try {
    return {
      v: kind === 'aezeed' ? ENVELOPE_VERSION_AEZEED : ENVELOPE_VERSION_BIP39,
      salt: salt.toString('base64'),
      wrappedDek: gcmEncrypt(kek, dek),
      seed: gcmEncrypt(dek, payload),
      hasBip39Passphrase: Boolean(bip39Passphrase),
    };
  } finally {
    zeroize(kek);
    zeroize(dek);
    zeroize(payload);
  }
}

/** Generate a fresh 12- or 24-word mnemonic. 24 words (256 bits) is the default. */
export function generateSeed(words: 12 | 24 = 24): string {
  return generateMnemonic(wordlist, words === 12 ? 128 : 256);
}

type OpenedSeed = { mnemonic: string; bip39Passphrase: string; kind?: SeedKind };

async function openEnvelope(env: SeedEnvelope, ownerPassphrase: string): Promise<OpenedSeed> {
  if (env.v !== ENVELOPE_VERSION_BIP39 && env.v !== ENVELOPE_VERSION_AEZEED) {
    throw new BackendError(`unsupported seed envelope version ${env.v}`, 500, 'BAD_ENVELOPE');
  }
  const kek = await deriveKek(ownerPassphrase, Buffer.from(env.salt, 'base64'));
  let dek: Buffer | null = null;
  try {
    // A wrong passphrase fails here, as a GCM tag mismatch. That is the ONLY signal — we never store a
    // verifier hash of the passphrase, because a verifier is an offline-crackable oracle.
    dek = gcmDecrypt(kek, env.wrappedDek);
    const payload = gcmDecrypt(dek, env.seed);
    try {
      const opened = JSON.parse(payload.toString('utf8')) as OpenedSeed;
      // Envelopes sealed before aezeed support carry no `kind`; the version is the fallback authority,
      // and v1 has only ever meant BIP39.
      return { ...opened, kind: opened.kind ?? (env.v === ENVELOPE_VERSION_AEZEED ? 'aezeed' : 'bip39') };
    } finally {
      zeroize(payload);
    }
  } catch (err) {
    if (err instanceof BackendError) throw err;
    throw new BackendError('incorrect passphrase', 401, 'BAD_PASSPHRASE');
  } finally {
    zeroize(kek);
    zeroize(dek);
  }
}

// ── derivation ───────────────────────────────────────────────────────────────────────────────────

export type Bip = 44 | 49 | 84 | 86;

/** Mainnet is coin type 0; every test network shares coin type 1 (BIP44). */
export function coinType(network: BitcoinNetwork): 0 | 1 {
  return network === 'bitcoin' ? 0 : 1;
}

export function accountPath(bip: Bip, network: BitcoinNetwork, account = 0): string {
  return `m/${bip}'/${coinType(network)}'/${account}'`;
}

/**
 * The one place the two seed formats diverge, and the reason `kind` has to be carried at all.
 *
 * BIP39 stretches the words into a 64-byte master seed via PBKDF2. aezeed does not stretch anything —
 * the 16 bytes recovered from the cipher seed ARE the BIP32 master seed, exactly as lnd/btcwallet uses
 * them. Feeding an aezeed phrase through the BIP39 path would succeed, derive real-looking keys, and
 * point at an entirely different (empty) wallet.
 */
async function rootFromSeed(opened: OpenedSeed): Promise<HDKey> {
  if (opened.kind === 'aezeed') {
    const seed = await decipherMnemonic(
      opened.mnemonic.split(' '),
      opened.bip39Passphrase || DEFAULT_AEZEED_PASSPHRASE,
    );
    try {
      return HDKey.fromMasterSeed(seed.entropy);
    } finally {
      seed.entropy.fill(0);
    }
  }

  const seed = Buffer.from(mnemonicToSeedSync(opened.mnemonic, opened.bip39Passphrase || undefined));
  try {
    return HDKey.fromMasterSeed(seed);
  } finally {
    zeroize(seed);
  }
}

/**
 * Derive the public account descriptors WITHOUT retaining any private material. Called once at import
 * time; the returned xpubs are stored in the clear and are what makes watch-only-while-locked work.
 */
export async function deriveAccountXpubs(
  env: SeedEnvelope,
  ownerPassphrase: string,
  network: BitcoinNetwork,
): Promise<{ fingerprint: string; xpubs: Record<Bip, string> }> {
  const opened = await openEnvelope(env, ownerPassphrase);
  const root = await rootFromSeed(opened);
  try {
    const fingerprint = Buffer.from(new Uint8Array(new Uint32Array([root.fingerprint]).buffer))
      .reverse()
      .toString('hex');
    const xpubs = {} as Record<Bip, string>;
    for (const bip of [44, 49, 84, 86] as const) {
      const node = root.derive(accountPath(bip, network));
      xpubs[bip] = node.publicExtendedKey;
    }
    return { fingerprint, xpubs };
  } finally {
    root.wipePrivateData();
  }
}

// ── the unlock session ───────────────────────────────────────────────────────────────────────────

const DEFAULT_TTL_SEC = Number(process.env.WALLET_UNLOCK_TTL_SEC ?? 900);

// Brute-force resistance. scrypt already makes each guess cost ~1s and ~128 MiB, but an attacker with
// the DB and .env can grind offline anyway — this only protects the live endpoint. Backoff is per
// wallet id and resets on success.
const MAX_ATTEMPTS = 5;
const LOCKOUT_MS = 60_000;

type Attempts = { count: number; lockedUntil: number };
const attempts = new Map<number, Attempts>();

function checkLockout(walletId: number): void {
  const a = attempts.get(walletId);
  if (a && a.lockedUntil > Date.now()) {
    const secs = Math.ceil((a.lockedUntil - Date.now()) / 1000);
    throw new BackendError(`too many failed attempts, retry in ${secs}s`, 429, 'LOCKED_OUT');
  }
}

function recordFailure(walletId: number): void {
  const a = attempts.get(walletId) ?? { count: 0, lockedUntil: 0 };
  a.count += 1;
  if (a.count >= MAX_ATTEMPTS) {
    a.lockedUntil = Date.now() + LOCKOUT_MS;
    a.count = 0;
  }
  attempts.set(walletId, a);
}

/**
 * A live, unlocked wallet. Holds the derived root key in memory and nothing else — the mnemonic itself
 * is decrypted, converted to a root key, and dropped inside `unlock()`; it is never retained.
 *
 * Structurally satisfies the `Signer` interface that backends/onchain.ts consumes.
 */
export class UnlockSession {
  private root: HDKey | null = null;
  private timer: ReturnType<typeof setTimeout> | null = null;
  private expiresAt = 0;

  constructor(readonly walletId: number) {}

  isUnlocked(): boolean {
    return this.root !== null && Date.now() < this.expiresAt;
  }

  /** Seconds until auto-lock, or 0 when locked. For the UI's countdown. */
  secondsRemaining(): number {
    if (!this.isUnlocked()) return 0;
    return Math.max(0, Math.ceil((this.expiresAt - Date.now()) / 1000));
  }

  async unlock(env: SeedEnvelope, ownerPassphrase: string, ttlSec = DEFAULT_TTL_SEC): Promise<void> {
    const opened = await openGuarded(this.walletId, env, ownerPassphrase);

    this.lock(); // replace any existing session rather than leaking the old root
    this.root = await rootFromSeed(opened);
    // Drop the words immediately — the root key is all any signing operation needs.
    opened.mnemonic = '';
    opened.bip39Passphrase = '';
    this.arm(ttlSec);
  }

  private arm(ttlSec: number): void {
    this.expiresAt = Date.now() + ttlSec * 1000;
    this.timer = setTimeout(() => this.lock(), ttlSec * 1000);
    // Don't hold the event loop open just to auto-lock; shutdown wipes memory anyway.
    this.timer.unref?.();
  }

  lock(): void {
    if (this.timer) {
      clearTimeout(this.timer);
      this.timer = null;
    }
    this.root?.wipePrivateData();
    this.root = null;
    this.expiresAt = 0;
  }

  /**
   * Run `fn` with the root key. The ONLY way key material leaves this class, and it never escapes as a
   * return value by construction — callers get a derived signature, not the key.
   *
   * Deliberately does NOT slide the TTL. An unlock is a bounded window the owner opened on purpose;
   * refreshing it on use would let a compromised session stay open indefinitely by signing.
   */
  withRoot<T>(fn: (root: HDKey) => T): T {
    if (!this.isUnlocked() || !this.root) {
      this.lock();
      throw new WalletLockedError();
    }
    return fn(this.root);
  }
}

// One session per wallet id, process-wide. The sidecar is a single process, so this map IS the unlock
// state — there is no cross-process sharing and deliberately no persistence: a sidecar restart relocks
// every wallet, which is the correct default.
const sessions = new Map<number, UnlockSession>();

export function sessionFor(walletId: number): UnlockSession {
  let s = sessions.get(walletId);
  if (!s) {
    s = new UnlockSession(walletId);
    sessions.set(walletId, s);
  }
  return s;
}

export function lockAll(): void {
  for (const s of sessions.values()) s.lock();
}

/**
 * Open an envelope under the same backoff `unlock()` uses.
 *
 * EVERY passphrase check goes through here, not just the session one. The lockout used to live inside
 * UnlockSession.unlock alone, which left export-seed — the one endpoint that returns the words in the
 * clear — accepting unlimited guesses, while /unlock capped at five a minute. An attacker with a session
 * cookie would simply never have used /unlock.
 */
async function openGuarded(walletId: number, env: SeedEnvelope, ownerPassphrase: string): Promise<OpenedSeed> {
  checkLockout(walletId);
  let opened: OpenedSeed;
  try {
    opened = await openEnvelope(env, ownerPassphrase);
  } catch (err) {
    recordFailure(walletId);
    throw err;
  }
  attempts.delete(walletId);
  return opened;
}

/**
 * Verify a passphrase without opening a session — used before destructive operations (seed export,
 * wallet deletion) so they need a fresh confirmation even when the wallet is already unlocked.
 *
 * Rethrows a LOCKED_OUT BackendError rather than folding it into `false`: "wrong passphrase" and "stop
 * guessing" are different answers, and a caller that showed the first for both would loop forever.
 */
export async function verifyPassphrase(walletId: number, env: SeedEnvelope, ownerPassphrase: string): Promise<boolean> {
  try {
    await openGuarded(walletId, env, ownerPassphrase);
    return true;
  } catch (err) {
    if (err instanceof BackendError && err.code === 'LOCKED_OUT') throw err;
    return false;
  }
}

/**
 * Reveal the mnemonic. The only function that returns raw seed words, and it exists solely so the owner
 * can back up or migrate. Always requires the passphrase even if a session is open, and callers must
 * gate it behind a fresh confirmation.
 */
export async function exportMnemonic(walletId: number, env: SeedEnvelope, ownerPassphrase: string): Promise<string> {
  const opened = await openGuarded(walletId, env, ownerPassphrase);
  return opened.mnemonic;
}

/**
 * Re-seal an existing seed under a new passphrase. Requires the old one. Note this mints a FRESH salt,
 * DEK and IVs rather than merely re-wrapping the existing DEK — so a copy of the old envelope, plus the
 * old passphrase, cannot decrypt anything written after a rotation.
 */
export async function changePassphrase(
  walletId: number,
  env: SeedEnvelope,
  oldPassphrase: string,
  newPassphrase: string,
): Promise<SeedEnvelope> {
  const opened = await openGuarded(walletId, env, oldPassphrase);
  return sealSeed(opened.mnemonic, newPassphrase, opened.bip39Passphrase || undefined);
}

/** Constant-time compare for any confirmation token we hand out and take back. */
export function safeEqual(a: string, b: string): boolean {
  const ab = Buffer.from(a);
  const bb = Buffer.from(b);
  return ab.length === bb.length && timingSafeEqual(ab, bb);
}
