import { describe, expect, it, vi } from "vitest";
import "./test-mocks.js";
import {
  addBlueBubblesParticipant,
  editBlueBubblesMessage,
  leaveBlueBubblesChat,
  markBlueBubblesChatRead,
  removeBlueBubblesParticipant,
  renameBlueBubblesChat,
  sendBlueBubblesTyping,
  setGroupIconBlueBubbles,
  unsendBlueBubblesMessage,
} from "./chat.js";
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
import { installBlueBubblesFetchTestHooks } from "./test-harness.js";

const mockFetch = vi.fn();

installBlueBubblesFetchTestHooks({
  mockFetch,
  privateApiStatusMock: vi.mocked(getCachedBlueBubblesPrivateApiStatus),
});

describe("chat", () => {
  function mockOkTextResponse() {
    mockFetch.mockResolvedValueOnce({
      ok: true,
      text: () => Promise.resolve(""),
    });
  }

  async function expectCalledUrlIncludesPassword(params: {
    password: string;
    invoke: () => Promise<void>;
  }) {
    mockOkTextResponse();
    await params.invoke();
    const calledUrl = mockFetch.mock.calls[0][0] as string;
    expect(calledUrl).toContain(`password=${params.password}`);
  }

  async function expectCalledUrlUsesConfigCredentials(params: {
    serverHost: string;
    password: string;
    invoke: (cfg: {
      channels: { bluebubbles: { serverUrl: string; password: string } };
    }) => Promise<void>;
  }) {
    mockOkTextResponse();
    await params.invoke({
      channels: {
        bluebubbles: {
          serverUrl: `http://${params.serverHost}`,
          password: params.password,
        },
      },
    });
    const calledUrl = mockFetch.mock.calls[0][0] as string;
    expect(calledUrl).toContain(params.serverHost);
    expect(calledUrl).toContain(`password=${params.password}`);
  }

  describe("markBlueBubblesChatRead", () => {
    it("does nothing when chatGuid is empty or whitespace", async () => {
      for (const chatGuid of ["", "   "]) {
        await markBlueBubblesChatRead(chatGuid, {
          serverUrl: "http://localhost:1234",
          password: "test",
        });
      }
      expect(mockFetch).not.toHaveBeenCalled();
    });

    it("throws when required credentials are missing", async () => {
      await expect(markBlueBubblesChatRead("chat-guid", {})).rejects.toThrow(
        "serverUrl is required",
      );
      await expect(
        markBlueBubblesChatRead("chat-guid", {
          serverUrl: "http://localhost:1234",
        }),
      ).rejects.toThrow("password is required");
    });

    it("marks chat as read successfully", async () => {
      mockFetch.mockResolvedValueOnce({
        ok: true,
        text: () => Promise.resolve(""),
      });

      await markBlueBubblesChatRead("iMessage;-;+15551234567", {
        serverUrl: "http://localhost:1234",
        password: "test-password",
      });

      expect(mockFetch).toHaveBeenCalledWith(
        expect.stringContaining("/api/v1/chat/iMessage%3B-%3B%2B15551234567/read"),
        expect.objectContaining({ method: "POST" }),
      );
    });

    it("does not send read receipt when private API is disabled", async () => {
      vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false);

      await markBlueBubblesChatRead("iMessage;-;+15551234567", {
        serverUrl: "http://localhost:1234",
        password: "test-password",
      });

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

    it("includes password in URL query", async () => {
      await expectCalledUrlIncludesPassword({
        password: "my-secret",
        invoke: () =>
          markBlueBubblesChatRead("chat-123", {
            serverUrl: "http://localhost:1234",
            password: "my-secret",
          }),
      });
    });

    it("throws on non-ok response", async () => {
      mockFetch.mockResolvedValueOnce({
        ok: false,
        status: 404,
        text: () => Promise.resolve("Chat not found"),
      });

      await expect(
        markBlueBubblesChatRead("missing-chat", {
          serverUrl: "http://localhost:1234",
          password: "test",
        }),
      ).rejects.toThrow("read failed (404): Chat not found");
    });

    it("trims chatGuid before using", async () => {
      mockFetch.mockResolvedValueOnce({
        ok: true,
        text: () => Promise.resolve(""),
      });

      await markBlueBubblesChatRead("  chat-with-spaces  ", {
        serverUrl: "http://localhost:1234",
        password: "test",
      });

      const calledUrl = mockFetch.mock.calls[0][0] as string;
      expect(calledUrl).toContain("/api/v1/chat/chat-with-spaces/read");
      expect(calledUrl).not.toContain("%20chat");
    });

    it("resolves credentials from config", async () => {
      await expectCalledUrlUsesConfigCredentials({
        serverHost: "config-server:9999",
        password: "config-pass",
        invoke: (cfg) =>
          markBlueBubblesChatRead("chat-123", {
            cfg,
          }),
      });
    });
  });

  describe("sendBlueBubblesTyping", () => {
    it("does nothing when chatGuid is empty or whitespace", async () => {
      for (const chatGuid of ["", "   "]) {
        await sendBlueBubblesTyping(chatGuid, true, {
          serverUrl: "http://localhost:1234",
          password: "test",
        });
      }
      expect(mockFetch).not.toHaveBeenCalled();
    });

    it("throws when required credentials are missing", async () => {
      await expect(sendBlueBubblesTyping("chat-guid", true, {})).rejects.toThrow(
        "serverUrl is required",
      );
      await expect(
        sendBlueBubblesTyping("chat-guid", true, {
          serverUrl: "http://localhost:1234",
        }),
      ).rejects.toThrow("password is required");
    });

    it("does not send typing when private API is disabled", async () => {
      vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false);

      await sendBlueBubblesTyping("iMessage;-;+15551234567", true, {
        serverUrl: "http://localhost:1234",
        password: "test",
      });

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

    it("uses POST for start and DELETE for stop", async () => {
      mockFetch
        .mockResolvedValueOnce({
          ok: true,
          text: () => Promise.resolve(""),
        })
        .mockResolvedValueOnce({
          ok: true,
          text: () => Promise.resolve(""),
        });

      await sendBlueBubblesTyping("iMessage;-;+15551234567", true, {
        serverUrl: "http://localhost:1234",
        password: "test",
      });
      await sendBlueBubblesTyping("iMessage;-;+15551234567", false, {
        serverUrl: "http://localhost:1234",
        password: "test",
      });

      expect(mockFetch).toHaveBeenCalledTimes(2);
      expect(mockFetch.mock.calls[0][0]).toContain(
        "/api/v1/chat/iMessage%3B-%3B%2B15551234567/typing",
      );
      expect(mockFetch.mock.calls[0][1].method).toBe("POST");
      expect(mockFetch.mock.calls[1][0]).toContain(
        "/api/v1/chat/iMessage%3B-%3B%2B15551234567/typing",
      );
      expect(mockFetch.mock.calls[1][1].method).toBe("DELETE");
    });

    it("includes password in URL query", async () => {
      mockFetch.mockResolvedValueOnce({
        ok: true,
        text: () => Promise.resolve(""),
      });

      await sendBlueBubblesTyping("chat-123", true, {
        serverUrl: "http://localhost:1234",
        password: "typing-secret",
      });

      const calledUrl = mockFetch.mock.calls[0][0] as string;
      expect(calledUrl).toContain("password=typing-secret");
    });

    it("throws on non-ok response", async () => {
      mockFetch.mockResolvedValueOnce({
        ok: false,
        status: 500,
        text: () => Promise.resolve("Internal error"),
      });

      await expect(
        sendBlueBubblesTyping("chat-123", true, {
          serverUrl: "http://localhost:1234",
          password: "test",
        }),
      ).rejects.toThrow("typing failed (500): Internal error");
    });

    it("trims chatGuid before using", async () => {
      mockFetch.mockResolvedValueOnce({
        ok: true,
        text: () => Promise.resolve(""),
      });

      await sendBlueBubblesTyping("  trimmed-chat  ", true, {
        serverUrl: "http://localhost:1234",
        password: "test",
      });

      const calledUrl = mockFetch.mock.calls[0][0] as string;
      expect(calledUrl).toContain("/api/v1/chat/trimmed-chat/typing");
    });

    it("encodes special characters in chatGuid", async () => {
      mockFetch.mockResolvedValueOnce({
        ok: true,
        text: () => Promise.resolve(""),
      });

      await sendBlueBubblesTyping("iMessage;+;group@chat.com", true, {
        serverUrl: "http://localhost:1234",
        password: "test",
      });

      const calledUrl = mockFetch.mock.calls[0][0] as string;
      expect(calledUrl).toContain("iMessage%3B%2B%3Bgroup%40chat.com");
    });

    it("resolves credentials from config", async () => {
      mockFetch.mockResolvedValueOnce({
        ok: true,
        text: () => Promise.resolve(""),
      });

      await sendBlueBubblesTyping("chat-123", true, {
        cfg: {
          channels: {
            bluebubbles: {
              serverUrl: "http://typing-server:8888",
              password: "typing-pass",
            },
          },
        },
      });

      const calledUrl = mockFetch.mock.calls[0][0] as string;
      expect(calledUrl).toContain("typing-server:8888");
      expect(calledUrl).toContain("password=typing-pass");
    });
  });

  describe("editBlueBubblesMessage", () => {
    it("throws when required args are missing", async () => {
      await expect(editBlueBubblesMessage("", "updated", {})).rejects.toThrow("messageGuid");
      await expect(editBlueBubblesMessage("message-guid", "   ", {})).rejects.toThrow("newText");
    });

    it("sends edit request with default payload values", async () => {
      mockFetch.mockResolvedValueOnce({
        ok: true,
        text: () => Promise.resolve(""),
      });

      await editBlueBubblesMessage(" message-guid ", " updated text ", {
        serverUrl: "http://localhost:1234",
        password: "test-password",
      });

      expect(mockFetch).toHaveBeenCalledWith(
        expect.stringContaining("/api/v1/message/message-guid/edit"),
        expect.objectContaining({
          method: "POST",
          headers: { "Content-Type": "application/json" },
        }),
      );
      const body = JSON.parse(mockFetch.mock.calls[0][1].body);
      expect(body).toEqual({
        editedMessage: "updated text",
        backwardsCompatibilityMessage: "Edited to: updated text",
        partIndex: 0,
      });
    });

    it("supports custom part index and backwards compatibility message", async () => {
      mockFetch.mockResolvedValueOnce({
        ok: true,
        text: () => Promise.resolve(""),
      });

      await editBlueBubblesMessage("message-guid", "new text", {
        serverUrl: "http://localhost:1234",
        password: "test-password",
        partIndex: 3,
        backwardsCompatMessage: "custom-backwards-message",
      });

      const body = JSON.parse(mockFetch.mock.calls[0][1].body);
      expect(body.partIndex).toBe(3);
      expect(body.backwardsCompatibilityMessage).toBe("custom-backwards-message");
    });

    it("throws on non-ok response", async () => {
      mockFetch.mockResolvedValueOnce({
        ok: false,
        status: 422,
        text: () => Promise.resolve("Unprocessable"),
      });

      await expect(
        editBlueBubblesMessage("message-guid", "new text", {
          serverUrl: "http://localhost:1234",
          password: "test-password",
        }),
      ).rejects.toThrow("edit failed (422): Unprocessable");
    });
  });

  describe("unsendBlueBubblesMessage", () => {
    it("throws when messageGuid is missing", async () => {
      await expect(unsendBlueBubblesMessage("", {})).rejects.toThrow("messageGuid");
    });

    it("sends unsend request with default part index", async () => {
      mockFetch.mockResolvedValueOnce({
        ok: true,
        text: () => Promise.resolve(""),
      });

      await unsendBlueBubblesMessage(" msg-123 ", {
        serverUrl: "http://localhost:1234",
        password: "test-password",
      });

      expect(mockFetch).toHaveBeenCalledWith(
        expect.stringContaining("/api/v1/message/msg-123/unsend"),
        expect.objectContaining({
          method: "POST",
          headers: { "Content-Type": "application/json" },
        }),
      );
      const body = JSON.parse(mockFetch.mock.calls[0][1].body);
      expect(body.partIndex).toBe(0);
    });

    it("uses custom part index", async () => {
      mockFetch.mockResolvedValueOnce({
        ok: true,
        text: () => Promise.resolve(""),
      });

      await unsendBlueBubblesMessage("msg-123", {
        serverUrl: "http://localhost:1234",
        password: "test-password",
        partIndex: 2,
      });

      const body = JSON.parse(mockFetch.mock.calls[0][1].body);
      expect(body.partIndex).toBe(2);
    });
  });

  describe("group chat mutation actions", () => {
    it("renames chat", async () => {
      mockFetch.mockResolvedValueOnce({
        ok: true,
        text: () => Promise.resolve(""),
      });

      await renameBlueBubblesChat(" chat-guid ", "New Group Name", {
        serverUrl: "http://localhost:1234",
        password: "test-password",
      });

      expect(mockFetch).toHaveBeenCalledWith(
        expect.stringContaining("/api/v1/chat/chat-guid"),
        expect.objectContaining({ method: "PUT" }),
      );
      const body = JSON.parse(mockFetch.mock.calls[0][1].body);
      expect(body.displayName).toBe("New Group Name");
    });

    it("adds and removes participant using matching endpoint", async () => {
      mockFetch
        .mockResolvedValueOnce({
          ok: true,
          text: () => Promise.resolve(""),
        })
        .mockResolvedValueOnce({
          ok: true,
          text: () => Promise.resolve(""),
        });

      await addBlueBubblesParticipant("chat-guid", "+15551234567", {
        serverUrl: "http://localhost:1234",
        password: "test-password",
      });
      await removeBlueBubblesParticipant("chat-guid", "+15551234567", {
        serverUrl: "http://localhost:1234",
        password: "test-password",
      });

      expect(mockFetch).toHaveBeenCalledTimes(2);
      expect(mockFetch.mock.calls[0][0]).toContain("/api/v1/chat/chat-guid/participant");
      expect(mockFetch.mock.calls[0][1].method).toBe("POST");
      expect(mockFetch.mock.calls[1][0]).toContain("/api/v1/chat/chat-guid/participant");
      expect(mockFetch.mock.calls[1][1].method).toBe("DELETE");

      const addBody = JSON.parse(mockFetch.mock.calls[0][1].body);
      const removeBody = JSON.parse(mockFetch.mock.calls[1][1].body);
      expect(addBody.address).toBe("+15551234567");
      expect(removeBody.address).toBe("+15551234567");
    });

    it("leaves chat without JSON body", async () => {
      mockFetch.mockResolvedValueOnce({
        ok: true,
        text: () => Promise.resolve(""),
      });

      await leaveBlueBubblesChat("chat-guid", {
        serverUrl: "http://localhost:1234",
        password: "test-password",
      });

      expect(mockFetch).toHaveBeenCalledWith(
        expect.stringContaining("/api/v1/chat/chat-guid/leave"),
        expect.objectContaining({ method: "POST" }),
      );
      expect(mockFetch.mock.calls[0][1].body).toBeUndefined();
      expect(mockFetch.mock.calls[0][1].headers).toBeUndefined();
    });
  });

  describe("setGroupIconBlueBubbles", () => {
    it("throws when chatGuid is empty", async () => {
      await expect(
        setGroupIconBlueBubbles("", new Uint8Array([1, 2, 3]), "icon.png", {
          serverUrl: "http://localhost:1234",
          password: "test",
        }),
      ).rejects.toThrow("chatGuid");
    });

    it("throws when buffer is empty", async () => {
      await expect(
        setGroupIconBlueBubbles("chat-guid", new Uint8Array(0), "icon.png", {
          serverUrl: "http://localhost:1234",
          password: "test",
        }),
      ).rejects.toThrow("image buffer");
    });

    it("throws when required credentials are missing", async () => {
      await expect(
        setGroupIconBlueBubbles("chat-guid", new Uint8Array([1, 2, 3]), "icon.png", {}),
      ).rejects.toThrow("serverUrl is required");
      await expect(
        setGroupIconBlueBubbles("chat-guid", new Uint8Array([1, 2, 3]), "icon.png", {
          serverUrl: "http://localhost:1234",
        }),
      ).rejects.toThrow("password is required");
    });

    it("throws when private API is disabled", async () => {
      vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false);
      await expect(
        setGroupIconBlueBubbles("chat-guid", new Uint8Array([1, 2, 3]), "icon.png", {
          serverUrl: "http://localhost:1234",
          password: "test",
        }),
      ).rejects.toThrow("requires Private API");
      expect(mockFetch).not.toHaveBeenCalled();
    });

    it("sets group icon successfully", async () => {
      mockFetch.mockResolvedValueOnce({
        ok: true,
        text: () => Promise.resolve(""),
      });

      const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); // PNG magic bytes
      await setGroupIconBlueBubbles("iMessage;-;chat-guid", buffer, "icon.png", {
        serverUrl: "http://localhost:1234",
        password: "test-password",
        contentType: "image/png",
      });

      expect(mockFetch).toHaveBeenCalledWith(
        expect.stringContaining("/api/v1/chat/iMessage%3B-%3Bchat-guid/icon"),
        expect.objectContaining({
          method: "POST",
          headers: expect.objectContaining({
            "Content-Type": expect.stringContaining("multipart/form-data"),
          }),
        }),
      );
    });

    it("includes password in URL query", async () => {
      await expectCalledUrlIncludesPassword({
        password: "my-secret",
        invoke: () =>
          setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "icon.png", {
            serverUrl: "http://localhost:1234",
            password: "my-secret",
          }),
      });
    });

    it("throws on non-ok response", async () => {
      mockFetch.mockResolvedValueOnce({
        ok: false,
        status: 500,
        text: () => Promise.resolve("Internal error"),
      });

      await expect(
        setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "icon.png", {
          serverUrl: "http://localhost:1234",
          password: "test",
        }),
      ).rejects.toThrow("setGroupIcon failed (500): Internal error");
    });

    it("trims chatGuid before using", async () => {
      mockFetch.mockResolvedValueOnce({
        ok: true,
        text: () => Promise.resolve(""),
      });

      await setGroupIconBlueBubbles("  chat-with-spaces  ", new Uint8Array([1]), "icon.png", {
        serverUrl: "http://localhost:1234",
        password: "test",
      });

      const calledUrl = mockFetch.mock.calls[0][0] as string;
      expect(calledUrl).toContain("/api/v1/chat/chat-with-spaces/icon");
      expect(calledUrl).not.toContain("%20chat");
    });

    it("resolves credentials from config", async () => {
      await expectCalledUrlUsesConfigCredentials({
        serverHost: "config-server:9999",
        password: "config-pass",
        invoke: (cfg) =>
          setGroupIconBlueBubbles("chat-123", new Uint8Array([1]), "icon.png", {
            cfg,
          }),
      });
    });

    it("includes filename in multipart body", async () => {
      mockFetch.mockResolvedValueOnce({
        ok: true,
        text: () => Promise.resolve(""),
      });

      await setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "custom-icon.jpg", {
        serverUrl: "http://localhost:1234",
        password: "test",
        contentType: "image/jpeg",
      });

      const body = mockFetch.mock.calls[0][1].body as Uint8Array;
      const bodyString = new TextDecoder().decode(body);
      expect(bodyString).toContain('filename="custom-icon.jpg"');
      expect(bodyString).toContain("image/jpeg");
    });
  });
});
