import { describe, expect, test } from "bun:test"

import {
  MAX_ATTEMPTS,
  WINDOW_MS,
  createAttemptLimit,
} from "@/lib/attempt-limit"

/**
 * No clock is read anywhere below. Every `now` is a number written in the test,
 * which is why none of these has to sleep and none of them can be flaky.
 *
 * Each test builds its own counters. The shared ones the application uses are
 * module state, and a suite that reached into them would either need a reset
 * door cut into production code or would start caring what order it runs in.
 */

describe("how much budget there is", () => {
  test("nothing blocks while there is budget left", () => {
    const limit = createAttemptLimit()

    for (let attempt = 0; attempt < MAX_ATTEMPTS - 1; attempt++) {
      limit.recordFailure("malai", attempt * 1000)
      expect(limit.tooManyAttempts("malai", attempt * 1000)).toBe(false)
    }
  })

  test("the tenth failure blocks and the ninth does not", () => {
    const limit = createAttemptLimit()

    for (let attempt = 0; attempt < MAX_ATTEMPTS - 1; attempt++) {
      limit.recordFailure("malai", 0)
    }
    expect(limit.tooManyAttempts("malai", 0)).toBe(false)

    limit.recordFailure("malai", 0)
    expect(limit.tooManyAttempts("malai", 0)).toBe(true)
  })

  /**
   * One person mistyping their own password must not cost anybody else their
   * attempts. A shared bucket would lock the whole factory out of the screen the
   * moment one account was guessed at.
   */
  test("two usernames do not share a bucket", () => {
    const limit = createAttemptLimit()

    for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
      limit.recordFailure("malai", 0)
    }

    expect(limit.tooManyAttempts("malai", 0)).toBe(true)
    expect(limit.tooManyAttempts("narin", 0)).toBe(false)

    limit.recordFailure("narin", 0)
    expect(limit.tooManyAttempts("narin", 0)).toBe(false)
  })
})

describe("when it lets go", () => {
  test("the window rolling lets them back in", () => {
    const limit = createAttemptLimit()

    for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
      limit.recordFailure("malai", 0)
    }

    expect(limit.tooManyAttempts("malai", WINDOW_MS - 1)).toBe(true)
    expect(limit.tooManyAttempts("malai", WINDOW_MS)).toBe(false)
  })

  /** The window is measured from the first failure, so the wait cannot grow. */
  test("failing again does not push the wait out", () => {
    const limit = createAttemptLimit()

    for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
      limit.recordFailure("malai", 0)
    }
    limit.recordFailure("malai", WINDOW_MS - 1000)

    expect(limit.tooManyAttempts("malai", WINDOW_MS)).toBe(false)
  })

  test("a success clears the count", () => {
    const limit = createAttemptLimit()

    for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
      limit.recordFailure("malai", 0)
    }
    expect(limit.tooManyAttempts("malai", 0)).toBe(true)

    limit.clearAttempts("malai")

    expect(limit.tooManyAttempts("malai", 0)).toBe(false)
    expect(limit.retryAfter("malai", 0)).toBe(0)
    expect(limit.size()).toBe(0)
  })

  /** Somebody who gets in on the ninth try starts again from ten. */
  test("a success part way through gives the whole budget back", () => {
    const limit = createAttemptLimit()

    for (let attempt = 0; attempt < MAX_ATTEMPTS - 1; attempt++) {
      limit.recordFailure("malai", 0)
    }
    limit.clearAttempts("malai")

    for (let attempt = 0; attempt < MAX_ATTEMPTS - 1; attempt++) {
      limit.recordFailure("malai", 0)
    }
    expect(limit.tooManyAttempts("malai", 0)).toBe(false)
  })
})

describe("what the screen is told to say", () => {
  test("the wait counts down to the end of the window", () => {
    const limit = createAttemptLimit()

    for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
      limit.recordFailure("malai", 0)
    }

    expect(limit.retryAfter("malai", 0)).toBe(WINDOW_MS / 1000)
    expect(limit.retryAfter("malai", WINDOW_MS - 90_000)).toBe(90)
    // Never rounded down to nothing, which would read as "try again now" on a
    // screen that is still refusing them.
    expect(limit.retryAfter("malai", WINDOW_MS - 1)).toBe(1)
  })

  test("nothing to wait for when nothing is blocked", () => {
    const limit = createAttemptLimit()

    expect(limit.retryAfter("nobody", 0)).toBe(0)

    limit.recordFailure("malai", 0)
    expect(limit.retryAfter("malai", 0)).toBe(0)
  })
})

describe("what keeps it from filling the process", () => {
  /**
   * The keys are whatever somebody typed into a username field, so a run of
   * invented names must not leave anything behind once their windows are over.
   */
  test("expired entries go on the next write", () => {
    const limit = createAttemptLimit({ windowMs: 1000 })

    for (let ghost = 0; ghost < 50; ghost++) {
      limit.recordFailure(`ghost-${ghost}`, 0)
    }
    expect(limit.size()).toBe(50)

    limit.recordFailure("malai", 5000)

    expect(limit.size()).toBe(1)
    expect(limit.tooManyAttempts("ghost-0", 5000)).toBe(false)
  })

  test("the cap holds, and the oldest run is what goes", () => {
    const limit = createAttemptLimit({
      maxKeys: 3,
      maxAttempts: 2,
      windowMs: 10_000,
    })

    limit.recordFailure("malai", 0)
    limit.recordFailure("malai", 0)
    expect(limit.tooManyAttempts("malai", 0)).toBe(true)

    limit.recordFailure("narin", 0)
    limit.recordFailure("nattikan", 0)
    expect(limit.size()).toBe(3)

    limit.recordFailure("somchai", 0)

    expect(limit.size()).toBe(3)
    // What the cap costs, said out loud: a live block is forgotten before its
    // window is over. Ten thousand keys is far past anything the roster of 193
    // can produce by typing, so paying it means somebody was flooding the form.
    expect(limit.tooManyAttempts("malai", 0)).toBe(false)
    expect(limit.tooManyAttempts("somchai", 0)).toBe(false)
  })
})
