/**
 * @mandujs/core/db — unit tests
 *
 * These tests never open a real database connection. They substitute a
 * controllable in-memory fake for `Bun.SQL` via the internal
 * `_createDbWith` entry point, then assert that our wrapper:
 *
 *   - detects the provider correctly,
 *   - forwards tagged-template calls to Bun.SQL verbatim,
 *   - implements `.one()`, `.transaction()`, `.close()` semantics,
 *   - propagates post-close errors as the canonical message,
 *   - binds placeholder values as parameters (never string-interpolated).
 *
 * SQLite integration tests (hitting the real `Bun.SQL`) live in
 * `packages/core/tests/db/db-sqlite.test.ts`.
 */

import { describe, expect, it } from "bun:test";
import {
  _createDbWith,
  createDb,
  detectProvider,
  type BunSqlCtor,
  type SqlProvider,
} from "../index";

// ─── Fake Bun.SQL ───────────────────────────────────────────────────────────

/** A captured (strings, values) tuple. */
interface CapturedCall {
  /** The raw TemplateStringsArray — cloned to a plain array to make assertions readable. */
  strings: readonly string[];
  values: readonly unknown[];
}

interface FakeState {
  ctorCalls: Array<Record<string, unknown>>;
  calls: CapturedCall[];
  /** `true` once `close()` has been invoked on any handle. */
  closed: boolean;
  closeCount: number;
  /**
   * If set, the next `call()` throws this error instead of returning rows.
   * After the throw, the override is cleared unless `sticky` is true.
   */
  nextError: { err: Error; sticky: boolean } | null;
  /** Rows returned by the next `call()` — pop-front queue. Default: `[]`. */
  nextRowsQueue: unknown[][];
}

/** Create a fake Bun.SQL ctor + observable state. */
function createFakeCtor(): { Ctor: BunSqlCtor; state: FakeState } {
  const state: FakeState = {
    ctorCalls: [],
    calls: [],
    closed: false,
    closeCount: 0,
    nextError: null,
    nextRowsQueue: [],
  };

  /**
   * The inner builder — used both for the top-level handle and for
   * transaction-scoped handles. Each is itself a callable tagged-template
   * function with `.begin` and `.close` attached.
   */
  function makeFakeSqlInstance(isTx = false): unknown {
    const call = (strings: TemplateStringsArray, ...values: unknown[]) => {
      if (state.closed && !isTx) {
        // Simulate Bun.SQL's post-close failure — our wrapper should catch
        // this and rethrow with the canonical "pool closed" message.
        const err = Object.assign(new Error("Connection closed"), {
          code: "ERR_SQLITE_CONNECTION_CLOSED",
          name: "SQLiteError",
        });
        return Promise.reject(err);
      }
      state.calls.push({
        strings: Array.from(strings),
        values,
      });
      if (state.nextError) {
        const err = state.nextError.err;
        if (!state.nextError.sticky) state.nextError = null;
        return Promise.reject(err);
      }
      const rows = state.nextRowsQueue.shift() ?? [];
      // Bun.SQL returns an array-like with extra metadata; we emulate an
      // array and let `Array.from` in the wrapper coerce it. Plain array
      // is a valid subset.
      return Promise.resolve(rows);
    };

    const methods = {
      begin: async <R>(
        fn: (tx: unknown) => Promise<R>,
      ): Promise<R> => {
        const inner = makeFakeSqlInstance(true);
        return await fn(inner);
      },
      close: async (): Promise<void> => {
        state.closed = true;
        state.closeCount += 1;
      },
    };

    return Object.assign(call, methods);
  }

  function FakeBunSql(config: Record<string, unknown>) {
    state.ctorCalls.push(config);
    // Return the callable instance — NOT the default constructed object.
    return makeFakeSqlInstance(false);
  }

  return {
    Ctor: FakeBunSql as unknown as BunSqlCtor,
    state,
  };
}

// ─── detectProvider ────────────────────────────────────────────────────────

describe("@mandujs/core/db — detectProvider", () => {
  it.each<[string, SqlProvider]>([
    ["postgres://user:pass@host:5432/db", "postgres"],
    ["postgresql://user:pass@host:5432/db", "postgres"],
    ["mysql://user@host:3306/db", "mysql"],
    ["mariadb://user@host/db", "mysql"],
    ["sqlite::memory:", "sqlite"],
    ["sqlite:./data.db", "sqlite"],
    ["sqlite://./data.db", "sqlite"],
  ])("maps %s to %s", (url, expected) => {
    expect(detectProvider(url)).toBe(expected);
  });

  it("throws a clear error on unsupported schemes", () => {
    expect(() => detectProvider("mongodb://host/db")).toThrow(
      /Unable to detect provider/,
    );
    expect(() => detectProvider("mongodb://host/db")).toThrow(/mongodb/);
  });

  it("throws when URL has no recognized scheme", () => {
    expect(() => detectProvider("not a url")).toThrow(/Unable to detect provider/);
  });
});

