/**
 * Tests for SessionManager title derivation.
 *
 * The title comes from the *first* user-submitted instruction (the UTF-8
 * decoded bytes of a terminal.input call with activity='submit'), is trimmed
 * and truncated to SESSION_TITLE_MAX_LENGTH (10), and is assigned at most
 * once per session.
 */

import type { ProviderDefinition, Session } from "@coder-studio/core";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SessionManager, type SessionManagerDeps } from "../session/manager.js";
import type { SessionDatabase } from "../session/types.js";
import type { ProviderConfigRepo } from "../storage/repositories/provider-config-repo.js";
import type { TerminalManager } from "../terminal/manager.js";
import type { Broadcaster } from "../ws/hub.js";

type MutableSessionManager = SessionManager & {
  sessions: Map<string, Session & { state: string }>;
};

const providerConfigRepoStub = {
  get: vi.fn(() => undefined),
} as unknown as ProviderConfigRepo;

const createProvider = (): ProviderDefinition =>
  ({
    id: "test-provider",
    displayName: "Test Provider",
    capability: "full",
    buildCommand: () => ({ argv: ["test"], cwd: "/test" }),
  }) as ProviderDefinition;

describe("SessionManager title derivation", () => {
  let sessionMgr: SessionManager;
  let mockDb: {
    insert: ReturnType<typeof vi.fn>;
    update: ReturnType<typeof vi.fn>;
    findById: ReturnType<typeof vi.fn>;
    findByWorkspaceId: ReturnType<typeof vi.fn>;
    delete: ReturnType<typeof vi.fn>;
  };
  let mockEventBus: {
    emit: ReturnType<typeof vi.fn>;
    on: ReturnType<typeof vi.fn>;
  };
  let mockTerminalMgr: {
    create: ReturnType<typeof vi.fn>;
    kill: ReturnType<typeof vi.fn>;
    close: ReturnType<typeof vi.fn>;
  };

  const broadcaster = {} as Broadcaster;

  beforeEach(() => {
    vi.clearAllMocks();

    mockDb = {
      insert: vi.fn(),
      update: vi.fn(),
      findById: vi.fn(),
      findByWorkspaceId: vi.fn(),
      delete: vi.fn(),
    };

    mockEventBus = {
      emit: vi.fn(),
      on: vi.fn(() => vi.fn()),
    };

    mockTerminalMgr = {
      create: vi.fn().mockReturnValue({
        id: "terminal-1",
        workspaceId: "ws-1",
        kind: "agent",
      }),
      kill: vi.fn(),
      close: vi.fn().mockResolvedValue(undefined),
    };

    const deps: SessionManagerDeps = {
      terminalMgr: mockTerminalMgr as unknown as TerminalManager,
      eventBus: mockEventBus as unknown as SessionManagerDeps["eventBus"],
      db: mockDb as unknown as SessionDatabase,
      broadcaster,
      providerRegistry: [],
      providerConfigRepo: providerConfigRepoStub,
    };

    sessionMgr = new SessionManager(deps);
  });

  async function createSession() {
    const session = await sessionMgr.create({
      workspaceId: "ws-1",
      workspacePath: "/test/path",
      providerId: "test-provider",
      provider: createProvider(),
    });
    // Reset mocks so assertions only see post-create calls.
    mockDb.update.mockClear();
    mockEventBus.emit.mockClear();
    return session;
  }

  it("captures the first submitted instruction as the session title", async () => {
    const session = await createSession();

    sessionMgr.onTerminalInput("terminal-1", "submit", "fix the build\n");

    expect(mockDb.update).toHaveBeenCalledWith(session.id, {
      title: "fix the b…",
      firstSubmittedUserInput: "fix the build",
    });
    expect(sessionMgr.get(session.id)?.title).toBe("fix the b…");
    expect(sessionMgr.get(session.id)?.firstSubmittedUserInput).toBe("fix the build");
  });

  it("stores the full normalized first submitted input alongside the compact title", async () => {
    const session = await createSession();

    sessionMgr.onTerminalInput("terminal-1", "submit", "  hello   world this is a test\n");

    expect(mockDb.update).toHaveBeenCalledWith(session.id, {
      title: "hello wor…",
      firstSubmittedUserInput: "hello world this is a test",
    });
    expect(sessionMgr.get(session.id)).toMatchObject({
      title: "hello wor…",
      firstSubmittedUserInput: "hello world this is a test",
    });
  });

  it("uses the raw text when it already fits in 10 chars", async () => {
    const session = await createSession();

    sessionMgr.onTerminalInput("terminal-1", "submit", "hi there\n");

    expect(sessionMgr.get(session.id)?.title).toBe("hi there");
  });

  it("collapses whitespace and trims before truncating", async () => {
    const session = await createSession();

    sessionMgr.onTerminalInput("terminal-1", "submit", "   hello   world  \n");

    // "hello world" is 11 chars, so it truncates to 9 chars + ellipsis.
    expect(sessionMgr.get(session.id)?.title).toBe("hello wor…");
  });

  it("does not overwrite an already-assigned title on later submits", async () => {
    const session = await createSession();

    sessionMgr.onTerminalInput("terminal-1", "submit", "first message\n");
    const firstTitle = sessionMgr.get(session.id)?.title;
    expect(firstTitle).toBeDefined();

    mockDb.update.mockClear();
    sessionMgr.onTerminalInput("terminal-1", "submit", "second message\n");

    // Nothing about the title should have been written to the DB.
    const updateCalls = mockDb.update.mock.calls;
    for (const [, patch] of updateCalls) {
      expect(patch).not.toHaveProperty("title");
      expect(patch).not.toHaveProperty("firstSubmittedUserInput");
    }
    expect(sessionMgr.get(session.id)?.title).toBe(firstTitle);
    expect(sessionMgr.get(session.id)?.firstSubmittedUserInput).toBe("first message");
  });

  it("ignores non-submit activity for title derivation", async () => {
    const session = await createSession();

    sessionMgr.onTerminalInput("terminal-1", "typing", "keypress");
    sessionMgr.onTerminalInput("terminal-1", "control", "ping");
    sessionMgr.onTerminalInput("terminal-1", "internal_submit", "ping");

    expect(sessionMgr.get(session.id)?.title).toBeUndefined();
    const updateCalls = mockDb.update.mock.calls;
    for (const [, patch] of updateCalls) {
      expect(patch).not.toHaveProperty("title");
    }
  });

  it("ignores empty/whitespace-only submits", async () => {
    const session = await createSession();

    sessionMgr.onTerminalInput("terminal-1", "submit", "   \n\t  ");

    expect(sessionMgr.get(session.id)?.title).toBeUndefined();
    const updateCalls = mockDb.update.mock.calls;
    for (const [, patch] of updateCalls) {
      expect(patch).not.toHaveProperty("title");
    }
  });

  it("derives the title from submitted text instead of terminal buffer state", async () => {
    const session = await createSession();

    sessionMgr.onTerminalInput("terminal-1", "submit", "new prompt\n");

    expect(sessionMgr.get(session.id)?.title).toBe("new prompt");
    expect(mockDb.update).toHaveBeenCalledWith(session.id, {
      title: "new prompt",
      firstSubmittedUserInput: "new prompt",
    });
  });

  it("broadcasts state.changed so clients pick up the new title", async () => {
    const session = await createSession();

    // Put the session into 'running' so onTerminalInput won't also flip state.
    const internal = (sessionMgr as MutableSessionManager).sessions.get(session.id);
    internal.state = "running";

    sessionMgr.onTerminalInput("terminal-1", "submit", "hello world hi\n");

    // Exactly one state.changed should have fired with the title attached.
    const stateEvents = mockEventBus.emit.mock.calls
      .map((args) => args[0])
      .filter((ev) => ev.type === "session.state.changed");
    expect(stateEvents).toHaveLength(1);
    expect(stateEvents[0]).toMatchObject({
      sessionId: session.id,
      from: "running",
      to: "running",
      session: expect.objectContaining({
        title: "hello wor…",
        firstSubmittedUserInput: "hello world hi",
      }),
    });
  });

  it("still flips idle -> running and records the title in one pass", async () => {
    const session = await createSession();
    const internal = (sessionMgr as MutableSessionManager).sessions.get(session.id);
    internal.state = "idle";

    sessionMgr.onTerminalInput("terminal-1", "submit", "run tests\n");

    const stateEvents = mockEventBus.emit.mock.calls
      .map((args) => args[0])
      .filter((ev) => ev.type === "session.state.changed");
    expect(stateEvents).toHaveLength(1);
    expect(stateEvents[0]).toMatchObject({
      from: "idle",
      to: "running",
      session: expect.objectContaining({ title: "run tests" }),
    });
  });
});
