Developer Demo

Feature Experimentation

Optimizely Feature Experimentation runs alongside SaaS CMS on the same platform. Flag decisions are evaluated in edge middleware - the URL is rewritten with the variation key before the page renders, enabling full ISR caching per variation. Bucketing events fire client-side after the user sees the variation, connecting A/B experiments directly to editor-created content variations.

✓ Server-side decisionsFeature flags · ExperimentsAudience targetingVariable deliveryCMS Variations integration

How It All Fits Together #

Four steps happen on every page request. FX bucketing runs in edge middleware and the ISR cache serves warm responses at CDN speed. A small client component fires the bucketing event after the variation renders.

🌐

Browser

Sends optimizelyEndUserId cookie

Middleware (edge)

Runs decideAll() with DISABLE_DECISION_EVENT - rewrites URL with __v_ variation segments

🗄️

Graph API

Filters by variation.value · serves matched CMS variant

📊

Browser SDK

FxBucketingEvent mounts client-side - calls decide(flagKey, []) to record user in experiment results

1

Middleware evaluates flags + rewrites URL

Edge middleware sets the stable optimizelyEndUserId cookie, fetches the FX datafile (60s edge cache), and runs decideAll([DISABLE_DECISION_EVENT]). Active variation keys are appended to the URL path as __v_ segments via a transparent NextResponse.rewrite().

2

Page decodes variation from URL params

The catch-all route receives slug = ["savings", "__v_homepage--business"]. extractVariations(slug) strips the __v_ segments back out and returns the clean path + active variation keys. No cookies() call means ISR is not blocked - the page renders statically.

3

Graph returns the right CMS variant

Active variation keys are passed to getContentByPath as a { include: 'SOME', value: [...keys] } filter. Graph serves the CMS content variant whose key matches - or the original if none exists. SDK docs ↗

4

Bucketing event fires in the browser

When Graph serves a CMS variation, the page looks up flagKey from the URL segment already decoded by extractVariations() - no extra SDK call. <FxBucketingEvent flagKey={...} /> mounts client-side and calls decide(flagKey, []) for that flag only - only the experiment the user saw records a participant.

Your Session #

A stable optimizelyEndUserId cookie is set by Next.js middleware on first visit. Flag decisions below are bucketed to this ID - reload and you always land in the same variation.

User IDanonymoumousstable per browser
Devicedesktopderived from User-Agent header (no cookie)
Bucketing ID-sign in via Audience Switcher to set
Active Variationssemantickeys passed to Graph

All Flag Decisions

Evaluated via userContext.decideAll(). Changes in the FX dashboard take effect within 60 seconds (datafile cache TTL). Each decision includes a variables map of typed values (strings, booleans, numbers, JSON) that let you control copy or configuration per variation without code changes.

banneroff
variation: off
{
  "title": "test",
  "description": "",
  "image": "",
  "imageText": "",
  "linkText": "",
  "cache": true,
  "banner_position": 0
}
search_algorithmenabled
variation: semantic
{
  "search_algorithm": {
    "_ranking": "SEMANTIC",
    "_semanticWeight": 0.9
  }
}
homepageoff
variation: off
{
  "cms_flag": true
}
hero_layoutoff
variation: off
{
  "layout": "split"
}
nav_search_styleoff
variation: off
{
  "style": "icon"
}
product_card_orderoff
variation: off
{
  "order": "checking_first"
}
trust_section_styleoff
variation: off
{
  "style": "stats"
}
footer_ctaoff
variation: off
{
  "style": "app_download"
}
hero_dual_ctaoff
variation: off
{
  "secondaryLabel": "",
  "secondaryUrl": ""
}
hero_social_proofoff
variation: off
mobile_navoff
variation: off
rates_baroff
variation: off
{
  "apy": "4.75%",
  "product": "savings"
}
sticky_offer_baroff
variation: off
{
  "message": "Limited offer: 0% APR for 12 months on balance transfers.",
  "linkText": "Learn more",
  "linkUrl": "/personal/credit-cards",
  "expiryLabel": "Ends Friday"
}
hero_copyoff
variation: off
{
  "headline": "",
  "subheadline": ""
}

