import { PromoValidationError } from './errors.js'
import { lineMatchesScope } from './scope.js'
import { assertPromoShape } from './validate.js'
import type { DiscountableCart, DiscountLine, DiscountResult, Promo } from './types.js'

interface ScopedLine {
  lineId: string
  lineValue: bigint
  unitPrice: bigint
  qty: number
}

function filterScopedLines(cart: DiscountableCart, scope: Promo['scope']): ScopedLine[] {
  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: ScopedLine[]): bigint {
  return lines.reduce((sum, line) => sum + line.lineValue, 0n)
}

function emptyResult(funder: Promo['funder']): DiscountResult {
  return { total: 0n, perLine: [], funder }
}

function percentageDiscount(scopedSubtotal: bigint, valueBps: number): bigint {
  return (2n * scopedSubtotal * BigInt(valueBps) + 10000n) / 20000n
}

function clampDiscount(totalDiscount: bigint, maxDiscountAmount?: bigint): bigint {
  if (maxDiscountAmount === undefined || totalDiscount <= maxDiscountAmount) {
    return totalDiscount
  }
  return maxDiscountAmount
}

function fixedDiscount(scopedSubtotal: bigint, valueAmount: bigint): bigint {
  return valueAmount < scopedSubtotal ? valueAmount : scopedSubtotal
}

function bogoAllocations(
  lines: ScopedLine[],
  buyQty: number,
  getQty: number,
): { total: bigint; allocations: Map<string, bigint> } {
  const totalScopedUnits = lines.reduce((sum, line) => sum + line.qty, 0)
  const groupSize = buyQty + getQty
  const freeCount = Math.floor(totalScopedUnits / groupSize) * getQty
  const allocations = new Map<string, bigint>()
  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: ScopedLine[],
  totalDiscount: bigint,
  scopedSubtotal: bigint,
): Map<string, bigint> {
  const allocations = new Map<string, bigint>()
  if (totalDiscount === 0n || scopedSubtotal === 0n) {
    return allocations
  }

  const bases: Array<{
    lineId: string
    base: bigint
    remainder: bigint
  }> = []

  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: Map<string, bigint>): DiscountResult['perLine'] {
  return [...allocations.entries()]
    .filter(([, amount]) => amount > 0n)
    .map(([lineId, amount]) => ({ lineId, amount }))
    .sort((left, right) => left.lineId.localeCompare(right.lineId))
}

function assertLine(line: DiscountLine): void {
  if (line.unitPrice < 0n) {
    throw new PromoValidationError('line.unitPrice')
  }
  if (!Number.isInteger(line.qty) || line.qty < 0) {
    throw new PromoValidationError('line.qty')
  }
}

export function applyPromo(promo: Promo, cart: DiscountableCart): DiscountResult {
  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,
      )
      const allocations = allocateLargestRemainder(scopedLines, total, scopedSubtotal)
      return { total, perLine: toPerLine(allocations), funder: promo.funder }
    }
    case 'fixed': {
      const total = fixedDiscount(scopedSubtotal, promo.valueAmount!)
      const allocations = allocateLargestRemainder(scopedLines, total, scopedSubtotal)
      return { total, perLine: toPerLine(allocations), 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 }
    }
  }
}
