import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";

import { MessageBubble } from "@/components/MessageBubble";
import { useShowTurnUsage } from "@/hooks/useShowTurnUsage";
import { fmtDateTime, formatMessageEndTime } from "@/lib/format";
import {
  DEFAULT_LOCAL_PREFS,
  LOCAL_PREFS_STORAGE_KEY,
  writeLocalPreferences,
} from "@/lib/local-preferences";
import type {
  CliAppInfo,
  McpPresetInfo,
  SlashCommand,
  UIMessage,
} from "@/lib/types";

const CLI_APPS: CliAppInfo[] = [
  {
    name: "zoom",
    display_name: "Zoom",
    category: "productivity",
    description: "Meetings",
    requires: "",
    source: "harness",
    entry_point: "cli-anything-zoom",
    install_supported: true,
    installed: true,
    available: true,
    status: "installed",
    logo_url: "https://example.invalid/zoom.svg",
    brand_color: "#0B5CFF",
    skill_installed: true,
  },
  {
    name: "krita",
    display_name: "Krita",
    category: "image",
    description: "Painting",
    requires: "",
    source: "harness",
    entry_point: "cli-anything-krita",
    install_supported: true,
    installed: false,
    available: false,
    status: "not_installed",
    logo_url: null,
    brand_color: "#3BABFF",
    skill_installed: false,
  },
];

const MCP_PRESETS: McpPresetInfo[] = [
  {
    name: "browserbase",
    display_name: "Browserbase",
    category: "browser",
    description: "Cloud browser automation",
    docs_url: "https://docs.browserbase.com",
    transport: "streamableHttp",
    requires: "Browserbase API key",
    note: "",
    install_supported: true,
    installed: true,
    configured: true,
    available: true,
    status: "configured",
    logo_url: "https://example.invalid/browserbase.svg",
    brand_color: "#111827",
    required_fields: [],
    connection_summary: "https://mcp.browserbase.com/mcp",
  },
];

function PreferenceAwareMessageBubble({ message }: { message: UIMessage }) {
  const showTurnUsage = useShowTurnUsage();
  return <MessageBubble message={message} showTurnUsage={showTurnUsage} />;
}

const SLASH_COMMANDS: SlashCommand[] = [
  {
    command: "/model",
    title: "Show or switch model",
    description: "Show the active model or switch to another configuration.",
    icon: "brain",
    lifecycle: "agent_turn_with_args",
    acceptsArgs: true,
  },
  {
    command: "/goal",
    title: "Start a goal",
    description: "Start a sustained goal.",
    icon: "activity",
    lifecycle: "agent_turn_with_args",
    acceptsArgs: true,
  },
  {
    command: "/new",
    title: "New chat",
    description: "Start a new chat.",
    icon: "square-pen",
    lifecycle: "finalize_active_turn",
    acceptsArgs: false,
  },
];