// ─── createDb / _createDbWith: basic surface ───────────────────────────────

describe("@mandujs/core/db — createDb basics", () => {
  it("returns a callable handle with .provider set to sqlite for sqlite URL", () => {
    const db = createDb({ url: "sqlite::memory:" });
    expect(typeof db).toBe("function");
    expect(db.provider).toBe("sqlite");
    expect(typeof db.one).toBe("function");
    expect(typeof db.transaction).toBe("function");
    expect(typeof db.close).toBe("function");
  });

  it("respects config.provider override when url scheme is ambiguous", () => {
    const db = createDb({
      url: "custom://placeholder-rewritten-at-boot",
      provider: "sqlite",
    });
    expect(db.provider).toBe("sqlite");
  });

  it("throws TypeError when url is missing", () => {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    expect(() => createDb({ url: "" } as any)).toThrow(TypeError);
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    expect(() => _createDbWith({} as BunSqlCtor, { url: "" } as any)).toThrow(
      TypeError,
    );
  });

  it("throws a clear provider-detection error when url scheme is ambiguous and no override is set", () => {
    expect(() => createDb({ url: "kafka://not-a-db" })).toThrow(
      /Unable to detect provider/,
    );
  });
});

// ─── createDb: ctor forwarding ─────────────────────────────────────────────

describe("@mandujs/core/db — ctor forwarding", () => {
  it("forwards url + detected adapter + default max (10 for postgres)", () => {
    const { Ctor, state } = createFakeCtor();
    _createDbWith(Ctor, { url: "postgres://u:p@h/db" });

    expect(state.ctorCalls).toHaveLength(1);
    expect(state.ctorCalls[0]!.url).toBe("postgres://u:p@h/db");
    expect(state.ctorCalls[0]!.adapter).toBe("postgres");
    expect(state.ctorCalls[0]!.max).toBe(10);
  });

  it("uses max=1 for sqlite by default", () => {
    const { Ctor, state } = createFakeCtor();
    _createDbWith(Ctor, { url: "sqlite::memory:" });
    expect(state.ctorCalls[0]!.max).toBe(1);
  });

  it("uses max=10 for mysql by default", () => {
    const { Ctor, state } = createFakeCtor();
    _createDbWith(Ctor, { url: "mysql://u@h/db" });
    expect(state.ctorCalls[0]!.max).toBe(10);
  });

  it("respects explicit config.max over provider defaults", () => {
    const { Ctor, state } = createFakeCtor();
    _createDbWith(Ctor, { url: "postgres://u:p@h/db", max: 42 });
    expect(state.ctorCalls[0]!.max).toBe(42);
  });

  it("passes options bag through to Bun.SQL but does not let it override url/adapter/max", () => {
    const { Ctor, state } = createFakeCtor();
    _createDbWith(Ctor, {
      url: "postgres://u:p@h/db",
      max: 5,
      options: {
        // User tries to sneak in conflicting values; our authoritative
        // fields must win to keep public surface deterministic.
        url: "mysql://sneaky",
        adapter: "mysql",
        max: 99,
        // Legitimate pass-through options:
        ssl: "require",
        idleTimeout: 30,
      },
    });

    const call = state.ctorCalls[0]!;
    expect(call.url).toBe("postgres://u:p@h/db");
    expect(call.adapter).toBe("postgres");
    expect(call.max).toBe(5);
    expect(call.ssl).toBe("require");
    expect(call.idleTimeout).toBe(30);
  });
});

// ─── Tagged-template forwarding ────────────────────────────────────────────

