import { headers } from "next/headers"

import { isAPIError } from "better-auth/api"

import {
  clearAttempts,
  recordFailure,
  retryAfter,
  tooManyAttempts,
} from "@/lib/attempt-limit"
import { auth } from "@/lib/auth"
import { checkNewPassword } from "@/lib/password"

/**
 * Somebody replacing their own password.
 *
 * It exists because until now nobody could. HR generates a first password and
 * hands it over on paper, and a person who simply wants one they can remember
 * had to go and ask for an operation that is none of HR's business.
 *
 * `auth.api.changePassword` and not a hash written here. `resetPassword` in
 * `lib/services/people.ts` reaches for the hasher directly because it is
 * setting a password for somebody who is not present; this one has the person
 * in front of it and their current password to prove it, which is exactly the
 * question the endpoint already asks.
 */

export type PasswordChange =
  | { ok: true }
  /** The current password does not match the one on the account. */
  | { ok: false; reason: "wrong_password" }
  | { ok: false; reason: "too_short" }
  | { ok: false; reason: "mismatch" }
  /**
   * Too many wrong current passwords lately. `retryAfter` is in seconds, so the
   * screen can say how long rather than saying no twice.
   */
  | { ok: false; reason: "too_many_attempts"; retryAfter: number }
  /**
   * The endpoint refused for a reason this screen has no sentence for: a login
   * with no credential row behind it, a password past the library's maximum, a
   * database that was not there. Nothing is guessed about which; a wrong guess
   * printed in Thai is worse than an honest "it did not happen".
   */
  | { ok: false; reason: "failed" }

/**
 * The counter this screen spends, which is deliberately not the one the sign-in
 * screen spends.
 *
 * `lib/services/sign-in.ts` keys on the normalised username. Getting your own
 * current password wrong here must not eat the budget that lets you sign in
 * tomorrow morning, and a run of failed sign-ins must not lock you out of the
 * one screen that fixes the password you cannot remember. Those are the same
 * person and two different questions.
 *
 * The colon is what makes that a guarantee rather than a hope: `checkUsername`
 * refuses every character outside letters, digits, a dot, a hyphen and an
 * underscore, so nothing typed into a username field can ever produce a key
 * beginning `change-password:`. A person id follows it because that is what the
 * session already knows and it survives HR renaming somebody.
 */
function counterFor(personId: string): string {
  return `change-password:${personId}`
}

/** Better Auth's own word for "that is not the current password". */
function saidWrongPassword(error: unknown): boolean {
  return isAPIError(error) && error.body?.code === "INVALID_PASSWORD"
}

export async function changeOwnPassword(
  personId: string,
  currentPassword: string,
  newPassword: string,
  again: string,
): Promise<PasswordChange> {
  const counter = counterFor(personId)
  // One reading, so a slow database cannot let the check and the record of it
  // disagree about which window they are in.
  const now = Date.now()

  /*
    Before the call that verifies the current password, never after, and for
    the reason `lib/services/sign-in.ts` gives at length: verifying a password
    is a deliberately expensive hash, and that cost is exactly the resource
    somebody guessing is trying to spend. A limit applied after the attempt
    would refuse the answer and still pay for it.
  */
  if (tooManyAttempts(counter, now)) {
    return {
      ok: false,
      reason: "too_many_attempts",
      retryAfter: retryAfter(counter, now),
    }
  }

  /*
    Judged again here, having already been judged in the browser. This function
    is reachable as a Server Action with whatever arguments somebody chooses,
    so a disabled button is a courtesy and never a check. Neither of these two
    refusals costs a hash, so neither is counted: they are not guesses at a
    password.
  */
  const problem = checkNewPassword(newPassword, again)
  if (problem) return { ok: false, reason: problem }

  try {
    await auth.api.changePassword({
      // The session comes from the request, which is what
      // `sensitiveSessionMiddleware` on that endpoint insists on. There is no
      // freshness window to satisfy, so nobody is sent back to the login screen
      // to prove they are still themselves.
      headers: await headers(),
      body: {
        currentPassword,
        newPassword,
        /**
         * These are shared terminals on a factory floor. Somebody changing
         * their password is very often doing it because they believe another
         * person has watched them type the old one, and a session left open on
         * a machine at the other end of the building outlives the password that
         * opened it. The endpoint replaces the caller's own session in the same
         * breath and sets the new cookie, which `nextCookies()` carries out of
         * the Server Action, so the person doing this stays signed in.
         */
        revokeOtherSessions: true,
      },
    })
  } catch (error) {
    // Counted before the error is read, so the two refusals below cost the same
    // budget and the answer says nothing the attempt did not already know.
    recordFailure(counter, now)
    return {
      ok: false,
      reason: saidWrongPassword(error) ? "wrong_password" : "failed",
    }
  }

  // Getting it right on the ninth try starts again from ten. Nobody who has
  // just proved they know the password should carry their own typing forward.
  clearAttempts(counter)
  return { ok: true }
}
