/**
 * Session management — JWT-only (M9 cleanup).
 *
 * Legacy `multideal_session` HMAC cookie removed. All sessions use:
 *   - `multideal_at`   HS256 access JWT (60 min)
 *   - `multideal_rt`   opaque refresh token (30 days, path=/api/auth/refresh)
 *   - `csrf_token`     non-HttpOnly double-submit cookie
 *
 * Session rows still live in Neon (sessions table) for refresh token lookup
 * and revocation. SESSION_SECRET is retained for vendor-reg-cookie HMAC.
 */

import { eq, and, gt, isNull } from 'drizzle-orm';
import type { DrizzleClient } from '@/server/db/client.js';
import { sessions, users } from '@/server/db/schema.js';
import * as sessionQ from '@/server/db/queries/sessions.js';
import * as authQ from '@/server/db/queries/auth/session.js';
import { issueCsrfToken } from './csrf.js';
import { hashIp } from '@/server/security/ip.js';
import { generateRefreshToken, hashRefreshToken, signAccessToken } from './tokens.js';
import {
  buildAccessCookie,
  buildRefreshCookie,
  ACCESS_TOKEN_TTL_SECS,
  isHttpsRequest,
  clearAccessCookie,
  clearRefreshCookie,
} from './cookies.js';

export {
  isHttpsRequest,
  clearAccessCookie,
  clearRefreshCookie,
  buildAccessCookie,
  buildRefreshCookie,
};

export type Session = typeof sessions.$inferSelect & {
  emailVerifiedAt: Date | null;
  onboardingCompletedAt: Date | null;
};
export type User = typeof users.$inferSelect;

// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------

export const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
export const GRACE_WINDOW_MS = 60_000; // 60 seconds — RT rotation grace window
/** Rotate session when less than this time remains (7 days). */
const SESSION_ROTATE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;

// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------

export interface CreateSessionResult {
  sessionId: string;
  csrfToken: string;
  /** Set-Cookie header for the HS256 access JWT (`multideal_at`). */
  accessCookie: string;
  /** Set-Cookie header for the opaque refresh token (`multideal_rt`). */
  refreshCookie: string;
}

/** Minimal user shape required by createSession to sign the access JWT. */
export interface SessionUserInfo {
  isAdmin: boolean;
  sessionVersion: number;
  /** mh-cookie cache version. Required — no default fallback. */
  mhVersion: number;
  emailVerifiedAt: Date | null;
  onboardingCompletedAt: Date | null;
}

/**
 * Creates a new session row in the DB and returns signed cookies.
 *
 * Issues three cookies:
 *   1. `multideal_at`  (HS256 access JWT, 60 min)
 *   2. `multideal_rt`  (opaque refresh token, 30 days, path=/api/auth/refresh)
 *   3. CSRF (caller appends via buildCsrfCookieHeader — not returned here)
 */
export async function createSession(
  db: DrizzleClient,
  userId: string,
  userAgent: string,
  ip: string,
  secureCookie: boolean,
  jwtSecret: string,
  user: SessionUserInfo,
): Promise<CreateSessionResult> {
  const sessionId = crypto.randomUUID();
  const csrfToken = issueCsrfToken();
  const expiresAt = new Date(Date.now() + SESSION_TTL_MS);

  // Generate opaque refresh token and its hash for DB storage
  const refreshToken = generateRefreshToken();
  const refreshTokenHash = await hashRefreshToken(refreshToken);

  await sessionQ.create(db, {
    id: sessionId,
    userId,
    csrfToken,
    userAgent,
    ipEncrypted: await hashIp(ip),
    expiresAt,
    refreshTokenHash,
  });

  // Sign access JWT
  const roles: string[] = user.isAdmin ? ['admin'] : ['user'];
  const accessJwt = await signAccessToken(
    {
      sub: userId,
      sv: user.sessionVersion,
      mhv: user.mhVersion,
      roles,
      evAt: user.emailVerifiedAt ? Math.floor(user.emailVerifiedAt.getTime() / 1000) : null,
      obAt: user.onboardingCompletedAt
        ? Math.floor(user.onboardingCompletedAt.getTime() / 1000)
        : null,
    },
    jwtSecret,
    ACCESS_TOKEN_TTL_SECS,
  );

  const accessCookie = buildAccessCookie(accessJwt, secureCookie);
  const refreshCookie = buildRefreshCookie(refreshToken, secureCookie);

  return { sessionId, csrfToken, accessCookie, refreshCookie };
}

/**
 * Marks a session row as revoked by session ID. Idempotent.
 * Used by logout (via refresh_token_hash lookup) and rotation.
 */