Live Demo: hero_copy #

This card is driven entirely by the hero_copy flag and its headline + subheadline variables.

Variations:off·control(product-led copy)·challenger(benefit-led copy + brand gradient)

hero_copy

Flag is off - enable it in the FX dashboard to see the live hero copy variation.

Bucketing ID Override #

By default the FX SDK buckets users by their userId. Setting the reserved $opt_bucketing_id attribute overrides which ID drives bucketing - while keeping the original userId for analytics. This means all users sharing the same bucketing ID land in the same variation, regardless of their individual user IDs.

B2B / account-level experiments

Every seat on the same company account sees the same variation. Avoids the awkward situation where user A sees Variation 1 and user B on the same account sees Variation 2 in the same meeting.

Cross-device consistency

A logged-in user ID works as the bucketing ID - the same variation follows the user across their phone, tablet, and desktop, regardless of which device generated their anonymous visitor ID.

Gradual rollouts to accounts

Roll out a new feature to 10% of accounts (not 10% of users). Avoids fragmenting the experience inside the same company during a staged rollout.

src/components/MyPage.tsx
import { getOptimizelyUser } from "@/lib/optimizely/user";
import { getVisitorContext } from "@/lib/optimizely/visitor";

export default async function MyPage() {
  const user = await getOptimizelyUser();
  const { bucketingId } = await getVisitorContext();

  // Normal decision - bucketed by the visitor's stable userId
  const decision = user.decide("hero_copy");

  // Account-level decision - when logged in, bucket by account ID instead.
  // All seats on the same account see the same variation.
  // userId is still used for analytics; only bucketing is overridden.
  const accountDecision = bucketingId
    ? user.decide("hero_copy", { bucketingId })
    : null;
}

Live comparison - hero_copy

Sign in via the Audience Switcher (bottom-right) to see the account-level decision alongside your normal decision.

Normal - bucketed by userId

hero_copy

Flag is off - enable it in the FX dashboard to see the live hero copy variation.

variation: off

Account - bucketed by not set

Sign in via the Audience Switcher to activate

CMS Variations - Connecting FX to Content #

FX variation keys and CMS content variations share a single string contract. When Graph receives an active variation key, it looks for a CMS content variant with the same name and returns it. Editors create the variant in Visual Builder; the SDK wires it at request time - no code change required after initial setup.

Request flow - this demo's homepage personalization

FX Dashboard

flag: homepage

variation: business

FX SDK

user bucketed into

variationKey: business

Graph query

variation: {

include: 'SOME',

value: ['business']

}

CMS serves

content variant

named "business"

(or original if no match)

CMS variations must be created in Visual Builder - the Management API cannot create them. The variation field exists in Graph's schema but is silently ignored by the Management API on write. However, once created in Visual Builder each variation becomes a new draft version - you can discover the version number via GET /content/{key}/versions and PATCH it with the correct composition + status: "published".

Your Variation Keys → Graph Filter

This is what the live page route is passing to getContentByPath for your session right now.

variationOption = {
  variation: {
    include: "SOME",
    value: ["semantic"],
    includeOriginal: true,
  },
}

Setting Up CMS Variations - Step by Step #

Once the integration code is in place (see below), editors can set up any number of content experiments without touching code. The variation key string is the only contract between Feature Experimentation and the CMS.

1

Create a flag and targeting rules in Feature Experimentation

Create manually in the FX dashboard or via the REST API. This demo uses flag homepage with two targeted-delivery rules, each serving 100% of its audience: business (audience: persona == "business") and personal (audience: persona == "personal"). Visitors matching neither audience fall through to the default off variation and see the base homepage. The persona attribute is set from the demo_persona cookie by the Audience Switcher.
2

Create variations in Visual Builder, then set their compositions

Open the homepage in the CMS Visual Builder. In the right-hand panel, click Add variation. Create two variations named exactly personal and business (case-sensitive - must match the FX variation keys exactly). Each becomes a new draft version in the CMS. Edit each variation's composition in Visual Builder and publish it - or discover the version numbers via GET /content/{key}/versions and PATCH them programmatically as described above.
3

