import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";

// ── Mocks ────────────────────────────────────────────────────────────────────

vi.mock("./logger.js", () => ({
  createLogger: () => ({
    info: vi.fn(),
    warn: vi.fn(),
    error: vi.fn(),
    debug: vi.fn(),
  }),
}));

const mockResolveSessionGitInfo = vi.fn<
  (sessionId: string, state: unknown) => Promise<void>
>();
vi.mock("./session-git-info.js", () => ({
  resolveSessionGitInfo: (...args: unknown[]) =>
    mockResolveSessionGitInfo(args[0] as string, args[1]),
}));

import { SessionManager } from "./session-manager.js";
import type { Session } from "./ws-bridge-types.js";
import type { SessionState } from "./session-types.js";

// ── Helpers ──────────────────────────────────────────────────────────────────

/** Minimal mock SessionStore that satisfies the interface used by SessionManager. */
function makeMockStore() {
  return {
    save: vi.fn(),
    remove: vi.fn(),
    loadAll: vi.fn().mockResolvedValue([]),
  };
}

// ═════════════════════════════════════════════════════════════════════════════
// SessionManager
// ═════════════════════════════════════════════════════════════════════════════

describe("SessionManager", () => {
  let sm: SessionManager;

  beforeEach(() => {
    vi.clearAllMocks();
    vi.useFakeTimers();
    mockResolveSessionGitInfo.mockResolvedValue(undefined);
    sm = new SessionManager();
  });

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

  // ═══════════════════════════════════════════════════════════════════════
  // getOrCreateSession
  // ═══════════════════════════════════════════════════════════════════════

  describe("getOrCreateSession", () => {
    it("creates a new session with default backend type 'claude'", () => {
      const session = sm.getOrCreateSession("s1");

      expect(session.id).toBe("s1");
      expect(session.backendType).toBe("claude");
      expect(session.state.session_id).toBe("s1");
      expect(session.state.backend_type).toBe("claude");
    });

    it("creates a new session with explicit backend type", () => {
      const session = sm.getOrCreateSession("s2", "codex");

      expect(session.backendType).toBe("codex");
      expect(session.state.backend_type).toBe("codex");
    });

    it("returns the same session on repeated calls with same ID", () => {
      const first = sm.getOrCreateSession("s1");
      const second = sm.getOrCreateSession("s1");

      expect(first).toBe(second);
    });

    it("initialises session with empty collections", () => {
      const session = sm.getOrCreateSession("s1");

      expect(session.cliSocket).toBeNull();
      expect(session.codexAdapter).toBeNull();
      expect(session.browserSockets.size).toBe(0);
      expect(session.messageHistory).toEqual([]);
      expect(session.pendingMessages).toEqual([]);
      expect(session.pendingPermissions.size).toBe(0);
      expect(session.pendingControlRequests.size).toBe(0);
      expect(session.eventBuffer).toEqual([]);
      expect(session.messageQueue).toEqual([]);
      expect(session.queuePaused).toBe(false);
    });

    it("initialises event sequencing fields", () => {
      const session = sm.getOrCreateSession("s1");

      expect(session.nextEventSeq).toBe(1);
      expect(session.lastAckSeq).toBe(0);
      expect(session.processedClientMessageIds).toEqual([]);
      expect(session.processedClientMessageIdSet.size).toBe(0);
    });

    it("initialises sent-message replay fields", () => {
      const session = sm.getOrCreateSession("s1");

      expect(session.sentMessageBuffer).toEqual([]);
      expect(session.sentMessageSeq).toBe(0);
      expect(session.cliMessageSeqMap.size).toBe(0);
      expect(session.keepAliveInterval).toBeNull();
    });

    it("updates backendType on existing session when explicitly provided", () => {
      const session = sm.getOrCreateSession("s1", "claude");
      expect(session.backendType).toBe("claude");

      sm.getOrCreateSession("s1", "codex");
      expect(session.backendType).toBe("codex");
      expect(session.state.backend_type).toBe("codex");
    });

    it("does not change backendType when called without explicit type on existing session", () => {
      sm.getOrCreateSession("s1", "codex");
      const session = sm.getOrCreateSession("s1");

      expect(session.backendType).toBe("codex");
    });
  });

  // ═══════════════════════════════════════════════════════════════════════
  // getSession
  // ═══════════════════════════════════════════════════════════════════════

  describe("getSession", () => {
    it("returns the session when it exists", () => {
      const created = sm.getOrCreateSession("s1");
      expect(sm.getSession("s1")).toBe(created);
    });

    it("returns undefined for an unknown session ID", () => {
      expect(sm.getSession("nonexistent")).toBeUndefined();
    });
  });

  // ═══════════════════════════════════════════════════════════════════════
  // getAllSessions / getAllSessionEntries
  // ═══════════════════════════════════════════════════════════════════════

  describe("getAllSessions", () => {
    it("returns empty array when no sessions exist", () => {
      expect(sm.getAllSessions()).toEqual([]);
    });

    it("returns state snapshots of all sessions", () => {
      sm.getOrCreateSession("a");
      sm.getOrCreateSession("b");

      const states = sm.getAllSessions();
      expect(states).toHaveLength(2);
      expect(states.map((s) => s.session_id).sort()).toEqual(["a", "b"]);
    });

    it("returns SessionState objects, not raw Session objects", () => {
      sm.getOrCreateSession("s1");
      const [state] = sm.getAllSessions();

      expect(state).toHaveProperty("session_id");
      expect(state).not.toHaveProperty("cliSocket");
    });
  });

  describe("getAllSessionEntries", () => {
    it("returns raw Session objects for shutdown cleanup", () => {
      sm.getOrCreateSession("s1");
      const entries = sm.getAllSessionEntries();

      expect(entries).toHaveLength(1);
      expect(entries[0]).toHaveProperty("cliSocket");
      expect(entries[0]).toHaveProperty("browserSockets");
    });
  });

  // ═══════════════════════════════════════════════════════════════════════
  // isCliConnected
  // ═══════════════════════════════════════════════════════════════════════

  describe("isCliConnected", () => {
    it("returns false for unknown session", () => {
      expect(sm.isCliConnected("unknown")).toBe(false);
    });

    it("returns false when session has no CLI socket (claude backend)", () => {
      sm.getOrCreateSession("s1", "claude");
      expect(sm.isCliConnected("s1")).toBe(false);
    });

    it("returns true when session has an active CLI socket", () => {
      const session = sm.getOrCreateSession("s1", "claude");
      session.cliSocket = {} as Session["cliSocket"];

      expect(sm.isCliConnected("s1")).toBe(true);
    });

    it("returns false for codex backend with no adapter", () => {
      sm.getOrCreateSession("s1", "codex");
      expect(sm.isCliConnected("s1")).toBe(false);
    });

    it("returns true for codex backend with connected adapter", () => {
      const session = sm.getOrCreateSession("s1", "codex");
      session.codexAdapter = { isConnected: () => true } as unknown as Session["codexAdapter"];

      expect(sm.isCliConnected("s1")).toBe(true);
    });

    it("returns false for codex backend with disconnected adapter", () => {
      const session = sm.getOrCreateSession("s1", "codex");
      session.codexAdapter = { isConnected: () => false } as unknown as Session["codexAdapter"];

      expect(sm.isCliConnected("s1")).toBe(false);
    });
  });

  // ═══════════════════════════════════════════════════════════════════════
  // removeSession
  // ═══════════════════════════════════════════════════════════════════════

  describe("removeSession", () => {
    it("removes a session from the in-memory map", () => {
      sm.getOrCreateSession("s1");
      sm.removeSession("s1");

      expect(sm.getSession("s1")).toBeUndefined();
    });

    it("clears auto-naming tracking for the session", () => {
      sm.getOrCreateSession("s1");
      sm.getAutoNamingAttempted().add("s1");

      sm.removeSession("s1");
      expect(sm.getAutoNamingAttempted().has("s1")).toBe(false);
    });

    it("calls store.remove when a store is attached", () => {
      const store = makeMockStore();
      sm.setStore(store as never);
      sm.getOrCreateSession("s1");

      sm.removeSession("s1");
      expect(store.remove).toHaveBeenCalledWith("s1");
    });

    it("does not throw when removing a nonexistent session", () => {
      expect(() => sm.removeSession("nonexistent")).not.toThrow();
    });

    it("clears git refresh debounce timers for the session", () => {
      const session = sm.getOrCreateSession("s1");

      // Trigger a git refresh then immediately call again to schedule a debounce timer
      void sm.refreshGitInfo(session);
      void sm.refreshGitInfo(session);

      sm.removeSession("s1");
      // Session should be fully cleaned up
      expect(sm.getSession("s1")).toBeUndefined();
    });
  });

  // ═══════════════════════════════════════════════════════════════════════
  // clearAll
  // ═══════════════════════════════════════════════════════════════════════

  describe("clearAll", () => {
    it("removes all sessions", () => {
      sm.getOrCreateSession("s1");
      sm.getOrCreateSession("s2");
      sm.getOrCreateSession("s3");

      sm.clearAll();
      expect(sm.getAllSessions()).toEqual([]);
    });

    it("clears auto-naming tracking", () => {
      sm.getOrCreateSession("s1");
      sm.getAutoNamingAttempted().add("s1");

      sm.clearAll();
      expect(sm.getAutoNamingAttempted().size).toBe(0);
    });
  });

  // ═══════════════════════════════════════════════════════════════════════
  // markContainerized
  // ═══════════════════════════════════════════════════════════════════════

  describe("markContainerized", () => {
    it("sets is_containerized and cwd on session state", () => {
      sm.getOrCreateSession("s1");
      sm.markContainerized("s1", "/home/user/project");

      const session = sm.getSession("s1")!;
      expect(session.state.is_containerized).toBe(true);
      expect(session.state.cwd).toBe("/home/user/project");
    });

    it("creates the session if it does not exist", () => {
      sm.markContainerized("new-session", "/host/cwd");

      const session = sm.getSession("new-session");
      expect(session).toBeDefined();
      expect(session!.state.is_containerized).toBe(true);
    });
  });

  // ═══════════════════════════════════════════════════════════════════════
  // appendToHistory
  // ═══════════════════════════════════════════════════════════════════════

  describe("appendToHistory", () => {
    it("appends messages to session history", () => {
      const session = sm.getOrCreateSession("s1");
      const msg = { type: "assistant" as const, content: "Hello" };

      sm.appendToHistory(session, msg as never);
      expect(session.messageHistory).toHaveLength(1);
    });

    it("trims oldest messages when exceeding MAX_MESSAGE_HISTORY_SIZE", () => {
      const session = sm.getOrCreateSession("s1");
      const maxSize = SessionManager.MAX_MESSAGE_HISTORY_SIZE;

      // Fill history to the cap
      for (let i = 0; i < maxSize; i++) {
        sm.appendToHistory(session, { type: "assistant", content: `msg-${i}` } as never);
      }
      expect(session.messageHistory).toHaveLength(maxSize);

      // Add one more — should trim the oldest
      sm.appendToHistory(session, { type: "assistant", content: "overflow" } as never);
      expect(session.messageHistory).toHaveLength(maxSize);

      // First message should be msg-1 (msg-0 was trimmed)
      expect((session.messageHistory[0] as { content: string }).content).toBe("msg-1");
      // Last should be the overflow message
      expect(
        (session.messageHistory[session.messageHistory.length - 1] as { content: string }).content,
      ).toBe("overflow");
    });
  });

  // ═══════════════════════════════════════════════════════════════════════
  // nextUserMsgId
  // ═══════════════════════════════════════════════════════════════════════

  describe("nextUserMsgId", () => {
    it("returns incrementing IDs starting from 0", () => {
      expect(sm.nextUserMsgId()).toBe(0);
      expect(sm.nextUserMsgId()).toBe(1);
      expect(sm.nextUserMsgId()).toBe(2);
    });
  });

  // Remaining tests are in the second half of this describe block
  // (callback registration, persistence, git refresh, lifecycle)

  // ═══════════════════════════════════════════════════════════════════════
  // Callback registration
  // ═══════════════════════════════════════════════════════════════════════

  describe("callback registration", () => {
    it("registers and retrieves onCLISessionIdReceived callback", () => {
      const cb = vi.fn();
      sm.onCLISessionIdReceived(cb);
      expect(sm.getOnCLISessionId()).toBe(cb);
    });

    it("registers and retrieves onFirstTurnCompletedCallback", () => {
      const cb = vi.fn();
      sm.onFirstTurnCompletedCallback(cb);
      expect(sm.getOnFirstTurnCompleted()).toBe(cb);
    });

    it("registers and retrieves onCLIRelaunchNeededCallback", () => {
      const cb = vi.fn();
      sm.onCLIRelaunchNeededCallback(cb);
      expect(sm.getOnCLIRelaunchNeeded()).toBe(cb);
    });

    it("returns null for unregistered callbacks", () => {
      expect(sm.getOnCLISessionId()).toBeNull();
      expect(sm.getOnFirstTurnCompleted()).toBeNull();
      expect(sm.getOnCLIRelaunchNeeded()).toBeNull();
    });
  });

  // ═══════════════════════════════════════════════════════════════════════
  // Persistence — persistSession
  // ═══════════════════════════════════════════════════════════════════════

  describe("persistSession", () => {
    it("calls store.save with serialised session data", () => {
      const store = makeMockStore();
      sm.setStore(store as never);
      const session = sm.getOrCreateSession("s1");

      sm.persistSession(session);

      expect(store.save).toHaveBeenCalledOnce();
      const saved = store.save.mock.calls[0][0] as Record<string, unknown>;
      expect(saved).toHaveProperty("id", "s1");
      expect(saved).toHaveProperty("state");
      expect(saved).toHaveProperty("messageHistory");
      expect(saved).toHaveProperty("pendingPermissions");
    });

    it("converts pendingPermissions Map to entries array", () => {
      const store = makeMockStore();
      sm.setStore(store as never);
      const session = sm.getOrCreateSession("s1");
      session.pendingPermissions.set("perm-1", { id: "perm-1" } as never);

      sm.persistSession(session);

      const saved = store.save.mock.calls[0][0] as Record<string, unknown>;
      expect(saved.pendingPermissions).toEqual([["perm-1", { id: "perm-1" }]]);
    });

    it("does nothing when no store is attached", () => {
      const session = sm.getOrCreateSession("s1");
      expect(() => sm.persistSession(session)).not.toThrow();
    });
  });

  // ═══════════════════════════════════════════════════════════════════════
  // Persistence — restoreFromDisk
  // ═══════════════════════════════════════════════════════════════════════

  describe("restoreFromDisk", () => {
    it("returns 0 when no store is attached", async () => {
      const count = await sm.restoreFromDisk();
      expect(count).toBe(0);
    });

    it("returns 0 when store has no persisted sessions", async () => {
      const store = makeMockStore();
      sm.setStore(store as never);

      const count = await sm.restoreFromDisk();
      expect(count).toBe(0);
    });

    it("restores a session from persisted data", async () => {
      const store = makeMockStore();
      store.loadAll.mockResolvedValue([
        {
          id: "restored-1",
          state: {
            session_id: "restored-1",
            backend_type: "claude",
            num_turns: 0,
          } as SessionState,
          messageHistory: [{ type: "assistant", content: "Hi" }] as never[],
          pendingMessages: ["pending-msg"],
          pendingPermissions: [],
        },
      ]);
      sm.setStore(store as never);

      const count = await sm.restoreFromDisk();

      expect(count).toBe(1);
      const session = sm.getSession("restored-1");
      expect(session).toBeDefined();
      expect(session!.messageHistory).toHaveLength(1);
      expect(session!.pendingMessages).toEqual(["pending-msg"]);
    });

    it("does not overwrite sessions that already exist in memory", async () => {
      const existingSession = sm.getOrCreateSession("s1");
      existingSession.state.cwd = "/already/in/memory";

      const store = makeMockStore();
      store.loadAll.mockResolvedValue([
        {
          id: "s1",
          state: { session_id: "s1", backend_type: "claude", cwd: "/from/disk" } as SessionState,
          messageHistory: [],
          pendingMessages: [],
          pendingPermissions: [],
        },
      ]);
      sm.setStore(store as never);

      await sm.restoreFromDisk();

      expect(sm.getSession("s1")!.state.cwd).toBe("/already/in/memory");
    });

    it("marks auto-naming as attempted for restored sessions with turns > 0", async () => {
      const store = makeMockStore();
      store.loadAll.mockResolvedValue([
        {
          id: "turned",
          state: { session_id: "turned", backend_type: "claude", num_turns: 3 } as SessionState,
          messageHistory: [],
          pendingMessages: [],
          pendingPermissions: [],
        },
      ]);
      sm.setStore(store as never);

      await sm.restoreFromDisk();

      expect(sm.getAutoNamingAttempted().has("turned")).toBe(true);
    });

    it("does not mark auto-naming for restored sessions with 0 turns", async () => {
      const store = makeMockStore();
      store.loadAll.mockResolvedValue([
        {
          id: "fresh",
          state: { session_id: "fresh", backend_type: "claude", num_turns: 0 } as SessionState,
          messageHistory: [],
          pendingMessages: [],
          pendingPermissions: [],
        },
      ]);
      sm.setStore(store as never);

      await sm.restoreFromDisk();

      expect(sm.getAutoNamingAttempted().has("fresh")).toBe(false);
    });

    it("calls resolveSessionGitInfo for each restored session", async () => {
      const store = makeMockStore();
      store.loadAll.mockResolvedValue([
        {
          id: "git-1",
          state: { session_id: "git-1", backend_type: "claude", num_turns: 0 } as SessionState,
          messageHistory: [],
          pendingMessages: [],
          pendingPermissions: [],
        },
        {
          id: "git-2",
          state: { session_id: "git-2", backend_type: "claude", num_turns: 0 } as SessionState,
          messageHistory: [],
          pendingMessages: [],
          pendingPermissions: [],
        },
      ]);
      sm.setStore(store as never);

      await sm.restoreFromDisk();

      expect(mockResolveSessionGitInfo).toHaveBeenCalledTimes(2);
      expect(mockResolveSessionGitInfo).toHaveBeenCalledWith("git-1", expect.any(Object));
      expect(mockResolveSessionGitInfo).toHaveBeenCalledWith("git-2", expect.any(Object));
    });

    it("restores pendingPermissions as a Map", async () => {
      const store = makeMockStore();
      store.loadAll.mockResolvedValue([
        {
          id: "perms",
          state: { session_id: "perms", backend_type: "claude", num_turns: 0 } as SessionState,
          messageHistory: [],
          pendingMessages: [],
          pendingPermissions: [["perm-abc", { id: "perm-abc", tool: "bash" }]],
        },
      ]);
      sm.setStore(store as never);

      await sm.restoreFromDisk();

      const session = sm.getSession("perms")!;
      expect(session.pendingPermissions).toBeInstanceOf(Map);
      expect(session.pendingPermissions.get("perm-abc")).toEqual({ id: "perm-abc", tool: "bash" });
    });

    it("defaults optional fields when not present in persisted data", async () => {
      const store = makeMockStore();
      store.loadAll.mockResolvedValue([
        {
          id: "minimal",
          state: { session_id: "minimal", backend_type: "claude", num_turns: 0 } as SessionState,
          messageHistory: [],
          pendingMessages: [],
          pendingPermissions: [],
        },
      ]);
      sm.setStore(store as never);

      await sm.restoreFromDisk();

      const session = sm.getSession("minimal")!;
      expect(session.nextEventSeq).toBe(1);
      expect(session.eventBuffer).toEqual([]);
      expect(session.lastAckSeq).toBe(0);
      expect(session.processedClientMessageIds).toEqual([]);
      expect(session.messageQueue).toEqual([]);
      expect(session.queuePaused).toBe(false);
    });
  });

  // ═══════════════════════════════════════════════════════════════════════
  // Git info refresh — debounce behaviour
  // ═══════════════════════════════════════════════════════════════════════

  describe("refreshGitInfo", () => {
    it("executes immediately on first call (leading edge)", async () => {
      const session = sm.getOrCreateSession("s1");

      await sm.refreshGitInfo(session);

      expect(mockResolveSessionGitInfo).toHaveBeenCalledOnce();
      expect(mockResolveSessionGitInfo).toHaveBeenCalledWith("s1", session.state);
    });

    it("debounces rapid consecutive calls within the window", async () => {
      const session = sm.getOrCreateSession("s1");

      // First call — executes immediately
      await sm.refreshGitInfo(session);
      expect(mockResolveSessionGitInfo).toHaveBeenCalledTimes(1);

      // Second call within debounce window — should be deferred
      await sm.refreshGitInfo(session);
      expect(mockResolveSessionGitInfo).toHaveBeenCalledTimes(1);

      // Advance past the debounce window
      await vi.advanceTimersByTimeAsync(5_000);
      expect(mockResolveSessionGitInfo).toHaveBeenCalledTimes(2);
    });

    it("fires trailing-edge call after debounce window elapses", async () => {
      const session = sm.getOrCreateSession("s1");

      await sm.refreshGitInfo(session);
      mockResolveSessionGitInfo.mockClear();

      // Trigger a deferred call
      await sm.refreshGitInfo(session);
      expect(mockResolveSessionGitInfo).not.toHaveBeenCalled();

      // Advance to just before debounce expires
      await vi.advanceTimersByTimeAsync(4_999);
      expect(mockResolveSessionGitInfo).not.toHaveBeenCalled();

      // Advance past debounce
      await vi.advanceTimersByTimeAsync(1);
      expect(mockResolveSessionGitInfo).toHaveBeenCalledOnce();
    });

    it("broadcasts session_update when git info changes", async () => {
      const session = sm.getOrCreateSession("s1");
      const broadcastFn = vi.fn();
      sm.setBroadcastFn(broadcastFn);

      mockResolveSessionGitInfo.mockImplementation(async (_id, _state) => {
        session.state.git_branch = "feature/new";
      });

      await sm.refreshGitInfo(session, { broadcastUpdate: true });

      expect(broadcastFn).toHaveBeenCalledOnce();
      expect(broadcastFn).toHaveBeenCalledWith(session, expect.objectContaining({
        type: "session_update",
        session: expect.objectContaining({ git_branch: "feature/new" }),
      }));
    });

    it("does not broadcast when git info has not changed", async () => {
      const session = sm.getOrCreateSession("s1");
      const broadcastFn = vi.fn();
      sm.setBroadcastFn(broadcastFn);

      await sm.refreshGitInfo(session, { broadcastUpdate: true });

      expect(broadcastFn).not.toHaveBeenCalled();
    });

    it("persists session when git info changes", async () => {
      const store = makeMockStore();
      sm.setStore(store as never);
      const session = sm.getOrCreateSession("s1");

      mockResolveSessionGitInfo.mockImplementation(async (_id, _state) => {
        session.state.git_branch = "main";
      });

      await sm.refreshGitInfo(session);

      expect(store.save).toHaveBeenCalled();
    });

    it("calls onGitInfoReady callback when notifyPoller is true and branch is known", async () => {
      const session = sm.getOrCreateSession("s1");
      session.state.cwd = "/projects/repo";
      const gitCb = vi.fn();
      sm.onSessionGitInfoReadyCallback(gitCb);

      mockResolveSessionGitInfo.mockImplementation(async (_id, _state) => {
        session.state.git_branch = "main";
      });

      await sm.refreshGitInfo(session, { notifyPoller: true });

      expect(gitCb).toHaveBeenCalledWith("s1", "/projects/repo", "main");
    });

    it("does not call onGitInfoReady when branch is empty", async () => {
      const session = sm.getOrCreateSession("s1");
      session.state.cwd = "/projects/repo";
      const gitCb = vi.fn();
      sm.onSessionGitInfoReadyCallback(gitCb);

      await sm.refreshGitInfo(session, { notifyPoller: true });

      expect(gitCb).not.toHaveBeenCalled();
    });

    it("does not call onGitInfoReady when cwd is empty", async () => {
      const session = sm.getOrCreateSession("s1");
      const gitCb = vi.fn();
      sm.onSessionGitInfoReadyCallback(gitCb);

      mockResolveSessionGitInfo.mockImplementation(async (_id, _state) => {
        session.state.git_branch = "main";
      });

      await sm.refreshGitInfo(session, { notifyPoller: true });

      expect(gitCb).not.toHaveBeenCalled();
    });

    it("executes immediately again after the debounce window fully elapses", async () => {
      const session = sm.getOrCreateSession("s1");

      await sm.refreshGitInfo(session);
      expect(mockResolveSessionGitInfo).toHaveBeenCalledTimes(1);

      // Advance past debounce window
      await vi.advanceTimersByTimeAsync(5_000);

      await sm.refreshGitInfo(session);
      expect(mockResolveSessionGitInfo).toHaveBeenCalledTimes(2);
    });
  });

  // ═══════════════════════════════════════════════════════════════════════
  // clearGitRefreshDebounce
  // ═══════════════════════════════════════════════════════════════════════

  describe("clearGitRefreshDebounce", () => {
    it("clears pending debounce timer for a session", async () => {
      const session = sm.getOrCreateSession("s1");

      await sm.refreshGitInfo(session);
      mockResolveSessionGitInfo.mockClear();
      await sm.refreshGitInfo(session);

      sm.clearGitRefreshDebounce("s1");

      await vi.advanceTimersByTimeAsync(10_000);
      expect(mockResolveSessionGitInfo).not.toHaveBeenCalled();
    });

    it("does not throw for a session with no debounce state", () => {
      expect(() => sm.clearGitRefreshDebounce("nonexistent")).not.toThrow();
    });
  });

  // ═══════════════════════════════════════════════════════════════════════
  // Dependency injection
  // ═══════════════════════════════════════════════════════════════════════

  describe("dependency injection", () => {
    it("setBroadcastFn stores the function for later use", async () => {
      const broadcastFn = vi.fn();
      sm.setBroadcastFn(broadcastFn);
      const session = sm.getOrCreateSession("s1");

      mockResolveSessionGitInfo.mockImplementation(async (_id, _state) => {
        session.state.git_branch = "develop";
      });

      await sm.refreshGitInfo(session, { broadcastUpdate: true });
      expect(broadcastFn).toHaveBeenCalled();
    });

    it("setStore enables persistence operations", () => {
      const store = makeMockStore();
      sm.setStore(store as never);
      const session = sm.getOrCreateSession("s1");

      sm.persistSession(session);
      expect(store.save).toHaveBeenCalledOnce();
    });
  });

  // ═══════════════════════════════════════════════════════════════════════
  // getCodexRateLimits
  // ═══════════════════════════════════════════════════════════════════════

  describe("getCodexRateLimits", () => {
    it("returns null for unknown session", () => {
      expect(sm.getCodexRateLimits("unknown")).toBeNull();
    });

    it("returns null when session has no codexAdapter", () => {
      sm.getOrCreateSession("s1");
      expect(sm.getCodexRateLimits("s1")).toBeNull();
    });

    it("returns rate limits from the codex adapter", () => {
      const session = sm.getOrCreateSession("s1", "codex");
      const mockLimits = { remaining: 10, total: 100 };
      session.codexAdapter = {
        getRateLimits: () => mockLimits,
        isConnected: () => true,
      } as unknown as Session["codexAdapter"];

      expect(sm.getCodexRateLimits("s1")).toEqual(mockLimits);
    });
  });

  // ═══════════════════════════════════════════════════════════════════════
  // Session lifecycle integration
  // ═══════════════════════════════════════════════════════════════════════

  describe("session lifecycle", () => {
    it("simulates: create → connect CLI → messages → disconnect → remove", () => {
      const session = sm.getOrCreateSession("lifecycle-1");
      expect(sm.isCliConnected("lifecycle-1")).toBe(false);

      session.cliSocket = {} as Session["cliSocket"];
      expect(sm.isCliConnected("lifecycle-1")).toBe(true);

      sm.appendToHistory(session, { type: "user", content: "Hello" } as never);
      sm.appendToHistory(session, { type: "assistant", content: "Hi there" } as never);
      expect(session.messageHistory).toHaveLength(2);

      session.cliSocket = null;
      expect(sm.isCliConnected("lifecycle-1")).toBe(false);

      expect(sm.getSession("lifecycle-1")).toBeDefined();

      sm.removeSession("lifecycle-1");
      expect(sm.getSession("lifecycle-1")).toBeUndefined();
      expect(sm.getAllSessions()).toHaveLength(0);
    });

    it("manages multiple concurrent sessions independently", () => {
      const s1 = sm.getOrCreateSession("multi-1");
      const s2 = sm.getOrCreateSession("multi-2", "codex");

      s1.cliSocket = {} as Session["cliSocket"];
      s2.codexAdapter = { isConnected: () => true } as unknown as Session["codexAdapter"];

      expect(sm.isCliConnected("multi-1")).toBe(true);
      expect(sm.isCliConnected("multi-2")).toBe(true);
      expect(sm.getAllSessions()).toHaveLength(2);

      sm.removeSession("multi-1");
      expect(sm.getAllSessions()).toHaveLength(1);
      expect(sm.isCliConnected("multi-1")).toBe(false);
      expect(sm.isCliConnected("multi-2")).toBe(true);
    });
  });

  // ═══════════════════════════════════════════════════════════════════════
  // getAutoNamingAttempted
  // ═══════════════════════════════════════════════════════════════════════

  describe("getAutoNamingAttempted", () => {
    it("returns a mutable Set that tracks naming attempts", () => {
      const attempted = sm.getAutoNamingAttempted();
      expect(attempted).toBeInstanceOf(Set);
      expect(attempted.size).toBe(0);

      attempted.add("s1");
      expect(sm.getAutoNamingAttempted().has("s1")).toBe(true);
    });
  });
});
