export interface MemoizeOptions<Args extends readonly unknown[]> {
  /** Max distinct keys retained; past it the least-recently-used entry is evicted. Default 256. Must be an integer ≥ 1. */
  max?: number
  /** Derives the cache key from the call args. Default: stable, key-order-INSENSITIVE serialization. */
  key?: (...args: Args) => string
}

const DEFAULT_MAX = 256

function isPlainObject(value: unknown): value is Record<string, unknown> {
  if (value === null || typeof value !== 'object') return false
  const prototype = Object.getPrototypeOf(value)
  return prototype === Object.prototype || prototype === null
}

function stableSerialize(value: unknown): string {
  if (value === null) return 'null'

  const valueType = typeof value
  if (valueType === 'string') return JSON.stringify(value)
  if (valueType === 'number') {
    if (Number.isNaN(value)) return 'number:NaN'
    if (Object.is(value, -0)) return 'number:-0'
    if (value === Infinity) return 'number:Infinity'
    if (value === -Infinity) return 'number:-Infinity'
    return `number:${value}`
  }
  if (valueType === 'boolean') return value ? 'true' : 'false'
  if (valueType === 'undefined') return 'undefined'
  if (valueType === 'bigint') return `bigint:${value}n`
  if (valueType === 'symbol' || valueType === 'function') {
    throw new TypeError('memoize: default key supports only JSON-like arguments; pass options.key for this value')
  }

  if (Array.isArray(value)) {
    return `[${value.map((item) => stableSerialize(item)).join(',')}]`
  }

  if (!isPlainObject(value)) {
    return `${Object.prototype.toString.call(value)}:${String(value)}`
  }

  const entries = Object.keys(value)
    .sort()
    .map((key) => `${JSON.stringify(key)}:${stableSerialize(value[key])}`)

  return `{${entries.join(',')}}`
}

function defaultKey<Args extends readonly unknown[]>(...args: Args): string {
  return stableSerialize(args)
}

export function memoize<Args extends readonly unknown[], Result>(
  fn: (...args: Args) => Result,
  options?: MemoizeOptions<Args>,
): (...args: Args) => Result {
  const max = options?.max ?? DEFAULT_MAX
  if (!Number.isInteger(max) || max < 1) {
    throw new TypeError('memoize: options.max must be an integer >= 1')
  }

  const cache = new Map<string, Result>()
  const getKey = options?.key ?? defaultKey

  return (...args: Args): Result => {
    const key = getKey(...args)

    if (cache.has(key)) {
      const cached = cache.get(key) as Result
      cache.delete(key)
      cache.set(key, cached)
      return cached
    }

    const result = fn(...args)
    cache.set(key, result)

    while (cache.size > max) {
      const oldestKey = cache.keys().next().value as string
      cache.delete(oldestKey)
    }

    return result
  }
}