Validate with the Audience Switcher

Use the floating pill in the bottom-right corner to preview each variation instantly - no waiting for FX bucketing. Select Business Customer; the homepage should show “Banking built for business” once the CMS variation exists.
4

Enable the flag and start the experiment

Turn on the flag and launch the experiment in the FX dashboard. FX handles traffic allocation and bucketing; Graph handles content delivery. Analytics, winner declaration, and rollout happen entirely in the FX dashboard - no code changes needed.
5

Validate with this page

Reload this page - your current variation key will appear in the Flag Decisions grid above and in the Your Variation Keys → Graph Filter block. If the key matches a CMS variation, that page now serves the personalised variant.

Audience Targeting #

FX attributes (like device) are matched against audiences defined in the FX dashboard. Targeting happens on the server - the browser never knows which audience it was matched to.

Built-in Attributes

device

read from User-Agent header server-side (no cookie - GDPR safe)

desktop
logged_in

from demo_logged_in cookie (Audience Switcher)

false

Adding Custom Attributes

Spread the base visitor attributes from getVisitorContext() and add your extras as the third argument to getDecision(). Then define matching audience conditions in the FX dashboard.

// Base attributes (device, logged_in, persona) are automatic.
// For extra attributes, spread after getVisitorContext():
const { userId, attributes } = await getVisitorContext();
await getDecision("my_flag", userId, {
  ...attributes,
  plan: "premium",  // from your database
  country: "GB",    // from geo header
});

Choosing an Approach #

There are three ways to integrate Feature Experimentation with a Next.js CMS page route. The right choice depends on whether CDN caching matters for your traffic profile.

A - This demoISR

Edge Middleware + URL rewrite

Middleware evaluates FX and rewrites the URL with __v_flagKey--variationKey. The page reads variation from params (no cookies()) so ISR works. Each variation URL is a separate CDN cache entry. A small client component fires the bucketing event after render.

+~10-50ms TTFB on cached requests
+CDN serves warm requests - server load scales down
-Bucketing event fires after page load (CSR)
-More moving parts (middleware + browser SDK)
BSSR

force-dynamic SSR

getOptimizelyUser() reads cookies in the page render. decideAll() runs server-side, variation filter goes to Graph, and the bucketing event fires server-side too. Simpler code - no middleware changes or client component needed.

+Simplest - everything in one server component
+Bucketing event fires synchronously before HTML
-~300-1000ms TTFB on every request (no caching)
-cookies() blocks ISR - force-dynamic is required
C - Not recommendedCSR

Client-side only

Page renders base content from the server. After hydration, the browser SDK evaluates flags and fetches the variation. Content swaps in after the initial render - the user sees a flash of the base content before the variation appears.

+No server changes required
-Visible flicker - base content shows for ~100-500ms
-Variation data in the waterfall - slower effective LCP
-CMS content fetched twice (base SSR + variation CSR)

Integration Code (Approach A) #

1. Middleware - FX bucketing + URL variation rewrite

Runs at the edge on every request. Sets the stable visitor ID, fetches the FX datafile (60s edge cache), evaluates all flags, and rewrites the URL with variation path segments. The user's browser always sees the original URL - the rewrite is transparent.

src/middleware.ts
// src/middleware.ts
import { createInstance, createStaticProjectConfigManager, OptimizelyDecideOption }
  from "@optimizely/optimizely-sdk/universal";

export const VARIATION_MARKER = "__v_";

