import {createHmac, timingSafeEqual} from 'node:crypto';

/**
 * The session cookie format, and the only place that knows how to mint or
 * verify one. SERVER ONLY — imported by the auth route handlers and by
 * src/proxy.ts, never by a component.
 *
 * ── Why a signed cookie rather than localStorage ─────────────────────────
 * The session this replaces lived in localStorage, which means (a) any script
 * on the origin could read it, (b) the "session" was whatever the client said
 * it was, and (c) the server had no way to gate a request. An httpOnly cookie
 * is invisible to JS, travels automatically, and can be checked in the proxy
 * before a protected page is ever rendered. That is the difference between a
 * login screen and authentication.
 *
 * ── Format ────────────────────────────────────────────────────────────────
 *   base64url(JSON payload) + "." + base64url(HMAC-SHA256(payload, secret))
 *
 * Self-contained and stateless, like a JWT without the algorithm-confusion
 * surface: there is no `alg` field to downgrade, because there is exactly one
 * algorithm. If a real backend issues JWTs instead, only `verifySessionToken`
 * changes — its callers already treat the result as an opaque payload.
 *
 * NOT encrypted, only signed: the payload is readable by anyone holding the
 * cookie (which is the user themselves), so it carries an id and a display
 * name, never anything secret.
 */

const DEV_SECRET = 'loyaly-dev-secret-not-for-production';

/**
 * Falls back to a constant in development so a fresh clone runs with no setup.
 * In production a missing AUTH_SECRET is fatal rather than silently signing
 * every session with a value that is checked into git.
 */
function secret(): string {
  const fromEnv = process.env.AUTH_SECRET;
  if (fromEnv) return fromEnv;
  if (process.env.NODE_ENV === 'production') {
    throw new Error(
      'AUTH_SECRET is required in production — refusing to sign sessions with the development key.',
    );
  }
  return DEV_SECRET;
}

export const SESSION_COOKIE = 'loyaly_session';

/** Cookie lifetimes. `remember me` is the difference between the two. */
export const REMEMBERED_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; // 30 days
export const SESSION_MAX_AGE_SECONDS = 60 * 60 * 12; // 12 hours

export interface SessionPayload {
  /** User id. The name is display-only; authorisation resolves from this. */
  sub: string;
  email: string;
  name: string;
  role: string;
  organisation: string;
  /** Issued-at and expiry, epoch seconds. */
  iat: number;
  exp: number;
}

function b64url(input: Buffer | string): string {
  return Buffer.from(input).toString('base64url');
}

function sign(data: string): string {
  return createHmac('sha256', secret()).update(data).digest('base64url');
}

export function createSessionToken(
  payload: Omit<SessionPayload, 'iat' | 'exp'>,
  maxAgeSeconds: number,
): string {
  const now = Math.floor(Date.now() / 1000);
  const full: SessionPayload = {
    ...payload,
    iat: now,
    exp: now + maxAgeSeconds,
  };
  const body = b64url(JSON.stringify(full));
  return `${body}.${sign(body)}`;
}

/**
 * Returns the payload only when the signature verifies AND the token is
 * unexpired. Every failure mode — malformed, tampered, expired — returns null,
 * because callers must treat all of them identically: no session.
 *
 * The comparison is timing-safe. A `===` here leaks how many leading bytes of
 * a forged signature were correct, which is enough to forge one byte at a time.
 */
export function verifySessionToken(
  token: string | undefined,
): SessionPayload | null {
  if (!token) return null;

  const dot = token.lastIndexOf('.');
  if (dot <= 0) return null;

  const body = token.slice(0, dot);
  const provided = Buffer.from(token.slice(dot + 1));
  const expected = Buffer.from(sign(body));

  if (
    provided.length !== expected.length ||
    !timingSafeEqual(provided, expected)
  ) {
    return null;
  }

  let payload: SessionPayload;
  try {
    payload = JSON.parse(
      Buffer.from(body, 'base64url').toString('utf8'),
    ) as SessionPayload;
  } catch {
    return null;
  }

  if (typeof payload.exp !== 'number' || payload.exp * 1000 <= Date.now()) {
    return null;
  }
  return payload;
}

/**
 * Cookie attributes, in one place so the login route and the logout route
 * cannot disagree about scope — a delete that misses on `path` leaves a live
 * session behind.
 */
export function sessionCookieOptions(maxAgeSeconds?: number) {
  return {
    httpOnly: true,
    sameSite: 'lax' as const,
    secure: process.env.NODE_ENV === 'production',
    path: '/',
    // Omitted for a browser-session cookie: the browser drops it on close,
    // which is exactly what "don't remember me" should mean.
    ...(maxAgeSeconds !== undefined ? {maxAge: maxAgeSeconds} : {}),
  };
}
