import { describe, expect, it } from 'vitest'
import { applyPromo } from './apply.js'
import { PromoValidationError } from './errors.js'
import type { DiscountableCart, DiscountLine, DiscountResult, Promo } from './types.js'

const PROMO_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'

function basePromo(overrides: Partial<Promo> = {}): Promo {
  return {
    id: PROMO_ID,
    code: 'SAVE10',
    kind: 'percentage',
    valueBps: 1000,
    scope: { kind: 'all' },
    eligibility: {},
    funder: 'platform',
    active: true,
    vendorId: null,
    ...overrides,
  }
}

function cart(lines: DiscountLine[]): DiscountableCart {
  return { currency: 'USD', lines }
}

function line(
  lineId: string,
  unitPrice: bigint,
  qty: number,
  extras: Partial<DiscountLine> = {},
): DiscountLine {
  return { lineId, unitPrice, qty, vendorId: null, ...extras }
}

function assertMoneyInvariants(result: DiscountResult, scopedSubtotal: bigint): void {
  const sum = result.perLine.reduce((acc, entry) => acc + entry.amount, 0n)
  expect(sum).toBe(result.total)
  for (const { amount } of result.perLine) {
    expect(amount >= 0n).toBe(true)
  }
  expect(result.total <= scopedSubtotal).toBe(true)
}

