import assert from "node:assert/strict";
import test from "node:test";
import {
  MULTIPART_PART_BYTES,
  UploadError,
  createUploadCoordinator,
} from "../../lib/server/uploads.ts";

class MemoryUploadStore {
  sessions = new Map();

  async create(session) {
    this.sessions.set(session.id, structuredClone(session));
  }

  async get(id) {
    const session = this.sessions.get(id);
    return session ? structuredClone(session) : null;
  }

  async save(session) {
    this.sessions.set(session.id, structuredClone(session));
  }

  async findExpired(expiresBefore, limit = 25) {
    return [...this.sessions.values()]
      .filter((session) => ["created", "uploading", "completing"].includes(session.status))
      .filter((session) => session.expiresAt <= expiresBefore)
      .slice(0, limit)
      .map((session) => structuredClone(session));
  }
}

class MemoryMultipartBucket {
  uploads = new Map();
  completed = new Map();
  aborted = new Set();

  async createMultipartUpload(key) {
    const uploadId = `r2-${this.uploads.size + 1}`;
    this.uploads.set(uploadId, { key, parts: new Map() });
    return { key, uploadId };
  }

  resumeMultipartUpload(key, uploadId) {
    const upload = this.uploads.get(uploadId);
    assert.equal(upload?.key, key);
    return {
      uploadPart: async (partNumber, value) => {
        const bytes = new Uint8Array(await new Response(value).arrayBuffer());
        const etag = `etag-${partNumber}-${bytes.byteLength}`;
        upload.parts.set(partNumber, { bytes, etag });
        return { partNumber, etag };
      },
      complete: async (parts) => {
        this.completed.set(key, parts);
        return { key, size: [...upload.parts.values()].reduce((sum, part) => sum + part.bytes.byteLength, 0) };
      },
      abort: async () => {
        this.aborted.add(uploadId);
      },
    };
  }
}

function makeCoordinator(now = new Date("2026-08-12T00:00:00.000Z")) {
  const store = new MemoryUploadStore();
  const bucket = new MemoryMultipartBucket();
  return {
    store,
    bucket,
    coordinator: createUploadCoordinator({
      store,
      bucket,
      assertStorageAdmission: async () => undefined,
      now: () => now,
      randomId: () => "book-1",
    }),
  };
}

test("checks the storage soft limit before creating an R2 upload", async () => {
  const store = new MemoryUploadStore();
  const bucket = new MemoryMultipartBucket();
  const checked = [];
  const coordinator = createUploadCoordinator({
    store,
    bucket,
    assertStorageAdmission: async (bytes) => { checked.push(bytes); throw new Error("storage full"); },
  });
  await assert.rejects(coordinator.create({
    fileName: "book.pdf", mimeType: "application/pdf", totalBytes: 123,
  }), /storage full/);
  assert.deepEqual(checked, [123]);
  assert.equal(bucket.uploads.size, 0);
});

test("creates a resumable 8 MiB upload session for a 200 MB book", async () => {
  const { coordinator } = makeCoordinator();
  const result = await coordinator.create({
    fileName: "A Very Long Book.pdf",
    mimeType: "application/pdf",
    totalBytes: 200 * 1024 * 1024,
  });

  assert.equal(result.id, "book-1");
  assert.equal(result.partSize, 8 * 1024 * 1024);
  assert.equal(result.partSize, MULTIPART_PART_BYTES);
  assert.equal(result.partCount, 25);
  assert.equal(result.objectKey, "sources/book-1/A-Very-Long-Book.pdf");
  assert.equal(result.status, "created");
  assert.equal(result.expiresAt, "2026-08-19T00:00:00.000Z");
});

test("uploads exact-sized parts, exposes resume state, and completes in order", async () => {
  const { coordinator, bucket } = makeCoordinator();
  await coordinator.create({
    fileName: "short.pdf",
    mimeType: "application/pdf",
    totalBytes: MULTIPART_PART_BYTES + 8,
  });

  const first = new Uint8Array(MULTIPART_PART_BYTES);
  first.set(new TextEncoder().encode("%PDF-1.7"));
  await coordinator.uploadPart("book-1", 1, first);
  await coordinator.uploadPart("book-1", 2, new Uint8Array(8));

  const resumed = await coordinator.status("book-1");
  assert.deepEqual(resumed.uploadedParts, [
    { partNumber: 1, etag: `etag-1-${MULTIPART_PART_BYTES}` },
    { partNumber: 2, etag: "etag-2-8" },
  ]);

  const completed = await coordinator.complete("book-1");
  assert.equal(completed.status, "complete");
  assert.deepEqual(bucket.completed.get(completed.objectKey), resumed.uploadedParts);
});

test("rejects wrong part lengths, missing parts, and a disguised first part", async () => {
  const { coordinator } = makeCoordinator();
  await coordinator.create({
    fileName: "book.pdf",
    mimeType: "application/pdf",
    totalBytes: MULTIPART_PART_BYTES + 5,
  });

  await assert.rejects(
    coordinator.uploadPart("book-1", 1, new Uint8Array(50)),
    (error) => error instanceof UploadError && error.code === "wrong_part_size",
  );
  await assert.rejects(
    coordinator.uploadPart("book-1", 1, new Uint8Array(MULTIPART_PART_BYTES)),
    (error) => error instanceof UploadError && error.code === "signature_mismatch",
  );
  await assert.rejects(
    coordinator.complete("book-1"),
    (error) => error instanceof UploadError && error.code === "missing_parts",
  );
});

test("aborts an active upload and rejects changes after expiry", async () => {
  const setup = makeCoordinator();
  await setup.coordinator.create({
    fileName: "book.epub",
    mimeType: "application/epub+zip",
    totalBytes: 10,
  });
  await setup.coordinator.abort("book-1");
  assert.equal(setup.bucket.aborted.has("r2-1"), true);
  assert.equal((await setup.coordinator.status("book-1")).status, "aborted");

  const expired = makeCoordinator(new Date("2026-08-20T00:00:00.000Z"));
  expired.store.sessions.set("old", {
    ...(await setup.store.get("book-1")),
    id: "old",
    status: "created",
  });
  await assert.rejects(
    expired.coordinator.status("old"),
    (error) => error instanceof UploadError && error.code === "upload_expired",
  );
});

test("cleans up expired multipart uploads before reserving a new upload", async () => {
  const setup = makeCoordinator(new Date("2026-08-20T00:00:00.000Z"));
  setup.store.sessions.set("old", {
    id: "old",
    r2UploadId: "r2-old",
    objectKey: "sources/old/book.pdf",
    fileName: "book.pdf",
    mimeType: "application/pdf",
    format: "pdf",
    totalBytes: 12,
    partSize: MULTIPART_PART_BYTES,
    partCount: 1,
    uploadedParts: [],
    status: "uploading",
    expiresAt: "2026-08-19T00:00:00.000Z",
    createdAt: "2026-08-12T00:00:00.000Z",
    updatedAt: "2026-08-12T00:00:00.000Z",
  });
  setup.bucket.uploads.set("r2-old", {
    key: "sources/old/book.pdf",
    parts: new Map(),
  });

  await setup.coordinator.create({
    fileName: "new.pdf",
    mimeType: "application/pdf",
    totalBytes: 12,
  });

  assert.equal(setup.bucket.aborted.has("r2-old"), true);
  assert.equal((await setup.store.get("old")).status, "aborted");
});
