import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';

import { SqliteEventStore } from '../sqlite-event.store';
import { sqliteStorageOptionsSchema } from '../sqlite.options';

function tmpDbPath(): string {
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sqlite-event-'));
  return path.join(dir, 'test.sqlite');
}

function cleanup(dbPath: string): void {
  try {
    const dir = path.dirname(dbPath);
    fs.rmSync(dir, { recursive: true, force: true });
  } catch {
    // ignore
  }
}

describe('SqliteStorageOptions Schema', () => {
  it('should validate correct options', () => {
    const result = sqliteStorageOptionsSchema.safeParse({ path: '/tmp/test.db' });
    expect(result.success).toBe(true);
    if (result.success) {
      expect(result.data.walMode).toBe(true);
      expect(result.data.ttlCleanupIntervalMs).toBe(60000);
    }
  });

  it('should reject empty path', () => {
    const result = sqliteStorageOptionsSchema.safeParse({ path: '' });
    expect(result.success).toBe(false);
  });

  it('should accept ttlCleanupIntervalMs of 0', () => {
    const result = sqliteStorageOptionsSchema.safeParse({ path: '/tmp/test.db', ttlCleanupIntervalMs: 0 });
    expect(result.success).toBe(true);
    if (result.success) {
      expect(result.data.ttlCleanupIntervalMs).toBe(0);
    }
  });

  it('should accept encryption config', () => {
    const result = sqliteStorageOptionsSchema.safeParse({
      path: '/tmp/test.db',
      encryption: { secret: 'my-secret' },
    });
    expect(result.success).toBe(true);
  });
});

