// Copyright 2023 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import * as Common from '../../core/common/common.js';
import * as Platform from '../../core/platform/platform.js';

import {contentAsDataURL, type DeferredContent} from './ContentProvider.js';
import {Text} from './Text.js';

const objectUrlRegistry = new FinalizationRegistry<string>(url => {
  URL.revokeObjectURL(url);
});

/**
 * The maximum size in bytes that we are willing to convert to a Blob.
 * 10MB is chosen as a safe upper limit to prevent freezing the DevTools UI
 * when synchronously decoding large Base64 strings.
 */
const MAX_BLOB_SIZE_BYTES = 10 * 1024 * 1024;

/**
 * This class is a small wrapper around either raw binary or text data.
 * As the binary data can actually contain textual data, we also store the
 * MIME type and if applicable, the charset.
 *
 * This information should be generally kept together, as interpreting text
 * from raw bytes requires an encoding.
 *
 * Note that we only rarely have to decode text ourselves in the frontend,
 * this is mostly handled by the backend. There are cases though (e.g. SVG,
 * or streaming response content) where we receive text data in
 * binary (base64-encoded) form.
 *
 * The class only implements decoding. We currently don't have a use-case
 * to re-encode text into base64 bytes using a specified charset.
 */
export class ContentData {
  readonly mimeType: string;
  readonly charset: string;
  #contentAsBase64?: string;
  #contentAsText?: string;
  #contentAsTextObj?: Text;
  #imagePreviewUrl?: string;

  constructor(data: string, isBase64: boolean, mimeType: string, charset?: string) {
    this.charset = charset || 'utf-8';
    if (isBase64) {
      this.#contentAsBase64 = data;
    } else {
      this.#contentAsText = data;
    }

    this.mimeType = mimeType;
    if (!this.mimeType) {
      // Tests or broken requests might pass an empty/undefined mime type. Fallback to
      // "default" mime types.
      this.mimeType = isBase64 ? 'application/octet-stream' : 'text/plain';
    }
  }

  /**
   * Returns the data as base64.
   *
   * @throws if this `ContentData` was constructed from text content.
   */
  get base64(): string {
    if (this.#contentAsBase64 === undefined) {
      throw new Error('Encoding text content as base64 is not supported');
    }
    return this.#contentAsBase64;
  }

