import { describe, expect, it, vi } from "vitest";
import { access, mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Readable } from "node:stream";
import {
  downloadMessageFile,
  FeishuFileDeliveryError,
  resolveSenderName,
  sendFile,
  sendFileData,
  sendLongText,
  sendTextMessage,
  type FeishuRuntime,
} from "../../src/feishu/client.js";
import { SenderNameCache } from "../../src/feishu/senderCache.js";

const itOnLinux = process.platform === "linux" ? it : it.skip;

describe("Feishu client helpers", () => {
  itOnLinux("downloads an image message resource through the SDK stream helper", async () => {
    const dir = await mkdtemp(join(tmpdir(), "hub-feishu-client-"));
    try {
      const workspace = join(dir, "workspace");
      await mkdir(workspace);
      const destination = join(workspace, ".cph", "inbox", "image.png");
      const getReadableStream = vi.fn(() => Readable.from([Buffer.from("image bytes")]));
      const messageResourceGet = vi.fn(async () => ({ getReadableStream }));
      const rawRequest = vi.fn(async () => Buffer.from("image bytes"));
      const rt = mockRuntime({
        fileCreate: vi.fn(),
        messageCreate: vi.fn(),
        messageResourceGet,
        request: rawRequest,
      });

      await expect(
        downloadMessageFile(rt, "message-1", "img-key-1", dir, workspace, ".cph/inbox/image.png", "image"),
      ).resolves.toBe(join(await realpath(workspace), ".cph", "inbox", "image.png"));

      expect(messageResourceGet).toHaveBeenCalledWith({
        params: { type: "image" },
        path: { message_id: "message-1", file_key: "img-key-1" },
      });
      expect(getReadableStream).toHaveBeenCalledTimes(1);
      await expect(readFile(destination, "utf8")).resolves.toBe("image bytes");
      expect(rawRequest).not.toHaveBeenCalled();
    } finally {
      await rm(dir, { recursive: true, force: true });
    }
  });

  itOnLinux("refuses an inbound download through a symlinked workspace directory", async () => {
    const root = await mkdtemp(join(tmpdir(), "hub-feishu-client-"));
    try {
      const workspace = join(root, "workspace");
      const outside = join(root, "outside");
      await Promise.all([mkdir(workspace), mkdir(outside)]);
      await symlink(outside, join(workspace, ".cph"));
      const messageResourceGet = vi.fn(async () => ({
        getReadableStream: () => Readable.from([Buffer.from("must-not-escape")]),
      }));
      const rt = mockRuntime({ fileCreate: vi.fn(), messageCreate: vi.fn(), messageResourceGet });

      await expect(
        downloadMessageFile(rt, "message-1", "file-key-1", root, workspace, ".cph/inbox/file.bin", "file"),
      ).rejects.toThrow(/workspace|symlink|directory/i);
      await expect(access(join(outside, "inbox", "file.bin"))).rejects.toMatchObject({ code: "ENOENT" });
    } finally {
      await rm(root, { recursive: true, force: true });
    }
  });

  it("accepts top-level file_key from the Lark SDK file upload response", async () => {
    const dir = await mkdtemp(join(tmpdir(), "hub-feishu-client-"));
    try {
      const file = join(dir, "student.pdf");
      await writeFile(file, "pdf bytes");
      const fileCreate = vi.fn(async () => ({ file_key: "file-key-1" }));
      const messageCreate = vi.fn(async () => ({ data: { message_id: "message-1" } }));
      const rt = mockRuntime({ fileCreate, messageCreate });

      await expect(sendFile(rt, "chat-1", file, "student.pdf")).resolves.toBe("message-1");
      expect(messageCreate).toHaveBeenCalledWith({
        params: { receive_id_type: "chat_id" },
        data: { receive_id: "chat-1", msg_type: "file", content: JSON.stringify({ file_key: "file-key-1" }) },
      });
    } finally {
      await rm(dir, { recursive: true, force: true });
    }
  });

  it("rejects malformed file upload responses instead of hiding the failure", async () => {
    const rt = mockRuntime({
      fileCreate: vi.fn(async () => ({ data: {} })),
      messageCreate: vi.fn(),
    });

    await expect(sendFileData(rt, "chat-1", Buffer.from("bytes"), "student.pdf"))
      .rejects.toEqual(expect.objectContaining({
        name: "FeishuFileDeliveryError",
        message: "Feishu file upload response is missing file_key",
      } satisfies Partial<FeishuFileDeliveryError>));
  });

  it("accepts top-level message_id from message create responses", async () => {
    const rt = mockRuntime({
      fileCreate: vi.fn(),
      messageCreate: vi.fn(async () => ({ message_id: "message-1" })),
    });

    await expect(sendTextMessage(rt, "chat-1", "hello")).resolves.toBe("message-1");
  });

  it("replies to a message without enabling Feishu topic mode", async () => {
    const messageCreate = vi.fn();
    const messageReply = vi.fn(async () => ({ data: { message_id: "reply-1" } }));
    const rt = mockRuntime({ fileCreate: vi.fn(), messageCreate, messageReply });

    await expect(sendTextMessage(rt, "chat-1", "hello", { replyToMessageId: "m-trigger" })).resolves.toBe("reply-1");

    expect(messageCreate).not.toHaveBeenCalled();
    expect(messageReply).toHaveBeenCalledWith({
      path: { message_id: "m-trigger" },
      data: {
        msg_type: "text",
        content: JSON.stringify({ text: "hello" }),
      },
    });
  });

  it("sends long text in chunks and returns the last successful message id", async () => {
    let messageNumber = 0;
    const messageCreate = vi.fn(async () => ({ data: { message_id: `message-${++messageNumber}` } }));
    const rt = mockRuntime({ fileCreate: vi.fn(), messageCreate });

    await expect(sendLongText(rt, "chat-1", "x".repeat(16_005))).resolves.toBe("message-3");

    expect(messageCreate).toHaveBeenCalledTimes(3);
    const sentTexts = messageCreate.mock.calls.map(([payload]) => {
      const content = (payload as { data: { content: string } }).data.content;
      return (JSON.parse(content) as { text: string }).text;
    });
    expect(sentTexts.map((text) => text.length)).toEqual([8000, 8000, 5]);
  });

  it("resolves sender names through the basic contact API and caches hits", async () => {
    const contactBasicBatch = vi.fn(async () => ({ data: { users: [{ name: "Jiarong" }] } }));
    const request = vi.fn();
    const rt = mockRuntime({ fileCreate: vi.fn(), messageCreate: vi.fn(), contactBasicBatch, request });
    const cache = new SenderNameCache();

    await expect(resolveSenderName(rt, "ou_user_1", cache)).resolves.toBe("Jiarong");
    await expect(resolveSenderName(rt, "ou_user_1", cache)).resolves.toBe("Jiarong");

    expect(contactBasicBatch).toHaveBeenCalledTimes(1);
    expect(contactBasicBatch).toHaveBeenCalledWith({
      params: { user_id_type: "open_id" },
      data: { user_ids: ["ou_user_1"] },
    });
    expect(request).not.toHaveBeenCalled();
  });
});

function mockRuntime(options: {
  readonly fileCreate: (payload: unknown) => Promise<unknown>;
  readonly messageCreate: (payload: unknown) => Promise<unknown>;
  readonly messageReply?: (payload: unknown) => Promise<unknown>;
  readonly contactBasicBatch?: (payload: unknown) => Promise<unknown>;
  readonly messageResourceGet?: (payload: unknown) => Promise<unknown>;
  readonly request?: (payload: unknown) => Promise<unknown>;
}): FeishuRuntime {
  return {
    client: {
      request: options.request,
      contact: {
        v3: {
          user: {
            basicBatch: options.contactBasicBatch,
          },
        },
      },
      im: {
        v1: {
          file: { create: options.fileCreate },
          messageResource: { get: options.messageResourceGet },
          message: {
            create: options.messageCreate,
            reply: options.messageReply ?? vi.fn(async () => ({ data: { message_id: "reply-1" } })),
          },
        },
      },
    } as unknown as FeishuRuntime["client"],
    logger: {
      info() {},
      warn() {},
      error() {},
      debug() {},
      fatal() {},
      child() { return this; },
      level: "silent",
    } as unknown as FeishuRuntime["logger"],
  };
}
