import { scrypt as scryptCb } from 'node:crypto';
import { promisify } from 'node:util';
import { wordlist } from '@scure/bip39/wordlists/english';
import { aezDecrypt } from './aez';

// LND's aezeed cipher seed — the format Zeus's embedded LND node hands out as a "backup phrase".
//
// It is NOT BIP39, and that is the entire problem this module exists to solve. An aezeed phrase is 24
// words drawn from the very same BIP39 English wordlist, so it is visually indistinguishable from a
// BIP39 mnemonic, but the bits underneath are a different structure entirely:
//
//   byte  0        version         (0)
//   bytes 1..24    ciphertext      (19-byte plaintext + 4-byte AEZ authenticator)
//   bytes 24..29   salt            (scrypt salt for the passphrase)
//   bytes 29..33   CRC32-Castagnoli over bytes 0..29
//                  ─────
//                  33 bytes = 24 words x 11 bits
//
// A BIP39 24-word phrase is also 33 bytes, but its last 8 bits are a SHA-256 checksum. So an aezeed
// phrase fails BIP39 validation every single time, with an error that reads exactly like "you mistyped
// a word" — which is unfixable advice, because nothing is mistyped.
//
// The deciphered plaintext is:
//
//   byte  0        internal version
//   bytes 1..3     birthday        (days since the Bitcoin genesis block, big-endian uint16)
//   bytes 3..19    entropy         (16 bytes)
//
// That 16-byte entropy is used DIRECTLY as the BIP32 master seed — LND does not run it through BIP39's
// PBKDF2. This is why an aezeed cannot be converted into an equivalent BIP39 phrase: doing so would
// change the derived keys and produce an empty wallet at different addresses.

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

export const CIPHER_SEED_VERSION = 0;
export const ENCIPHERED_SIZE = 33;
export const ENTROPY_SIZE = 16;
const PLAINTEXT_SIZE = 19;
const SALT_SIZE = 5;
const CHECKSUM_SIZE = 4;
const CIPHERTEXT_EXPANSION = 4;
const SALT_OFFSET = ENCIPHERED_SIZE - CHECKSUM_SIZE - SALT_SIZE; // 24
const CHECKSUM_OFFSET = ENCIPHERED_SIZE - CHECKSUM_SIZE; // 29

/**
 * LND's default when the user declines to set a seed passphrase. Not a secret — it is a literal
 * constant in lnd/aezeed, and every Zeus wallet created without an explicit passphrase uses it.
 */
export const DEFAULT_AEZEED_PASSPHRASE = 'aezeed';

// scrypt parameters for version 0, straight from lnd/aezeed/params.go.
const SCRYPT_N = 32768;
const SCRYPT_R = 8;
const SCRYPT_P = 1;
const KEY_LEN = 32;
// 128 * N * r is ~33.5 MB here, over Node's 32 MB default, which fails with a bare MEMORY_LIMIT_EXCEEDED.
const SCRYPT_MAXMEM = 64 * 1024 * 1024;

/** LND counts a seed's birthday in days from this instant. */
const BITCOIN_GENESIS = Date.UTC(2009, 0, 3, 18, 15, 5);
const ONE_DAY_MS = 86_400_000;

// ── CRC32-Castagnoli ─────────────────────────────────────────────────────────────────────────────
//
// Castagnoli (0x82F63B78), NOT the far more common IEEE polynomial. Getting this wrong makes every
// valid seed look corrupt.

const CRC32C_TABLE = Uint32Array.from({ length: 256 }, (_, n) => {
  let c = n;
  for (let k = 0; k < 8; k++) c = c & 1 ? 0x82f63b78 ^ (c >>> 1) : c >>> 1;
  return c >>> 0;
});

function crc32c(bytes: Uint8Array): number {
  let c = 0xffffffff;
  for (const b of bytes) c = CRC32C_TABLE[(c ^ b) & 0xff]! ^ (c >>> 8);
  return (c ^ 0xffffffff) >>> 0;
}

// ── mnemonic <-> bytes ───────────────────────────────────────────────────────────────────────────

const WORD_INDEX = new Map<string, number>(wordlist.map((w, i) => [w, i]));

/**
 * Pack 24 words into the 33 raw bytes, 11 bits each. Returns null when any word is outside the
 * wordlist or the count is wrong — i.e. when this cannot be an aezeed phrase at all.
 */
export function mnemonicToCipherSeedBytes(words: string[]): Uint8Array | null {
  if (words.length !== 24) return null;

  const bits: number[] = [];
  for (const word of words) {
    const index = WORD_INDEX.get(word);
    if (index === undefined) return null;
    for (let b = 10; b >= 0; b--) bits.push((index >> b) & 1);
  }

  const bytes = new Uint8Array(ENCIPHERED_SIZE);
  for (let i = 0; i < ENCIPHERED_SIZE; i++) {
    let v = 0;
    for (let b = 0; b < 8; b++) v = (v << 1) | bits[i * 8 + b]!;
    bytes[i] = v;
  }
  return bytes;
}

