// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

import { UploadSession, UploadResult, Progress } from './types';
import { UploadError } from './errors';

/**
 * Progress callback function type.
 */
export type ProgressCallback = (progress: Progress) => void;

/**
 * Options for chunked upload.
 */
export interface ChunkedUploadOptions {
  /** Size of each chunk in bytes (default: 10MB) */
  chunkSize?: number;
  /** Number of parallel upload workers (default: 4) */
  parallel?: number;
  /** Whether to verify file checksum (default: true) */
  verifyChecksum?: boolean;
  /** Callback function for progress updates */
  onProgress?: ProgressCallback;
}

const DEFAULT_CHUNK_SIZE = 10 * 1024 * 1024; // 10MB
const MAX_PARALLEL = 8;

/**
 * Handles chunked file uploads.
 */
export class ChunkedUpload {
  private baseUrl: string;
  private token: string;
  private owner: string;
  private repo: string;
  private releaseId: number;
  private options: Required<Omit<ChunkedUploadOptions, 'onProgress'>> & {
    onProgress?: ProgressCallback;
  };
  private session?: UploadSession;
  private bytesUploaded = 0;
  private startTime = 0;

  constructor(
    baseUrl: string,
    token: string,
    owner: string,
    repo: string,
    releaseId: number,
    options: ChunkedUploadOptions = {}
  ) {
    this.baseUrl = baseUrl;
    this.token = token;
    this.owner = owner;
    this.repo = repo;
    this.releaseId = releaseId;
    this.options = {
      chunkSize: options.chunkSize ?? DEFAULT_CHUNK_SIZE,
      parallel: Math.min(options.parallel ?? 4, MAX_PARALLEL),
      verifyChecksum: options.verifyChecksum ?? true,
      onProgress: options.onProgress,
    };
  }

  /**
   * Upload a file.
   */
  async upload(file: File | Blob, filename: string): Promise<UploadResult> {
    this.startTime = Date.now();
    const fileSize = file.size;

    // Calculate checksum if needed
    let checksum: string | undefined;
    if (this.options.verifyChecksum) {
      checksum = await this.calculateSHA256(file);
    }

    // Create session
    this.session = await this.createSession(filename, fileSize, checksum);

    // Upload chunks
    try {
      await this.uploadChunks(file);
    } catch (error) {
      throw new UploadError(
        `Upload failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
        this.session.id
      );
    }

    // Complete upload
    return this.completeUpload();
  }

  private async calculateSHA256(file: File | Blob): Promise<string> {
    const buffer = await file.arrayBuffer();
    const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
    const hashArray = Array.from(new Uint8Array(hashBuffer));
    return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
  }

  private async createSession(
    filename: string,
    fileSize: number,
    checksum?: string
  ): Promise<UploadSession> {
    const url = `${this.baseUrl}/api/v1/repos/${encodeURIComponent(
      this.owner
    )}/${encodeURIComponent(this.repo)}/releases/${
      this.releaseId
    }/assets/upload-session`;

    const body: Record<string, unknown> = {
      name: filename,
      size: fileSize,
      chunk_size: this.options.chunkSize,
    };
    if (checksum) {
      body.checksum = checksum;
    }

    const response = await fetch(url, {
      method: 'POST',
      headers: {
        Authorization: `token ${this.token}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(body),
    });

    if (!response.ok) {
      const error = await response.text();
      throw new Error(`Failed to create session: ${error}`);
    }

    return response.json();
  }

  private async uploadChunks(file: File | Blob): Promise<void> {
    if (!this.session) {
      throw new Error('No session');
    }

    const { total_chunks, chunk_size, file_size } = this.session;
    const chunks: Array<{ number: number; data: Blob }> = [];

    // Prepare chunks
    for (let i = 0; i < total_chunks; i++) {
      const start = i * chunk_size;
      const end = Math.min(start + chunk_size, file_size);
      const data = file.slice(start, end);
      chunks.push({ number: i, data });
    }

    // Upload in parallel batches
    const parallel = this.options.parallel;
    for (let i = 0; i < chunks.length; i += parallel) {
      const batch = chunks.slice(i, i + parallel);
      await Promise.all(
        batch.map((chunk) => this.uploadChunk(chunk.number, chunk.data))
      );
    }
  }

  private async uploadChunk(chunkNumber: number, data: Blob): Promise<void> {
    if (!this.session) {
      throw new Error('No session');
    }

    const url = `${this.baseUrl}/api/v1/repos/uploads/${this.session.id}/chunks/${chunkNumber}`;

    const response = await fetch(url, {
      method: 'PUT',
      headers: {
        Authorization: `token ${this.token}`,
        'Content-Type': 'application/octet-stream',
      },
      body: data,
    });

    if (!response.ok) {
      const error = await response.text();
      throw new UploadError(
        `Failed to upload chunk ${chunkNumber}: ${error}`,
        this.session.id,
        chunkNumber
      );
    }

    this.bytesUploaded += data.size;
    this.reportProgress();
  }

  private reportProgress(): void {
    if (!this.options.onProgress || !this.session) {
      return;
    }

    const elapsed = (Date.now() - this.startTime) / 1000;
    const speed = elapsed > 0 ? this.bytesUploaded / elapsed : 0;
    const remaining = this.session.file_size - this.bytesUploaded;
    const eta = speed > 0 ? (remaining / speed) * 1000 : 0;

    const progress: Progress = {
      bytesDone: this.bytesUploaded,
      bytesTotal: this.session.file_size,
      chunksDone: Math.floor(
        this.bytesUploaded / this.session.chunk_size
      ),
      chunksTotal: this.session.total_chunks,
      percent: (this.bytesUploaded / this.session.file_size) * 100,
      speed,
      eta,
    };

    this.options.onProgress(progress);
  }

  private async completeUpload(): Promise<UploadResult> {
    if (!this.session) {
      throw new Error('No session');
    }

    const url = `${this.baseUrl}/api/v1/repos/uploads/${this.session.id}/complete`;

    const response = await fetch(url, {
      method: 'POST',
      headers: {
        Authorization: `token ${this.token}`,
      },
    });

    if (!response.ok) {
      const error = await response.text();
      throw new Error(`Failed to complete upload: ${error}`);
    }

    return response.json();
  }

  /**
   * Cancel the upload.
   */
  async cancel(): Promise<void> {
    if (!this.session) {
      return;
    }

    const url = `${this.baseUrl}/api/v1/repos/uploads/${this.session.id}`;

    await fetch(url, {
      method: 'DELETE',
      headers: {
        Authorization: `token ${this.token}`,
      },
    });
  }

  /**
   * Get the current session.
   */
  getSession(): UploadSession | undefined {
    return this.session;
  }
}

/**
 * Format bytes as a human-readable string.
 */
export function formatBytes(bytes: number): string {
  if (bytes < 1024) {
    return `${bytes} B`;
  } else if (bytes < 1024 * 1024) {
    return `${(bytes / 1024).toFixed(1)} KB`;
  } else if (bytes < 1024 * 1024 * 1024) {
    return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
  } else {
    return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`;
  }
}

/**
 * Format duration in milliseconds as a human-readable string.
 */
export function formatDuration(ms: number): string {
  const seconds = Math.floor(ms / 1000);
  if (seconds < 60) {
    return `${seconds}s`;
  }
  const minutes = Math.floor(seconds / 60);
  const remainingSeconds = seconds % 60;
  if (minutes < 60) {
    return `${minutes}m${remainingSeconds}s`;
  }
  const hours = Math.floor(minutes / 60);
  const remainingMinutes = minutes % 60;
  return `${hours}h${remainingMinutes}m`;
}
