import { describe, test, expect, beforeEach } from 'bun:test';
import { SubtleCryptoProvider, MemoryStorage } from '@shade/crypto-web';
import {
  ShadeSessionManager,
  StreamRatchet,
  StreamClosedError,
  DecryptionError,
} from '../src/index.js';

const crypto = new SubtleCryptoProvider();
const enc = new TextEncoder();
const dec = new TextDecoder();

/**
 * Establish a *bidirectional* parent session: Alice→Bob X3DH, then one
 * Alice→Bob message Bob decrypts so Bob also has a session for 'alice'.
 * Both sides then hold the peer's pinned identity DH key — the input the
 * stream handshake derives from.
 */
async function bidirectionalPair() {
  const aliceStorage = new MemoryStorage();
  const bobStorage = new MemoryStorage();
  const alice = new ShadeSessionManager(crypto, aliceStorage);
  const bob = new ShadeSessionManager(crypto, bobStorage);
  await alice.initialize();
  await bob.initialize();

  const otpks = await bob.generateOneTimePreKeys(4);
  const bundle = await bob.createPreKeyBundle();
  bundle.oneTimePreKey = { keyId: otpks[0]!.keyId, publicKey: otpks[0]!.keyPair.publicKey };
  await alice.initSessionFromBundle('bob', bundle);

  const hello = await alice.encrypt('bob', 'parent-hello');
  expect(await bob.decrypt('alice', hello)).toBe('parent-hello');

  return { alice, bob, aliceStorage, bobStorage };
}

/** Run the full STREAM_OPEN / STREAM_OPEN_ACK handshake between managers. */
async function openStreamPair(alice: ShadeSessionManager, bob: ShadeSessionManager) {
  const begun = await alice.beginStream('bob'); // initiator
  const accepted = await bob.acceptStream('alice', begun.streamId, begun.ephemeralPublicKey);
  const aliceStream = await begun.complete(accepted.ephemeralPublicKey);
  return { aliceStream, bobStream: accepted.stream, streamId: begun.streamId };
}

