// @vitest-environment node
/**
 * Maturity sweep oracle —
 * ledger-clawback.int.test.ts (pending clawback netting), maturation-logic.test.ts,
 * and reconcile-wallet.test.ts (withdrawable bucket isolation).
 */
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'
import { sql, eq } from 'drizzle-orm'
import { appendEntry } from '@platform-modules/ledger'
import { getVesting, ledgerEntryVesting } from '@platform-modules/ledger/vesting'
import { makeTestDb, closeTestDb, resetTables, type TestDb } from './test/pglite-db.js'
import {
  createUser,
  createReferralLink,
  createReferral,
} from './test/fixtures.js'
import { createPgliteMaturityHostStore } from './test/maturity-host-store-fixture.js'
import { computeMatureAt } from './accrual.js'
import { computeWithdrawableAt } from './maturity-sweep.js'
import { affiliateEntriesTable } from './schema.js'

const DAY_MS = 86_400_000

const getMaturitySweep = () => import('./maturity-sweep.js')
const getAccrual = () => import('./accrual.js')

let testDb: TestDb

async function runMaturationSweep() {
  const { sweepMaturation } = await getMaturitySweep()
  return testDb.transaction(async (tx) => {
    const host = createPgliteMaturityHostStore(tx)
    return sweepMaturation(tx, host)
  })
}

async function runWithdrawableSweep() {
  const { sweepWithdrawable } = await getMaturitySweep()
  return testDb.transaction(async (tx) => {
    const host = createPgliteMaturityHostStore(tx)
    return sweepWithdrawable(tx, host)
  })
}

async function accrueEarn(
  db: TestDb,
  input: {
    userId: string
    amountMinor: bigint
    entryType: 'affiliate_commission' | 'referral_reward'
    sourceType: string
    sourceId: string
    matureAt: Date
  },
) {
  const { accrueCommission } = await getAccrual()
  return db.transaction((tx) => accrueCommission(tx, input))
}

/** Accrue as pending, then set mature_at for sweep candidacy without born-matured sweptAt. */
async function seedSweepCandidateEarn(
  db: TestDb,
  input: {
    userId: string
    amountMinor: bigint
    entryType: 'affiliate_commission' | 'referral_reward'
    sourceType: string
    sourceId: string
    matureAt?: Date
  },
) {
  const matureAt = input.matureAt ?? new Date(Date.now() - DAY_MS)
  const result = await accrueEarn(db, {
    ...input,
    matureAt: new Date(Date.now() + 30 * DAY_MS),
  })

  await db.execute(sql`
    UPDATE ledger_entry_vesting
    SET mature_at = ${matureAt.toISOString()}
    WHERE entry_id = (
      SELECT entry_id FROM affiliate_entries
      WHERE source_type = ${input.sourceType} AND source_id = ${input.sourceId}
        AND entry_type = ${input.entryType}
    )
  `)

  return result
}

async function entryIdForSource(db: TestDb, sourceId: string, entryType?: string) {
  const rows = await db
    .select()
    .from(affiliateEntriesTable)
    .where(eq(affiliateEntriesTable.sourceId, sourceId))
  const row = entryType ? rows.find((r) => r.entryType === entryType) : rows[0]
  return row!.entryId
}

async function seedMaturedEarnForWithdrawable(
  db: TestDb,
  input: {
    userId: string
    amountMinor: bigint
    entryType: 'affiliate_commission' | 'referral_reward'
    sourceType: string
    sourceId: string
    referralId?: string
    bornMatured?: boolean
  },
) {
  const pastMature = new Date(Date.now() - DAY_MS)
  if (input.bornMatured) {
    await accrueEarn(db, { ...input, matureAt: pastMature })
    return entryIdForSource(db, input.sourceId, input.entryType)
  }
  await seedSweepCandidateEarn(db, { ...input, matureAt: pastMature })
  await runMaturationSweep()
  return entryIdForSource(db, input.sourceId, input.entryType)
}

/** Clawback side-row only — for I0 partition tests (no bucket pre-decrement). */
async function seedClawbackSideRowOnly(
  db: TestDb,
  args: {
    ownerId: string
    clawbackMinor: bigint
    sourceType: string
    earnSourceId: string
  },
) {
  const sourceId = `clawback:${args.earnSourceId}`
  return db.transaction(async (tx) => {
    const { inserted, id } = await appendEntry(tx, {
      key: `refund_clawback:${args.sourceType}:${sourceId}`,
      delta: -args.clawbackMinor,
      reason: 'refund_clawback',
      ref: { userId: args.ownerId, sourceType: args.sourceType, sourceId },
    })
    if (!inserted || id === null) throw new Error('clawback insert failed')

    await tx.insert(affiliateEntriesTable).values({
      entryId: id,
      ownerId: args.ownerId,
      entryType: 'refund_clawback',
      sourceType: args.sourceType,
      sourceId,
    })

    return { entryId: id }
  })
}

