import { describe, expect, it } from 'vitest'
import {
  accrueVesting,
  getVesting,
  isVestingInvariantError,
  promoteEntries,
  recomputeWithdrawable,
  setEntryWithdrawableAt,
  VestingInvariantError,
} from './vesting.js'
import {
  createTestDb,
  getLedgerEntryVesting,
  getWalletVesting,
  seedLedgerEntry,
  seedLedgerEntryVesting,
  seedWalletVesting,
} from './test-fixture.js'

const future = () => new Date(Date.now() + 86_400_000)
const past = () => new Date(Date.now() - 86_400_000)

describe('accrueVesting', () => {
  it('matureAt in the future → pending; bumps pending + lifetime', async () => {
    const db = await createTestDb()
    await seedLedgerEntry(db, '00000000-0000-0000-0000-000000000001', 500n, 'k1')
    const r = await db.transaction((tx) =>
      accrueVesting(tx, { ownerId: 'u1', entryId: '00000000-0000-0000-0000-000000000001', amountMinor: 500n, matureAt: future() }),
    )
    expect(r).toEqual({ state: 'pending' })
    expect(await getWalletVesting(db, 'u1')).toMatchObject({ pendingMinor: 500n, maturedMinor: 0n, lifetimeEarnedMinor: 500n })
  })

  it('matureAt in the past → matured (instant-mature)', async () => {
    const db = await createTestDb()
    await seedLedgerEntry(db, '00000000-0000-0000-0000-000000000002', 300n, 'k2')
    const r = await db.transaction((tx) =>
      accrueVesting(tx, { ownerId: 'u2', entryId: '00000000-0000-0000-0000-000000000002', amountMinor: 300n, matureAt: past() }),
    )
    expect(r).toEqual({ state: 'matured' })
    expect(await getWalletVesting(db, 'u2')).toMatchObject({ pendingMinor: 0n, maturedMinor: 300n, lifetimeEarnedMinor: 300n })
  })

  it('inserts the entry ledger_entry_vesting row with matureAt', async () => {
    const db = await createTestDb()
    const entryId = '00000000-0000-0000-0000-000000000003'
    const matureAt = future()
    await seedLedgerEntry(db, entryId, 100n, 'k3')
    await db.transaction((tx) =>
      accrueVesting(tx, { ownerId: 'u3', entryId, amountMinor: 100n, matureAt }),
    )
    const row = await getLedgerEntryVesting(db, entryId)
    expect(row).toBeDefined()
    expect(row!.matureAt).toEqual(matureAt)
  })

  it('second accrual on the same owner INCREMENTS buckets (matured-branch upsert is not a clobber)', async () => {
    const db = await createTestDb()
    await seedLedgerEntry(db, '00000000-0000-0000-0000-000000000004', 500n, 'k4')
    await seedLedgerEntry(db, '00000000-0000-0000-0000-000000000005', 300n, 'k5')
    // First accrual lands in pending.
    await db.transaction((tx) =>
      accrueVesting(tx, { ownerId: 'u4', entryId: '00000000-0000-0000-0000-000000000004', amountMinor: 500n, matureAt: future() }),
    )
    // Second accrual (matured branch) on the SAME owner must add, not overwrite — and must leave pending intact.
    const r = await db.transaction((tx) =>
      accrueVesting(tx, { ownerId: 'u4', entryId: '00000000-0000-0000-0000-000000000005', amountMinor: 300n, matureAt: past() }),
    )
    expect(r).toEqual({ state: 'matured' })
    expect(await getWalletVesting(db, 'u4')).toMatchObject({
      pendingMinor: 500n, // untouched by the matured-branch upsert (no clobber)
      maturedMinor: 300n,
      lifetimeEarnedMinor: 800n, // 500 + 300, accumulated across both accruals
    })
  })

  it('pending-branch upsert INCREMENTS on conflict and leaves matured intact (no clobber)', async () => {
    const db = await createTestDb()
    await seedLedgerEntry(db, '00000000-0000-0000-0000-000000000006', 200n, 'k6')
    await seedLedgerEntry(db, '00000000-0000-0000-0000-000000000007', 700n, 'k7')
    // First accrual lands in matured (instant-mature).
    await db.transaction((tx) =>
      accrueVesting(tx, { ownerId: 'u5', entryId: '00000000-0000-0000-0000-000000000006', amountMinor: 200n, matureAt: past() }),
    )
    // Second accrual exercises the pending-on-conflict SQL branch: pending += amt, matured preserved.
    await db.transaction((tx) =>
      accrueVesting(tx, { ownerId: 'u5', entryId: '00000000-0000-0000-0000-000000000007', amountMinor: 700n, matureAt: future() }),
    )
    expect(await getWalletVesting(db, 'u5')).toMatchObject({
      pendingMinor: 700n, // added via sql`pending + amt`
      maturedMinor: 200n, // untouched by the pending-branch upsert (no clobber)
      lifetimeEarnedMinor: 900n, // 200 + 700
    })
  })

  it('future-mature row is born UN-swept (sweptAt null — awaits the maturation sweep)', async () => {
    const db = await createTestDb()
    await seedLedgerEntry(db, '00000000-0000-0000-0000-000000000009', 700n, 'k9')
    await db.transaction((tx) =>
      accrueVesting(tx, { ownerId: 'u7', entryId: '00000000-0000-0000-0000-000000000009', amountMinor: 700n, matureAt: future() }),
    )
    expect(await getLedgerEntryVesting(db, '00000000-0000-0000-0000-000000000009')).toMatchObject({ sweptAt: null })
  })

  it('born-matured row is born ALREADY-swept (sweptAt set — excluded from the maturation sweep, no double-promote)', async () => {
    const db = await createTestDb()
    await seedLedgerEntry(db, '00000000-0000-0000-0000-000000000010', 400n, 'k10')
    await db.transaction((tx) =>
      accrueVesting(tx, { ownerId: 'u8', entryId: '00000000-0000-0000-0000-000000000010', amountMinor: 400n, matureAt: past() }),
    )
    const row = await getLedgerEntryVesting(db, '00000000-0000-0000-0000-000000000010')
    expect(row!.sweptAt).not.toBeNull()
  })

  it('non-positive amountMinor → throws, no bucket moved, no row inserted (trust-boundary fail-closed)', async () => {
    const db = await createTestDb()
    await seedLedgerEntry(db, '00000000-0000-0000-0000-000000000011', 100n, 'k11')
    for (const bad of [0n, -100n]) {
      await expect(
        db.transaction((tx) =>
          accrueVesting(tx, { ownerId: 'u9', entryId: '00000000-0000-0000-0000-000000000011', amountMinor: bad, matureAt: future() }),
        ),
      ).rejects.toThrow('accrueVesting: amount must be positive')
    }
    expect(await getWalletVesting(db, 'u9')).toBeUndefined()
    expect(await getLedgerEntryVesting(db, '00000000-0000-0000-0000-000000000011')).toBeUndefined()
  })

  it('replay with the same entryId rejects (PK backstop) and lands NO second bucket write', async () => {
    const db = await createTestDb()
    const entryId = '00000000-0000-0000-0000-000000000008'
    await seedLedgerEntry(db, entryId, 400n, 'k8')
    // First accrual succeeds.
    await db.transaction((tx) =>
      accrueVesting(tx, { ownerId: 'u6', entryId, amountMinor: 400n, matureAt: future() }),
    )
    // A replay (same entryId) must abort on the ledger_entry_vesting PK conflict — the plain insert is the
    // backstop that fires BEFORE any bucket mutation, so the tx rolls back with no double-accrual.
    await expect(
      db.transaction((tx) =>
        accrueVesting(tx, { ownerId: 'u6', entryId, amountMinor: 400n, matureAt: future() }),
      ),
    ).rejects.toThrow()
    // Buckets reflect exactly ONE accrual — the aborted replay left nothing behind.
    expect(await getWalletVesting(db, 'u6')).toMatchObject({
      pendingMinor: 400n,
      maturedMinor: 0n,
      lifetimeEarnedMinor: 400n,
    })
  })
})

