/**
 * Raw queries against the `cell` table.
 *
 * Convention: `deps: QueryDeps` first, no audit side effects, mutations
 * return the affected row (or `null` for not-found).
 *
 * `cell.refs` is auto-extracted from `data` on every create and update via
 * `fact_hash_extract_refs` (depth-first walk for `blake3:`-prefixed strings). Callers
 * never pass `refs` directly — the column is a derived projection of
 * `data` for cells-by-fact discovery, mirroring what a fact store does for
 * JSON facts.
 *
 * Soft delete via `deleted_at`. All `get` / `list` queries exclude
 * tombstones by default; `include_deleted: true` opts in for admin /
 * audit views.
 *
 * `path` uniqueness is global, enforced by `idx_cell_path_unique` (partial
 * on active rows). Path reuse after soft delete falls out of the partial
 * index — queries do not need special handling.
 *
 * @module
 */
import { fact_hash_extract_refs } from '@fuzdev/fuz_util/fact_hash.js';
import { assert_row } from './assert_row.js';
/**
 * SQL fragment for the `grant_count` projection — correlated subquery
 * against `cell_grant`. Inlined in every cell-row SELECT / RETURNING so
 * `CellRow` carries the count uniformly. Pass the cell alias used in
 * the outer query (`'cell'` for table-name references, `'c'` for the
 * aliased form in `query_cell_list`).
 *
 * `::int` narrows from `bigint` to `int` so the JS row hydrates as a
 * `number` rather than a bigint primitive — counts on a single cell are
 * trivially within int32.
 */
const grant_count_projection = (cell_alias) => `(SELECT COUNT(*)::int FROM cell_grant WHERE cell_id = ${cell_alias}.id) AS grant_count`;
/**
 * Insert a cell row, deriving `refs` from `data`.
 *
 * `updated_by` is left NULL on insert — same convention as `updated_at`
 * (NULL until first update). The "last modifier" stamp is meaningful only
 * after a real edit; copying the creator's id into `updated_by` at create
 * time would make a no-op update by a different actor look authored by
 * the creator.
 *
 * @param deps - query deps
 * @param input - data, optional visibility, path, and ownership
 * @returns the inserted row
 * @mutates `cell` - inserts one row
 */
export const query_cell_create = async (deps, input) => {
    const refs = derive_refs(input.data);
    const row = await deps.db.query_one(`INSERT INTO cell
		   (data, visibility, path, refs, created_by)
		 VALUES ($1::jsonb, COALESCE($2::cell_visibility, 'private'::cell_visibility), $3, $4::text[], $5)
		 RETURNING *, ${grant_count_projection('cell')}`, [
        JSON.stringify(input.data),
        input.visibility ?? null,
        input.path ?? null,
        refs,
        input.created_by ?? null,
    ]);
    return assert_row(row, 'INSERT INTO cell');
};
/**
 * Fetch a cell by id. Excludes soft-deleted rows by default.
 *
 * @param deps - query deps
 * @param id - cell id
 * @param options - `include_deleted: true` returns tombstones
 * @returns the row or `null` when not found (or soft-deleted and not requested)
 */
export const query_cell_get = async (deps, id, options) => {
    const include_deleted = options?.include_deleted === true;
    const row = await deps.db.query_one(`SELECT *, ${grant_count_projection('cell')}
		 FROM cell
		 WHERE id = $1
		   AND ($2::bool OR deleted_at IS NULL)`, [id, include_deleted]);
    return row ?? null;
};
/**
 * Fetch a cell by `path`. Excludes soft-deleted rows; the global partial
 * unique index on `path WHERE deleted_at IS NULL` guarantees at most one
 * result.
 *
 * @param deps - query deps
 * @param path - the named lookup alias (e.g. `/map/main`)
 * @returns the row or `null` when not found
 */
export const query_cell_get_by_path = async (deps, path) => {
    const row = await deps.db.query_one(`SELECT *, ${grant_count_projection('cell')}
		 FROM cell
		 WHERE path = $1 AND deleted_at IS NULL`, [path]);
    return row ?? null;
};
/**
 * Bulk-load active cell rows by id, **no visibility filter applied**. Used
 * by the strict relation-read filter (`auth/cell_relation_visibility.ts`'s
 * `filter_visible_target_ids`), which runs `can_view_cell` per row in
 * memory rather than in SQL. Soft-deleted rows are excluded so relations
 * to tombstones never surface.
 *
 * @param deps - query deps
 * @param ids - cell ids to load (duplicates are harmless)
 * @returns active rows in arbitrary order (caller indexes by `id`)
 */
