import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { act, renderHook } from "@testing-library/react";
import type { Agent, ChatSession, Project } from "@multica/core/types";

interface QueuedRestore {
  id: string;
  content: string;
  attachments?: unknown[];
  sessionId: string;
}

// --- Shared mutable state (hoisted so vi.mock factories can reach it) --------
const h = vi.hoisted(() => {
  const store = {
    activeSessionId: null as string | null,
    selectedAgentId: null as string | null,
    selectedProjectId: null as string | null,
    setActiveSession: vi.fn((id: string | null) => {
      store.activeSessionId = id;
    }),
    setSelectedAgentId: vi.fn((id: string | null) => {
      store.selectedAgentId = id;
    }),
    setSelectedProjectId: vi.fn((id: string | null) => {
      store.selectedProjectId = id;
    }),
    appliedDraftRestoreIds: [] as string[],
    markDraftRestoreApplied: vi.fn((id: string) => {
      if (!store.appliedDraftRestoreIds.includes(id)) {
        store.appliedDraftRestoreIds = [...store.appliedDraftRestoreIds, id];
      }
    }),
    forgetDraftRestoreApplied: vi.fn((id: string) => {
      store.appliedDraftRestoreIds = store.appliedDraftRestoreIds.filter((x) => x !== id);
    }),
    // Server-less restores (a failed send, a synchronous cancel) queue per session
    // and are persisted; the shared slot never holds one for another session.
    pendingSendRestores: {} as Record<string, QueuedRestore[]>,
    enqueuePendingSendRestore: vi.fn((r: QueuedRestore) => {
      const existing = store.pendingSendRestores[r.sessionId] ?? [];
      if (existing.some((q) => q.id === r.id)) return;
      store.pendingSendRestores = {
        ...store.pendingSendRestores,
        [r.sessionId]: [...existing, r],
      };
    }),
    dequeuePendingSendRestore: vi.fn((sessionId: string, restoreId: string) => {
      const existing = store.pendingSendRestores[sessionId] ?? [];
      const remaining = existing.filter((q) => q.id !== restoreId);
      const next = { ...store.pendingSendRestores };
      if (remaining.length > 0) next[sessionId] = remaining;
      else delete next[sessionId];
      store.pendingSendRestores = next;
    }),
  };
  return {
    store,
    archivedMutate: vi.fn(),
    markReadMutate: vi.fn(),
    // Stable across renders so tests can assert on it; lazy-creates the session
    // a new chat's first send needs.
    createSessionMutate: vi.fn(async () => ({ id: "new-session" })),
    // Foreground gate for the auto mark-read effect; tests flip it.
    appForeground: { value: true },
    consumeRestoreMutate: vi.fn(),
    setProjectMutate: vi.fn(),
    removeFromCaches: vi.fn(),
    // useQuery reads these so each test can vary the loaded data.
    sessions: [] as ChatSession[],
    agents: [] as Agent[],
    projects: [] as Project[],
    draftRestores: null as
      | { restores: { id: string; chat_session_id: string; content: string }[] }
      | null,
  };
});