describe('streaming sub-ratchet (V4.11)', () => {
  let alice: ShadeSessionManager;
  let bob: ShadeSessionManager;
  let aliceStorage: MemoryStorage;

  beforeEach(async () => {
    ({ alice, bob, aliceStorage } = await bidirectionalPair());
  });

  test('both sides derive the same stream root (round-trips frames)', async () => {
    const { aliceStream, bobStream } = await openStreamPair(alice, bob);

    // Initiator → responder (first frame triggers responder DH step).
    const f1 = await aliceStream.seal(enc.encode('log line 1'));
    expect(dec.decode(await bobStream.open(f1))).toBe('log line 1');

    // Responder → initiator (now responder may seal).
    const r1 = await bobStream.seal(enc.encode('command-response 1'));
    expect(dec.decode(await aliceStream.open(r1))).toBe('command-response 1');
  });

  test('two streams to the same peer get independent roots', async () => {
    const s1 = await openStreamPair(alice, bob);
    const s2 = await openStreamPair(alice, bob);
    expect(s1.streamId).not.toEqual(s2.streamId);

    const a = await s1.aliceStream.seal(enc.encode('on stream 1'));
    // A frame from stream 1 must not decrypt on stream 2's ratchet.
    await expect(s2.bobStream.open(a)).rejects.toBeInstanceOf(DecryptionError);
    // …but does on its own.
    expect(dec.decode(await s1.bobStream.open(a))).toBe('on stream 1');
  });

  test('R2/R3: long one-directional burst stays correct and memory-bounded', async () => {
    const { aliceStream, bobStream } = await openStreamPair(alice, bob);
    const N = 5000;

    // Capture a live receive-chain key buffer to prove forward secrecy:
    // ratchetDecrypt zeroizes the previous chain key in place.
    await bobStream.open(await aliceStream.seal(enc.encode('frame-0')));
    const bobSession = (bobStream as unknown as { session: { receiveChain: { chainKey: Uint8Array }; skippedKeys: Map<string, Uint8Array> } }).session;
    const staleChainKey = bobSession.receiveChain.chainKey;
    const staleCopy = staleChainKey.slice();
    expect(staleCopy.some((b) => b !== 0)).toBe(true);

    for (let i = 1; i < N; i++) {
      const wire = await aliceStream.seal(enc.encode(`frame-${i}`));
      expect(dec.decode(await bobStream.open(wire))).toBe(`frame-${i}`);
    }

    // In-order delivery ⇒ zero skipped keys retained across 5k frames.
    expect(bobSession.skippedKeys.size).toBe(0);
    // The chain key in use at frame 0 was overwritten (forward secrecy).
    expect(staleChainKey.every((b) => b === 0)).toBe(true);
  });

  test('R5: opening/using/closing a stream never touches the parent session', async () => {
    const before = await aliceStorage.getSession('bob');
    const snapshot = JSON.stringify({
      root: Array.from(before!.rootKey),
      sendCtr: before!.sendChain.counter,
      prevCtr: before!.previousSendCounter,
    });

    const { aliceStream, bobStream } = await openStreamPair(alice, bob);
    for (let i = 0; i < 200; i++) {
      await bobStream.open(await aliceStream.seal(enc.encode(`x${i}`)));
    }
    await aliceStream.close();
    await bobStream.close();

    const after = await aliceStorage.getSession('bob');
    expect(
      JSON.stringify({
        root: Array.from(after!.rootKey),
        sendCtr: after!.sendChain.counter,
        prevCtr: after!.previousSendCounter,
      }),
    ).toBe(snapshot);

    // Parent HTTP path still works after the stream lifecycle.
    const env = await alice.encrypt('bob', 'after-stream');
    expect(await bob.decrypt('alice', env)).toBe('after-stream');
  });

  test('R1: replayed / rewound frame is rejected', async () => {
    const { aliceStream, bobStream } = await openStreamPair(alice, bob);
    const f1 = await aliceStream.seal(enc.encode('once'));
    expect(dec.decode(await bobStream.open(f1))).toBe('once');
    // Re-delivering the exact same sealed frame must fail.
    await expect(bobStream.open(f1)).rejects.toBeInstanceOf(DecryptionError);
  });

  test('close() zeroizes and blocks further use; idempotent', async () => {
    const { aliceStream, bobStream } = await openStreamPair(alice, bob);
    await aliceStream.close();
    await aliceStream.close(); // idempotent
    expect(aliceStream.closed).toBe(true);
    await expect(aliceStream.seal(enc.encode('nope'))).rejects.toBeInstanceOf(
      StreamClosedError,
    );
    // The peer end is unaffected by our local close.
    expect(bobStream.closed).toBe(false);
  });

  test('handshake is mutually authenticated against pinned identities', async () => {
    // A third party (mallory) with its own identity cannot stand in for
    // bob: alice derives against bob's pinned identity key, so a
    // handshake completed with mallory's ephemeral yields a different
    // root and frames fail to open.
    const mStorage = new MemoryStorage();
    const mallory = new ShadeSessionManager(crypto, mStorage);
    await mallory.initialize();
    // Give mallory a parent session label so acceptStream has identity
    // material, but pinned to the WRONG (alice) identity vs what alice
    // pinned for 'bob'.
    const otpks = await mallory.generateOneTimePreKeys(2);
    const mb = await mallory.createPreKeyBundle();
    mb.oneTimePreKey = { keyId: otpks[0]!.keyId, publicKey: otpks[0]!.keyPair.publicKey };
    await alice.initSessionFromBundle('mallory', mb);
    const helo = await alice.encrypt('mallory', 'hi');
    await mallory.decrypt('alice', helo);

    const begun = await alice.beginStream('bob');
    const mAccept = await mallory.acceptStream('alice', begun.streamId, begun.ephemeralPublicKey);
    const aliceStream = await begun.complete(mAccept.ephemeralPublicKey);
    const frame = await aliceStream.seal(enc.encode('secret'));
    await expect(mAccept.stream.open(frame)).rejects.toBeInstanceOf(DecryptionError);
  });
});