export async function revokeSession(db: DrizzleClient, sessionId: string): Promise<void> {
  await sessionQ.revoke(db, sessionId);
}

/**
 * Revoke the current refresh token by its SHA-256 hash.
 * Used by logout to mark the session row revoked via the RT hash
 * (since JWT-path sessions have sentinel IDs that don't match DB rows).
 */
export async function revokeSessionByRefreshToken(
  db: DrizzleClient,
  refreshTokenHash: string,
): Promise<void> {
  await sessionQ.revokeByRefreshTokenHash(db, refreshTokenHash);
}

/**
 * Session fixation prevention: revoke old session and create a new one.
 * Returns the new session credentials.
 */
export async function rotateSession(
  db: DrizzleClient,
  oldSessionId: string,
  userId: string,
  userAgent: string,
  ip: string,
  secureCookie: boolean,
  jwtSecret: string,
  user: SessionUserInfo,
): Promise<CreateSessionResult> {
  await revokeSession(db, oldSessionId);
  return createSession(db, userId, userAgent, ip, secureCookie, jwtSecret, user);
}

/**
 * Returns the raw Session attached to `locals`, or null if unauthenticated.
 *
 * Lives here so refresh / lifecycle routes that legitimately need the raw
 * Session row (id, expiresAt, csrfToken) don't have to reach into
 * `locals.session` directly.
 */
export function getCurrentSession(locals: { session?: Session | undefined }): Session | null {
  return locals.session ?? null;
}

/**
 * Returns true if the session should be rotated (near expiry).
 */
export function sessionNeedsRotation(session: { expiresAt: Date }): boolean {
  const msRemaining = session.expiresAt.getTime() - Date.now();
  return msRemaining < SESSION_ROTATE_THRESHOLD_MS;
}

// ---------------------------------------------------------------------------
// SSR refresh helper
// ---------------------------------------------------------------------------

export interface RefreshSessionResult {
  accessJwt: string;
  accessCookie: string;
  /** Absent when concurrent refresh already rotated (race lost). */
  refreshCookie?: string;
  claims: {
    sub: string;
    sv: number;
    roles: string[];
    evAt: number | null;
    obAt: number | null;
    mhv: number;
    iat: number;
    exp: number;
  };
  isAdmin: boolean;
}

/**
 * Attempts to refresh the session from a raw refresh token (read from cookie).
 *
 * Called by SSR middleware when `multideal_at` is absent/expired but
 * `multideal_rt` is present (now Path=/ so middleware can read it).
 *
 * Steps:
 *  1. Hash RT → look up session + user (innerJoin) where not revoked + not expired.
 *  2. Generate new RT + hash.
 *  3. Atomic rotation via UPDATE WHERE old hash → RETURNING id.
 *     Race loser gets access JWT only (no new refresh cookie).
 *  4. Sign new access JWT.
 *  5. Return { accessJwt, accessCookie, refreshCookie?, claims, isAdmin }.
 *
 * Returns null when no valid session row is found.
 */