export async function middleware(request: NextRequest) {
  const response = NextResponse.next();

  // Set a stable visitor ID (not httpOnly - browser SDK reads it for bucketing events).
  const userId = request.cookies.get("optimizelyEndUserId")?.value ?? crypto.randomUUID();
  if (!request.cookies.get("optimizelyEndUserId")) {
    response.cookies.set("optimizelyEndUserId", userId, {
      maxAge: 60 * 60 * 24 * 365, sameSite: "lax", path: "/",
    });
  }

  // Skip API routes and already-rewritten paths.
  if (request.nextUrl.pathname.startsWith("/api/")) return response;
  if (request.nextUrl.pathname.includes(VARIATION_MARKER)) return response;

  // Fetch datafile with 60s edge cache (Vercel CDN caches this automatically).
  const datafile = await fetch(DATAFILE_URL, { next: { revalidate: 60 } }).then((r) => r.text());

  const client = createInstance({
    projectConfigManager: createStaticProjectConfigManager({ datafile }),
    requestHandler: noOpRequestHandler, // no HTTP needed - static config only
  });

  const ctx = client.createUserContext(userId, { device, persona, ... });
  const decisions = ctx.decideAll([OptimizelyDecideOption.DISABLE_DECISION_EVENT]);

  // Collect active decisions sorted by variationKey for a stable ISR cache key.
  const activeDecisions = Object.values(decisions)
    .filter((d) => d.enabled && d.variationKey && d.variationKey !== "off")
    .sort((a, b) => (a.variationKey as string).localeCompare(b.variationKey as string));

  if (activeDecisions.length === 0) return response;

  // Rewrite URL: /savings → /savings/__v_homepage--business
  // Each segment encodes flagKey--variationKey so the page knows which flag fired.
  // The user sees /savings in the browser - the rewrite is transparent.
  // Next.js catches each rewritten path as a separate ISR cache entry.
  const url = request.nextUrl.clone();
  const variationSuffix = activeDecisions
    .map((d) => `__v_${d.flagKey}--${d.variationKey}`)
    .join("/");
  url.pathname = url.pathname.replace(/\/$/, "") + `/${variationSuffix}`;
  return NextResponse.rewrite(url, { headers: response.headers });
}

2. FX user helper - one user context per request

The optimizelyEndUserId cookie has an expiration date, so the same visitor always lands in the same bucket - across page loads and return visits. React cache() deduplicates within a single render tree: cookies are read once, the SDK context is created once, and concurrent visitors each get their own independent context - nothing shared across users. decideAll() evaluates every flag for this visitor in one call with DISABLE_DECISION_EVENT - no impressions fired yet.

src/lib/optimizely/user.ts
// src/lib/optimizely/user.ts
import { cache } from "react";
import { OptimizelyDecideOption } from "@optimizely/optimizely-sdk";
import { getOptimizelyClient } from "./experimentation";
import { getVisitorContext } from "./visitor";

// cache() scopes to a single HTTP request's render tree.
// Every component that calls getOptimizelyUser() shares one user context.
// 1,000 concurrent visitors → 1,000 independent contexts, nothing shared.
export const getOptimizelyUser = cache(async () => {
  const [client, { userId, attributes, bucketingId }] = await Promise.all([
    getOptimizelyClient(), // one SDK instance per request (React cache() + 60s Next.js fetch cache)
    getVisitorContext(),    // reads: optimizelyEndUserId cookie, User-Agent → device, demo cookies
  ]);

  if (!client) return noOpUser; // FX unreachable → all decide() calls return { enabled: false }

  const ctx = client.createUserContext(userId, attributes);
  return {
    userId,
    bucketingId,
    //
    // decide(flagKey)                → DISABLE_DECISION_EVENT: flag evaluated, no impression fired
    // decide(flagKey, [])            → no options → impression fires, registers user in FX results
    // decide(flagKey, {bucketingId}) → override bucketing ID for account-level experiments
    // decide(flagKey, {attributes})  → merge extra attributes for this call only
    //
    decide(flagKey: string, opts?: OptimizelyDecideOption[] | DecideOpts): FxDecision { /* ... */ },
    decideAll(): Record<string, FxDecision> {
      // Always DISABLE_DECISION_EVENT - evaluate all flags without recording anything.
      return ctx.decideAll([OptimizelyDecideOption.DISABLE_DECISION_EVENT]);
    },
  };
});

Why a wrapper instead of calling the SDK directly?

Optimizely ships two SDKs. @optimizely/react-sdk is a separate package that provides an OptimizelyProvider context wrapper and a useDecision() hook - designed for client-side React apps where a single SDK instance lives in the browser and flags are evaluated in the client. That SDK also has an isServerSide prop on OptimizelyProvider that disables background polling and event batching during SSR so the SDK doesn't try to run browser-only logic - it was removed in react-sdk v4 in favour of configuring a static per-request instance directly.

