import { betterAuth } from "better-auth"
import { APIError, createAuthMiddleware } from "better-auth/api"
import { drizzleAdapter } from "better-auth/adapters/drizzle"
import { nextCookies } from "better-auth/next-js"
import { username } from "better-auth/plugins/username"

import { db } from "@/lib/db/client"
import * as authSchema from "@/lib/db/auth-schema"
import { env } from "@/lib/env"
import { PASSWORD_MIN } from "@/lib/password"
import { hasLeft } from "@/lib/services/people"
import { USERNAME_MAX, USERNAME_MIN, isUsername } from "@/lib/username"

/**
 * Signing in.
 *
 * People sign in with a username, given to them when they are hired and stored
 * on the person in `people.username`. The employee code is not the login: the
 * client's answer to their own question 10 says it is "an ID stored for later,
 * for sync with old database", and it is a pillar of the accounting system it
 * already lives in. Both live in the database and the person is what links
 * them.
 *
 * Better Auth still requires an email on every user, so each account carries a
 * derived one, `<username>@advmfr.local`. It is never sent to, never shown, and
 * never typed. `.local` is reserved by RFC 6762 and cannot resolve, so an
 * address built this way can never accidentally reach a real inbox. The
 * alternative was to make the email itself the login, which would have meant an
 * identifier masquerading as an address in every row of the database.
 *
 * No email verification and no password reset by mail, because there is no mail.
 * A forgotten password is reset by Admin or HR, in person, which is what the
 * help screen already tells people.
 */