export const query_cell_load_many = async (deps, ids) => {
    if (ids.length === 0)
        return [];
    return deps.db.query(`SELECT *, ${grant_count_projection('cell')}
		 FROM cell
		 WHERE id = ANY($1::uuid[]) AND deleted_at IS NULL`, [ids]);
};
/**
 * Update a cell. Fields left `undefined` in the patch keep their existing
 * value; explicit `null` writes `NULL`. `refs` is re-derived from `data`
 * whenever the patch updates `data`. `updated_at` is bumped to `NOW()`
 * on every successful update.
 *
 * @param deps - query deps
 * @param id - cell id
 * @param patch - subset of mutable fields
 * @returns the updated row, or `null` when no row matched (already deleted
 *   or never existed)
 * @mutates `cell` - updates one row
 */
export const query_cell_update = async (deps, id, patch) => {
    const data_provided = patch.data !== undefined;
    const refs = data_provided ? derive_refs(patch.data) : null;
    const visibility_provided = patch.visibility !== undefined;
    const row = await deps.db.query_one(`UPDATE cell SET
		   data       = CASE WHEN $2::bool THEN $3::jsonb ELSE data END,
		   refs       = CASE WHEN $2::bool THEN $4::text[] ELSE refs END,
		   path       = CASE WHEN $5::bool THEN $6 ELSE path END,
		   updated_by = CASE WHEN $7::bool THEN $8 ELSE updated_by END,
		   visibility = CASE WHEN $9::bool THEN $10::cell_visibility ELSE visibility END,
		   updated_at = NOW()
		 WHERE id = $1 AND deleted_at IS NULL
		 RETURNING *, ${grant_count_projection('cell')}`, [
        id,
        data_provided,
        data_provided ? JSON.stringify(patch.data) : null,
        refs,
        patch.path !== undefined,
        patch.path ?? null,
        patch.updated_by !== undefined,
        patch.updated_by ?? null,
        visibility_provided,
        patch.visibility ?? null,
    ]);
    return row ?? null;
};
/**
 * Soft-delete a cell. Sets `deleted_at = NOW()`, `updated_at = NOW()`,
 * and `updated_by = options.deleted_by` (or `NULL`). No-op when the row
 * is already deleted.
 *
 * @param deps - query deps
 * @param id - cell id
 * @param options - `deleted_by` records who triggered the delete
 * @returns `true` when a row was soft-deleted, `false` when no active row matched
 * @mutates `cell` - sets `deleted_at` on one row
 */
export const query_cell_delete = async (deps, id, options) => {
    const row = await deps.db.query_one(`UPDATE cell
		 SET deleted_at = NOW(),
		     updated_at = NOW(),
		     updated_by = $2
		 WHERE id = $1 AND deleted_at IS NULL
		 RETURNING id`, [id, options?.deleted_by ?? null]);
    return row !== undefined;
};
/**
 * List cells whose `data.kind` matches the given value, newest first.
 * Uses the `idx_cell_data` GIN index (`data @> ...`).
 *
 * @param deps - query deps
 * @param kind - `data.kind` value to match (e.g. `'collection'`, `'entry'`)
 * @param options - pagination
 * @returns matching active rows
 */
export const query_cell_list_by_data_kind = async (deps, kind, options) => deps.db.query(`SELECT *, ${grant_count_projection('cell')}
		 FROM cell
		 WHERE data @> $1::jsonb
		   AND deleted_at IS NULL
		 ORDER BY created_at DESC
		 LIMIT $2 OFFSET $3`, [JSON.stringify({ kind }), options?.limit ?? null, options?.offset ?? 0]);
/**
 * List active cells created by an actor, newest first. Backed by the
 * `idx_cell_created_by` partial index.
 *
 * @param deps - query deps
 * @param actor_id - the creator's actor id
 * @param options - pagination
 * @returns matching active rows
 */