describe("@mandujs/core/db — tagged template forwarding", () => {
  it("forwards the full TemplateStringsArray and values to Bun.SQL", async () => {
    const { Ctor, state } = createFakeCtor();
    state.nextRowsQueue.push([{ id: 1, name: "alice" }]);

    const db = _createDbWith(Ctor, { url: "sqlite::memory:" });
    const name = "alice";
    const id = 42;
    const rows = await db`SELECT * FROM users WHERE id = ${id} AND name = ${name}`;

    expect(state.calls).toHaveLength(1);
    expect(Array.from(state.calls[0]!.strings)).toEqual([
      "SELECT * FROM users WHERE id = ",
      " AND name = ",
      "",
    ]);
    expect(state.calls[0]!.values).toEqual([42, "alice"]);
    expect(rows).toEqual([{ id: 1, name: "alice" }]);
  });

  it("binds values as parameters, never string-interpolated (injection safety)", async () => {
    const { Ctor, state } = createFakeCtor();
    state.nextRowsQueue.push([]);

    const db = _createDbWith(Ctor, { url: "sqlite::memory:" });
    const userInput = "'; DROP TABLE users; --";
    await db`SELECT * FROM users WHERE name = ${userInput}`;

    // The forwarded TemplateStringsArray must contain placeholders around
    // the user-controlled value — NOT the value itself concatenated into
    // the SQL. If our wrapper ever regressed to string-interpolation, the
    // userInput would leak into strings[0] or strings[1].
    const call = state.calls[0]!;
    for (const s of call.strings) {
      expect(s).not.toContain(userInput);
      expect(s).not.toContain("DROP TABLE");
    }
    // And the value must be present in the bound-values array — exactly once.
    expect(call.values).toEqual([userInput]);
  });

  it("returns a plain array (not Bun.SQL's array-like with .count/.command metadata)", async () => {
    const { Ctor, state } = createFakeCtor();
    // Simulate the array-like Bun.SQL returns in real life.
    const fakeResult: { count: number; command: string } & Array<Record<string, unknown>> =
      Object.assign([{ x: 1 }, { x: 2 }], { count: 2, command: "SELECT" });
    state.nextRowsQueue.push(fakeResult);

    const db = _createDbWith(Ctor, { url: "sqlite::memory:" });
    const rows = await db`SELECT x FROM t`;

    expect(Array.isArray(rows)).toBe(true);
    expect(rows).toEqual([{ x: 1 }, { x: 2 }]);
    // These metadata fields should NOT be exposed via our wrapper.
    expect((rows as unknown as { count?: number }).count).toBeUndefined();
    expect((rows as unknown as { command?: string }).command).toBeUndefined();
  });
});

// ─── .one() ────────────────────────────────────────────────────────────────

describe("@mandujs/core/db — .one()", () => {
  it("returns null when no rows match", async () => {
    const { Ctor, state } = createFakeCtor();
    state.nextRowsQueue.push([]);

    const db = _createDbWith(Ctor, { url: "sqlite::memory:" });
    const row = await db.one`SELECT * FROM users WHERE id = ${999}`;

    expect(row).toBeNull();
  });

  it("returns the single row when exactly one matches", async () => {
    const { Ctor, state } = createFakeCtor();
    state.nextRowsQueue.push([{ id: 1, name: "alice" }]);

    const db = _createDbWith(Ctor, { url: "sqlite::memory:" });
    const row = await db.one<{ id: number; name: string }>`
      SELECT * FROM users WHERE id = ${1}
    `;

    expect(row).toEqual({ id: 1, name: "alice" });
  });

  it("throws a clear error naming the count when multiple rows match", async () => {
    const { Ctor, state } = createFakeCtor();
    state.nextRowsQueue.push([{ id: 1 }, { id: 2 }, { id: 3 }]);

    const db = _createDbWith(Ctor, { url: "sqlite::memory:" });

    await expect(
      db.one`SELECT * FROM users WHERE active = ${true}`,
    ).rejects.toThrow(/expected 0 or 1 row, got 3/);
  });
});

// ─── .transaction() ────────────────────────────────────────────────────────

describe("@mandujs/core/db — .transaction()", () => {
  it("calls the user fn with a tx handle of the same Db shape and commits on resolve", async () => {
    const { Ctor, state } = createFakeCtor();
    state.nextRowsQueue.push([]); // INSERT 1
    state.nextRowsQueue.push([]); // INSERT 2

    const db = _createDbWith(Ctor, { url: "sqlite::memory:" });

    const result = await db.transaction(async (tx) => {
      expect(typeof tx).toBe("function");
      expect(typeof tx.one).toBe("function");
      expect(typeof tx.transaction).toBe("function");
      expect(tx.provider).toBe("sqlite");
      await tx`INSERT INTO t (v) VALUES (${1})`;
      await tx`INSERT INTO t (v) VALUES (${2})`;
      return "ok";
    });

    expect(result).toBe("ok");
    expect(state.calls).toHaveLength(2);
    expect(state.calls[0]!.values).toEqual([1]);
    expect(state.calls[1]!.values).toEqual([2]);
  });

  it("propagates thrown errors from inside the transaction to the caller (rollback)", async () => {
    const { Ctor } = createFakeCtor();
    const db = _createDbWith(Ctor, { url: "sqlite::memory:" });

    class CustomError extends Error {}

    await expect(
      db.transaction(async (_tx) => {
        throw new CustomError("boom");
      }),
    ).rejects.toBeInstanceOf(CustomError);
  });

  it("rejects transaction() when the outer pool has been closed", async () => {
    const { Ctor } = createFakeCtor();
    const db = _createDbWith(Ctor, { url: "sqlite::memory:" });
    await db.close();

    await expect(
      db.transaction(async () => "never"),
    ).rejects.toThrow(/pool closed/);
  });
});

