/** Interactive playground for the PhysiX OpenEnv interface.
 *
 *  Lets the user drive an episode by hand: start a session (= one
 *  `reset`), then submit one or more actions (= `step`s) directly,
 *  no LLM in the loop. Shows the JSON request body, JSON response,
 *  and a copy-pasteable curl command for every call so judges can
 *  see exactly the wire format.
 *
 *  Why this talks to `/interactive/sessions/*` rather than the bare
 *  `/reset` and `/step`:
 *
 *    The official OpenEnv HTTP server (`openenv.core.env_server`)
 *    constructs a fresh ``Environment`` instance on every call to
 *    `/reset` and `/step`, so the bare endpoints are intentionally
 *    stateless and cannot carry an episode across requests. PhysiX
 *    layers a per-browser session router on top of that for stateful
 *    flows; this pane uses it so visitors actually get to step
 *    through an episode. The bare `/reset`, `/schema`, and `/metadata`
 *    endpoints are still surfaced lower in the page as a read-only
 *    reference for the underlying contract.
 */

import { useCallback, useEffect, useMemo, useState } from "react";

import { TrajectoryCanvas } from "@/components/TrajectoryCanvas";
import { cn } from "@/lib/cn";
import {
  type DirectStepResponse,
  InteractiveApiError,
  InteractiveClient,
  type InteractiveStartResponse,
  type OpenEnvMetadata,
  type OpenEnvResetResponse,
  type OpenEnvSchemaResponse,
  type SessionSummary,
  type SystemDescriptor,
} from "@/lib/interactiveClient";
import { pickPrimaryVariable } from "@/lib/trajectory";
import type {
  PhysiXAction,
  RewardBreakdown,
  TrajectorySample,
} from "@/types/physix";

const DEFAULT_EQUATION = "d2y/dt2 = -9.81";
const DEFAULT_PARAMS_JSON = "{}";
const DEFAULT_RATIONALE = "Free-fall under gravity.";

// Same shape used by RunWithLlmPane / ComparePane so the OpenEnv tab
// renders the identical reward layout when no step has run yet.
const ZERO_REWARD: RewardBreakdown = {
  match: 0,
  progress: 0,
  simplicity: 0,
  format: 0,
  total: 0,
  shape: 0,
  freq: 0,
  amplitude: 0,
};

function getApiBaseUrl(): string {
  const fromEnv = (import.meta as ImportMeta & { env?: Record<string, string> })
    .env?.VITE_PHYSIX_API_URL;
  return (fromEnv ?? "http://localhost:8000").replace(/\/+$/, "");
}

interface CallRecord<T> {
  status: "idle" | "running" | "ok" | "error";
  result: T | null;
  error: string | null;
  /** Wall-clock latency in ms, recorded client-side. */
  latencyMs: number | null;
  /** The exact request body sent (or null for GET). */
  requestBody: unknown;
  /** Endpoint label shown in the curl block. */
  url: string | null;
  method: "GET" | "POST" | "DELETE";
}

const idleCall = <T,>(
  method: CallRecord<T>["method"] = "GET",
): CallRecord<T> => ({
  status: "idle",
  result: null,
  error: null,
  latencyMs: null,
  requestBody: null,
  url: null,
  method,
});