export const query_cell_list_by_creator = async (deps, actor_id, options) => deps.db.query(`SELECT *, ${grant_count_projection('cell')}
		 FROM cell
		 WHERE created_by = $1 AND deleted_at IS NULL
		 ORDER BY created_at DESC
		 LIMIT $2 OFFSET $3`, [actor_id, options?.limit ?? null, options?.offset ?? 0]);
/**
 * Filterable list query for the generic `cell_list` RPC.
 *
 * Takes a flat filter shape (single optional clause per dimension; the
 * `cell_list` API explicitly does NOT support OR'd alternatives within a
 * dimension — keep it simple) plus an optional viewer-aware visibility
 * predicate.
 *
 * The visibility predicate mirrors `can_view_cell` in SQL form:
 *
 * ```
 * (viewer_is_admin
 *  OR cell.visibility = 'public'
 *  OR (viewer_actor_id IS NOT NULL AND created_by = viewer_actor_id)
 *  OR (viewer_actor_id IS NOT NULL AND <grant admits caller>))
 * ```
 *
 * The grants branch closes parity with `can_view_cell`: a SQL `EXISTS`
 * over `cell_grant`, parameterized by the caller's `actor_id` and the
 * parallel `(role[], scope_id[])` projection of `auth.role_grants`. The
 * caller's role_grants are materialized once via a `caller_role_grants`
 * CTE so the role-grant `unnest` isn't re-scanned per outer row. Empty
 * role_grant arrays are fine: the CTE yields zero rows, the inner EXISTS
 * returns false, and the actor-grant branch still fires for actor-shaped
 * grants.
 *
 * `shared_with_caller_only: true` (`shared_with: 'me'` at the wire layer)
 * takes a **different SQL shape**: instead of layering an extra
 * conjunction on the cell-driven scan, it semi-joins through
 * `cell_grant`, letting the planner drive from the (typically tiny)
 * admitted-grant set via `idx_cell_grant_actor` /
 * `idx_cell_grant_role_scope` rather than scanning every cell row. For a
 * sharee with N grants over a table of M cells, the cost drops from
 * O(M) to O(N + matched-cells). Owner-is-implicit (a cell's owner never
 * appears as a grant principal) means the grants branch is itself
 * owner-excluding, but the explicit `created_by IS DISTINCT FROM caller`
 * guards against any future deviation. The shared_with branch does NOT
 * bypass for admin: an admin asking "what's shared with me" wants their
 * own grant footprint, not every cell.
 *
 * Soft-deleted rows are excluded by default; opt-in via `include_deleted`.
 *
 * @param deps - query deps
 * @param params - filter + visibility + ordering + pagination
 * @returns matching rows, ordered per `order_by` / `order_direction`
 */