This project uses @optimizely/optimizely-sdk directly instead. Next.js App Router server components can't use React context or hooks, so OptimizelyProvider and useDecision don't apply. The wrapper getOptimizelyUser() fills the same role: it resolves the stable userId and visitor attributes from cookies and headers, creates one user context per request via React cache(), and defaults to DISABLE_DECISION_EVENT so routing passes don't pollute the FX results page - you fire the impression with user.decide(flagKey, []) only when the variation is actually rendered.

Two-layer architecture

getOptimizelyClient() - SDK instance built per request from a datafile cached 60s by Next.js fetch. One datafile download per minute regardless of traffic.

getOptimizelyUser() - user context, scoped to the current HTTP request via React cache(). Isolated per visitor, never shared.

When to use what

user.decide(flagKey) - preferred. Full visitor context already resolved. Supports { bucketingId } and { attributes } overrides.

getDecision(flagKey, userId, attrs) - low-level, for standalone server calls where you pass userId and attributes yourself (always suppresses impressions). The edge middleware uses the SDK's /universal entry directly instead.

Why the wrapper exists
// ❌ Using the FX SDK directly - don't do this in server components
import { createInstance } from "@optimizely/optimizely-sdk";

export default async function MyPage() {
  const res = await fetch(DATAFILE_URL);         // fetched fresh every render
  const client = createInstance({ datafile: await res.text() });
  const ctx = client?.createUserContext(???);    // where does userId come from?
  const decision = ctx?.decide("my_flag");       // no impression control
}

// ✅ Using the wrapper - one function call, everything resolved
import { getOptimizelyUser } from "@/lib/optimizely/user";

export default async function MyPage() {
  const user = await getOptimizelyUser();

  // userId + device + persona attributes come from cookies automatically.
  // SDK instance is memoised per request - datafile not re-fetched.
  // DISABLE_DECISION_EVENT by default - impression not fired yet.
  const decision = user.decide("my_flag");
  if (!decision.enabled) return null;

  // Now the variation will be rendered - fire the impression.
  void user.decide("my_flag", []);

  return <Variant variables={decision.variables} />;
}

3. CMS page route - flags → Graph variation filter

The catch-all route evaluates all flags, collects active variation keys, and passes them to Graph. Every CMS page automatically serves the right content variant.

src/app/[[...slug]]/page.tsx
// src/app/[[...slug]]/page.tsx
// Middleware rewrites: /savings → /savings/__v_homepage--business
//   VARIATION_MARKER = "__v_"   FLAG_VAR_SEP = "--"
// Both flagKey and variationKey are encoded in the URL segment so the page
// knows which flag to fire the bucketing event for - no extra SDK call needed.
import { VARIATION_MARKER, FLAG_VAR_SEP } from "@/middleware";

function extractVariations(slug) {
  const cleanSlug = slug?.filter((s) => !s.startsWith(VARIATION_MARKER));
  const flagVariations = slug
    ?.filter((s) => s.startsWith(VARIATION_MARKER))
    .map((s) => {
      const [flagKey, variationKey] = s.slice(VARIATION_MARKER.length).split(FLAG_VAR_SEP);
      return { flagKey, variationKey };
    }) ?? [];
  return {
    cleanSlug,
    activeVariations: flagVariations.map((fv) => fv.variationKey), // for Graph filter
    flagVariations,                                                  // for bucketing event
  };
}

async function CmsPage({ params }) {
  const { slug } = await params;
  const { cleanSlug, activeVariations, flagVariations } = extractVariations(slug);

  // Pass variation keys to Graph - same filter as always, now sourced from URL not cookies
  const variationOption = activeVariations.length > 0
    ? { variation: { include: "SOME" as const, value: activeVariations, includeOriginal: true } }
    : undefined;

  const client = getClient();
  const items = await client.getContentByPath(`/${cleanSlug.join("/")}/`, variationOption);
  const page = items.find((i) => activeVariations.includes(i._metadata?.variation)) ?? items[0];

  // Look up flagKey by the variationKey Graph actually served
  const servedVariation = page._metadata?.variation ?? null;
  const servedFlagKey = flagVariations.find((fv) => fv.variationKey === servedVariation)?.flagKey ?? null;

  return (
    <>
      <OptimizelyComponent content={page} />
      {servedFlagKey && <FxBucketingEvent flagKey={servedFlagKey} />}
    </>
  );
}