vi.mock("@multica/core/hooks", () => ({ useWorkspaceId: () => "ws-1" }));
vi.mock("@multica/core/auth", () => ({
  useAuthStore: (sel: (s: { user: { id: string } }) => unknown) =>
    sel({ user: { id: "user-1" } }),
}));
vi.mock("@multica/core/workspace/queries", () => ({
  agentListOptions: () => ({ queryKey: ["agents"] }),
  memberListOptions: () => ({ queryKey: ["members"] }),
}));
vi.mock("@multica/core/projects/queries", () => ({
  projectListOptions: () => ({ queryKey: ["projects"] }),
}));
vi.mock("@multica/views/issues/components", () => ({ canAssignAgent: () => true }));
vi.mock("@multica/core/api", () => ({
  api: { sendChatMessage: vi.fn(), cancelTaskById: vi.fn() },
  // Names the 403 that a revoked invoke permission raises (MUL-4525); plain
  // failures have no reason code.
  dispatchReasonCode: () => undefined,
}));
vi.mock("@multica/core/agents", () => ({
  useAgentPresenceDetail: () => ({ availability: "online" }),
  useWorkspaceAgentAvailability: () => "available",
}));
vi.mock("@multica/core/hooks/use-file-upload", () => ({
  useFileUpload: () => ({ uploadWithToast: vi.fn() }),
}));
vi.mock("@multica/core/chat/mutations", () => ({
  useCreateChatSession: () => ({ mutateAsync: h.createSessionMutate }),
  useMarkChatSessionRead: () => ({ mutate: h.markReadMutate }),
  useSetChatSessionArchived: () => ({ mutate: h.archivedMutate }),
  useSetChatSessionProject: () => ({
    mutate: h.setProjectMutate,
    isPending: false,
  }),
  useConsumeChatDraftRestore: () => ({ mutate: h.consumeRestoreMutate }),
}));
vi.mock("../../common/use-app-foreground", () => ({
  useAppForeground: () => h.appForeground.value,
}));
vi.mock("@multica/core/chat", () => ({
  useChatStore: Object.assign(
    (sel: (s: typeof h.store) => unknown) => sel(h.store),
    { getState: () => h.store },
  ),
}));
vi.mock("@multica/core/realtime", () => ({
  removeChatMessageFromCaches: h.removeFromCaches,
}));
vi.mock("@multica/core/logger", () => ({
  createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
}));
vi.mock("../../i18n", () => ({ useT: () => ({ t: () => "x" }) }));
vi.mock("sonner", () => ({ toast: { error: vi.fn(), success: vi.fn() } }));

vi.mock("@tanstack/react-query", async (importOriginal) => {
  const actual = await importOriginal<typeof import("@tanstack/react-query")>();
  return {
    ...actual,
    useQuery: (options: { queryKey?: unknown[] }) => {
      const key = options.queryKey ?? [];
      if (key.includes("agents")) return { data: h.agents };
      if (key.includes("members")) {
        return { data: [{ user_id: "user-1", role: "admin" }] };
      }
      if (key.includes("sessions")) return { data: h.sessions, isSuccess: true };
      if (key.includes("projects")) return { data: h.projects, isSuccess: true };
      if (key.includes("draft-restores")) return { data: h.draftRestores };
      return { data: null };
    },
    useInfiniteQuery: () => ({
      data: undefined,
      isLoading: false,
      fetchNextPage: vi.fn(),
      hasNextPage: false,
      isFetchingNextPage: false,
    }),
    useQueryClient: () => ({
      getQueryData: vi.fn(),
      setQueryData: vi.fn(),
      invalidateQueries: vi.fn(),
    }),
  };
});

import { useChatController } from "./use-chat-controller";
import { api } from "@multica/core/api";

// --- Fixtures ---------------------------------------------------------------
function makeSession(
  overrides: Partial<ChatSession> & Pick<ChatSession, "id" | "agent_id">,
): ChatSession {
  return {
    workspace_id: "ws-1",
    creator_id: "user-1",
    title: `Chat ${overrides.id}`,
    status: "active",
    has_unread: false,
    unread_count: 0,
    last_message: null,
    pinned: false,
    created_at: new Date(0).toISOString(),
    updated_at: new Date(0).toISOString(),
    ...overrides,
  };
}

const agentA = { id: "agent-a", name: "Alpha" } as unknown as Agent;
const agentB = { id: "agent-b", name: "Beta" } as unknown as Agent;

// Descending updated_at → sortChatSessions renders them sA, sB, sC.
const sA = makeSession({ id: "sA", agent_id: "agent-a", updated_at: "2026-07-08T03:00:00Z" });
const sB = makeSession({ id: "sB", agent_id: "agent-b", updated_at: "2026-07-08T02:00:00Z" });
const sC = makeSession({ id: "sC", agent_id: "agent-a", updated_at: "2026-07-08T01:00:00Z" });

