import { describe, test, expect } from 'bun:test';
import {
  sealSeed,
  generateSeed,
  deriveAccountXpubs,
  UnlockSession,
  verifyPassphrase,
  exportMnemonic,
  changePassphrase,
} from './keys';

/**
 * The seed custody core. This is the one file in the wallet where a bug is unrecoverable rather than
 * merely wrong — a mistake here either loses the owner's coins or leaks the key that spends them, so the
 * properties below are asserted rather than assumed.
 *
 * scrypt at N=2^17 makes each seal/open deliberately expensive (~1s), which is the entire point of the
 * parameter and also why this file carries a raised timeout instead of a smaller N.
 */

// BIP39's canonical all-`abandon` vector, and the BIP32 root fingerprint it must produce.
const VECTOR = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
const ROOT_FINGERPRINT = '73c5da0a';
const PASS = 'correct horse battery staple';

const SLOW = 60_000;

describe('sealSeed / deriveAccountXpubs', () => {
  test(
    'seals the vector and derives all four accounts',
    async () => {
      const env = await sealSeed(VECTOR, PASS);
      const { fingerprint, xpubs } = await deriveAccountXpubs(env, PASS, 'bitcoin');

      expect(env.v).toBe(1);
      expect(fingerprint).toBe(ROOT_FINGERPRINT);
      for (const bip of [44, 49, 84, 86] as const) expect(xpubs[bip]).toBeTruthy();
    },
    SLOW,
  );

  test(
    'the envelope never contains the plaintext mnemonic',
    async () => {
      const env = await sealSeed(VECTOR, PASS);
      expect(JSON.stringify(env)).not.toContain('abandon');
    },
    SLOW,
  );

  test(
    'sealing is non-deterministic but derivation is stable',
    async () => {
      // Fresh salt and nonce per seal, so two seals of one seed must not be byte-identical — otherwise a
      // DB dump would reveal which wallets share a seed.
      const [a, b] = [await sealSeed(VECTOR, PASS), await sealSeed(VECTOR, PASS)];
      expect(JSON.stringify(a)).not.toBe(JSON.stringify(b));

      const [xa, xb] = [await deriveAccountXpubs(a, PASS, 'bitcoin'), await deriveAccountXpubs(b, PASS, 'bitcoin')];
      expect(xb.xpubs[84]).toBe(xa.xpubs[84]);
    },
    SLOW,
  );

  test(
    'rejects a mnemonic that fails its checksum',
    async () => {
      const bad = VECTOR.replace(/about$/, 'abandon');
      await expect(sealSeed(bad, PASS)).rejects.toThrow(/BIP39/i);
    },
    SLOW,
  );

  test(
    'rejects a passphrase under the minimum length',
    async () => {
      await expect(sealSeed(VECTOR, 'short')).rejects.toThrow(/8 characters/);
    },
    SLOW,
  );

  test(
    'testnet derives a different account under the same root',
    async () => {
      const env = await sealSeed(VECTOR, PASS);
      const [main, test_] = [
        await deriveAccountXpubs(env, PASS, 'bitcoin'),
        await deriveAccountXpubs(env, PASS, 'testnet'),
      ];
      // Coin type 1' vs 0' — a different account, but the same seed, so the same root fingerprint.
      expect(test_.xpubs[84]).not.toBe(main.xpubs[84]);
      expect(test_.fingerprint).toBe(main.fingerprint);
    },
    SLOW,
  );
});

/**
 * A throwaway wallet id per call. Every passphrase check is now rate-limited per wallet id in module
 * state, so tests that share one would start locking each other out after five wrong guesses.
 */
let idSeq = 0;
const wid = () => ++idSeq;

