import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

import {
  createPublishArchive,
  getPublishApiBaseUrl,
  publishProjectArchive,
  uploadTimeoutMs,
} from "./publishProject.js";

function makeProjectDir(): string {
  return mkdtempSync(join(tmpdir(), "hf-publish-"));
}

describe("createPublishArchive", () => {
  it("packages the project and skips hidden files and node_modules", () => {
    const dir = makeProjectDir();
    try {
      writeFileSync(join(dir, "index.html"), "<html></html>", "utf-8");
      mkdirSync(join(dir, "assets"));
      writeFileSync(join(dir, "assets/logo.svg"), "<svg />", "utf-8");
      mkdirSync(join(dir, ".git"));
      writeFileSync(join(dir, ".env"), "SECRET=1", "utf-8");
      mkdirSync(join(dir, "node_modules"));
      writeFileSync(join(dir, "node_modules/ignored.js"), "console.log('ignore')", "utf-8");

      const archive = createPublishArchive(dir);

      expect(archive.fileCount).toBe(2);
      expect(archive.buffer.byteLength).toBeGreaterThan(0);
    } finally {
      rmSync(dir, { recursive: true, force: true });
    }
  });
});

describe("uploadTimeoutMs", () => {
  it("returns the minimum timeout for small files", () => {
    expect(uploadTimeoutMs(0)).toBe(120_000);
    expect(uploadTimeoutMs(50 * 1024 * 1024)).toBe(120_000);
  });

  it("scales above the floor for large files", () => {
    expect(uploadTimeoutMs(64 * 1024 * 1024)).toBeGreaterThan(120_000);
    expect(uploadTimeoutMs(500 * 1024 * 1024)).toBeGreaterThan(900_000);
  });

  it("returns an integer", () => {
    expect(Number.isInteger(uploadTimeoutMs(123_456))).toBe(true);
  });
});

describe("publishProjectArchive", () => {
  beforeEach(() => {
    vi.stubEnv("HYPERFRAMES_PUBLISHED_PROJECTS_API_URL", "");
    vi.stubEnv("HEYGEN_API_URL", "");
  });

  afterEach(() => {
    vi.unstubAllGlobals();
    vi.unstubAllEnvs();
  });

  it("uploads through the staged publish flow and returns the stable project URL", async () => {
    const dir = makeProjectDir();
    const fetchMock = vi
      .fn()
      .mockResolvedValueOnce(
        new Response(
          JSON.stringify({
            data: {
              upload_url:
                "https://s3.example.com/upload?X-Amz-SignedHeaders=content-length;content-type;host;x-amz-server-side-encryption",
              upload_key: "ephemeral_store/hyperframes/project_uploads/upload-1/demo.zip",
              upload_headers: {
                "content-type": "application/zip",
                "x-amz-server-side-encryption": "AES256",
              },
              content_type: "application/zip",
            },
          }),
          { status: 200 },
        ),
      )
      .mockResolvedValueOnce(new Response(null, { status: 200 }))
      .mockResolvedValueOnce(
        new Response(
          JSON.stringify({
            data: {
              project_id: "hfp_123",
              title: "demo",
              file_count: 2,
              url: "https://hyperframes.dev/p/hfp_123",
              claim_token: "claim-token",
            },
          }),
          { status: 200 },
        ),
      );
    vi.stubGlobal("fetch", fetchMock);

    try {
      writeFileSync(join(dir, "index.html"), "<html></html>", "utf-8");
      writeFileSync(join(dir, "styles.css"), "body {}", "utf-8");

      const result = await publishProjectArchive(dir);

      expect(getPublishApiBaseUrl()).toBe("https://api2.heygen.com");
      expect(result).toMatchObject({
        projectId: "hfp_123",
        url: "https://hyperframes.dev/p/hfp_123",
      });
      expect(fetchMock).toHaveBeenCalledTimes(3);
      expect(fetchMock).toHaveBeenNthCalledWith(
        1,
        "https://api2.heygen.com/v1/hyperframes/projects/publish/upload",
        expect.objectContaining({
          method: "POST",
          headers: { "content-type": "application/json", heygen_route: "canary" },
          signal: expect.any(AbortSignal),
        }),
      );
      expect(fetchMock).toHaveBeenNthCalledWith(
        2,
        "https://s3.example.com/upload?X-Amz-SignedHeaders=content-length;content-type;host;x-amz-server-side-encryption",
        expect.objectContaining({
          method: "PUT",
          headers: {
            "content-length": expect.any(String),
            "content-type": "application/zip",
            "x-amz-server-side-encryption": "AES256",
          },
          signal: expect.any(AbortSignal),
        }),
      );
      expect(fetchMock).toHaveBeenNthCalledWith(
        3,
        "https://api2.heygen.com/v1/hyperframes/projects/publish/complete",
        expect.objectContaining({
          method: "POST",
          headers: { "content-type": "application/json", heygen_route: "canary" },
          signal: expect.any(AbortSignal),
        }),
      );
    } finally {
      rmSync(dir, { recursive: true, force: true });
    }
  });

  it("falls back to the legacy multipart endpoint when staged publish is not deployed", async () => {
    const dir = makeProjectDir();
    const fetchMock = vi
      .fn()
      .mockResolvedValueOnce(new Response("not found", { status: 404 }))
      .mockResolvedValueOnce(
        new Response(
          JSON.stringify({
            data: {
              project_id: "hfp_123",
              title: "demo",
              file_count: 2,
              url: "https://hyperframes.dev/p/hfp_123",
              claim_token: "claim-token",
            },
          }),
          { status: 200 },
        ),
      );
    vi.stubGlobal("fetch", fetchMock);

    try {
      writeFileSync(join(dir, "index.html"), "<html></html>", "utf-8");

      const result = await publishProjectArchive(dir);

      expect(result.projectId).toBe("hfp_123");
      expect(fetchMock).toHaveBeenCalledTimes(2);
      expect(fetchMock).toHaveBeenNthCalledWith(
        2,
        "https://api2.heygen.com/v1/hyperframes/projects/publish",
        expect.objectContaining({
          method: "POST",
          headers: { heygen_route: "canary" },
          signal: expect.any(AbortSignal),
        }),
      );
    } finally {
      rmSync(dir, { recursive: true, force: true });
    }
  });

  it("does not fall back to multipart when a staged S3 upload fails", async () => {
    const dir = makeProjectDir();
    const fetchMock = vi
      .fn()
      .mockResolvedValueOnce(
        new Response(
          JSON.stringify({
            data: {
              upload_url: "https://s3.example.com/upload",
              upload_key: "ephemeral_store/hyperframes/project_uploads/upload-1/demo.zip",
              upload_headers: {
                "content-type": "application/zip",
                "x-amz-server-side-encryption": "AES256",
              },
              content_type: "application/zip",
            },
          }),
          { status: 200 },
        ),
      )
      .mockResolvedValueOnce(new Response("denied", { status: 403 }));
    vi.stubGlobal("fetch", fetchMock);

    try {
      writeFileSync(join(dir, "index.html"), "<html></html>", "utf-8");

      await expect(publishProjectArchive(dir)).rejects.toThrow("Failed to upload project archive");
      expect(fetchMock).toHaveBeenCalledTimes(2);
    } finally {
      rmSync(dir, { recursive: true, force: true });
    }
  });
});