function setup(activeSessionId: string | null, sessions: ChatSession[], agents: Agent[]) {
  h.store.activeSessionId = activeSessionId;
  h.store.selectedAgentId = null;
  h.store.selectedProjectId = null;
  h.sessions = sessions;
  h.agents = agents;
  h.projects = [];
  const { result } = renderHook(() => useChatController());
  // Ignore any render-time store writes (self-heal etc.); we assert only the
  // effect of the call under test.
  h.store.setActiveSession.mockClear();
  h.store.setSelectedAgentId.mockClear();
  h.store.setSelectedProjectId.mockClear();
  h.archivedMutate.mockClear();
  return result;
}

describe("useChatController project context", () => {
  const project = {
    id: "project-a",
    workspace_id: "ws-1",
    title: "Project Alpha",
  } as Project;
  const otherProject = {
    id: "project-b",
    workspace_id: "ws-1",
    title: "Project Beta",
  } as Project;

  beforeEach(() => {
    h.store.setActiveSession.mockClear();
    h.store.setSelectedProjectId.mockClear();
    h.createSessionMutate.mockClear();
    h.setProjectMutate.mockClear();
    h.createSessionMutate.mockResolvedValue({ id: "new-session" });
  });

  afterEach(() => {
    h.store.selectedProjectId = null;
    h.projects = [];
  });

  it("removes the project from the current chat without leaving it", () => {
    const projectSession = makeSession({
      id: "project-session",
      agent_id: "agent-a",
      project_id: project.id,
    });
    h.store.activeSessionId = projectSession.id;
    h.store.selectedProjectId = project.id;
    h.sessions = [projectSession];
    h.agents = [agentA];
    h.projects = [project];

    const { result } = renderHook(() => useChatController());
    h.store.setActiveSession.mockClear();
    h.store.setSelectedProjectId.mockClear();

    act(() => result.current.handleProjectChange(null));

    expect(h.setProjectMutate).toHaveBeenCalledWith({
      sessionId: projectSession.id,
      projectId: null,
    });
    expect(h.store.setSelectedProjectId).not.toHaveBeenCalled();
    expect(h.store.setActiveSession).not.toHaveBeenCalled();
  });

  it("starts a fresh chat when switching an existing chat to another project", () => {
    const projectSession = makeSession({
      id: "project-session",
      agent_id: "agent-a",
      project_id: project.id,
    });
    h.store.activeSessionId = projectSession.id;
    h.store.selectedProjectId = project.id;
    h.sessions = [projectSession];
    h.agents = [agentA];
    h.projects = [project, otherProject];

    const { result } = renderHook(() => useChatController());
    h.store.setActiveSession.mockClear();
    h.store.setSelectedProjectId.mockClear();

    act(() => result.current.handleProjectChange(otherProject.id));

    expect(h.setProjectMutate).not.toHaveBeenCalled();
    expect(h.store.setSelectedProjectId).toHaveBeenCalledWith(otherProject.id);
    expect(h.store.setActiveSession).toHaveBeenCalledWith(null);
  });

  it("pins the fresh chat to the open session's agent when selectedAgentId is stale", () => {
    // The open session belongs to agent B, but the persisted preference is
    // still agent A. Switching to another project clears the active session,
    // which would otherwise drop selection back to the stale agent A and send
    // the lazily-created chat to the wrong agent (MUL-5150 regression). The
    // switch must first sync selectedAgentId to the open session's agent.
    const projectSession = makeSession({
      id: "project-session",
      agent_id: agentB.id,
      project_id: project.id,
    });
    h.store.activeSessionId = projectSession.id;
    h.store.selectedAgentId = agentA.id; // stale preference for a different agent
    h.store.selectedProjectId = project.id;
    h.sessions = [projectSession];
    h.agents = [agentA, agentB];
    h.projects = [project, otherProject];

    const { result } = renderHook(() => useChatController());
    h.store.setActiveSession.mockClear();
    h.store.setSelectedAgentId.mockClear();
    h.store.setSelectedProjectId.mockClear();

    act(() => result.current.handleProjectChange(otherProject.id));

    expect(h.store.setSelectedAgentId).toHaveBeenCalledWith(agentB.id);
    expect(h.store.setSelectedProjectId).toHaveBeenCalledWith(otherProject.id);
    expect(h.store.setActiveSession).toHaveBeenCalledWith(null);
  });

  it("does not write project changes into the new-chat draft while an active session resolves", () => {
    h.store.activeSessionId = "loading-session";
    h.store.selectedProjectId = project.id;
    h.sessions = [];
    h.agents = [agentA];
    h.projects = [project];

    const { result } = renderHook(() => useChatController());
    h.store.setActiveSession.mockClear();
    h.store.setSelectedProjectId.mockClear();

    act(() => result.current.handleProjectChange(null));

    expect(h.setProjectMutate).not.toHaveBeenCalled();
    expect(h.store.setSelectedProjectId).not.toHaveBeenCalled();
    expect(h.store.setActiveSession).not.toHaveBeenCalled();
  });

  it("persists the selected project when lazy-creating a session", async () => {
    h.store.activeSessionId = null;
    h.store.selectedAgentId = agentA.id;
    h.store.selectedProjectId = project.id;
    h.sessions = [];
    h.agents = [agentA];
    h.projects = [project];
    vi.mocked(api.sendChatMessage).mockResolvedValue({
      message_id: "message-1",
      task_id: "task-1",
      created_at: new Date(0).toISOString(),
    } as Awaited<ReturnType<typeof api.sendChatMessage>>);

    const { result } = renderHook(() => useChatController());
    await act(async () => {
      await result.current.handleSend("hello", undefined, vi.fn());
    });

    expect(h.createSessionMutate).toHaveBeenCalledWith(
      expect.objectContaining({ project_id: project.id }),
    );
  });

  it("does not inherit the current session project when starting a new chat", () => {
    const projectSession = makeSession({
      id: "project-session",
      agent_id: "agent-a",
      project_id: project.id,
    });
    h.store.activeSessionId = projectSession.id;
    h.store.selectedProjectId = project.id;
    h.sessions = [projectSession];
    h.agents = [agentA, agentB];
    h.projects = [project];

    const { result } = renderHook(() => useChatController());
    h.store.setActiveSession.mockClear();
    h.store.setSelectedProjectId.mockClear();

    act(() => result.current.handleStartNewChat(agentB));

    expect(h.store.setSelectedProjectId).toHaveBeenCalledWith(null);
    expect(h.store.setActiveSession).toHaveBeenCalledWith(null);
  });

  it("clears the current session project when using the plain new-chat action", () => {
    const projectSession = makeSession({
      id: "project-session",
      agent_id: "agent-a",
      project_id: project.id,
    });
    h.store.activeSessionId = projectSession.id;
    h.store.selectedProjectId = project.id;
    h.sessions = [projectSession];
    h.agents = [agentA];
    h.projects = [project];

    const { result } = renderHook(() => useChatController());
    h.store.setActiveSession.mockClear();
    h.store.setSelectedProjectId.mockClear();

    act(() => result.current.handleNewChat());

    expect(h.store.setSelectedProjectId).toHaveBeenCalledWith(null);
    expect(h.store.setActiveSession).toHaveBeenCalledWith(null);
  });

  it("keeps a historical session project out of the next-chat draft state", () => {
    const projectSession = makeSession({
      id: "project-session",
      agent_id: "agent-a",
      project_id: project.id,
    });
    h.store.activeSessionId = null;
    h.store.selectedProjectId = null;
    h.sessions = [projectSession];
    h.agents = [agentA];
    h.projects = [project];

    const { result } = renderHook(() => useChatController());
    h.store.setActiveSession.mockClear();
    h.store.setSelectedProjectId.mockClear();

    act(() => result.current.handleSelectSession(projectSession));

    expect(h.store.setSelectedProjectId).not.toHaveBeenCalled();
    expect(h.store.setActiveSession).toHaveBeenCalledWith(projectSession.id);
  });
});

