import { unstable_cache } from "next/cache";

/**
 * Tenant-scoped server cache.
 *
 * WHY THIS EXISTS
 * ---------------
 * The root layout is `force-dynamic` and refetches header-footer/home,
 * programs and activities on every page view - ~96 KB and ~1.3s of backend
 * latency to draw nav chrome that changes maybe weekly.
 *
 * Next's own fetch cache cannot help: every service calls the backend through
 * axios (`apiClient`), and Next only dedupes/caches native `fetch()`. So the
 * cache has to be explicit, which is what this module provides.
 *
 * THE RULE YOU MUST NOT BREAK
 * ---------------------------
 * This app is multi-tenant. Content is keyed on domain + franchiseeId + lang,
 * and normally those are injected *invisibly* by the apiClient interceptor,
 * which reads them from request headers.
 *
 * `headers()` is NOT available inside an `unstable_cache` callback, and the
 * interceptor swallows that failure in an empty catch (see app/api/client.ts).
 * A call wrapped naively therefore does not crash - it silently drops tenant
 * and language context and returns GLOBAL ENGLISH content for every
 * franchisee.
 *
 * So: resolve the context in request scope, pass it here explicitly, and hand
 * it to the service as explicit arguments. Never let a cached fetcher depend on
 * the interceptor. Every cache key below carries all three values.
 */

export interface TenantContext {
  domain: string;
  franchiseeId: string | null;
  /** "default" when the visitor has not picked a language. */
  lang: string;
}

/**
 * TTLs in seconds.
 *
 * Chosen by "how long would a franchisee tolerate their own edit not showing
 * up", not by how often the data changes - that is the constraint that got the
 * previous caching attempt reverted (see programDetailService).
 *
 * These are the backstop, not the primary freshness mechanism: /api/revalidate
 * purges on publish, so edits normally appear immediately and the TTL only
 * matters when that webhook does not arrive.
 *
 * Note `unstable_cache` is stale-while-revalidate: past the TTL the next
 * request still gets the stale value and only triggers a background refresh, so
 * real staleness is TTL + one request. On a quiet tenant that can exceed the
 * number below in wall-clock terms. That is expected, not a bug.
 */
export const TTL = {
  /** Nav chrome: header/footer, programs and activities menus. */
  CHROME: 300,
  /** Editable page bodies. */
  CONTENT: 300,
  /** Policies - change a few times a year. */
  POLICY: 3600,
} as const;

/**
 * Never cache any of these, for two different reasons:
 *
 *   - classwise classes/locations drive what a parent can actually register
 *     for. Seats fill, and no webhook tells us when. A cached "available" that
 *     is already full is a broken checkout, not a stale page.
 *   - coupon/payment/registration/invoice data is per-user. Caching it risks
 *     serving one visitor's state to another - the tenant-leak bug, one level
 *     down.
 *
 * Exported as a list so nobody wraps one of them later by pattern match.
 */
export const NEVER_CACHE = [
  "classwiseClassesService",
  "classwiseLocationsService",
  "couponService",
  "paymentService",
  "paymentPageService",
  "paymentStatusService",
  "saveCardService",
  "registerDetailsService",
  "registrationInvoiceService",
  "checkoutPageService",
  "submissionPageService",
  "formsService",
  "thankYouPageService",
] as const;

/**
 * Purge tags.
 *
 * Tags are composite on purpose. A bare `type:pages` tag shared across tenants
 * would mean one franchisee editing their banner invalidates page content for
 * every franchisee on the platform - a refetch stampede, and exactly what
 * per-tenant keying is meant to prevent. So the type is always scoped under the
 * tenant it belongs to.
 *
 * `content:all` is the one deliberate exception: global/corporate content is
 * inherited by tenants that have no row of their own, so their cached copy may
 * BE that global content. When it changes, everything really must go.
 */
export const ALL_CONTENT_TAG = "content:all";

/** Tag for one tenant; global content uses the "global" sentinel. */
export function franchiseeTag(franchiseeId: string | null): string {
  return `franchisee:${franchiseeId ?? "global"}`;
}

/** Tag for one content type within one tenant. */
export function franchiseeTypeTag(
  franchiseeId: string | null,
  type: string,
): string {
  return `${franchiseeTag(franchiseeId)}:${type}`;
}

/**
 * Thrown by a cached fetcher when the backend answered but the answer is a
 * fallback rather than real data.
 *
 * This matters because the services never throw on a backend problem - they
 * return static/empty fallbacks (see apiErrorHandler: "Always return fallback
 * data even on error"). To the cache a fallback is indistinguishable from a
 * success, so without this a 30-second backend restart would freeze an empty
 * programs list in for the full TTL, leaving every tenant with an empty nav
 * menu long after the backend recovered.
 *
 * A rejected promise is never stored by `unstable_cache`, so throwing here
 * keeps the degraded response out of the cache; the caller serves it uncached
 * and the very next request retries.
 */
export class DegradedResponseError extends Error {
  constructor(what: string) {
    super(`Degraded backend response: ${what}`);
    this.name = "DegradedResponseError";
  }
}

/**
 * Wrap a fetcher in a tenant-keyed cache.
 *
 * The returned function takes a resolved TenantContext, which forces the caller
 * to read request headers *before* entering the cache scope.
 *
 * `unstable_cache` is re-created per call rather than hoisted to module scope:
 * it keys on `keyParts`, not on function identity, so this is the supported way
 * to build a key out of per-request values.
 */
export function cachedByTenant<T>(
  name: string,
  fetcher: (ctx: TenantContext) => Promise<T>,
  options: { revalidate: number; types?: string[] },
): (ctx: TenantContext) => Promise<T> {
  // OFF unless explicitly switched on with ENABLE_TENANT_CACHE=1.
  //
  // Opt-in rather than opt-out so that merging this code anywhere - production
  // included - changes nothing by itself. An opt-out flag would mean caching
  // silently activating on any deployment where someone forgot to set the
  // variable, which is the one way this lands somewhere it was not wanted.
  //
  // Deliberately a full bypass rather than `revalidate: 0`, whose meaning in
  // `unstable_cache` is not obvious from reading it. This way "off" is off, and
  // nothing about Next's cache semantics has to be reasoned about to be sure.
  //
  // /api/revalidate stays harmless while off: a purge finds nothing tagged and
  // returns ok.
  if (process.env.ENABLE_TENANT_CACHE !== "1") {
    return (ctx: TenantContext) => fetcher(ctx);
  }

  return (ctx: TenantContext) =>
    unstable_cache(
      () => fetcher(ctx),
      // Tenant key. Dropping any of these three cross-contaminates franchisees.
      [name, ctx.domain, ctx.franchiseeId ?? "global", ctx.lang],
      {
        revalidate: options.revalidate,
        tags: [
          ALL_CONTENT_TAG,
          franchiseeTag(ctx.franchiseeId),
          ...(options.types ?? []).map((type) =>
            franchiseeTypeTag(ctx.franchiseeId, type),
          ),
        ],
      },
    )();
}
