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

/** Captured emoji picker callbacks so tests can simulate selection. */
let lastEmojiPickerOptions: { onSelect: (emoji: string) => void; onClose: () => void } | null =
  null;

vi.mock("@components/EmojiPicker", () => ({
  createEmojiPicker: (opts: { onSelect: (emoji: string) => void; onClose: () => void }) => {
    lastEmojiPickerOptions = opts;
    const element = document.createElement("div");
    element.classList.add("emoji-picker");
    return { element, destroy: vi.fn() };
  },
}));

/** Captured GIF picker callbacks so tests can simulate selection. */
type CapturedGifPickerOptions = {
  onSelect: (url: string) => void;
  onClose: () => void;
  onUnavailable?: (reason: string) => void;
};
let lastGifPickerOptions: CapturedGifPickerOptions | null = null;

vi.mock("@components/GifPicker", () => ({
  createGifPicker: (opts: CapturedGifPickerOptions) => {
    lastGifPickerOptions = opts;
    const element = document.createElement("div");
    element.classList.add("gif-picker");
    return { element, destroy: vi.fn() };
  },
}));

import {
  createMessageInput,
  wrapWithMarker,
  type MessageInputOptions,
} from "@components/MessageInput";
import type { GifApi } from "@lib/gifProvider";

/** GIF endpoints on the user's own server (never api.klipy.com). */
const stubGifApi: GifApi = {
  gifSearch: vi.fn(async () => ({ results: [] })),
  gifTrending: vi.fn(async () => ({ results: [] })),
};

function makeOptions(overrides: Partial<MessageInputOptions> = {}): MessageInputOptions {
  return {
    channelId: 1,
    channelName: "general",
    gifApi: stubGifApi,
    onSend: vi.fn(),
    onTyping: vi.fn(),
    onEditMessage: vi.fn(),
    ...overrides,
  };
}

