import { and, eq, isNull, sql } from "drizzle-orm";
import { bigint, boolean, integer, jsonb, pgTable, primaryKey, text, timestamp, uuid } from "drizzle-orm/pg-core";
//#region src/errors.ts
var PromoValidationError = class extends Error {
	field;
	name = "PromoValidationError";
	code = "PROMO_VALIDATION";
	constructor(field) {
		super(`promo validation failed: ${field}`);
		this.field = field;
	}
};
var PromoQuotaExhaustedError = class extends Error {
	reason;
	name = "PromoQuotaExhaustedError";
	code = "PROMO_QUOTA_EXHAUSTED";
	constructor(reason) {
		super(`promo quota exhausted: ${reason}`);
		this.reason = reason;
	}
};
function isPromoValidationError(e) {
	return typeof e === "object" && e !== null && e.name === "PromoValidationError" && e.code === "PROMO_VALIDATION";
}
function isPromoQuotaExhaustedError(e) {
	return typeof e === "object" && e !== null && e.name === "PromoQuotaExhaustedError" && e.code === "PROMO_QUOTA_EXHAUSTED";
}
//#endregion
//#region src/scope.ts
function lineMatchesScope(line, scope) {
	switch (scope.kind) {
		case "all": return true;
		case "products": return line.productId !== void 0 && scope.ids.includes(line.productId);
		case "categories": return line.categoryIds !== void 0 && line.categoryIds.some((id) => scope.ids.includes(id));
		case "tags": return line.tags !== void 0 && line.tags.some((tag) => scope.tags.includes(tag));
		case "vendor": return line.vendorId === scope.vendorId;
	}
}
function anyLineMatchesScope(lines, scope) {
	return lines.some((line) => lineMatchesScope(line, scope));
}
//#endregion
//#region src/types.ts
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function assertUuid(value, field) {
	if (!UUID_RE.test(value)) throw new PromoValidationError(field);
}
function assertPositiveInt(n, field) {
	if (!Number.isInteger(n) || n <= 0) throw new PromoValidationError(field);
}
//#endregion
//#region src/validate.ts
function assertPromoShape(promo) {
	if ((promo.valueAmount !== void 0 || promo.minOrderAmount !== void 0 || promo.maxDiscountAmount !== void 0 || promo.maxOrderAmount !== void 0) && promo.currency === void 0) throw new PromoValidationError("currency");
	if (promo.maxOrderAmount !== void 0) {
		if (promo.maxOrderAmount <= 0n) throw new PromoValidationError("maxOrderAmount");
		if (promo.minOrderAmount !== void 0 && promo.maxOrderAmount <= promo.minOrderAmount) throw new PromoValidationError("maxOrderAmount");
	}
	switch (promo.kind) {
		case "percentage":
			if (promo.valueBps === void 0) throw new PromoValidationError("valueBps");
			if (promo.valueBps <= 0 || promo.valueBps > 1e4) throw new PromoValidationError("valueBps");
			if (promo.valueAmount !== void 0) throw new PromoValidationError("valueAmount");
			if (promo.bogo !== void 0) throw new PromoValidationError("bogo");
			if (promo.maxDiscountAmount !== void 0 && promo.maxDiscountAmount <= 0n) throw new PromoValidationError("maxDiscountAmount");
			break;
		case "fixed":
			if (promo.valueAmount === void 0) throw new PromoValidationError("valueAmount");
			if (promo.valueAmount <= 0n) throw new PromoValidationError("valueAmount");
			if (promo.valueBps !== void 0) throw new PromoValidationError("valueBps");
			if (promo.bogo !== void 0) throw new PromoValidationError("bogo");
			if (promo.maxDiscountAmount !== void 0) throw new PromoValidationError("maxDiscountAmount");
			break;
		case "bogo":
			if (promo.bogo === void 0) throw new PromoValidationError("bogo");
			if (promo.bogo.buyQty === void 0 || promo.bogo.getQty === void 0) throw new PromoValidationError("bogo");
			assertPositiveInt(promo.bogo.buyQty, "bogo.buyQty");
			assertPositiveInt(promo.bogo.getQty, "bogo.getQty");
			if (promo.valueBps !== void 0) throw new PromoValidationError("valueBps");
			if (promo.valueAmount !== void 0) throw new PromoValidationError("valueAmount");
			if (promo.maxDiscountAmount !== void 0) throw new PromoValidationError("maxDiscountAmount");
	}
}
function validatePromo(promo, ctx) {
	assertPromoShape(promo);
	if (!promo.active) return {
		ok: false,
		reason: "inactive"
	};
	if (promo.startsAt !== void 0 && ctx.now < promo.startsAt) return {
		ok: false,
		reason: "not_started"
	};
	if (promo.endsAt !== void 0 && ctx.now > promo.endsAt) return {
		ok: false,
		reason: "expired"
	};
	if (promo.currency !== void 0 && promo.currency !== ctx.cartCurrency) return {
		ok: false,
		reason: "currency_mismatch"
	};
	if (promo.minOrderAmount !== void 0 && ctx.cartSubtotal < promo.minOrderAmount) return {
		ok: false,
		reason: "min_order_not_met"
	};
	if (promo.maxOrderAmount !== void 0 && ctx.cartSubtotal > promo.maxOrderAmount) return {
		ok: false,
		reason: "max_order_exceeded"
	};
	if (!anyLineMatchesScope(ctx.lines, promo.scope)) return {
		ok: false,
		reason: "out_of_scope"
	};
	if (promo.eligibility.firstPurchaseOnly && !ctx.isFirstPurchase) return {
		ok: false,
		reason: "not_first_purchase"
	};
	if (promo.eligibility.membersOnly && !ctx.isMember) return {
		ok: false,
		reason: "not_member"
	};
	if (promo.eligibility.allowlistOnly && !ctx.isAllowlisted) return {
		ok: false,
		reason: "not_on_allowlist"
	};
	if (promo.maxUses !== void 0 && ctx.globalUses !== void 0 && ctx.globalUses >= promo.maxUses) return {
		ok: false,
		reason: "quota_exhausted"
	};
	if (promo.perUserCap !== void 0 && ctx.userRedemptionCount !== void 0 && ctx.userRedemptionCount >= promo.perUserCap) return {
		ok: false,
		reason: "per_user_cap_reached"
	};
	return {
		ok: true,
		promo
	};
}
//#endregion
//#region src/apply.ts
function filterScopedLines(cart, scope) {
	return cart.lines.filter((line) => lineMatchesScope(line, scope)).map((line) => ({
		lineId: line.lineId,
		lineValue: line.unitPrice * BigInt(line.qty),
		unitPrice: line.unitPrice,
		qty: line.qty
	}));
}
function scopedSubtotalOf(lines) {
	return lines.reduce((sum, line) => sum + line.lineValue, 0n);
}
function emptyResult(funder) {
	return {
		total: 0n,
		perLine: [],
		funder
	};
}
function percentageDiscount(scopedSubtotal, valueBps) {
	return (2n * scopedSubtotal * BigInt(valueBps) + 10000n) / 20000n;
}
function clampDiscount(totalDiscount, maxDiscountAmount) {
	if (maxDiscountAmount === void 0 || totalDiscount <= maxDiscountAmount) return totalDiscount;
	return maxDiscountAmount;
}
function fixedDiscount(scopedSubtotal, valueAmount) {
	return valueAmount < scopedSubtotal ? valueAmount : scopedSubtotal;
}
function bogoAllocations(lines, buyQty, getQty) {
	const totalScopedUnits = lines.reduce((sum, line) => sum + line.qty, 0);
	const groupSize = buyQty + getQty;
	const freeCount = Math.floor(totalScopedUnits / groupSize) * getQty;
	const allocations = /* @__PURE__ */ new Map();
	if (freeCount <= 0) return {
		total: 0n,
		allocations
	};
	const sorted = [...lines].sort((left, right) => {
		if (left.unitPrice !== right.unitPrice) return left.unitPrice < right.unitPrice ? -1 : 1;
		return left.lineId.localeCompare(right.lineId);
	});
	let remaining = freeCount;
	let total = 0n;
	for (const line of sorted) {
		const take = Math.min(remaining, line.qty);
		if (take > 0) {
			const amount = BigInt(take) * line.unitPrice;
			total += amount;
			allocations.set(line.lineId, (allocations.get(line.lineId) ?? 0n) + amount);
			remaining -= take;
			if (remaining === 0) break;
		}
	}
	return {
		total,
		allocations
	};
}
function allocateLargestRemainder(lines, totalDiscount, scopedSubtotal) {
	const allocations = /* @__PURE__ */ new Map();
	if (totalDiscount === 0n || scopedSubtotal === 0n) return allocations;
	const bases = [];
	let allocated = 0n;
	for (const line of lines) {
		const numerator = totalDiscount * line.lineValue;
		const base = numerator / scopedSubtotal;
		const remainder = numerator % scopedSubtotal;
		bases.push({
			lineId: line.lineId,
			base,
			remainder
		});
		allocated += base;
		if (base > 0n) allocations.set(line.lineId, base);
	}
	let residue = totalDiscount - allocated;
	if (residue === 0n) return allocations;
	const ranked = [...bases].sort((left, right) => {
		if (left.remainder !== right.remainder) return left.remainder > right.remainder ? -1 : 1;
		return left.lineId.localeCompare(right.lineId);
	});
	for (let i = 0; i < Number(residue); i++) {
		const lineId = ranked[i].lineId;
		allocations.set(lineId, (allocations.get(lineId) ?? 0n) + 1n);
	}
	return allocations;
}
function toPerLine(allocations) {
	return [...allocations.entries()].filter(([, amount]) => amount > 0n).map(([lineId, amount]) => ({
		lineId,
		amount
	})).sort((left, right) => left.lineId.localeCompare(right.lineId));
}
function assertLine(line) {
	if (line.unitPrice < 0n) throw new PromoValidationError("line.unitPrice");
	if (!Number.isInteger(line.qty) || line.qty < 0) throw new PromoValidationError("line.qty");
}
function applyPromo(promo, cart) {
	assertPromoShape(promo);
	for (const line of cart.lines) assertLine(line);
	const scopedLines = filterScopedLines(cart, promo.scope);
	const scopedSubtotal = scopedSubtotalOf(scopedLines);
	if (scopedLines.length === 0 || scopedSubtotal === 0n) return emptyResult(promo.funder);
	switch (promo.kind) {
		case "percentage": {
			const total = clampDiscount(percentageDiscount(scopedSubtotal, promo.valueBps), promo.maxDiscountAmount);
			return {
				total,
				perLine: toPerLine(allocateLargestRemainder(scopedLines, total, scopedSubtotal)),
				funder: promo.funder
			};
		}
		case "fixed": {
			const total = fixedDiscount(scopedSubtotal, promo.valueAmount);
			return {
				total,
				perLine: toPerLine(allocateLargestRemainder(scopedLines, total, scopedSubtotal)),
				funder: promo.funder
			};
		}
		case "bogo": {
			const { buyQty, getQty } = promo.bogo;
			const { total, allocations } = bogoAllocations(scopedLines, buyQty, getQty);
			return {
				total,
				perLine: toPerLine(allocations),
				funder: promo.funder
			};
		}
	}
}
//#endregion
//#region src/migrate.ts
var PromoMigrateError = class extends Error {
	detail;
	name = "PromoMigrateError";
	code = "PROMO_MIGRATE";
	constructor(detail) {
		super(`promo migrate: ${detail}`);
		this.detail = detail;
	}
};
function isPromoMigrateError(e) {
	return typeof e === "object" && e !== null && e.name === "PromoMigrateError" && e.code === "PROMO_MIGRATE";
}
const MIGRATION_STATEMENTS = [
	sql`
    CREATE TABLE IF NOT EXISTS promo (
      id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
      code text NOT NULL,
      kind text NOT NULL,
      value_bps integer,
      value_amount bigint,
      currency text,
      max_discount_amount bigint,
      bogo_buy_qty integer,
      bogo_get_qty integer,
      scope jsonb NOT NULL,
      eligibility jsonb NOT NULL,
      funder text NOT NULL,
      max_uses integer,
      per_user_cap integer,
      uses integer NOT NULL DEFAULT 0,
      starts_at timestamptz,
      ends_at timestamptz,
      min_order_amount bigint,
      max_order_amount bigint,
      active boolean NOT NULL DEFAULT true,
      vendor_id text,
      created_at timestamptz NOT NULL DEFAULT NOW(),
      updated_at timestamptz NOT NULL DEFAULT NOW(),
      CONSTRAINT promo_value_bps_chk CHECK (
        value_bps IS NULL OR (value_bps > 0 AND value_bps <= 10000)
      ),
      CONSTRAINT promo_value_amount_chk CHECK (value_amount IS NULL OR value_amount > 0),
      CONSTRAINT promo_max_discount_amount_chk CHECK (
        max_discount_amount IS NULL OR max_discount_amount > 0
      ),
      CONSTRAINT promo_max_order_amount_chk CHECK (
        max_order_amount IS NULL OR max_order_amount > 0
      ),
      CONSTRAINT promo_max_uses_chk CHECK (max_uses IS NULL OR max_uses > 0),
      CONSTRAINT promo_per_user_cap_chk CHECK (per_user_cap IS NULL OR per_user_cap > 0)
    )
  `,
	sql`
    ALTER TABLE promo
    ADD COLUMN IF NOT EXISTS max_discount_amount bigint
  `,
	sql`
    ALTER TABLE promo
    ADD COLUMN IF NOT EXISTS max_order_amount bigint
  `,
	sql`
    DO $$
    BEGIN
      ALTER TABLE promo
      ADD CONSTRAINT promo_max_discount_amount_chk
      CHECK (max_discount_amount IS NULL OR max_discount_amount > 0);
    EXCEPTION WHEN duplicate_object THEN
      NULL;
    END
    $$
  `,
	sql`
    DO $$
    BEGIN
      ALTER TABLE promo
      ADD CONSTRAINT promo_max_order_amount_chk
      CHECK (max_order_amount IS NULL OR max_order_amount > 0);
    EXCEPTION WHEN duplicate_object THEN
      NULL;
    END
    $$
  `,
	sql`
    CREATE TABLE IF NOT EXISTS promo_user_usage (
      promo_id uuid NOT NULL,
      user_id text NOT NULL,
      uses integer NOT NULL DEFAULT 0,
      created_at timestamptz NOT NULL DEFAULT NOW(),
      updated_at timestamptz NOT NULL DEFAULT NOW(),
      PRIMARY KEY (promo_id, user_id)
    )
  `,
	sql`
    CREATE UNIQUE INDEX IF NOT EXISTS uq_promo_code_platform
    ON promo (code)
    WHERE vendor_id IS NULL
  `,
	sql`
    CREATE UNIQUE INDEX IF NOT EXISTS uq_promo_code_vendor
    ON promo (code, vendor_id)
    WHERE vendor_id IS NOT NULL
  `
];
async function pushSchema(db) {
	try {
		for (const statement of MIGRATION_STATEMENTS) await db.execute(statement);
	} catch (e) {
		throw new PromoMigrateError(e instanceof Error ? e.message : String(e));
	}
}
//#endregion
//#region src/schema.ts
const promo = pgTable("promo", {
	id: uuid("id").primaryKey().defaultRandom(),
	code: text("code").notNull(),
	kind: text("kind").$type().notNull(),
	valueBps: integer("value_bps"),
	valueAmount: bigint("value_amount", { mode: "bigint" }),
	currency: text("currency"),
	maxDiscountAmount: bigint("max_discount_amount", { mode: "bigint" }),
	bogoBuyQty: integer("bogo_buy_qty"),
	bogoGetQty: integer("bogo_get_qty"),
	scope: jsonb("scope").$type().notNull(),
	eligibility: jsonb("eligibility").$type().notNull(),
	funder: text("funder").$type().notNull(),
	maxUses: integer("max_uses"),
	perUserCap: integer("per_user_cap"),
	uses: integer("uses").notNull().default(0),
	startsAt: timestamp("starts_at", { withTimezone: true }),
	endsAt: timestamp("ends_at", { withTimezone: true }),
	minOrderAmount: bigint("min_order_amount", { mode: "bigint" }),
	maxOrderAmount: bigint("max_order_amount", { mode: "bigint" }),
	active: boolean("active").notNull().default(true),
	vendorId: text("vendor_id"),
	createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
	updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
});
const promoUserUsage = pgTable("promo_user_usage", {
	promoId: uuid("promo_id").notNull(),
	userId: text("user_id").notNull(),
	uses: integer("uses").notNull().default(0),
	createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
	updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
}, (t) => [primaryKey({ columns: [t.promoId, t.userId] })]);
const promotionsSchema = {
	promo,
	promoUserUsage
};
//#endregion
//#region src/reads.ts
function rowToPromo(row) {
	const kind = row.kind;
	const result = {
		id: row.id,
		code: row.code,
		kind,
		scope: row.scope,
		eligibility: row.eligibility,
		funder: row.funder,
		active: row.active,
		vendorId: row.vendorId ?? null
	};
	if (row.valueBps != null) result.valueBps = row.valueBps;
	if (row.valueAmount != null) result.valueAmount = row.valueAmount;
	if (row.currency != null) result.currency = row.currency;
	if (row.maxDiscountAmount != null) result.maxDiscountAmount = row.maxDiscountAmount;
	if (kind === "bogo" && row.bogoBuyQty != null && row.bogoGetQty != null) result.bogo = {
		buyQty: row.bogoBuyQty,
		getQty: row.bogoGetQty
	};
	if (row.maxUses != null) result.maxUses = row.maxUses;
	if (row.perUserCap != null) result.perUserCap = row.perUserCap;
	if (row.startsAt != null) result.startsAt = row.startsAt;
	if (row.endsAt != null) result.endsAt = row.endsAt;
	if (row.minOrderAmount != null) result.minOrderAmount = row.minOrderAmount;
	if (row.maxOrderAmount != null) result.maxOrderAmount = row.maxOrderAmount;
	return result;
}
async function getPromoByCode(q, code, vendorId) {
	const where = vendorId != null && vendorId !== void 0 ? and(eq(promo.code, code), eq(promo.vendorId, vendorId)) : and(eq(promo.code, code), isNull(promo.vendorId));
	const [row] = await q.select().from(promo).where(where).limit(1);
	if (!row) return null;
	return rowToPromo(row);
}
//#endregion
//#region src/record-redemption.ts
function firstRow(res) {
	return ((Array.isArray(res) ? res : res.rows) ?? [])[0];
}
async function recordRedemption(tx, promoId, userId) {
	assertUuid(promoId, "promoId");
	const capRow = firstRow(await tx.execute(sql`
    SELECT per_user_cap
    FROM promo
    WHERE id = ${promoId}::uuid
  `));
	const perUserCap = capRow?.per_user_cap === null || capRow?.per_user_cap === void 0 ? null : Number(capRow.per_user_cap);
	if (userId !== null && perUserCap !== null) {
		if (!firstRow(await tx.execute(sql`
      INSERT INTO promo_user_usage (promo_id, user_id, uses)
      VALUES (${promoId}::uuid, ${userId}, 1)
      ON CONFLICT (promo_id, user_id)
      DO UPDATE SET
        uses = promo_user_usage.uses + 1,
        updated_at = NOW()
      WHERE promo_user_usage.uses < ${perUserCap}
      RETURNING uses
    `))) throw new PromoQuotaExhaustedError("per_user");
	}
	if (!firstRow(await tx.execute(sql`
    UPDATE promo
    SET uses = uses + 1, updated_at = NOW()
    WHERE id = ${promoId}::uuid
      AND (max_uses IS NULL OR uses < max_uses)
    RETURNING uses
  `))) throw new PromoQuotaExhaustedError("global");
}
//#endregion
export { PromoMigrateError, PromoQuotaExhaustedError, PromoValidationError, applyPromo, getPromoByCode, isPromoMigrateError, isPromoQuotaExhaustedError, isPromoValidationError, promo, promoUserUsage, promotionsSchema, pushSchema, recordRedemption, validatePromo };
