/**
 * Adversarial tests for V3.10 Social Key Recovery.
 *
 * Holds the line on the V3.10 acceptance criteria:
 *   - No coalition of (k-1) guardians can reconstruct the secret.
 *   - Forged shares are detected by the AEAD on the backup blob.
 *   - Guardian decline propagates to the new device.
 *   - Per-guardian fingerprint-gate refusal blocks the share release.
 */

import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import fc from 'fast-check';
import type { Shade } from '@shade/sdk';
import {
  attachGuardian,
  combineShares,
  decodeShare,
  encodeShare,
  MemoryRecoveryStore,
  RecoveryDeclinedError,
  recoveryKeyToBackupPassphrase,
  requestRecovery,
  setupRecovery,
  splitSecret,
  type GuardianApproveHandler,
} from '../src/index.js';
import {
  MemoryRecoveryTransport,
  spawnShade,
  startTestPrekeyServer,
  type TestEnv,
} from './helpers.js';

const cryptoRandom = (n: number): Uint8Array => {
  const out = new Uint8Array(n);
  globalThis.crypto.getRandomValues(out);
  return out;
};

describe('Adversarial — k-1 collusion never recovers', () => {
  test('property: any (k-1) subset of shares fails to recover the key', () => {
    fc.assert(
      fc.property(
        fc.uint8Array({ minLength: 16, maxLength: 32 }),
        fc.integer({ min: 2, max: 5 }),
        fc.integer({ min: 0, max: 3 }),
        (recoveryKey, k, extra) => {
          const n = k + extra;
          if (n > 8) return;
          const shares = splitSecret(recoveryKey, k, n, cryptoRandom);
          // Try every (k-1)-sized subset.
          const indices = Array.from({ length: n }, (_, i) => i);
          for (const subset of subsets(indices, k - 1)) {
            const reconstructed = combineShares(subset.map((i) => shares[i]!));
            // Recovered bytes never equal the secret (with probability
            // 1 - 1/256^len, vanishingly small for 16+ byte secrets).
            expect(Array.from(reconstructed)).not.toEqual(Array.from(recoveryKey));
          }
        },
      ),
      { numRuns: 30 },
    );
  });
});