/** Host-side pending clawback seed — mirrors W9 clawback.ts bucket pre-decrement. */
async function seedPendingClawback(
  db: TestDb,
  args: {
    ownerId: string
    clawbackMinor: bigint
    sourceType: string
    earnSourceId: string
    eventSuffix?: string
  },
) {
  const sourceId = args.eventSuffix
    ? `clawback:${args.earnSourceId}:${args.eventSuffix}`
    : `clawback:${args.earnSourceId}`

  return db.transaction(async (tx) => {
    const { inserted, id } = await appendEntry(tx, {
      key: `refund_clawback:${args.sourceType}:${sourceId}`,
      delta: -args.clawbackMinor,
      reason: 'refund_clawback',
      ref: { userId: args.ownerId, sourceType: args.sourceType, sourceId },
    })
    if (!inserted || id === null) throw new Error('clawback insert failed')

    await tx.insert(affiliateEntriesTable).values({
      entryId: id,
      ownerId: args.ownerId,
      entryType: 'refund_clawback',
      sourceType: args.sourceType,
      sourceId,
    })

    await tx.execute(sql`
      UPDATE wallet_vesting
      SET pending_minor = GREATEST(0, pending_minor - ${args.clawbackMinor}),
          updated_at = NOW()
      WHERE owner_id = ${args.ownerId}
    `)

    return { entryId: id }
  })
}

async function getVestingRow(db: TestDb, entryId: string) {
  const rows = await db
    .select()
    .from(ledgerEntryVesting)
    .where(eq(ledgerEntryVesting.entryId, entryId))
  return rows[0]
}

async function getAffiliateSideRow(db: TestDb, entryId: string) {
  const rows = await db
    .select()
    .from(affiliateEntriesTable)
    .where(eq(affiliateEntriesTable.entryId, entryId))
  return rows[0]
}

async function createDeal(
  db: TestDb,
  overrides?: Partial<{ dealType: string; windowEnd: Date }>,
): Promise<{ id: string }> {
  const id = crypto.randomUUID()
  const dealType = overrides?.dealType ?? 'COUPON'
  const windowEnd = overrides?.windowEnd ?? new Date(Date.now() - 7 * DAY_MS)
  await db.execute(sql`
    INSERT INTO deals (id, deal_type, window_end)
    VALUES (${id}, ${dealType}, ${windowEnd.toISOString()})
  `)
  return { id }
}

async function createPurchase(
  db: TestDb,
  args: {
    userId: string
    dealId: string
    redeemedAt?: Date | null
    createdAt?: Date
  },
): Promise<{ id: string }> {
  const id = crypto.randomUUID()
  const redeemedAt = args.redeemedAt ?? null
  const createdAt = args.createdAt ?? new Date()
  await db.execute(sql`
    INSERT INTO purchases (id, user_id, deal_id, payment_status, redeemed_at, created_at)
    VALUES (
      ${id},
      ${args.userId},
      ${args.dealId},
      'PAID',
      ${redeemedAt ? redeemedAt.toISOString() : null},
      ${createdAt.toISOString()}
    )
  `)
  return { id }
}

async function createEnrollment(db: TestDb, userId: string): Promise<{ id: string }> {
  const id = crypto.randomUUID()
  await db.execute(sql`
    INSERT INTO affiliate_enrollments (id, user_id, status)
    VALUES (${id}, ${userId}, 'active')
  `)
  return { id }
}

// ─── computeWithdrawableAt unit (ported from maturation-logic style) ─────────

describe('computeWithdrawableAt', () => {
  const purchaseCreatedAt = new Date('2026-01-01T00:00:00Z')
  const redeemedAt = new Date('2026-01-15T00:00:00Z')

  it('coupon + redeemed → redeemedAt + disputeWindowDays', () => {
    const result = computeWithdrawableAt(
      { kind: 'coupon', redeemedAt, purchaseCreatedAt },
      14,
    )
    expect(result!.getTime()).toBe(redeemedAt.getTime() + 14 * DAY_MS)
  })

  it('coupon + unredeemed → null', () => {
    expect(
      computeWithdrawableAt({ kind: 'coupon', redeemedAt: null, purchaseCreatedAt }, 30),
    ).toBeNull()
  })

  it('physical → purchaseCreatedAt + disputeWindowDays', () => {
    const result = computeWithdrawableAt(
      { kind: 'physical', redeemedAt: null, purchaseCreatedAt },
      30,
    )
    expect(result!.getTime()).toBe(purchaseCreatedAt.getTime() + 30 * DAY_MS)
  })
})

// ─── computeMatureAt unit ───────

describe('computeMatureAt — coupon + redeemed', () => {
  const paidAt = new Date('2026-01-01T00:00:00Z')
  const expiresAt = new Date('2026-02-01T00:00:00Z')
  const redeemedAt = new Date('2026-01-15T00:00:00Z')

  it('coupon + redeemedAt: anchor = redeemedAt + holdDays', () => {
    const result = computeMatureAt(
      { kind: 'coupon', paidAt, expiresAt, redeemedAt },
      { holdDays: 14 },
    )
    expect(result.getTime()).toBe(redeemedAt.getTime() + 14 * DAY_MS)
  })
})

describe('computeMatureAt — physical', () => {
  const paidAt = new Date('2026-01-01T00:00:00Z')

  it('physical → paidAt + holdDays', () => {
    const result = computeMatureAt(
      { kind: 'physical', paidAt, expiresAt: null, redeemedAt: null },
      { holdDays: 30 },
    )
    expect(result.getTime()).toBe(paidAt.getTime() + 30 * DAY_MS)
  })
})

// ─── sweepMaturation integration (design §4 I0/P1–P4) ────────────────────────

