/**
 * Server-side page layout loader.
 *
 * Resolves the active layout body (live version, draft preview, or hardcoded fallback),
 * filters modules by visitor visibility, and hydrates each module's data via
 * MODULE_REGISTRY[type].loadData. Returns null data on errors / unknown types —
 * the renderer skips those modules silently.
 */

import { eq } from 'drizzle-orm';
import { captureCaught } from '@/server/observability/capture.server';
import { pageLayouts, pageLayoutPointers } from '@/server/db/schema';
import { MODULE_REGISTRY } from './registry';
import { HARDCODED_DEFAULT_LAYOUT } from './fallback';
import { getHotDealThreshold, prefetchPageDeals } from '@/features/feed/FeedDataLoader';
import type { DrizzleClient } from '@/server/db/client';
import type { LayoutBody, LoadCtx, LayoutModule } from './types';

export interface LoadedModule {
  instanceId: string;
  type: string;
  config: unknown;
  data: unknown;
  /**
   * True when this module would be filtered out for the current visitor in
   * production but is being surfaced anyway because the admin is previewing.
   * The renderer decorates these with a "hidden" badge.
   */
  hiddenInProd?: boolean;
}

export interface LoadedPage {
  mobile: LoadedModule[];
  desktop: LoadedModule[];
}

const LAYOUT_CACHE_TTL = 300; // 5 minutes

/**
 * Max parallel module hydrations per request. Each loadData fires Neon-HTTP
 * subrequests + sync Drizzle/zod work; an unbounded fan-out across ~10 modules
 * burns per-request CPU in bursts and was the trigger for Worker error 1102
 * on cold cache misses. Empirically chosen — see commit "perf(page-layout)".
 */
const HYDRATE_CONCURRENCY = 4;

/** Stored at page-layout/{route}:{locale}.json in R2_BUCKET */
export interface PageLayoutSnapshot {
  version: 1;
  /** BUILD_ID of the worker that wrote the snapshot, or 'cron'. */
  buildId: string;
  generatedAt: string;
  route: string;
  locale: string;
  page: LoadedPage;
}

export interface LoadPageOptions {
  previewDraft?: boolean;
  /**
   * Optional execution-context callback — when provided, cache.put runs
   * after the response is sent rather than blocking it. Pass
   * from the caller (Astro v6+).
   */
  waitUntil?: (promise: Promise<unknown>) => void;
  /**
   * R2 bucket binding. When provided, a cache miss on caches.default first
   * attempts an R2 read before falling through to Neon queries. The R2
   * object is expected to be a PageLayoutSnapshot JSON blob written by the
   * warm-layout cron. ~1ms CPU vs ~10-20ms for Neon — breaks the cold-isolate
   * doom loop on free tier Workers.
   *
   * Only used for guest (isGuest=true), non-preview requests.
   */
  r2Bucket?: R2Bucket;
}

export async function loadPageForRender(
  db: DrizzleClient,
  page: string,
  ctx: LoadCtx,
  opts: LoadPageOptions = {},
): Promise<LoadedPage> {
  // Draft previews always bypass cache — admins must see the latest draft state
  if (opts.previewDraft) {
    return loadPageForRenderFresh(db, page, ctx, opts);
  }

  // Logged-in pages contain user-specific module data (greeting, savings, etc.).
  // Caching them under a shared key would serve one user's data to another.
  // Only guest pages are cacheable — their module data is fully anonymous.
  if (!ctx.isGuest) {
    return loadPageForRenderFresh(db, page, ctx, opts);
  }

  const cache = (caches as unknown as { default: Cache }).default;
  const slugBit = ctx.routeParams?.slug ? `/${encodeURIComponent(ctx.routeParams.slug)}` : '';
  const cacheKey = new Request(`https://layout-cache.multideal/${page}${slugBit}?guest=1`);

  // R2 snapshot read — bypasses Neon on cold isolate (free tier 10ms CPU ceiling).
  // Written by the warm-layout cron every 30 min and pre-deploy by deploy.sh.
  if (opts.r2Bucket) {
    const r2Key = `page-layout/${page}:${ctx.locale}.json`;
    try {
      const r2Obj = await opts.r2Bucket.get(r2Key);
      if (r2Obj) {
        const snap = await r2Obj.json<PageLayoutSnapshot>();
        if (snap.version === 1 && snap.page) {
          return snap.page;
        }
      }
    } catch (err) {
      // Corrupted or missing snapshot — fall through to Neon path below.
      console.warn(
        JSON.stringify({ level: 'warn', msg: 'r2_snapshot_miss', reason: (err as Error)?.message }),
      );
    }
  }

  const hit = await cache.match(cacheKey);
  if (hit) {
    return hit.json() as Promise<LoadedPage>;
  }

  const result = await loadPageForRenderFresh(db, page, ctx, opts);

  // Cache write must not block the response — on a cold-miss request that
  // already ran ~20 Neon subrequests, the JSON.stringify + cache.put cost
  // can be what tips the request over the per-request CPU budget. Pushing
  // it to waitUntil keeps the response path lean.
  const putPromise = cache.put(
    cacheKey,
    new Response(JSON.stringify(result), {
      headers: {
        'Cache-Control': `public, max-age=${LAYOUT_CACHE_TTL}, stale-while-revalidate=${LAYOUT_CACHE_TTL}`,
      },
    }),
  );
  if (opts.waitUntil) {
    opts.waitUntil(putPromise);
  } else {
    await putPromise;
  }

  return result;
}

