// Copyright (C) 2020 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import {defer} from './deferred';
import {Time} from './time';
import {sha1, digestToHex} from './hash';

export const BUCKET_NAME = 'perfetto-ui-data';
export const MIME_JSON = 'application/json; charset=utf-8';
export const MIME_BINARY = 'application/octet-stream';

// Matches a SHA-1 hash, the only format used as a file name in the bucket.
// Reject anything else to prevent path traversal when concatenating into URLs.
export function isValidGcsFileName(name: string): boolean {
  return /^[a-fA-F0-9]{40}$/.test(name);
}

export interface GcsUploaderArgs {
  /**
   * The mime-type to use for the upload. If undefined uses
   * application/octet-stream.
   */
  mimeType?: string;

  /**
   * The name to use for the uploaded file. By default it uses a hash of
   * the passed data/blob and uses content-addressing.
   */
  fileName?: string;

  /** An optional callback that is invoked upon upload progress (or failure) */
  onProgress?: (uploader: GcsUploader) => void;
}

/**
 * A utility class to handle uploads of possibly large files to
 * Google Cloud Storage.
 * It returns immediately if the file exists already
 */
export class GcsUploader {
  state: 'UPLOADING' | 'UPLOADED' | 'ERROR' = 'UPLOADING';
  error = '';
  totalSize = 0;
  uploadedSize = 0;
  uploadedUrl = '';
  uploadedFileName = '';

  private args: GcsUploaderArgs;
  private onProgress: (_: GcsUploader) => void;
  private req: XMLHttpRequest;
  private donePromise = defer<void>();
  private startTime = performance.now();

  constructor(data: Blob | ArrayBuffer | string, args: GcsUploaderArgs) {
    this.args = args;
    this.onProgress = args.onProgress ?? ((_: GcsUploader) => {});
    this.req = new XMLHttpRequest();
    this.start(data);
  }

  private async start(data: Blob | ArrayBuffer | string) {
    let fname = this.args.fileName;
    if (fname === undefined) {
      // If the file name is unspecified, hash the contents.
      if (data instanceof Blob) {
        fname = await hashFileStreaming(data);
      } else {
        fname = await sha1(data);
      }
    }
    this.uploadedFileName = fname;
    this.uploadedUrl = `https://storage.googleapis.com/${BUCKET_NAME}/${fname}`;

    // Check if the file has been uploaded already. If so, skip.
    const res = await fetch(
      `https://www.googleapis.com/storage/v1/b/${BUCKET_NAME}/o/${fname}`,
    );
    if (res.status === 200) {
      console.log(
        `Skipping upload of ${this.uploadedUrl} because it exists already`,
      );
      this.state = 'UPLOADED';
      this.donePromise.resolve();
      return;
    }

    const reqUrl =
      'https://www.googleapis.com/upload/storage/v1/b/' +
      `${BUCKET_NAME}/o?uploadType=media` +
      `&name=${fname}&predefinedAcl=publicRead`;
    this.req.onabort = (e: ProgressEvent) => this.onRpcEvent(e);
    this.req.onerror = (e: ProgressEvent) => this.onRpcEvent(e);
    this.req.upload.onprogress = (e: ProgressEvent) => this.onRpcEvent(e);
    this.req.onloadend = (e: ProgressEvent) => this.onRpcEvent(e);
    this.req.open('POST', reqUrl, /* async= */ true);
    const mimeType = this.args.mimeType ?? MIME_BINARY;
    this.req.setRequestHeader('Content-Type', mimeType);
    this.req.send(data);
  }

  waitForCompletion(): Promise<void> {
    return this.donePromise;
  }

  abort() {
    if (this.state === 'UPLOADING') {
      this.req.abort();
    }
  }

  getEtaString() {
    let str = `${Math.ceil((100 * this.uploadedSize) / this.totalSize)}%`;
    str += ` (${(this.uploadedSize / 1e6).toFixed(2)} MB)`;
    const elapsed = (performance.now() - this.startTime) / 1000;
    const rate = this.uploadedSize / elapsed;
    const etaSecs = Math.round((this.totalSize - this.uploadedSize) / rate);
    str += ' - ETA: ' + Time.toTimecode(Time.fromSeconds(etaSecs)).dhhmmss;
    return str;
  }

  private onRpcEvent(e: ProgressEvent) {
    let done = false;
    switch (e.type) {
      case 'progress':
        this.uploadedSize = e.loaded;
        this.totalSize = e.total;
        break;
      case 'abort':
        this.state = 'ERROR';
        this.error = 'Upload aborted';
        break;
      case 'error':
        this.state = 'ERROR';
        this.error = `${this.req.status} - ${this.req.statusText}`;
        break;
      case 'loadend':
        done = true;
        if (this.req.status === 200) {
          this.state = 'UPLOADED';
        } else if (this.state === 'UPLOADING') {
          this.state = 'ERROR';
          this.error = `${this.req.status} - ${this.req.statusText}`;
        }
        break;
      default:
        return;
    }
    this.onProgress(this);
    if (done) {
      this.donePromise.resolve();
    }
  }
}

/**
 * Converts a hash for the given file in streaming mode, without loading the
 * whole file into memory. The result is "a" SHA-1 but is not the same of
 * `shasum -a 1 file`. The reason for this is that the crypto APIs support
 * only one-shot digest computation and lack the usual update() + digest()
 * chunked API. So we end up computing a SHA-1 of the concatenation of the
 * SHA-1 of each chunk.
 * Speed: ~800 MB/s on a M2 Macbook Air 2023.
 * @param file The file to hash.
 * @returns A hex-encoded string containing the hash of the file.
 */
async function hashFileStreaming(file: Blob): Promise<string> {
  const CHUNK_SIZE = 32 * 1024 * 1024; // 32MB
  const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
  let chunkDigests = '';

  for (let i = 0; i < totalChunks; i++) {
    const start = i * CHUNK_SIZE;
    const end = Math.min(start + CHUNK_SIZE, file.size);
    const chunk = await file.slice(start, end).arrayBuffer();
    const digest = await crypto.subtle.digest('SHA-1', chunk);
    chunkDigests += digestToHex(digest);
  }
  return sha1(chunkDigests);
}
