import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { sqlQueryForEngine, executeRawJsonb } from '../src/core/sql-query.ts';

let engine: PGLiteEngine;

beforeAll(async () => {
  engine = new PGLiteEngine();
  await engine.connect({});
}, 30_000);

afterAll(async () => {
  if (engine) await engine.disconnect();
});

describe('sqlQueryForEngine', () => {
  test('runs parameterized tagged-template SQL against PGLite', async () => {
    const sql = sqlQueryForEngine(engine);
    const rows = await sql`SELECT ${'pglite'}::text AS engine, ${3}::int AS count`;
    expect(rows).toEqual([{ engine: 'pglite', count: 3 }]);
  });

  test('rejects postgres.js-style fragment / object values explicitly', async () => {
    const sql = sqlQueryForEngine(engine);
    await expect(
      sql`SELECT ${(Promise.resolve([]) as any)}::text AS bad`
    ).rejects.toThrow(/only supports scalar bind values/);
    await expect(
      sql`SELECT ${(['read', 'write'] as any)}::text[] AS bad`
    ).rejects.toThrow(/only supports scalar bind values/);
    await expect(
      sql`SELECT ${({ takes_holders: ['world'] } as any)}::jsonb AS bad`
    ).rejects.toThrow(/only supports scalar bind values/);
  });
});

