import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { StreamingResponder } from "./streaming-responder.ts";
import type { AgentEvent } from "./types.ts";

const startEvent: AgentEvent = { type: "system", subtype: "status", message: "processing" };

function textDelta(text: string): AgentEvent {
  return {
    type: "stream_event",
    // Shape the responder unwraps: { type: "stream_event", event: { type: "content_block_delta", delta } }
    event: {
      type: "stream_event",
      event: { type: "content_block_delta", delta: { type: "text_delta", text } },
    },
  } as unknown as AgentEvent;
}

function toolUse(name: string): AgentEvent {
  return { type: "tool_use_summary", tool_name: name };
}

function deferred<T>(): { promise: Promise<T>; resolve: (v: T) => void } {
  let resolve!: (v: T) => void;
  const promise = new Promise<T>((r) => {
    resolve = r;
  });
  return { promise, resolve };
}

describe("StreamingResponder", () => {
  beforeEach(() => vi.useFakeTimers());
  afterEach(() => vi.useRealTimers());

  it("uses native assistant status, then posts the reply as one message", async () => {
    const post = vi.fn(async () => "1700.1");
    const update = vi.fn(async () => {});
    const setStatus = vi.fn(async () => {});
    const r = new StreamingResponder(post, update, undefined, setStatus, 50);

    r.handleEvent(startEvent);
    await vi.advanceTimersByTimeAsync(0);

    // Thinking status shown, no placeholder message posted.
    expect(setStatus).toHaveBeenCalledWith("is thinking…");
    expect(post).not.toHaveBeenCalled();

    // A tool before any text updates the status, still no message.
    r.handleEvent(toolUse("WebSearch"));
    await vi.advanceTimersByTimeAsync(0);
    expect(setStatus).toHaveBeenCalledWith("is using WebSearch…");
    expect(post).not.toHaveBeenCalled();

    // Text arrives -> first flush posts a real message (which clears the status).
    r.handleEvent(textDelta("Hello"));
    await vi.advanceTimersByTimeAsync(60);
    expect(post).toHaveBeenCalledTimes(1);
    expect(post).toHaveBeenCalledWith("Hello");

    const handled = await r.finalize("Hello there");
    expect(handled).toBe(true);
    expect(update).toHaveBeenCalledWith("1700.1", "Hello there");
  });

  it("posts the final text directly when the reply beats the flush tick", async () => {
    const post = vi.fn(async () => "1700.2");
    const update = vi.fn(async () => {});
    const setStatus = vi.fn(async () => {});
    const r = new StreamingResponder(post, update, undefined, setStatus, 5000);

    r.handleEvent(startEvent);
    await vi.advanceTimersByTimeAsync(0);
    expect(setStatus).toHaveBeenCalledWith("is thinking…");

    // No stream flush happened; finalize must post the full reply.
    const handled = await r.finalize("Quick answer");
    expect(handled).toBe(true);
    expect(post).toHaveBeenCalledWith("Quick answer");
    expect(update).not.toHaveBeenCalled();
  });

  it("falls back to a placeholder message when setStatus rejects", async () => {
    const post = vi.fn(async () => "1700.3");
    const update = vi.fn(async () => {});
    const setStatus = vi.fn(async () => {
      throw new Error("not an assistant thread");
    });
    const r = new StreamingResponder(post, update, undefined, setStatus, 50);

    r.handleEvent(startEvent);
    // Let the rejected setStatus promise settle -> triggers placeholder post.
    await vi.advanceTimersByTimeAsync(0);
    expect(post).toHaveBeenCalledWith("_Thinking…_");

    r.handleEvent(textDelta("Answer"));
    await vi.advanceTimersByTimeAsync(60);
    expect(update).toHaveBeenCalledWith("1700.3", "Answer");

    const handled = await r.finalize("Answer complete");
    expect(handled).toBe(true);
    expect(update).toHaveBeenLastCalledWith("1700.3", "Answer complete");
  });

  it("uses the placeholder strategy when no setStatus callback is provided", async () => {
    const post = vi.fn(async () => "1700.4");
    const update = vi.fn(async () => {});
    const r = new StreamingResponder(post, update, undefined, undefined, 50);

    r.handleEvent(startEvent);
    await vi.advanceTimersByTimeAsync(0);
    expect(post).toHaveBeenCalledWith("_Thinking…_");

    r.handleEvent(textDelta("Streamed"));
    await vi.advanceTimersByTimeAsync(60);
    expect(update).toHaveBeenCalledWith("1700.4", "Streamed");

    expect(await r.finalize("Streamed done")).toBe(true);
  });

  it("does not double-post when the reply arrives while the first chunk post is in flight", async () => {
    // Regression: finalize() must await the in-flight postFirstChunk() so it
    // edits that message instead of posting fullText as a second message.
    const firstPost = deferred<string | undefined>();
    let calls = 0;
    const post = vi.fn((_text: string) => {
      calls += 1;
      return calls === 1 ? firstPost.promise : Promise.resolve("later");
    });
    const update = vi.fn(async () => {});
    const setStatus = vi.fn(async () => {});
    const r = new StreamingResponder(post, update, undefined, setStatus, 50);

    r.handleEvent(startEvent);
    await vi.advanceTimersByTimeAsync(0);

    r.handleEvent(textDelta("Hello"));
    await vi.advanceTimersByTimeAsync(60); // flush -> postFirstChunk -> post("Hello") in flight
    expect(post).toHaveBeenCalledTimes(1);
    expect(post).toHaveBeenCalledWith("Hello");

    // finalize fires while the first post is still pending, then it resolves.
    const finalizeP = r.finalize("Hello there");
    firstPost.resolve("ts-1");
    const handled = await finalizeP;

    expect(handled).toBe(true);
    expect(post).toHaveBeenCalledTimes(1); // NOT posted a second time
    expect(update).toHaveBeenCalledWith("ts-1", "Hello there");
  });

  it("reuses the fallback placeholder when the reply arrives while it is in flight", async () => {
    // Regression: setStatus rejects -> placeholder post starts; finalize() must
    // await it and edit it rather than returning false (which would orphan it).
    const firstPost = deferred<string | undefined>();
    let calls = 0;
    const post = vi.fn((_text: string) => {
      calls += 1;
      return calls === 1 ? firstPost.promise : Promise.resolve("later");
    });
    const update = vi.fn(async () => {});
    const setStatus = vi.fn(async () => {
      throw new Error("not an assistant thread");
    });
    const r = new StreamingResponder(post, update, undefined, setStatus, 50);

    r.handleEvent(startEvent);
    await vi.advanceTimersByTimeAsync(0); // setStatus rejects -> placeholder post in flight
    expect(post).toHaveBeenCalledTimes(1);
    expect(post).toHaveBeenCalledWith("_Thinking…_");

    const finalizeP = r.finalize("Done");
    firstPost.resolve("ts-2");
    const handled = await finalizeP;

    expect(handled).toBe(true);
    expect(post).toHaveBeenCalledTimes(1); // no separate send path
    expect(update).toHaveBeenCalledWith("ts-2", "Done");
  });

  it("placeholder is phase-aware: shows the running tool before any text", async () => {
    const post = vi.fn(async () => "1700.5");
    const update = vi.fn(async () => {});
    const r = new StreamingResponder(post, update, undefined, undefined, 50);

    r.handleEvent(startEvent);
    await vi.advanceTimersByTimeAsync(0);
    expect(post).toHaveBeenCalledWith("_Thinking…_");

    // A tool runs before any text -> placeholder updates to a clean status line
    // (no stray leading newlines, mcp__ prefix stripped).
    r.handleEvent(toolUse("mcp__nomos-slack__slack_search"));
    await vi.advanceTimersByTimeAsync(60);
    expect(update).toHaveBeenCalledWith("1700.5", "_Using slack_search…_");

    // Text arrives -> the placeholder becomes the streamed content.
    r.handleEvent(textDelta("Here you go"));
    await vi.advanceTimersByTimeAsync(60);
    expect(update).toHaveBeenLastCalledWith("1700.5", "Here you go");
  });

  it("finalize returns false (fall back to chunked send) when nothing was posted", async () => {
    // No setStatus, and no start event -> no message id, not in status mode.
    const post = vi.fn(async () => "x");
    const update = vi.fn(async () => {});
    const r = new StreamingResponder(post, update, undefined, undefined, 50);

    expect(await r.finalize("orphan")).toBe(false);
    expect(post).not.toHaveBeenCalled();
    expect(update).not.toHaveBeenCalled();
  });
});