/**
 * Whether a phrase is an aezeed cipher seed. Cheap, offline, and decisive: it checks the version byte
 * and the CRC32C, so it never touches scrypt or the passphrase. Safe to run on every phrase the owner
 * types, and safe to ship to a client — it reveals nothing and needs no secret.
 */
export function isAezeedPhrase(words: string[]): boolean {
  const bytes = mnemonicToCipherSeedBytes(words);
  if (!bytes) return false;
  if (bytes[0] !== CIPHER_SEED_VERSION) return false;
  return crc32c(bytes.subarray(0, CHECKSUM_OFFSET)) === readU32BE(bytes, CHECKSUM_OFFSET);
}

const readU32BE = (b: Uint8Array, off: number): number =>
  ((b[off]! << 24) | (b[off + 1]! << 16) | (b[off + 2]! << 8) | b[off + 3]!) >>> 0;

// ── decipher ─────────────────────────────────────────────────────────────────────────────────────

export type CipherSeed = {
  internalVersion: number;
  /** Days since the Bitcoin genesis block. */
  birthday: number;
  /** The BIP32 master seed. 16 bytes. Caller is responsible for zeroing it. */
  entropy: Uint8Array;
};

export class AezeedError extends Error {
  constructor(
    message: string,
    readonly code: 'BAD_VERSION' | 'BAD_CHECKSUM' | 'BAD_PASSPHRASE' | 'BAD_LENGTH',
  ) {
    super(message);
    this.name = 'AezeedError';
  }
}

/** Convert a birthday (days since genesis) to a date — useful as a rescan floor. */
export function birthdayToDate(birthday: number): Date {
  return new Date(BITCOIN_GENESIS + birthday * ONE_DAY_MS);
}

/**
 * Decipher a 33-byte aezeed. Throws AezeedError with a code the caller can turn into a useful message —
 * in particular BAD_PASSPHRASE, which is the difference between "your words are wrong" (they are not)
 * and "this seed has a passphrase set and we need it".
 */
export async function decipherCipherSeed(bytes: Uint8Array, passphrase: string): Promise<CipherSeed> {
  if (bytes.length !== ENCIPHERED_SIZE) {
    throw new AezeedError(`aezeed must be ${ENCIPHERED_SIZE} bytes, got ${bytes.length}`, 'BAD_LENGTH');
  }
  if (bytes[0] !== CIPHER_SEED_VERSION) {
    throw new AezeedError(`unsupported aezeed version ${bytes[0]}`, 'BAD_VERSION');
  }
  if (crc32c(bytes.subarray(0, CHECKSUM_OFFSET)) !== readU32BE(bytes, CHECKSUM_OFFSET)) {
    throw new AezeedError('aezeed checksum mismatch', 'BAD_CHECKSUM');
  }

  const salt = Buffer.from(bytes.subarray(SALT_OFFSET, SALT_OFFSET + SALT_SIZE));
  const key = await scrypt(passphrase.normalize('NFKD'), salt, KEY_LEN, {
    N: SCRYPT_N,
    r: SCRYPT_R,
    p: SCRYPT_P,
    maxmem: SCRYPT_MAXMEM,
  });

  // Associated data is the version byte followed by the salt, so a seed cannot be replayed under a
  // different version or salt.
  const ad = new Uint8Array(1 + SALT_SIZE);
  ad[0] = CIPHER_SEED_VERSION;
  ad.set(salt, 1);

  try {
    const plaintext = aezDecrypt(key, null, [ad], CIPHERTEXT_EXPANSION, bytes.subarray(1, SALT_OFFSET));
    if (!plaintext || plaintext.length !== PLAINTEXT_SIZE) {
      throw new AezeedError('incorrect seed passphrase', 'BAD_PASSPHRASE');
    }
    return {
      internalVersion: plaintext[0]!,
      birthday: (plaintext[1]! << 8) | plaintext[2]!,
      entropy: plaintext.slice(3, 3 + ENTROPY_SIZE),
    };
  } finally {
    key.fill(0);
  }
}

/** Convenience: words straight to a deciphered seed. */
export async function decipherMnemonic(words: string[], passphrase = DEFAULT_AEZEED_PASSPHRASE): Promise<CipherSeed> {
  const bytes = mnemonicToCipherSeedBytes(words);
  if (!bytes) throw new AezeedError('not a 24-word phrase from the BIP39 wordlist', 'BAD_LENGTH');
  return decipherCipherSeed(bytes, passphrase);
}