export async function loadPageForRenderFresh(
  db: DrizzleClient,
  page: string,
  ctx: LoadCtx,
  opts: { previewDraft?: boolean } = {},
): Promise<LoadedPage> {
  // Prefetch a shared anonymous deal pool so deal-row modules can filter in-memory
  // (one round-trip) rather than each firing its own query (N trips). Pool is anonymous —
  // modules that need per-user data (greeting-bar, near-you, hot-deals) ignore it and
  // query directly. Skip on draft previews where stale pool data would be confusing.
  const dealPoolPromise = !opts.previewDraft ? prefetchPageDeals(db) : Promise.resolve(undefined);

  const [body, hotDealThreshold, prefetchedDeals] = await Promise.all([
    resolveBody(db, page, opts),
    getHotDealThreshold(db),
    dealPoolPromise,
  ]);

  const preview = !!opts.previewDraft;
  const ctx2: LoadCtx = { ...ctx, hotDealThreshold, prefetchedDeals };
  // Per-request cache: identical (type, config) modules on mobile + desktop
  // share one loadData invocation. Halves DB work when mobile/desktop arrays
  // share the same module instances (typical: home page has 10 modules ×2).
  const loadDataCache = new Map<string, Promise<unknown>>();
  // Shared concurrency limiter across mobile + desktop hydrate calls so the
  // total in-flight loadData count is capped, not 2× the limit.
  const limit = makeLimiter(HYDRATE_CONCURRENCY);
  const [mobile, desktop] = await Promise.all([
    hydrate(db, selectForRender(body.mobile, ctx2, preview), ctx2, loadDataCache, limit),
    hydrate(db, selectForRender(body.desktop, ctx2, preview), ctx2, loadDataCache, limit),
  ]);
  return { mobile, desktop };
}

/**
 * Tiny p-limit. Caps in-flight async work to `max` concurrent. Subsequent
 * callers wait in a FIFO queue. Inline rather than adding a dep — single
 * use site, zero external surface area.
 */
type Limiter = <T>(fn: () => Promise<T>) => Promise<T>;
function makeLimiter(max: number): Limiter {
  let active = 0;
  const queue: Array<() => void> = [];
  const drain = (): void => {
    while (active < max && queue.length > 0) {
      const next = queue.shift();
      if (next) {
        active++;
        next();
      }
    }
  };
  return <T>(fn: () => Promise<T>): Promise<T> =>
    new Promise<T>((resolve, reject) => {
      const run = (): void => {
        fn().then(
          (v) => {
            active--;
            resolve(v);
            drain();
          },
          (e) => {
            active--;
            reject(e);
            drain();
          },
        );
      };
      queue.push(run);
      drain();
    });
}

async function resolveBody(
  db: DrizzleClient,
  page: string,
  opts: { previewDraft?: boolean },
): Promise<LayoutBody> {
  const [row] = await db
    .select({
      draftBody: pageLayoutPointers.draftBody,
      liveBody: pageLayouts.body,
    })
    .from(pageLayoutPointers)
    .leftJoin(pageLayouts, eq(pageLayoutPointers.liveVersionId, pageLayouts.id))
    .where(eq(pageLayoutPointers.page, page))
    .limit(1);

  if (!row) return HARDCODED_DEFAULT_LAYOUT;
  if (opts.previewDraft && row.draftBody) return row.draftBody as LayoutBody;
  if (!row.liveBody) return HARDCODED_DEFAULT_LAYOUT;
  return row.liveBody as LayoutBody;
}

function isVisibleFor(m: LayoutModule, ctx: LoadCtx): boolean {
  const vis = m.visibility;
  if (!vis) return true;
  return (ctx.isGuest && vis.guests) || (!ctx.isGuest && vis.loggedIn);
}

/**
 * In production mode, drop modules whose visibility excludes the current visitor.
 * In preview mode, keep every module and flag the ones that would be hidden so
 * the renderer can show a "hidden in production" badge — this lets admins see
 * their full draft structure while editing.
 */
function selectForRender(
  mods: LayoutModule[],
  ctx: LoadCtx,
  preview: boolean,
): (LayoutModule & { hiddenInProd?: boolean })[] {
  if (!preview) return mods.filter((m) => isVisibleFor(m, ctx));
  return mods.map((m) => ({ ...m, hiddenInProd: !isVisibleFor(m, ctx) }));
}

async function hydrate(
  db: DrizzleClient,
  mods: (LayoutModule & { hiddenInProd?: boolean })[],
  ctx: LoadCtx,
  cache: Map<string, Promise<unknown>>,
  limit: Limiter,
): Promise<LoadedModule[]> {
  return Promise.all(
    mods.map(async (m) => {
      const base = { instanceId: m.instanceId, type: m.type, hiddenInProd: m.hiddenInProd };
      const def = MODULE_REGISTRY[m.type];
      if (!def) {
        return { ...base, config: m.config, data: null };
      }

      const parsed = def.configSchema.safeParse(m.config);
      if (!parsed.success) {
        return { ...base, config: m.config, data: null };
      }

      // Dedup by (type, normalized config). Two modules with identical
      // post-zod config produce identical data, so loadData runs once.
      const key = `${m.type}:${JSON.stringify(parsed.data)}`;
      let p = cache.get(key);
      if (!p) {
        // limit() bounds concurrent in-flight loadData calls. Without this,
        // 10+ modules fan out simultaneously and the sum of their sync
        // post-query work (Drizzle row mapping, zod parse, toDealCardDeal)
        // bursts CPU enough to trip the Worker per-request budget on cold
        // cache misses → error 1102.
        p = limit(() => def.loadData(db, parsed.data, ctx)).catch((err) => {
          console.error(`[page-organizer] loadData failed for ${m.type}`, err);
          captureCaught(err, {
            scope: 'server.page-layout.loader',
            severity: 'warning',
            extra: { moduleType: m.type },
          });
          return null;
        });
        cache.set(key, p);
      }
      const data = await p;
      return { ...base, config: parsed.data, data };
    }),
  );
}
