import type { UIMessage } from "@ai-sdk/react";
import { useChat } from "@ai-sdk/react";
import { BoltIcon, CheckIcon, StopIcon } from "@heroicons/react/20/solid";
import { ClipboardIcon, TrashIcon } from "@heroicons/react/24/outline";
import { type MetaFunction } from "@remix-run/node";
import { Link, useFetcher, useNavigate, useRouteLoaderData } from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { generateJWT as internal_generateJWT, MachinePresetName } from "@trigger.dev/core/v3";
import { TriggerChatTransport } from "@trigger.dev/sdk/chat";
import { useCallback, useEffect, useRef, useState } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { CubeSparkleIcon } from "~/assets/icons/CubeSparkleIcon";
import { PlusIcon } from "~/assets/icons/PlusIcon";
import { RunsIcon } from "~/assets/icons/RunsIcon";
import { JSONEditor } from "~/components/code/JSONEditor";
import { Button } from "~/components/primitives/Buttons";
import {
  ClientTabs,
  ClientTabsContent,
  ClientTabsList,
  ClientTabsTrigger,
} from "~/components/primitives/ClientTabs";
import { DateTime } from "~/components/primitives/DateTime";
import { DurationPicker } from "~/components/primitives/DurationPicker";
import { Header3 } from "~/components/primitives/Headers";
import { Hint } from "~/components/primitives/Hint";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
  Popover,
  PopoverContent,
  PopoverMenuItem,
  PopoverVerticalEllipseTrigger,
} from "~/components/primitives/Popover";
import {
  ResizableHandle,
  ResizablePanel,
  ResizablePanelGroup,
} from "~/components/primitives/Resizable";
import { Select, SelectItem } from "~/components/primitives/Select";
import { Spinner } from "~/components/primitives/Spinner";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { MessageBubble } from "~/components/runs/v3/agent/AgentMessageView";
import { RunTagInput } from "~/components/runs/v3/RunTagInput";
import { env as serverEnv } from "~/env.server";
import { useAutoScrollToBottom } from "~/hooks/useAutoScrollToBottom";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import type { PlaygroundConversation } from "~/presenters/v3/PlaygroundPresenter.server";
import { playgroundPresenter } from "~/presenters/v3/PlaygroundPresenter.server";
import { AIPayloadTabContent } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/AIPayloadTabContent";
import { SchemaTabContent } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/SchemaTabContent";
import { extractJwtSigningSecretKey } from "~/services/realtime/jwtAuth.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { EnvironmentParamSchema } from "~/utils/pathBuilder";

export const meta: MetaFunction = () => {
  return [{ title: "Playground | Trigger.dev" }];
};

export const loader = async ({ request, params }: LoaderFunctionArgs) => {
  const userId = await requireUserId(request);
  const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
  const agentSlug = params.agentParam;

  if (!agentSlug) {
    throw new Response(undefined, { status: 404, statusText: "Agent not specified" });
  }

  const project = await findProjectBySlug(organizationSlug, projectParam, userId);
  if (!project) {
    throw new Response(undefined, { status: 404, statusText: "Project not found" });
  }

  const environment = await findEnvironmentBySlug(project.id, envParam, userId);
  if (!environment) {
    throw new Response(undefined, { status: 404, statusText: "Environment not found" });
  }

  const agent = await playgroundPresenter.getAgent({
    environmentId: environment.id,
    environmentType: environment.type,
    agentSlug,
  });

  if (!agent) {
    throw new Response(undefined, { status: 404, statusText: "Agent not found" });
  }

  const agentConfig = agent.config as { type?: string } | null;
  const apiOrigin = serverEnv.API_ORIGIN || serverEnv.LOGIN_ORIGIN || "http://localhost:3030";

  const recentConversations = await playgroundPresenter.getRecentConversations({
    environmentId: environment.id,
    agentSlug,
    userId,
  });

  // Check for ?conversation= param to resume an existing conversation
  const url = new URL(request.url);
  const conversationId = url.searchParams.get("conversation");

  let activeConversation: {
    chatId: string;
    runFriendlyId: string | null;
    publicAccessToken: string | null;
    clientData: unknown;
    messages: unknown;
    lastEventId: string | null;
  } | null = null;

  if (conversationId) {
    const conv = recentConversations.find((c) => c.id === conversationId);
    if (conv) {
      let jwt: string | null = null;
      if (conv.isActive && conv.runFriendlyId) {
        jwt = await internal_generateJWT({
          secretKey: extractJwtSigningSecretKey(environment),
          payload: {
            sub: environment.id,
            pub: true,
            scopes: [`read:runs:${conv.runFriendlyId}`, `write:inputStreams:${conv.runFriendlyId}`],
          },
          expirationTime: "1h",
        });
      }

      activeConversation = {
        chatId: conv.chatId,
        runFriendlyId: conv.runFriendlyId,
        publicAccessToken: jwt,
        clientData: conv.clientData,
        messages: conv.messages,
        lastEventId: conv.lastEventId,
      };
    }
  }

  return typedjson({
    agent: {
      slug: agent.slug,
      filePath: agent.filePath,
      type: agentConfig?.type ?? "unknown",
      clientDataSchema: agent.payloadSchema ?? null,
    },
    apiOrigin,
    recentConversations,
    activeConversation,
  });
};