describe('passphrase handling', () => {
  test(
    'accepts the right passphrase and rejects a wrong one',
    async () => {
      const env = await sealSeed(VECTOR, PASS);
      expect(await verifyPassphrase(wid(), env, PASS)).toBe(true);
      expect(await verifyPassphrase(wid(), env, 'not the passphrase')).toBe(false);
    },
    SLOW,
  );

  test(
    'locks out after five wrong guesses, on export as well as unlock',
    async () => {
      const env = await sealSeed(VECTOR, PASS);
      const id = wid();

      // The backoff used to live inside UnlockSession.unlock only, so export-seed — which returns the
      // words in the clear — took unlimited guesses. Exercised through exportMnemonic for that reason.
      for (let i = 0; i < 5; i++) {
        await expect(exportMnemonic(id, env, `wrong ${i}`)).rejects.toThrow(/incorrect passphrase/);
      }
      await expect(exportMnemonic(id, env, `wrong 5`)).rejects.toThrow(/too many failed attempts/);
      // The right passphrase is refused too — otherwise the lockout would only slow a guesser down.
      await expect(exportMnemonic(id, env, PASS)).rejects.toThrow(/too many failed attempts/);
      // And it is not folded into a bland `false` by the boolean-returning sibling.
      await expect(verifyPassphrase(id, env, PASS)).rejects.toThrow(/too many failed attempts/);
    },
    SLOW,
  );

  test(
    'round-trips the mnemonic exactly',
    async () => {
      const env = await sealSeed(VECTOR, PASS);
      expect(await exportMnemonic(wid(), env, PASS)).toBe(VECTOR);
    },
    SLOW,
  );

  test(
    'rotation swaps the passphrase without disturbing the seed',
    async () => {
      const env = await sealSeed(VECTOR, PASS);
      const next = 'an entirely different passphrase';
      const rotated = await changePassphrase(wid(), env, PASS, next);

      expect(await verifyPassphrase(wid(), rotated, next)).toBe(true);
      expect(await verifyPassphrase(wid(), rotated, PASS)).toBe(false);
      expect(await exportMnemonic(wid(), rotated, next)).toBe(VECTOR);

      // The xpubs are stored alongside the envelope; if rotation changed them the wallet would silently
      // start watching a different account and report a zero balance.
      const [before, after] = [
        await deriveAccountXpubs(env, PASS, 'bitcoin'),
        await deriveAccountXpubs(rotated, next, 'bitcoin'),
      ];
      expect(after.xpubs[84]).toBe(before.xpubs[84]);
      expect(after.fingerprint).toBe(before.fingerprint);
    },
    SLOW,
  );
});

describe('UnlockSession', () => {
  test('holds no key material until unlocked', async () => {
    const s = new UnlockSession(1);
    expect(s.isUnlocked()).toBe(false);
    expect(() => s.withRoot((r) => r.publicExtendedKey)).toThrow(/locked/i);
  });

  test(
    'unlocks, derives the stored xpub, then relocks',
    async () => {
      const env = await sealSeed(VECTOR, PASS);
      const { xpubs } = await deriveAccountXpubs(env, PASS, 'bitcoin');

      const s = new UnlockSession(1);
      await s.unlock(env, PASS, 60);
      expect(s.isUnlocked()).toBe(true);
      expect(s.secondsRemaining()).toBeGreaterThan(0);
      expect(s.secondsRemaining()).toBeLessThanOrEqual(60);

      // Proves the unlocked root is the same key the watch-only xpub came from.
      expect(s.withRoot((r) => r.derive("m/84'/0'/0'").publicExtendedKey)).toBe(xpubs[84]);

      s.lock();
      expect(s.isUnlocked()).toBe(false);
      expect(() => s.withRoot((r) => r.publicExtendedKey)).toThrow(/locked/i);
    },
    SLOW,
  );

  test(
    'a failed unlock leaves the session locked',
    async () => {
      const env = await sealSeed(VECTOR, PASS);
      const s = new UnlockSession(1);
      await expect(s.unlock(env, 'the wrong passphrase', 60)).rejects.toThrow();
      expect(s.isUnlocked()).toBe(false);
    },
    SLOW,
  );
});