export function OpenEnvExplorerPane(): JSX.Element {
  const apiBaseUrl = useMemo(() => getApiBaseUrl(), []);
  const client = useMemo(
    () => new InteractiveClient({ baseUrl: apiBaseUrl }),
    [apiBaseUrl],
  );

  // --- Catalogue of physical systems (used to populate the reset selector) ---
  const [systems, setSystems] = useState<SystemDescriptor[] | null>(null);
  const [systemsError, setSystemsError] = useState<string | null>(null);

  useEffect(() => {
    let cancelled = false;
    void (async () => {
      try {
        const list = await client.listSystems();
        if (!cancelled) setSystems(list);
      } catch (err) {
        if (!cancelled) setSystemsError(formatErr(err));
      }
    })();
    return () => {
      cancelled = true;
    };
  }, [client]);

  // --- Session state -----------------------------------------------------
  // Session id of the currently active episode. null = no session yet.
  const [sessionId, setSessionId] = useState<string | null>(null);
  const [summary, setSummary] = useState<SessionSummary | null>(null);

  // --- Form state -------------------------------------------------------
  const [systemId, setSystemId] = useState<string>("free_fall");
  const [seed, setSeed] = useState<string>("42");
  const [maxTurns, setMaxTurns] = useState<number>(8);
  const [equation, setEquation] = useState<string>(DEFAULT_EQUATION);
  const [paramsJson, setParamsJson] = useState<string>(DEFAULT_PARAMS_JSON);
  const [paramsError, setParamsError] = useState<string | null>(null);
  const [rationale, setRationale] = useState<string>(DEFAULT_RATIONALE);

  // --- Per-endpoint call records ----------------------------------------
  const [metadataCall, setMetadataCall] = useState<CallRecord<OpenEnvMetadata>>(
    idleCall<OpenEnvMetadata>("GET"),
  );
  const [schemaCall, setSchemaCall] = useState<CallRecord<OpenEnvSchemaResponse>>(
    idleCall<OpenEnvSchemaResponse>("GET"),
  );
  const [statelessResetCall, setStatelessResetCall] = useState<
    CallRecord<OpenEnvResetResponse>
  >(idleCall<OpenEnvResetResponse>("POST"));
  const [resetCall, setResetCall] = useState<CallRecord<InteractiveStartResponse>>(
    idleCall<InteractiveStartResponse>("POST"),
  );
  const [stepCall, setStepCall] = useState<CallRecord<DirectStepResponse>>(
    idleCall<DirectStepResponse>("POST"),
  );

  // Auto-fetch metadata + schema once on mount.
  useEffect(() => {
    void runMetadata();
    void runSchema();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // Refresh the session summary whenever the session id changes (and
  // after every successful step below).
  const refreshSummary = useCallback(async (): Promise<void> => {
    if (!sessionId) return;
    try {
      const s = await client.getSummary(sessionId);
      setSummary(s);
    } catch {
      // Session may have been GC'd; clear it so the user gets a fresh
      // start on the next reset.
      setSummary(null);
    }
  }, [client, sessionId]);

  useEffect(() => {
    void refreshSummary();
  }, [refreshSummary]);

  const runMetadata = useCallback(async (): Promise<void> => {
    setMetadataCall({
      ...idleCall<OpenEnvMetadata>("GET"),
      status: "running",
      url: `${apiBaseUrl}/metadata`,
    });
    const t0 = performance.now();
    try {
      const result = await client.openEnvMetadata();
      setMetadataCall({
        status: "ok",
        result,
        error: null,
        latencyMs: performance.now() - t0,
        requestBody: null,
        method: "GET",
        url: `${apiBaseUrl}/metadata`,
      });
    } catch (err) {
      setMetadataCall({
        status: "error",
        result: null,
        error: formatErr(err),
        latencyMs: performance.now() - t0,
        requestBody: null,
        method: "GET",
        url: `${apiBaseUrl}/metadata`,
      });
    }
  }, [apiBaseUrl, client]);

  const runSchema = useCallback(async (): Promise<void> => {
    setSchemaCall({
      ...idleCall<OpenEnvSchemaResponse>("GET"),
      status: "running",
      url: `${apiBaseUrl}/schema`,
    });
    const t0 = performance.now();
    try {
      const result = await client.openEnvSchema();
      setSchemaCall({
        status: "ok",
        result,
        error: null,
        latencyMs: performance.now() - t0,
        requestBody: null,
        method: "GET",
        url: `${apiBaseUrl}/schema`,
      });
    } catch (err) {
      setSchemaCall({
        status: "error",
        result: null,
        error: formatErr(err),
        latencyMs: performance.now() - t0,
        requestBody: null,
        method: "GET",
        url: `${apiBaseUrl}/schema`,
      });
    }
  }, [apiBaseUrl, client]);

  const runStatelessReset = useCallback(async (): Promise<void> => {
    const seedNum = parseSeed(seed);
    const body: Record<string, unknown> = {};
    if (seedNum !== null) body.seed = seedNum;
    if (systemId) body.system_id = systemId;
    setStatelessResetCall({
      status: "running",
      result: null,
      error: null,
      latencyMs: null,
      requestBody: body,
      method: "POST",
      url: `${apiBaseUrl}/reset`,
    });
    const t0 = performance.now();
    try {
      const result = await client.openEnvReset({
        seed: seedNum,
        system_id: systemId || null,
      });
      setStatelessResetCall({
        status: "ok",
        result,
        error: null,
        latencyMs: performance.now() - t0,
        requestBody: body,
        method: "POST",
        url: `${apiBaseUrl}/reset`,
      });
    } catch (err) {
      setStatelessResetCall({
        status: "error",
        result: null,
        error: formatErr(err),
        latencyMs: performance.now() - t0,
        requestBody: body,
        method: "POST",
        url: `${apiBaseUrl}/reset`,
      });
    }
  }, [apiBaseUrl, client, seed, systemId]);

  const runReset = useCallback(async (): Promise<void> => {
    const seedNum = parseSeed(seed);
    const body: Record<string, unknown> = { max_turns: maxTurns };
    if (seedNum !== null) body.seed = seedNum;
    if (systemId) body.system_id = systemId;
    setResetCall({
      status: "running",
      result: null,
      error: null,
      latencyMs: null,
      requestBody: body,
      method: "POST",
      url: `${apiBaseUrl}/interactive/sessions`,
    });
    const t0 = performance.now();
    try {
      // End the previous session if any, so we don't leak server memory.
      if (sessionId) {
        await client.endSession(sessionId).catch(() => {
          /* best-effort */
        });
      }
      const result = await client.startSession({
        system_id: systemId || undefined,
        seed: seedNum ?? undefined,
        max_turns: maxTurns,
      });
      setResetCall({
        status: "ok",
        result,
        error: null,
        latencyMs: performance.now() - t0,
        requestBody: body,
        method: "POST",
        url: `${apiBaseUrl}/interactive/sessions`,
      });
      setSessionId(result.session_id);
      // Reset the previous step result so the canvas reflects the
      // fresh reset and the user can see only the observed trajectory.
      setStepCall(idleCall<DirectStepResponse>("POST"));
      setSummary({
        session_id: result.session_id,
        system_id: result.system.system_id,
        turn: 0,
        max_turns: result.max_turns,
        converged: false,
        done: false,
      });
    } catch (err) {
      setResetCall({
        status: "error",
        result: null,
        error: formatErr(err),
        latencyMs: performance.now() - t0,
        requestBody: body,
        method: "POST",
        url: `${apiBaseUrl}/interactive/sessions`,
      });
    }
  }, [apiBaseUrl, client, maxTurns, seed, sessionId, systemId]);

  const runStep = useCallback(async (): Promise<void> => {
    const parsedParams: Record<string, number> = {};
    if (paramsJson.trim()) {
      try {
        const parsed = JSON.parse(paramsJson) as unknown;
        if (
          parsed === null ||
          typeof parsed !== "object" ||
          Array.isArray(parsed)
        ) {
          setParamsError("params must be a JSON object, e.g. {\"k\": 4.0}.");
          return;
        }
        for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
          if (typeof v !== "number" || !Number.isFinite(v)) {
            setParamsError(`params.${k} must be a finite number.`);
            return;
          }
          parsedParams[k] = v;
        }
      } catch (err) {
        setParamsError(`params is not valid JSON: ${(err as Error).message}`);
        return;
      }
    }
    setParamsError(null);

    const action: PhysiXAction = {
      equation: equation.trim(),
      params: parsedParams,
      rationale: rationale.trim(),
    };
    const body = { action };

    // Lazy-reset: starting a session before the first step is the same
    // contract as `gym.Env.reset()` before the first `step()`.
    let activeSessionId = sessionId;
    if (!activeSessionId) {
      try {
        const seedNum = parseSeed(seed);
        const start = await client.startSession({
          system_id: systemId || undefined,
          seed: seedNum ?? undefined,
          max_turns: maxTurns,
        });
        activeSessionId = start.session_id;
        setSessionId(activeSessionId);
        setResetCall({
          status: "ok",
          result: start,
          error: null,
          latencyMs: 0,
          requestBody: { auto: true },
          method: "POST",
          url: `${apiBaseUrl}/interactive/sessions`,
        });
      } catch (err) {
        setStepCall({
          status: "error",
          result: null,
          error: `Auto-reset failed: ${formatErr(err)}`,
          latencyMs: 0,
          requestBody: body,
          method: "POST",
          url: `${apiBaseUrl}/interactive/sessions/(none)/step`,
        });
        return;
      }
    }

    const stepUrl = `${apiBaseUrl}/interactive/sessions/${activeSessionId}/step`;
    setStepCall({
      status: "running",
      result: null,
      error: null,
      latencyMs: null,
      requestBody: body,
      method: "POST",
      url: stepUrl,
    });
    const t0 = performance.now();
    try {
      const result = await client.directStep(activeSessionId, action);
      setStepCall({
        status: "ok",
        result,
        error: null,
        latencyMs: performance.now() - t0,
        requestBody: body,
        method: "POST",
        url: stepUrl,
      });
      void refreshSummary();
    } catch (err) {
      setStepCall({
        status: "error",
        result: null,
        error: formatErr(err),
        latencyMs: performance.now() - t0,
        requestBody: body,
        method: "POST",
        url: stepUrl,
      });
    }
  }, [
    apiBaseUrl,
    client,
    equation,
    maxTurns,
    paramsJson,
    rationale,
    refreshSummary,
    seed,
    sessionId,
    systemId,
  ]);

  // Trajectory bits to draw: prefer the latest step's observation, fall
  // back to the reset's, fall back to nothing.
  const observed: TrajectorySample[] = useMemo(() => {
    const fromStep = stepCall.result?.observation.trajectory;
    const fromReset = resetCall.result?.observation.trajectory;
    return fromStep ?? fromReset ?? [];
  }, [stepCall.result, resetCall.result]);

  const stateVariables: string[] = useMemo(() => {
    const fromStep = stepCall.result?.observation.state_variables;
    const fromReset = resetCall.result?.observation.state_variables;
    return fromStep ?? fromReset ?? ["y", "vy"];
  }, [stepCall.result, resetCall.result]);

  const primaryVariable = pickPrimaryVariable(stateVariables);
  const predicted: TrajectorySample[] =
    stepCall.result?.predicted_trajectory ?? [];
  // Pull the reward breakdown off the last successful step so the panel
  // below renders the same layout the LLM tabs use. Falls back to the
  // all-zero stub before any step has been taken.
  const lastReward: RewardBreakdown =
    stepCall.result?.observation.reward_breakdown ?? ZERO_REWARD;
  const hasReward = stepCall.status === "ok" && stepCall.result !== null;

  const hasReset = resetCall.status === "ok" && sessionId !== null;

  return (
    <section className="flex flex-col gap-6">
      <header className="flex flex-col gap-2">
        <p className="heading-eyebrow text-accentBlue">OpenEnv interface</p>
        <h2 className="text-2xl font-semibold leading-tight">
          Drive the env directly: reset, step, observe.
        </h2>
        <p className="max-w-3xl text-sm leading-relaxed text-textMuted">
          PhysiX-Live implements the standard{" "}
          <a
            className="text-primary hover:underline"
            href="https://github.com/meta-pytorch/openenv"
            target="_blank"
            rel="noreferrer"
          >
            OpenEnv
          </a>{" "}
          contract: a fresh environment per <span className="font-mono">/reset</span>,
          then one observation per <span className="font-mono">/step</span>{" "}
          until <span className="font-mono">done</span>. Because the bare
          OpenEnv HTTP routes are stateless (they construct a new env per
          request), the actual playable flow below uses the per-session
          interactive router that wraps the same env with a session id —
          equivalent to a long-lived <span className="font-mono">gym.Env</span>{" "}
          handle. The bare <span className="font-mono">/reset</span>,{" "}
          <span className="font-mono">/schema</span>, and{" "}
          <span className="font-mono">/metadata</span> endpoints are
          surfaced at the bottom for reference.
        </p>
      </header>

      <StatusStrip
        baseUrl={apiBaseUrl}
        metadata={metadataCall.result}
        summary={summary}
      />

      <div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
        {/* Reset card */}
        <div className="panel flex flex-col gap-4">
          <div className="flex items-center justify-between">
            <div className="flex items-center gap-3">
              <span className="badge bg-primary/15 text-primary">POST</span>
              <h3 className="font-mono text-sm">
                /interactive/sessions
              </h3>
            </div>
            <CallStatusPill record={resetCall} />
          </div>
          <p className="text-sm text-textMuted">
            Begin a new episode. Equivalent to{" "}
            <span className="font-mono">/reset</span> on a stateful env. The
            response includes a <span className="font-mono">session_id</span>{" "}
            you'll pass to subsequent <span className="font-mono">/step</span>{" "}
            calls.
          </p>

          <label className="flex flex-col gap-1 text-xs text-textMuted">
            system_id
            <select
              className="rounded-lg border border-border bg-surfaceMuted px-3 py-2 text-sm text-textPrimary"
              value={systemId}
              onChange={(e) => setSystemId(e.target.value)}
              disabled={resetCall.status === "running"}
            >
              <option value="">(server picks at random)</option>
              {(systems ?? []).map((s) => (
                <option key={s.system_id} value={s.system_id}>
                  {s.system_id} — [{s.state_variables.join(", ")}]
                </option>
              ))}
            </select>
            {systemsError && (
              <span className="text-xs text-primary">
                Couldn't load systems: {systemsError}
              </span>
            )}
          </label>

          <div className="grid grid-cols-2 gap-3">
            <label className="flex flex-col gap-1 text-xs text-textMuted">
              seed
              <input
                className="rounded-lg border border-border bg-surfaceMuted px-3 py-2 font-mono text-sm text-textPrimary"
                type="number"
                value={seed}
                onChange={(e) => setSeed(e.target.value)}
                disabled={resetCall.status === "running"}
                placeholder="(omit for random)"
              />
            </label>
            <label className="flex flex-col gap-1 text-xs text-textMuted">
              max_turns
              <input
                className="rounded-lg border border-border bg-surfaceMuted px-3 py-2 font-mono text-sm text-textPrimary"
                type="number"
                min={1}
                max={32}
                value={maxTurns}
                onChange={(e) =>
                  setMaxTurns(Math.max(1, Math.min(32, Number(e.target.value) || 1)))
                }
                disabled={resetCall.status === "running"}
              />
            </label>
          </div>

          <button
            type="button"
            className="btn-primary self-start"
            onClick={() => void runReset()}
            disabled={resetCall.status === "running"}
          >
            {resetCall.status === "running" ? "Resetting…" : "POST reset"}
          </button>

          <CurlBlock
            method="POST"
            url={`${apiBaseUrl}/interactive/sessions`}
            body={resetCall.requestBody}
          />
          <ResponseBlock record={resetCall} kind="reset" />
        </div>

        {/* Step card */}
        <div className="panel flex flex-col gap-4">
          <div className="flex items-center justify-between">
            <div className="flex items-center gap-3">
              <span className="badge bg-accentBlue/15 text-accentBlue">
                POST
              </span>
              <h3 className="font-mono text-sm">
                /interactive/sessions/{summary?.session_id?.slice(0, 8) ?? "{id}"}/step
              </h3>
            </div>
            <CallStatusPill record={stepCall} />
          </div>
          <p className="text-sm text-textMuted">
            Submit one action. PhysiX expects an ODE in its small SymPy
            grammar plus optional numerical parameter substitutions.
            {!hasReset && (
              <>
                {" "}
                <span className="text-accentAmber">
                  No active session yet — clicking step will auto-reset
                  using the values from the card on the left.
                </span>
              </>
            )}
          </p>

          <label className="flex flex-col gap-1 text-xs text-textMuted">
            equation
            <input
              className="rounded-lg border border-border bg-surfaceMuted px-3 py-2 font-mono text-sm text-textPrimary"
              value={equation}
              onChange={(e) => setEquation(e.target.value)}
              disabled={stepCall.status === "running"}
              placeholder="d2y/dt2 = -9.81"
            />
          </label>
          <label className="flex flex-col gap-1 text-xs text-textMuted">
            params (JSON object of name → number)
            <input
              className="rounded-lg border border-border bg-surfaceMuted px-3 py-2 font-mono text-sm text-textPrimary"
              value={paramsJson}
              onChange={(e) => setParamsJson(e.target.value)}
              disabled={stepCall.status === "running"}
              placeholder='{"k": 4.2}'
            />
            {paramsError && (
              <span className="text-xs text-primary">{paramsError}</span>
            )}
          </label>
          <label className="flex flex-col gap-1 text-xs text-textMuted">
            rationale (free text)
            <input
              className="rounded-lg border border-border bg-surfaceMuted px-3 py-2 text-sm text-textPrimary"
              value={rationale}
              onChange={(e) => setRationale(e.target.value)}
              disabled={stepCall.status === "running"}
              placeholder="Why this hypothesis?"
            />
          </label>

          <button
            type="button"
            className="btn-primary self-start"
            onClick={() => void runStep()}
            disabled={stepCall.status === "running" || !equation.trim()}
          >
            {stepCall.status === "running" ? "Stepping…" : "POST step"}
          </button>

          <CurlBlock
            method="POST"
            url={
              sessionId
                ? `${apiBaseUrl}/interactive/sessions/${sessionId}/step`
                : `${apiBaseUrl}/interactive/sessions/{session_id}/step`
            }
            body={stepCall.requestBody}
          />
          <ResponseBlock record={stepCall} kind="step" />
        </div>
      </div>

      {/* Trajectory preview */}
      <div className="panel flex flex-col gap-3">
        <div className="flex items-center justify-between">
          <h3 className="font-mono text-sm">
            Last observation —{" "}
            <span className="text-textMuted">{primaryVariable}(t)</span>
          </h3>
          <span className="text-xs text-textMuted">
            {observed.length} sample{observed.length === 1 ? "" : "s"} ·{" "}
            {stateVariables.join(", ") || "—"}
            {hasReward && (
              <>
                {" · "}total{" "}
                <span className="font-mono text-textPrimary">
                  {lastReward.total.toFixed(3)}
                </span>
              </>
            )}
          </span>
        </div>
        {/* Dense reward row — same layout as the LLM tabs. Only shown
            once an actual /step has scored, otherwise the all-zero
            stub would mislead users into thinking match=0 is real. */}
        {hasReward && <DenseRewardRow reward={lastReward} />}
        <TrajectoryCanvas
          observed={observed}
          predicted={predicted}
          variable={primaryVariable}
          variableLabel={primaryVariable}
          predictedProgress={1}
        />
        {observed.length === 0 && (
          <p className="text-xs text-textMuted">
            No observation yet — call <span className="font-mono">/reset</span>{" "}
            above to load one.
          </p>
        )}
      </div>

      {/* Stateless reference endpoints. */}
      <div className="flex flex-col gap-3">
        <h3 className="text-sm font-semibold text-textPrimary">
          Stateless reference endpoints
        </h3>
        <p className="text-xs text-textMuted">
          These three endpoints come from the OpenEnv core HTTP layer.
          They construct a new environment per request, so a follow-up{" "}
          <span className="font-mono">/step</span> on the bare{" "}
          <span className="font-mono">/reset</span> would 500. Useful for
          inspection — for an episode use the session-backed cards above.
        </p>
        <div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
          <ReferenceCard
            title="POST /reset"
            description="Single-shot reset on a fresh env. Returns the initial observation."
            record={statelessResetCall}
            onRetry={runStatelessReset}
            customAction={
              <button
                type="button"
                className="btn-secondary self-start"
                onClick={() => void runStatelessReset()}
                disabled={statelessResetCall.status === "running"}
              >
                {statelessResetCall.status === "running" ? "Calling…" : "Call"}
              </button>
            }
          />
          <ReferenceCard
            title="GET /schema"
            description="JSON schemas for the action / observation / state objects."
            record={schemaCall}
            onRetry={runSchema}
          />
          <ReferenceCard
            title="GET /metadata"
            description="Self-describing environment metadata (name, version, README)."
            record={metadataCall}
            onRetry={runMetadata}
          />
        </div>
      </div>
    </section>
  );
}

// ---------------- supporting components ----------------

interface StatusStripProps {
  baseUrl: string;
  metadata: OpenEnvMetadata | null;
  summary: SessionSummary | null;
}

function StatusStrip({
  baseUrl,
  metadata,
  summary,
}: StatusStripProps): JSX.Element {
  return (
    <div className="panel-muted flex flex-wrap items-center gap-x-6 gap-y-2 text-sm">
      <span className="flex items-center gap-2">
        <span className="heading-eyebrow text-textMuted">Server</span>
        <span className="font-mono text-textPrimary">{baseUrl}</span>
      </span>
      {metadata && (
        <span className="flex items-center gap-2">
          <span className="heading-eyebrow text-textMuted">Env</span>
          <span className="font-mono text-textPrimary">
            {metadata.name}
            {metadata.version ? ` · v${metadata.version}` : ""}
          </span>
        </span>
      )}
      <span className="flex items-center gap-2">
        <span className="heading-eyebrow text-textMuted">session_id</span>
        <span className="font-mono text-textPrimary">
          {summary?.session_id?.slice(0, 12) ?? "—"}
          {summary?.session_id ? "…" : ""}
        </span>
      </span>
      <span className="flex items-center gap-2">
        <span className="heading-eyebrow text-textMuted">turn</span>
        <span className="font-mono text-textPrimary">
          {summary ? `${summary.turn} / ${summary.max_turns}` : "—"}
        </span>
      </span>
      {summary?.converged && (
        <span className="rounded-full border border-accentGreen/40 bg-accentGreen/10 px-2 py-0.5 text-[11px] uppercase tracking-wider text-accentGreen">
          converged
        </span>
      )}
      {summary?.done && !summary.converged && (
        <span className="rounded-full border border-textMuted/40 bg-surfaceMuted px-2 py-0.5 text-[11px] uppercase tracking-wider text-textMuted">
          budget exhausted
        </span>
      )}
    </div>
  );
}

interface CallStatusPillProps {
  record: CallRecord<unknown>;
}

function CallStatusPill({ record }: CallStatusPillProps): JSX.Element | null {
  if (record.status === "idle") return null;
  const map: Record<"running" | "ok" | "error", { label: string; cls: string }> = {
    running: {
      label: "running",
      cls: "border-accentBlue/40 text-accentBlue",
    },
    ok: {
      label: `200 · ${record.latencyMs?.toFixed(0)}ms`,
      cls: "border-accentGreen/40 text-accentGreen",
    },
    error: {
      label: "error",
      cls: "border-primary/50 text-primary",
    },
  };
  const { label, cls } = map[record.status];
  return (
    <span
      className={`inline-flex items-center rounded-full border px-2 py-0.5 font-mono text-xs ${cls}`}
    >
      {label}
    </span>
  );
}

interface CurlBlockProps {
  method: "GET" | "POST" | "DELETE";
  url: string;
  body: unknown;
}

function CurlBlock({ method, url, body }: CurlBlockProps): JSX.Element {
  const cmd = useMemo(() => buildCurl(method, url, body), [method, url, body]);
  const [copied, setCopied] = useState(false);

  async function copy(): Promise<void> {
    try {
      await navigator.clipboard.writeText(cmd);
      setCopied(true);
      setTimeout(() => setCopied(false), 1500);
    } catch {
      // clipboard blocked — silent
    }
  }

  return (
    <div className="rounded-lg border border-border bg-surfaceMuted">
      <div className="flex items-center justify-between px-3 py-2 text-xs text-textMuted">
        <span className="heading-eyebrow">curl</span>
        <button
          type="button"
          onClick={() => void copy()}
          className="rounded px-2 py-0.5 text-xs text-textMuted hover:text-textPrimary"
        >
          {copied ? "copied!" : "copy"}
        </button>
      </div>
      <pre className="overflow-x-auto px-3 pb-3 font-mono text-xs leading-relaxed text-textPrimary">
        {cmd}
      </pre>
    </div>
  );
}

interface ResponseBlockProps {
  record: CallRecord<unknown>;
  kind: "reset" | "step";
}

function ResponseBlock({ record, kind }: ResponseBlockProps): JSX.Element | null {
  if (record.status === "idle") return null;
  if (record.status === "running") {
    return <p className="text-xs text-textMuted">Awaiting response…</p>;
  }
  if (record.status === "error") {
    return (
      <div className="rounded-lg border border-primary/40 bg-primary/10 p-3 text-xs text-primary">
        <span className="heading-eyebrow text-primary">error</span>
        <pre className="mt-1 whitespace-pre-wrap font-mono">{record.error}</pre>
      </div>
    );
  }
  // ok
  const body = pruneObservation(record.result, kind);
  return (
    <details className="rounded-lg border border-border bg-surfaceMuted" open>
      <summary className="cursor-pointer px-3 py-2 text-xs text-textMuted">
        <span className="heading-eyebrow text-accentGreen">200 OK</span>
        <span className="ml-2 font-mono text-textPrimary">
          {record.latencyMs?.toFixed(0)}ms
        </span>
        <span className="ml-2">· response (trajectory truncated)</span>
      </summary>
      <pre className="max-h-72 overflow-auto px-3 pb-3 font-mono text-xs leading-relaxed text-textPrimary">
        {JSON.stringify(body, null, 2)}
      </pre>
    </details>
  );
}

interface ReferenceCardProps {
  title: string;
  description: string;
  record: CallRecord<unknown>;
  onRetry: () => void;
  customAction?: JSX.Element;
}

function ReferenceCard({
  title,
  description,
  record,
  onRetry,
  customAction,
}: ReferenceCardProps): JSX.Element {
  return (
    <div className="panel flex flex-col gap-3">
      <div className="flex items-center justify-between">
        <h3 className="font-mono text-sm">{title}</h3>
        <CallStatusPill record={record} />
      </div>
      <p className="text-sm text-textMuted">{description}</p>
      {customAction}
      {record.status === "error" && (
        <div className="rounded-lg border border-primary/40 bg-primary/10 p-3 text-xs text-primary">
          <pre className="whitespace-pre-wrap font-mono">{record.error}</pre>
          <button
            type="button"
            className="mt-1 underline"
            onClick={onRetry}
          >
            retry
          </button>
        </div>
      )}
      {record.status === "ok" && record.result !== null && (
        <details className="rounded-lg border border-border bg-surfaceMuted">
          <summary className="cursor-pointer px-3 py-2 text-xs text-textMuted">
            <span className="heading-eyebrow text-accentGreen">200 OK</span>
            <span className="ml-2">· view JSON</span>
          </summary>
          <pre className="max-h-72 overflow-auto px-3 pb-3 font-mono text-xs leading-relaxed text-textPrimary">
            {JSON.stringify(record.result, null, 2)}
          </pre>
        </details>
      )}
    </div>
  );
}

// ---------------- reward display ----------------
//
// Kept in sync with the duplicate in RunWithLlmPane / ComparePane so all
// three tabs render the same layout: the four trainable reward
// components on top (match / progress / simplicity / format) and the
// three diagnostic-only sub-scores (shape / freq / amplitude) on the
// bottom labelled "diag". The diag row exists because R² collapses to
// zero on small phase shifts, which makes match=0 misleading on its
// own; shape/freq/amplitude give partial credit for "visual closeness"
// without ever feeding into the reward total or the trainer.

function DenseRewardRow({ reward }: { reward: RewardBreakdown }): JSX.Element {
  const rewardComponents: { name: string; value: number }[] = [
    { name: "match", value: reward.match ?? 0 },
    { name: "progress", value: reward.progress ?? 0 },
    { name: "simplicity", value: reward.simplicity ?? 0 },
    { name: "format", value: reward.format ?? 0 },
  ];
  const diagComponents: { name: string; value: number }[] = [
    { name: "shape", value: reward.shape ?? 0 },
    { name: "freq", value: reward.freq ?? 0 },
    { name: "amplitude", value: reward.amplitude ?? 0 },
  ];
  return (
    <div className="flex flex-col gap-2 rounded-md border border-border bg-surface px-3 py-2 font-mono text-[11px]">
      <div className="grid grid-cols-4 gap-2">
        {rewardComponents.map(({ name, value }) => (
          <RewardCell key={name} name={name} value={value} />
        ))}
      </div>
      <div className="flex items-center gap-2 border-t border-border/60 pt-2">
        <span
          className="text-[10px] uppercase tracking-wider text-textMuted"
          title="Diagnostic-only — not part of the reward total or training signal. Shows visual closeness (shape / freq / amplitude) for cases where R² collapses to zero."
        >
          diag
        </span>
        <div className="grid flex-1 grid-cols-3 gap-2">
          {diagComponents.map(({ name, value }) => (
            <RewardCell key={name} name={name} value={value} muted />
          ))}
        </div>
      </div>
    </div>
  );
}

function RewardCell({
  name,
  value,
  muted = false,
}: {
  name: string;
  value: number;
  muted?: boolean;
}): JSX.Element {
  return (
    <div className="flex flex-col gap-1">
      <div className="flex items-baseline justify-between">
        <span className="text-textMuted">{name}</span>
        <span className={muted ? "text-textMuted" : "text-textPrimary"}>
          {value.toFixed(2)}
        </span>
      </div>
      <div
        className="h-1 w-full overflow-hidden rounded-full bg-border"
        aria-hidden
      >
        <div
          className={cn(
            "h-full rounded-full",
            value >= 0.7
              ? muted
                ? "bg-accentBlue/60"
                : "bg-accentGreen/70"
              : value >= 0.3
                ? "bg-accentAmber/70"
                : "bg-textMuted/40",
          )}
          style={{ width: `${Math.max(0, Math.min(1, value)) * 100}%` }}
        />
      </div>
    </div>
  );
}

// ---------------- helpers ----------------

function formatErr(err: unknown): string {
  if (err instanceof InteractiveApiError) return `[${err.status}] ${err.detail}`;
  if (err instanceof Error) return err.message;
  return String(err);
}

function parseSeed(raw: string): number | null {
  const trimmed = raw.trim();
  if (!trimmed) return null;
  const n = Number(trimmed);
  if (!Number.isInteger(n) || n < 0) return null;
  return n;
}

function buildCurl(
  method: "GET" | "POST" | "DELETE",
  url: string,
  body: unknown,
): string {
  if (method === "GET" || method === "DELETE") {
    return `curl -X ${method} ${shellQuote(url)}`;
  }
  const payload = body == null ? "{}" : JSON.stringify(body);
  return [
    `curl -X ${method}`,
    `  ${shellQuote(url)}`,
    `  -H ${shellQuote("Content-Type: application/json")}`,
    `  -d ${shellQuote(payload)}`,
  ].join(" \\\n");
}

function shellQuote(s: string): string {
  return `'${s.replace(/'/g, "'\\''")}'`;
}

/**
 * The trajectory array dominates the JSON payload (often 100+ samples).
 * Truncate it for the response viewer so the JSON pane stays readable —
 * the canvas above already plots the full thing.
 */
function pruneObservation(
  result: unknown,
  _kind: "reset" | "step",
): unknown {
  if (result === null || typeof result !== "object") return result;
  const r = result as Record<string, unknown>;
  const obs = r.observation as Record<string, unknown> | undefined;
  if (!obs) return result;
  const traj = obs.trajectory;
  if (Array.isArray(traj) && traj.length > 6) {
    return {
      ...r,
      observation: {
        ...obs,
        trajectory: [
          ...traj.slice(0, 3),
          `… ${traj.length - 6} more samples …`,
          ...traj.slice(-3),
        ],
      },
    };
  }
  return result;
}