export default function PlaygroundAgentPage() {
  const { agent, activeConversation } = useTypedLoaderData<typeof loader>();
  // Key on agent slug + conversation chatId so React remounts all stateful
  // children when switching agents or navigating between conversations.
  // Without the agent slug, switching agents keeps key="new" and React
  // reuses the component — useState initializers don't re-run.
  const conversationKey = `${agent.slug}:${activeConversation?.chatId ?? "new"}`;
  return <PlaygroundChat key={conversationKey} />;
}

const PARENT_ROUTE_ID =
  "routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground";

function PlaygroundChat() {
  const { agent, apiOrigin, recentConversations, activeConversation } =
    useTypedLoaderData<typeof loader>();
  const parentData = useRouteLoaderData(PARENT_ROUTE_ID) as
    | {
        agents: Array<{ slug: string }>;
        versions: string[];
        regions: Array<{
          id: string;
          name: string;
          description?: string;
          isDefault: boolean;
        }>;
        isDev: boolean;
      }
    | undefined;
  const versions = parentData?.versions ?? [];
  const regions = parentData?.regions ?? [];
  const isDev = parentData?.isDev ?? false;
  const defaultRegion = regions.find((r) => r.isDefault);
  const navigate = useNavigate();
  const organization = useOrganization();
  const project = useProject();
  const environment = useEnvironment();

  const [conversationId, setConversationId] = useState<string | null>(() =>
    activeConversation
      ? (recentConversations.find((c) => c.chatId === activeConversation.chatId)?.id ?? null)
      : null
  );
  const [chatId, _setChatId] = useState(() => activeConversation?.chatId ?? crypto.randomUUID());
  const [clientDataJson, setClientDataJson] = useState(() =>
    activeConversation?.clientData ? JSON.stringify(activeConversation.clientData, null, 2) : "{}"
  );
  const clientDataJsonRef = useRef(clientDataJson);
  clientDataJsonRef.current = clientDataJson;
  const [machine, setMachine] = useState<string | undefined>(undefined);
  const [tags, setTags] = useState<string[]>([]);
  const [maxAttempts, setMaxAttempts] = useState<number | undefined>(undefined);
  const [maxDuration, setMaxDuration] = useState<number | undefined>(undefined);
  const [version, setVersion] = useState<string | undefined>(undefined);
  const [region, setRegion] = useState<string | undefined>(() =>
    isDev ? undefined : defaultRegion?.name
  );

  const actionPath = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/playground/action`;

  // Server-side `start` via Remix action — atomically creates the
  // backing Session for `chatId` and triggers the first run, returns
  // the session-scoped PAT. Idempotent: called on initial use AND on
  // 401, so the same code path serves both first-run and PAT renewal.
  const startSession = useCallback(async (): Promise<string> => {
    const formData = new FormData();
    formData.set("intent", "start");
    formData.set("agentSlug", agent.slug);
    formData.set("chatId", chatId);
    formData.set("clientData", clientDataJsonRef.current);
    if (tags.length > 0) formData.set("tags", tags.join(","));
    if (machine) formData.set("machine", machine);
    if (maxAttempts) formData.set("maxAttempts", String(maxAttempts));
    if (maxDuration) formData.set("maxDuration", String(maxDuration));
    if (version) formData.set("version", version);
    if (region) formData.set("region", region);

    const response = await fetch(actionPath, { method: "POST", body: formData });
    const data = (await response.json()) as {
      runId?: string;
      publicAccessToken?: string;
      conversationId?: string;
      error?: string;
    };

    if (!response.ok || !data.publicAccessToken) {
      throw new Error(data.error ?? "Failed to start chat session");
    }

    if (data.conversationId) {
      setConversationId(data.conversationId);
    }

    return data.publicAccessToken;
  }, [actionPath, agent.slug, chatId, tags, machine, maxAttempts, maxDuration, version, region]);

  // Same-origin resource routes: use the page origin, not apiOrigin, so in/append
  // doesn't go cross-origin (CORS preflight) when API_ORIGIN != APP_ORIGIN.
  const origin = typeof window !== "undefined" ? window.location.origin : apiOrigin;
  const playgroundBaseURL = `${origin}/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/playground`;

  // The transport is constructed once (guarded ref below); reading
  // `startSession` directly there would freeze its closure to the
  // first render's sidebar values, so subsequent edits to tags /
  // machine / maxAttempts / maxDuration / version / region would be
  // silently ignored on the first send. Mirror the `clientDataJsonRef`
  // pattern so the transport always calls the latest `startSession`.
  const startSessionRef = useRef(startSession);
  startSessionRef.current = startSession;

  // Create TriggerChatTransport directly (not via useTriggerChatTransport hook
  // to avoid React version mismatch between SDK and webapp)
  const transportRef = useRef<TriggerChatTransport | null>(null);
  if (transportRef.current === null) {
    transportRef.current = new TriggerChatTransport({
      task: agent.slug,
      // The Remix action is idempotent on `(env, externalId)` and
      // returns a fresh session PAT every time, so it serves both
      // first-run create and PAT renewal. `startSession` runs on
      // `transport.preload(chatId)` and lazily on the first
      // `sendMessage`; `accessToken` runs on a 401/403 from any
      // session-PAT-authed request. Wiring the same call to both
      // keeps the Preload button working without a separate refresh
      // route.
      startSession: async () => ({ publicAccessToken: await startSessionRef.current() }),
      accessToken: () => startSessionRef.current(),
      baseURL: playgroundBaseURL,
      // Use safeParseJson so a mid-edit invalid JSON state in the editor
      // doesn't throw and crash the component during transport construction.
      clientData: safeParseJson(clientDataJson),
      ...(activeConversation?.publicAccessToken
        ? {
            sessions: {
              [activeConversation.chatId]: {
                publicAccessToken: activeConversation.publicAccessToken,
                lastEventId: activeConversation.lastEventId ?? undefined,
              },
            },
          }
        : {}),
    });
  }
  const transport = transportRef.current;

  // Keep the transport's `defaultMetadata` in sync with the JSON editor.
  // Without this the transport uses the value captured at construction for
  // every per-turn metadata merge, even after the user edits the JSON.
  // `startSession` reads from `clientDataJsonRef.current` directly so session
  // creation is unaffected — this only fixes the per-turn metadata path.
  useEffect(() => {
    // JSONEditor fires onChange on every keystroke — intermediate values
    // like `{"key":` are syntactically invalid. `safeParseJson` returns
    // `{}` on parse failure so the next valid keystroke lands the update
    // without crashing the component mid-edit.
    transport.setClientData(safeParseJson(clientDataJson));
  }, [clientDataJson, transport]);

  // Initial messages from persisted conversation (for resume)
  const initialMessages = activeConversation?.messages
    ? (activeConversation.messages as UIMessage[])
    : [];

  // Track the initial message count so we only save after genuinely new turns
  // (not during resume replay which re-fires onFinish for replayed turns)
  const initialMessageCountRef = useRef(initialMessages?.length ?? 0);

  // Save messages after each turn completes
  const saveMessages = useCallback(
    (allMessages: UIMessage[]) => {
      // Skip saves during resume replay — only save when we have more messages than we started with
      if (allMessages.length <= initialMessageCountRef.current) return;

      const currentSession = transport.getSession(chatId);
      const lastEventId = currentSession?.lastEventId;

      const formData = new FormData();
      formData.set("intent", "save");
      formData.set("agentSlug", agent.slug);
      formData.set("chatId", chatId);
      formData.set("messages", JSON.stringify(allMessages));
      if (lastEventId) formData.set("lastEventId", lastEventId);

      // Fire and forget
      fetch(actionPath, { method: "POST", body: formData }).catch(() => {});

      // Update the baseline so subsequent saves work correctly
      initialMessageCountRef.current = allMessages.length;
    },
    [chatId, agent.slug, actionPath, transport]
  );

  // useChat from AI SDK — handles message accumulation, streaming, stop
  const { messages, sendMessage, stop, status, error } = useChat({
    id: chatId,
    messages: initialMessages,
    transport,
    onFinish: ({ messages: allMessages }) => {
      saveMessages(allMessages);
    },
  });

  const isStreaming = status === "streaming";
  const isSubmitted = status === "submitted";

  // Sticky-bottom auto-scroll for the messages list. The hook walks up to
  // the surrounding `overflow-y-auto` panel and follows the conversation
  // as new chunks stream in — pauses if you scroll up to read history,
  // resumes when you scroll back into the bottom band. Same behavior as
  // the run-inspector Agent tab.
  const messagesRootRef = useAutoScrollToBottom([messages, isSubmitted]);

  // Pending messages — steering during streaming
  const pending = usePlaygroundPendingMessages({
    transport,
    chatId,
    status,
    messages,
    sendMessage,
    metadata: safeParseJson(clientDataJson),
  });

  const [input, setInput] = useState("");
  const [preloading, setPreloading] = useState(false);
  const [preloaded, setPreloaded] = useState(false);
  const inputRef = useRef<HTMLTextAreaElement>(null);

  // Focus on mount; refocus when the state-1 ↔ state-2 transition remounts the textarea.
  const isEmpty = messages.length === 0;
  useEffect(() => {
    inputRef.current?.focus();
  }, [isEmpty]);

  const session = transport.getSession(chatId);

  const handlePreload = useCallback(async () => {
    setPreloading(true);
    try {
      await transport.preload(chatId);
      setPreloaded(true);
      inputRef.current?.focus();
    } finally {
      setPreloading(false);
    }
  }, [transport, chatId]);

  const handleNewConversation = useCallback(() => {
    // Navigate without ?conversation= so the loader returns activeConversation=null
    // and the key changes to "new", causing a full remount with fresh state.
    navigate(window.location.pathname);
  }, [navigate]);

  const handleDeleteConversation = useCallback(async () => {
    if (!conversationId) return;

    const formData = new FormData();
    formData.set("intent", "delete");
    formData.set("agentSlug", agent.slug);
    formData.set("deleteConversationId", conversationId);

    await fetch(actionPath, { method: "POST", body: formData });
    handleNewConversation();
  }, [conversationId, agent.slug, actionPath, handleNewConversation]);

  const handleSend = useCallback(() => {
    const trimmed = input.trim();
    if (!trimmed) return;

    setInput("");
    // steer() handles both cases: sends via input stream during streaming,
    // or sends as a normal message when ready
    pending.steer(trimmed);
    // Keep focus after the state-1 → state-2 remount completes.
    requestAnimationFrame(() => inputRef.current?.focus());
  }, [input, pending]);

  const handleKeyDown = useCallback(
    (e: React.KeyboardEvent) => {
      if (e.key === "Enter" && !e.shiftKey) {
        e.preventDefault();
        handleSend();
      }
    },
    [handleSend]
  );

  return (
    <ResizablePanelGroup orientation="horizontal" className="h-full">
      <ResizablePanel id="playground-chat" min="300px">
        <div className="relative flex h-full flex-col">
          {/* Header — floats over the scroll area so messages can scroll behind it */}
          <div className="pointer-events-none absolute inset-x-0 top-0 z-10 flex items-center justify-end px-2 py-2">
            <div className="pointer-events-auto">
              <ConversationMenu
                runFriendlyId={activeConversation?.runFriendlyId ?? null}
                hasMessages={messages.length > 0}
                hasConversation={conversationId !== null}
                messages={messages}
                onDeleteConversation={handleDeleteConversation}
                onNewConversation={handleNewConversation}
              />
            </div>
          </div>

          {/* Scroll container is always mounted — useAutoScrollToBottom caches
              its container on mount and won't refind it across remounts. */}
          <div className="flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
            <div
              ref={messagesRootRef}
              className={cn(
                "min-h-full",
                messages.length === 0
                  ? "flex items-center justify-center px-4 pb-[20vh] pt-8"
                  : "mx-auto w-full max-w-3xl space-y-4 px-4 pb-4 pt-12"
              )}
            >
              {messages.length === 0 ? (
                <div className="w-full max-w-2xl">
                  <h2 className="mb-6 text-center font-sans text-xl font-semibold leading-7 tracking-tight text-text-dimmed">
                    Type a message to start testing{" "}
                    <span className="inline-flex items-center gap-1.5 align-middle">
                      <CubeSparkleIcon className="size-6 text-agents" />
                      <code className="text-text-bright">{agent.slug}</code>
                    </span>
                  </h2>
                  <ChatComposer
                    inputRef={inputRef}
                    value={input}
                    onChange={setInput}
                    onKeyDown={handleKeyDown}
                    placeholder="Type a message…"
                    minHeightClassName="min-h-[80px]"
                    maxHeightClassName="max-h-[60vh]"
                    footer={
                      <>
                        <span className="text-[11px] text-text-dimmed">
                          Press Enter to send, Shift+Enter for new line
                        </span>
                        <PreloadButton
                          preloading={preloading}
                          preloaded={preloaded}
                          onPreload={handlePreload}
                        />
                      </>
                    }
                  />
                </div>
              ) : (
                <>
                  {messages.map((msg) => (
                    <MessageBubble key={msg.id} message={msg} />
                  ))}
                  {isSubmitted && (
                    <div className="flex justify-start">
                      <div className="flex items-center gap-2 rounded-lg bg-background-hover px-4 py-2.5">
                        <Spinner className="size-3" />
                        <span className="text-sm text-text-dimmed">Thinking…</span>
                      </div>
                    </div>
                  )}
                </>
              )}
            </div>
          </div>

          {/* Error */}
          {error && messages.length > 0 && (
            <div className="mx-4 mb-2 flex items-start gap-2 rounded border border-error/30 bg-error/10 px-3 py-2">
              <span className="flex-1 text-xs text-error">{error.message}</span>
            </div>
          )}

          {messages.length > 0 && (
            <div className="px-4 pb-8">
              <div className="mx-auto max-w-3xl">
                <ChatComposer
                  inputRef={inputRef}
                  value={input}
                  onChange={setInput}
                  onKeyDown={handleKeyDown}
                  placeholder={isStreaming ? "Send a steering message…" : "Type a message…"}
                  minHeightClassName="min-h-[44px]"
                  maxHeightClassName="max-h-[40vh]"
                  trailing={
                    isStreaming ? (
                      <Button variant="danger/small" LeadingIcon={StopIcon} onClick={stop}>
                        Stop
                      </Button>
                    ) : undefined
                  }
                  footer={
                    <span className="text-[11px] text-text-dimmed">
                      Press Enter to send, Shift+Enter for new line
                    </span>
                  }
                />
                {pending.pending.length > 0 && (
                  <div className="mt-1.5 space-y-1">
                    {pending.pending.map((msg) => (
                      <div key={msg.id} className="flex items-center gap-2 text-[10px]">
                        <span
                          className={cn(
                            "rounded px-1.5 py-0.5",
                            msg.mode === "steering"
                              ? "bg-amber-500/10 text-amber-400"
                              : "bg-background-raised text-text-dimmed"
                          )}
                        >
                          {msg.mode === "steering" ? "Steering" : "Queued"}
                        </span>
                        <span className="truncate text-text-dimmed">{msg.text}</span>
                        {msg.injected && <span className="text-success">Injected</span>}
                      </div>
                    ))}
                  </div>
                )}
                {isStreaming && (
                  <div className="mt-1.5 text-[10px] text-text-dimmed">
                    Send a steering message to guide the agent between tool calls
                  </div>
                )}
              </div>
            </div>
          )}
        </div>
      </ResizablePanel>
      <ResizableHandle id="playground-sidebar-handle" />
      <ResizablePanel id="playground-sidebar" default="420px" min="360px" max="720px">
        <PlaygroundSidebar
          clientDataJson={clientDataJson}
          onClientDataChange={setClientDataJson}
          getCurrentClientData={() => clientDataJsonRef.current}
          clientDataSchema={agent.clientDataSchema}
          agentSlug={agent.slug}
          machine={machine}
          onMachineChange={setMachine}
          tags={tags}
          onTagsChange={setTags}
          maxAttempts={maxAttempts}
          onMaxAttemptsChange={setMaxAttempts}
          maxDuration={maxDuration}
          onMaxDurationChange={setMaxDuration}
          version={version}
          onVersionChange={setVersion}
          versions={versions}
          region={region}
          onRegionChange={setRegion}
          regions={regions}
          isDev={isDev}
          session={session}
          runFriendlyId={activeConversation?.runFriendlyId ?? undefined}
          messageCount={messages.length}
          isStreaming={isStreaming}
          status={status}
          recentConversations={recentConversations}
          currentConversationId={conversationId}
          actionPath={actionPath}
        />
      </ResizablePanel>
    </ResizablePanelGroup>
  );
}

// Message rendering — `MessageBubble` is imported from
// `~/components/runs/v3/agent/AgentMessageView`. The same module is used by
// the run details Agent view so both surfaces stay in sync.

function ChatComposer({
  inputRef,
  value,
  onChange,
  onKeyDown,
  placeholder,
  minHeightClassName,
  maxHeightClassName,
  trailing,
  footer,
}: {
  inputRef: React.RefObject<HTMLTextAreaElement>;
  value: string;
  onChange: (val: string) => void;
  onKeyDown: (e: React.KeyboardEvent) => void;
  placeholder: string;
  minHeightClassName: string;
  maxHeightClassName: string;
  trailing?: React.ReactNode;
  footer?: React.ReactNode;
}) {
  return (
    <div className="rounded-2xl border border-border-bright bg-background-bright p-2 transition focus-within:border-border-brighter">
      <div className="flex items-end gap-2">
        <textarea
          ref={inputRef}
          value={value}
          onChange={(e) => onChange(e.target.value)}
          onKeyDown={onKeyDown}
          placeholder={placeholder}
          className={cn(
            "scrollbar-gutter-stable flex-1 resize-none border-0 bg-transparent px-2 py-1.5 text-sm text-text-bright placeholder-text-dimmed outline-hidden ring-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control field-sizing-content focus:border-0 focus:outline-hidden focus:ring-0",
            minHeightClassName,
            maxHeightClassName
          )}
        />
        {trailing}
      </div>
      {footer && (
        <div className="mt-1 flex items-center justify-between gap-2 px-2 pb-1">{footer}</div>
      )}
    </div>
  );
}

function PreloadButton({
  preloading,
  preloaded,
  onPreload,
}: {
  preloading: boolean;
  preloaded: boolean;
  onPreload: () => void;
}) {
  const tooltip = preloaded
    ? "The agent is warmed up and waiting."
    : "Pre-starts the agent to eliminate the cold-start delay on your first message.";

  // Wrap in a span so hover fires even when the inner button is disabled —
  // disabled buttons don't receive pointer events on their own.
  return (
    <SimpleTooltip
      asChild
      side="top"
      className="max-w-[240px]"
      content={tooltip}
      button={
        <span className="inline-flex">
          <Button
            variant="tertiary/small"
            LeadingIcon={preloading ? Spinner : preloaded ? CheckIcon : BoltIcon}
            leadingIconClassName={preloaded ? "text-success" : undefined}
            onClick={onPreload}
            disabled={preloading || preloaded}
          >
            {preloaded ? "Preloaded" : preloading ? "Preloading…" : "Preload"}
          </Button>
        </span>
      }
    />
  );
}

// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------

const machinePresets = Object.values(MachinePresetName.enum);

function PlaygroundSidebar({
  clientDataJson,
  onClientDataChange,
  getCurrentClientData,
  clientDataSchema,
  agentSlug,
  machine,
  onMachineChange,
  tags,
  onTagsChange,
  maxAttempts,
  onMaxAttemptsChange,
  maxDuration,
  onMaxDurationChange,
  version,
  onVersionChange,
  versions,
  region,
  onRegionChange,
  regions,
  isDev,
  session,
  runFriendlyId,
  messageCount,
  isStreaming,
  status,
  recentConversations,
  currentConversationId,
  actionPath,
}: {
  clientDataJson: string;
  onClientDataChange: (val: string) => void;
  getCurrentClientData: () => string;
  clientDataSchema: unknown;
  agentSlug: string;
  machine: string | undefined;
  onMachineChange: (val: string | undefined) => void;
  tags: string[];
  onTagsChange: (val: string[]) => void;
  maxAttempts: number | undefined;
  onMaxAttemptsChange: (val: number | undefined) => void;
  maxDuration: number | undefined;
  onMaxDurationChange: (val: number | undefined) => void;
  version: string | undefined;
  onVersionChange: (val: string | undefined) => void;
  versions: string[];
  region: string | undefined;
  onRegionChange: (val: string | undefined) => void;
  regions: Array<{ id: string; name: string; description?: string; isDefault: boolean }>;
  isDev: boolean;
  session:
    | {
        publicAccessToken: string;
        lastEventId?: string;
        isStreaming?: boolean;
      }
    | undefined;
  /**
   * Friendly id of the latest run for this conversation (drawn from the
   * playground's own `playgroundConversation` table, which mirrors the
   * Session's `currentRunId`). Optional because a conversation may
   * exist briefly before the first run lands.
   */
  runFriendlyId: string | undefined;
  messageCount: number;
  isStreaming: boolean;
  status: string;
  recentConversations: PlaygroundConversation[];
  currentConversationId: string | null;
  actionPath: string;
}) {
  const regionItems = regions.map((r) => ({
    value: r.name,
    label: r.description ? `${r.name} — ${r.description}` : r.name,
  }));
  return (
    <div className="flex h-full flex-col">
      <ClientTabs
        defaultValue="clientData"
        className="flex h-full min-h-0 flex-col overflow-hidden pt-1"
      >
        <div className="h-fit overflow-x-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
          <ClientTabsList variant="underline" className="mx-3 shrink-0">
            <ClientTabsTrigger
              value="clientData"
              variant="underline"
              layoutId="playground-sidebar-tabs"
              className="shrink-0"
            >
              Client Data
            </ClientTabsTrigger>
            <ClientTabsTrigger
              value="options"
              variant="underline"
              layoutId="playground-sidebar-tabs"
              className="shrink-0"
            >
              Options
            </ClientTabsTrigger>
            <ClientTabsTrigger
              value="session"
              variant="underline"
              layoutId="playground-sidebar-tabs"
              className="shrink-0"
            >
              Session
            </ClientTabsTrigger>
            <ClientTabsTrigger
              value="history"
              variant="underline"
              layoutId="playground-sidebar-tabs"
              className="shrink-0"
            >
              History
            </ClientTabsTrigger>
          </ClientTabsList>
        </div>

        {/* Client Data tab */}
        <ClientTabsContent
          value="clientData"
          className="min-h-0 flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
        >
          <div className="min-w-64 space-y-4 p-3">
            <div className="space-y-2">
              <div className="space-y-0.5">
                <Header3 className="text-text-bright">Custom metadata</Header3>
                <Paragraph variant="extra-small" className="text-text-dimmed">
                  Sent with each conversation turn.
                </Paragraph>
              </div>
              <div className="overflow-hidden rounded border border-border-bright">
                <JSONEditor
                  defaultValue={clientDataJson}
                  readOnly={false}
                  onChange={onClientDataChange}
                  minHeight="120px"
                  maxHeight="300px"
                  showClearButton={false}
                  showCopyButton={false}
                />
              </div>
            </div>

            <AIPayloadTabContent
              onPayloadGenerated={onClientDataChange}
              payloadSchema={clientDataSchema ?? undefined}
              taskIdentifier={agentSlug}
              getCurrentPayload={getCurrentClientData}
              generateButtonLabel="Generate client data"
              placeholder="Prompt to generate client data using AI…"
              isAgent={true}
              showExamplePromptsHeader={false}
              examplePromptsOverride={[
                "Generate valid client data",
                "Generate client data with all fields",
                "Generate client data with edge cases",
              ]}
            />

            {clientDataSchema != null && (
              <SchemaTabContent
                schema={clientDataSchema}
                title="Schema"
                description="JSON Schema for the agent's client data."
                showDocsLink={false}
              />
            )}
          </div>
        </ClientTabsContent>

        {/* Options tab */}
        <ClientTabsContent
          value="options"
          className="min-h-0 flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
        >
          <div className="space-y-4 p-3">
            <InputGroup fullWidth>
              <Label variant="small" required={false}>
                Machine
              </Label>
              <Select
                value={machine ?? ""}
                setValue={(val) =>
                  onMachineChange(val && typeof val === "string" ? val : undefined)
                }
                placeholder="Default"
                variant="tertiary/small"
                items={machinePresets}
                filter={(item, search) => item.toLowerCase().includes(search.toLowerCase())}
              >
                {(matches) =>
                  matches.map((preset) => (
                    <SelectItem key={preset} value={preset}>
                      {preset}
                    </SelectItem>
                  ))
                }
              </Select>
              <Hint>Overrides the machine preset.</Hint>
            </InputGroup>

            <InputGroup fullWidth>
              <Label variant="small" required={false}>
                Tags
              </Label>
              <RunTagInput
                tags={tags}
                onTagsChange={onTagsChange}
                variant="small"
                maxTags={3}
                placeholder="Add tag…"
              />
              <Hint>Add tags to easily filter runs. 3 max (2 added automatically).</Hint>
            </InputGroup>

            <InputGroup fullWidth>
              <Label variant="small" required={false}>
                Max attempts
              </Label>
              <Input
                type="number"
                variant="small"
                min={1}
                placeholder="Default"
                value={maxAttempts ?? ""}
                onChange={(e) => {
                  const val = e.target.value;
                  onMaxAttemptsChange(val ? parseInt(val, 10) : undefined);
                }}
              />
              <Hint>Retries failed runs up to the specified number of attempts.</Hint>
            </InputGroup>

            <InputGroup fullWidth>
              <Label variant="small" required={false}>
                Max duration
              </Label>
              <DurationPicker value={maxDuration} onChange={onMaxDurationChange} variant="small" />
              <Hint>Overrides the maximum compute time limit for the run.</Hint>
            </InputGroup>

            {versions.length > 0 && (
              <InputGroup fullWidth>
                <Label variant="small" required={false}>
                  Version
                </Label>
                <Select
                  value={version ?? ""}
                  setValue={(val) =>
                    onVersionChange(val && typeof val === "string" ? val : undefined)
                  }
                  placeholder="Latest"
                  variant="tertiary/small"
                  disabled={isDev}
                  items={versions}
                  filter={(item, search) => item.toLowerCase().includes(search.toLowerCase())}
                >
                  {(matches) =>
                    matches.map((v, i) => (
                      <SelectItem key={v} value={v}>
                        {i === 0 ? `${v} (latest)` : v}
                      </SelectItem>
                    ))
                  }
                </Select>
                <Hint>
                  {isDev
                    ? "Version is determined by the running dev server."
                    : "Lock the run to a specific deployed version."}
                </Hint>
              </InputGroup>
            )}

            {regionItems.length > 1 && (
              <InputGroup fullWidth>
                <Label variant="small" required={false}>
                  Region
                </Label>
                <Select
                  value={region ?? ""}
                  setValue={(val) =>
                    onRegionChange(val && typeof val === "string" ? val : undefined)
                  }
                  text={(val) => val || undefined}
                  placeholder={isDev ? "–" : "Default"}
                  variant="tertiary/small"
                  disabled={isDev}
                  items={regionItems}
                  filter={(item, search) => item.label.toLowerCase().includes(search.toLowerCase())}
                >
                  {(matches) =>
                    matches.map((r) => (
                      <SelectItem key={r.value} value={r.value}>
                        {r.label}
                      </SelectItem>
                    ))
                  }
                </Select>
                <Hint>
                  {isDev
                    ? "Region is not applicable in development."
                    : "Run the agent in a specific region."}
                </Hint>
              </InputGroup>
            )}
          </div>
        </ClientTabsContent>

        {/* Session tab */}
        <ClientTabsContent
          value="session"
          className="min-h-0 flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
        >
          <div className="min-w-64 space-y-3 p-3">
            {session ? (
              <>
                {runFriendlyId && <SessionField label="Run ID" value={runFriendlyId} />}
                <SessionField label="Messages" value={String(messageCount)} />
                <div>
                  <label className="mb-0.5 block text-[10px] font-medium uppercase tracking-wider text-text-dimmed">
                    Status
                  </label>
                  <span className="flex items-center gap-1.5 text-xs">
                    <span
                      className={cn(
                        "size-1.5 rounded-full",
                        isStreaming ? "animate-pulse bg-success" : "bg-blue-500"
                      )}
                    />
                    <span className="capitalize text-text-bright">{status}</span>
                  </span>
                </div>
              </>
            ) : (
              <p className="text-xs text-text-dimmed">
                No active session. Send a message to start a conversation.
              </p>
            )}
          </div>
        </ClientTabsContent>

        {/* History tab */}
        <ClientTabsContent
          value="history"
          className="min-h-0 flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
        >
          <HistoryTabContent
            conversations={recentConversations}
            currentConversationId={currentConversationId}
            actionPath={actionPath}
          />
        </ClientTabsContent>
      </ClientTabs>
    </div>
  );
}

// ---------------------------------------------------------------------------
// Pending messages hook (reimplemented to avoid React version mismatch)
// ---------------------------------------------------------------------------

const PENDING_MESSAGE_INJECTED_TYPE = "data-pending-message-injected";

type PendingMessageEntry = {
  id: string;
  text: string;
  mode: "steering" | "queued";
  injected: boolean;
};

function usePlaygroundPendingMessages({
  transport,
  chatId,
  status,
  messages,
  sendMessage,
  metadata,
}: {
  transport: TriggerChatTransport;
  chatId: string;
  status: string;
  messages: UIMessage[];
  sendMessage: (msg: { text: string }, opts?: { metadata?: Record<string, unknown> }) => void;
  metadata?: Record<string, unknown>;
}) {
  type InternalMsg = {
    id: string;
    role: "user";
    parts: { type: "text"; text: string }[];
    _mode: "steering" | "queued";
  };
  const [pendingMsgs, setPendingMsgs] = useState<InternalMsg[]>([]);
  const injectedIdsRef = useRef<Set<string>>(new Set());
  const prevStatusRef = useRef(status);

  // Watch for injection confirmation chunks
  useEffect(() => {
    if (status !== "streaming") return;
    let newlyInjected = false;
    for (const msg of messages) {
      if (msg.role !== "assistant") continue;
      for (const part of msg.parts ?? []) {
        if ((part as any).type === PENDING_MESSAGE_INJECTED_TYPE) {
          const messageIds = (part as any).data?.messageIds as string[] | undefined;
          if (Array.isArray(messageIds)) {
            for (const id of messageIds) {
              if (!injectedIdsRef.current.has(id)) {
                injectedIdsRef.current.add(id);
                newlyInjected = true;
              }
            }
          }
        }
      }
    }
    if (newlyInjected) {
      setPendingMsgs((prev) => prev.filter((m) => !injectedIdsRef.current.has(m.id)));
    }
  }, [status, messages]);

  // Handle turn completion — auto-send non-injected messages as next turn
  useEffect(() => {
    const turnCompleted = prevStatusRef.current === "streaming" && status === "ready";
    prevStatusRef.current = status;
    if (!turnCompleted) return;

    const toSend = pendingMsgs.filter((m) => !injectedIdsRef.current.has(m.id));
    setPendingMsgs([]);
    injectedIdsRef.current.clear();

    if (toSend.length > 0) {
      const text = toSend.map((m) => m.parts[0]?.text ?? "").join("\n");
      sendMessage({ text }, metadata ? { metadata } : undefined);
    }
  }, [status, pendingMsgs, sendMessage, metadata, messages]);

  const steer = useCallback(
    (text: string) => {
      if (status === "streaming") {
        const msg: InternalMsg = {
          id: crypto.randomUUID(),
          role: "user",
          parts: [{ type: "text", text }],
          _mode: "steering",
        };
        transport.sendPendingMessage(chatId, msg as any, metadata);
        setPendingMsgs((prev) => [...prev, msg]);
      } else {
        sendMessage({ text }, metadata ? { metadata } : undefined);
      }
    },
    [status, transport, chatId, sendMessage, metadata]
  );

  const pending: PendingMessageEntry[] = pendingMsgs.map((m) => ({
    id: m.id,
    text: m.parts[0]?.text ?? "",
    mode: m._mode,
    injected: injectedIdsRef.current.has(m.id),
  }));

  return { pending, steer };
}

function ConversationMenu({
  runFriendlyId,
  hasMessages,
  hasConversation,
  messages,
  onDeleteConversation,
  onNewConversation,
}: {
  runFriendlyId: string | null;
  hasMessages: boolean;
  hasConversation: boolean;
  messages: UIMessage[];
  onDeleteConversation: () => void;
  onNewConversation: () => void;
}) {
  const [isOpen, setIsOpen] = useState(false);

  const handleCopyRaw = useCallback(() => {
    void navigator.clipboard.writeText(JSON.stringify(messages, null, 2));
    setIsOpen(false);
  }, [messages]);

  const wrap = useCallback(
    (fn: () => void) => () => {
      setIsOpen(false);
      fn();
    },
    []
  );

  return (
    <Popover open={isOpen} onOpenChange={setIsOpen}>
      <PopoverVerticalEllipseTrigger variant="minimal" />
      <PopoverContent className="w-fit min-w-48 p-1" align="end">
        <div className="flex flex-col gap-1">
          <PopoverMenuItem
            icon={PlusIcon}
            leadingIconClassName="text-green-500 -mx-0.5"
            title="New conversation"
            onClick={wrap(onNewConversation)}
          />
          {runFriendlyId && (
            <PopoverMenuItem
              to={`/runs/${runFriendlyId}`}
              icon={RunsIcon}
              leadingIconClassName="text-runs -mx-0.5"
              title="View runs"
              onClick={() => setIsOpen(false)}
            />
          )}
          {hasMessages && (
            <PopoverMenuItem
              icon={ClipboardIcon}
              leadingIconClassName="text-yellow-500"
              title="Copy raw"
              onClick={handleCopyRaw}
            />
          )}
          {hasConversation && (
            <PopoverMenuItem
              icon={TrashIcon}
              leadingIconClassName="text-error"
              title="Delete conversation"
              danger
              onClick={wrap(onDeleteConversation)}
            />
          )}
        </div>
      </PopoverContent>
    </Popover>
  );
}

function HistoryTabContent({
  conversations,
  currentConversationId,
  actionPath,
}: {
  conversations: PlaygroundConversation[];
  currentConversationId: string | null;
  actionPath: string;
}) {
  const fetcher = useFetcher();
  const navigate = useNavigate();

  const deletingId =
    fetcher.state !== "idle" ? (fetcher.formData?.get("deleteConversationId") as string) : null;

  const handleDelete = useCallback(
    (e: React.MouseEvent, conv: PlaygroundConversation) => {
      e.preventDefault();
      e.stopPropagation();

      fetcher.submit(
        {
          intent: "delete",
          agentSlug: conv.agentSlug,
          deleteConversationId: conv.id,
        },
        { method: "POST", action: actionPath }
      );

      // Deleted the active conv → clear ?conversation= so chat resets.
      if (conv.id === currentConversationId) {
        navigate(window.location.pathname);
      }
    },
    [actionPath, currentConversationId, fetcher, navigate]
  );

  if (conversations.length === 0) {
    return (
      <div className="p-3">
        <p className="text-xs text-text-dimmed">
          No previous conversations yet. Send a message to start one.
        </p>
      </div>
    );
  }

  return (
    <ol className="space-y-0.5 p-2">
      {conversations.map((conv, index) => {
        const isActive = conv.id === currentConversationId;
        return (
          <li key={conv.id}>
            <div
              className={cn(
                "group flex items-start gap-2 rounded-sm px-2 py-1.5 transition-colors hover:bg-background-bright",
                isActive && "bg-background-hover hover:bg-background-hover",
                deletingId === conv.id && "pointer-events-none opacity-50"
              )}
            >
              <span className="w-5 shrink-0 text-right text-sm font-medium tabular-nums text-text-dimmed">
                {index + 1}
              </span>
              <Link
                to={`?conversation=${conv.id}`}
                className="flex min-w-0 flex-1 flex-col items-start gap-0.5 outline-hidden focus-custom"
              >
                <Paragraph variant="small/bright" className="line-clamp-1 text-left">
                  {conv.title}
                </Paragraph>
                <div className="text-xs text-text-dimmed">
                  <DateTime date={conv.updatedAt} showTooltip={false} />
                </div>
              </Link>
              <button
                type="button"
                onClick={(e) => handleDelete(e, conv)}
                aria-label="Delete conversation"
                className="shrink-0 rounded p-1 text-text-dimmed opacity-0 transition-opacity group-hover:opacity-100 hover:text-error"
              >
                <TrashIcon className="size-3.5" />
              </button>
            </div>
          </li>
        );
      })}
    </ol>
  );
}

function safeParseJson(json: string): Record<string, unknown> {
  try {
    return JSON.parse(json || "{}") as Record<string, unknown>;
  } catch {
    return {};
  }
}

function SessionField({ label, value }: { label: string; value: string }) {
  return (
    <div>
      <label className="mb-0.5 block text-[10px] font-medium uppercase tracking-wider text-text-dimmed">
        {label}
      </label>
      <code className="block truncate text-xs text-text-bright">{value}</code>
    </div>
  );
}