describe("useChatController.advanceSelectionAfterArchive", () => {
  beforeEach(() => {
    h.store.setActiveSession.mockClear();
    h.store.setSelectedAgentId.mockClear();
    h.archivedMutate.mockClear();
  });

  it("advances to the next chat and syncs the selected agent across agents", () => {
    const result = setup("sA", [sA, sB, sC], [agentA, agentB]);
    act(() => result.current.advanceSelectionAfterArchive(sA));

    expect(h.store.setActiveSession).toHaveBeenCalledWith("sB");
    // The next chat belongs to a different agent — selectedAgentId must follow
    // so a subsequent "new chat" defaults to the right agent (the review bug).
    expect(h.store.setSelectedAgentId).toHaveBeenCalledWith("agent-b");
  });

  it("does not touch the selected agent when the next chat is the same agent", () => {
    // Both chats belong to agent-a; archiving the open one advances within the
    // same agent, so there is no reason to rewrite selectedAgentId.
    const a1 = makeSession({ id: "a1", agent_id: "agent-a", updated_at: "2026-07-08T03:00:00Z" });
    const a2 = makeSession({ id: "a2", agent_id: "agent-a", updated_at: "2026-07-08T02:00:00Z" });
    const result = setup("a1", [a1, a2], [agentA, agentB]);
    act(() => result.current.advanceSelectionAfterArchive(a1));

    expect(h.store.setActiveSession).toHaveBeenCalledWith("a2");
    expect(h.store.setSelectedAgentId).not.toHaveBeenCalled();
  });

  it("falls back to the previous chat when archiving the last open one", () => {
    const result = setup("sC", [sA, sB, sC], [agentA, agentB]);
    act(() => result.current.advanceSelectionAfterArchive(sC));

    expect(h.store.setActiveSession).toHaveBeenCalledWith("sB");
  });

  it("clears the selection when archiving the only chat", () => {
    const only = makeSession({ id: "only", agent_id: "agent-a" });
    const result = setup("only", [only], [agentA]);
    act(() => result.current.advanceSelectionAfterArchive(only));

    expect(h.store.setActiveSession).toHaveBeenCalledWith(null);
    expect(h.store.setSelectedAgentId).not.toHaveBeenCalled();
  });

  it("is a no-op when the archived chat is not the open one", () => {
    const result = setup("sB", [sA, sB, sC], [agentA, agentB]);
    act(() => result.current.advanceSelectionAfterArchive(sA));

    expect(h.store.setActiveSession).not.toHaveBeenCalled();
    expect(h.store.setSelectedAgentId).not.toHaveBeenCalled();
  });
});

