/**
 * Query ergonomics built on the `Db` client: offset pagination and a batched
 * relation loader (avoids N+1). Both are dialect-aware — placeholders follow the
 * driver's style (`$N` for Postgres, `?` for SQLite/MySQL).
 */

import type { Db, Row } from "./driver.ts";
import type { Model } from "./schema.ts";
import type { Dialect } from "./sql.ts";

const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;

function placeholder(dialect: Dialect, index: number): string {
  return dialect === "postgres" ? `$${index}` : "?";
}

function assertIdent(name: string, what: string): void {
  if (!IDENT_RE.test(name)) throw new Error(`Unsafe ${what}: ${JSON.stringify(name)}`);
}

// --- Pagination ------------------------------------------------------------

export interface PageOptions {
  page?: number;
  perPage?: number;
  /** Upper bound on perPage. Default 100. */
  maxPerPage?: number;
}

export interface Paginated<T> {
  items: T[];
  page: number;
  perPage: number;
  total: number;
  totalPages: number;
  hasNext: boolean;
  hasPrev: boolean;
}

/**
 * Paginate a SELECT. Pass the base query WITHOUT a LIMIT; the helper appends the
 * page window and derives the total with a COUNT over the same query.
 *
 *   await paginate(db, { sql: "SELECT * FROM users ORDER BY name", model: users }, { page: 2 })
 */
export async function paginate<T = Row>(
  db: Db,
  query: { sql: string; params?: unknown[]; countSql?: string; model?: Model<T> },
  opts: PageOptions = {},
): Promise<Paginated<T>> {
  const dialect = db.driver.dialect;
  const params = query.params ?? [];
  const maxPerPage = opts.maxPerPage ?? 100;
  const page = Math.max(1, Math.floor(opts.page ?? 1));
  const perPage = Math.min(maxPerPage, Math.max(1, Math.floor(opts.perPage ?? 20)));
  const offset = (page - 1) * perPage;

  const countSql = query.countSql ?? `SELECT COUNT(*) AS n FROM (${query.sql}) AS __wire_sub`;
  const countRow = await db.one<{ n: number | string }>(countSql, params);
  const total = Number(countRow?.n ?? 0);

  const limitPh = placeholder(dialect, params.length + 1);
  const offsetPh = placeholder(dialect, params.length + 2);
  const items = await db.all<T>(
    `${query.sql} LIMIT ${limitPh} OFFSET ${offsetPh}`,
    [...params, perPage, offset],
    query.model,
  );

  const totalPages = perPage > 0 ? Math.ceil(total / perPage) : 0;
  return {
    items,
    page,
    perPage,
    total,
    totalPages,
    hasNext: page < totalPages,
    hasPrev: page > 1,
  };
}

// --- Relations (batched, no N+1) -------------------------------------------

export interface RelationOptions<C> {
  /** Parent field whose value matches the child's foreign key. Default "id". */
  localKey?: string;
  /** Child table to load from. */
  table: string;
  /** Child column that references the parent. */
  foreignKey: string;
  /** Property name to attach on each parent. */
  as: string;
  /** true → attach a single child (belongsTo); false → an array (hasMany). */
  single?: boolean;
  /** Map child rows through a model. */
  model?: Model<C>;
}

/**
 * Load a relation for a set of parent rows in ONE query and attach it to each
 * parent (no N+1). Returns the same parents, each with `opts.as` populated.
 *
 *   await loadRelated(db, users, { table: "posts", foreignKey: "userId", as: "posts" })
 */
export async function loadRelated<P extends Row, C extends Row = Row>(
  db: Db,
  parents: P[],
  opts: RelationOptions<C>,
): Promise<(P & Record<string, C | C[] | null>)[]> {
  const localKey = opts.localKey ?? "id";
  assertIdent(opts.table, "table name");
  assertIdent(opts.foreignKey, "foreign key");

  const results = parents as (P & Record<string, C | C[] | null>)[];
  if (parents.length === 0) return results;

  const keys = [...new Set(parents.map((p) => p[localKey]).filter((k) => k != null))];
  if (keys.length === 0) {
    for (const parent of results)
      (parent as Record<string, unknown>)[opts.as] = opts.single ? null : [];
    return results;
  }

  const dialect = db.driver.dialect;
  const placeholders = keys.map((_, i) => placeholder(dialect, i + 1)).join(", ");
  const children = await db.all<C>(
    `SELECT * FROM ${opts.table} WHERE ${opts.foreignKey} IN (${placeholders})`,
    keys,
    opts.model,
  );

  const grouped = new Map<unknown, C[]>();
  for (const child of children) {
    const fk = (child as Row)[opts.foreignKey];
    const bucket = grouped.get(fk);
    if (bucket) bucket.push(child);
    else grouped.set(fk, [child]);
  }

  for (const parent of results) {
    const matches = grouped.get(parent[localKey]) ?? [];
    (parent as Record<string, unknown>)[opts.as] = opts.single ? (matches[0] ?? null) : matches;
  }
  return results;
}