describe('sweepMaturation integration', () => {
  beforeAll(async () => {
    testDb = await makeTestDb()
  })

  afterAll(async () => {
    await closeTestDb()
  })

  beforeEach(async () => {
    await resetTables(testDb)
  })

  it('returns { promoted: 0, recomputed: 0 } when no rows are ready', async () => {
    const result = await runMaturationSweep()
    expect(result.promoted).toBe(0)
    expect(result.recomputed).toBe(0)
    expect(result.sweptAt).toBeInstanceOf(Date)
  })

  it('returns { promoted: 0, recomputed: 0 } when only future-mature rows exist', async () => {
    const { id: userId } = await createUser(testDb)
    await accrueEarn(testDb, {
      userId,
      amountMinor: 500n,
      entryType: 'referral_reward',
      sourceType: 'referral',
      sourceId: crypto.randomUUID(),
      matureAt: new Date(Date.now() + 30 * DAY_MS),
    })

    const result = await runMaturationSweep()
    expect(result.promoted).toBe(0)
    expect(result.recomputed).toBe(0)
  })

  it('P1/P3 happy path — promotes matured earns and moves net pending→matured', async () => {
    const { id: userId } = await createUser(testDb)
    const sourceId = crypto.randomUUID()

    const earn = await seedSweepCandidateEarn(testDb, {
      userId,
      amountMinor: 1000n,
      entryType: 'referral_reward',
      sourceType: 'referral',
      sourceId,
    })

    const entryId = await entryIdForSource(testDb, sourceId)

    await runMaturationSweep()

    const vestingRow = await getVestingRow(testDb, entryId)
    expect(vestingRow?.sweptAt).not.toBeNull()

    const vesting = await getVesting(testDb, userId)
    expect(vesting.pendingMinor).toBe(0n)
    expect(vesting.maturedMinor).toBe(1000n)
    expect(earn.state).toBe('pending')
  })

  it('bounded clawback query — nets matching clawback only; unrelated orphan untouched', async () => {
    const { id: userId } = await createUser(testDb)
    const sourceId = crypto.randomUUID()
    const orphanEarnSourceId = crypto.randomUUID()

    await seedSweepCandidateEarn(testDb, {
      userId,
      amountMinor: 1000n,
      entryType: 'referral_reward',
      sourceType: 'referral',
      sourceId,
    })

    const { entryId: matchingClawbackId } = await seedPendingClawback(testDb, {
      ownerId: userId,
      clawbackMinor: 300n,
      sourceType: 'referral',
      earnSourceId: sourceId,
    })

    const { entryId: orphanClawbackId } = await seedClawbackSideRowOnly(testDb, {
      ownerId: userId,
      clawbackMinor: 500n,
      sourceType: 'referral',
      earnSourceId: orphanEarnSourceId,
    })

    await runMaturationSweep()

    const vesting = await getVesting(testDb, userId)
    expect(vesting.maturedMinor).toBe(700n)
    expect(vesting.pendingMinor).toBe(0n)

    const matching = await getAffiliateSideRow(testDb, matchingClawbackId)
    expect(matching?.consumedAt).not.toBeNull()

    const orphan = await getAffiliateSideRow(testDb, orphanClawbackId)
    expect(orphan?.consumedAt).toBeNull()
  })

  it('P0 — backslash in earn sourceId nets partial-refund clawback (literal prefix, not LIKE)', async () => {
    const { id: userId } = await createUser(testDb)
    // I0 allows backslash; Postgres default LIKE treats `\` as escape → drops real matches.
    const sourceId = 'x\\y'

    await seedSweepCandidateEarn(testDb, {
      userId,
      amountMinor: 1000n,
      entryType: 'referral_reward',
      sourceType: 'referral',
      sourceId,
    })

    const { entryId: clawbackId } = await seedPendingClawback(testDb, {
      ownerId: userId,
      clawbackMinor: 300n,
      sourceType: 'referral',
      earnSourceId: sourceId,
      eventSuffix: 'evt1',
    })

    await runMaturationSweep()

    const vesting = await getVesting(testDb, userId)
    expect(vesting.maturedMinor).toBe(700n) // 1000 − 300 — NOT full 1000 (money-inflation P0)
    expect(vesting.pendingMinor).toBe(0n)

    const clawback = await getAffiliateSideRow(testDb, clawbackId)
    expect(clawback?.consumedAt).not.toBeNull()
  })

  it('metachar defense-in-depth — % and _ in earn sourceId nets suffixed clawback', async () => {
    const { id: userId } = await createUser(testDb)
    const sourceId = 'a%b_c'

    await seedSweepCandidateEarn(testDb, {
      userId,
      amountMinor: 800n,
      entryType: 'referral_reward',
      sourceType: 'referral',
      sourceId,
    })

    const { entryId: clawbackId } = await seedPendingClawback(testDb, {
      ownerId: userId,
      clawbackMinor: 250n,
      sourceType: 'referral',
      earnSourceId: sourceId,
      eventSuffix: 'evt1',
    })

    await runMaturationSweep()

    const vesting = await getVesting(testDb, userId)
    expect(vesting.maturedMinor).toBe(550n)
    expect(vesting.pendingMinor).toBe(0n)

    const clawback = await getAffiliateSideRow(testDb, clawbackId)
    expect(clawback?.consumedAt).not.toBeNull()
  })

  it('partial-refund LIKE-ANY arm — two suffixed clawbacks against one earn both net + consume', async () => {
    const { id: userId } = await createUser(testDb)
    const sourceId = crypto.randomUUID()

    await seedSweepCandidateEarn(testDb, {
      userId,
      amountMinor: 1000n,
      entryType: 'referral_reward',
      sourceType: 'referral',
      sourceId,
    })

    // clawback:<sourceId>:evt1 and :evt2 — only the starts_with prefix arm loads these (the
    // exact arm matches clawback:<sourceId> with no suffix). Proves prefix arm loads BOTH.
    const { entryId: cb1Id } = await seedPendingClawback(testDb, {
      ownerId: userId,
      clawbackMinor: 200n,
      sourceType: 'referral',
      earnSourceId: sourceId,
      eventSuffix: 'evt1',
    })
    const { entryId: cb2Id } = await seedPendingClawback(testDb, {
      ownerId: userId,
      clawbackMinor: 150n,
      sourceType: 'referral',
      earnSourceId: sourceId,
      eventSuffix: 'evt2',
    })

    await runMaturationSweep()

    const vesting = await getVesting(testDb, userId)
    expect(vesting.maturedMinor).toBe(650n) // 1000 − (200 + 150)
    expect(vesting.pendingMinor).toBe(0n)

    const cb1 = await getAffiliateSideRow(testDb, cb1Id)
    const cb2 = await getAffiliateSideRow(testDb, cb2Id)
    expect(cb1?.consumedAt).not.toBeNull()
    expect(cb2?.consumedAt).not.toBeNull()
  })

  it('P2 clawback once — nets clawback, sets consumedAt, second sweep does not re-net', async () => {
    const { id: userId } = await createUser(testDb)
    const sourceId = crypto.randomUUID()

    await seedSweepCandidateEarn(testDb, {
      userId,
      amountMinor: 1000n,
      entryType: 'referral_reward',
      sourceType: 'referral',
      sourceId,
    })

    const { entryId: clawbackId } = await seedPendingClawback(testDb, {
      ownerId: userId,
      clawbackMinor: 400n,
      sourceType: 'referral',
      earnSourceId: sourceId,
    })

    await runMaturationSweep()

    const vesting = await getVesting(testDb, userId)
    expect(vesting.pendingMinor).toBe(0n)
    expect(vesting.maturedMinor).toBe(600n)

    const clawback = await getAffiliateSideRow(testDb, clawbackId)
    expect(clawback?.consumedAt).not.toBeNull()

    const beforeSecond = await getVesting(testDb, userId)
    const second = await runMaturationSweep()
    expect(second.promoted).toBe(0)
    const afterSecond = await getVesting(testDb, userId)
    expect(afterSecond.maturedMinor).toBe(beforeSecond.maturedMinor)
    expect(afterSecond.pendingMinor).toBe(beforeSecond.pendingMinor)
  })

  it('I0 — purchase clawback never matches referral_reward earn (source_type partition)', async () => {
    const { id: userId } = await createUser(testDb)
    const sharedSourceId = crypto.randomUUID()

    await seedSweepCandidateEarn(testDb, {
      userId,
      amountMinor: 800n,
      entryType: 'referral_reward',
      sourceType: 'referral',
      sourceId: sharedSourceId,
    })

    await seedClawbackSideRowOnly(testDb, {
      ownerId: userId,
      clawbackMinor: 300n,
      sourceType: 'purchase',
      earnSourceId: sharedSourceId,
    })

    await runMaturationSweep()

    const vesting = await getVesting(testDb, userId)
    expect(vesting.maturedMinor).toBe(800n)
    expect(vesting.pendingMinor).toBe(0n)

    const clawbacks = await testDb
      .select()
      .from(affiliateEntriesTable)
      .where(eq(affiliateEntriesTable.entryType, 'refund_clawback'))
    expect(clawbacks[0]!.consumedAt).toBeNull()
  })

  it('clawback 1:1 cardinality — purchase clawback nets commission earn only, not referral_reward', async () => {
    const { id: userId } = await createUser(testDb)
    const sharedX = crypto.randomUUID()

    await seedSweepCandidateEarn(testDb, {
      userId,
      amountMinor: 500n,
      entryType: 'affiliate_commission',
      sourceType: 'purchase',
      sourceId: sharedX,
    })

    await seedSweepCandidateEarn(testDb, {
      userId,
      amountMinor: 700n,
      entryType: 'referral_reward',
      sourceType: 'referral',
      sourceId: sharedX,
    })

    const referralEntryId = (
      await testDb
        .select()
        .from(affiliateEntriesTable)
        .where(eq(affiliateEntriesTable.sourceId, sharedX))
    ).find((r) => r.entryType === 'referral_reward')!.entryId

    await seedPendingClawback(testDb, {
      ownerId: userId,
      clawbackMinor: 200n,
      sourceType: 'purchase',
      earnSourceId: sharedX,
    })

    await runMaturationSweep()

    const vesting = await getVesting(testDb, userId)
    expect(vesting.maturedMinor).toBe(1000n)
    expect(vesting.pendingMinor).toBe(0n)

    const referralVesting = await getVestingRow(testDb, referralEntryId)
    expect(referralVesting?.sweptAt).not.toBeNull()
  })

  it('orphan clawback — matched earn already swept stays unconsumed and moves no money', async () => {
    const { id: userId } = await createUser(testDb)
    const sourceId = crypto.randomUUID()

    await seedSweepCandidateEarn(testDb, {
      userId,
      amountMinor: 900n,
      entryType: 'affiliate_commission',
      sourceType: 'purchase',
      sourceId,
    })

    await runMaturationSweep()

    const before = await getVesting(testDb, userId)
    expect(before.maturedMinor).toBe(900n)

    const { entryId: clawbackId } = await seedPendingClawback(testDb, {
      ownerId: userId,
      clawbackMinor: 100n,
      sourceType: 'purchase',
      earnSourceId: sourceId,
    })

    await runMaturationSweep()

    const after = await getVesting(testDb, userId)
    expect(after.maturedMinor).toBe(before.maturedMinor)
    expect(after.pendingMinor).toBe(before.pendingMinor)

    const clawback = await getAffiliateSideRow(testDb, clawbackId)
    expect(clawback?.consumedAt).toBeNull()
  })

  it('P4 — net exceeding pending aborts tx; buckets unchanged', async () => {
    const { id: userId } = await createUser(testDb)
    const sourceId = crypto.randomUUID()

    await seedSweepCandidateEarn(testDb, {
      userId,
      amountMinor: 500n,
      entryType: 'referral_reward',
      sourceType: 'referral',
      sourceId,
    })

    await testDb.execute(sql`
      UPDATE wallet_vesting SET pending_minor = 100 WHERE owner_id = ${userId}
    `)

    await expect(runMaturationSweep()).rejects.toThrow()

    const vesting = await getVesting(testDb, userId)
    expect(vesting.pendingMinor).toBe(100n)
    expect(vesting.maturedMinor).toBe(0n)

    const entryId = (
      await testDb.select().from(affiliateEntriesTable).where(eq(affiliateEntriesTable.sourceId, sourceId))
    )[0]!.entryId
    const vestingRow = await getVestingRow(testDb, entryId)
    expect(vestingRow?.sweptAt).toBeNull()
  })

  it('P1 disjoint — concurrent sweeps SKIP LOCKED without double-promote', async () => {
    const { id: userId } = await createUser(testDb)

    await seedSweepCandidateEarn(testDb, {
      userId,
      amountMinor: 400n,
      entryType: 'referral_reward',
      sourceType: 'referral',
      sourceId: crypto.randomUUID(),
    })
    await seedSweepCandidateEarn(testDb, {
      userId,
      amountMinor: 600n,
      entryType: 'referral_reward',
      sourceType: 'referral',
      sourceId: crypto.randomUUID(),
    })

    const barrier = { ready: 0, go: false }
    const runConcurrent = async () => {
      barrier.ready++
      while (!barrier.go) {
        await new Promise((r) => setTimeout(r, 5))
      }
      return runMaturationSweep()
    }

    const p1 = runConcurrent()
    const p2 = runConcurrent()
    while (barrier.ready < 2) {
      await new Promise((r) => setTimeout(r, 5))
    }
    barrier.go = true

    const [r1, r2] = await Promise.all([p1, p2])
    expect(r1.promoted + r2.promoted).toBe(2)

    const vesting = await getVesting(testDb, userId)
    expect(vesting.maturedMinor).toBe(1000n)
    expect(vesting.pendingMinor).toBe(0n)
  })

  it('born-matured exclusion — instant-mature earn not re-promoted by sweep', async () => {
    const { id: userId } = await createUser(testDb)
    const pastMature = new Date(Date.now() - DAY_MS)
    const futureMature = new Date(Date.now() + 10 * DAY_MS)

    await accrueEarn(testDb, {
      userId,
      amountMinor: 300n,
      entryType: 'affiliate_commission',
      sourceType: 'purchase',
      sourceId: 'born-matured-src',
      matureAt: pastMature,
    })

    await accrueEarn(testDb, {
      userId,
      amountMinor: 700n,
      entryType: 'referral_reward',
      sourceType: 'referral',
      sourceId: 'future-src',
      matureAt: futureMature,
    })

    const bornEntryId = (
      await testDb
        .select()
        .from(affiliateEntriesTable)
        .where(eq(affiliateEntriesTable.sourceId, 'born-matured-src'))
    )[0]!.entryId

    const bornVestingBefore = await getVestingRow(testDb, bornEntryId)
    expect(bornVestingBefore?.sweptAt).not.toBeNull()

    const before = await getVesting(testDb, userId)
    expect(before.maturedMinor).toBe(300n)
    expect(before.pendingMinor).toBe(700n)

    await testDb.execute(sql`
      UPDATE ledger_entry_vesting
      SET mature_at = ${pastMature.toISOString()}
      WHERE entry_id = (
        SELECT entry_id FROM affiliate_entries WHERE source_id = 'future-src'
      )
    `)

    await runMaturationSweep()

    const after = await getVesting(testDb, userId)
    expect(after.maturedMinor).toBe(1000n)
    expect(after.pendingMinor).toBe(0n)

    const bornVestingAfter = await getVestingRow(testDb, bornEntryId)
    expect(bornVestingAfter?.sweptAt?.getTime()).toBe(bornVestingBefore?.sweptAt?.getTime())
  })

  it('earn-owner coverage — fully-cancelled owner still sweeps earns with zero-net delta', async () => {
    const { id: userId } = await createUser(testDb)
    const sourceId = crypto.randomUUID()

    await seedSweepCandidateEarn(testDb, {
      userId,
      amountMinor: 1000n,
      entryType: 'referral_reward',
      sourceType: 'referral',
      sourceId,
    })

    await seedPendingClawback(testDb, {
      ownerId: userId,
      clawbackMinor: 1000n,
      sourceType: 'referral',
      earnSourceId: sourceId,
    })

    await runMaturationSweep()

    const vesting = await getVesting(testDb, userId)
    expect(vesting.pendingMinor).toBe(0n)
    expect(vesting.maturedMinor).toBe(0n)

    const entryId = (
      await testDb.select().from(affiliateEntriesTable).where(eq(affiliateEntriesTable.sourceId, sourceId))
    )[0]!.entryId
    const vestingRow = await getVestingRow(testDb, entryId)
    expect(vestingRow?.sweptAt).not.toBeNull()
  })

  it('promotes only the mature row and leaves the future row untouched', async () => {
    const { id: userId } = await createUser(testDb)

    await seedSweepCandidateEarn(testDb, {
      userId,
      amountMinor: 1000n,
      entryType: 'referral_reward',
      sourceType: 'referral',
      sourceId: 'mature-src',
    })

    await accrueEarn(testDb, {
      userId,
      amountMinor: 500n,
      entryType: 'referral_reward',
      sourceType: 'referral',
      sourceId: 'future-src',
      matureAt: new Date(Date.now() + 30 * DAY_MS),
    })

    await runMaturationSweep()

    const matureEntryId = (
      await testDb
        .select()
        .from(affiliateEntriesTable)
        .where(eq(affiliateEntriesTable.sourceId, 'mature-src'))
    )[0]!.entryId
    const futureEntryId = (
      await testDb
        .select()
        .from(affiliateEntriesTable)
        .where(eq(affiliateEntriesTable.sourceId, 'future-src'))
    )[0]!.entryId

    expect((await getVestingRow(testDb, matureEntryId))?.sweptAt).not.toBeNull()
    expect((await getVestingRow(testDb, futureEntryId))?.sweptAt).toBeNull()

    const vesting = await getVesting(testDb, userId)
    expect(vesting.maturedMinor).toBe(1000n)
    expect(vesting.pendingMinor).toBe(500n)
  })

  it('recompute write-branch is load-bearing: future mature_at + since-redeemed coupon promotes ONLY via recompute', async () => {
    const { id: userId } = await createUser(testDb)

    await seedSweepCandidateEarn(testDb, {
      userId,
      amountMinor: 500n,
      entryType: 'referral_reward',
      sourceType: 'referral',
      sourceId: crypto.randomUUID(),
    })

    const deal = await createDeal(testDb, { dealType: 'COUPON' })
    const redeemedAt = new Date(Date.now() - 40 * DAY_MS)
    const purchase = await createPurchase(testDb, {
      userId,
      dealId: deal.id,
      redeemedAt,
    })
    const futureMature = new Date(Date.now() + 10 * DAY_MS)

    await accrueEarn(testDb, {
      userId,
      amountMinor: 750n,
      entryType: 'affiliate_commission',
      sourceType: 'purchase',
      sourceId: purchase.id,
      matureAt: futureMature,
    })

    const rowAEntryId = await entryIdForSource(
      testDb,
      purchase.id,
      'affiliate_commission',
    )

    const result = await runMaturationSweep()
    expect(result.recomputed).toBe(1)

    const rowAVesting = await getVestingRow(testDb, rowAEntryId)
    expect(rowAVesting!.matureAt!.getTime()).toBeLessThan(Date.now())
    expect(rowAVesting?.sweptAt).not.toBeNull()

    const vesting = await getVesting(testDb, userId)
    expect(vesting.maturedMinor).toBe(1250n)
    expect(vesting.pendingMinor).toBe(0n)
  })

  it('second sweep call promotes 0 rows (already swept)', async () => {
    const { id: userId } = await createUser(testDb)

    await seedSweepCandidateEarn(testDb, {
      userId,
      amountMinor: 500n,
      entryType: 'referral_reward',
      sourceType: 'referral',
      sourceId: crypto.randomUUID(),
    })

    const first = await runMaturationSweep()
    expect(first.promoted).toBe(1)

    const second = await runMaturationSweep()
    expect(second.promoted).toBe(0)
    expect(second.recomputed).toBe(0)
  })
})