describe('Adversarial — guardian decline + forged share', () => {
  let env: TestEnv;
  let alice: Shade;
  let alice2: Shade;
  let guardians: Shade[];
  let transport: MemoryRecoveryTransport;
  const guardianStores = new Map<string, MemoryRecoveryStore>();
  const detachers: Array<() => void> = [];

  // Per-test approve toggles (so we can flip a guardian to decline mid-suite).
  const approveOverrides = new Map<string, GuardianApproveHandler>();

  beforeAll(async () => {
    env = await startTestPrekeyServer();
    alice = await spawnShade(env.prekeyUrl, 'alice');
    const guardianAddrs = ['bob', 'carol', 'dan', 'eve', 'faythe'];
    guardians = await Promise.all(guardianAddrs.map((a) => spawnShade(env.prekeyUrl, a)));
    alice2 = await spawnShade(env.prekeyUrl, 'alice-new-device');

    transport = new MemoryRecoveryTransport();
    transport.add(alice);
    transport.add(alice2);
    for (const g of guardians) transport.add(g);

    for (const g of guardians) {
      const store = new MemoryRecoveryStore();
      guardianStores.set(g.myAddress, store);
      const attached = attachGuardian({
        shade: g,
        store,
        approve: async (ctx) => {
          const override = approveOverrides.get(g.myAddress);
          if (override !== undefined) return override(ctx);
          return true;
        },
        deliver: transport.bind(g),
      });
      detachers.push(attached.stop);
    }

    // Run setup once so all guardians have a deposit.
    const result = await setupRecovery({
      shade: alice,
      guardians: guardians.map((g) => g.myAddress),
      threshold: 3,
      deliver: transport.bind(alice),
    });
    expect(result.allDelivered).toBe(true);
  });

  afterAll(async () => {
    for (const d of detachers) d();
    await alice.shutdown();
    await alice2.shutdown();
    for (const g of guardians) await g.shutdown();
    env.stop();
  });

  test('declines from 3 guardians push us below threshold', async () => {
    approveOverrides.set('bob', async () => false);
    approveOverrides.set('carol', async () => false);
    approveOverrides.set('dan', async () => false);

    const sample = await guardianStores.get('eve')!.list();
    const setupId = sample[0]!.setupId;

    await expect(
      requestRecovery({
        shade: alice2,
        originalAddress: 'alice',
        guardians: guardians.map((g) => g.myAddress),
        threshold: 3,
        setupId,
        deliver: transport.bind(alice2),
        timeoutMs: 5000,
      }),
    ).rejects.toBeInstanceOf(RecoveryDeclinedError);

    approveOverrides.clear();
  });

  test('throwing approve handler counts as decline with descriptive reason', async () => {
    approveOverrides.set('bob', async () => {
      throw new Error('user pressed cancel');
    });
    approveOverrides.set('carol', async () => {
      throw new Error('user pressed cancel');
    });
    approveOverrides.set('dan', async () => {
      throw new Error('user pressed cancel');
    });

    const sample = await guardianStores.get('eve')!.list();
    const setupId = sample[0]!.setupId;

    try {
      await requestRecovery({
        shade: alice2,
        originalAddress: 'alice',
        guardians: guardians.map((g) => g.myAddress),
        threshold: 3,
        setupId,
        deliver: transport.bind(alice2),
        timeoutMs: 5000,
      });
      throw new Error('expected RecoveryDeclinedError');
    } catch (err) {
      expect(err).toBeInstanceOf(RecoveryDeclinedError);
    }

    approveOverrides.clear();
  });

  test('unknown setupId from new device is auto-declined by guardians', async () => {
    await expect(
      requestRecovery({
        shade: alice2,
        originalAddress: 'alice',
        guardians: guardians.map((g) => g.myAddress),
        threshold: 3,
        setupId: 'fake-setup-id',
        deliver: transport.bind(alice2),
        timeoutMs: 5000,
      }),
    ).rejects.toBeInstanceOf(RecoveryDeclinedError);
  });
});

describe('Adversarial — single forged share rejected by AEAD', () => {
  // We exercise this at the unit level instead of going through the
  // full Shade-pair wiring, because the entire point is that
  // requestRecovery's reconstruction loop tries threshold-sized
  // subsets of grants until one authenticates the backup blob.
  test('a corrupted share never authenticates against the backup AEAD tag', async () => {
    const recoveryKey = cryptoRandom(32);
    // Encrypt some plaintext with this key via the same path Shade uses.
    const passphrase = recoveryKeyToBackupPassphrase(recoveryKey);
    expect(passphrase.startsWith('shade-rk:')).toBe(true);

    // Split into 3-of-5.
    const shares = splitSecret(recoveryKey, 3, 5, cryptoRandom);
    // Forge share #2 by flipping a high-entropy byte.
    const forged = { x: shares[1]!.x, y: new Uint8Array(shares[1]!.y) };
    forged.y[5] = (forged.y[5]! ^ 0xff) & 0xff;
    const forgedSet = [shares[0]!, forged, shares[2]!];
    const reconstructed = combineShares(forgedSet);
    // The reconstructed key MUST differ from the real one.
    expect(Array.from(reconstructed)).not.toEqual(Array.from(recoveryKey));
    // Conversely, the honest 3 shares reconstruct exactly.
    const honest = combineShares([shares[0]!, shares[1]!, shares[2]!]);
    expect(Array.from(honest)).toEqual(Array.from(recoveryKey));
  });

  test('encode → tamper → decode preserves x-coordinate but flips y', () => {
    const share = { x: 7, y: new Uint8Array([1, 2, 3, 4, 5]) };
    const bytes = encodeShare(share);
    const tampered = new Uint8Array(bytes);
    tampered[3] ^= 0x42;
    const decoded = decodeShare(tampered);
    expect(decoded.x).toBe(7);
    expect(decoded.y[2]).not.toBe(3);
  });
});