export async function refreshSessionFromCookie(
  db: DrizzleClient,
  rawRefreshToken: string,
  secureCookie: boolean,
  jwtSecret: string,
): Promise<RefreshSessionResult | null> {
  const oldHash = await hashRefreshToken(rawRefreshToken);
  const now = new Date();

  const [row] = await db
    .select({
      sessionId: sessions.id,
      userId: sessions.userId,
      sessionVersion: users.sessionVersion,
      mhVersion: users.mhVersion,
      isAdmin: users.isAdmin,
      emailVerifiedAt: users.emailVerifiedAt,
      onboardingCompletedAt: users.onboardingCompletedAt,
    })
    .from(sessions)
    .innerJoin(users, eq(sessions.userId, users.id))
    .where(
      and(
        eq(sessions.refreshTokenHash, oldHash),
        isNull(sessions.revokedAt),
        gt(sessions.expiresAt, now),
      ),
    )
    .limit(1);

  if (!row) {
    // Window-2 race: primary SELECT missed because a concurrent winner already
    // rotated this RT. Check the grace window via previous_refresh_token_hash.
    const graceCutoff = new Date(now.getTime() - GRACE_WINDOW_MS);
    const [graceRow] = await db
      .select({
        sessionId: sessions.id,
        userId: sessions.userId,
        sessionVersion: users.sessionVersion,
        mhVersion: users.mhVersion,
        isAdmin: users.isAdmin,
        emailVerifiedAt: users.emailVerifiedAt,
        onboardingCompletedAt: users.onboardingCompletedAt,
      })
      .from(sessions)
      .innerJoin(users, eq(sessions.userId, users.id))
      .where(
        and(
          eq(sessions.previousRefreshTokenHash, oldHash),
          gt(sessions.lastRefreshedAt, graceCutoff),
          isNull(sessions.revokedAt),
          gt(sessions.expiresAt, now),
        ),
      )
      .limit(1);

    if (!graceRow) return null;

    // Grace hit — issue fresh AT only; winner already issued the new RT cookie.
    const roles: string[] = graceRow.isAdmin ? ['admin'] : ['user'];
    const evAt = graceRow.emailVerifiedAt
      ? Math.floor(graceRow.emailVerifiedAt.getTime() / 1000)
      : null;
    const obAt = graceRow.onboardingCompletedAt
      ? Math.floor(graceRow.onboardingCompletedAt.getTime() / 1000)
      : null;
    const accessJwt = await signAccessToken(
      {
        sub: graceRow.userId,
        sv: graceRow.sessionVersion,
        mhv: graceRow.mhVersion,
        roles,
        evAt,
        obAt,
      },
      jwtSecret,
      ACCESS_TOKEN_TTL_SECS,
    );
    const accessCookie = buildAccessCookie(accessJwt, secureCookie);
    const nowSecs = Math.floor(Date.now() / 1000);
    const claims = {
      sub: graceRow.userId,
      sv: graceRow.sessionVersion,
      mhv: graceRow.mhVersion,
      roles,
      evAt,
      obAt,
      iat: nowSecs,
      exp: nowSecs + ACCESS_TOKEN_TTL_SECS,
    };
    return { accessJwt, accessCookie, claims, isAdmin: graceRow.isAdmin };
  }

  // Generate new RT + hash
  const newRefreshToken = generateRefreshToken();
  const newHash = await hashRefreshToken(newRefreshToken);

  // Atomic rotation: first concurrent winner updates, loser sees 0 rows returned
  const updated = await authQ.rotateRefreshToken(
    db,
    oldHash,
    newHash,
    now,
    new Date(now.getTime() + SESSION_TTL_MS),
  );

  // Sign new access JWT
  const roles: string[] = row.isAdmin ? ['admin'] : ['user'];
  const evAt = row.emailVerifiedAt ? Math.floor(row.emailVerifiedAt.getTime() / 1000) : null;
  const obAt = row.onboardingCompletedAt
    ? Math.floor(row.onboardingCompletedAt.getTime() / 1000)
    : null;
  const accessJwt = await signAccessToken(
    { sub: row.userId, sv: row.sessionVersion, mhv: row.mhVersion, roles, evAt, obAt },
    jwtSecret,
    ACCESS_TOKEN_TTL_SECS,
  );
  const accessCookie = buildAccessCookie(accessJwt, secureCookie);

  // Build claims for middleware use
  const nowSecs = Math.floor(Date.now() / 1000);
  const claims = {
    sub: row.userId,
    sv: row.sessionVersion,
    mhv: row.mhVersion,
    roles,
    evAt,
    obAt,
    iat: nowSecs,
    exp: nowSecs + ACCESS_TOKEN_TTL_SECS,
  };

  if (updated.length === 0) {
    // Race lost — return access JWT only, no new refresh cookie
    return { accessJwt, accessCookie, claims, isAdmin: row.isAdmin };
  }

  const refreshCookie = buildRefreshCookie(newRefreshToken, secureCookie);
  return { accessJwt, accessCookie, refreshCookie, claims, isAdmin: row.isAdmin };
}

// ---------------------------------------------------------------------------
// MH version bump helpers
// ---------------------------------------------------------------------------

export async function bumpMhVersion(db: DrizzleClient, userId: string): Promise<number> {
  const [row] = await authQ.incrementMhVersion(db, userId);

  if (!row) {
    throw new Error(`bumpMhVersion: user not found (id=${userId})`);
  }

  return row.mhVersion;
}

/** Atomically increment sessionVersion — invalidates all outstanding access JWTs. */
export async function bumpSessionVersion(db: DrizzleClient, userId: string): Promise<number> {
  const [row] = await authQ.incrementSessionVersion(db, userId);

  if (!row) {
    throw new Error(`bumpSessionVersion: user not found (id=${userId})`);
  }

  return row.sessionVersion;
}

/** Revoke every active session row and bump sv — global sign-out. Returns new sv. */
export async function revokeAllSessions(db: DrizzleClient, userId: string): Promise<number> {
  await authQ.revokeActiveSessions(db, userId, new Date());
  return bumpSessionVersion(db, userId);
}