export const query_cell_list = async (deps, params) => {
    const order_column = params.order_by === 'updated_at' ? 'updated_at' : 'created_at';
    const order_direction = params.order_direction === 'asc' ? 'ASC' : 'DESC';
    // Caller's actor + role_grants feed the `cell_grant` predicate. Empty
    // arrays (not NULL) keep the SQL uniform — the `caller_role_grants` CTE
    // yields zero rows for an empty role_grant set, no special-casing needed.
    const caller_actor_id = params.caller_actor_id ?? null;
    const role_grant_roles = params.caller_role_grant_roles ?? [];
    const role_grant_scope_ids = params.caller_role_grant_scope_ids ?? [];
    // Parallel-array invariant. `unnest(text[], uuid[])` null-pads on
    // length mismatch — a longer roles array would silently widen
    // role-grant admits with NULL `scope_id`s (treated as any-scope by
    // the predicate). Security-relevant; assert at the SQL boundary
    // rather than trusting every caller.
    if (role_grant_roles.length !== role_grant_scope_ids.length) {
        throw new Error(`query_cell_list: caller_role_grant_roles (len=${role_grant_roles.length}) and ` +
            `caller_role_grant_scope_ids (len=${role_grant_scope_ids.length}) must be parallel arrays`);
    }
    const shared_with_caller_only = params.shared_with_caller_only === true;
    // Column references and `$N::type` placeholders are interpolated; user
    // values flow exclusively through the parameterized array below.
    //
    // `starts_with(path, $5)` is used instead of `LIKE $5 || '%'` so caller-
    // supplied wildcards (`%`, `_`, `\`) match literally — Postgres 11+ has
    // the function and pglite/PG 16 supports it.
    //
    // Two SQL shapes share the same 14-param positional layout:
    //
    // - `shared_with_caller_only: false` — cell-driven scan with the
    //   visibility predicate (admin / public / owner / grant-admits).
    // - `shared_with_caller_only: true` — grant-driven semi-join: the
    //   planner walks `cell_grant` first via partial indexes
    //   (`idx_cell_grant_actor`, `idx_cell_grant_role_scope`) and probes
    //   `cell` by id, instead of scanning every row in `cell`.
    const sql = shared_with_caller_only
        ? build_shared_with_sql(order_column, order_direction)
        : build_general_sql(order_column, order_direction);
    return deps.db.query(sql, [
        params.include_deleted === true,
        params.data_kind ?? null,
        params.ref ?? null,
        params.created_by ?? null,
        params.path_prefix ?? null,
        params.viewer_is_admin,
        params.viewer_actor_id ?? null,
        params.limit ?? null,
        params.offset ?? 0,
        params.ids && params.ids.length > 0 ? params.ids : null,
        caller_actor_id,
        role_grant_roles,
        role_grant_scope_ids,
        params.visibility ?? null,
    ]);
};
/**
 * The `cell_grant` admits-caller predicate, factored once and reused by
 * both SQL shapes (general visibility branch + shared-with-me semi-join).
 *
 * Resolves true when the grant row at `g_alias` admits the caller via
 * either an actor-shaped principal (`g.actor_id = $11`) or a
 * role-shaped principal whose `(role, scope_id)` matches a row in the
 * `caller_role_grants` CTE. NULL `g.scope_id` matches any scope, mirroring
 * `grant_admits` in `cell_authorize.ts`.
 */
const grant_admits_caller_predicate = (g_alias) => `(
	     ($11::uuid IS NOT NULL AND ${g_alias}.actor_id = $11)
	     OR (
	       ${g_alias}.role IS NOT NULL
	       AND EXISTS (
	         SELECT 1 FROM caller_role_grants p
	         WHERE p.role = ${g_alias}.role
	           AND (${g_alias}.scope_id IS NULL OR p.scope_id IS NOT DISTINCT FROM ${g_alias}.scope_id)
	       )
	     )
	   )`;
/**
 * Materialize the caller's `(role, scope_id)` role_grant pairs once per
 * query so the role-grant predicate doesn't re-scan `unnest()` per
 * outer row. PG 12+ inlines non-recursive single-use CTEs, so this is
 * planner-equivalent to a subquery — the form is for clarity.
 */
const CALLER_ROLE_GRANTS_CTE = `WITH caller_role_grants AS (
	   SELECT role, scope_id FROM unnest($12::text[], $13::uuid[]) AS p(role, scope_id)
	 )`;
/** General `cell_list` SQL: cell-driven scan, full visibility predicate. */
const build_general_sql = (order_column, order_direction) => `${CALLER_ROLE_GRANTS_CTE}
	 SELECT c.*, ${grant_count_projection('c')} FROM cell c
	 WHERE ($1::bool OR c.deleted_at IS NULL)
	   AND ($2::text IS NULL OR c.data @> jsonb_build_object('kind', $2::text))
	   AND ($14::cell_visibility IS NULL OR c.visibility = $14::cell_visibility)
	   AND ($3::text IS NULL OR c.refs @> ARRAY[$3]::text[])
	   AND ($4::uuid IS NULL OR c.created_by = $4)
	   AND ($5::text IS NULL OR starts_with(c.path, $5))
	   AND ($10::uuid[] IS NULL OR c.id = ANY($10))
	   AND (
	     $6::bool
	     OR c.visibility = 'public'
	     OR ($7::uuid IS NOT NULL AND c.created_by = $7)
	     OR ($7::uuid IS NOT NULL AND EXISTS (
	       SELECT 1 FROM cell_grant g
	       WHERE g.cell_id = c.id AND ${grant_admits_caller_predicate('g')}
	     ))
	   )
	 ORDER BY c.${order_column} ${order_direction} NULLS LAST
	 LIMIT $8 OFFSET $9`;