describe('SqliteEventStore', () => {
  let store: SqliteEventStore;
  let dbPath: string;

  beforeEach(() => {
    dbPath = tmpDbPath();
    store = new SqliteEventStore({
      path: dbPath,
      ttlCleanupIntervalMs: 0,
      maxEvents: 100,
      ttlMs: 300000,
    });
  });

  afterEach(() => {
    store.close();
    cleanup(dbPath);
  });

  describe('storeEvent', () => {
    it('should store an event and return an event ID', async () => {
      const message = { jsonrpc: '2.0', method: 'test', params: {} };
      const id = await store.storeEvent('stream-1', message);

      expect(id).toBe('stream-1:1');
    });

    it('should increment event IDs per stream', async () => {
      const msg = { jsonrpc: '2.0', method: 'test' };
      const id1 = await store.storeEvent('stream-1', msg);
      const id2 = await store.storeEvent('stream-1', msg);
      const id3 = await store.storeEvent('stream-2', msg);

      expect(id1).toBe('stream-1:1');
      expect(id2).toBe('stream-1:2');
      expect(id3).toBe('stream-2:1');
    });

    it('should track event count', async () => {
      const msg = { jsonrpc: '2.0', method: 'test' };
      await store.storeEvent('stream-1', msg);
      await store.storeEvent('stream-1', msg);

      expect(store.size).toBe(2);
    });
  });

  describe('replayEventsAfter', () => {
    it('should replay events after a given event ID', async () => {
      const msg1 = { jsonrpc: '2.0', id: 1, method: 'test1' };
      const msg2 = { jsonrpc: '2.0', id: 2, method: 'test2' };
      const msg3 = { jsonrpc: '2.0', id: 3, method: 'test3' };

      const id1 = await store.storeEvent('stream-1', msg1);
      await store.storeEvent('stream-1', msg2);
      await store.storeEvent('stream-1', msg3);

      const replayed: Array<{ id: string; message: unknown }> = [];

      const streamId = await store.replayEventsAfter(id1, {
        send: async (eventId, message) => {
          replayed.push({ id: eventId, message });
        },
      });

      expect(streamId).toBe('stream-1');
      expect(replayed.length).toBe(2);
      expect(replayed[0].message).toEqual(msg2);
      expect(replayed[1].message).toEqual(msg3);
    });

    it('should return default stream for unknown event ID', async () => {
      const streamId = await store.replayEventsAfter('unknown-id', {
        send: async () => {
          // should not be called
        },
      });

      expect(streamId).toBe('default-stream');
    });

    it('should not replay expired events', async () => {
      // Create store with very short TTL
      store.close();
      cleanup(dbPath);
      dbPath = tmpDbPath();
      store = new SqliteEventStore({
        path: dbPath,
        ttlCleanupIntervalMs: 0,
        ttlMs: 1, // 1ms TTL
      });

      const msg = { jsonrpc: '2.0', method: 'test' };
      const id1 = await store.storeEvent('stream-1', msg);
      await store.storeEvent('stream-1', msg);

      // Wait for events to expire
      await new Promise((r) => setTimeout(r, 10));

      const replayed: unknown[] = [];
      await store.replayEventsAfter(id1, {
        send: async (_id, message) => {
          replayed.push(message);
        },
      });

      expect(replayed.length).toBe(0);
    });
  });

  describe('constructor options', () => {
    it('should work with walMode disabled', async () => {
      store.close();
      cleanup(dbPath);
      dbPath = tmpDbPath();
      store = new SqliteEventStore({
        path: dbPath,
        walMode: false,
        ttlCleanupIntervalMs: 0,
        maxEvents: 100,
        ttlMs: 300000,
      });

      const msg = { jsonrpc: '2.0', method: 'test' };
      const id = await store.storeEvent('stream-1', msg);
      expect(id).toBe('stream-1:1');
    });

    it('should start cleanup timer when interval is positive', () => {
      store.close();
      cleanup(dbPath);
      dbPath = tmpDbPath();
      store = new SqliteEventStore({
        path: dbPath,
        ttlCleanupIntervalMs: 60000,
        maxEvents: 100,
        ttlMs: 300000,
      });

      // Store should be functional
      expect(store.size).toBe(0);
    });
  });

  describe('counter rehydration', () => {
    it('should continue IDs after store restart', async () => {
      const msg = { jsonrpc: '2.0', method: 'test' };
      await store.storeEvent('stream-1', msg);
      await store.storeEvent('stream-1', msg);
      const id3 = await store.storeEvent('stream-1', msg);
      expect(id3).toBe('stream-1:3');

      // Close and reopen the store with the same database
      store.close();
      store = new SqliteEventStore({
        path: dbPath,
        ttlCleanupIntervalMs: 0,
        maxEvents: 100,
        ttlMs: 300000,
      });

      // Next event should continue from counter 4, not restart at 1
      const id4 = await store.storeEvent('stream-1', msg);
      expect(id4).toBe('stream-1:4');

      // A different stream should still start at 1
      const idOther = await store.storeEvent('stream-2', msg);
      expect(idOther).toBe('stream-2:1');
    });
  });

  describe('constructor error handling', () => {
    it('should throw descriptive error for an unwritable db path', () => {
      expect(() => {
        new SqliteEventStore({
          path: '/nonexistent/dir/bad/test.sqlite',
          ttlCleanupIntervalMs: 0,
          maxEvents: 100,
          ttlMs: 300000,
        });
      }).toThrow(/SqliteEventStore: failed to (create directory|open database)/);
    });

    it('should throw descriptive error for non-Error exception', () => {
      // Verify the String(err) fallback by testing with a bad path
      expect(() => {
        new SqliteEventStore({
          path: '/nonexistent/dir/bad/test.sqlite',
          ttlCleanupIntervalMs: 0,
        });
      }).toThrow(/SqliteEventStore/);
    });
  });

  describe('counter rehydration edge cases', () => {
    it('should handle corrupt event ID gracefully during rehydration', async () => {
      const msg = { jsonrpc: '2.0', method: 'test' };
      // Store an event normally
      await store.storeEvent('stream-1', msg);

      // Manually insert an event with a non-numeric ID suffix
      const db = store.getDatabase();
      db.prepare('INSERT INTO events (id, stream_id, message, created_at) VALUES (?, ?, ?, ?)').run(
        'stream-bad:not-a-number',
        'stream-bad',
        JSON.stringify(msg),
        Date.now(),
      );

      // Reopen store to force counter rehydration from DB
      store.close();
      store = new SqliteEventStore({
        path: dbPath,
        ttlCleanupIntervalMs: 0,
        maxEvents: 100,
        ttlMs: 300000,
      });

      // Should recover and start from 1 (isNaN fallback)
      const id = await store.storeEvent('stream-bad', msg);
      expect(id).toBe('stream-bad:1');
    });
  });

  describe('max events eviction', () => {
    it('should evict oldest events when exceeding max', async () => {
      store.close();
      cleanup(dbPath);
      dbPath = tmpDbPath();
      store = new SqliteEventStore({
        path: dbPath,
        ttlCleanupIntervalMs: 0,
        maxEvents: 3,
        ttlMs: 300000,
      });

      const msg = { jsonrpc: '2.0', method: 'test' };
      await store.storeEvent('stream-1', msg);
      await store.storeEvent('stream-1', msg);
      await store.storeEvent('stream-1', msg);
      await store.storeEvent('stream-1', msg); // This should trigger eviction

      expect(store.size).toBeLessThanOrEqual(3);
    });
  });
});