describe("useChatController.archiveSession", () => {
  it("fires the archive mutation for the given session", () => {
    const result = setup("sA", [sA, sB, sC], [agentA, agentB]);
    act(() => result.current.archiveSession("sA"));

    expect(h.archivedMutate).toHaveBeenCalledWith({ sessionId: "sA", archived: true });
  });
});

// MUL-4360 mount race: `activeSessionId` is persisted, so on a bare `/chat`
// navigation the page restores the last session as active for one frame before
// its URL→store effect clears it back to null. The auto-mark-read must NOT fire
// for that transiently-active session — otherwise the badge vanishes though the
// user never opened it (the exact "no active session yet the red dot cleared"
// report). The read is deferred a tick and cancelled when activeSessionId moves.
describe("useChatController auto mark-read", () => {
  const unread = makeSession({
    id: "sA",
    agent_id: "agent-a",
    has_unread: true,
    unread_count: 2,
  });

  beforeEach(() => {
    vi.useFakeTimers();
    h.store.activeSessionId = null;
    h.store.selectedAgentId = null;
    h.markReadMutate.mockClear();
    h.appForeground.value = true;
    h.sessions = [unread];
    h.agents = [agentA];
  });

  afterEach(() => {
    vi.useRealTimers();
  });

  it("marks read a session that stays active past the tick", () => {
    h.store.activeSessionId = "sA";
    renderHook(() => useChatController());

    // Deferred, not synchronous — nothing fires on the mount frame.
    expect(h.markReadMutate).not.toHaveBeenCalled();

    act(() => vi.advanceTimersByTime(1));
    expect(h.markReadMutate).toHaveBeenCalledWith("sA");
  });

  it("does NOT mark read a session that was only momentarily active on mount", () => {
    // Mount restores sA as active (persisted, unread)...
    h.store.activeSessionId = "sA";
    const { rerender } = renderHook(() => useChatController());

    // ...then the page's URL→store effect clears it before the tick elapses.
    h.store.activeSessionId = null;
    rerender();

    act(() => vi.advanceTimersByTime(1));
    expect(h.markReadMutate).not.toHaveBeenCalled();
  });
});

