import { describe, it, expect, beforeEach, vi } from 'vitest';
import { createPgliteClient } from '@platform-modules/db/pglite';
import type { Querier } from '@platform-modules/db';
import {
  mediaSchema,
  mediaTableSql,
  insertMedia,
  listMedia,
  getMedia,
  deleteMedia,
  usedBytes,
  type MediaSchema,
} from './media.js';

let db: Querier<MediaSchema>;

beforeEach(async () => {
  const client = createPgliteClient({ schema: mediaSchema });
  for (const stmt of mediaTableSql().split(';').map((s) => s.trim()).filter(Boolean)) {
    await client.execute(stmt);
  }
  db = client as unknown as Querier<MediaSchema>;
});

describe('mediaTableSql', () => {
  it('emits idempotent CREATE TABLE with exact media_assets columns', () => {
    const ddl = mediaTableSql();
    expect(ddl).toContain('CREATE TABLE IF NOT EXISTS media_assets');
    for (const col of ['key', 'url', 'content_type', 'size', 'width', 'height', 'uploader_id', 'created_at']) {
      expect(ddl).toContain(col);
    }
    expect(ddl).toContain('size bigint');
    expect(ddl).toMatch(/created_at timestamptz\(3\)[^;]*default now\(\)/i);
  });
});

describe('media store', () => {
  it('insert → get round-trips and usedBytes sums sizes', async () => {
    await insertMedia(db, { key: 'media/a.png', url: 'u', contentType: 'image/png', size: 100, uploaderId: 'u1' });
    await insertMedia(db, { key: 'media/b.png', url: 'u', contentType: 'image/png', size: 50, uploaderId: 'u1' });
    expect((await getMedia(db, 'media/a.png'))?.size).toBe(100);
    expect(await usedBytes(db)).toBe(150);
  });

  it('deleteMedia removes the row and returns it; second delete is idempotent (null)', async () => {
    await insertMedia(db, { key: 'media/a.png', url: 'u', contentType: 'image/png', size: 1, uploaderId: 'owner' });
    expect(await deleteMedia(db, 'media/a.png')).not.toBeNull();
    expect(await getMedia(db, 'media/a.png')).toBeNull();
    expect(await deleteMedia(db, 'media/a.png')).toBeNull(); // absent → null, no throw
  });

  it('listMedia keyset-paginates newest-first', async () => {
    for (let i = 0; i < 3; i++) {
      await insertMedia(db, { key: `media/${i}`, url: 'u', contentType: 'image/png', size: 1, uploaderId: 'u1' });
    }
    const p1 = await listMedia(db, { limit: 2 });
    expect(p1.rows).toHaveLength(2);
    expect(p1.cursor).toBeTruthy();
    const p2 = await listMedia(db, { limit: 2, cursor: p1.cursor });
    expect(p2.rows).toHaveLength(1);
    expect(p2.cursor).toBeUndefined();
  });

  it('listMedia skips no row when a page boundary falls inside one millisecond', async () => {
    // Pinned timestamp, not a tight insert loop: `created_at` is timestamptz(3), so real inserts collide
    // only by luck and the boundary bug reproduces intermittently. Forcing one shared instant makes the
    // page-2 assertion deterministic.
    const at = '2026-08-12T00:00:00.000Z';
    for (const k of ['media/a', 'media/b', 'media/c']) {
      await (db as unknown as { execute: (s: string) => Promise<unknown> }).execute(
        `INSERT INTO media_assets (key, url, content_type, size, uploader_id, created_at)
         VALUES ('${k}', 'u', 'image/png', 1, 'u1', '${at}')`,
      );
    }
    const p1 = await listMedia(db, { limit: 2 });
    expect(p1.rows.map((r) => r.key)).toEqual(['media/c', 'media/b']);
    expect(p1.cursor).toBeTruthy();

    const p2 = await listMedia(db, { limit: 2, cursor: p1.cursor });
    expect(p2.rows.map((r) => r.key)).toEqual(['media/a']);
    expect(p2.cursor).toBeUndefined();
  });

  it('listMedia pages correctly when a key contains the cursor separator', async () => {
    // The cursor is `<iso>|<key>` and a key may legitimately contain `|`, so the decoder must split on the
    // FIRST separator. Splitting on the last folds part of the key into the timestamp → Invalid Date → the
    // page silently returns nothing.
    const at = '2026-08-12T00:00:00.000Z';
    for (const k of ['media/a|1', 'media/b|2']) {
      await (db as unknown as { execute: (s: string) => Promise<unknown> }).execute(
        `INSERT INTO media_assets (key, url, content_type, size, uploader_id, created_at)
         VALUES ('${k}', 'u', 'image/png', 1, 'u1', '${at}')`,
      );
    }
    const p1 = await listMedia(db, { limit: 1 });
    expect(p1.rows.map((r) => r.key)).toEqual(['media/b|2']);
    const p2 = await listMedia(db, { limit: 1, cursor: p1.cursor });
    expect(p2.rows.map((r) => r.key)).toEqual(['media/a|1']);
  });
});


import type { D1Client } from '@platform-modules/db/sqlite/d1';
import { reserveAndInsertMediaD1 } from './media.js';

describe('D1 media quota reservation', () => {
  function fakeD1(resultRows: readonly unknown[]) {
    const prepared = { marker: 'prepared' };
    const prepare = vi.fn(() => prepared);
    const batch = vi.fn().mockResolvedValue([resultRows]);
    return {
      db: { prepare, batch } as unknown as D1Client<MediaSchema>,
      prepare,
      batch,
      prepared,
    };
  }

  it('submits one conditional insert through exactly one D1 batch', async () => {
    const fx = fakeD1([{ key: 'media/a', url: 'a', contentType: 'image/png', size: 60, width: null, height: null, uploaderId: 'u1', createdAt: '2026-08-22T00:00:00.000Z' }]);
    const row = await reserveAndInsertMediaD1(fx.db, { key: 'media/a', url: 'a', contentType: 'image/png', size: 60, uploaderId: 'u1' }, 100);
    expect(fx.prepare).toHaveBeenCalledTimes(1);
    expect(fx.batch).toHaveBeenCalledTimes(1);
    expect(fx.batch).toHaveBeenCalledWith([fx.prepared]);
    expect(row).toMatchObject({ key: 'media/a', size: 60, uploaderId: 'u1' });
    expect(row.createdAt).toBeInstanceOf(Date);
  });

  it('fails closed when the conditional insert returns no row', async () => {
    const fx = fakeD1([]);
    await expect(reserveAndInsertMediaD1(fx.db, { key: 'media/b', url: 'b', contentType: 'image/png', size: 50, uploaderId: 'u1' }, 100)).rejects.toMatchObject({ name: 'QuotaExceededError' });
    expect(fx.prepare).toHaveBeenCalledTimes(1);
    expect(fx.batch).toHaveBeenCalledTimes(1);
  });
});
