import { describe, expect, it, vi } from 'vitest'
import { memoize } from './memo.js'

describe('memoize', () => {
  it('reuses the cached value for identical arguments (fn called once)', () => {
    const fn = vi.fn((a: number, b: number) => a + b)
    const memoized = memoize(fn)

    expect(memoized(1, 2)).toBe(3)
    expect(memoized(1, 2)).toBe(3)
    expect(fn).toHaveBeenCalledTimes(1)
  })

  it('gives distinct arguments distinct entries (guards stable-key collapse)', () => {
    const fn = vi.fn((locale: string) => locale.toUpperCase())
    const memoized = memoize(fn)

    expect(memoized('en')).toBe('EN')
    expect(memoized('fr')).toBe('FR')
    expect(fn).toHaveBeenCalledTimes(2)
  })

  it('treats plain-object argument keys as order-insensitive by default', () => {
    const fn = vi.fn((value: { a: number; b: number }) => value.a + value.b)
    const memoized = memoize(fn)

    expect(memoized({ a: 1, b: 2 })).toBe(3)
    expect(memoized({ b: 2, a: 1 })).toBe(3)
    expect(fn).toHaveBeenCalledTimes(1)
  })

  it('treats nested object keys as order-insensitive by default', () => {
    const fn = vi.fn((value: { outer: { a: number; b: number } }) => value.outer.a + value.outer.b)
    const memoized = memoize(fn)

    expect(memoized({ outer: { a: 1, b: 2 } })).toBe(3)
    expect(memoized({ outer: { b: 2, a: 1 } })).toBe(3)
    expect(fn).toHaveBeenCalledTimes(1)
  })

  it('never exceeds max entries under many distinct keys, recomputing evicted keys', () => {
    const seen: string[] = []
    const fn = vi.fn((value: string) => {
      seen.push(value)
      return value
    })
    const memoized = memoize(fn, { max: 3 })

    for (const value of ['a', 'b', 'c', 'd', 'e']) memoized(value)
    // 5 distinct keys through a max-3 cache => 5 constructions, cache holds only the last 3.
    expect(fn).toHaveBeenCalledTimes(5)

    // 'a'/'b' were evicted (oldest); re-requesting 'a' recomputes.
    memoized('a')
    expect(fn).toHaveBeenCalledTimes(6)
    // 'e' is still hot (among last 3) => no recompute.
    memoized('e')
    expect(fn).toHaveBeenCalledTimes(6)
  })

  it('keeps a recently-read key while evicting an older unread key (LRU)', () => {
    const fn = vi.fn((value: string) => value.toUpperCase())
    const memoized = memoize(fn, { max: 2 })

    expect(memoized('a')).toBe('A') // cache: a
    expect(memoized('b')).toBe('B') // cache: a,b
    expect(memoized('a')).toBe('A') // hit, bumps a => cache: b,a
    expect(memoized('c')).toBe('C') // evicts b => cache: a,c
    expect(memoized('b')).toBe('B') // b was evicted => recompute
    expect(fn).toHaveBeenCalledTimes(4)
  })

  it('memoizes an undefined return via has()-gating (not recomputed)', () => {
    const fn = vi.fn((_value: string) => undefined)
    const memoized = memoize(fn)

    expect(memoized('x')).toBeUndefined()
    expect(memoized('x')).toBeUndefined()
    expect(fn).toHaveBeenCalledTimes(1)
  })

  it('honours a custom key function (distinct args collapse to one key)', () => {
    const fn = vi.fn((value: string) => value.toUpperCase())
    const memoized = memoize(fn, { key: (value: string) => String(value.length) })

    expect(memoized('aa')).toBe('AA')
    expect(memoized('bb')).toBe('AA') // same key (length 2) => cached first result
    expect(fn).toHaveBeenCalledTimes(1)
  })

  it('throws when the default key sees a function argument', () => {
    const memoized = memoize((value: () => string) => value())

    expect(() => memoized(() => 'value')).toThrow(TypeError)
  })

  it('allows non-serializable arguments when the caller supplies a key', () => {
    const fn = vi.fn((value: () => string) => value())
    const memoized = memoize(fn, { key: () => 'shared' })

    expect(memoized(() => 'first')).toBe('first')
    expect(memoized(() => 'second')).toBe('first')
    expect(fn).toHaveBeenCalledTimes(1)
  })

  it('throws a TypeError when max is less than 1', () => {
    expect(() => memoize((value: string) => value, { max: 0 })).toThrow(TypeError)
  })

  it('throws a TypeError when max is not an integer', () => {
    expect(() => memoize((value: string) => value, { max: 2.5 })).toThrow(TypeError)
  })
})
