/**
 * Session title generation unit tests: prompt shape, sanitization rules, single-shot request
 * driving (fake LLM), and Session.generateTitle's composition-layer wiring (no real requests sent).
 */
import { describe, it, expect } from "vitest";
import {
  assistantText,
  emptyTokenCounts,
  sanitizeTitle,
  Session,
  thinkingMessage,
  tokenUsage,
  userText,
} from "../src/index.js";
// Prompt/request internals are no longer exported via the barrel: imported directly from the internal module.
import { buildTitlePrompt, generateTitleWithLLM } from "../src/internal/session-title.js";
import type {
  EnvironmentInterface,
  LLMInterface,
  LLMOutcome,
  OmniMessage,
  SessionMetaPayload,
} from "../src/index.js";

/** A fake LLM: yields the given messages and finishes with the outcome; records the prompt received. */
function fakeLLM(
  outputs: OmniMessage[],
  outcome: LLMOutcome = { status: "completed" },
  seenPrompts: string[] = [],
): LLMInterface {
  return {
    async *streamGenerate({ newMessages }) {
      const first = newMessages[0];
      if (first) seenPrompts.push((first.payload as { text: string }).text);
      for (const msg of outputs) yield msg;
      return outcome;
    },
  };
}

const fakeEnvironment: EnvironmentInterface = {
  listTools: async () => [],
  // eslint-disable-next-line require-yield
  executeTool: async function* () {
    throw new Error("not used");
  },
  toolPermission: () => undefined,
};

const META: SessionMetaPayload = {
  session_id: "session-title-1",
  provider: "custom",
  model_id: "m1",
  model_context_window: 1000,
  system_prompt: "sp",
  tools: [],
  agent_state: "/tmp/state",
  workspace: "/tmp/w",
};

/** Image-fold wiring every Session takes; these tests send no images, so it is never exercised. */
const IMAGES = { imagesDir: "/tmp/scratchpad/session-title-1", modelHasVision: true } as const;

describe("session-title", () => {
  it("generateTitleWithLLM: collects model text and usage, returns the sanitized result", async () => {
    const seen: string[] = [];
    const result = await generateTitleWithLLM(
      fakeLLM(
        [
          thinkingMessage("thinking"), // thinking does not count
          assistantText("「Tailwind theme setup」。"),
          tokenUsage(emptyTokenCounts(), { cache_read: 1, cache_write: 2, output: 3, total: 6 }),
        ],
        { status: "completed" },
        seen,
      ),
      { userText: "explain @theme", assistantText: "sure thing." },
    );
    expect(result.title).toBe("Tailwind theme setup");
    expect(result.usage).toEqual({ cache_read: 1, cache_write: 2, output: 3, total: 6 });
    expect(seen[0]).toBe(buildTitlePrompt("explain @theme", "sure thing."));
    expect(seen[0]).toContain("SAME language");
  });

  it("sends no request when material is empty; title is null when the outcome is not completed (usage kept)", async () => {
    const seen: string[] = [];
    const empty = await generateTitleWithLLM(fakeLLM([], { status: "completed" }, seen), {
      userText: "  ",
      assistantText: "a",
    });
    expect(empty).toEqual({ title: null, usage: null });
    expect(seen).toHaveLength(0);

    const failed = await generateTitleWithLLM(
      fakeLLM(
        [
          assistantText("partial"),
          tokenUsage(emptyTokenCounts(), { cache_read: 0, cache_write: 0, output: 1, total: 1 }),
        ],
        { status: "failed", errorMessage: "401" },
      ),
      { userText: "u", assistantText: "a" },
    );
    expect(failed.title).toBeNull();
    expect(failed.usage?.total).toBe(1);
  });

  it("still generates with empty assistant material (tool-only turn): uses only the user request, prompt omits the assistant section", async () => {
    const seen: string[] = [];
    const result = await generateTitleWithLLM(
      fakeLLM([assistantText("Configure the Tailwind theme")], { status: "completed" }, seen),
      { userText: "help me configure @theme", assistantText: "" },
    );
    expect(result.title).toBe("Configure the Tailwind theme");
    expect(seen[0]).toBe(buildTitlePrompt("help me configure @theme", ""));
    expect(seen[0]).not.toContain("[Assistant]");
  });

  it("sanitizeTitle: strips quotes/punctuation to a fixed point, collapses whitespace, truncates overlong input, returns null for empty", () => {
    expect(sanitizeTitle("“ Build config notes 。”")).toBe("Build config notes");
    expect(sanitizeTitle("『Title』！")).toBe("Title");
    expect(sanitizeTitle("  \n ")).toBeNull();
    expect(sanitizeTitle("x".repeat(50))).toHaveLength(30);
    // A leaked [use_skills] block is stripped from the model output.
    expect(
      sanitizeTitle("[use_skills]\nskills: web-design\n[/use_skills]\nBuild a landing page"),
    ).toBe("Build a landing page");
  });

  it("Session.generateTitle: sends via createBareLLM; returns null when no factory is provided", async () => {
    const withFactory = new Session({
      meta: META,
      ...IMAGES,
      llm: fakeLLM([]),
      environment: fakeEnvironment,
      createBareLLM: () => fakeLLM([assistantText("Title A")]),
    });
    expect(
      await withFactory.generateTitle({ material: { userText: "u", assistantText: "a" } }),
    ).toEqual({
      title: "Title A",
      usage: null,
    });

    const withoutFactory = new Session({
      meta: META,
      ...IMAGES,
      llm: fakeLLM([]),
      environment: fakeEnvironment,
    });
    expect(await withoutFactory.generateTitle()).toEqual({
      title: null,
      usage: null,
    });
  });

  it("Session.generateTitle: self-collects material (run gathers the user input and model text), none needed from the caller", async () => {
    const seen: string[] = [];
    const session = new Session({
      meta: META,
      ...IMAGES,
      llm: fakeLLM([thinkingMessage("thinking"), assistantText("answer body")]),
      environment: fakeEnvironment,
      createBareLLM: () => fakeLLM([assistantText("Title B")], { status: "completed" }, seen),
    });
    for await (const _ of session.run([userText("user question")])) {
      void _; // Drains the output stream; once run finishes, the material is settled
    }
    const res = await session.generateTitle();
    expect(res.title).toBe("Title B");
    // Material = the first Task's user text + model text (thinking does not count), matching
    // buildTitlePrompt's shape.
    expect(seen[0]).toBe(buildTitlePrompt("user question", "answer body"));
    // Anti-CoT shape: an explicit no-thinking rule, and the prompt ends with an empty think
    // block so reasoning models treat their thinking phase as already closed.
    expect(seen[0]).toContain("do not think aloud");
    expect(seen[0]!.endsWith("<think></think>")).toBe(true);

    // No request is sent when no material has been collected (run was never called).
    const idle = new Session({
      meta: META,
      ...IMAGES,
      llm: fakeLLM([]),
      environment: fakeEnvironment,
      createBareLLM: () => fakeLLM([assistantText("must not be produced")]),
    });
    expect(await idle.generateTitle()).toEqual({ title: null, usage: null });
  });
});