describe('promoteEntries', () => {
  const e1 = '00000000-0000-0000-0000-000000000101'
  const e2 = '00000000-0000-0000-0000-000000000102'
  const e3 = '00000000-0000-0000-0000-000000000103'
  const e4 = '00000000-0000-0000-0000-000000000104'
  const ePromoteOnly = '00000000-0000-0000-0000-000000000105'
  const e5 = '00000000-0000-0000-0000-000000000106'
  const e6 = '00000000-0000-0000-0000-000000000107'
  const e7 = '00000000-0000-0000-0000-000000000108'
  const e8 = '00000000-0000-0000-0000-000000000109'
  const eAlreadySwept = '00000000-0000-0000-0000-000000000110'
  const eZeroNet = '00000000-0000-0000-0000-000000000111'
  const eNegDelta = '00000000-0000-0000-0000-000000000112'

  it('moves the SINGLE net delta and marks earns swept (M1 labelled earns)', async () => {
    const db = await createTestDb()
    const matureAt = past()
    await seedWalletVesting(db, 'u1', { pendingMinor: 1000n })
    await seedLedgerEntry(db, e1, 600n, 'promote-k1')
    await seedLedgerEntry(db, e2, 400n, 'promote-k2')
    await seedLedgerEntryVesting(db, e1, { matureAt, sweptAt: null })
    await seedLedgerEntryVesting(db, e2, { matureAt, sweptAt: null })

    await db.transaction((tx) =>
      promoteEntries(tx, {
        earnEntryIds: [
          { entryId: e1, ownerId: 'u1' },
          { entryId: e2, ownerId: 'u1' },
        ],
        perOwnerDeltas: [{ ownerId: 'u1', deltaMinor: 1000n }],
      }),
    )

    expect(await getWalletVesting(db, 'u1')).toMatchObject({ pendingMinor: 0n, maturedMinor: 1000n })
    expect((await getLedgerEntryVesting(db, e1))!.sweptAt).not.toBeNull()
    expect((await getLedgerEntryVesting(db, e2))!.sweptAt).not.toBeNull()
  })

  it('throws when an earn owner is missing from perOwnerDeltas (no silent money-drop)', async () => {
    const db = await createTestDb()
    const matureAt = past()
    await seedWalletVesting(db, 'u1', { pendingMinor: 500n })
    await seedLedgerEntry(db, e6, 300n, 'promote-k7')
    await seedLedgerEntry(db, e7, 200n, 'promote-k8')
    await seedLedgerEntryVesting(db, e6, { matureAt, sweptAt: null })
    await seedLedgerEntryVesting(db, e7, { matureAt, sweptAt: null })

    await expect(
      db.transaction((tx) =>
        promoteEntries(tx, {
          earnEntryIds: [
            { entryId: e6, ownerId: 'u1' },
            { entryId: e7, ownerId: 'u2' },
          ],
          perOwnerDeltas: [{ ownerId: 'u1', deltaMinor: 500n }],
        }),
      ),
    ).rejects.toSatisfy(isVestingInvariantError)

    expect((await getLedgerEntryVesting(db, e6))?.sweptAt).toBeNull()
  })

  it('throws on an orphan perOwnerDeltas owner with no earn', async () => {
    const db = await createTestDb()
    const matureAt = past()
    await seedWalletVesting(db, 'u1', { pendingMinor: 300n })
    await seedWalletVesting(db, 'u3', {})
    await seedLedgerEntry(db, e8, 300n, 'promote-k9')
    await seedLedgerEntryVesting(db, e8, { matureAt, sweptAt: null })

    await expect(
      db.transaction((tx) =>
        promoteEntries(tx, {
          earnEntryIds: [{ entryId: e8, ownerId: 'u1' }],
          perOwnerDeltas: [
            { ownerId: 'u1', deltaMinor: 300n },
            { ownerId: 'u3', deltaMinor: 100n },
          ],
        }),
      ),
    ).rejects.toSatisfy(isVestingInvariantError)
  })

  it('throws on a duplicate earnEntryId', async () => {
    const db = await createTestDb()
    const matureAt = past()
    await seedWalletVesting(db, 'u1', { pendingMinor: 200n })
    await seedLedgerEntry(db, e1, 200n, 'promote-k-dup')
    await seedLedgerEntryVesting(db, e1, { matureAt, sweptAt: null })

    await expect(
      db.transaction((tx) =>
        promoteEntries(tx, {
          earnEntryIds: [
            { entryId: e1, ownerId: 'u1' },
            { entryId: e1, ownerId: 'u1' },
          ],
          perOwnerDeltas: [{ ownerId: 'u1', deltaMinor: 200n }],
        }),
      ),
    ).rejects.toSatisfy(isVestingInvariantError)
  })

  it('throws when an earn is already swept or nonexistent (count mismatch)', async () => {
    const db = await createTestDb()
    await seedWalletVesting(db, 'u1', { pendingMinor: 200n })
    await seedLedgerEntry(db, eAlreadySwept, 200n, 'promote-k-swept')
    await seedLedgerEntryVesting(db, eAlreadySwept, { matureAt: past(), sweptAt: past() })

    await expect(
      db.transaction((tx) =>
        promoteEntries(tx, {
          earnEntryIds: [{ entryId: eAlreadySwept, ownerId: 'u1' }],
          perOwnerDeltas: [{ ownerId: 'u1', deltaMinor: 200n }],
        }),
      ),
    ).rejects.toSatisfy(isVestingInvariantError)
  })

  it('throws on a duplicate owner in perOwnerDeltas', async () => {
    const db = await createTestDb()
    const matureAt = past()
    await seedWalletVesting(db, 'u1', { pendingMinor: 400n })
    await seedLedgerEntry(db, e1, 400n, 'promote-k-dup-owner')
    await seedLedgerEntryVesting(db, e1, { matureAt, sweptAt: null })

    await expect(
      db.transaction((tx) =>
        promoteEntries(tx, {
          earnEntryIds: [{ entryId: e1, ownerId: 'u1' }],
          perOwnerDeltas: [
            { ownerId: 'u1', deltaMinor: 200n },
            { ownerId: 'u1', deltaMinor: 200n },
          ],
        }),
      ),
    ).rejects.toSatisfy(isVestingInvariantError)
  })

  it('accepts a fully-cancelled owner emitted as deltaMinor 0n (zero-net contract)', async () => {
    const db = await createTestDb()
    const matureAt = past()
    await seedWalletVesting(db, 'u1', { pendingMinor: 100n, maturedMinor: 0n })
    await seedLedgerEntry(db, eZeroNet, 100n, 'promote-k-zero')
    await seedLedgerEntryVesting(db, eZeroNet, { matureAt, sweptAt: null })

    await db.transaction((tx) =>
      promoteEntries(tx, {
        earnEntryIds: [{ entryId: eZeroNet, ownerId: 'u1' }],
        perOwnerDeltas: [{ ownerId: 'u1', deltaMinor: 0n }],
      }),
    )

    expect(await getWalletVesting(db, 'u1')).toMatchObject({ pendingMinor: 100n, maturedMinor: 0n })
    expect((await getLedgerEntryVesting(db, eZeroNet))?.sweptAt).not.toBeNull()
  })

  it('throws on a negative perOwnerDeltas delta (matured underflow fail-closed)', async () => {
    const db = await createTestDb()
    const matureAt = past()
    await seedWalletVesting(db, 'u1', { pendingMinor: 100n, maturedMinor: 0n })
    await seedLedgerEntry(db, eNegDelta, 100n, 'promote-k-neg')
    await seedLedgerEntryVesting(db, eNegDelta, { matureAt, sweptAt: null })

    await expect(
      db.transaction((tx) =>
        promoteEntries(tx, {
          earnEntryIds: [{ entryId: eNegDelta, ownerId: 'u1' }],
          perOwnerDeltas: [{ ownerId: 'u1', deltaMinor: -50n }],
        }),
      ),
    ).rejects.toSatisfy(isVestingInvariantError)

    expect((await getLedgerEntryVesting(db, eNegDelta))?.sweptAt).toBeNull()
    expect(await getWalletVesting(db, 'u1')).toMatchObject({ pendingMinor: 100n, maturedMinor: 0n })
  })

  it('underflow (delta > pending) trips the nonneg CHECK → tx aborts', async () => {
    const db = await createTestDb()
    await seedWalletVesting(db, 'u2', { pendingMinor: 100n })
    await seedLedgerEntry(db, e3, 500n, 'promote-k3')
    await seedLedgerEntryVesting(db, e3, { matureAt: past(), sweptAt: null })

    await expect(
      db.transaction((tx) =>
        promoteEntries(tx, {
          earnEntryIds: [{ entryId: e3, ownerId: 'u2' }],
          perOwnerDeltas: [{ ownerId: 'u2', deltaMinor: 500n }],
        }),
      ),
    ).rejects.toThrow()

    expect(await getWalletVesting(db, 'u2')).toMatchObject({ pendingMinor: 100n })
    expect((await getLedgerEntryVesting(db, e3))!.sweptAt).toBeNull()
  })

  it('marks sweptAt ONLY on earnEntryIds (never on unrelated rows)', async () => {
    const db = await createTestDb()
    const matureAt = past()
    await seedWalletVesting(db, 'u3', { pendingMinor: 500n })
    await seedLedgerEntry(db, ePromoteOnly, 500n, 'promote-k5')
    await seedLedgerEntry(db, e4, 200n, 'promote-k4')
    await seedLedgerEntryVesting(db, ePromoteOnly, { matureAt, sweptAt: null })
    await seedLedgerEntryVesting(db, e4, { matureAt, sweptAt: null })

    await db.transaction((tx) =>
      promoteEntries(tx, {
        earnEntryIds: [{ entryId: ePromoteOnly, ownerId: 'u3' }],
        perOwnerDeltas: [{ ownerId: 'u3', deltaMinor: 500n }],
      }),
    )

    expect((await getLedgerEntryVesting(db, ePromoteOnly))!.sweptAt).not.toBeNull()
    expect((await getLedgerEntryVesting(db, e4))!.sweptAt).toBeNull()
  })

  it('missing wallet_vesting row → throws AND leaves the earn UN-swept (data-loss fail-closed)', async () => {
    const db = await createTestDb()
    await seedLedgerEntry(db, e5, 200n, 'promote-k6')
    await seedLedgerEntryVesting(db, e5, { matureAt: past(), sweptAt: null })

    await expect(
      db.transaction((tx) =>
        promoteEntries(tx, {
          earnEntryIds: [{ entryId: e5, ownerId: 'u3' }],
          perOwnerDeltas: [{ ownerId: 'u3', deltaMinor: 200n }],
        }),
      ),
    ).rejects.toSatisfy(isVestingInvariantError)

    expect((await getLedgerEntryVesting(db, e5))?.sweptAt).toBeNull()
    expect(await getWalletVesting(db, 'u3')).toBeUndefined()
  })
})

