import { createReadStream } from "node:fs";
import { open } from "node:fs/promises";
import { fetchWithRetry, HttpError } from "./http.js";
import type { DriveItem, TokenProvider } from "./types.js";

type Fetch = typeof fetch;

export const SIMPLE_UPLOAD_LIMIT = 10 * 1024 * 1024;
export const UPLOAD_CHUNK_SIZE = 10 * 1024 * 1024;

interface UploadSessionResponse {
  uploadUrl: string;
}

class UploadSessionExpiredError extends Error {}

export function encodeGraphPath(path: string): string {
  return path.split("/").map((segment) => encodeURIComponent(segment)).join("/");
}

export class GraphClient {
  constructor(
    private readonly tokens: TokenProvider,
    private readonly siteId: string,
    private readonly fetchImpl: Fetch = fetch,
  ) {}

  async upload(filePath: string, size: number, sharePointPath: string): Promise<DriveItem> {
    if (size <= SIMPLE_UPLOAD_LIMIT) {
      return this.simpleUpload(filePath, size, sharePointPath);
    }
    for (let sessionAttempt = 0; sessionAttempt < 3; sessionAttempt += 1) {
      const session = await this.createUploadSession(sharePointPath);
      try {
        return await this.uploadSession(filePath, size, session.uploadUrl);
      } catch (error) {
        if (!(error instanceof UploadSessionExpiredError) || sessionAttempt === 2) throw error;
      }
    }
    throw new Error("Graph upload session could not be completed.");
  }

  private async simpleUpload(filePath: string, size: number, sharePointPath: string): Promise<DriveItem> {
    const endpoint = this.graphPathEndpoint(sharePointPath, "content");
    const response = await this.graphRequest(endpoint, () => ({
      method: "PUT",
      headers: {
        "Content-Type": "application/octet-stream",
        "Content-Length": String(size),
      },
      body: createReadStream(filePath) as never,
      duplex: "half",
    } as RequestInit));
    return (await response.json()) as DriveItem;
  }

  private async createUploadSession(sharePointPath: string): Promise<UploadSessionResponse> {
    const endpoint = this.graphPathEndpoint(sharePointPath, "createUploadSession");
    const response = await this.graphRequest(endpoint, () => ({
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ item: { "@microsoft.graph.conflictBehavior": "replace" } }),
    }));
    return (await response.json()) as UploadSessionResponse;
  }

  private async uploadSession(filePath: string, totalSize: number, uploadUrl: string): Promise<DriveItem> {
    const file = await open(filePath, "r");
    try {
      let offset = 0;
      while (offset < totalSize) {
        const length = Math.min(UPLOAD_CHUNK_SIZE, totalSize - offset);
        const buffer = Buffer.allocUnsafe(length);
        const { bytesRead } = await file.read(buffer, 0, length, offset);
        if (bytesRead !== length) {
          throw new Error(`Unexpected end of temporary file at byte ${offset}.`);
        }
        const end = offset + bytesRead - 1;
        const response = await fetchWithRetry(
          () => this.fetchImpl(uploadUrl, {
            method: "PUT",
            headers: {
              "Content-Type": "application/octet-stream",
              "Content-Length": String(bytesRead),
              "Content-Range": `bytes ${offset}-${end}/${totalSize}`,
            },
            body: buffer,
          }),
          { allow404: true },
        );
        if (response.status === 404) throw new UploadSessionExpiredError("Graph upload session expired.");
        if (response.status === 200 || response.status === 201) {
          return (await response.json()) as DriveItem;
        }
        if (response.status !== 202) {
          const body = await response.text();
          throw new HttpError(`Unexpected upload-session response ${response.status}.`, response.status, body, false);
        }
        offset = end + 1;
      }
    } finally {
      await file.close();
    }
    throw new Error("Graph upload session ended without returning a DriveItem.");
  }

  private graphPathEndpoint(sharePointPath: string, action: string): string {
    return `https://graph.microsoft.com/v1.0/sites/${encodeURIComponent(this.siteId)}`
      + `/drive/root:/${encodeGraphPath(sharePointPath)}:/${action}`;
  }

  private async graphRequest(endpoint: string, init: () => RequestInit): Promise<Response> {
    return fetchWithRetry(async (attempt) => {
      const token = await this.tokens.getToken(attempt > 0);
      const request = init();
      const headers = new Headers(request.headers);
      headers.set("Authorization", `Bearer ${token.accessToken}`);
      return this.fetchImpl(endpoint, { ...request, headers });
    });
  }
}
