/**
 * Colo-local user cache: cache.default keyed by userId + mhv.
 *
 * Eliminates per-request Neon round-trip from session middleware.
 * TTL 60s. Cache key includes mhv so any mhv bump (profile edit, vendor
 * state change, admin role change) automatically invalidates on next request.
 */

import type { DrizzleClient } from '@/server/db/client.js';
import type { User } from '@/server/auth/session.js';
import * as userQueries from '@/server/db/queries/users.js';
import * as vendorQueries from '@/server/db/queries/vendors.js';
import { isActiveAffiliate } from '@/server/auth/access.js';
import { captureCaught } from '@/server/observability/capture.server';

// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------

export interface CachedUserView {
  /** Exact shape of the users-table row, with Date fields rehydrated on read. */
  user: User;
  /** Resolved from vendorQueries.hasActiveVendor. */
  isVendor: boolean;
  /** Resolved from auth/access.isActiveAffiliate — coalesced into the same Promise.all. */
  isAffiliate: boolean;
  /**
   * True when the DB row mhVersion is ahead of the token mhv (display/role-hint
   * cache is stale). NOT an auth signal — caller re-mints the access token in place;
   * authentication is unaffected. (user.sessionVersion is the auth gate.)
   */
  mhStale: boolean;
  /** Epoch ms — debug only, not used by readers. */
  cachedAt: number;
}

// ---------------------------------------------------------------------------
// Internals
// ---------------------------------------------------------------------------

const TTL_SECS = 60;

/**
 * Keys whose serialized JSON values are ISO strings and must be Date in memory.
 * JSON.stringify converts Date → ISO string; we restore them on read.
 */
const DATE_KEYS = [
  'createdAt',
  'emailVerifiedAt',
  'onboardingCompletedAt',
  'deletionRequestedAt',
] as const;

function rehydrateDates(raw: Record<string, unknown>): User {
  const out = { ...raw } as Record<string, unknown>;
  for (const k of DATE_KEYS) {
    const v = out[k];
    if (typeof v === 'string') out[k] = new Date(v);
  }
  return out as User;
}

// ---------------------------------------------------------------------------
// Main export
// ---------------------------------------------------------------------------

/**
 * Returns a CachedUserView for the given userId + mhv, or null only if the user
 * is not found in DB. When DB mhVersion > token mhv, returns the live row with
 * mhStale:true (caller re-mints the access token; auth is unaffected).
 *
 * Cache key: `https://_internal/user-cache/${userId}/${mhv}/v1`
 * Cache miss fires Promise.all([findById, hasActiveVendor]) in parallel.
 * Cache write is scheduled via waitUntil when available (non-blocking).
 */
export async function getCachedUser(
  db: DrizzleClient,
  userId: string,
  mhv: number,
  opts?: { waitUntil?: (p: Promise<unknown>) => void; needsVendor?: boolean },
): Promise<CachedUserView | null> {
  const cache = (globalThis as { caches?: { default?: Cache } }).caches?.default;
  const cacheReq = cache ? new Request(`https://_internal/user-cache/${userId}/${mhv}/v2`) : null;

  // 1. Cache read
  if (cache && cacheReq) {
    try {
      const hit = await cache.match(cacheReq);
      if (hit) {
        const expires = Number(hit.headers.get('x-uc-expires') ?? 0);
        if (Date.now() < expires) {
          const body = (await hit.json()) as {
            user: Record<string, unknown>;
            isVendor: boolean;
            isAffiliate: boolean;
            cachedAt: number;
          };
          return {
            user: rehydrateDates(body.user),
            isVendor: body.isVendor,
            isAffiliate: body.isAffiliate,
            mhStale: Number((body.user as { mhVersion?: number }).mhVersion ?? 0) > mhv,
            cachedAt: body.cachedAt,
          };
        }
      }
    } catch (e) {
      captureCaught(e, { scope: 'server.middleware.user-cache.read', severity: 'info' });
    }
  }

  // 2. Cache miss → coalesce findById + hasActiveVendor + isActiveAffiliate in one Promise.all.
  // Affiliate is folded into the same fan-out to keep session middleware within the
  // 10ms CPU ceiling — a sibling SELECT round-trips Neon without serial blocking.
  // When needsVendor is false (customer routes), skip the 2 vendor/affiliate queries
  // to save ~4ms CPU on Workers Free 10ms ceiling. Cache write is also skipped to
  // prevent polluting the cache with isVendor:false for actual vendor accounts.
  const needsVendor = opts?.needsVendor ?? true;
  const [userRow, isVendor, isAffiliate] = await Promise.all([
    userQueries.findById(db, userId),
    needsVendor ? vendorQueries.hasActiveVendor(db, userId) : Promise.resolve(false),
    needsVendor ? isActiveAffiliate(db, userId) : Promise.resolve(false),
  ]);

  if (!userRow) return null;

  const view: CachedUserView = {
    user: userRow as User,
    isVendor,
    isAffiliate,
    mhStale: userRow.mhVersion > mhv,
    cachedAt: Date.now(),
  };

  // 3. Cache write — schedule via waitUntil so it doesn't block the response.
  // Skip when needsVendor is false: writing isVendor:false would corrupt subsequent
  // vendor-route cache hits for users who are actual vendors.
  if (needsVendor && cache && cacheReq) {
    try {
      const res = new Response(JSON.stringify(view), {
        headers: {
          'Cache-Control': `public, max-age=${TTL_SECS}`,
          'x-uc-expires': String(Date.now() + TTL_SECS * 1000),
          'Content-Type': 'application/json',
        },
      });
      const put = cache.put(cacheReq, res);
      if (opts?.waitUntil) {
        opts.waitUntil(put);
      } else {
        await put;
      }
    } catch (e) {
      captureCaught(e, { scope: 'server.middleware.user-cache.write', severity: 'info' });
    }
  }

  return view;
}