describe('isVestingInvariantError', () => {
  // The FALSE path is the guard's whole job at the T10 affiliate caller: it must NOT misclassify a
  // DB nonneg-CHECK failure (the underflow case above, asserted via bare toThrow) as a vesting invariant.
  it('returns false for a non-vesting error, null, and a foreign-discriminant object', () => {
    expect(isVestingInvariantError(new Error('db nonneg check'))).toBe(false)
    expect(isVestingInvariantError(null)).toBe(false)
    expect(isVestingInvariantError(undefined)).toBe(false)
    expect(isVestingInvariantError({ _ledgerError: 'SomeOtherError' })).toBe(false)
    expect(isVestingInvariantError('VestingInvariantError')).toBe(false)
  })

  it('returns true for a real VestingInvariantError (structural discriminant, not instanceof)', () => {
    const e = new VestingInvariantError('m')
    expect(isVestingInvariantError(e)).toBe(true)
    expect(e.code).toBe('VESTING_INVARIANT')
  })
})

describe('setEntryWithdrawableAt', () => {
  const entryId = '00000000-0000-0000-0000-000000000201'

  it('stores withdrawableAt on the ledger_entry_vesting row', async () => {
    const db = await createTestDb()
    const withdrawableAt = new Date('2026-06-01T12:00:00Z')
    await seedLedgerEntry(db, entryId, 100n, 'anchor-k1')
    await seedLedgerEntryVesting(db, entryId, { matureAt: past() })

    await db.transaction((tx) =>
      setEntryWithdrawableAt(tx, { entryId, withdrawableAt }),
    )

    expect((await getLedgerEntryVesting(db, entryId))!.withdrawableAt).toEqual(withdrawableAt)
  })

  it('nonexistent entry → throws VestingInvariantError, never a silent no-op', async () => {
    const db = await createTestDb()

    await expect(
      db.transaction((tx) =>
        setEntryWithdrawableAt(tx, {
          entryId: '00000000-0000-0000-0000-000000000299',
          withdrawableAt: new Date('2026-06-01T12:00:00Z'),
        }),
      ),
    ).rejects.toSatisfy(isVestingInvariantError)
  })

  it('accepts null to clear the anchor', async () => {
    const db = await createTestDb()
    const withdrawableAt = new Date('2026-06-01T12:00:00Z')
    await seedLedgerEntry(db, entryId, 100n, 'anchor-k2')
    await seedLedgerEntryVesting(db, entryId, { matureAt: past() })
    await db.transaction((tx) =>
      setEntryWithdrawableAt(tx, { entryId, withdrawableAt }),
    )

    await db.transaction((tx) =>
      setEntryWithdrawableAt(tx, { entryId, withdrawableAt: null }),
    )

    expect((await getLedgerEntryVesting(db, entryId))!.withdrawableAt).toBeNull()
  })
})