describe("MessageInput", () => {
  let container: HTMLDivElement;

  beforeEach(() => {
    lastEmojiPickerOptions = null;
    lastGifPickerOptions = null;
    container = document.createElement("div");
    document.body.appendChild(container);
  });

  afterEach(() => {
    container.remove();
  });

  it("mounts with message-input-wrap class", () => {
    const opts = makeOptions();
    const comp = createMessageInput(opts);
    comp.mount(container);

    expect(container.querySelector(".message-input-wrap")).not.toBeNull();

    comp.destroy?.();
  });

  it("has textarea with correct placeholder", () => {
    const opts = makeOptions({ channelName: "random" });
    const comp = createMessageInput(opts);
    comp.mount(container);

    const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
    expect(textarea).not.toBeNull();
    expect(textarea.placeholder).toBe("Message #random");

    comp.destroy?.();
  });

  it("send button click calls onSend with textarea content", () => {
    const opts = makeOptions();
    const comp = createMessageInput(opts);
    comp.mount(container);

    const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
    textarea.value = "Hello world";

    const sendBtn = container.querySelector(".send-btn") as HTMLButtonElement;
    sendBtn.click();

    expect(opts.onSend).toHaveBeenCalledWith("Hello world", null, []);

    comp.destroy?.();
  });

  it("enter key sends message", () => {
    const opts = makeOptions();
    const comp = createMessageInput(opts);
    comp.mount(container);

    const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
    textarea.value = "Enter message";

    textarea.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));

    expect(opts.onSend).toHaveBeenCalledWith("Enter message", null, []);

    comp.destroy?.();
  });

  it("shift+enter does NOT send (just newlines)", () => {
    const opts = makeOptions();
    const comp = createMessageInput(opts);
    comp.mount(container);

    const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
    textarea.value = "Line 1";

    textarea.dispatchEvent(
      new KeyboardEvent("keydown", { key: "Enter", shiftKey: true, bubbles: true }),
    );

    expect(opts.onSend).not.toHaveBeenCalled();

    comp.destroy?.();
  });

  it("empty textarea does not send", () => {
    const opts = makeOptions();
    const comp = createMessageInput(opts);
    comp.mount(container);

    const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
    textarea.value = "";

    const sendBtn = container.querySelector(".send-btn") as HTMLButtonElement;
    sendBtn.click();

    expect(opts.onSend).not.toHaveBeenCalled();

    comp.destroy?.();
  });

  it("setReplyTo shows reply bar", () => {
    const opts = makeOptions();
    const comp = createMessageInput(opts);
    comp.mount(container);

    comp.setReplyTo(42, "testuser");

    const replyBar = container.querySelector(".reply-bar") as HTMLDivElement;
    expect(replyBar.classList.contains("visible")).toBe(true);
    expect(replyBar.textContent).toContain("testuser");

    comp.destroy?.();
  });

  it("clearReply hides reply bar", () => {
    const opts = makeOptions();
    const comp = createMessageInput(opts);
    comp.mount(container);

    comp.setReplyTo(42, "testuser");
    comp.clearReply();

    const replyBar = container.querySelector(".reply-bar") as HTMLDivElement;
    expect(replyBar.classList.contains("visible")).toBe(false);

    comp.destroy?.();
  });

  it("startEdit sets textarea value and shows edit bar", () => {
    const opts = makeOptions();
    const comp = createMessageInput(opts);
    comp.mount(container);

    comp.startEdit(99, "editing this");

    const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
    expect(textarea.value).toBe("editing this");

    // The edit bar is the second .reply-bar
    const bars = container.querySelectorAll(".reply-bar");
    const editBar = bars[1] as HTMLDivElement;
    expect(editBar.classList.contains("visible")).toBe(true);

    comp.destroy?.();
  });

  it("cancelEdit clears textarea and hides edit bar", () => {
    const opts = makeOptions();
    const comp = createMessageInput(opts);
    comp.mount(container);

    comp.startEdit(99, "editing this");
    comp.cancelEdit();

    const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
    expect(textarea.value).toBe("");

    const bars = container.querySelectorAll(".reply-bar");
    const editBar = bars[1] as HTMLDivElement;
    expect(editBar.classList.contains("visible")).toBe(false);

    comp.destroy?.();
  });

  it("typing emits onTyping (throttled)", () => {
    vi.useFakeTimers();
    const opts = makeOptions();
    const comp = createMessageInput(opts);
    comp.mount(container);

    const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;

    // First input should trigger onTyping
    textarea.dispatchEvent(new Event("input", { bubbles: true }));
    expect(opts.onTyping).toHaveBeenCalledTimes(1);

    // Immediate second input should NOT trigger (throttled at 3s)
    textarea.dispatchEvent(new Event("input", { bubbles: true }));
    expect(opts.onTyping).toHaveBeenCalledTimes(1);

    // After 3 seconds, should fire again
    vi.advanceTimersByTime(3000);
    textarea.dispatchEvent(new Event("input", { bubbles: true }));
    expect(opts.onTyping).toHaveBeenCalledTimes(2);

    vi.useRealTimers();
    comp.destroy?.();
  });

  it("attach button is disabled with tooltip", () => {
    const opts = makeOptions();
    const comp = createMessageInput(opts);
    comp.mount(container);

    const attachBtn = container.querySelector(".attach-btn") as HTMLButtonElement;
    expect(attachBtn).not.toBeNull();
    expect(attachBtn.disabled).toBe(true);
    expect(attachBtn.title).toBe("File uploads not available");

    comp.destroy?.();
  });

  it("debounces rapid sends", () => {
    vi.useFakeTimers();
    const opts = makeOptions();
    const comp = createMessageInput(opts);
    comp.mount(container);

    const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
    const sendBtn = container.querySelector(".send-btn") as HTMLButtonElement;

    textarea.value = "msg1";
    sendBtn.click();
    expect(opts.onSend).toHaveBeenCalledTimes(1);

    // Immediately try to send again (within 200ms debounce)
    textarea.value = "msg2";
    sendBtn.click();
    expect(opts.onSend).toHaveBeenCalledTimes(1); // still 1

    // After debounce period
    vi.advanceTimersByTime(200);
    textarea.value = "msg3";
    sendBtn.click();
    expect(opts.onSend).toHaveBeenCalledTimes(2);

    vi.useRealTimers();
    comp.destroy?.();
  });

  // ── Edit mode sends via onEditMessage ──

  it("sending in edit mode calls onEditMessage instead of onSend", () => {
    const opts = makeOptions();
    const comp = createMessageInput(opts);
    comp.mount(container);

    comp.startEdit(77, "old content");

    const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
    textarea.value = "updated content";

    const sendBtn = container.querySelector(".send-btn") as HTMLButtonElement;
    sendBtn.click();

    expect(opts.onEditMessage).toHaveBeenCalledWith(77, "updated content");
    expect(opts.onSend).not.toHaveBeenCalled();

    // After send, edit bar should be hidden and textarea cleared
    const bars = container.querySelectorAll(".reply-bar");
    const editBar = bars[1] as HTMLDivElement;
    expect(editBar.classList.contains("visible")).toBe(false);
    expect(textarea.value).toBe("");

    comp.destroy?.();
  });

  // ── Emptied edits are refused, never submitted ──

  it("does not submit an emptied edit while an attachment is queued", async () => {
    const uploadResult = { id: "srv-9", url: "http://server/pic.png", filename: "pic.png" };
    const onUploadFile = vi.fn(async () => uploadResult);
    const opts = makeOptions({ onUploadFile });
    const comp = createMessageInput(opts);
    comp.mount(container);

    // Queue an attachment first, then enter edit mode.
    const testFile = new File(["image data"], "pic.png", { type: "image/png" });
    const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
    Object.defineProperty(fileInput, "files", { value: [testFile], writable: true });
    fileInput.dispatchEvent(new Event("change", { bubbles: true }));
    await vi.waitFor(() => {
      expect(onUploadFile).toHaveBeenCalledWith(testFile);
    });
    // Wait for the upload to fully settle so the send is not blocked by the
    // uploads-in-flight guard instead of the empty-content one.
    const previewBar = container.querySelector(".attachment-preview-bar");
    await vi.waitFor(() => {
      expect(previewBar!.querySelector(".uploading")).toBeNull();
    });

    comp.startEdit(77, "old content");
    const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
    textarea.value = "";

    const sendBtn = container.querySelector(".send-btn") as HTMLButtonElement;
    sendBtn.click();

    // Same as with no attachment queued: the empty edit is refused and edit
    // mode survives.
    expect(opts.onEditMessage).not.toHaveBeenCalled();
    expect(opts.onSend).not.toHaveBeenCalled();
    const bars = container.querySelectorAll(".reply-bar");
    const editBar = bars[1] as HTMLDivElement;
    expect(editBar.classList.contains("visible")).toBe(true);

    // Typing real content and sending again still submits the edit.
    textarea.value = "fixed content";
    sendBtn.click();
    expect(opts.onEditMessage).toHaveBeenCalledWith(77, "fixed content");

    comp.destroy?.();
  });

  it("emptied edit with no attachment queued is a no-op that stays in edit mode", () => {
    const opts = makeOptions();
    const comp = createMessageInput(opts);
    comp.mount(container);

    comp.startEdit(88, "old content");
    const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
    textarea.value = "";

    const sendBtn = container.querySelector(".send-btn") as HTMLButtonElement;
    sendBtn.click();

    expect(opts.onEditMessage).not.toHaveBeenCalled();
    expect(opts.onSend).not.toHaveBeenCalled();
    const bars = container.querySelectorAll(".reply-bar");
    const editBar = bars[1] as HTMLDivElement;
    expect(editBar.classList.contains("visible")).toBe(true);

    comp.destroy?.();
  });

  it("sends an attachment-only message with no text", async () => {
    const uploadResult = { id: "srv-7", url: "http://server/pic.png", filename: "pic.png" };
    const onUploadFile = vi.fn(async () => uploadResult);
    const opts = makeOptions({ onUploadFile });
    const comp = createMessageInput(opts);
    comp.mount(container);

    const testFile = new File(["image data"], "pic.png", { type: "image/png" });
    const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
    Object.defineProperty(fileInput, "files", { value: [testFile], writable: true });
    fileInput.dispatchEvent(new Event("change", { bubbles: true }));
    await vi.waitFor(() => {
      expect(onUploadFile).toHaveBeenCalledWith(testFile);
    });
    const previewBar = container.querySelector(".attachment-preview-bar");
    await vi.waitFor(() => {
      expect(previewBar!.querySelector(".uploading")).toBeNull();
    });

    const sendBtn = container.querySelector(".send-btn") as HTMLButtonElement;
    sendBtn.click();

    expect(opts.onSend).toHaveBeenCalledWith("", null, ["srv-7"]);

    comp.destroy?.();
  });

  // ── Reply context is included in send ──

  it("sending with reply includes replyTo messageId", () => {
    const opts = makeOptions();
    const comp = createMessageInput(opts);
    comp.mount(container);

    comp.setReplyTo(55, "replyuser");

    const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
    textarea.value = "reply content";

    const sendBtn = container.querySelector(".send-btn") as HTMLButtonElement;
    sendBtn.click();

    expect(opts.onSend).toHaveBeenCalledWith("reply content", 55, []);

    // Reply bar should be hidden after send
    const replyBar = container.querySelector(".reply-bar") as HTMLDivElement;
    expect(replyBar.classList.contains("visible")).toBe(false);

    comp.destroy?.();
  });

  // ── Escape key behavior ──

  it("escape key cancels edit mode", () => {
    const opts = makeOptions();
    const comp = createMessageInput(opts);
    comp.mount(container);

    comp.startEdit(88, "editing");

    const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
    textarea.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));

    // Edit bar should be hidden
    const bars = container.querySelectorAll(".reply-bar");
    const editBar = bars[1] as HTMLDivElement;
    expect(editBar.classList.contains("visible")).toBe(false);
    expect(textarea.value).toBe("");

    comp.destroy?.();
  });

  it("escape key clears reply when not in edit mode", () => {
    const opts = makeOptions();
    const comp = createMessageInput(opts);
    comp.mount(container);

    comp.setReplyTo(44, "replyuser");

    const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
    textarea.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));

    const replyBar = container.querySelector(".reply-bar") as HTMLDivElement;
    expect(replyBar.classList.contains("visible")).toBe(false);

    comp.destroy?.();
  });

  // ── ArrowUp on empty textarea dispatches edit-last-message ──

  it("ArrowUp on empty textarea dispatches edit-last-message custom event", () => {
    const opts = makeOptions();
    const comp = createMessageInput(opts);
    comp.mount(container);

    const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
    textarea.value = "";

    const listener = vi.fn();
    container.addEventListener("edit-last-message", listener);

    textarea.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true }));

    expect(listener).toHaveBeenCalledTimes(1);

    comp.destroy?.();
  });

  it("ArrowUp with content in textarea does NOT dispatch edit-last-message", () => {
    const opts = makeOptions();
    const comp = createMessageInput(opts);
    comp.mount(container);

    const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
    textarea.value = "some text";

    const listener = vi.fn();
    container.addEventListener("edit-last-message", listener);

    textarea.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true }));

    expect(listener).not.toHaveBeenCalled();

    comp.destroy?.();
  });

  // ── File attachment via onUploadFile ──

  it("attach button is enabled when onUploadFile is provided", () => {
    const opts = makeOptions({
      onUploadFile: vi.fn(async () => ({ id: "a1", url: "http://x.png", filename: "x.png" })),
    });
    const comp = createMessageInput(opts);
    comp.mount(container);

    const attachBtn = container.querySelector(".attach-btn") as HTMLButtonElement;
    expect(attachBtn.disabled).toBe(false);

    comp.destroy?.();
  });

  it("file picker accept attribute only advertises extensions the MIME allowlist accepts", () => {
    const opts = makeOptions({
      onUploadFile: vi.fn(async () => ({ id: "a1", url: "http://x.png", filename: "x.png" })),
    });
    const comp = createMessageInput(opts);
    comp.mount(container);

    const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
    // .rar/.7z were advertised here but rejected by ALLOWED_TYPES on pick —
    // an always-rejected picker option.
    expect(fileInput.accept).not.toContain(".rar");
    expect(fileInput.accept).not.toContain(".7z");

    comp.destroy?.();
  });

  it("file upload shows preview and sends attachment ID with message", async () => {
    const uploadResult = { id: "srv-123", url: "http://server/file.png", filename: "file.png" };
    const onUploadFile = vi.fn(async () => uploadResult);
    const opts = makeOptions({ onUploadFile });
    const comp = createMessageInput(opts);
    comp.mount(container);

    // Simulate file selection via the hidden input
    const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
    expect(fileInput).not.toBeNull();

    const testFile = new File(["image data"], "test.png", { type: "image/png" });
    Object.defineProperty(fileInput, "files", { value: [testFile], writable: true });
    fileInput.dispatchEvent(new Event("change", { bubbles: true }));

    // Wait for the async upload to complete
    await vi.waitFor(() => {
      expect(onUploadFile).toHaveBeenCalledWith(testFile);
    });

    // Preview bar should be visible
    const previewBar = container.querySelector(".attachment-preview-bar");
    expect(previewBar!.classList.contains("visible")).toBe(true);

    // Now send a message -- should include the attachment ID
    const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
    textarea.value = "with attachment";
    const sendBtn = container.querySelector(".send-btn") as HTMLButtonElement;
    sendBtn.click();

    expect(opts.onSend).toHaveBeenCalledWith("with attachment", null, ["srv-123"]);

    comp.destroy?.();
  });

  it("rejects file exceeding 100 MB size limit", async () => {
    const onUploadFile = vi.fn(async () => ({ id: "x", url: "x", filename: "x" }));
    const opts = makeOptions({ onUploadFile });
    const comp = createMessageInput(opts);
    comp.mount(container);

    // Create a file > 100MB
    const bigFile = new File(["x"], "huge.bin", { type: "application/octet-stream" });
    Object.defineProperty(bigFile, "size", { value: 101 * 1024 * 1024 });

    const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
    Object.defineProperty(fileInput, "files", { value: [bigFile], writable: true });
    fileInput.dispatchEvent(new Event("change", { bubbles: true }));

    // Give async handlers a tick
    await new Promise((r) => setTimeout(r, 10));

    // Upload should NOT have been called
    expect(onUploadFile).not.toHaveBeenCalled();

    // Error message should be displayed
    const error = container.querySelector(".attachment-upload-error");
    expect(error).not.toBeNull();
    expect(error!.textContent).toContain("too large");

    comp.destroy?.();
  });

  it("upload error makes the (initially hidden) preview bar visible", async () => {
    const onUploadFile = vi.fn(async () => ({ id: "x", url: "x", filename: "x" }));
    const opts = makeOptions({ onUploadFile });
    const comp = createMessageInput(opts);
    comp.mount(container);

    // Nothing queued yet, so the preview bar starts without the "visible"
    // class -- app.css only shows it via .visible.
    const previewBar = container.querySelector(".attachment-preview-bar");
    expect(previewBar!.classList.contains("visible")).toBe(false);

    const bigFile = new File(["x"], "huge.bin", { type: "application/octet-stream" });
    Object.defineProperty(bigFile, "size", { value: 101 * 1024 * 1024 });

    const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
    Object.defineProperty(fileInput, "files", { value: [bigFile], writable: true });
    fileInput.dispatchEvent(new Event("change", { bubbles: true }));

    await new Promise((r) => setTimeout(r, 10));

    expect(container.querySelector(".attachment-upload-error")).not.toBeNull();
    expect(previewBar!.classList.contains("visible")).toBe(true);

    comp.destroy?.();
  });

  it("preview bar loses 'visible' again once the error auto-dismisses with nothing else queued", async () => {
    vi.useFakeTimers();
    const onUploadFile = vi.fn(async () => ({ id: "x", url: "x", filename: "x" }));
    const opts = makeOptions({ onUploadFile });
    const comp = createMessageInput(opts);
    comp.mount(container);

    const bigFile = new File(["x"], "huge.bin", { type: "application/octet-stream" });
    Object.defineProperty(bigFile, "size", { value: 101 * 1024 * 1024 });
    const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
    Object.defineProperty(fileInput, "files", { value: [bigFile], writable: true });
    fileInput.dispatchEvent(new Event("change", { bubbles: true }));

    await vi.advanceTimersByTimeAsync(10);
    const previewBar = container.querySelector(".attachment-preview-bar");
    expect(previewBar!.classList.contains("visible")).toBe(true);

    await vi.advanceTimersByTimeAsync(4000);
    expect(container.querySelector(".attachment-upload-error")).toBeNull();
    expect(previewBar!.classList.contains("visible")).toBe(false);

    vi.useRealTimers();
    comp.destroy?.();
  });

  it("rejects unsupported file types", async () => {
    const onUploadFile = vi.fn(async () => ({ id: "x", url: "x", filename: "x" }));
    const opts = makeOptions({ onUploadFile });
    const comp = createMessageInput(opts);
    comp.mount(container);

    const badFile = new File(["exe data"], "bad.exe", { type: "application/x-msdownload" });

    const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
    Object.defineProperty(fileInput, "files", { value: [badFile], writable: true });
    fileInput.dispatchEvent(new Event("change", { bubbles: true }));

    await new Promise((r) => setTimeout(r, 10));

    expect(onUploadFile).not.toHaveBeenCalled();
    const error = container.querySelector(".attachment-upload-error");
    expect(error).not.toBeNull();
    expect(error!.textContent).toContain("is not a supported file type");

    comp.destroy?.();
  });

  it("shows upload error when onUploadFile rejects", async () => {
    const onUploadFile = vi.fn(async () => {
      throw new Error("Server exploded");
    });
    const opts = makeOptions({ onUploadFile });
    const comp = createMessageInput(opts);
    comp.mount(container);

    const file = new File(["data"], "doc.pdf", { type: "application/pdf" });
    const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
    Object.defineProperty(fileInput, "files", { value: [file], writable: true });
    fileInput.dispatchEvent(new Event("change", { bubbles: true }));

    // Wait for the async rejection
    await vi.waitFor(() => {
      const error = container.querySelector(".attachment-upload-error");
      expect(error).not.toBeNull();
      expect(error!.textContent).toContain("Server exploded");
    });

    comp.destroy?.();
  });

  it("blocks send while uploads are still in flight", async () => {
    // Create an upload that never resolves during the test
    let resolveUpload: ((v: { id: string; url: string; filename: string }) => void) | null = null;
    const onUploadFile = vi.fn(
      () =>
        new Promise<{ id: string; url: string; filename: string }>((res) => {
          resolveUpload = res;
        }),
    );
    const opts = makeOptions({ onUploadFile });
    const comp = createMessageInput(opts);
    comp.mount(container);

    const file = new File(["data"], "doc.txt", { type: "text/plain" });
    const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
    Object.defineProperty(fileInput, "files", { value: [file], writable: true });
    fileInput.dispatchEvent(new Event("change", { bubbles: true }));

    // The upload is in flight but not resolved
    const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
    textarea.value = "trying to send";
    const sendBtn = container.querySelector(".send-btn") as HTMLButtonElement;
    sendBtn.click();

    // Should NOT have sent -- upload still pending
    expect(opts.onSend).not.toHaveBeenCalled();
    // Should show "wait" error
    const error = container.querySelector(".attachment-upload-error");
    expect(error).not.toBeNull();
    expect(error!.textContent).toContain("wait for uploads");

    // Now resolve the upload
    resolveUpload!({ id: "done", url: "http://x", filename: "doc.txt" });
    await new Promise((r) => setTimeout(r, 10));

    comp.destroy?.();
  });

  it("remove button removes attachment preview while upload is pending", async () => {
    // Use a long-running upload so we can click remove while it's still pending
    let resolveUpload: ((v: { id: string; url: string; filename: string }) => void) | null = null;
    const onUploadFile = vi.fn(
      () =>
        new Promise<{ id: string; url: string; filename: string }>((res) => {
          resolveUpload = res;
        }),
    );
    const opts = makeOptions({ onUploadFile });
    const comp = createMessageInput(opts);
    comp.mount(container);

    const file = new File(["text"], "file.txt", { type: "text/plain" });
    const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
    Object.defineProperty(fileInput, "files", { value: [file], writable: true });
    fileInput.dispatchEvent(new Event("change", { bubbles: true }));

    // Wait for the preview to appear (upload started but not resolved)
    await vi.waitFor(() => {
      expect(onUploadFile).toHaveBeenCalled();
    });

    // Preview bar should be visible with the uploading item
    const previewBar = container.querySelector(".attachment-preview-bar");
    expect(previewBar!.classList.contains("visible")).toBe(true);

    // Remove the attachment while upload is still pending (tempId still matches)
    const removeBtn = container.querySelector('[data-testid="attachment-remove"]') as HTMLElement;
    expect(removeBtn).not.toBeNull();
    removeBtn.click();

    // Preview bar should lose visible class (all attachments removed from list)
    expect(previewBar!.classList.contains("visible")).toBe(false);

    // The preview item DOM element should be removed from the bar
    expect(previewBar!.querySelector(".attachment-preview-item")).toBeNull();

    // Clean up the pending promise
    resolveUpload!({ id: "done", url: "http://x", filename: "file.txt" });
    await new Promise((r) => setTimeout(r, 10));

    comp.destroy?.();
  });

  it("remove button removes attachment preview after the upload has completed", async () => {
    const uploadResult = { id: "srv-123", url: "http://server/file.png", filename: "file.png" };
    const onUploadFile = vi.fn(async () => uploadResult);
    const opts = makeOptions({ onUploadFile });
    const comp = createMessageInput(opts);
    comp.mount(container);

    const testFile = new File(["image data"], "test.png", { type: "image/png" });
    const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
    Object.defineProperty(fileInput, "files", { value: [testFile], writable: true });
    fileInput.dispatchEvent(new Event("change", { bubbles: true }));

    // Wait for the upload to resolve — the entry's id is now the server id,
    // not the tempId the remove button was created with.
    await vi.waitFor(() => {
      expect(onUploadFile).toHaveBeenCalledWith(testFile);
    });
    const previewBar = container.querySelector(".attachment-preview-bar");
    await vi.waitFor(() => {
      expect(previewBar!.querySelector(".uploading")).toBeNull();
    });

    const removeBtn = container.querySelector('[data-testid="attachment-remove"]') as HTMLElement;
    expect(removeBtn).not.toBeNull();
    removeBtn.click();

    expect(previewBar!.classList.contains("visible")).toBe(false);
    expect(previewBar!.querySelector(".attachment-preview-item")).toBeNull();

    // Sending now must not include the removed attachment.
    const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
    textarea.value = "no attachment";
    const sendBtn = container.querySelector(".send-btn") as HTMLButtonElement;
    sendBtn.click();
    expect(opts.onSend).toHaveBeenCalledWith("no attachment", null, []);

    comp.destroy?.();
  });

  it("refuses to attach a file while editing a message", async () => {
    const onUploadFile = vi.fn(async () => ({
      id: "srv-1",
      url: "http://x.png",
      filename: "x.png",
    }));
    const opts = makeOptions({ onUploadFile });
    const comp = createMessageInput(opts);
    comp.mount(container);

    comp.startEdit(77, "old content");

    const file = new File(["data"], "photo.png", { type: "image/png" });
    const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
    Object.defineProperty(fileInput, "files", { value: [file], writable: true });
    fileInput.dispatchEvent(new Event("change", { bubbles: true }));

    await new Promise((r) => setTimeout(r, 10));

    // Must not upload, and must not silently ride along with the next
    // ordinary send once the edit is cancelled.
    expect(onUploadFile).not.toHaveBeenCalled();
    expect(container.querySelector(".attachment-upload-error")).not.toBeNull();

    comp.destroy?.();
  });

  // ── setReplyTo clears edit mode ──

  it("setReplyTo hides edit bar if editing", () => {
    const opts = makeOptions();
    const comp = createMessageInput(opts);
    comp.mount(container);

    comp.startEdit(88, "editing");
    const bars = container.querySelectorAll(".reply-bar");
    const editBar = bars[1] as HTMLDivElement;
    expect(editBar.classList.contains("visible")).toBe(true);

    comp.setReplyTo(55, "replying");

    // Edit bar hidden, reply bar shown
    expect(editBar.classList.contains("visible")).toBe(false);
    const replyBar = bars[0] as HTMLDivElement;
    expect(replyBar.classList.contains("visible")).toBe(true);

    comp.destroy?.();
  });

  it("setReplyTo while editing clears the stale edit text from the textarea", () => {
    const opts = makeOptions();
    const comp = createMessageInput(opts);
    comp.mount(container);

    comp.startEdit(88, "old message body");
    const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
    expect(textarea.value).toBe("old message body");

    comp.setReplyTo(55, "replying");

    // The edit text must not survive into reply mode -- otherwise Enter
    // reposts it verbatim as a duplicate reply.
    expect(textarea.value).toBe("");

    comp.destroy?.();
  });

  // ── startEdit clears reply mode ──

  it("startEdit hides reply bar if replying", () => {
    const opts = makeOptions();
    const comp = createMessageInput(opts);
    comp.mount(container);

    comp.setReplyTo(55, "replying");
    const bars = container.querySelectorAll(".reply-bar");
    const replyBar = bars[0] as HTMLDivElement;
    expect(replyBar.classList.contains("visible")).toBe(true);

    comp.startEdit(88, "now editing");

    // Reply bar hidden, edit bar shown
    expect(replyBar.classList.contains("visible")).toBe(false);
    const editBar = bars[1] as HTMLDivElement;
    expect(editBar.classList.contains("visible")).toBe(true);

    comp.destroy?.();
  });

  // ── Reply close button ──

  it("clicking reply close button hides reply bar", () => {
    const opts = makeOptions();
    const comp = createMessageInput(opts);
    comp.mount(container);

    comp.setReplyTo(42, "replyuser");
    const replyBar = container.querySelector(".reply-bar") as HTMLDivElement;
    expect(replyBar.classList.contains("visible")).toBe(true);

    const closeBtn = replyBar.querySelector(".reply-close") as HTMLElement;
    closeBtn.click();

    expect(replyBar.classList.contains("visible")).toBe(false);

    comp.destroy?.();
  });

  // ── Textarea auto-resize ──

  it("textarea height adjusts on input (auto-resize)", () => {
    const opts = makeOptions();
    const comp = createMessageInput(opts);
    comp.mount(container);

    const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
    // After mount, textarea should have style.height set
    // Just verify input event triggers without error (auto-resize runs)
    textarea.value = "Line 1\nLine 2\nLine 3";
    textarea.dispatchEvent(new Event("input", { bubbles: true }));

    // Height should be set (not "auto")
    expect(textarea.style.height).not.toBe("");

    comp.destroy?.();
  });

  // ── Emoji picker ──

  it("clicking emoji button opens emoji picker", () => {
    const opts = makeOptions();
    const comp = createMessageInput(opts);
    comp.mount(container);

    const emojiBtn = container.querySelector(".emoji-btn") as HTMLElement;
    emojiBtn.click();

    const picker = container.querySelector(".emoji-picker");
    expect(picker).not.toBeNull();

    comp.destroy?.();
  });

  it("selecting emoji inserts it into textarea at cursor position", () => {
    const opts = makeOptions();
    const comp = createMessageInput(opts);
    comp.mount(container);

    const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
    textarea.value = "Hello  world";
    textarea.selectionStart = 6;
    textarea.selectionEnd = 6;

    // Open picker
    const emojiBtn = container.querySelector(".emoji-btn") as HTMLElement;
    emojiBtn.click();

    expect(lastEmojiPickerOptions).not.toBeNull();
    lastEmojiPickerOptions!.onSelect("🎉");

    // Emoji should be inserted at cursor position
    expect(textarea.value).toBe("Hello 🎉 world");

    comp.destroy?.();
  });

  // ── GIF picker ──

  it("clicking GIF button opens GIF picker", () => {
    const opts = makeOptions();
    const comp = createMessageInput(opts);
    comp.mount(container);

    const gifBtn = container.querySelector(".gif-btn") as HTMLElement;
    gifBtn.click();

    const picker = container.querySelector(".gif-picker");
    expect(picker).not.toBeNull();

    comp.destroy?.();
  });

  it("selecting a GIF sends it as a message immediately", () => {
    const opts = makeOptions();
    const comp = createMessageInput(opts);
    comp.mount(container);

    const gifBtn = container.querySelector(".gif-btn") as HTMLElement;
    gifBtn.click();

    expect(lastGifPickerOptions).not.toBeNull();
    lastGifPickerOptions!.onSelect("https://media.klipy.com/example.gif");

    expect(opts.onSend).toHaveBeenCalledWith("https://media.klipy.com/example.gif", null, []);

    comp.destroy?.();
  });

  it("selecting a GIF sends it without discarding a typed draft", () => {
    const opts = makeOptions();
    const comp = createMessageInput(opts);
    comp.mount(container);

    const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
    textarea.value = "wait for it...";

    const gifBtn = container.querySelector(".gif-btn") as HTMLElement;
    gifBtn.click();
    expect(lastGifPickerOptions).not.toBeNull();
    lastGifPickerOptions!.onSelect("https://media.klipy.com/example.gif");

    // The GIF is sent as its own message...
    expect(opts.onSend).toHaveBeenCalledWith("https://media.klipy.com/example.gif", null, []);
    // ...and the user's typed draft survives, instead of being overwritten
    // and thrown away.
    expect(textarea.value).toBe("wait for it...");

    comp.destroy?.();
  });

  it("opening GIF picker closes emoji picker", () => {
    const opts = makeOptions();
    const comp = createMessageInput(opts);
    comp.mount(container);

    // Open emoji picker first
    const emojiBtn = container.querySelector(".emoji-btn") as HTMLElement;
    emojiBtn.click();
    expect(container.querySelector(".emoji-picker")).not.toBeNull();

    // Open GIF picker
    const gifBtn = container.querySelector(".gif-btn") as HTMLElement;
    gifBtn.click();

    // Emoji picker should be removed
    expect(container.querySelector(".emoji-picker")).toBeNull();
    // GIF picker should be present
    expect(container.querySelector(".gif-picker")).not.toBeNull();

    comp.destroy?.();
  });

  it("opening emoji picker closes GIF picker", () => {
    const opts = makeOptions();
    const comp = createMessageInput(opts);
    comp.mount(container);

    // Open GIF picker first
    const gifBtn = container.querySelector(".gif-btn") as HTMLElement;
    gifBtn.click();
    expect(container.querySelector(".gif-picker")).not.toBeNull();

    // Open emoji picker
    const emojiBtn = container.querySelector(".emoji-btn") as HTMLElement;
    emojiBtn.click();

    // GIF picker should be removed
    expect(container.querySelector(".gif-picker")).toBeNull();
    // Emoji picker should be present
    expect(container.querySelector(".emoji-picker")).not.toBeNull();

    comp.destroy?.();
  });

  // ── Destroy cleans up ──

  it("destroy removes DOM and cleans up pickers", () => {
    const opts = makeOptions();
    const comp = createMessageInput(opts);
    comp.mount(container);

    // Open a picker so there's state to clean up
    const emojiBtn = container.querySelector(".emoji-btn") as HTMLElement;
    emojiBtn.click();

    comp.destroy?.();

    expect(container.querySelector(".message-input-wrap")).toBeNull();
  });

  // ── Paste file handling ──

  it("pasting an image file triggers upload", async () => {
    const onUploadFile = vi.fn(async () => ({
      id: "paste-1",
      url: "http://x",
      filename: "paste.png",
    }));
    const opts = makeOptions({ onUploadFile });
    const comp = createMessageInput(opts);
    comp.mount(container);

    const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
    const file = new File(["img"], "paste.png", { type: "image/png" });

    // ClipboardEvent is not available in jsdom, so create a plain Event and
    // attach clipboardData manually
    const pasteEvent = new Event("paste", { bubbles: true }) as Event & {
      clipboardData?: { items: Array<{ kind: string; type: string; getAsFile(): File | null }> };
    };
    Object.defineProperty(pasteEvent, "clipboardData", {
      value: {
        items: [
          {
            kind: "file",
            type: "image/png",
            getAsFile: () => file,
          },
        ],
      },
    });

    textarea.dispatchEvent(pasteEvent);

    await vi.waitFor(() => {
      expect(onUploadFile).toHaveBeenCalledWith(file);
    });

    comp.destroy?.();
  });

  // ── Files with empty MIME type are rejected (security hardening) ──

  it("files with empty MIME type are rejected", async () => {
    const onUploadFile = vi.fn(async () => ({ id: "unk-1", url: "http://x", filename: "data" }));
    const opts = makeOptions({ onUploadFile });
    const comp = createMessageInput(opts);
    comp.mount(container);

    const noTypeFile = new File(["data"], "mystery", { type: "" });
    const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
    Object.defineProperty(fileInput, "files", { value: [noTypeFile], writable: true });
    fileInput.dispatchEvent(new Event("change", { bubbles: true }));

    await new Promise((r) => setTimeout(r, 10));

    expect(onUploadFile).not.toHaveBeenCalled();
    const error = container.querySelector(".attachment-upload-error");
    expect(error).not.toBeNull();
    expect(error!.textContent).toContain("is not a supported file type");

    comp.destroy?.();
  });

  // ── Attachment count cap (server hard-rejects >10 attachments) ──

  it("refuses to queue an 11th attachment instead of uploading it", async () => {
    const onUploadFile = vi.fn(async () => ({ id: "x", url: "http://x", filename: "x" }));
    const opts = makeOptions({ onUploadFile });
    const comp = createMessageInput(opts);
    comp.mount(container);

    const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;

    for (let i = 0; i < 11; i++) {
      const file = new File(["data"], `file${i}.txt`, { type: "text/plain" });
      Object.defineProperty(fileInput, "files", { value: [file], writable: true });
      fileInput.dispatchEvent(new Event("change", { bubbles: true }));
      await new Promise((r) => setTimeout(r, 10));
    }

    // The server hard-rejects a chat_send with more than 10 attachments (as
    // a parse error with no attachment-specific messaging), so the composer
    // must never upload -- let alone queue -- an 11th one.
    expect(onUploadFile).toHaveBeenCalledTimes(10);
    expect(container.querySelectorAll(".attachment-preview-item").length).toBe(10);

    const error = container.querySelector(".attachment-upload-error");
    expect(error).not.toBeNull();
    expect(error!.textContent).toContain("10");

    comp.destroy?.();
  });

  // ── Toggling emoji picker closed ──

  it("clicking emoji button again closes the picker", () => {
    const opts = makeOptions();
    const comp = createMessageInput(opts);
    comp.mount(container);

    const emojiBtn = container.querySelector(".emoji-btn") as HTMLElement;

    // Open
    emojiBtn.click();
    expect(container.querySelector(".emoji-picker")).not.toBeNull();

    // Close
    emojiBtn.click();
    expect(container.querySelector(".emoji-picker")).toBeNull();

    comp.destroy?.();
  });

  // ── GIF affordance degrades when the server has no GIF provider ───────────

  describe("GIF button degradation", () => {
    it("is enabled when a gifApi is wired", () => {
      const comp = createMessageInput(makeOptions());
      comp.mount(container);

      const gifBtn = container.querySelector(".gif-btn") as HTMLButtonElement;
      expect(gifBtn.hasAttribute("disabled")).toBe(false);

      comp.destroy?.();
    });

    it("renders disabled with a visible reason when no gifApi is wired", () => {
      const comp = createMessageInput(makeOptions({ gifApi: undefined }));
      comp.mount(container);

      const gifBtn = container.querySelector(".gif-btn") as HTMLButtonElement;
      expect(gifBtn.hasAttribute("disabled")).toBe(true);
      expect(gifBtn.title).toBe("GIFs are not enabled on this server");
      expect(gifBtn.getAttribute("aria-label")).toContain("not enabled");

      comp.destroy?.();
    });

    it("does not open a picker when no gifApi is wired", () => {
      const comp = createMessageInput(makeOptions({ gifApi: undefined }));
      comp.mount(container);

      (container.querySelector(".gif-btn") as HTMLElement).click();
      expect(container.querySelector(".gif-picker")).toBeNull();

      comp.destroy?.();
    });

    it("disables the GIF button when the picker reports the server has GIFs off", () => {
      const comp = createMessageInput(makeOptions());
      comp.mount(container);

      const gifBtn = container.querySelector(".gif-btn") as HTMLButtonElement;
      gifBtn.click();
      lastGifPickerOptions!.onUnavailable!("GIFs are not enabled on this server");

      expect(gifBtn.hasAttribute("disabled")).toBe(true);
      expect(gifBtn.title).toBe("GIFs are not enabled on this server");

      comp.destroy?.();
    });

    it("keeps the GIF button disabled after the composer is re-enabled", () => {
      const comp = createMessageInput(makeOptions({ gifApi: undefined }));
      comp.mount(container);

      comp.setDisabled("Read-only channel");
      comp.setDisabled(null);

      const gifBtn = container.querySelector(".gif-btn") as HTMLButtonElement;
      expect(gifBtn.hasAttribute("disabled")).toBe(true);

      comp.destroy?.();
    });
  });

  // -------------------------------------------------------------------------
  // Formatting shortcuts
  // -------------------------------------------------------------------------

  describe("formatting shortcuts", () => {
    /** Mount a composer with `value` selected from `start` to `end`. */
    function mountWithSelection(
      value: string,
      start: number,
      end: number,
    ): { textarea: HTMLTextAreaElement; destroy: () => void } {
      const comp = createMessageInput(makeOptions());
      comp.mount(container);
      const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
      textarea.value = value;
      textarea.selectionStart = start;
      textarea.selectionEnd = end;
      return { textarea, destroy: () => comp.destroy?.() };
    }

    function press(textarea: HTMLTextAreaElement, key: string): KeyboardEvent {
      const event = new KeyboardEvent("keydown", {
        key,
        ctrlKey: true,
        bubbles: true,
        cancelable: true,
      });
      textarea.dispatchEvent(event);
      return event;
    }

    it("Ctrl+B wraps the selection in **", () => {
      const { textarea, destroy } = mountWithSelection("make this bold", 5, 9);
      const event = press(textarea, "b");
      expect(textarea.value).toBe("make **this** bold");
      expect(textarea.value.slice(textarea.selectionStart, textarea.selectionEnd)).toBe("this");
      expect(event.defaultPrevented).toBe(true);
      destroy();
    });

    it("Ctrl+I wraps the selection in *", () => {
      const { textarea, destroy } = mountWithSelection("hello", 0, 5);
      press(textarea, "i");
      expect(textarea.value).toBe("*hello*");
      destroy();
    });

    it("Ctrl+U wraps the selection in __ instead of opening the file picker", () => {
      const onUploadFile = vi.fn();
      const comp = createMessageInput(makeOptions({ onUploadFile }));
      comp.mount(container);
      const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
      textarea.value = "hello";
      textarea.selectionStart = 0;
      textarea.selectionEnd = 5;

      // The global Ctrl+U shortcut listens on document — the composer must
      // stop the event before it gets there.
      const globalHandler = vi.fn();
      document.addEventListener("keydown", globalHandler);

      press(textarea, "u");

      expect(textarea.value).toBe("__hello__");
      expect(globalHandler).not.toHaveBeenCalled();
      expect(onUploadFile).not.toHaveBeenCalled();
      document.removeEventListener("keydown", globalHandler);
      comp.destroy?.();
    });

    it("inserts empty markers and parks the caret between them", () => {
      const { textarea, destroy } = mountWithSelection("ab", 1, 1);
      press(textarea, "b");
      expect(textarea.value).toBe("a****b");
      expect(textarea.selectionStart).toBe(3);
      expect(textarea.selectionEnd).toBe(3);
      destroy();
    });

    it("unwraps an already-bold selection", () => {
      const { textarea, destroy } = mountWithSelection("**this**", 0, 8);
      press(textarea, "b");
      expect(textarea.value).toBe("this");
      destroy();
    });

    it("leaves other Ctrl combos alone", () => {
      const { textarea, destroy } = mountWithSelection("hello", 0, 5);
      const event = press(textarea, "k");
      expect(textarea.value).toBe("hello");
      expect(event.defaultPrevented).toBe(false);
      destroy();
    });

    it("does nothing while the composer is disabled", () => {
      const comp = createMessageInput(makeOptions());
      comp.mount(container);
      comp.setDisabled("Read-only channel");
      const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
      textarea.value = "hello";
      textarea.selectionStart = 0;
      textarea.selectionEnd = 5;
      press(textarea, "b");
      expect(textarea.value).toBe("hello");
      comp.destroy?.();
    });
  });

  describe("wrapWithMarker", () => {
    it("wraps a selection and reselects the inner text", () => {
      expect(wrapWithMarker("abc", 1, 2, "~~")).toEqual({
        value: "a~~b~~c",
        selectionStart: 3,
        selectionEnd: 4,
      });
    });

    it("unwraps markers that surround the selection", () => {
      expect(wrapWithMarker("a**b**c", 3, 4, "**")).toEqual({
        value: "abc",
        selectionStart: 1,
        selectionEnd: 2,
      });
    });

    it("does not mistake a short selection for a wrapped one", () => {
      expect(wrapWithMarker("**", 0, 2, "**").value).toBe("******");
    });

    it("wraps rather than mangles a selection spanning multiple already-wrapped spans", () => {
      // The selection starts and ends with "*" but is not itself a single
      // wrapped span — unwrapping it would destroy both interior spans.
      const result = wrapWithMarker("*hello* world *bye*", 0, 19, "*");
      expect(result.value).toBe("**hello* world *bye**");
    });

    it("wraps rather than downgrades bold text when italicizing", () => {
      const result = wrapWithMarker("**bold**", 0, 8, "*");
      expect(result.value).toBe("***bold***");
    });

    it("wraps rather than downgrades bold text when italicizing a double-clicked word", () => {
      // Selecting only the word (as a double-click would), not the "**"
      // markers themselves: start/end land just inside the bold pair, so
      // the single "*" immediately outside each edge belongs to a "**"
      // pair rather than being a matching "*" marker of its own.
      const result = wrapWithMarker("**bold**", 2, 6, "*");
      expect(result.value).toBe("***bold***");
    });
  });
});
