import { and, desc, eq, lt, or, sql } from "drizzle-orm";
import { check, index, integer, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
//#region src/schema.ts
const review = pgTable("review", {
	id: uuid("id").primaryKey().defaultRandom(),
	productId: text("product_id").notNull(),
	userId: text("user_id").notNull(),
	purchaseId: text("purchase_id").notNull(),
	vendorId: text("vendor_id"),
	rating: integer("rating").notNull(),
	body: text("body"),
	status: text("status").$type().notNull().default("pending"),
	vendorReply: text("vendor_reply"),
	createdAt: timestamp("created_at", {
		withTimezone: true,
		precision: 3
	}).notNull().defaultNow(),
	updatedAt: timestamp("updated_at", {
		withTimezone: true,
		precision: 3
	}).notNull().defaultNow()
}, (t) => [
	check("review_rating_chk", sql`${t.rating} >= 1 AND ${t.rating} <= 5`),
	check("review_body_len_chk", sql`${t.body} IS NULL OR length(${t.body}) <= 5000`),
	check("review_reply_len_chk", sql`${t.vendorReply} IS NULL OR length(${t.vendorReply}) <= 5000`),
	check("review_status_chk", sql`${t.status} IN ('pending', 'approved', 'rejected')`),
	uniqueIndex("review_user_product_purchase_uq").on(t.userId, t.productId, t.purchaseId),
	index("review_product_status_idx").on(t.productId, t.status)
]);
const reviewsSchema = { review };
//#endregion
//#region src/aggregate.ts
const RATINGS = [
	1,
	2,
	3,
	4,
	5
];
function emptyDistribution() {
	return {
		1: 0,
		2: 0,
		3: 0,
		4: 0,
		5: 0
	};
}
async function getRatingAggregate(q, productId) {
	const rows = await q.select({
		rating: review.rating,
		count: sql`count(*)::int`
	}).from(review).where(and(eq(review.productId, productId), eq(review.status, "approved"))).groupBy(review.rating);
	const distribution = emptyDistribution();
	let count = 0;
	let sum = 0;
	for (const row of rows) {
		const rating = row.rating;
		if (!RATINGS.includes(rating)) continue;
		distribution[rating] = row.count;
		count += row.count;
		sum += rating * row.count;
	}
	return {
		productId,
		avg: count === 0 ? 0 : sum / count,
		count,
		distribution
	};
}
//#endregion
//#region src/errors.ts
var NotPurchasedError = class extends Error {
	userId;
	productId;
	name = "NotPurchasedError";
	code = "NOT_PURCHASED";
	httpStatus = 403;
	constructor(userId, productId) {
		super(`not purchased: user ${userId} product ${productId}`);
		this.userId = userId;
		this.productId = productId;
	}
};
var ReviewNotFoundError = class extends Error {
	reviewId;
	name = "ReviewNotFoundError";
	code = "REVIEW_NOT_FOUND";
	httpStatus = 404;
	constructor(reviewId) {
		super("review not found");
		this.reviewId = reviewId;
	}
};
var ReviewValidationError = class extends Error {
	field;
	name = "ReviewValidationError";
	code = "REVIEW_VALIDATION";
	httpStatus = 400;
	constructor(field) {
		super(`review validation failed: ${field}`);
		this.field = field;
	}
};
var ImmutableFieldError = class extends Error {
	field;
	name = "ImmutableFieldError";
	code = "IMMUTABLE_FIELD";
	constructor(field) {
		super(`field cannot be changed after create: ${field}`);
		this.field = field;
	}
};
var ReviewsConfigError = class extends Error {
	detail;
	name = "ReviewsConfigError";
	code = "REVIEWS_CONFIG";
	constructor(detail) {
		super(`reviews config: ${detail}`);
		this.detail = detail;
	}
};
var ReviewsMigrateError = class extends Error {
	detail;
	name = "ReviewsMigrateError";
	code = "REVIEWS_MIGRATE";
	constructor(detail) {
		super(`reviews migrate: ${detail}`);
		this.detail = detail;
	}
};
function isNotPurchasedError(e) {
	return typeof e === "object" && e !== null && e.name === "NotPurchasedError" && e.code === "NOT_PURCHASED";
}
function isReviewNotFoundError(e) {
	return typeof e === "object" && e !== null && e.name === "ReviewNotFoundError" && e.code === "REVIEW_NOT_FOUND";
}
function isReviewValidationError(e) {
	return typeof e === "object" && e !== null && e.name === "ReviewValidationError" && e.code === "REVIEW_VALIDATION";
}
function isImmutableFieldError(e) {
	return typeof e === "object" && e !== null && e.name === "ImmutableFieldError" && e.code === "IMMUTABLE_FIELD";
}
function isReviewsConfigError(e) {
	return typeof e === "object" && e !== null && e.name === "ReviewsConfigError" && e.code === "REVIEWS_CONFIG";
}
function isReviewsMigrateError(e) {
	return typeof e === "object" && e !== null && e.name === "ReviewsMigrateError" && e.code === "REVIEWS_MIGRATE";
}
//#endregion
//#region src/ids.ts
/**
* Canonical review-id (UUID) shape — the single source of truth for the
* `reviewId` / cursor-id trust boundary. `review.id` is a Postgres `uuid`
* column; passing a non-uuid value into `eq(review.id, …)` makes Postgres
* raise `invalid input syntax for type uuid` — a RAW DB error (info-leak) and
* a non-uniform throw that breaks the no-oracle property. Every public entry
* point that takes an untrusted review id MUST funnel it through
* `assertReviewId` first (D9), exactly as the list cursor does (D8).
*/
const REVIEW_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/**
* Validate an untrusted `reviewId` at the trust boundary.
* Malformed (non-uuid, empty, oversized) → typed `ReviewValidationError`,
* never a raw DB throw. Returns nothing; throws on invalid input.
*/
function assertReviewId(reviewId) {
	if (typeof reviewId !== "string" || !REVIEW_ID_RE.test(reviewId)) throw new ReviewValidationError("reviewId");
}
//#endregion
//#region src/row-to-review.ts
function rowToReview(row) {
	return {
		id: row.id,
		productId: row.productId,
		userId: row.userId,
		purchaseId: row.purchaseId,
		vendorId: row.vendorId,
		rating: row.rating,
		body: row.body,
		status: row.status,
		vendorReply: row.vendorReply,
		createdAt: row.createdAt,
		updatedAt: row.updatedAt
	};
}
//#endregion
//#region src/types.ts
const REVIEW_LIST_DEFAULT_LIMIT = 20;
const REVIEW_LIST_MAX_LIMIT = 100;
//#endregion
//#region src/list.ts
const CURSOR_SEP = "|";
function resolveLimit(limit) {
	const raw = limit ?? 20;
	if (!Number.isFinite(raw) || raw < 1) return 20;
	return Math.min(Math.floor(raw), 100);
}
function encodeCursor(createdAt, id) {
	return `${createdAt.toISOString()}${CURSOR_SEP}${id}`;
}
function decodeCursor(cursor) {
	if (cursor.length > 256) throw new ReviewValidationError("cursor");
	const sep = cursor.lastIndexOf(CURSOR_SEP);
	if (sep <= 0) throw new ReviewValidationError("cursor");
	const createdAtRaw = cursor.slice(0, sep);
	const id = cursor.slice(sep + 1);
	const createdAt = new Date(createdAtRaw);
	if (Number.isNaN(createdAt.getTime())) throw new ReviewValidationError("cursor");
	if (!REVIEW_ID_RE.test(id)) throw new ReviewValidationError("cursor");
	return {
		createdAt,
		id
	};
}
function buildCursorPredicate(cursor) {
	const { createdAt, id } = decodeCursor(cursor);
	return or(lt(review.createdAt, createdAt), and(eq(review.createdAt, createdAt), lt(review.id, id)));
}
async function listReviews(q, productId, opts) {
	const limit = resolveLimit(opts.limit);
	const predicates = [eq(review.productId, productId)];
	if (opts.audience === "public") predicates.push(eq(review.status, "approved"));
	if (opts.vendorId !== void 0) predicates.push(eq(review.vendorId, opts.vendorId));
	if (opts.cursor !== void 0) predicates.push(buildCursorPredicate(opts.cursor));
	const whereClause = and(...predicates);
	const rows = await q.select().from(review).where(whereClause).orderBy(desc(review.createdAt), desc(review.id)).limit(limit + 1);
	const hasMore = rows.length > limit;
	const pageRows = hasMore ? rows.slice(0, limit) : rows;
	const items = pageRows.map(rowToReview);
	const last = pageRows[pageRows.length - 1];
	return {
		items,
		nextCursor: hasMore && last !== void 0 ? encodeCursor(last.createdAt, last.id) : null
	};
}
async function listReviewsByVendor(q, vendorId, opts) {
	const limit = resolveLimit(opts.limit);
	const predicates = [eq(review.vendorId, vendorId)];
	if (opts.productId !== void 0) predicates.push(eq(review.productId, opts.productId));
	if (opts.audience === "public") predicates.push(eq(review.status, "approved"));
	if (opts.cursor !== void 0) predicates.push(buildCursorPredicate(opts.cursor));
	const whereClause = and(...predicates);
	const rows = await q.select().from(review).where(whereClause).orderBy(desc(review.createdAt), desc(review.id)).limit(limit + 1);
	const hasMore = rows.length > limit;
	const pageRows = hasMore ? rows.slice(0, limit) : rows;
	const items = pageRows.map(rowToReview);
	const last = pageRows[pageRows.length - 1];
	return {
		items,
		nextCursor: hasMore && last !== void 0 ? encodeCursor(last.createdAt, last.id) : null
	};
}
//#endregion
//#region src/moderate.ts
function actionToStatus(action) {
	return action === "approve" ? "approved" : "rejected";
}
async function moderateReview(q, reviewId, action) {
	assertReviewId(reviewId);
	const status = actionToStatus(action);
	const now = /* @__PURE__ */ new Date();
	const [updated] = await q.update(review).set({
		status,
		updatedAt: now
	}).where(eq(review.id, reviewId)).returning();
	if (!updated) throw new ReviewNotFoundError(reviewId);
}
//#endregion
//#region src/reply.ts
const isConcrete = (v) => typeof v === "string" && v.length > 0;
async function replyToReview(q, reviewId, vendorId, reply) {
	assertReviewId(reviewId);
	if (typeof reply !== "string" || reply.length > 5e3) throw new ReviewValidationError("reply");
	const [existing] = await q.select().from(review).where(eq(review.id, reviewId)).limit(1);
	if (!existing || !isConcrete(existing.vendorId) || !isConcrete(vendorId) || existing.vendorId !== vendorId) throw new ReviewNotFoundError(reviewId);
	const now = /* @__PURE__ */ new Date();
	const [updated] = await q.update(review).set({
		vendorReply: reply,
		updatedAt: now
	}).where(eq(review.id, reviewId)).returning();
	if (!updated) throw new ReviewNotFoundError(reviewId);
}
//#endregion
//#region src/migrate.ts
async function pushReviewsSchema(db) {
	try {
		await db.execute(sql`
      DO $$
      BEGIN
        IF EXISTS (
          SELECT 1 FROM information_schema.tables WHERE table_name = 'review'
        ) AND NOT EXISTS (
          SELECT 1 FROM information_schema.columns WHERE table_name = 'review' AND column_name = 'purchase_id'
        ) THEN
          DROP TABLE review CASCADE;
        END IF;
      END$$
    `);
		await db.execute(sql`
      CREATE TABLE IF NOT EXISTS review (
        id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
        product_id text NOT NULL,
        user_id text NOT NULL,
        purchase_id text NOT NULL,
        vendor_id text,
        rating integer NOT NULL CHECK (rating >= 1 AND rating <= 5),
        body text CHECK (body IS NULL OR length(body) <= 5000),
        status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected')),
        vendor_reply text CHECK (vendor_reply IS NULL OR length(vendor_reply) <= 5000),
        created_at timestamptz(3) NOT NULL DEFAULT NOW(),
        updated_at timestamptz(3) NOT NULL DEFAULT NOW()
      )
    `);
		await db.execute(sql`DROP INDEX IF EXISTS review_user_product_uq`);
		await db.execute(sql`CREATE UNIQUE INDEX IF NOT EXISTS review_user_product_purchase_uq ON review (user_id, product_id, purchase_id)`);
		await db.execute(sql`CREATE INDEX IF NOT EXISTS review_product_status_idx ON review (product_id, status)`);
	} catch (e) {
		throw new ReviewsMigrateError(e instanceof Error ? e.message : String(e));
	}
}
function assertValidRating(rating) {
	if (!Number.isInteger(rating) || rating < 1 || rating > 5) throw new ReviewValidationError("rating");
}
function assertValidBody(body) {
	if (body != null && body.length > 5e3) throw new ReviewValidationError("body");
}
function assertImmutableUnchanged(field, provided, existing) {
	if (provided !== void 0 && provided !== existing) throw new ImmutableFieldError(field);
}
async function resolveStatus(body, moderation) {
	if (!moderation) return "pending";
	const result = await moderation.score(body ?? "");
	if (result.action === "approve") return "approved";
	if (result.action === "reject") return "rejected";
	return "pending";
}
async function submitReview(q, input, opts) {
	assertValidRating(input.rating);
	assertValidBody(input.body);
	if (!input.purchaseId) throw new ReviewValidationError("purchaseId");
	if (!opts.verifiedPurchase) throw new ReviewsConfigError("verifiedPurchase port required");
	if (!await opts.verifiedPurchase.hasPurchased(input.userId, input.productId, input.purchaseId)) throw new NotPurchasedError(input.userId, input.productId);
	const status = await resolveStatus(input.body, opts?.moderation);
	const now = /* @__PURE__ */ new Date();
	const body = input.body ?? null;
	const vendorId = input.vendorId;
	const [existing] = await q.select().from(review).where(and(eq(review.userId, input.userId), eq(review.productId, input.productId), eq(review.purchaseId, input.purchaseId))).limit(1);
	if (existing) {
		assertImmutableUnchanged("userId", input.userId, existing.userId);
		assertImmutableUnchanged("productId", input.productId, existing.productId);
		assertImmutableUnchanged("vendorId", vendorId, existing.vendorId);
		assertImmutableUnchanged("purchaseId", input.purchaseId, existing.purchaseId);
	}
	const [row] = await q.insert(review).values({
		productId: input.productId,
		userId: input.userId,
		vendorId,
		purchaseId: input.purchaseId,
		rating: input.rating,
		body,
		status,
		createdAt: now,
		updatedAt: now
	}).onConflictDoUpdate({
		target: [
			review.userId,
			review.productId,
			review.purchaseId
		],
		set: {
			rating: input.rating,
			body,
			status,
			updatedAt: now
		}
	}).returning();
	if (!row) throw new ReviewValidationError("insert");
	return rowToReview(row);
}
//#endregion
export { ImmutableFieldError, NotPurchasedError, REVIEW_LIST_DEFAULT_LIMIT, REVIEW_LIST_MAX_LIMIT, ReviewNotFoundError, ReviewValidationError, ReviewsConfigError, ReviewsMigrateError, getRatingAggregate, isImmutableFieldError, isNotPurchasedError, isReviewNotFoundError, isReviewValidationError, isReviewsConfigError, isReviewsMigrateError, listReviews, listReviewsByVendor, moderateReview, pushReviewsSchema, replyToReview, review, reviewsSchema, submitReview };