  /**
   * Returns the content as text. If this `ContentData` was constructed with base64
   * encoded bytes, it will use the provided charset to attempt to decode the bytes.
   *
   * @throws if `mimeType` is not a text type.
   */
  get text(): string {
    if (this.#contentAsText !== undefined) {
      return this.#contentAsText;
    }

    if (!this.isTextContent) {
      throw new Error('Cannot interpret binary data as text');
    }

    const bytes = Common.Base64.decode(this.#contentAsBase64 as string);
    this.#contentAsText = new TextDecoder(this.charset).decode(bytes);
    return this.#contentAsText;
  }

  /** @returns true, if this `ContentData` was constructed from text content or the mime type indicates text that can be decoded */
  get isTextContent(): boolean {
    return this.#createdFromText || Platform.MimeType.isTextType(this.mimeType);
  }

  get isEmpty(): boolean {
    // Don't trigger unnecessary decoding. Only check if both of the strings are empty.
    return !Boolean(this.#contentAsBase64) && !Boolean(this.#contentAsText);
  }

  get createdFromBase64(): boolean {
    return this.#contentAsBase64 !== undefined;
  }

  get #createdFromText(): boolean {
    return this.#contentAsBase64 === undefined;
  }

  /**
   * Returns the text content as a `Text` object. The returned object is always the same to
   * minimize the number of times we have to calculate the line endings array.
   *
   * @throws if `mimeType` is not a text type.
   */
  get textObj(): Text {
    if (this.#contentAsTextObj === undefined) {
      this.#contentAsTextObj = new Text(this.text);
    }
    return this.#contentAsTextObj;
  }

  /**
   * @returns True, iff the contents (base64 or text) are equal.
   * Does not compare mime type and charset, but will decode base64 data if both
   * mime types indicate that it's text content.
   */
  contentEqualTo(other: ContentData): boolean {
    if (this.#contentAsBase64 !== undefined && other.#contentAsBase64 !== undefined) {
      return this.#contentAsBase64 === other.#contentAsBase64;
    }
    if (this.#contentAsText !== undefined && other.#contentAsText !== undefined) {
      return this.#contentAsText === other.#contentAsText;
    }
    if (this.isTextContent && other.isTextContent) {
      return this.text === other.text;
    }
    return false;
  }

  asDataUrl(): string|null {
    // To keep with existing behavior we prefer to return the content
    // encoded if that is how this ContentData was constructed with.
    if (this.#contentAsBase64 !== undefined) {
      const charset = this.isTextContent ? this.charset : null;
      return contentAsDataURL(this.#contentAsBase64, this.mimeType ?? '', true, charset);
    }
    return contentAsDataURL(this.text, this.mimeType ?? '', false, 'utf-8');
  }

  /**
   * Returns the content as a Blob.
   *
   * We prefer Base64 as the source for the Blob because it represents the raw binary
   * bytes. Converting binary data (like an image) to a UTF-16 string (contentAsText)
   * is destructive and will corrupt the image data.
   *
   * @returns The Blob representation, or `null` if the content exceeds the 10MB safety limit.
   */
  asBlob(): Blob|null {
    // We prefer Base64 as the source for the Blob because it represents the raw binary
    // bytes. Converting binary data (like an image) to a UTF-16 string (contentAsText)
    // is destructive and will corrupt the image data.
    if (this.#contentAsBase64 !== undefined) {
      // Base64 encoding uses 4 characters to represent 3 bytes of data.
      // Therefore, the byte size is approximately 75% of the string length.
      if (this.#contentAsBase64.length * 0.75 > MAX_BLOB_SIZE_BYTES) {
        return null;
      }
      const bytes = Common.Base64.decode(this.#contentAsBase64);
      return new Blob([bytes], {type: this.mimeType});
    }
    const text = this.#contentAsText ?? '';
    if (text.length > MAX_BLOB_SIZE_BYTES) {
      return null;
    }
    return new Blob([text], {type: this.mimeType});
  }

  /**
   * Gets the image as either a data: or blob: URL.
   *
   * We prefer data: for simplicity, but these are limited in size to 1MB. If
   * the resource is >1MB, we fall back to a blob: URL.
   *
   * @returns An object URL, or `null` if the content exceeds the 10MB safety limit.
   */
  asImagePreviewUrl(): string|null {
    if (this.#imagePreviewUrl) {
      return this.#imagePreviewUrl;
    }
    const url = this.asDataUrl();
    if (url !== null) {
      this.#imagePreviewUrl = url;
      return this.#imagePreviewUrl;
    }

    const blob = this.asBlob();
    if (blob === null) {
      return null;
    }
    this.#imagePreviewUrl = URL.createObjectURL(blob);
    objectUrlRegistry.register(this, this.#imagePreviewUrl);
    return this.#imagePreviewUrl;
  }

  /**
   * @deprecated Used during migration from `DeferredContent` to `ContentData`.
   */
  asDeferedContent(): DeferredContent {
    // To prevent encoding mistakes, we'll return text content already decoded.
    if (this.isTextContent) {
      return {content: this.text, isEncoded: false};
    }
    if (this.#contentAsText !== undefined) {
      // Unknown text mime type, this should not really happen.
      return {content: this.#contentAsText, isEncoded: false};
    }
    if (this.#contentAsBase64 !== undefined) {
      return {content: this.#contentAsBase64, isEncoded: true};
    }
    throw new Error('Unreachable');
  }

  static isError(contentDataOrError: ContentDataOrError): contentDataOrError is {error: string} {
    return 'error' in contentDataOrError;
  }

  /** @returns `value` if the passed `ContentDataOrError` is an error, or the text content otherwise */
  static textOr<T>(contentDataOrError: ContentDataOrError, value: T): string|T {
    if (ContentData.isError(contentDataOrError)) {
      return value;
    }
    return contentDataOrError.text;
  }

  /** @returns an empty 'text/plain' content data if the passed `ContentDataOrError` is an error, or the content data itself otherwise */
  static contentDataOrEmpty(contentDataOrError: ContentDataOrError): ContentData {
    if (ContentData.isError(contentDataOrError)) {
      return EMPTY_TEXT_CONTENT_DATA;
    }
    return contentDataOrError;
  }

  /**
   * @deprecated Used during migration from `DeferredContent` to `ContentData`.
   */
  static asDeferredContent(contentDataOrError: ContentDataOrError): DeferredContent {
    if (ContentData.isError(contentDataOrError)) {
      return {error: contentDataOrError.error, content: null, isEncoded: false};
    }
    return contentDataOrError.asDeferedContent();
  }
}

export const EMPTY_TEXT_CONTENT_DATA = new ContentData('', /* isBase64 */ false, 'text/plain');

export type ContentDataOrError = ContentData|{error: string};