describe("MessageBubble", () => {
  afterEach(() => {
    localStorage.removeItem(LOCAL_PREFS_STORAGE_KEY);
  });

  it("renders user messages as right-aligned pills", () => {
    const message: UIMessage = {
      id: "u1",
      role: "user",
      content: "hello",
      createdAt: Date.now(),
    };

    const { container } = render(<MessageBubble message={message} />);
    const row = container.firstElementChild;
    const pill = screen.getByText("hello");

    expect(row).toHaveClass("ml-auto", "flex");
    expect(pill).toHaveClass("ml-auto", "w-fit", "rounded-floating");
    expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument();
    expect(screen.queryByRole("button", { name: "Fork" })).not.toBeInTheDocument();
  });

  it("renders cross-session input with its public handle", () => {
    const message: UIMessage = {
      id: "session-message:message-1",
      role: "user",
      content: "Please review this.",
      createdAt: 1_700_000_000_123,
      sessionMessage: {
        message_id: "message-1",
        session: {
          id: "handle_0123456789abcdef0123456789abcdef",
          name: "mira-0123456789",
        },
      },
    };

    const { container } = render(<MessageBubble message={message} />);

    expect(container.querySelector("[data-session-message]")).toBeInTheDocument();
    expect(screen.getByText("@mira-0123456789")).toBeInTheDocument();
    expect(screen.getByText("Please review this.")).toBeInTheDocument();
  });

  it("outlines temporary-chat user messages with a short dashed border", () => {
    const message: UIMessage = {
      id: "u-temporary",
      role: "user",
      content: "private question",
      createdAt: Date.now(),
    };

    const { rerender } = render(<MessageBubble message={message} temporary />);
    const bubble = screen.getByText("private question");

    expect(bubble).toHaveAttribute("data-temporary-message", "true");
    expect(bubble).toHaveClass("border-dashed", "border-muted-foreground/40", "bg-transparent");

    rerender(<MessageBubble message={message} />);
    expect(bubble).not.toHaveClass("border-dashed");
    expect(bubble).toHaveClass("bg-secondary/70");
  });

  it("does not replay an entrance animation when persisted messages mount", () => {
    const messages: UIMessage[] = [
      {
        id: "u-persisted",
        role: "user",
        content: "Earlier question",
        createdAt: Date.now(),
      },
      {
        id: "a-persisted",
        role: "assistant",
        content: "Earlier answer",
        createdAt: Date.now(),
      },
      {
        id: "t-persisted",
        role: "tool",
        kind: "trace",
        content: "Earlier tool call",
        createdAt: Date.now(),
      },
    ];

    for (const message of messages) {
      const { container, unmount } = render(<MessageBubble message={message} />);
      for (const className of ["animate-in", "fade-in-0", "slide-in-from-bottom-1"]) {
        expect(container.firstElementChild).not.toHaveClass(className);
      }
      unmount();
    }
  });

  it("renders failed delivery details on focus without persistent accepted chrome", async () => {
    const message: UIMessage = {
      id: "u-delivery",
      role: "user",
      content: "hello",
      createdAt: Date.now(),
      deliveryStatus: "sending",
    };

    const { rerender } = render(<MessageBubble message={message} />);

    expect(screen.getByRole("status")).toHaveTextContent("Sending…");

    rerender(<MessageBubble message={{ ...message, deliveryStatus: "accepted" }} />);
    expect(screen.queryByRole("status")).not.toBeInTheDocument();

    rerender(
      <MessageBubble
        message={{
          ...message,
          deliveryStatus: "failed",
          deliveryErrorKind: "message_too_big",
        }}
      />,
    );
    expect(screen.queryByRole("status")).not.toBeInTheDocument();
    const failedStatus = screen.getByRole("button", {
      name: "Not sent: Message too large",
    });
    expect(failedStatus).toHaveClass(
      "text-destructive/80",
      "dark:text-red-400/80",
    );
    expect(screen.getByText("hello")).not.toHaveClass("ring-1");
    expect(screen.getByText("hello")).not.toHaveClass("ring-destructive/30");
    expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();

    fireEvent.focus(failedStatus);

    const tooltip = await screen.findByRole("tooltip");
    expect(tooltip).toHaveTextContent("Message too large");
    expect(tooltip).toHaveTextContent(
      "The server rejected your last message because it exceeded the size limit.",
    );
    expect(screen.getByRole("alert")).toHaveClass("sr-only");
  });

  it("styles only generated quoted context in user messages", () => {
    const message: UIMessage = {
      id: "u-quote",
      role: "user",
      content: "> [!QUOTE]\n> selected assistant excerpt\n\nWhat about this?",
      createdAt: Date.now(),
    };

    const { rerender } = render(<MessageBubble message={message} />);

    const quote = screen.getByLabelText("Quoted context");
    expect(quote).toHaveTextContent("selected assistant excerpt");
    expect(quote).not.toHaveAttribute("title");
    expect(screen.queryByText("Quoted context")).not.toBeInTheDocument();
    expect(screen.getByText("What about this?")).toBeInTheDocument();

    rerender(
      <MessageBubble
        message={{
          ...message,
          content: "> manually typed quote\n\nWhat about this?",
        }}
      />,
    );
    expect(screen.queryByLabelText("Quoted context")).not.toBeInTheDocument();
  });

  it("copies user messages from the shared message action", async () => {
    const writeText = vi.fn().mockResolvedValue(undefined);
    Object.defineProperty(navigator, "clipboard", {
      configurable: true,
      value: { writeText },
    });
    const message: UIMessage = {
      id: "u-copy",
      role: "user",
      content: "Copy this user prompt.",
      createdAt: Date.now(),
    };

    try {
      render(<MessageBubble message={message} />);

      fireEvent.click(screen.getByRole("button", { name: "Copy" }));

      expect(writeText).toHaveBeenCalledWith("Copy this user prompt.");
      await waitFor(() =>
        expect(screen.getByRole("button", { name: "Copied" })).toBeInTheDocument(),
      );
    } finally {
      Reflect.deleteProperty(navigator, "clipboard");
    }
  });

  it("highlights recognized slash command names without adding container chrome", () => {
    const message: UIMessage = {
      id: "u-command",
      role: "user",
      content: "/model gpt-5",
      createdAt: Date.now(),
    };

    render(<MessageBubble message={message} slashCommands={SLASH_COMMANDS} />);

    const command = screen.getByTestId("message-slash-command");
    expect(command).toHaveTextContent("/model");
    expect(command).toHaveClass(
      "font-[550]",
      "transition-colors",
      "duration-150",
    );
    expect(command).not.toHaveClass("font-mono");
    expect(command.getAttribute("style")).not.toContain("text-shadow");
    expect(command.getAttribute("style")).toContain("var(--inline-token-highlight)");
    expect(command.className).not.toMatch(/(?:^|\s)(?:bg-|border|ring|rounded)/);
    expect(command.parentElement).toHaveTextContent("/model gpt-5");
    expect(command.parentElement).toHaveClass("rounded-floating", "bg-secondary/70");
  });

  it("keeps unknown and invalid slash commands as plain message text", () => {
    const unknown: UIMessage = {
      id: "u-unknown-command",
      role: "user",
      content: "/unknown value",
      createdAt: Date.now(),
    };
    const invalidExactCommand: UIMessage = {
      id: "u-invalid-command",
      role: "user",
      content: "/new with-arguments",
      createdAt: Date.now(),
    };

    const { rerender } = render(
      <MessageBubble message={unknown} slashCommands={SLASH_COMMANDS} />,
    );
    expect(screen.queryByTestId("message-slash-command")).not.toBeInTheDocument();
    expect(screen.getByText("/unknown value")).toBeInTheDocument();

    rerender(<MessageBubble message={invalidExactCommand} slashCommands={SLASH_COMMANDS} />);
    expect(screen.queryByTestId("message-slash-command")).not.toBeInTheDocument();
    expect(screen.getByText("/new with-arguments")).toBeInTheDocument();
  });

  it("preserves installed capability mentions in slash command arguments", () => {
    const message: UIMessage = {
      id: "u-command-mention",
      role: "user",
      content: "/goal ask @zoom to schedule the review",
      createdAt: Date.now(),
    };

    render(
      <MessageBubble
        message={message}
        slashCommands={SLASH_COMMANDS}
        cliApps={CLI_APPS}
      />,
    );

    expect(screen.getByTestId("message-slash-command")).toHaveTextContent("/goal");
    expect(screen.getByTestId("message-cli-mention-zoom")).toHaveTextContent("@zoom");
  });

  it("highlights skill references without a live skill catalog", () => {
    const message: UIMessage = {
      id: "u-skill-reference",
      role: "user",
      content: "Ask $github to review this with @zoom",
      createdAt: Date.now(),
    };

    render(
      <MessageBubble
        message={message}
        cliApps={CLI_APPS}
      />,
    );

    const skill = screen.getByTestId("message-skill-reference-github");
    expect(skill).toHaveTextContent(/^github$/);
    expect(skill).toHaveClass(
      "font-[550]",
      "transition-colors",
      "duration-150",
    );
    expect(skill.getAttribute("style")).not.toContain("text-shadow");
    expect(skill.getAttribute("style")).toContain("var(--inline-token-highlight)");
    expect(skill.className).not.toMatch(/(?:^|\s)(?:bg-|border|ring|rounded)/);
    expect(screen.getByTestId("message-cli-mention-zoom")).toHaveTextContent("@zoom");
    expect(skill.parentElement).toHaveTextContent("Ask github to review this with @zoom");
  });

  it("highlights well-formed skill references and leaves a bare marker plain", () => {
    const message: UIMessage = {
      id: "u-plain-skill-reference",
      role: "user",
      content: "Try $unknown or $blocked-skill and $",
      createdAt: Date.now(),
    };

    render(<MessageBubble message={message} />);

    expect(screen.getByTestId("message-skill-reference-unknown")).toHaveTextContent(/^unknown$/);
    expect(screen.getByTestId("message-skill-reference-blocked-skill"))
      .toHaveTextContent(/^blocked-skill$/);
    const references = screen.getAllByTestId(/^message-skill-reference-/);
    expect(references).toHaveLength(2);
    expect(references[0].parentElement)
      .toHaveTextContent("Try unknown or blocked-skill and $");
  });

  it("renders fork control in completed assistant action rows", () => {
    const onForkFromHere = vi.fn();
    const message: UIMessage = {
      id: "a-fork",
      role: "assistant",
      content: "branch after this answer",
      latencyMs: 1_200,
      createdAt: Date.now(),
    };

    render(<MessageBubble message={message} onForkFromHere={onForkFromHere} />);

    fireEvent.click(screen.getByRole("button", { name: "Fork" }));
    expect(onForkFromHere).toHaveBeenCalledTimes(1);
  });

  it("shows the assistant completion time in the former latency slot", async () => {
    const completedAt = Date.UTC(2026, 6, 25, 12, 34, 56);
    const { container } = render(
      <MessageBubble
        message={{
          id: "a-completed-at",
          role: "assistant",
          content: "Finished answer",
          latencyMs: 13_000,
          completedAt,
          createdAt: Date.now(),
        }}
      />,
    );

    expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument();
    const time = container.querySelector("[data-assistant-completed-at]");
    expect(time).toHaveTextContent(formatMessageEndTime(completedAt));
    expect(time).toHaveAttribute("dateTime", new Date(completedAt).toISOString());
    expect(time).not.toHaveAttribute("title");
    expect(time).toHaveAttribute("tabIndex", "0");
    expect(time).toHaveClass(
      "cursor-help",
      "text-[11px]",
      "leading-none",
      "text-muted-foreground/70",
      "tabular-nums",
    );

    fireEvent.pointerMove(time!);
    expect(await screen.findByRole("tooltip")).toHaveTextContent(fmtDateTime(completedAt));
  });

  it("falls back to the assistant creation time when replay has no completion time", () => {
    const createdAt = Date.UTC(2026, 6, 25, 12, 34, 56);
    const { container } = render(
      <MessageBubble
        message={{
          id: "a-created-at",
          role: "assistant",
          content: "Proactive answer",
          createdAt,
        }}
      />,
    );

    const time = container.querySelector("[data-message-timestamp]");
    expect(time).toHaveTextContent(formatMessageEndTime(createdAt));
    expect(time).toHaveAttribute("dateTime", new Date(createdAt).toISOString());
    expect(time).not.toHaveAttribute("title");
    expect(time).not.toHaveAttribute("data-assistant-completed-at");
  });

  it("renders the creation time for user messages", async () => {
    const createdAt = Date.UTC(2026, 6, 25, 12, 34, 56);
    const { container } = render(
      <MessageBubble
        message={{
          id: "u-created-at",
          role: "user",
          content: "A user message",
          createdAt,
        }}
      />,
    );

    const time = container.querySelector("[data-message-created-at]");
    expect(time).toHaveTextContent(formatMessageEndTime(createdAt));
    expect(time).toHaveAttribute("dateTime", new Date(createdAt).toISOString());
    expect(time).not.toHaveAttribute("title");
    expect(time).toHaveAttribute("tabIndex", "0");

    fireEvent.pointerMove(time!);
    expect(await screen.findByRole("tooltip")).toHaveTextContent(fmtDateTime(createdAt));
  });

  it("does not infer completion time from the assistant creation timestamp", () => {
    const createdAt = Date.UTC(2026, 6, 25, 12, 34, 0);
    const latencyMs = 13_000;
    const { container } = render(
      <MessageBubble
        message={{
          id: "a-replayed-completion",
          role: "assistant",
          content: "Replayed answer",
          latencyMs,
          createdAt,
        }}
      />,
    );

    expect(container.querySelector("[data-assistant-completed-at]")).not.toBeInTheDocument();
  });

  it("renders installed CLI app mentions inside sent user messages", () => {
    const message: UIMessage = {
      id: "u-cli",
      role: "user",
      content: "Hi nano, please use @zoom to book a meeting, not @krita",
      createdAt: Date.now(),
    };

    render(<MessageBubble message={message} cliApps={CLI_APPS} />);

    const token = screen.getByTestId("message-cli-mention-zoom");
    expect(token).toHaveTextContent("@zoom");
    expect(token).toHaveAttribute("title", "CLI app: Zoom");
    expect(token).toHaveClass("font-[550]");
    expect(token.className).not.toContain("rounded");
    expect(token.className).not.toContain("px-");
    expect(token.getAttribute("style")).toContain("color: #0B5CFF");
    expect(token.getAttribute("style")).not.toContain("text-shadow");
    expect(screen.getByTestId("message-cli-mention-logo-zoom")).toBeInTheDocument();
    expect(screen.queryByTestId("message-cli-mention-krita")).not.toBeInTheDocument();
    expect(screen.getByText(/not @krita/)).toBeInTheDocument();
  });

  it("places automation metadata after the timestamp and reveals its source on hover", async () => {
    const completedAt = Date.UTC(2026, 6, 25, 12, 34, 56);
    const message: UIMessage = {
      id: "a-cron",
      role: "assistant",
      content: "Time to drink water.",
      source: { kind: "cron", label: "drink water" },
      completedAt,
      createdAt: completedAt - 1_000,
    };

    const { container } = render(<MessageBubble message={message} />);

    const footer = container.querySelector("[data-assistant-footer]")!;
    const timestamp = footer.querySelector("[data-message-timestamp]")!;
    const trigger = footer.querySelector("[data-automation-trigger]")!;

    expect(timestamp).toHaveTextContent(formatMessageEndTime(completedAt));
    expect(trigger).toHaveTextContent("Triggered automatically");
    expect(trigger.previousElementSibling).toBe(timestamp);
    expect(trigger).toHaveClass(
      "text-[11px]",
      "leading-none",
      "text-muted-foreground/70",
      "tabular-nums",
    );
    expect(trigger.className).not.toMatch(/(?:^|\s)(?:border|bg-)/);
    expect(trigger.querySelector("svg")).not.toBeInTheDocument();
    expect(screen.queryByText("drink water")).not.toBeInTheDocument();
    expect(screen.getByText("Time to drink water.")).toBeInTheDocument();

    fireEvent.pointerMove(trigger);
    expect(await screen.findByRole("tooltip")).toHaveTextContent("drink water");
  });

  it("renders structured CLI app attachments even without the installed catalog", () => {
    const message: UIMessage = {
      id: "u-cli-attached",
      role: "user",
      content: "Please use @drawio for the diagram",
      createdAt: Date.now(),
      cliApps: [{
        name: "drawio",
        display_name: "Draw.io",
        category: "diagram",
        entry_point: "cli-anything-drawio",
        logo_url: "https://example.invalid/drawio.svg",
        brand_color: "#F08705",
      }],
    };

    render(<MessageBubble message={message} cliApps={[]} />);

    const token = screen.getByTestId("message-cli-mention-drawio");
    expect(token).toHaveTextContent("@drawio");
    expect(token.className).not.toContain("rounded");
    expect(token.className).not.toContain("px-");
    expect(token.getAttribute("style")).toContain("color: #F08705");
    expect(screen.getByTestId("message-cli-mention-logo-drawio")).toBeInTheDocument();
  });

  it("renders MCP preset mentions inside sent user messages", () => {
    const message: UIMessage = {
      id: "u-mcp",
      role: "user",
      content: "Use @browserbase to inspect the checkout flow",
      createdAt: Date.now(),
    };

    render(<MessageBubble message={message} mcpPresets={MCP_PRESETS} />);

    const token = screen.getByTestId("message-mcp-mention-browserbase");
    expect(token).toHaveTextContent("@browserbase");
    expect(token).toHaveAttribute("title", "MCP server: Browserbase");
    expect(token.getAttribute("style")).toContain("color: #111827");
    expect(screen.getByTestId("message-mcp-mention-logo-browserbase")).toBeInTheDocument();
  });

  it("renders persisted session mentions inside sent user messages", () => {
    const message: UIMessage = {
      id: "u-session",
      role: "user",
      content: "Use @收费设计 as context",
      createdAt: Date.now(),
      sessionMentions: [{
        name: "收费设计",
        session_key: "websocket:pricing",
        title: "收费设计",
      }],
    };

    render(<MessageBubble message={message} />);

    const token = screen.getByTestId("message-session-mention-收费设计");
    expect(token).toHaveTextContent("@收费设计");
    expect(token).toHaveAttribute("title", "Session: 收费设计");
    expect(token.closest("a")).toHaveAttribute("href", "#/chat/websocket%3Apricing");
    expect(token.closest("a")?.getAttribute("style")).toContain(
      "text-decoration-color: var(--inline-token-highlight)",
    );
  });

  it("copies completed assistant replies from the action row", async () => {
    const writeText = vi.fn().mockResolvedValue(undefined);
    Object.defineProperty(navigator, "clipboard", {
      configurable: true,
      value: { writeText },
    });
    const message: UIMessage = {
      id: "a-copy",
      role: "assistant",
      content: "I can help with the next step.",
      createdAt: Date.now(),
    };

    render(<MessageBubble message={message} />);

    fireEvent.click(screen.getByRole("button", { name: "Copy" }));

    expect(writeText).toHaveBeenCalledWith("I can help with the next step.");
    await waitFor(() =>
      expect(screen.getByRole("button", { name: "Copied" })).toBeInTheDocument(),
    );
  });

  it("copies completed assistant replies with the textarea fallback", async () => {
    Object.defineProperty(navigator, "clipboard", {
      configurable: true,
      value: undefined,
    });
    const execCommand = vi.fn().mockReturnValue(true);
    Object.defineProperty(document, "execCommand", {
      configurable: true,
      value: execCommand,
    });
    const message: UIMessage = {
      id: "a-copy-fallback",
      role: "assistant",
      content: "Fallback copy reply.",
      createdAt: Date.now(),
    };

    try {
      render(<MessageBubble message={message} />);

      fireEvent.click(screen.getByRole("button", { name: "Copy" }));

      await waitFor(() => expect(execCommand).toHaveBeenCalledWith("copy"));
      await waitFor(() =>
        expect(screen.getByRole("button", { name: "Copied" })).toBeInTheDocument(),
      );
    } finally {
      Reflect.deleteProperty(navigator, "clipboard");
      Reflect.deleteProperty(document, "execCommand");
    }
  });

  it("falls back when the Clipboard API rejects assistant reply copy", async () => {
    const writeText = vi.fn().mockRejectedValue(new Error("not allowed"));
    Object.defineProperty(navigator, "clipboard", {
      configurable: true,
      value: { writeText },
    });
    const execCommand = vi.fn().mockReturnValue(true);
    Object.defineProperty(document, "execCommand", {
      configurable: true,
      value: execCommand,
    });
    const message: UIMessage = {
      id: "a-copy-reject",
      role: "assistant",
      content: "Rejected clipboard copy.",
      createdAt: Date.now(),
    };

    try {
      render(<MessageBubble message={message} />);

      fireEvent.click(screen.getByRole("button", { name: "Copy" }));

      expect(writeText).toHaveBeenCalledWith("Rejected clipboard copy.");
      await waitFor(() => expect(execCommand).toHaveBeenCalledWith("copy"));
      await waitFor(() =>
        expect(screen.getByRole("button", { name: "Copied" })).toBeInTheDocument(),
      );
    } finally {
      Reflect.deleteProperty(navigator, "clipboard");
      Reflect.deleteProperty(document, "execCommand");
    }
  });

  it("does not show copy actions for streaming placeholders", () => {
    const message: UIMessage = {
      id: "a-streaming",
      role: "assistant",
      content: "",
      isStreaming: true,
      createdAt: Date.now(),
    };

    render(<MessageBubble message={message} />);

    expect(screen.queryByRole("button", { name: "Copy" })).not.toBeInTheDocument();
  });

  it("keeps assistant footer geometry mounted across stream completion", () => {
    const streaming: UIMessage = {
      id: "a-footer-stable",
      role: "assistant",
      content: "Stable answer",
      isStreaming: true,
      createdAt: Date.now(),
    };
    const { container, rerender } = render(<MessageBubble message={streaming} />);

    const reservedFooter = container.querySelector("[data-assistant-footer]");
    expect(reservedFooter).not.toBeNull();
    expect(reservedFooter).toHaveAttribute("data-state", "reserved");
    expect(reservedFooter).toHaveClass("mt-2", "min-h-8", "opacity-0");

    rerender(
      <MessageBubble
        message={{
          ...streaming,
          isStreaming: false,
          completedAt: Date.now(),
        }}
      />,
    );

    const visibleFooter = container.querySelector("[data-assistant-footer]");
    expect(visibleFooter).toBe(reservedFooter);
    expect(visibleFooter).toHaveAttribute("data-state", "visible");
    expect(visibleFooter).toHaveClass("mt-2", "min-h-8", "opacity-100");
  });

  it("does not show copy when showCopyAction is false", () => {
    const message: UIMessage = {
      id: "a-mid",
      role: "assistant",
      content: "Mid-turn snippet.",
      createdAt: Date.now(),
    };

    render(<MessageBubble message={message} showCopyAction={false} />);

    expect(screen.queryByRole("button", { name: "Copy" })).not.toBeInTheDocument();
  });

  it("renders trace messages as collapsible tool groups", () => {
    const message: UIMessage = {
      id: "t1",
      role: "tool",
      kind: "trace",
      content: 'search "hk weather"',
      traces: ['weather("get")', 'search "hk weather"'],
      createdAt: Date.now(),
    };

    render(<MessageBubble message={message} />);
    const toggle = screen.getByRole("button", { name: /used 2 tools/i });

    expect(screen.queryByText('weather("get")')).not.toBeInTheDocument();
    expect(screen.queryByText('search "hk weather"')).not.toBeInTheDocument();

    fireEvent.click(toggle);
    expect(screen.getByText('weather("get")')).toBeInTheDocument();
    expect(screen.getByText('search "hk weather"')).toBeInTheDocument();
  });

  it("renders video media as an inline player", () => {
    const message: UIMessage = {
      id: "a1",
      role: "assistant",
      content: "here is the clip",
      createdAt: Date.now(),
      media: [
        {
          kind: "video",
          url: "/api/media/sig/payload",
          name: "demo.mp4",
        },
      ],
    };

    const { container } = render(<MessageBubble message={message} />);

    expect(screen.getByText("here is the clip")).toBeInTheDocument();
    const video = screen.getByLabelText(/video attachment/i);
    expect(video.tagName).toBe("VIDEO");
    expect(video).toHaveAttribute("src", "/api/media/sig/payload");
    expect(video).toHaveAttribute("preload", "metadata");
    expect(container.querySelector("video[controls]")).toBeInTheDocument();
    expect(screen.queryByText("Preview")).not.toBeInTheDocument();
    expect(screen.queryByText("Code")).not.toBeInTheDocument();
  });

  it("renders streaming reasoning as one compact activity line", () => {
    const message: UIMessage = {
      id: "a-reasoning-streaming",
      role: "assistant",
      content: "",
      createdAt: Date.now(),
      reasoning: "Step 1: parse intent. Step 2: compute.",
      reasoningStreaming: true,
    };

    const { container } = render(<MessageBubble message={message} />);

    const preview = screen.getByText("Step 1: parse intent. Step 2: compute.");
    expect(preview).toBeInTheDocument();
    expect(container.querySelector(".reasoning-sheen-stripe")).not.toBeInTheDocument();
    expect(preview).toHaveClass("streaming-text-sheen");
    expect(preview).toHaveAttribute(
      "data-sheen-text",
      "Step 1: parse intent. Step 2: compute.",
    );
    expect(screen.queryByText("Thinking…")).not.toBeInTheDocument();
    expect(screen.queryByRole("button", { name: /thinking/i })).not.toBeInTheDocument();
  });

  it("keeps completed reasoning on one line above the answer", () => {
    const message: UIMessage = {
      id: "a-reasoning-done",
      role: "assistant",
      content: "The answer is 42.",
      createdAt: Date.now(),
      reasoning: "hidden until expanded",
      reasoningStreaming: false,
    };

    render(<MessageBubble message={message} />);

    const preview = screen.getByText("hidden until expanded");
    expect(preview).toBeInTheDocument();
    expect(screen.getByText("The answer is 42.")).toBeInTheDocument();
    expect(preview.closest('[data-testid="activity-step"]')).toHaveClass("mb-2");
    expect(screen.queryByText("Thinking")).not.toBeInTheDocument();
    expect(screen.queryByRole("button", { name: /thinking/i })).not.toBeInTheDocument();
  });

  it("compacts reasoning markdown into plain single-line text", () => {
    const message: UIMessage = {
      id: "a-reasoning-md",
      role: "assistant",
      content: "",
      createdAt: Date.now(),
      reasoning: "### Section title\n\nBody line.",
      reasoningStreaming: false,
    };

    const { container } = render(<MessageBubble message={message} />);

    expect(screen.getByText("Section title Body line.")).toBeInTheDocument();
    expect(container.textContent).not.toContain("###");
    expect(container.querySelector("h3")).not.toBeInTheDocument();
  });

  it("renders inline file paths as compact file references", async () => {
    await import("@/components/MarkdownTextRenderer");
    const message: UIMessage = {
      id: "a-file-path",
      role: "assistant",
      content:
        "改动在 `webui/src/components/MarkdownTextRenderer.tsx` 和 `/Users/renxubin/.nanobot/workspace/minecraft-fps/index.html`。",
      createdAt: Date.now(),
    };

    try {
      render(<MessageBubble message={message} />);

      const references = await screen.findAllByTestId("inline-file-path");
      expect(references).toHaveLength(2);
      expect(references[0].parentElement).not.toHaveClass("translate-y-[0.08em]");
      expect(references[0].parentElement).toHaveClass("align-baseline");
      expect(references[0].parentElement).toHaveClass("leading-[inherit]");
      expect(references[0]).toHaveClass("items-baseline");
      expect(references[0]).toHaveTextContent("MarkdownTextRenderer.tsx");
      expect(references[0]).not.toHaveTextContent("webui/src/components");
      expect(screen.getByText("index.html")).toBeInTheDocument();
      expect(references[1]).not.toHaveTextContent("/Users/renxubin");
      expect(references[1]).not.toHaveAttribute("title");
      expect(references[1]).toHaveAttribute(
        "aria-label",
        "/Users/renxubin/.nanobot/workspace/minecraft-fps/index.html",
      );

      vi.useFakeTimers();
      fireEvent.pointerMove(references[1].parentElement!);
      await act(async () => {
        vi.advanceTimersByTime(500);
      });
      const tooltip = screen.getByRole("tooltip");
      expect(tooltip).toHaveTextContent(
        "/Users/renxubin/.nanobot/workspace/minecraft-fps/index.html",
      );
    } finally {
      vi.useRealTimers();
    }
  });

  it("renders assistant image media as a larger generated result", () => {
    const message: UIMessage = {
      id: "a-image",
      role: "assistant",
      content: "done",
      createdAt: Date.now(),
      media: [
        {
          kind: "image",
          url: "/api/media/sig/image",
          name: "generated.png",
        },
      ],
    };

    const { container } = render(<MessageBubble message={message} />);

    const imageButton = screen.getByRole("button", { name: /view image/i });
    expect(imageButton).toHaveClass("w-[min(100%,34rem)]", "rounded-panel");
    expect(imageButton).toHaveClass(
      "border",
      "border-border/60",
      "focus-visible:ring-2",
    );
    expect(imageButton).not.toHaveClass("hover:scale-[1.01]");
    expect(imageButton).not.toHaveClass("hover:ring-2");
    expect(imageButton).not.toHaveClass("hover:ring-primary/25");
    expect(imageButton).not.toHaveAttribute("title");
    expect(container.querySelector("img")).toHaveClass("h-auto", "w-full", "object-contain");
  });

  it("renders mislabeled html assistant media as a file attachment", () => {
    const message: UIMessage = {
      id: "a-html",
      role: "assistant",
      content: "file ready",
      createdAt: Date.now(),
      media: [
        {
          kind: "image",
          url: "/api/media/sig/html",
          name: "index.html",
        },
      ],
    };

    const { container } = render(<MessageBubble message={message} />);

    expect(screen.getByLabelText("File attachment")).toHaveTextContent("index.html");
    expect(container.querySelector("img")).not.toBeInTheDocument();
  });

  it("renders assistant svg media as an image preview", () => {
    const message: UIMessage = {
      id: "a-svg",
      role: "assistant",
      content: "chart ready",
      createdAt: Date.now(),
      media: [
        {
          kind: "file",
          url: "/api/media/sig/svg",
          name: "growth.svg",
        },
      ],
    };

    const { container } = render(<MessageBubble message={message} />);

    expect(screen.getByRole("button", { name: /view image: growth.svg/i })).toBeInTheDocument();
    expect(container.querySelector('img[src="/api/media/sig/svg"]')).toBeInTheDocument();
    expect(screen.queryByLabelText("File attachment")).not.toBeInTheDocument();
  });

  it("hides turn usage by default and leaves latency visible when the preference changes", async () => {
    const message: UIMessage = {
      id: "turn-usage-preference",
      role: "assistant",
      content: "done",
      createdAt: Date.now(),
      latencyMs: 18_200,
      usage: {
        prompt_tokens: 12_400,
        completion_tokens: 823,
        context_tokens: 8_100,
        request_count: 1,
      },
    };

    const { container } = render(<PreferenceAwareMessageBubble message={message} />);

    expect(container.querySelector("[data-turn-usage]")).not.toBeInTheDocument();
    expect(container.querySelector("[data-turn-latency]")).toHaveTextContent("18s");

    act(() => {
      writeLocalPreferences({ ...DEFAULT_LOCAL_PREFS, showTurnUsage: true });
    });
    expect(await screen.findByText("8.1K context")).toHaveAttribute("data-turn-usage");

    act(() => {
      writeLocalPreferences({ ...DEFAULT_LOCAL_PREFS, showTurnUsage: false });
    });
    await waitFor(() => {
      expect(container.querySelector("[data-turn-usage]")).not.toBeInTheDocument();
    });
    expect(container.querySelector("[data-turn-latency]")).toHaveTextContent("18s");
  });

  it("shows consecutive contexts while keeping each turn's aggregate cost in details", async () => {
    const turns: UIMessage[] = [
      {
        id: "turn-a",
        role: "assistant",
        content: "first reply",
        createdAt: Date.now(),
        usage: {
          prompt_tokens: 111_000,
          completion_tokens: 333,
          cached_tokens: 55_500,
          context_tokens: 56_000,
          request_count: 2,
        },
      },
      {
        id: "turn-b",
        role: "assistant",
        content: "second reply",
        createdAt: Date.now(),
        usage: {
          prompt_tokens: 57_000,
          completion_tokens: 192,
          cached_tokens: 55_290,
          context_tokens: 57_000,
          request_count: 1,
        },
      },
    ];

    render(<>{turns.map((message) => (
      <MessageBubble key={message.id} message={message} showTurnUsage />
    ))}</>);

    const turnAUsage = screen.getByText("56K context");
    const turnBUsage = screen.getByText("57K context");
    expect(turnAUsage).toHaveAttribute("data-turn-usage");
    expect(turnBUsage).toHaveAttribute("data-turn-usage");
    expect(screen.queryByText(/111K turn input/)).not.toBeInTheDocument();

    fireEvent.focus(turnAUsage);
    expect(screen.getByRole("tooltip")).toHaveTextContent(
      "111K turn input · 2 requests · 333 out · 50% cached",
    );

    fireEvent.blur(turnAUsage);
    await waitFor(() => expect(screen.queryByRole("tooltip")).not.toBeInTheDocument());

    fireEvent.focus(turnBUsage);
    expect(screen.getByRole("tooltip")).toHaveTextContent(
      "57K turn input · 1 request · 192 out · 97% cached",
    );
  });

  it("falls back to clearly labeled estimated turn input when context is unavailable", () => {
    const message: UIMessage = {
      id: "a-estimated-usage",
      role: "assistant",
      content: "done",
      createdAt: Date.now(),
      usage: {
        prompt_tokens: 1_250,
        completion_tokens: 90,
        request_count: 1,
        estimated_tokens: 1_340,
      },
    };

    render(<MessageBubble message={message} showTurnUsage />);

    const usage = screen.getByText("~1.3K turn input");
    expect(usage).not.toHaveTextContent("context");
    fireEvent.focus(usage);
    expect(screen.getByRole("tooltip")).toHaveTextContent(
      "1 request · ~90 out · Includes estimated usage",
    );
    expect(screen.getByRole("tooltip")).not.toHaveTextContent("cached");
  });
});
