import { and, eq, gt, lt, or, sql } from "drizzle-orm";
import { bigint, bigserial, index, integer, pgTable, text } from "drizzle-orm/pg-core";
//#region src/errors.ts
/** Bad target / parent / author / body — carry field + detail (coding-standard §4). */
var CommentValidationError = class extends Error {
	field;
	detail;
	name = "CommentValidationError";
	code = "COMMENT_VALIDATION";
	constructor(field, detail) {
		super(`comment validation failed: ${field} — ${detail}`);
		this.field = field;
		this.detail = detail;
	}
};
/** No row found for the given id. */
var CommentNotFoundError = class extends Error {
	selector;
	name = "CommentNotFoundError";
	code = "COMMENT_NOT_FOUND";
	constructor(selector) {
		super(`comment not found: ${selector}`);
		this.selector = selector;
	}
};
/** IDOR / moderation denial — carries action + actorId + commentId for tracing. */
var CommentAuthzError = class extends Error {
	action;
	actorId;
	commentId;
	detail;
	name = "CommentAuthzError";
	code = "COMMENT_AUTHZ";
	constructor(action, actorId, commentId, detail) {
		super(`comment authz denied: ${action} by ${actorId} on ${commentId} — ${detail}`);
		this.action = action;
		this.actorId = actorId;
		this.commentId = commentId;
		this.detail = detail;
	}
};
/**
* Missing or non-function sanitize argument on post/edit.
* Hard-floor — a comment is never stored without a valid injected sanitizer (spec §3).
*/
var CommentSanitizationError = class extends Error {
	detail;
	name = "CommentSanitizationError";
	code = "COMMENT_SANITIZATION";
	constructor(detail) {
		super(`comment sanitization error: ${detail}`);
		this.detail = detail;
	}
};
/**
* Injected moderator/classifier returned an out-of-contract verdict (malformed status, or a
* non-finite spamScore). Trust-boundary failure — the moderator is an external authority and its
* return is untrusted. Surfaced as a typed error (no bare throw) so consumers' isCommentError()
* handling recognizes it; on the postModerated path it is caught internally → fail-safe pending.
*/
var CommentModerationError = class extends Error {
	detail;
	name = "CommentModerationError";
	code = "COMMENT_MODERATION";
	constructor(detail) {
		super(`comment moderation error: ${detail}`);
		this.detail = detail;
	}
};
/** Opaque keyset cursor is malformed / tampered. */
var InvalidCursorError = class extends Error {
	name = "InvalidCursorError";
	code = "COMMENT_INVALID_CURSOR";
	constructor() {
		super("comment cursor is invalid or tampered");
	}
};
/**
* Structural type-guard — instanceof-free (cross-package dedup safety, CLAUDE.md §6).
* Matches any of the module's typed errors by structural `code` property.
*/
function isCommentError(e) {
	return typeof e === "object" && e !== null && "code" in e && typeof e.code === "string" && e.code.startsWith("COMMENT_");
}
//#endregion
//#region src/authz.ts
/**
* Object-level authz — IDOR floor (spec §3).
* Allowed: authoring user OR actor.canModerate.
* Guest-authored comments: no session identity → only a moderator may modify (standard comment behavior).
*/
function assertCanModify(actor, action, owner, commentId) {
	if (actor?.canModerate) return;
	if (owner.kind === "guest") throw new CommentAuthzError(action, actor?.id ?? null, commentId, "guest-authored comment requires moderator to modify");
	if (owner.kind === "user") {
		if (actor?.id === owner.userId) return;
		throw new CommentAuthzError(action, actor?.id ?? null, commentId, "actor is not the author and lacks canModerate");
	}
	throw new CommentAuthzError(action, actor?.id ?? null, commentId, "unknown author kind");
}
/**
* Moderation transition gate (spec §3).
* setStatus requires canModerate.
*/
function assertCanModerate(actor, action, commentId) {
	if (!actor.canModerate) throw new CommentAuthzError(action, actor.id, commentId, "actor lacks canModerate");
}
//#endregion
//#region src/cursor.ts
/**
* Keyset cursor over NUMERIC (createdAtMs, seq) — collation-safe across pg dialects.
* Ported from @platform-modules/record-store/src/cursor.ts (spec §4 record-store lesson).
*/
const DEFAULT_LIMIT = 20;
const MAX_LIMIT = 200;
const PLUS = /\+/g;
const SLASH = /\//g;
const TRAILING_EQ = /=+$/;
const DASH = /-/g;
const UNDERSCORE = /_/g;
function toBase64Url(value) {
	return btoa(value).replace(PLUS, "-").replace(SLASH, "_").replace(TRAILING_EQ, "");
}
function fromBase64Url(value) {
	let base64 = value.replace(DASH, "+").replace(UNDERSCORE, "/");
	const pad = base64.length % 4;
	if (pad) base64 += "=".repeat(4 - pad);
	return atob(base64);
}
function isKeyset(value) {
	return typeof value === "object" && value !== null && "c" in value && "s" in value && typeof value.c === "number" && typeof value.s === "number" && Number.isFinite(value.c) && Number.isFinite(value.s);
}
function encodeCursor(key) {
	const payload = {
		c: key.createdAtMs,
		s: key.seq
	};
	return toBase64Url(JSON.stringify(payload));
}
function decodeCursor(cursor) {
	try {
		const json = fromBase64Url(cursor);
		const parsed = JSON.parse(json);
		if (!isKeyset(parsed)) throw new InvalidCursorError();
		return {
			createdAtMs: parsed.c,
			seq: parsed.s
		};
	} catch (e) {
		if (e instanceof InvalidCursorError) throw e;
		throw new InvalidCursorError();
	}
}
/** Clamp untrusted limit — non-finite/undefined/<=0 → default; huge → max. Never throws. */
function clampLimit(value, opts) {
	const dflt = opts?.defaultLimit ?? DEFAULT_LIMIT;
	const max = opts?.maxLimit ?? MAX_LIMIT;
	if (value === void 0 || !Number.isFinite(value) || value <= 0) return dflt;
	const t = Math.trunc(value);
	if (t <= 0) return dflt;
	return Math.min(t, max);
}
//#endregion
//#region src/derive.ts
/**
* Derive time fields from a now() provider — record-store pattern (spec §3).
* createdAt ISO is RE-DERIVED from createdAtMs (never the raw now() echo),
* so it is collation-safe and identical across all adapters.
*/
function deriveCreatedAt(now) {
	const createdAtMs = Date.parse(now());
	if (Number.isNaN(createdAtMs)) throw new RangeError("deriveCreatedAt: now() returned an unparseable timestamp");
	return {
		createdAtMs,
		createdAt: new Date(createdAtMs).toISOString()
	};
}
//#endregion
//#region src/model.ts
/** Maximum raw body length (cheap-DoS floor, spec §3). */
const MAX_BODY_LEN = 1e4;
function validateTarget(target) {
	if (typeof target !== "object" || target === null || typeof target.type !== "string" || !target.type.trim() || typeof target.id !== "string" || !target.id.trim()) throw new CommentValidationError("target", "must have non-empty string type and id");
}
function validateAuthor(author) {
	if (typeof author !== "object" || author === null) throw new CommentValidationError("author", "must be an object");
	const a = author;
	if (a.kind === "user") {
		if (typeof a.userId !== "string" || !String(a.userId).trim()) throw new CommentValidationError("author.userId", "required for user kind");
	} else if (a.kind === "guest") {
		if (typeof a.name !== "string" || !String(a.name).trim()) throw new CommentValidationError("author.name", "required for guest kind");
	} else throw new CommentValidationError("author.kind", "must be \"user\" or \"guest\"");
}
/**
* Validator-agnostic boundary normalization (spec §3, coding-standard).
* Throws CommentValidationError for: empty body, body > MAX_BODY_LEN, bad target, bad author.
*/
function normalizeCommentInput(raw) {
	if (typeof raw !== "object" || raw === null) throw new CommentValidationError("input", "must be an object");
	validateTarget(raw.target);
	const body = typeof raw.body === "string" ? raw.body : "";
	if (!body.trim()) throw new CommentValidationError("body", "must not be empty");
	if (body.length > 1e4) throw new CommentValidationError("body", `exceeds maximum length of ${MAX_BODY_LEN} characters`);
	validateAuthor(raw.author);
	return {
		target: raw.target,
		parentId: raw.parentId ?? null,
		author: raw.author,
		body,
		initialStatus: raw.initialStatus
	};
}
//#endregion
//#region src/schema.ts
/**
* Drizzle pgTable for comments — tenantless public schema (spec §4, content parity).
* seq: bigserial (auto-incrementing tie-break for collation-safe keyset, spec §3).
* created_at_ms: bigint stored as number (avoids string round-trip from pg drivers, advisor note).
* depth: stored at insert (spec §3 — immutable; not recomputed on read).
*/
const comments = pgTable("comments", {
	seq: bigserial("seq", { mode: "number" }).notNull(),
	id: text("id").primaryKey(),
	targetType: text("target_type").notNull(),
	targetId: text("target_id").notNull(),
	parentId: text("parent_id"),
	depth: integer("depth").notNull().default(0),
	authorKind: text("author_kind").notNull(),
	authorUserId: text("author_user_id"),
	authorName: text("author_name"),
	authorEmail: text("author_email"),
	authorUrl: text("author_url"),
	body: text("body").notNull().default(""),
	bodyHtml: text("body_html").notNull().default(""),
	status: text("status").$type().notNull().default("pending"),
	createdAtMs: bigint("created_at_ms", { mode: "number" }).notNull(),
	createdAt: text("created_at").notNull(),
	editedAt: text("edited_at")
}, (t) => [
	index("comments_target_keyset_idx").on(t.targetType, t.targetId, t.createdAtMs, t.seq),
	index("comments_parent_id_idx").on(t.parentId),
	index("comments_status_idx").on(t.status),
	index("comments_moderation_idx").on(t.status, t.createdAtMs, t.seq)
]);
const commentsSchema = { comments };
/**
* Pure DDL helper — returns a CREATE TABLE SQL string the host applies once (spec §4).
* Column names + types match the drizzle table above exactly (seam-reviewed parity requirement).
* The host owns migrations; this module never ships a migration file.
*/
function commentsTableSql(table = "comments") {
	return `
CREATE TABLE IF NOT EXISTS ${table} (
  seq        bigserial NOT NULL,
  id         text PRIMARY KEY,
  target_type text NOT NULL,
  target_id  text NOT NULL,
  parent_id  text NULL REFERENCES ${table}(id),
  depth      integer NOT NULL DEFAULT 0,
  author_kind text NOT NULL,
  author_user_id text NULL,
  author_name text NULL,
  author_email text NULL,
  author_url  text NULL,
  body        text NOT NULL DEFAULT '',
  body_html   text NOT NULL DEFAULT '',
  status      text NOT NULL DEFAULT 'pending',
  created_at_ms bigint NOT NULL,
  created_at  text NOT NULL,
  edited_at   text NULL
);
CREATE INDEX IF NOT EXISTS ${table}_target_keyset_idx ON ${table} (target_type, target_id, created_at_ms, seq);
CREATE INDEX IF NOT EXISTS ${table}_parent_id_idx ON ${table} (parent_id) WHERE parent_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS ${table}_status_idx ON ${table} (status);
CREATE INDEX IF NOT EXISTS ${table}_moderation_idx ON ${table} (status, created_at_ms, seq);
`.trim();
}
//#endregion
//#region src/store.ts
/** Maximum thread depth (spec §3 — FZ depth-cap=5). */
const MAX_DEPTH = 5;
/**
* keysetFilter — the strict-ordered cursor predicate over NUMERIC (createdAtMs, seq).
* Shared by list (target-scoped) and listForModeration (cross-target); both rely on
* (createdAtMs, seq) being a TOTAL order — seq is a per-store bigserial, so this holds
* globally (regardless of target).
*/
function keysetFilter(order, key) {
	if (order === "oldest") return or(gt(comments.createdAtMs, key.createdAtMs), and(eq(comments.createdAtMs, key.createdAtMs), gt(comments.seq, key.seq)));
	return or(lt(comments.createdAtMs, key.createdAtMs), and(eq(comments.createdAtMs, key.createdAtMs), lt(comments.seq, key.seq)));
}
/**
* Map a DB row to a Comment — viewer-aware (spec §3 info-disclosure: guest email redacted
* unless actor.canModerate).
*/
function toComment(row, canModerate = false) {
	let author;
	if (row.authorKind === "user") author = {
		kind: "user",
		userId: row.authorUserId ?? ""
	};
	else author = {
		kind: "guest",
		name: row.authorName ?? "",
		...canModerate && row.authorEmail ? { email: row.authorEmail } : {},
		...row.authorUrl ? { url: row.authorUrl } : {}
	};
	return {
		id: row.id,
		target: {
			type: row.targetType,
			id: row.targetId
		},
		parentId: row.parentId ?? null,
		author,
		body: row.body,
		bodyHtml: row.bodyHtml,
		status: row.status,
		depth: row.depth,
		createdAt: row.createdAt,
		editedAt: row.editedAt ?? null
	};
}
function assertSanitize(sanitize, verb) {
	if (typeof sanitize !== "function") throw new CommentSanitizationError(`${verb}: sanitize must be a function, got ${typeof sanitize}`);
}
/**
* list — flat paginated Page<Comment> (spec §3).
* Default: status='published', order='oldest', limit=20.
* Non-published statuses require actor.canModerate → CommentAuthzError.
* Keyset: NUMERIC (createdAtMs, seq) — collation-safe (spec §3 ordering floor).
*/
async function list(db, target, query = {}, actor, _opts = {}) {
	const order = query.order ?? "oldest";
	const limit = clampLimit(query.limit);
	let requestedStatuses = query.status ? Array.isArray(query.status) ? query.status : [query.status] : ["published"];
	if (requestedStatuses.length === 0) requestedStatuses = ["published"];
	const nonPublished = requestedStatuses.filter((s) => s !== "published");
	if (nonPublished.length > 0 && !actor?.canModerate) throw new CommentAuthzError("list", actor?.id ?? null, "(list)", `viewing non-published statuses [${nonPublished.join(",")}] requires canModerate`);
	const filters = [eq(comments.targetType, target.type), eq(comments.targetId, target.id)];
	if (requestedStatuses.length === 1) filters.push(eq(comments.status, requestedStatuses[0]));
	else filters.push(or(...requestedStatuses.map((s) => eq(comments.status, s))));
	if (query.cursor) filters.push(keysetFilter(order, decodeCursor(query.cursor)));
	const rows = await db.select().from(comments).where(and(...filters)).orderBy(...order === "oldest" ? [comments.createdAtMs, comments.seq] : [sql`${comments.createdAtMs} desc`, sql`${comments.seq} desc`]).limit(limit + 1);
	const hasMore = rows.length > limit;
	const pageRows = hasMore ? rows.slice(0, limit) : rows;
	const canMod = actor?.canModerate ?? false;
	const items = pageRows.map((r) => toComment(r, canMod));
	let nextCursor = null;
	if (hasMore && pageRows.length > 0) {
		const last = pageRows[pageRows.length - 1];
		nextCursor = encodeCursor({
			createdAtMs: last.createdAtMs,
			seq: last.seq
		});
	}
	return {
		items,
		nextCursor
	};
}
/**
* getById — returns Comment or null; same guest-email redaction as list (spec §3).
* Returns tombstoned rows (trashed rows are still valid Comment shapes).
*/
async function getById(db, id, actor, _opts = {}) {
	const [row] = await db.select().from(comments).where(eq(comments.id, id)).limit(1);
	if (!row) return null;
	return toComment(row, actor?.canModerate ?? false);
}
async function getRow(db, id) {
	const [row] = await db.select().from(comments).where(eq(comments.id, id)).limit(1);
	if (!row) throw new CommentNotFoundError(`id=${id}`);
	return row;
}
/**
* insertComment — internal create core (subpath-private; NOT barrelled).
* Parent validation + depth + sanitize + author-mapping + insert with explicit status.
*/
async function insertComment(db, normalized, status, sanitize, _opts = {}) {
	let depth = 0;
	if (normalized.parentId) {
		const parent = await db.select().from(comments).where(eq(comments.id, normalized.parentId)).limit(1);
		if (!parent[0]) throw new CommentValidationError("parentId", `parent comment not found: ${normalized.parentId}`);
		if (parent[0].targetType !== normalized.target.type || parent[0].targetId !== normalized.target.id) throw new CommentValidationError("parentId", "parent comment belongs to a different target");
		depth = Math.min(parent[0].depth + 1, MAX_DEPTH);
	}
	const { createdAtMs, createdAt } = deriveCreatedAt(() => (/* @__PURE__ */ new Date()).toISOString());
	const bodyHtml = sanitize(normalized.body);
	const id = crypto.randomUUID();
	const authorKind = normalized.author.kind;
	const authorUserId = authorKind === "user" ? normalized.author.userId : null;
	const authorName = authorKind === "guest" ? normalized.author.name : null;
	const authorEmail = authorKind === "guest" ? normalized.author.email ?? null : null;
	const authorUrl = authorKind === "guest" ? normalized.author.url ?? null : null;
	await db.insert(comments).values({
		id,
		targetType: normalized.target.type,
		targetId: normalized.target.id,
		parentId: normalized.parentId ?? null,
		depth,
		authorKind,
		authorUserId,
		authorName,
		authorEmail,
		authorUrl,
		body: normalized.body,
		bodyHtml,
		status,
		createdAtMs,
		createdAt,
		editedAt: null
	});
	return {
		id,
		target: normalized.target
	};
}
/**
* post — create a new comment (spec §3).
* sanitize REQUIRED (hard-floor #1); missing/non-function → CommentSanitizationError.
* initialStatus defaults to 'pending'; non-moderator passing non-pending → CommentAuthzError.
* depth stored at insert: parent.depth+1 capped at MAX_DEPTH; 0 for top-level.
* Validates parent exists + shares same target.
*/
async function post(db, input, actor, sanitize, opts = {}) {
	assertSanitize(sanitize, "post");
	const normalized = normalizeCommentInput(input);
	let initialStatus = "pending";
	if (normalized.initialStatus && normalized.initialStatus !== "pending") {
		if (!actor?.canModerate) throw new CommentAuthzError("post", actor?.id ?? null, "(new)", `non-moderator cannot set initialStatus to '${normalized.initialStatus}'`);
		initialStatus = normalized.initialStatus;
	} else if (normalized.initialStatus === "pending") initialStatus = "pending";
	return insertComment(db, normalized, initialStatus, sanitize, opts);
}
/**
* edit — update body + re-sanitize bodyHtml + bump editedAt (spec §3).
* sanitize REQUIRED (hard-floor #1).
* authz: author or canModerate (hard-floor #2).
*/
async function edit(db, id, body, actor, sanitize, _opts = {}) {
	assertSanitize(sanitize, "edit");
	const row = await getRow(db, id);
	assertCanModify(actor, "edit", rowToAuthor(row), id);
	if (typeof body !== "string" || !body.trim()) throw new CommentValidationError("body", "must not be empty");
	if (body.length > 1e4) throw new CommentValidationError("body", `exceeds maximum length of ${MAX_BODY_LEN} characters`);
	const bodyHtml = sanitize(body);
	const editedAt = (/* @__PURE__ */ new Date()).toISOString();
	await db.update(comments).set({
		body,
		bodyHtml,
		editedAt
	}).where(eq(comments.id, id));
	return {
		id,
		target: {
			type: row.targetType,
			id: row.targetId
		}
	};
}
/**
* setStatus — moderation transition (spec §3).
* Requires actor.canModerate (hard-floor #2).
*/
async function setStatus(db, id, status, actor, _opts = {}) {
	assertCanModerate(actor, "setStatus", id);
	const row = await getRow(db, id);
	await db.update(comments).set({ status }).where(eq(comments.id, id));
	return {
		id,
		target: {
			type: row.targetType,
			id: row.targetId
		}
	};
}
/**
* remove — tombstone if children exist, else hard-delete (spec §3 deletion floor).
* authz: author or canModerate (hard-floor #2).
*/
async function remove(db, id, actor, _opts = {}) {
	assertCanModify(actor, "remove", rowToAuthor(await getRow(db, id)), id);
	const [child] = await db.select({ id: comments.id }).from(comments).where(eq(comments.parentId, id)).limit(1);
	if (child) await db.update(comments).set({
		body: "",
		bodyHtml: "",
		status: "trashed",
		authorName: null,
		authorEmail: null,
		authorUrl: null
	}).where(eq(comments.id, id));
	else await db.delete(comments).where(eq(comments.id, id));
}
/**
* count — published-only count for "N comments" display (spec §3).
*/
async function count(db, target, _opts = {}) {
	const [row] = await db.select({ n: sql`count(*)::int` }).from(comments).where(and(eq(comments.targetType, target.type), eq(comments.targetId, target.id), eq(comments.status, "published")));
	return row?.n ?? 0;
}
/**
* listForModeration — CROSS-TARGET moderation queue (spec §3 hard-floor #5).
* NO target filter — scans all rows, filtered by status only.
* actor REQUIRED + canModerate UNCONDITIONAL (fail-closed) — cross-target enumeration is
* privileged even for published rows. Default status=['pending'], default order='newest'.
* Keyset (createdAtMs, seq) is globally sound (seq = per-store bigserial — total order
* across all targets). Guest email VISIBLE (moderator-only by construction).
*/
async function listForModeration(db, query = {}, actor, _opts = {}) {
	if (!actor?.canModerate) throw new CommentAuthzError("listForModeration", actor?.id ?? null, "(cross-target)", "cross-target moderation listing requires canModerate");
	const order = query.order ?? "newest";
	const limit = clampLimit(query.limit);
	let requestedStatuses = query.status ? Array.isArray(query.status) ? query.status : [query.status] : ["pending"];
	if (requestedStatuses.length === 0) requestedStatuses = ["pending"];
	const filters = [requestedStatuses.length === 1 ? eq(comments.status, requestedStatuses[0]) : or(...requestedStatuses.map((s) => eq(comments.status, s)))];
	if (query.cursor) filters.push(keysetFilter(order, decodeCursor(query.cursor)));
	const rows = await db.select().from(comments).where(and(...filters)).orderBy(...order === "oldest" ? [comments.createdAtMs, comments.seq] : [sql`${comments.createdAtMs} desc`, sql`${comments.seq} desc`]).limit(limit + 1);
	const hasMore = rows.length > limit;
	const pageRows = hasMore ? rows.slice(0, limit) : rows;
	const items = pageRows.map((r) => toComment(r, true));
	let nextCursor = null;
	if (hasMore && pageRows.length > 0) {
		const last = pageRows[pageRows.length - 1];
		nextCursor = encodeCursor({
			createdAtMs: last.createdAtMs,
			seq: last.seq
		});
	}
	return {
		items,
		nextCursor
	};
}
function rowToAuthor(row) {
	if (row.authorKind === "user") return {
		kind: "user",
		userId: row.authorUserId ?? ""
	};
	return {
		kind: "guest",
		name: row.authorName ?? ""
	};
}
//#endregion
export { CommentNotFoundError as C, isCommentError as D, InvalidCursorError as E, CommentModerationError as S, CommentValidationError as T, decodeCursor as _, insertComment as a, assertCanModify as b, post as c, comments as d, commentsSchema as f, clampLimit as g, normalizeCommentInput as h, getById as i, remove as l, MAX_BODY_LEN as m, count as n, list as o, commentsTableSql as p, edit as r, listForModeration as s, assertSanitize as t, setStatus as u, encodeCursor as v, CommentSanitizationError as w, CommentAuthzError as x, assertCanModerate as y };