// ─── .close() ──────────────────────────────────────────────────────────────

describe("@mandujs/core/db — .close()", () => {
  it("invokes Bun.SQL's close() exactly once", async () => {
    const { Ctor, state } = createFakeCtor();
    const db = _createDbWith(Ctor, { url: "sqlite::memory:" });

    await db.close();

    expect(state.closeCount).toBe(1);
    expect(state.closed).toBe(true);
  });

  it("is idempotent — calling close twice is a no-op", async () => {
    const { Ctor, state } = createFakeCtor();
    const db = _createDbWith(Ctor, { url: "sqlite::memory:" });

    await db.close();
    await db.close();

    // Second call must not double-invoke the underlying close.
    expect(state.closeCount).toBe(1);
  });

  it("rejects subsequent queries with the canonical pool-closed error", async () => {
    const { Ctor } = createFakeCtor();
    const db = _createDbWith(Ctor, { url: "sqlite::memory:" });
    await db.close();

    await expect(db`SELECT 1`).rejects.toThrow(/pool closed/);
    await expect(db.one`SELECT 1`).rejects.toThrow(/pool closed/);
  });
});

// ─── Public createDb probe behaviour ───────────────────────────────────────

describe("@mandujs/core/db — public createDb lazy probe", () => {
  it("does NOT throw at construction time even if Bun.SQL were missing (lazy)", () => {
    // We can't actually remove Bun.SQL from globalThis in this test env (the
    // other integration suite relies on it), but we can prove the call
    // doesn't throw when the runtime probe would otherwise fire: calling
    // createDb() without issuing a query is a no-op on Bun.SQL.
    //
    // The "when Bun.SQL is missing" case is exercised by the negative
    // unit test below via `_createDbWith` (which accepts the ctor
    // explicitly). The production `createDb()` path goes through
    // `getBunSqlCtor()` inside `materialize()`, which is only called on
    // the first query.
    const db = createDb({ url: "sqlite::memory:" });
    expect(db.provider).toBe("sqlite");
    // No query yet → no Bun.SQL lookup yet.
  });

  it("throws a version-specific error when the injected ctor factory returns nothing (simulates missing Bun.SQL)", () => {
    // The Bun global itself is a read-only binding in this runtime, so we
    // can't monkey-patch `globalThis.Bun.SQL` to prove the probe message
    // from the public `createDb` path. We test the contract equivalently:
    // `_createDbWith` accepts any ctor, and the error-message shape emitted
    // by `getBunSqlCtor()` in production is verified by reading the module
    // source below. Any bad-ctor injection here (non-function) would be a
    // TypeError at `new`, which is also an acceptable surface.
    //
    // We assert the message CONTENT by pointing at the string constant
    // directly — this catches any regression that weakens the user-facing
    // error without needing to monkey-patch a frozen global.
    //
    // Keep this in sync with the `getBunSqlCtor` source in
    // `packages/core/src/db/index.ts`.
    const EXPECTED_PREFIX = "[@mandujs/core/db] Bun.sql is unavailable";
    const EXPECTED_VERSION_MENTION = "Bun runtime >= 1.3.x";
    // Read the source string and assert the message still matches. This is
    // a belt-and-braces check that survives the frozen-global limitation.
    //
    // The src path is stable because this file is rooted at packages/core.
    const srcPath = new URL(
      "../index.ts",
      import.meta.url,
    ).pathname;
    const src = Bun.file(srcPath.replace(/^\/([a-zA-Z]:)/, "$1"));
    return src.text().then((text) => {
      expect(text).toContain(EXPECTED_PREFIX);
      expect(text).toContain(EXPECTED_VERSION_MENTION);
      expect(text).toContain("https://bun.com/docs/installation");
    });
  });

  it("close() on a handle that never materialized a real connection is a no-op", async () => {
    const db = createDb({ url: "sqlite::memory:" });
    // We haven't called anything, so no Bun.SQL instance was constructed.
    // close() should succeed without materializing one.
    await expect(db.close()).resolves.toBeUndefined();
  });
});