/**
 * Shared-with-me `cell_list` SQL: grant-driven semi-join. The `IN
 * (SELECT g.cell_id ...)` form lets the planner walk `cell_grant`
 * first via the partial indexes on `actor_id` / `(role, scope_id)`,
 * then probe `cell` by primary key. For a sharee with a few grants
 * over a large `cell` table this is dramatically faster than the
 * cell-driven `EXISTS` form.
 *
 * `$7::uuid IS NOT NULL` is asserted by the handler (anonymous callers
 * are rejected at the action layer), but we re-encode it as
 * `created_by IS DISTINCT FROM $7` so a NULL caller actor (defense in
 * depth) doesn't accidentally admit cells with NULL `created_by`.
 *
 * `$6` (admin bypass) is intentionally not consulted: an admin asking
 * "what's shared with me" wants their grant footprint, not every cell.
 */
const build_shared_with_sql = (order_column, order_direction) => `${CALLER_ROLE_GRANTS_CTE}
	 SELECT c.*, ${grant_count_projection('c')} FROM cell c
	 WHERE ($1::bool OR c.deleted_at IS NULL)
	   AND ($2::text IS NULL OR c.data @> jsonb_build_object('kind', $2::text))
	   AND ($14::cell_visibility IS NULL OR c.visibility = $14::cell_visibility)
	   AND ($3::text IS NULL OR c.refs @> ARRAY[$3]::text[])
	   AND ($4::uuid IS NULL OR c.created_by = $4)
	   AND ($5::text IS NULL OR starts_with(c.path, $5))
	   AND ($10::uuid[] IS NULL OR c.id = ANY($10))
	   AND $7::uuid IS NOT NULL
	   AND c.created_by IS DISTINCT FROM $7
	   AND c.id IN (
	     SELECT g.cell_id FROM cell_grant g
	     WHERE ${grant_admits_caller_predicate('g')}
	   )
	   -- $6 (viewer_is_admin) intentionally not consulted: an admin
	   -- asking "what's shared with me" wants their grant footprint, not
	   -- every cell. Cast-only reference keeps the param's type known to
	   -- the planner so the shared 14-param positional layout stays valid.
	   AND $6::bool IS NOT NULL
	 ORDER BY c.${order_column} ${order_direction} NULLS LAST
	 LIMIT $8 OFFSET $9`;
/**
 * List active cells whose `refs` array contains the given fact hash,
 * newest first. Backed by the `idx_cell_refs` GIN index.
 *
 * Used by the fact-serving route's authz walk: a fact is viewable iff
 * **at least one** referencing active cell admits the caller via
 * `can_view_cell`. Unreferenced facts (no row returned here) are
 * unreachable through the public surface — orphan-fact GC handles them.
 *
 * `include_grant_count` defaults to true so the row hydrates uniformly
 * with the rest of the cell query surface. The fact-serving route is
 * the one hot path where the count is wasted work — pass `false`
 * there to skip the per-row correlated subquery; the field falls back
 * to a constant 0 so `CellRow` stays type-stable.
 *
 * @param deps - query deps
 * @param hash - fact hash to search for
 * @param options - pagination + grant-count toggle
 * @returns matching active rows
 */
export const query_cell_list_by_ref = async (deps, hash, options) => {
    const include_grant_count = options?.include_grant_count !== false;
    const projection = include_grant_count ? grant_count_projection('cell') : '0::int AS grant_count';
    return deps.db.query(`SELECT *, ${projection}
		 FROM cell
		 WHERE refs @> ARRAY[$1]::text[]
		   AND deleted_at IS NULL
		 ORDER BY created_at DESC
		 LIMIT $2 OFFSET $3`, [hash, options?.limit ?? null, options?.offset ?? 0]);
};
/**
 * Derive the `refs` array column value from a cell's `data`.
 *
 * Returns `null` (rather than `[]`) when no refs are present — the column
 * is nullable and the `idx_cell_refs` partial index is `WHERE refs IS NOT
 * NULL`, so an empty array would force every cell into the index.
 */
const derive_refs = (data) => {
    const refs = fact_hash_extract_refs(data);
    return refs.length > 0 ? refs : null;
};