export const auth = betterAuth({
  appName: "ADVMFR Task Tracker",
  secret: env.BETTER_AUTH_SECRET,
  baseURL: env.BETTER_AUTH_URL,

  /**
   * The schema is handed over explicitly. The adapter would otherwise look for
   * it on the Drizzle instance, and Drizzle 1.0 dropped the `schema` option
   * that used to put it there, so it would find nothing and fail looking for a
   * table it was never told about.
   */
  database: drizzleAdapter(db, { provider: "pg", schema: authSchema }),

  /**
   * Four routes `auth.handler` publishes that this product has no use for, and
   * that each did something nobody asked for. All four were measured open
   * before this line named them; none is called by any screen, because signing
   * in, signing out and changing a password all go through Server Actions and
   * `auth.api`.
   *
   * `/sign-up/email` let anybody create an account. It reached nothing, since
   * `currentUser` refuses a login with no roster row behind it, but it wrote
   * rows on demand and it took usernames out of HR's hands: `openLogin` looks
   * for a duplicate in `people`, so a name squatted in the auth table passed
   * that check and failed deeper in, with no explanation on the screen.
   *
   * `/update-user` let anybody signed in rename their own login. Measured: a
   * staff account renamed itself from `nattikan` to `nattikan2` and returned
   * 200. `people.username` still said `nattikan`, so HR's screen and the login
   * disagreed, and the person was locked out being told their password was
   * wrong. Usernames are HR's to assign, per the client's answer of 4 August.
   *
   * `/is-username-available` answered truthfully to anybody, with no session:
   * a real name came back unavailable and an invented one available. That is a
   * roster oracle, and the plugin's own documentation names this exact option
   * as the way to close it. ELEVA-133 accepted an enumeration surface on the
   * sign-in screen deliberately; it did not accept one that needs no password
   * attempt at all.
   *
   * `/change-password` is closed for the plainest of the four reasons: nothing
   * calls it. `/settings` posts to a Server Action, which reaches
   * `auth.api.changePassword` directly, so the HTTP route is a second door into
   * the same room with a different lock on it. Its own limiter is the router's
   * `onRequestRateLimit`, which `lib/attempt-limit.ts` explains never sees the
   * Server Action path, so leaving the route open would have meant the one way
   * in that is counted and one that is not.
   *
   * `disabledPaths` and not `emailAndPassword.disableSignUp`, which is the
   * obvious option and the wrong one. That flag is read inside the endpoint
   * handler (`api/routes/sign-up.mjs`), so it would also refuse
   * `auth.api.signUpEmail`, which is how `openLogin` creates an account.
   * `disabledPaths` is read in the router's `onRequest` (`api/index.mjs`), so
   * it closes the HTTP route and leaves the server-side API working. The
   * difference is the whole fix.
   */
  disabledPaths: [
    "/sign-up/email",
    "/update-user",
    "/is-username-available",
    "/change-password",
  ],

  /**
   * A session lasts a shift, not a week.
   *
   * Better Auth's default is seven days, refreshed daily, and nothing here had
   * said otherwise. On a shared terminal that means somebody signs in on Monday
   * morning, walks away, and the machine stays signed in as them all week. The
   * next person to walk up **is** that person: every task they finish and every
   * approval they give is recorded under a name that was not in the room.
   *
   * That walks straight through every other guard in this codebase, each of
   * which defends the same problem from a different side. The language switcher
   * lives on the person and not the browser because a browser is inherited.
   * Changing a password revokes other sessions because one left open at the far
   * end of the building outlives the password that opened it. The tier and the
   * archived check are read from the roster on every request. None of it helps
   * when the request carries a valid cookie belonging to somebody who left the
   * room on Tuesday.
   *
   * Eight hours because that is a shift, and an hour of refresh so that a
   * session in use stays alive while one nobody has touched runs out. Better
   * Auth has no idle timeout, so this pairing is how you get one: the clock
   * only moves forward when somebody is actually there.
   *
   * **These two numbers are a placeholder with a reason, not a policy.** Nobody
   * has asked the factory how long a person stands at a terminal, whether an
   * office machine should behave like one at the end of a line, or whether
   * people will sign out. Eight hours is defensible and reversible; the real
   * number is a decision, and ELEVA-220 is where it is recorded.
   */
  session: {
    expiresIn: 60 * 60 * 8,
    updateAge: 60 * 60,
  },

  emailAndPassword: {
    enabled: true,
    // Nothing to verify against and nowhere to send a link.
    requireEmailVerification: false,
    /*
      Six, and still a placeholder rather than a policy. The number and the
      reasoning moved to `lib/password.ts` when the settings screen was built:
      that screen has to print the figure in a hint and it is a client
      component, so it cannot read anything that pulls a database in behind it.
      One constant, read here and read there.
    */
    minPasswordLength: PASSWORD_MIN,
  },

  /**
   * Somebody who has left keeps a working password until this refuses them.
   *
   * `people.archived_at` already means the person is gone, so the check reads
   * that and nothing else. The alternative was a second column saying whether a
   * login still works, and two columns answering one question is how a leaver
   * ends up archived in the roster and still signing in.
   *
   * It sits here rather than in the service because the HTTP route is reachable
   * without going through the service, and a guard the caller can walk around
   * is not a guard.
   */
  hooks: {
    before: createAuthMiddleware(async (ctx) => {
      if (ctx.path !== "/sign-in/username") return
      const name = ctx.body?.username
      if (typeof name !== "string") return

      if (await hasLeft(name)) {
        throw new APIError("UNAUTHORIZED", {
          message: "This account is no longer active.",
          code: "PERSON_ARCHIVED",
        })
      }
    }),
  },

  plugins: [
    username({
      minUsernameLength: USERNAME_MIN,
      maxUsernameLength: USERNAME_MAX,
      /*
        The shape HR may choose from, in `lib/username.ts`. It was eight digits
        for a day, when the employee code was the login; the client's answer to
        their own question 10 says that code is an internal sync identifier and
        never the username, and it is a pillar of the accounting system it
        already lives in.
      */
      usernameValidator: isUsername,
    }),
    // Last in the array, per the Next.js integration guide: it sets cookies for
    // anything that returns a Set-Cookie header from a server action.
    nextCookies(),
  ],
})