// Foreground gating (MUL-4485): a reply that lands while the app is backgrounded
// must stay unread and clear once the user returns. This composes with the
// MUL-4360 mount-race defer above — the read is scheduled a tick after the
// effect runs — so each assertion advances fake timers to let it (or not) fire.
describe("useChatController auto mark-read — foreground gating (MUL-4485)", () => {
  const unreadActive = makeSession({ id: "sU", agent_id: "agent-a", has_unread: true });

  beforeEach(() => {
    vi.useFakeTimers();
    h.markReadMutate.mockClear();
    h.appForeground.value = true;
  });

  afterEach(() => {
    vi.useRealTimers();
  });

  function renderController(activeSessionId: string | null, sessions: ChatSession[]) {
    h.store.activeSessionId = activeSessionId;
    h.store.selectedAgentId = null;
    h.sessions = sessions;
    h.agents = [agentA];
    return renderHook(() => useChatController({ isActive: true }));
  }

  it("marks the active unread session read while the app is in the foreground", () => {
    renderController("sU", [unreadActive]);
    act(() => vi.advanceTimersByTime(1));
    expect(h.markReadMutate).toHaveBeenCalledWith("sU");
  });

  it("does NOT mark read while the app is backgrounded, so the reply stays unread", () => {
    h.appForeground.value = false;
    renderController("sU", [unreadActive]);
    act(() => vi.advanceTimersByTime(1));
    expect(h.markReadMutate).not.toHaveBeenCalled();
  });

  it("marks read once the user returns to the foreground", () => {
    h.appForeground.value = false;
    const { rerender } = renderController("sU", [unreadActive]);
    act(() => vi.advanceTimersByTime(1));
    expect(h.markReadMutate).not.toHaveBeenCalled();

    act(() => {
      h.appForeground.value = true;
      rerender();
    });
    act(() => vi.advanceTimersByTime(1));
    expect(h.markReadMutate).toHaveBeenCalledWith("sU");
  });
});