describe('aezeed seeds', () => {
  // The same fixture the aezeed suite uses: entropy 000102…0f under the default passphrase. That entropy
  // is BIP32 test vector 1's master seed, so the fingerprint below is a published constant from a third
  // specification — this asserts the whole chain (words → AEZ → BIP32) against something outside Officer.
  const AEZEED =
    'about wisdom spawn awkward catalog large teach salad large phrase drip caught coral snake chief mountain manage gym dog alcohol doctor valley evolve citizen';
  const BIP32_VECTOR_1_FINGERPRINT = '3442193e';

  test(
    'seals as v2 and derives the BIP32 vector root',
    async () => {
      const env = await sealSeed(AEZEED, PASS);
      expect(env.v).toBe(2);

      const { fingerprint, xpubs } = await deriveAccountXpubs(env, PASS, 'bitcoin');
      expect(fingerprint).toBe(BIP32_VECTOR_1_FINGERPRINT);
      for (const bip of [44, 49, 84, 86] as const) expect(xpubs[bip]).toBeTruthy();
    },
    SLOW,
  );

  test(
    'does NOT derive what the BIP39 path would have',
    async () => {
      // The failure this whole `kind` mechanism exists to prevent. An aezeed phrase is 24 valid wordlist
      // words, so `mnemonicToSeedSync` accepts it happily and returns a perfectly real — and completely
      // wrong — master key, pointing at an empty wallet the owner cannot tell apart from a bad rescan.
      const { mnemonicToSeedSync } = await import('@scure/bip39');
      const { HDKey } = await import('@scure/bip32');
      const wrong = HDKey.fromMasterSeed(mnemonicToSeedSync(AEZEED));
      const wrongFingerprint = Buffer.from(new Uint8Array(new Uint32Array([wrong.fingerprint]).buffer))
        .reverse()
        .toString('hex');
      expect(wrongFingerprint).not.toBe(BIP32_VECTOR_1_FINGERPRINT);

      const env = await sealSeed(AEZEED, PASS);
      const { fingerprint } = await deriveAccountXpubs(env, PASS, 'bitcoin');
      expect(fingerprint).toBe(BIP32_VECTOR_1_FINGERPRINT);
    },
    SLOW,
  );

  test(
    'the words survive an export round trip',
    async () => {
      const env = await sealSeed(AEZEED, PASS);
      expect(await exportMnemonic(wid(), env, PASS)).toBe(AEZEED);
    },
    SLOW,
  );

  test(
    'unlocking derives the same root as the stored xpub',
    async () => {
      const env = await sealSeed(AEZEED, PASS);
      const { xpubs } = await deriveAccountXpubs(env, PASS, 'bitcoin');

      const s = new UnlockSession(2);
      await s.unlock(env, PASS, 60);
      expect(s.withRoot((r) => r.derive("m/84'/0'/0'").publicExtendedKey)).toBe(xpubs[84]);
      s.lock();
    },
    SLOW,
  );

  test(
    'a wrong seed passphrase is refused at seal time, not silently accepted',
    async () => {
      // AEZ's 4-byte tag makes this loud. Without the check the wallet would seal fine and only reveal
      // itself later as an empty balance — the single most expensive way to find out.
      await expect(sealSeed(AEZEED, PASS, 'not the seed passphrase')).rejects.toThrow(/passphrase/i);
    },
    SLOW,
  );

  test(
    'a phrase that is neither BIP39 nor aezeed is still rejected',
    async () => {
      const bad = AEZEED.split(' ');
      [bad[3], bad[4]] = [bad[4]!, bad[3]!];
      await expect(sealSeed(bad.join(' '), PASS)).rejects.toThrow(/BIP39/i);
    },
    SLOW,
  );

  test(
    'rotation preserves the aezeed kind and its derivation',
    async () => {
      const env = await sealSeed(AEZEED, PASS);
      const next = 'an entirely different passphrase';
      const rotated = await changePassphrase(wid(), env, PASS, next);

      expect(rotated.v).toBe(2);
      const after = await deriveAccountXpubs(rotated, next, 'bitcoin');
      expect(after.fingerprint).toBe(BIP32_VECTOR_1_FINGERPRINT);
    },
    SLOW,
  );
});

describe('generateSeed', () => {
  test(
    'produces a distinct, valid mnemonic that survives a round trip',
    async () => {
      const mnemonic = generateSeed();
      expect([12, 24]).toContain(mnemonic.split(' ').length);
      expect(generateSeed()).not.toBe(mnemonic);

      const env = await sealSeed(mnemonic, 'a sufficiently long passphrase');
      expect(await exportMnemonic(wid(), env, 'a sufficiently long passphrase')).toBe(mnemonic);
    },
    SLOW,
  );
});
