// Storage interface contract.
// This is the SPEC. Runs against any storage impl (memory, ws, firebase).
// TDD: written first (RED), impl built to satisfy (GREEN).
//
// Usage:
//   const { runStorageContract } = require('./contract.test');
//   runStorageContract('memory', () => createMemoryStorage());

'use strict';

// Each impl factory returns a fresh storage instance (async-creatable is fine).
// Interface every impl MUST provide:
//   getDoc(path)              -> Promise<obj|null>
//   setDoc(path, data)        -> Promise<void>           (replace)
//   updateDoc(path, patch)    -> Promise<void>           (shallow merge)
//   deleteDoc(path)           -> Promise<void>
//   addDoc(collectionPath, data) -> Promise<{id, path}>  (auto-gen id)
//   getCollection(path)       -> Promise<arr>            (immediate child docs)
//   batchWrite(ops)           -> Promise<void>           ops: [{type, path, data?}]
//   subscribeDoc(path, cb)    -> unsubscribe fn          cb(doc|null)
//   subscribeCollection(path, cb) -> unsubscribe fn      cb(arr)

function runStorageContract(name, factory) {
  describe(`storage contract: ${name}`, () => {
    let storage;
    beforeEach(async () => { storage = await factory(); });
    afterEach(async () => { if (storage && storage.dispose) await storage.dispose(); });

    describe('getDoc / setDoc', () => {
      test('setDoc then getDoc returns the doc', async () => {
        await storage.setDoc('campaigns/a', { name: 'Alpha' });
        const doc = await storage.getDoc('campaigns/a');
        expect(doc).toEqual({ name: 'Alpha' });
      });

      test('getDoc on missing path returns null', async () => {
        const doc = await storage.getDoc('campaigns/missing');
        expect(doc).toBeNull();
      });

      test('setDoc overwrites entirely (not merge)', async () => {
        await storage.setDoc('campaigns/a', { name: 'Alpha', players: [] });
        await storage.setDoc('campaigns/a', { name: 'Beta' });
        const doc = await storage.getDoc('campaigns/a');
        expect(doc).toEqual({ name: 'Beta' });
      });
    });

    describe('updateDoc (shallow merge)', () => {
      test('merges patch into existing doc', async () => {
        await storage.setDoc('campaigns/a', { name: 'Alpha', players: [1] });
        await storage.updateDoc('campaigns/a', { players: [1, 2] });
        const doc = await storage.getDoc('campaigns/a');
        expect(doc).toEqual({ name: 'Alpha', players: [1, 2] });
      });

      test('updateDoc on missing doc creates it', async () => {
        await storage.updateDoc('campaigns/a', { name: 'Alpha' });
        const doc = await storage.getDoc('campaigns/a');
        expect(doc).toEqual({ name: 'Alpha' });
      });
    });

    describe('deleteDoc', () => {
      test('removes doc', async () => {
        await storage.setDoc('campaigns/a', { name: 'Alpha' });
        await storage.deleteDoc('campaigns/a');
        expect(await storage.getDoc('campaigns/a')).toBeNull();
      });

      test('delete missing doc is no-op (no throw)', async () => {
        await expect(storage.deleteDoc('campaigns/none')).resolves.toBeUndefined();
      });
    });

    describe('addDoc', () => {
      test('auto-generates id and stores doc at collection/id', async () => {
        const { id, path } = await storage.addDoc('campaigns/a/encounters', { name: 'E1' });
        expect(id).toBeTruthy();
        expect(path).toBe(`campaigns/a/encounters/${id}`);
        const doc = await storage.getDoc(path);
        expect(doc).toEqual({ name: 'E1' });
      });

      test('two addDocs produce distinct ids', async () => {
        const r1 = await storage.addDoc('logs', { m: 'one' });
        const r2 = await storage.addDoc('logs', { m: 'two' });
        expect(r1.id).not.toBe(r2.id);
      });
    });

    describe('firebase-prefixed path identity', () => {
      // App passes firebase-prefixed paths (artifacts/{APP_ID}/public/data/...).
      // Adapter must normalize internally so write+read at prefixed path round-trips
      // AND collection queries at bare canonical path find prefixed-written docs.
      // Catches replay-script bug (wrote prefixed, adapter reads bare, missed).
      const PREFIX = 'artifacts/test-app/public/data';

      test('setDoc prefixed then getCollection bare finds it', async () => {
        await storage.setDoc(`${PREFIX}/campaigns/c1`, { name: 'P1' });
        const docs = await storage.getCollection('campaigns');
        expect(docs.some(d => d.name === 'P1')).toBe(true);
      });

      test('setDoc prefixed then getDoc same prefixed path returns it', async () => {
        await storage.setDoc(`${PREFIX}/campaigns/c2`, { name: 'P2' });
        const doc = await storage.getDoc(`${PREFIX}/campaigns/c2`);
        expect(doc).toEqual({ name: 'P2' });
      });

      test('setDoc prefixed then getDoc bare path returns it', async () => {
        await storage.setDoc(`${PREFIX}/campaigns/c3`, { name: 'P3' });
        const doc = await storage.getDoc('campaigns/c3');
        expect(doc).toEqual({ name: 'P3' });
      });

      test('setDoc bare then getCollection prefixed finds it', async () => {
        await storage.setDoc('campaigns/c4', { name: 'P4' });
        const docs = await storage.getCollection(`${PREFIX}/campaigns`);
        expect(docs.some(d => d.name === 'P4')).toBe(true);
      });
    });

    describe('getCollection', () => {
      test('returns immediate child docs only (not nested)', async () => {
        await storage.setDoc('campaigns/a', { name: 'A' });
        await storage.setDoc('campaigns/b', { name: 'B' });
        await storage.setDoc('campaigns/a/encounters/e1', { name: 'E1' });
        const docs = await storage.getCollection('campaigns');
        expect(docs).toHaveLength(2);
        const names = docs.map(d => d.name).sort();
        expect(names).toEqual(['A', 'B']);
      });

      test('empty collection returns []', async () => {
        const docs = await storage.getCollection('campaigns');
        expect(docs).toEqual([]);
      });

      test('subcollection returns only its direct children', async () => {
        await storage.setDoc('campaigns/a/encounters/e1', { name: 'E1' });
        await storage.setDoc('campaigns/a/encounters/e2', { name: 'E2' });
        await storage.setDoc('campaigns/a/encounters/e1/participants/p1', { name: 'P1' });
        const docs = await storage.getCollection('campaigns/a/encounters');
        expect(docs).toHaveLength(2);
      });
    });

    describe('batchWrite', () => {
      test('applies multiple deletes atomically', async () => {
        await storage.setDoc('campaigns/a', { name: 'A' });
        await storage.setDoc('campaigns/b', { name: 'B' });
        await storage.batchWrite([
          { type: 'delete', path: 'campaigns/a' },
          { type: 'delete', path: 'campaigns/b' },
        ]);
        expect(await storage.getDoc('campaigns/a')).toBeNull();
        expect(await storage.getDoc('campaigns/b')).toBeNull();
      });

      test('applies set + delete mixed', async () => {
        await storage.setDoc('campaigns/a', { name: 'A' });
        await storage.batchWrite([
          { type: 'set', path: 'campaigns/b', data: { name: 'B' } },
          { type: 'delete', path: 'campaigns/a' },
        ]);
        expect(await storage.getDoc('campaigns/a')).toBeNull();
        expect(await storage.getDoc('campaigns/b')).toEqual({ name: 'B' });
      });
    });

    describe('subscribeDoc', () => {
      test('fires cb immediately with current value', async () => {
        await storage.setDoc('campaigns/a', { name: 'Alpha' });
        const calls = [];
        storage.subscribeDoc('campaigns/a', (doc) => calls.push(doc));
        await flush();
        expect(calls).toHaveLength(1);
        expect(calls[0]).toEqual({ name: 'Alpha' });
      });

      test('fires cb on subsequent change', async () => {
        const calls = [];
        storage.subscribeDoc('campaigns/a', (doc) => calls.push(doc));
        await flush();
        await storage.setDoc('campaigns/a', { name: 'Alpha' });
        await flush();
        const last = calls[calls.length - 1];
        expect(last).toEqual({ name: 'Alpha' });
      });

      test('unsubscribe stops callbacks', async () => {
        const calls = [];
        const unsub = storage.subscribeDoc('campaigns/a', (doc) => calls.push(doc));
        await flush();
        unsub();
        await storage.setDoc('campaigns/a', { name: 'X' });
        await flush();
        expect(calls.filter(Boolean)).toHaveLength(0);
      });
    });

    describe('subscribeCollection', () => {
      test('fires cb with current docs', async () => {
        await storage.setDoc('campaigns/a', { name: 'A' });
        const calls = [];
        storage.subscribeCollection('campaigns', (docs) => calls.push(docs));
        await flush();
        expect(calls).toHaveLength(1);
        expect(calls[0]).toHaveLength(1);
      });

      test('fires on add to collection', async () => {
        const calls = [];
        storage.subscribeCollection('campaigns', (docs) => calls.push(docs));
        await flush();
        await storage.setDoc('campaigns/a', { name: 'A' });
        await flush();
        const last = calls[calls.length - 1];
        expect(last).toHaveLength(1);
      });
    });
  });
}

// flush so async subscribers settle. WS roundtrip needs real delay (network),
// memory fires near-instant. 50ms covers localhost WS comfortably.
function flush() {
  return new Promise((resolve) => setTimeout(resolve, 50));
}

module.exports = { runStorageContract, flush };