describe("useChatController durable draft restores (#5219)", () => {
  beforeEach(() => {
    h.draftRestores = null;
    h.consumeRestoreMutate.mockClear();
    h.removeFromCaches.mockClear();
    h.store.appliedDraftRestoreIds = [];
    h.store.markDraftRestoreApplied.mockClear();
    h.store.forgetDraftRestoreApplied.mockClear();
    h.store.pendingSendRestores = {};
    h.store.enqueuePendingSendRestore.mockClear();
    h.store.dequeuePendingSendRestore.mockClear();
    h.appForeground.value = true;
  });

  // The reconnect/offline recovery path: no chat:cancel_finalized event was
  // seen — the restore arrives purely through the draft-restores query on
  // composer mount, is handed to the composer, and is consumed server-side
  // only after the composer reports the hand-off complete.
  it("hands a fetched restore to the composer and consumes it after the hand-off", () => {
    h.draftRestores = {
      restores: [{ id: "msg-1", chat_session_id: "sA", content: "run the thing" }],
    };
    const result = setup("sA", [sA, sB, sC], [agentA, agentB]);

    expect(result.current.restoreDraftRequest).toEqual({
      id: "msg-1",
      content: "run the thing",
      attachments: undefined,
      sessionId: "sA",
      serverRestoreId: "msg-1",
    });
    // The deleted bubble may still be cached when the event was missed.
    expect(h.removeFromCaches).toHaveBeenCalledWith(expect.anything(), "sA", "msg-1");
    // Not yet consumed: the draft isn't persisted client-side until the
    // composer takes it.
    expect(h.consumeRestoreMutate).not.toHaveBeenCalled();

    act(() => {
      result.current.handleRestoreDraftApplied();
    });

    // The ledger entry is written BEFORE the request goes out: a consume that
    // never lands must not let this restore be offered (and re-sent) again.
    expect(h.store.markDraftRestoreApplied).toHaveBeenCalledWith("msg-1");
    expect(h.consumeRestoreMutate).toHaveBeenCalledWith(
      { sessionId: "sA", restoreId: "msg-1" },
      expect.anything(),
    );
    expect(result.current.restoreDraftRequest).toBeNull();
  });

  // The lost-consume case (#5219 review): the DELETE never reached the server,
  // so the row comes back on the next fetch — but it was already applied, and
  // the user may have sent it. It must be reconciled (consumed again), never
  // re-offered to the composer.
  it("reconciles a row whose consume was lost instead of re-offering it", () => {
    h.store.appliedDraftRestoreIds = ["msg-1"];
    h.draftRestores = {
      restores: [{ id: "msg-1", chat_session_id: "sA", content: "run the thing" }],
    };
    const result = setup("sA", [sA, sB, sC], [agentA, agentB]);

    expect(result.current.restoreDraftRequest).toBeNull();
    expect(h.consumeRestoreMutate).toHaveBeenCalledWith(
      { sessionId: "sA", restoreId: "msg-1" },
      expect.anything(),
    );
  });

  it("leaves a restore belonging to another session pending", () => {
    h.draftRestores = {
      restores: [{ id: "msg-2", chat_session_id: "sB", content: "other session draft" }],
    };
    const result = setup("sA", [sA, sB, sC], [agentA, agentB]);

    expect(result.current.restoreDraftRequest).toBeNull();
    expect(h.consumeRestoreMutate).not.toHaveBeenCalled();
  });

  // A backgrounded browser tab still renders this controller. It must not claim a
  // restore the user is waiting on in a foreground surface — the same silent-theft
  // hazard the hidden chat window guards against, reached here through the app
  // foreground gate rather than isOpen.
  it("does NOT offer or consume a restore while the app is backgrounded", () => {
    h.appForeground.value = false;
    h.draftRestores = {
      restores: [{ id: "msg-1", chat_session_id: "sA", content: "run the thing" }],
    };
    const result = setup("sA", [sA, sB, sC], [agentA, agentB]);

    expect(result.current.restoreDraftRequest).toBeNull();
    expect(h.consumeRestoreMutate).not.toHaveBeenCalled();
  });

  it("offers the restore once the surface returns to the foreground", () => {
    h.appForeground.value = false;
    h.draftRestores = {
      restores: [{ id: "msg-1", chat_session_id: "sA", content: "run the thing" }],
    };
    h.store.activeSessionId = "sA";
    h.store.selectedAgentId = null;
    h.sessions = [sA, sB, sC];
    h.agents = [agentA, agentB];
    const { result, rerender } = renderHook(() => useChatController());

    expect(result.current.restoreDraftRequest).toBeNull();

    act(() => {
      h.appForeground.value = true;
      rerender();
    });

    expect(result.current.restoreDraftRequest?.serverRestoreId).toBe("msg-1");
  });
});