// ─── sweepWithdrawable integration ───────────────────────────────────────────

describe('sweepWithdrawable integration', () => {
  beforeAll(async () => {
    testDb = await makeTestDb()
  })

  afterAll(async () => {
    await closeTestDb()
  })

  beforeEach(async () => {
    await resetTables(testDb)
  })

  it('sets withdrawableAt per matured entry and wallet_vesting.withdrawableMinor = max(0, eligible − paid)', async () => {
    const user = await createUser(testDb)
    const deal = await createDeal(testDb, { dealType: 'GROUP' })
    const purchaseCreatedAt = new Date(Date.now() - 200 * DAY_MS)
    const purchase = await createPurchase(testDb, {
      userId: user.id,
      dealId: deal.id,
      createdAt: purchaseCreatedAt,
    })

    const entryId = await seedMaturedEarnForWithdrawable(testDb, {
      userId: user.id,
      amountMinor: 2000n,
      entryType: 'affiliate_commission',
      sourceType: 'purchase',
      sourceId: purchase.id,
    })

    await runWithdrawableSweep()

    const vestingRow = await getVestingRow(testDb, entryId)
    expect(vestingRow?.withdrawableAt).not.toBeNull()

    const vesting = await getVesting(testDb, user.id)
    expect(vesting.withdrawableMinor).toBe(2000n)
  })

  it('leaves unredeemed coupon withdrawableAt null and excludes from withdrawable balance', async () => {
    const user = await createUser(testDb)
    const deal = await createDeal(testDb, { dealType: 'COUPON' })
    const purchase = await createPurchase(testDb, {
      userId: user.id,
      dealId: deal.id,
      redeemedAt: null,
    })

    const entryId = await seedMaturedEarnForWithdrawable(testDb, {
      userId: user.id,
      amountMinor: 800n,
      entryType: 'affiliate_commission',
      sourceType: 'purchase',
      sourceId: purchase.id,
    })

    await runWithdrawableSweep()

    const vestingRow = await getVestingRow(testDb, entryId)
    expect(vestingRow?.withdrawableAt).toBeNull()

    const vesting = await getVesting(testDb, user.id)
    expect(vesting.withdrawableMinor).toBe(0n)
  })

  it('excludes quarantined referral earnings from withdrawable (earned/paid netting)', async () => {
    const referrer = await createUser(testDb)
    const referee = await createUser(testDb)
    const link = await createReferralLink(testDb, referrer.id)
    const referral = await createReferral(testDb, {
      referrerUserId: referrer.id,
      refereeUserId: referee.id,
      linkId: link.id,
    })

    await testDb.execute(sql`
      UPDATE referrals SET quarantined_at = NOW() WHERE id = ${referral.id}
    `)

    const referralSourceId = crypto.randomUUID()
    await seedMaturedEarnForWithdrawable(testDb, {
      userId: referrer.id,
      amountMinor: 1500n,
      entryType: 'referral_reward',
      sourceType: 'referral',
      sourceId: referralSourceId,
      referralId: referral.id,
    })

    await testDb.execute(sql`
      UPDATE ledger_entry_vesting
      SET withdrawable_at = ${new Date(Date.now() - DAY_MS).toISOString()}
      WHERE entry_id = (
        SELECT entry_id FROM affiliate_entries WHERE source_id = ${referralSourceId}
      )
    `)

    await runWithdrawableSweep()

    const vesting = await getVesting(testDb, referrer.id)
    expect(vesting.withdrawableMinor).toBe(0n)
  })

  it('subtracts settled payouts from withdrawableMinor', async () => {
    const user = await createUser(testDb)
    const enrollment = await createEnrollment(testDb, user.id)
    const sourceId = crypto.randomUUID()

    await seedMaturedEarnForWithdrawable(testDb, {
      userId: user.id,
      amountMinor: 5000n,
      entryType: 'affiliate_commission',
      sourceType: 'purchase',
      sourceId,
    })

    await testDb.execute(sql`
      UPDATE ledger_entry_vesting
      SET withdrawable_at = ${new Date(Date.now() - DAY_MS).toISOString()}
      WHERE entry_id = (
        SELECT entry_id FROM affiliate_entries WHERE source_id = ${sourceId}
      )
    `)

    await testDb.execute(sql`
      INSERT INTO affiliate_payouts (
        id, user_id, enrollment_id, amount_agorot, status, idempotency_key
      ) VALUES (
        ${crypto.randomUUID()}, ${user.id}, ${enrollment.id}, 2000, 'approved', ${crypto.randomUUID()}
      )
    `)

    await runWithdrawableSweep()

    const vesting = await getVesting(testDb, user.id)
    expect(vesting.withdrawableMinor).toBe(3000n)
  })

  it('second sweep call is idempotent (withdrawable unchanged)', async () => {
    const user = await createUser(testDb)
    const sourceId = crypto.randomUUID()

    await seedMaturedEarnForWithdrawable(testDb, {
      userId: user.id,
      amountMinor: 900n,
      entryType: 'referral_reward',
      sourceType: 'referral',
      sourceId,
    })

    await testDb.execute(sql`
      UPDATE ledger_entry_vesting
      SET withdrawable_at = ${new Date(Date.now() - DAY_MS).toISOString()}
      WHERE entry_id = (
        SELECT entry_id FROM affiliate_entries WHERE source_id = ${sourceId}
      )
    `)

    await runWithdrawableSweep()
    const afterFirst = await getVesting(testDb, user.id)
    expect(afterFirst.withdrawableMinor).toBe(900n)

    const second = await runWithdrawableSweep()
    const afterSecond = await getVesting(testDb, user.id)
    expect(afterSecond.withdrawableMinor).toBe(900n)
    expect(second.updated).toBeGreaterThanOrEqual(0)
  })

  it('rejects out-of-bounds disputeWindowDays from host (trust-boundary)', async () => {
    await testDb.execute(sql`
      UPDATE referral_settings SET dispute_window_days = 99999 WHERE id = 1
    `)

    const user = await createUser(testDb)
    await accrueEarn(testDb, {
      userId: user.id,
      amountMinor: 100n,
      entryType: 'referral_reward',
      sourceType: 'referral',
      sourceId: crypto.randomUUID(),
      matureAt: new Date(Date.now() - DAY_MS),
    })

    const result = await runWithdrawableSweep()
    expect(result.updated).toBe(0)
  })

  it('born-matured earn is eligible once setEntryWithdrawableAt runs (not excluded by sweptAt filter)', async () => {
    const user = await createUser(testDb)
    const deal = await createDeal(testDb, { dealType: 'GROUP' })
    const purchaseCreatedAt = new Date(Date.now() - 200 * DAY_MS)
    const purchase = await createPurchase(testDb, {
      userId: user.id,
      dealId: deal.id,
      createdAt: purchaseCreatedAt,
    })

    const entryId = await seedMaturedEarnForWithdrawable(testDb, {
      userId: user.id,
      amountMinor: 1200n,
      entryType: 'affiliate_commission',
      sourceType: 'purchase',
      sourceId: purchase.id,
      bornMatured: true,
    })

    const beforeSweep = await getVestingRow(testDb, entryId)
    expect(beforeSweep?.sweptAt).not.toBeNull()

    await runWithdrawableSweep()

    const vesting = await getVesting(testDb, user.id)
    expect(vesting.withdrawableMinor).toBe(1200n)

    const { computeOwnerEligibleAndPaid } = await getMaturitySweep()
    const totals = await testDb.transaction((tx) =>
      computeOwnerEligibleAndPaid(tx, user.id),
    )
    expect(totals.eligibleMinor).toBe(1200n)
    expect(totals.eligibleMinor).toBeLessThanOrEqual(totals.maturedMinor)
  })

  it('compute postcondition: eligibleMinor <= maturedMinor excludes stale over-count rows', async () => {
    const user = await createUser(testDb)
    const maturedSourceId = crypto.randomUUID()
    const staleSourceId = crypto.randomUUID()

    await seedMaturedEarnForWithdrawable(testDb, {
      userId: user.id,
      amountMinor: 1000n,
      entryType: 'referral_reward',
      sourceType: 'referral',
      sourceId: maturedSourceId,
    })

    await accrueEarn(testDb, {
      userId: user.id,
      amountMinor: 500n,
      entryType: 'referral_reward',
      sourceType: 'referral',
      sourceId: staleSourceId,
      matureAt: new Date(Date.now() + 30 * DAY_MS),
    })

    const staleEntryId = await entryIdForSource(testDb, staleSourceId)
    const maturedEntryId = await entryIdForSource(testDb, maturedSourceId)
    await testDb.execute(sql`
      UPDATE ledger_entry_vesting
      SET withdrawable_at = ${new Date(Date.now() - DAY_MS).toISOString()}
      WHERE entry_id = ${maturedEntryId}
    `)
    await testDb.execute(sql`
      UPDATE ledger_entry_vesting
      SET withdrawable_at = ${new Date(Date.now() - DAY_MS).toISOString()}
      WHERE entry_id = ${staleEntryId}
    `)

    const { computeOwnerEligibleAndPaid } = await getMaturitySweep()
    const totals = await testDb.transaction((tx) =>
      computeOwnerEligibleAndPaid(tx, user.id),
    )
    expect(totals.maturedMinor).toBe(1000n)
    expect(totals.eligibleMinor).toBe(1000n)
    expect(totals.eligibleMinor).toBeLessThanOrEqual(totals.maturedMinor)
  })

  it('compute reads matured ledger_entry_vesting PLAIN (no FOR UPDATE on entries)', async () => {
    const { computeOwnerEligibleAndPaid } = await getMaturitySweep()
    const fnSrc = computeOwnerEligibleAndPaid.toString().toLowerCase()
    expect(fnSrc).not.toMatch(/for\s+update/)
    expect(fnSrc).toContain('ledger_entry_vesting')
    expect(fnSrc).toContain('swept_at is not null')
  })

  it('paid-counts-requested — status=requested payout is included in paidMinor', async () => {
    const user = await createUser(testDb)
    const enrollmentId = crypto.randomUUID()
    await testDb.execute(sql`
      INSERT INTO affiliate_enrollments (id, user_id, status)
      VALUES (${enrollmentId}, ${user.id}, 'active')
    `)
    const payoutId = crypto.randomUUID()
    await testDb.execute(sql`
      INSERT INTO affiliate_payouts (
        id, user_id, enrollment_id, amount_agorot, status, idempotency_key
      ) VALUES (
        ${payoutId}, ${user.id}, ${enrollmentId}, ${900}, 'requested', ${`idem-${payoutId}`}
      )
    `)
    await testDb.execute(sql`
      INSERT INTO wallet_vesting (
        owner_id, pending_minor, matured_minor, withdrawable_minor, lifetime_earned_minor
      ) VALUES (${user.id}, 0, 5000, 4100, 5000)
      ON CONFLICT (owner_id) DO UPDATE SET
        matured_minor = 5000,
        withdrawable_minor = 4100,
        updated_at = NOW()
    `)

    const { computeOwnerEligibleAndPaid } = await getMaturitySweep()
    const totals = await testDb.transaction((tx) =>
      computeOwnerEligibleAndPaid(tx, user.id),
    )
    expect(totals.paidMinor).toBe(900n)
  })
})

// ─── reconcile-wallet isolation (ported assertion) ───────────────────────────

describe('maturity sweeps wallet bucket isolation', () => {
  it('computeWithdrawableAt does not mutate wallet projection fields', () => {
    const compiled = computeWithdrawableAt.toString()
    expect(compiled).not.toContain('pending_agorot')
    expect(compiled).not.toContain('matured_agorot')
    expect(compiled).not.toContain('withdrawable_agorot')
  })
})