describe('Fingerprint-gate enforcement on guardian side', () => {
  // Verifies V3.10 acceptance criterion #3:
  //   "Guardian-side widget krever fingerprint-bekreftelse før send"
  //
  // This test simulates a guardian whose approve callback ONLY returns
  // true when the requesterFingerprint is what they OOB-confirmed. The
  // wrong fingerprint → decline.
  let env: TestEnv;
  let alice: Shade;
  let alice2: Shade;
  let bob: Shade;
  let transport: MemoryRecoveryTransport;
  let guardianStore: MemoryRecoveryStore;
  let detach: (() => void) | null = null;

  beforeAll(async () => {
    env = await startTestPrekeyServer();
    alice = await spawnShade(env.prekeyUrl, 'alice');
    bob = await spawnShade(env.prekeyUrl, 'bob');
    alice2 = await spawnShade(env.prekeyUrl, 'alice-new-device');
    transport = new MemoryRecoveryTransport();
    transport.add(alice);
    transport.add(alice2);
    transport.add(bob);
    guardianStore = new MemoryRecoveryStore();
  });

  afterAll(async () => {
    if (detach !== null) detach();
    await alice.shutdown();
    await alice2.shutdown();
    await bob.shutdown();
    env.stop();
  });

  test('approve handler that demands an OOB-correct fingerprint releases share', async () => {
    const oobConfirmedFingerprint = await alice2.fingerprint;
    const attached = attachGuardian({
      shade: bob,
      store: guardianStore,
      approve: async (ctx) => {
        // The user has pre-committed to releasing the share only when
        // requesterFingerprint matches the value they verified OOB.
        return ctx.requesterFingerprint === oobConfirmedFingerprint;
      },
      deliver: transport.bind(bob),
    });
    detach = attached.stop;

    // Setup: bob holds a share for alice (1-of-1 trivially recoverable).
    const setupResult = await setupRecovery({
      shade: alice,
      guardians: [bob.myAddress],
      threshold: 1,
      deliver: transport.bind(alice),
    });
    expect(setupResult.allDelivered).toBe(true);

    const recovered = await requestRecovery({
      shade: alice2,
      originalAddress: 'alice',
      guardians: [bob.myAddress],
      threshold: 1,
      setupId: setupResult.setupId,
      deliver: transport.bind(alice2),
      timeoutMs: 5000,
    });
    expect(recovered.applied).toBe(true);
  });

  test('approve handler that REJECTS a wrong fingerprint never sends a grant', async () => {
    // Force the approve to compare against a fingerprint that doesn't match.
    detach!();
    const fakeOob = '00000 00000 00000 00000 00000 00000 00000 00000 00000 00000 00000 00000';
    const attached = attachGuardian({
      shade: bob,
      store: guardianStore,
      approve: async (ctx) => ctx.requesterFingerprint === fakeOob,
      deliver: transport.bind(bob),
    });
    detach = attached.stop;

    // Take the existing setup's setupId (already in store from previous test).
    const sample = await guardianStore.list();
    expect(sample.length).toBeGreaterThan(0);
    const setupId = sample[0]!.setupId;

    await expect(
      requestRecovery({
        shade: alice2,
        originalAddress: 'alice',
        guardians: [bob.myAddress],
        threshold: 1,
        setupId,
        deliver: transport.bind(alice2),
        timeoutMs: 3000,
      }),
    ).rejects.toBeInstanceOf(RecoveryDeclinedError);
  });
});

function* subsets<T>(items: ReadonlyArray<T>, size: number): IterableIterator<T[]> {
  if (size <= 0) {
    yield [];
    return;
  }
  if (items.length < size) return;
  const indices = new Array<number>(size);
  for (let i = 0; i < size; i++) indices[i] = i;
  while (true) {
    yield indices.map((i) => items[i]!);
    let i = size - 1;
    while (i >= 0 && indices[i]! === items.length - size + i) i--;
    if (i < 0) return;
    indices[i] = indices[i]! + 1;
    for (let j = i + 1; j < size; j++) indices[j] = indices[j - 1]! + 1;
  }
}