What getContentByPath sends to Graph under the hood:

Theoretical GraphQL (personal variation active)
# Theoretical GraphQL generated by getContentByPath("/en/", variationFilter)
# when the user is bucketed into the "personal" variation:

query {
  _Content(
    where: {
      _metadata: { url: { default: { eq: "/en/" } } }
    }
    variation: {
      include: SOME
      value: ["personal"]
      includeOriginal: true   # always include the base - safe fallback for non-experiment users
    }
    limit: 10
  ) {
    items {
      _metadata {
        key
        version
        variation       # "personal" | null (base version)
        url { default }
      }
      ... on HomePage {
        composition { ... }
      }
    }
  }
}

# Graph returns TWO items:
#   items[0] → base homepage          (variation: null)
#   items[1] → "personal" variation   (variation: "personal")
#
# The code picks items[1] because it matches activeVariations.
# If no matching CMS variation exists, includeOriginal: true ensures
# items[0] (the base) is returned - experiment is safe to deploy before
# editors create any CMS variations.

4. Fire the bucketing event when the variation is rendered

Middleware encoded flagKey--variationKey into the URL. The page's extractVariations(slug) parses it back out, so flagKey is known without any SDK call. When Graph confirms a variation was served, the page passes flagKey to <FxBucketingEvent /> which calls decide(flagKey, []) client-side - one SDK call, for that flag only.

src/components/FxBucketingEvent.tsx
// src/components/FxBucketingEvent.tsx
// flagKey is passed in from the page - it was encoded in the URL by middleware:
//   /savings → /savings/__v_homepage--business
// extractVariations() in page.tsx parses it back out.
// No decideAll() here - the flagKey is already known from the route.

"use client";
export function FxBucketingEvent({ flagKey }: { flagKey: string }) {
  useEffect(() => {
    const userId = getCookie("optimizelyEndUserId");
    if (!userId) return;
    void getOptimizelyBrowserClient().then((client) => {
      if (!client) return;
      const ctx = client.createUserContext(userId, { device, ... });
      ctx?.decide(flagKey, []); // fire bucketing event for this flag only
    });
  }, [flagKey]);
  return null;
}

// src/app/[[...slug]]/page.tsx
// flagKey comes from the URL segment, not from a client-side SDK call.
// flagVariations = [{ flagKey: "homepage", variationKey: "business" }]
const { cleanSlug, activeVariations, flagVariations } = extractVariations(slug);
const servedVariation = page._metadata?.variation ?? null;
const servedFlagKey = flagVariations.find((fv) => fv.variationKey === servedVariation)?.flagKey ?? null;

return (
  <>
    <OptimizelyComponent content={page} />
    {servedFlagKey && <FxBucketingEvent flagKey={servedFlagKey} />}
  </>
);

5. Using a single flag decision in a component

For feature-gating or variable-driven UI outside the CMS page route. The same impression rule applies - call user.decide(flagKey, []) when the variation is actually rendered to fire the impression.

src/components/SubscribeBanner.tsx
import { getOptimizelyUser } from "@/lib/optimizely/user";

export default async function MyPage() {
  const user = await getOptimizelyUser();

  // Evaluate without firing an impression (default: DISABLE_DECISION_EVENT)
  const decision = user.decide("hero_copy");
  if (!decision.enabled) return null;

  // Once you know the variation will be shown, fire the impression:
  void user.decide("hero_copy", []);

  // Variables come back typed - you cast to the type you expect
  const headline = decision.variables.headline as string;
  const subheadline = decision.variables.subheadline as string;
  return <Hero headline={headline} subheadline={subheadline} variation={decision.variationKey} />;
}