describe('executeRawJsonb (D1 wave / v0.31)', () => {
  test('round-trips an object as JSONB on PGLite (jsonb_typeof = object, ->>  reads value)', async () => {
    // Verifies the cross-engine JSONB write helper produces a real Postgres
    // JSONB object — not a quoted JSON string. Codex's plan-review #9 said
    // "use the actual JSONB contract, not string-grep for backslash-quote",
    // and that's what this asserts: jsonb_typeof + ->>.
    const tableName = `t_jsonb_${Math.random().toString(36).slice(2, 10)}`;
    await engine.executeRaw(`CREATE TEMP TABLE ${tableName} (j jsonb)`);
    try {
      await executeRawJsonb(
        engine,
        `INSERT INTO ${tableName} (j) VALUES ($1::jsonb)`,
        [],
        [{ k: 'v', n: 42 }],
      );
      const rows = await engine.executeRaw<{ kind: string; k: string; n: number }>(
        `SELECT jsonb_typeof(j) AS kind, j->>'k' AS k, (j->>'n')::int AS n FROM ${tableName}`,
      );
      expect(rows).toHaveLength(1);
      expect(rows[0].kind).toBe('object');
      expect(rows[0].k).toBe('v');
      expect(rows[0].n).toBe(42);
    } finally {
      await engine.executeRaw(`DROP TABLE ${tableName}`);
    }
  });

  test('takes-holders shape: object preserved, ->> returns the encoded array, NOT a double-encoded string (v0.12.0 regression guard)', async () => {
    // The v0.12.0 silent-data-loss bug stored `${JSON.stringify(perms)}::jsonb`
    // as a JSON string-of-an-object, so `permissions->>'takes_holders'`
    // would return a string with backslashes instead of the array.
    // Post-fix: real JSONB object, ->> on the array key returns the
    // pretty-printed JSON of the array (because jsonb -> array -> ->> is
    // the array as a text), and jsonb_typeof on the parent stays 'object'.
    const tableName = `t_perms_${Math.random().toString(36).slice(2, 10)}`;
    await engine.executeRaw(
      `CREATE TEMP TABLE ${tableName} (id serial PRIMARY KEY, permissions jsonb)`,
    );
    try {
      const perms = { takes_holders: ['world', 'garry'] };
      await executeRawJsonb(
        engine,
        `INSERT INTO ${tableName} (permissions) VALUES ($1::jsonb)`,
        [],
        [perms],
      );
      const rows = await engine.executeRaw<{
        outer_kind: string;
        holders_kind: string;
        first_holder: string;
        text_form: string;
      }>(
        `SELECT
           jsonb_typeof(permissions) AS outer_kind,
           jsonb_typeof(permissions->'takes_holders') AS holders_kind,
           permissions->'takes_holders'->>0 AS first_holder,
           permissions::text AS text_form
         FROM ${tableName}`,
      );
      expect(rows).toHaveLength(1);
      // The parent JSONB is an object; the takes_holders child is an array.
      // Pre-fix this would be 'string' / 'string' (string-of-object).
      expect(rows[0].outer_kind).toBe('object');
      expect(rows[0].holders_kind).toBe('array');
      expect(rows[0].first_holder).toBe('world');
      // Defense in depth: the text representation must NOT contain
      // backslash-quote sequences, which is what double-encoded JSONB
      // looked like in the v0.12.0 incident (e.g. `"{\"takes_holders\":...}"`).
      expect(rows[0].text_form).not.toContain('\\"');
      // And the text representation should look like a normal JSON object
      // — starts with `{`, not `"{`.
      expect(rows[0].text_form.startsWith('{')).toBe(true);
    } finally {
      await engine.executeRaw(`DROP TABLE ${tableName}`);
    }
  });

  test('null jsonb value stores NULL, not the string "null"', async () => {
    // serve-http.ts sometimes inserts NULL params (e.g. tools/list, scope-
    // rejected paths). The helper must accept null without trying to
    // encode it as the string "null" or rejecting it.
    const tableName = `t_jnull_${Math.random().toString(36).slice(2, 10)}`;
    await engine.executeRaw(`CREATE TEMP TABLE ${tableName} (j jsonb)`);
    try {
      await executeRawJsonb(
        engine,
        `INSERT INTO ${tableName} (j) VALUES ($1::jsonb)`,
        [],
        [null],
      );
      const rows = await engine.executeRaw<{ kind: string | null; is_null: boolean }>(
        `SELECT jsonb_typeof(j) AS kind, (j IS NULL) AS is_null FROM ${tableName}`,
      );
      expect(rows[0].is_null).toBe(true);
      // jsonb_typeof on SQL NULL returns NULL.
      expect(rows[0].kind).toBeNull();
    } finally {
      await engine.executeRaw(`DROP TABLE ${tableName}`);
    }
  });

  test('mixes scalar params and jsonb params in positional order', async () => {
    // Real call shape: scalars first ($1..$N), JSONB params next
    // ($N+1..$N+M). Mirrors the auth.ts `INSERT INTO access_tokens
    // (name, token_hash, permissions) VALUES ($1, $2, $3::jsonb)` pattern.
    const tableName = `t_mix_${Math.random().toString(36).slice(2, 10)}`;
    await engine.executeRaw(
      `CREATE TEMP TABLE ${tableName} (name text, weight int, payload jsonb)`,
    );
    try {
      await executeRawJsonb(
        engine,
        `INSERT INTO ${tableName} (name, weight, payload) VALUES ($1, $2, $3::jsonb)`,
        ['alice', 7],
        [{ tags: ['a', 'b'] }],
      );
      const rows = await engine.executeRaw<{ name: string; weight: number; first_tag: string }>(
        `SELECT name, weight, payload->'tags'->>0 AS first_tag FROM ${tableName}`,
      );
      expect(rows).toEqual([{ name: 'alice', weight: 7, first_tag: 'a' }]);
    } finally {
      await engine.executeRaw(`DROP TABLE ${tableName}`);
    }
  });

  test('rejects non-scalar values in scalarParams (defense in depth)', async () => {
    // The scalar position validator should fire even when a misuse passes
    // an object via scalarParams instead of jsonbParams. Catches the
    // cross-up-the-positions footgun loud at the helper boundary.
    await expect(
      executeRawJsonb(
        engine,
        `SELECT $1::text AS bad`,
        [{ object: 'in scalar position' } as any],
        [],
      ),
    ).rejects.toThrow(/only supports scalar bind values/);
  });

  test('rejects a top-level array jsonb param (gbrain#1861 P2a guard)', async () => {
    // A bare JS array bound to a $N::jsonb position can serialize as a Postgres
    // array literal (not jsonb) through postgres.js, re-entering the
    // "malformed array literal" class #1861 escaped. The helper must reject it
    // and steer callers to the { rows: [...] } wrapper. Objects + null are fine
    // (covered by the tests above).
    await expect(
      executeRawJsonb(
        engine,
        `INSERT INTO nope (j) VALUES ($1::jsonb)`,
        [],
        [[{ a: 1 }, { a: 2 }] as any],
      ),
    ).rejects.toThrow(/top-level array jsonb param/);
  });
});
