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

/* eslint-disable @typescript-eslint/no-explicit-any */

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

import {IgnoreListManager} from './IgnoreListManager.js';
import {Events as WorkspaceImplEvents, type Project} from './WorkspaceImpl.js';

const UIStrings = {
  /**
   * @description Text for the index of something
   */
  index: '(index)',
  /**
   * @description Text in UISource Code of the DevTools local workspace
   */
  thisFileWasChangedExternally: 'This file was changed externally. Would you like to reload it?',
} as const;
const str_ = i18n.i18n.registerUIStrings('models/workspace/UISourceCode.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);

export class UISourceCode extends Common.ObjectWrapper.ObjectWrapper<EventTypes> implements
    TextUtils.ContentProvider.ContentProvider {
  readonly #origin: Platform.DevToolsPath.UrlString;
  readonly #parentURL: Platform.DevToolsPath.UrlString;
  #project: Project;
  #url: Platform.DevToolsPath.UrlString;
  #name: string;
  #contentType: Common.ResourceType.ResourceType;
  #requestContentPromise: Promise<TextUtils.ContentData.ContentDataOrError>|null = null;
  #decorations = new Map<string, any>();
  #hasCommits = false;
  #messages: Set<Message>|null = null;
  #content: TextUtils.ContentData.ContentDataOrError|null = null;
  #forceLoadOnCheckContent = false;
  #checkingContent = false;
  #lastAcceptedContent: string|null = null;
  #workingCopy: string|null = null;
  #workingCopyGetter: (() => string)|null = null;
  #disableEdit = false;
  #contentEncoded: boolean|undefined;
  #isKnownThirdParty = false;
  #isUnconditionallyIgnoreListed = false;
  #containsAiChanges = false;

  constructor(project: Project, url: Platform.DevToolsPath.UrlString, contentType: Common.ResourceType.ResourceType) {
    super();
    this.#project = project;
    this.#url = url;

    const parsedURL = Common.ParsedURL.ParsedURL.fromString(url);
    if (parsedURL) {
      this.#origin = parsedURL.securityOrigin();
      this.#parentURL = Common.ParsedURL.ParsedURL.concatenate(this.#origin, parsedURL.folderPathComponents);
      if (parsedURL.queryParams && !(parsedURL.lastPathComponent && contentType.isFromSourceMap())) {
        // If there is a query param, display it like a URL. Unless it is from a source map,
        // in which case the query param is probably a hash that is best left hidden.
        this.#name = parsedURL.lastPathComponent + '?' + parsedURL.queryParams;
      } else {
        // file name looks best decoded
        try {
          this.#name = decodeURIComponent(parsedURL.lastPathComponent);
        } catch {
          // Decoding might fail.
          this.#name = parsedURL.lastPathComponent;
        }
      }
    } else {
      this.#origin = Platform.DevToolsPath.EmptyUrlString;
      this.#parentURL = Platform.DevToolsPath.EmptyUrlString;
      this.#name = url;
    }

    this.#contentType = contentType;
  }

  requestMetadata(): Promise<UISourceCodeMetadata|null> {
    return this.#project.requestMetadata(this);
  }

  name(): string {
    return this.#name;
  }

  mimeType(): string {
    return this.#project.mimeType(this);
  }

  url(): Platform.DevToolsPath.UrlString {
    return this.#url;
  }

  // Identifier used for deduplicating scripts that are considered by the
  // DevTools UI to be the same script. For now this is just the url but this
  // is likely to change in the future.
  canonicalScriptId(): string {
    return `${this.#contentType.name()},${this.#url}`;
  }

  parentURL(): Platform.DevToolsPath.UrlString {
    return this.#parentURL;
  }

  origin(): Platform.DevToolsPath.UrlString {
    return this.#origin;
  }

  fullDisplayName(): string {
    return this.#project.fullDisplayName(this);
  }

  displayName(skipTrim?: boolean): string {
    if (!this.#name) {
      return i18nString(UIStrings.index);
    }
    const name = this.#name;
    return skipTrim ? name : Platform.StringUtilities.trimEndWithMaxLength(name, 100);
  }

  canRename(): boolean {
    return this.#project.canRename();
  }

  rename(newName: Platform.DevToolsPath.RawPathString): Promise<boolean> {
    const {resolve, promise} = Promise.withResolvers<boolean>();
    this.#project.rename(this, newName, innerCallback.bind(this));
    return promise;

    function innerCallback(
        this: UISourceCode, success: boolean, newName?: string, newURL?: Platform.DevToolsPath.UrlString,
        newContentType?: Common.ResourceType.ResourceType): void {
      if (success) {
        this.#updateName(
            newName as Platform.DevToolsPath.RawPathString, newURL as Platform.DevToolsPath.UrlString,
            newContentType as Common.ResourceType.ResourceType);
      }
      resolve(success);
    }
  }

  remove(): void {
    this.#project.deleteFile(this);
  }

  #updateName(
      name: Platform.DevToolsPath.RawPathString, url: Platform.DevToolsPath.UrlString,
      contentType?: Common.ResourceType.ResourceType): void {
    const oldURL = this.#url;
    this.#name = name;
    if (url) {
      this.#url = url;
    } else {
      this.#url = Common.ParsedURL.ParsedURL.relativePathToUrlString(name, oldURL);
    }
    if (contentType) {
      this.#contentType = contentType;
    }
    this.dispatchEventToListeners(Events.TitleChanged, this);
    this.project().workspace().dispatchEventToListeners(
        WorkspaceImplEvents.UISourceCodeRenamed, {oldURL, uiSourceCode: this});
  }

  contentURL(): Platform.DevToolsPath.UrlString {
    return this.url();
  }

  contentType(): Common.ResourceType.ResourceType {
    return this.#contentType;
  }

  project(): Project {
    return this.#project;
  }

  requestContentData({cachedWasmOnly}: {cachedWasmOnly?: boolean} = {}):
      Promise<TextUtils.ContentData.ContentDataOrError> {
    if (this.#requestContentPromise) {
      return this.#requestContentPromise;
    }

    if (this.#content) {
      return Promise.resolve(this.#content);
    }

    if (cachedWasmOnly && this.mimeType() === 'application/wasm') {
      return Promise.resolve(new TextUtils.WasmDisassembly.WasmDisassembly([], [], []));
    }

    this.#requestContentPromise = this.#requestContent();
    return this.#requestContentPromise;
  }

  async #requestContent(): Promise<TextUtils.ContentData.ContentDataOrError> {
    if (this.#content) {
      throw new Error('Called UISourceCode#requestContentImpl even though content is available for ' + this.#url);
    }

    try {
      this.#content = await this.#project.requestFileContent(this);
    } catch (err) {
      this.#content = {error: err ? String(err) : ''};
    }

    return this.#content;
  }

  #decodeContent(content: TextUtils.ContentProvider.DeferredContent|null): string|null {
    if (!content) {
      return null;
    }
    return content.isEncoded && content.content ? window.atob(content.content) : content.content;
  }

  /** Only used to compare whether content changed */
  #unsafeDecodeContentData(content: TextUtils.ContentData.ContentDataOrError|null): string|null {
    if (!content || TextUtils.ContentData.ContentData.isError(content)) {
      return null;
    }
    return content.createdFromBase64 ? window.atob(content.base64) : content.text;
  }

  async checkContentUpdated(): Promise<void> {
    if (!this.#content && !this.#forceLoadOnCheckContent) {
      return;
    }

    if (!this.#project.canSetFileContent() || this.#checkingContent) {
      return;
    }

    this.#checkingContent = true;
    const updatedContent =
        TextUtils.ContentData.ContentData.asDeferredContent(await this.#project.requestFileContent(this));
    if ('error' in updatedContent) {
      return;
    }
    this.#checkingContent = false;
    if (updatedContent.content === null) {
      const workingCopy = this.workingCopy();
      this.#contentCommitted('', false);
      this.setWorkingCopy(workingCopy);
      return;
    }
    if (this.#lastAcceptedContent === updatedContent.content) {
      return;
    }

    if (this.#unsafeDecodeContentData(this.#content) === this.#decodeContent(updatedContent)) {
      this.#lastAcceptedContent = null;
      return;
    }

    if (!this.isDirty() || this.#workingCopy === updatedContent.content) {
      this.#contentCommitted(updatedContent.content, false);
      return;
    }

    await Common.Revealer.reveal(this);

    // Make sure we are in the next frame before stopping the world with confirm
    await new Promise(resolve => window.setTimeout(resolve, 0));

    const shouldUpdate = window.confirm(i18nString(UIStrings.thisFileWasChangedExternally));
    if (shouldUpdate) {
      this.#contentCommitted(updatedContent.content, false);
    } else {
      this.#lastAcceptedContent = updatedContent.content;
    }
  }

  forceLoadOnCheckContent(): void {
    this.#forceLoadOnCheckContent = true;
  }

  #commitContent(content: string): void {
    if (this.#project.canSetFileContent()) {
      void this.#project.setFileContent(this, content, false);
    }
    this.#contentCommitted(content, true);
  }

  #contentCommitted(content: string, committedByUser: boolean): void {
    this.#lastAcceptedContent = null;
    this.#content = new TextUtils.ContentData.ContentData(content, Boolean(this.#contentEncoded), this.mimeType());
    this.#requestContentPromise = null;

    this.#hasCommits = true;

    this.#resetWorkingCopy();
    const data = {uiSourceCode: this, content, encoded: this.#contentEncoded};
    this.dispatchEventToListeners(Events.WorkingCopyCommitted, data);
    this.#project.workspace().dispatchEventToListeners(WorkspaceImplEvents.WorkingCopyCommitted, data);
    if (committedByUser) {
      this.#project.workspace().dispatchEventToListeners(WorkspaceImplEvents.WorkingCopyCommittedByUser, data);
    }
  }

  addRevision(content: string): void {
    this.#commitContent(content);
  }

  hasCommits(): boolean {
    return this.#hasCommits;
  }

  workingCopy(): string {
    return this.workingCopyContent().content || '';
  }

  workingCopyContent(): TextUtils.ContentProvider.DeferredContent {
    return this.workingCopyContentData().asDeferedContent();
  }

  workingCopyContentData(): TextUtils.ContentData.ContentData {
    if (this.#workingCopyGetter) {
      this.#workingCopy = this.#workingCopyGetter();
      this.#workingCopyGetter = null;
    }
    const contentData = this.#content ? TextUtils.ContentData.ContentData.contentDataOrEmpty(this.#content) :
                                        TextUtils.ContentData.EMPTY_TEXT_CONTENT_DATA;
    if (this.#workingCopy !== null) {
      return new TextUtils.ContentData.ContentData(this.#workingCopy, /* isBase64 */ false, contentData.mimeType);
    }
    return contentData;
  }

  resetWorkingCopy(): void {
    this.#resetWorkingCopy();
    this.#workingCopyChanged();
  }

  #resetWorkingCopy(): void {
    this.#workingCopy = null;
    this.#workingCopyGetter = null;
    this.setContainsAiChanges(false);
  }

  setWorkingCopy(newWorkingCopy: string): void {
    this.#workingCopy = newWorkingCopy;
    this.#workingCopyGetter = null;
    this.#workingCopyChanged();
  }

  setContainsAiChanges(containsAiChanges: boolean): void {
    this.#containsAiChanges = containsAiChanges;
  }

  containsAiChanges(): boolean {
    return this.#containsAiChanges;
  }

  setContent(content: string, isBase64: boolean): void {
    this.#contentEncoded = isBase64;
    if (this.#project.canSetFileContent()) {
      void this.#project.setFileContent(this, content, isBase64);
    }
    this.#contentCommitted(content, true);
  }

  setWorkingCopyGetter(workingCopyGetter: () => string): void {
    this.#workingCopyGetter = workingCopyGetter;
    this.#workingCopyChanged();
  }

  #workingCopyChanged(): void {
    this.#removeAllMessages();
    this.dispatchEventToListeners(Events.WorkingCopyChanged, this);
    this.#project.workspace().dispatchEventToListeners(WorkspaceImplEvents.WorkingCopyChanged, {uiSourceCode: this});
  }

  removeWorkingCopyGetter(): void {
    if (!this.#workingCopyGetter) {
      return;
    }
    this.#workingCopy = this.#workingCopyGetter();
    this.#workingCopyGetter = null;
  }

  commitWorkingCopy(): void {
    if (this.isDirty()) {
      this.#commitContent(this.workingCopy());
    }
  }

  isDirty(): boolean {
    return this.#workingCopy !== null || this.#workingCopyGetter !== null;
  }

  isKnownThirdParty(): boolean {
    return this.#isKnownThirdParty;
  }

  markKnownThirdParty(): void {
    this.#isKnownThirdParty = true;
  }

  /**
   * {@link markAsUnconditionallyIgnoreListed}
   */
  isUnconditionallyIgnoreListed(): boolean {
    return this.#isUnconditionallyIgnoreListed;
  }

  isFetchXHR(): boolean {
    return [Common.ResourceType.resourceTypes.XHR, Common.ResourceType.resourceTypes.Fetch].includes(
        this.contentType());
  }

  /**
   * Unconditionally ignore list this UISourcecode, ignoring any user
   * setting. We use this to mark breakpoint/logpoint condition scripts for now.
   */
  markAsUnconditionallyIgnoreListed(): void {
    this.#isUnconditionallyIgnoreListed = true;
  }

  extension(): string {
    return Common.ParsedURL.ParsedURL.extractExtension(this.#name);
  }

  content(): string {
    if (!this.#content || 'error' in this.#content) {
      return '';
    }
    return this.#content.text;
  }

  loadError(): string|null {
    return (this.#content && 'error' in this.#content && this.#content.error) || null;
  }

  searchInContent(query: string, caseSensitive: boolean, isRegex: boolean):
      Promise<TextUtils.ContentProvider.SearchMatch[]> {
    if (!this.#content || 'error' in this.#content) {
      return this.#project.searchInFileContent(this, query, caseSensitive, isRegex);
    }
    return Promise.resolve(
        TextUtils.TextUtils.performSearchInContentData(this.#content, query, caseSensitive, isRegex));
  }

  contentLoaded(): boolean {
    return Boolean(this.#content);
  }

  uiLocation(lineNumber: number, columnNumber?: number): UILocation {
    return new UILocation(this, lineNumber, columnNumber);
  }

  messages(): Set<Message> {
    return this.#messages ? new Set(this.#messages) : new Set();
  }

  addLineMessage(
      level: Message.Level, text: string, lineNumber: number, columnNumber?: number,
      clickHandler?: (() => void)): Message {
    const range = TextUtils.TextRange.TextRange.createFromLocation(lineNumber, columnNumber || 0);
    const message = new Message(level, text, clickHandler, range);
    this.addMessage(message);
    return message;
  }

  addMessage(message: Message): void {
    if (!this.#messages) {
      this.#messages = new Set();
    }
    this.#messages.add(message);
    this.dispatchEventToListeners(Events.MessageAdded, message);
  }

  removeMessage(message: Message): void {
    if (this.#messages?.delete(message)) {
      this.dispatchEventToListeners(Events.MessageRemoved, message);
    }
  }

  #removeAllMessages(): void {
    if (!this.#messages) {
      return;
    }
    for (const message of this.#messages) {
      this.dispatchEventToListeners(Events.MessageRemoved, message);
    }
    this.#messages = null;
  }

  setDecorationData(type: string, data: any): void {
    if (data !== this.#decorations.get(type)) {
      this.#decorations.set(type, data);
      this.dispatchEventToListeners(Events.DecorationChanged, type);
    }
  }

  getDecorationData(type: string): any {
    return this.#decorations.get(type);
  }

  disableEdit(): void {
    this.#disableEdit = true;
  }

  editDisabled(): boolean {
    return this.#disableEdit;
  }

  isIgnoreListed(): boolean {
    return IgnoreListManager.instance().isUserOrSourceMapIgnoreListedUISourceCode(this);
  }
}

export enum Events {
  /* eslint-disable @typescript-eslint/naming-convention -- Used by web_tests. */
  WorkingCopyChanged = 'WorkingCopyChanged',
  WorkingCopyCommitted = 'WorkingCopyCommitted',
  TitleChanged = 'TitleChanged',
  MessageAdded = 'MessageAdded',
  MessageRemoved = 'MessageRemoved',
  DecorationChanged = 'DecorationChanged',
  /* eslint-enable @typescript-eslint/naming-convention */
}

export interface WorkingCopyCommittedEvent {
  uiSourceCode: UISourceCode;
  content: string;
  encoded: boolean|undefined;
}

export interface EventTypes {
  [Events.WorkingCopyChanged]: UISourceCode;
  [Events.WorkingCopyCommitted]: WorkingCopyCommittedEvent;
  [Events.TitleChanged]: UISourceCode;
  [Events.MessageAdded]: Message;
  [Events.MessageRemoved]: Message;
  [Events.DecorationChanged]: string;
}

export class UILocation {
  uiSourceCode: UISourceCode;
  lineNumber: number;
  columnNumber: number|undefined;
  constructor(uiSourceCode: UISourceCode, lineNumber: number, columnNumber?: number) {
    this.uiSourceCode = uiSourceCode;
    this.lineNumber = lineNumber;
    this.columnNumber = columnNumber;
  }

  linkText(skipTrim = false, showColumnNumber = false): string {
    const displayName = this.uiSourceCode.displayName(skipTrim);
    const lineAndColumnText = this.lineAndColumnText(showColumnNumber);
    let text = lineAndColumnText ? displayName + ':' + lineAndColumnText : displayName;
    if (this.uiSourceCode.isDirty()) {
      text = '*' + text;
    }
    return text;
  }

  lineAndColumnText(showColumnNumber = false): string|undefined {
    let lineAndColumnText;
    if (this.uiSourceCode.mimeType() === 'application/wasm') {
      // For WebAssembly locations, we follow the conventions described in
      // github.com/WebAssembly/design/blob/master/Web.md#developer-facing-display-conventions
      if (typeof this.columnNumber === 'number') {
        lineAndColumnText = `0x${this.columnNumber.toString(16)}`;
      }
    } else {
      lineAndColumnText = `${this.lineNumber + 1}`;
      if (showColumnNumber && typeof this.columnNumber === 'number') {
        lineAndColumnText += ':' + (this.columnNumber + 1);
      }
    }
    return lineAndColumnText;
  }

  id(): string {
    if (typeof this.columnNumber === 'number') {
      return this.uiSourceCode.project().id() + ':' + this.uiSourceCode.url() + ':' + this.lineNumber + ':' +
          this.columnNumber;
    }
    return this.lineId();
  }

  lineId(): string {
    return this.uiSourceCode.project().id() + ':' + this.uiSourceCode.url() + ':' + this.lineNumber;
  }

  static comparator(location1: UILocation, location2: UILocation): number {
    return location1.compareTo(location2);
  }

  compareTo(other: UILocation): number {
    if (this.uiSourceCode.url() !== other.uiSourceCode.url()) {
      return this.uiSourceCode.url() > other.uiSourceCode.url() ? 1 : -1;
    }
    if (this.lineNumber !== other.lineNumber) {
      return this.lineNumber - other.lineNumber;
    }
    // We consider `undefined` less than an actual column number, since
    // UI location without a column number corresponds to the whole line.
    if (this.columnNumber === other.columnNumber) {
      return 0;
    }
    if (typeof this.columnNumber !== 'number') {
      return -1;
    }
    if (typeof other.columnNumber !== 'number') {
      return 1;
    }
    return this.columnNumber - other.columnNumber;
  }

  isIgnoreListed(): boolean {
    return this.uiSourceCode.isIgnoreListed();
  }
}

/**
 * A text range inside a specific {@link UISourceCode}.
 *
 * We use a class instead of an interface so we can implement a revealer for it.
 */
export class UILocationRange {
  readonly uiSourceCode: UISourceCode;
  readonly range: TextUtils.TextRange.TextRange;

  constructor(uiSourceCode: UISourceCode, range: TextUtils.TextRange.TextRange) {
    this.uiSourceCode = uiSourceCode;
    this.range = range;
  }
}

/**
 * A text range inside a specific {@link UISourceCode}, representing a function.
 */
export class UIFunctionBounds {
  readonly uiSourceCode: UISourceCode;
  readonly range: TextUtils.TextRange.TextRange;
  readonly name: string;

  constructor(uiSourceCode: UISourceCode, range: TextUtils.TextRange.TextRange, name: string) {
    this.uiSourceCode = uiSourceCode;
    this.range = range;
    this.name = name;
  }
}

/**
 * A message associated with a range in a `UISourceCode`. The range will be
 * underlined starting at the range's start and ending at the line end (the
 * end of the range is currently disregarded).
 * An icon is going to appear at the end of the line according to the
 * `level` of the Message. This is only the model; displaying is handled
 * where UISourceCode displaying is handled.
 */
export class Message {
  readonly #level: Message.Level;
  readonly #text: string;
  range: TextUtils.TextRange.TextRange;
  readonly #clickHandler?: (() => void);

  constructor(level: Message.Level, text: string, clickHandler?: (() => void), range?: TextUtils.TextRange.TextRange) {
    this.#level = level;
    this.#text = text;
    this.range = range ?? new TextUtils.TextRange.TextRange(0, 0, 0, 0);
    this.#clickHandler = clickHandler;
  }

  level(): Message.Level {
    return this.#level;
  }

  text(): string {
    return this.#text;
  }

  clickHandler(): (() => void)|undefined {
    return this.#clickHandler;
  }

  lineNumber(): number {
    return this.range.startLine;
  }

  columnNumber(): number|undefined {
    return this.range.startColumn;
  }

  isEqual(another: Message): boolean {
    return this.text() === another.text() && this.level() === another.level() && this.range.equal(another.range);
  }
}

export namespace Message {
  export const enum Level {
    ERROR = 'Error',
    ISSUE = 'Issue',
    WARNING = 'Warning',
  }
}

export class UISourceCodeMetadata {
  modificationTime: Date|null;
  contentSize: number|null;

  constructor(modificationTime: Date|null, contentSize: number|null) {
    this.modificationTime = modificationTime;
    this.contentSize = contentSize;
  }
}

export const enum DecoratorType {
  PERFORMANCE = 'performance',
  MEMORY = 'memory',
  COVERAGE = 'coverage',
}

/** 1-based. line => column => value */
export type LineColumnProfileMap = Map<number, Map<number, number>>;
/** Used by ProfilePlugin to track runtime/memory costs. */
export type ProfileDataMap = Map<UISourceCode, LineColumnProfileMap>;

/**
 * Converts an existing LineColumnProfileMap to a new one using the provided mapping.
 *
 * The input and output line/column of originalToMappedLocation is 0-indexed.
 */
export function createMappedProfileData(
    profileData: LineColumnProfileMap,
    originalToMappedLocation: (line: number, column: number) => number[] | null): LineColumnProfileMap {
  const mappedProfileData: LineColumnProfileMap = new Map();
  for (const [lineNumber, columnData] of profileData) {
    for (const [columnNumber, data] of columnData) {
      const mappedLocation = originalToMappedLocation(lineNumber - 1, columnNumber - 1);
      if (!mappedLocation) {
        continue;
      }

      const oneBasedFormattedLineNumber = mappedLocation[0] + 1;
      const oneBasedFormattedColumnNumber = mappedLocation[1] + 1;
      let mappedColumnData = mappedProfileData.get(oneBasedFormattedLineNumber);
      if (!mappedColumnData) {
        mappedColumnData = new Map();
        mappedProfileData.set(oneBasedFormattedLineNumber, mappedColumnData);
      }
      mappedColumnData.set(
          oneBasedFormattedColumnNumber, (mappedColumnData.get(oneBasedFormattedColumnNumber) || 0) + data);
    }
  }

  return mappedProfileData;
}