Key Things to Know#

  • The variation key is the only contract between FX and the CMS. The string must match exactly (case-sensitive) between the FX variation key and the CMS variation name.
  • includeOriginal: true means users outside the experiment always get the original content. Safe to add the filter before any CMS variations exist.
  • Datafile is cached for 60 seconds via Next.js fetch revalidation. Changes in the FX dashboard propagate within one minute with no server restart.
  • React cache() is scoped to a single HTTP request. Any number of server components can call getOptimizelyUser() - they all share one user context for that request. Concurrent visitors each get their own completely isolated context; nothing is shared across users.
  • DISABLE_DECISION_EVENT suppresses bucketing events during the middleware routing pass. Once the variation is rendered, <FxBucketingEvent servedVariation={...} /> mounts client-side and fires the impression for that flag only.
  • Variations work on any content type - pages, shared blocks, navigation. Wherever Graph accepts a variation filter, the SDK wires in seamlessly.
  • CMS variations must be created in Visual Builder, but can then be updated via the Management API. The REST API silently ignores the variation field on POST - you cannot create a named variation programmatically. But creating one in the UI generates a new draft version that you can PATCH and publish.
Source files1 file
src/lib/optimizely/user.ts
import { cache } from "react";
import { OptimizelyDecideOption } from "@optimizely/optimizely-sdk";
import { getOptimizelyClient } from "./experimentation";
import type { FxDecision, FxAttributes } from "./experimentation";
import { getVisitorContext } from "./visitor";

type DecideOpts =
  | OptimizelyDecideOption[]
  | { options?: OptimizelyDecideOption[]; bucketingId?: string; attributes?: FxAttributes };

function resolveOpts(opts: DecideOpts | undefined): {
  sdkOptions: OptimizelyDecideOption[];
  bucketingId?: string;
  attributes?: FxAttributes;
} {
  if (!opts || Array.isArray(opts)) {
    return { sdkOptions: opts ?? [OptimizelyDecideOption.DISABLE_DECISION_EVENT] };
  }
  return {
    sdkOptions: opts.options ?? [OptimizelyDecideOption.DISABLE_DECISION_EVENT],
    bucketingId: opts.bucketingId,
    attributes: opts.attributes,
  };
}

const noDecision = (flagKey: string): FxDecision => ({
  flagKey, enabled: false, variationKey: null, variables: {}, reasons: [],
});

const noOpUser = {
  userId: "anonymous" as string,
  bucketingId: undefined as string | undefined,
  decide: (flagKey: string, _opts?: DecideOpts): FxDecision => noDecision(flagKey),
  decideAll: (): Record<string, FxDecision> => ({}),
};

export const getOptimizelyUser = cache(async () => {
  const [client, { userId, attributes, bucketingId }] = await Promise.all([
    getOptimizelyClient(),
    getVisitorContext(),
  ]);

  if (!client) return { ...noOpUser, userId, bucketingId };

  const ctx = client.createUserContext(userId, attributes);
  if (!ctx) return { ...noOpUser, userId, bucketingId };

  return {
    userId,
    bucketingId,
    decide(flagKey: string, opts?: DecideOpts): FxDecision {
      const { sdkOptions, bucketingId: bId, attributes: attrOverrides } = resolveOpts(opts);
      const activeCtx = bId || attrOverrides
        ? client.createUserContext(userId, {
            ...attributes,
            ...attrOverrides,
            ...(bId ? { $opt_bucketing_id: bId } : {}),
          }) ?? ctx
        : ctx;
      const d = activeCtx.decide(flagKey, sdkOptions);
      return {
        flagKey,
        enabled: d.enabled,
        variationKey: d.variationKey,
        variables: d.variables as Record<string, unknown>,
        reasons: d.reasons,
      };
    },
    decideAll(): Record<string, FxDecision> {
      const raw = ctx.decideAll([OptimizelyDecideOption.DISABLE_DECISION_EVENT]);
      const out: Record<string, FxDecision> = {};
      for (const [key, d] of Object.entries(raw)) {
        out[key] = {
          flagKey: key,
          enabled: d.enabled,
          variationKey: d.variationKey,
          variables: d.variables as Record<string, unknown>,
          reasons: d.reasons,
        };
      }
      return out;
    },
  };
});