import type { MiddlewareHandler } from 'hono';
import { resolveAuthToken } from '@@/auth-token';
import * as errors from '@@/custom-errors';
import { isLockdown, noteBlocked } from '../api/auth/panic';
import { getUserById, isTokenBlacklisted } from 'officerdb';

export const userMiddleware: MiddlewareHandler = async function (ctx, next) {
  // Duress lockdown: reject every authenticated request, cutting off all existing sessions.
  if (isLockdown()) {
    noteBlocked(`${ctx.req.method} ${ctx.req.path}`);
    throw errors.UNAUTHORIZED();
  }

  const { authorization } = ctx.req.header();

  // Support token in query param for media elements (<audio>, <video>, <img>)
  // that cannot send Authorization headers
  let token: string | undefined;
  if (authorization) {
    [, token] = authorization.split(' ');
  } else {
    token = ctx.req.query('token') || undefined;
  }
  if (!token) throw errors.UNAUTHORIZED();

  // There was an Origin check here until 2026-08-13. It is gone with the rest of origin validation —
  // it had defaulted to off, so it ran on no real install. A valid token is required below, and the
  // capability gate in hono.ts confines a non-owner to what their role grants.

  try {
    // Both credentials resolve here — see auth-token.ts. Everything below applies to a session JWT only:
    // an API key's revocation is a column checked during that lookup, so there is no blacklist to consult
    // and no issued-at to compare. A password change deliberately does NOT kill a user's API keys; they
    // are the credential you rotate independently, which is the whole reason they exist.
    const user = await resolveAuthToken(token);
    if (!user) throw errors.UNAUTHORIZED();

    if (user.via === 'jwt') {
      // Check if token is blacklisted (explicit signout)
      if (user.jti) {
        if (await isTokenBlacklisted(user.jti)) throw errors.UNAUTHORIZED();
      }

      // The account still has to exist, and still has to be allowed in.
      //
      // This lookup used to happen only for the password-change comparison below, and its result was read
      // as `dbUser?.passwordChangedAt` — so a DELETED account fell straight through the optional chain and
      // kept working on a token that is still cryptographically valid, for up to the full 30 days. Observed
      // 2026-08-11: an account deleted from the dashboard survived a page refresh in another window.
      //
      // `status` is the same shape of hole. signin.ts refuses anything that is not 'Active', but nothing
      // re-checked it afterwards, so marking someone Blocked or Banned did not end the session they already
      // had — which is precisely when you would be doing it.
      //
      // Re-read per request rather than trusted as a claim, for the reason the role is not a claim either:
      // a revocation has to take effect on the next request, not at next sign-in.
      if (user.id) {
        const dbUser = await getUserById(user.id);
        if (!dbUser) throw errors.UNAUTHORIZED();
        if (dbUser.status !== 'Active') throw errors.UNAUTHORIZED();

        // Token issued before the password changed → refuse. iat is seconds, passwordChangedAt is a Date.
        if (user.iat && dbUser.passwordChangedAt && user.iat * 1000 < dbUser.passwordChangedAt.getTime()) {
          throw errors.UNAUTHORIZED();
        }
      }
    }

    ctx.set('user', user);
    return next();
  } catch (ex) {
    if (ex instanceof errors.CustomError) throw ex;
    throw errors.UNAUTHORIZED();
  }
};