// After a send, the composer is scrubbed only if the user is still on the
// session they sent from — otherwise the shared editor is showing a different
// draft and clearing it would wipe visible input. MUL-4864 changes what
// "still here" means: with ONE new-chat draft, the composer no longer belongs
// to an agent, so only `activeSessionId` can answer it.
describe("useChatController.handleSend — compose target tracking", () => {
  beforeEach(() => {
    h.store.setActiveSession.mockClear();
    h.createSessionMutate.mockClear();
    h.createSessionMutate.mockResolvedValue({ id: "new-session" });
    vi.mocked(api.sendChatMessage).mockResolvedValue({
      message_id: "msg-1",
      task_id: "task-1",
      created_at: new Date(0).toISOString(),
    } as unknown as Awaited<ReturnType<typeof api.sendChatMessage>>);
  });

  function sendFrom(activeSessionId: string | null, whileSending?: () => void) {
    h.store.activeSessionId = activeSessionId;
    h.store.selectedAgentId = "agent-a";
    h.sessions = [sA];
    h.agents = [agentA];
    const { result } = renderHook(() => useChatController());
    h.store.setActiveSession.mockClear();
    const commitInput = vi.fn();
    return {
      commitInput,
      send: () =>
        act(async () => {
          const pending = result.current.handleSend("hello", undefined, commitInput);
          // Runs between ensureSession and the commit — the window the user
          // actually races with when they touch the picker after hitting send.
          whileSending?.();
          await pending;
        }),
    };
  }

  it("scrubs the composer after a new chat's first send", async () => {
    const { commitInput, send } = sendFrom(null);
    await send();

    expect(commitInput).toHaveBeenCalledWith(
      expect.objectContaining({ clearEditor: true, extraDraftKeys: ["new-session"] }),
    );
    expect(h.store.setActiveSession).toHaveBeenCalledWith("new-session");
  });

  it("scrubs the composer even if the agent picker moved mid-send", async () => {
    // The sent text is still sitting in the one shared composer. Leaving it
    // there would arm a second, unintended send to the agent just picked.
    const { commitInput, send } = sendFrom(null, () => {
      h.store.selectedAgentId = "agent-b";
    });
    await send();

    expect(commitInput).toHaveBeenCalledWith(
      expect.objectContaining({ clearEditor: true, extraDraftKeys: ["new-session"] }),
    );
    // The message goes to the agent selected when Send was pressed — a later
    // flick of the picker does not re-route work already on its way.
    expect(h.createSessionMutate).toHaveBeenCalledWith(
      expect.objectContaining({ agent_id: "agent-a" }),
    );
    // …and that session is what opens, so the user sees the reply they asked for.
    expect(h.store.setActiveSession).toHaveBeenCalledWith("new-session");
  });

  it("keeps the input when session create fails, and opens nothing", async () => {
    h.createSessionMutate.mockRejectedValue(new Error("create failed"));
    const { commitInput, send } = sendFrom(null);
    await send();

    // No commit = the draft is never cleared, so the user's words survive.
    expect(commitInput).not.toHaveBeenCalled();
    expect(h.store.setActiveSession).not.toHaveBeenCalled();
  });

  it("leaves the composer alone when the user navigated to another session mid-send", async () => {
    // Genuine navigation: the editor now shows sB's draft, which this send has
    // no business clearing. Fire-and-forget — the reply surfaces as unread.
    const { commitInput, send } = sendFrom(null, () => {
      h.store.activeSessionId = "sB";
    });
    await send();

    expect(commitInput).toHaveBeenCalledWith(
      expect.objectContaining({ clearEditor: false, extraDraftKeys: ["new-session"] }),
    );
    expect(h.store.setActiveSession).not.toHaveBeenCalled();
  });

  it("still clears the sent draft when sending from an existing session", async () => {
    const { commitInput, send } = sendFrom("sA");
    await send();

    expect(commitInput).toHaveBeenCalledWith(
      expect.objectContaining({ clearEditor: true, extraDraftKeys: ["sA"] }),
    );
  });
});