describe('getVesting', () => {
  it('returns the four bucket counters for a seeded owner', async () => {
    const db = await createTestDb()
    await seedWalletVesting(db, 'u1', {
      pendingMinor: 300n,
      maturedMinor: 700n,
      withdrawableMinor: 500n,
      lifetimeEarnedMinor: 1000n,
    })

    expect(await getVesting(db, 'u1')).toEqual({
      pendingMinor: 300n,
      maturedMinor: 700n,
      withdrawableMinor: 500n,
      lifetimeEarnedMinor: 1000n,
    })
  })

  it('returns BIGINT zeros when no wallet_vesting row exists', async () => {
    const db = await createTestDb()

    expect(await getVesting(db, 'missing-owner')).toEqual({
      pendingMinor: 0n,
      maturedMinor: 0n,
      withdrawableMinor: 0n,
      lifetimeEarnedMinor: 0n,
    })
  })
})

describe('recomputeWithdrawable', () => {
  it('sets withdrawableMinor = max(0, eligible - paid) under the owner lock', async () => {
    const db = await createTestDb()
    await seedWalletVesting(db, 'u1', { maturedMinor: 1000n, withdrawableMinor: 0n })
    await db.transaction((tx) =>
      recomputeWithdrawable(tx, { ownerId: 'u1' }, async () => ({ eligibleMinor: 700n, paidMinor: 200n })),
    )
    expect(await getWalletVesting(db, 'u1')).toMatchObject({ withdrawableMinor: 500n })
  })

  it('clamps to 0 when paid >= eligible', async () => {
    const db = await createTestDb()
    await seedWalletVesting(db, 'u2', {})
    await db.transaction((tx) =>
      recomputeWithdrawable(tx, { ownerId: 'u2' }, async () => ({ eligibleMinor: 100n, paidMinor: 400n })),
    )
    expect(await getWalletVesting(db, 'u2')).toMatchObject({ withdrawableMinor: 0n })
  })
})