describe('applyPromo', () => {
  it('percentage exact-sum with indivisible residue and lineId asc tiebreak', () => {
    const promo = basePromo({ valueBps: 1000 })
    const input = cart([
      line('line-c', 333n, 1),
      line('line-a', 333n, 1),
      line('line-b', 333n, 1),
    ])
    const scopedSubtotal = 999n

    const result = applyPromo(promo, input)

    expect(result.total).toBe(100n)
    expect(result.perLine).toEqual([
      { lineId: 'line-a', amount: 34n },
      { lineId: 'line-b', amount: 33n },
      { lineId: 'line-c', amount: 33n },
    ])
    expect(result.funder).toBe('platform')
    assertMoneyInvariants(result, scopedSubtotal)
  })

  it('largest-remainder routes the residue unit by remainder magnitude, not lineId', () => {
    // fixed D=7 over subtotal 100; bases a:1 b:3 c:2 (Σ6), residue 1.
    // remainders a:40 b:50 c:10 → the +1 MUST land on line-b (largest remainder),
    // NOT line-a (lineId-first). A wrong remainder key or lineId-first allocator gives a:2,b:3.
    const promo = basePromo({
      kind: 'fixed',
      valueBps: undefined,
      valueAmount: 7n,
      currency: 'USD',
    })
    const input = cart([
      line('line-a', 20n, 1),
      line('line-b', 50n, 1),
      line('line-c', 30n, 1),
    ])
    const scopedSubtotal = 100n

    const result = applyPromo(promo, input)

    expect(result.total).toBe(7n)
    expect(result.perLine).toEqual([
      { lineId: 'line-a', amount: 1n },
      { lineId: 'line-b', amount: 4n },
      { lineId: 'line-c', amount: 2n },
    ])
    assertMoneyInvariants(result, scopedSubtotal)
  })

  it('largest-remainder distributes a residue of 2 to the two largest remainders and drops zero lines', () => {
    // fixed D=4 over subtotal 100; bases all 0/1 (Σ2), residue 2.
    // remainders a:40 b:20 c:80 d:40 → top two = c(80), then a(40, lineId-asc over d) → c & a get +1.
    // line-d allocates 0n and MUST be omitted from perLine.
    const promo = basePromo({
      kind: 'fixed',
      valueBps: undefined,
      valueAmount: 4n,
      currency: 'USD',
    })
    const input = cart([
      line('line-a', 40n, 1),
      line('line-b', 30n, 1),
      line('line-c', 20n, 1),
      line('line-d', 10n, 1),
    ])
    const scopedSubtotal = 100n

    const result = applyPromo(promo, input)

    expect(result.total).toBe(4n)
    expect(result.perLine).toEqual([
      { lineId: 'line-a', amount: 2n },
      { lineId: 'line-b', amount: 1n },
      { lineId: 'line-c', amount: 1n },
    ])
    assertMoneyInvariants(result, scopedSubtotal)
  })

  it('percentage round-half-up rounds up at exact half boundary', () => {
    const promo = basePromo({ valueBps: 1 })
    const roundsUp = applyPromo(promo, cart([line('line-1', 5000n, 1)]))
    const roundsDown = applyPromo(promo, cart([line('line-1', 4999n, 1)]))

    expect(roundsUp.total).toBe(1n)
    expect(roundsDown.total).toBe(0n)
    assertMoneyInvariants(roundsUp, 5000n)
    assertMoneyInvariants(roundsDown, 4999n)
  })

  it('fixed caps discount at scopedSubtotal when valueAmount exceeds it', () => {
    const promo = basePromo({
      kind: 'fixed',
      valueBps: undefined,
      valueAmount: 5000n,
      currency: 'USD',
    })
    const scopedSubtotal = 3000n
    const input = cart([line('line-1', 3000n, 1)])

    const result = applyPromo(promo, input)

    expect(result.total).toBe(scopedSubtotal)
    expect(result.perLine).toEqual([{ lineId: 'line-1', amount: 3000n }])
    assertMoneyInvariants(result, scopedSubtotal)
  })

  it('fixed applies full valueAmount when below scopedSubtotal', () => {
    const promo = basePromo({
      kind: 'fixed',
      valueBps: undefined,
      valueAmount: 1500n,
      currency: 'USD',
    })
    const scopedSubtotal = 4000n
    const input = cart([
      line('line-a', 1000n, 1),
      line('line-b', 3000n, 1),
    ])

    const result = applyPromo(promo, input)

    expect(result.total).toBe(1500n)
    expect(result.perLine.reduce((sum, entry) => sum + entry.amount, 0n)).toBe(1500n)
    assertMoneyInvariants(result, scopedSubtotal)
  })

  it('clamps percentage discount by maxDiscountAmount before largest-remainder allocation', () => {
    const promo = basePromo({
      currency: 'USD',
      valueBps: 5_000,
      maxDiscountAmount: 100n,
    })
    const input = cart([
      line('line-c', 333n, 1),
      line('line-a', 333n, 1),
      line('line-b', 333n, 1),
    ])
    const scopedSubtotal = 999n

    const result = applyPromo(promo, input)

    expect(result.total).toBe(100n)
    expect(result.perLine).toEqual([
      { lineId: 'line-a', amount: 34n },
      { lineId: 'line-b', amount: 33n },
      { lineId: 'line-c', amount: 33n },
    ])
    assertMoneyInvariants(result, scopedSubtotal)
  })

  it('bogo with billion-scale qty completes without per-unit materialization', () => {
    const promo = basePromo({
      kind: 'bogo',
      valueBps: undefined,
      bogo: { buyQty: 1, getQty: 1 },
    })
    const qty = 1_000_000_000
    const input = cart([
      line('expensive', 1000n, qty),
      line('cheap', 100n, qty),
    ])

    const result = applyPromo(promo, input)

    const sum = result.perLine.reduce((acc, entry) => acc + entry.amount, 0n)
    expect(sum).toBe(result.total)
    expect(result.total).toBeGreaterThan(0n)
    expect(result.total).toBe(BigInt(qty) * 100n)
  })

  it('bogo frees the cheapest units pooled across scoped lines', () => {
    const promo = basePromo({
      kind: 'bogo',
      valueBps: undefined,
      bogo: { buyQty: 2, getQty: 1 },
    })
    const input = cart([
      line('expensive', 1000n, 2, { productId: 'p1' }),
      line('cheap', 100n, 4, { productId: 'p2' }),
    ])
    const scopedSubtotal = 2400n

    const result = applyPromo(promo, input)

    expect(result.total).toBe(200n)
    expect(result.perLine).toEqual([{ lineId: 'cheap', amount: 200n }])
    assertMoneyInvariants(result, scopedSubtotal)
  })

  it('scope filter discounts only matching product lines', () => {
    const promo = basePromo({
      scope: { kind: 'products', ids: ['prod-match'] },
    })
    const input = cart([
      line('match', 2000n, 1, { productId: 'prod-match' }),
      line('other', 3000n, 1, { productId: 'prod-other' }),
    ])
    const scopedSubtotal = 2000n

    const result = applyPromo(promo, input)

    expect(result.total).toBe(200n)
    expect(result.perLine).toEqual([{ lineId: 'match', amount: 200n }])
    assertMoneyInvariants(result, scopedSubtotal)
  })

  it('fail-closed: products-scoped line missing productId is not discounted', () => {
    const promo = basePromo({
      scope: { kind: 'products', ids: ['prod-match'] },
    })
    const input = cart([
      line('missing-classification', 2000n, 1),
      line('match', 1000n, 1, { productId: 'prod-match' }),
    ])
    const scopedSubtotal = 1000n

    const result = applyPromo(promo, input)

    expect(result.total).toBe(100n)
    expect(result.perLine).toEqual([{ lineId: 'match', amount: 100n }])
    assertMoneyInvariants(result, scopedSubtotal)
  })

  it('returns zero discount when no scoped lines match', () => {
    const promo = basePromo({
      scope: { kind: 'products', ids: ['prod-x'] },
    })
    const input = cart([line('other', 5000n, 1, { productId: 'prod-y' })])

    const result = applyPromo(promo, input)

    expect(result).toEqual({ total: 0n, perLine: [], funder: 'platform' })
  })

  it('returns zero discount when scopedSubtotal is zero', () => {
    const promo = basePromo()
    const input = cart([line('freebie', 0n, 2)])

    const result = applyPromo(promo, input)

    expect(result).toEqual({ total: 0n, perLine: [], funder: 'platform' })
  })

  it('throws on a malformed promo instead of over-discounting (shape enforced at every entry)', () => {
    const input = cart([line('line-1', 1000n, 1)])

    expect(() => applyPromo(basePromo({ valueBps: 20_000 }), input)).toThrow(PromoValidationError)
    expect(() => applyPromo(basePromo({ valueBps: 0 }), input)).toThrow(PromoValidationError)
    expect(() =>
      applyPromo(
        basePromo({ kind: 'fixed', valueBps: undefined, valueAmount: -100n, currency: 'USD' }),
        input,
      ),
    ).toThrow(PromoValidationError)
    expect(() =>
      applyPromo(
        basePromo({ kind: 'bogo', valueBps: undefined, bogo: { buyQty: 0, getQty: 1 } }),
        input,
      ),
    ).toThrow(PromoValidationError)
  })

  it('throws on a negative unitPrice or non-integer/negative qty line (trust boundary)', () => {
    const promo = basePromo({ valueBps: 5000 })

    expect(() =>
      applyPromo(promo, cart([line('neg', -500n, 1), line('pos', 1000n, 1)])),
    ).toThrow(PromoValidationError)
    expect(() => applyPromo(promo, cart([line('frac-qty', 1000n, 1.5)]))).toThrow(
      PromoValidationError,
    )
    expect(() => applyPromo(promo, cart([line('neg-qty', 1000n, -1)]))).toThrow(
      PromoValidationError,
    )
  })
})
