// Copyright 2025 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 Host from '../../core/host/host.js';
import type * as Platform from '../../core/platform/platform.js';
import * as ProjectSettings from '../project_settings/project_settings.js';

/**
 * Description and state of the automatic file system.
 */
export interface AutomaticFileSystem {
  root: Platform.DevToolsPath.RawPathString;
  uuid: string;
  state: 'disconnected'|'connecting'|'connected';
}

/**
 * Indicates the availability of the Automatic Workspace Folders feature.
 *
 * `'available'` means that the feature is enabled and the project settings
 * are also available. It doesn't indicate whether or not the page is actually
 * providing a `com.chrome.devtools.json` or not, and whether or not that file
 * (if it exists) provides workspace information.
 */
export type AutomaticFileSystemAvailability = 'available'|'unavailable';

let automaticFileSystemManagerInstance: AutomaticFileSystemManager|undefined;

/**
 * Automatically connects and disconnects workspace folders.
 *
 * @see http://go/chrome-devtools:automatic-workspace-folders-design
 */
export class AutomaticFileSystemManager extends Common.ObjectWrapper.ObjectWrapper<EventTypes> {
  #automaticFileSystem: AutomaticFileSystem|null;
  #availability: AutomaticFileSystemAvailability = 'unavailable';
  #inspectorFrontendHost: Host.InspectorFrontendHostAPI.InspectorFrontendHostAPI;
  #projectSettingsModel: ProjectSettings.ProjectSettingsModel.ProjectSettingsModel;

  /**
   * Yields the current `AutomaticFileSystem` (if any).
   *
   * @returns the current automatic file system or `null`.
   */
  get automaticFileSystem(): Readonly<AutomaticFileSystem>|null {
    return this.#automaticFileSystem;
  }

  /**
   * Yields the availability of the Automatic Workspace Folders feature.
   *
   * `'available'` means that the feature is enabled and the project settings
   * are also available. It doesn't indicate whether or not the page is actually
   * providing a `com.chrome.devtools.json` or not, and whether or not that file
   * (if it exists) provides workspace information.
   *
   * @returns `'available'` if the feature is available and the project settings
   *         feature is also available, otherwise `'unavailable'`.
   */
  get availability(): AutomaticFileSystemAvailability {
    return this.#availability;
  }

  /**
   * @internal
   */
  private constructor(
      inspectorFrontendHost: Host.InspectorFrontendHostAPI.InspectorFrontendHostAPI,
      projectSettingsModel: ProjectSettings.ProjectSettingsModel.ProjectSettingsModel) {
    super();
    this.#automaticFileSystem = null;
    this.#inspectorFrontendHost = inspectorFrontendHost;
    this.#projectSettingsModel = projectSettingsModel;
    this.#inspectorFrontendHost.events.addEventListener(
        Host.InspectorFrontendHostAPI.Events.FileSystemRemoved, this.#fileSystemRemoved, this);
    this.#projectSettingsModel.addEventListener(
        ProjectSettings.ProjectSettingsModel.Events.AVAILABILITY_CHANGED, this.#availabilityChanged, this);
    this.#availabilityChanged({data: this.#projectSettingsModel.availability});
    this.#projectSettingsModel.addEventListener(
        ProjectSettings.ProjectSettingsModel.Events.PROJECT_SETTINGS_CHANGED, this.#projectSettingsChanged, this);
    this.#projectSettingsChanged({data: this.#projectSettingsModel.projectSettings});
  }

  /**
   * Yields the `AutomaticFileSystemManager` singleton.
   *
   * @returns the singleton.
   */
  static instance({forceNew, inspectorFrontendHost, projectSettingsModel}: {
    forceNew: boolean|null,
    inspectorFrontendHost: Host.InspectorFrontendHostAPI.InspectorFrontendHostAPI|null,
    projectSettingsModel: ProjectSettings.ProjectSettingsModel.ProjectSettingsModel|null,
  } = {forceNew: false, inspectorFrontendHost: null, projectSettingsModel: null}): AutomaticFileSystemManager {
    if (!automaticFileSystemManagerInstance || forceNew) {
      if (!inspectorFrontendHost || !projectSettingsModel) {
        throw new Error(
            'Unable to create AutomaticFileSystemManager: ' +
            'inspectorFrontendHost, and projectSettingsModel must be provided');
      }
      automaticFileSystemManagerInstance = new AutomaticFileSystemManager(
          inspectorFrontendHost,
          projectSettingsModel,
      );
    }
    return automaticFileSystemManagerInstance;
  }

  /**
   * Clears the `AutomaticFileSystemManager` singleton (if any);
   */
  static removeInstance(): void {
    if (automaticFileSystemManagerInstance) {
      automaticFileSystemManagerInstance.#dispose();
      automaticFileSystemManagerInstance = undefined;
    }
  }

  #dispose(): void {
    this.#inspectorFrontendHost.events.removeEventListener(
        Host.InspectorFrontendHostAPI.Events.FileSystemRemoved, this.#fileSystemRemoved, this);
    this.#projectSettingsModel.removeEventListener(
        ProjectSettings.ProjectSettingsModel.Events.AVAILABILITY_CHANGED, this.#availabilityChanged, this);
    this.#projectSettingsModel.removeEventListener(
        ProjectSettings.ProjectSettingsModel.Events.PROJECT_SETTINGS_CHANGED, this.#projectSettingsChanged, this);
  }

  #availabilityChanged(
      event: Common.EventTarget.EventTargetEvent<ProjectSettings.ProjectSettingsModel.ProjectSettingsAvailability>):
      void {
    const availability = event.data;
    if (this.#availability !== availability) {
      this.#availability = availability;
      this.dispatchEventToListeners(Events.AVAILABILITY_CHANGED, this.#availability);
    }
  }

  #fileSystemRemoved(event: Common.EventTarget.EventTargetEvent<Platform.DevToolsPath.RawPathString>): void {
    if (this.#automaticFileSystem === null) {
      return;
    }
    if (this.#automaticFileSystem.root === event.data) {
      this.#automaticFileSystem = Object.freeze({
        ...this.#automaticFileSystem,
        state: 'disconnected',
      });
      this.dispatchEventToListeners(Events.AUTOMATIC_FILE_SYSTEM_CHANGED, this.#automaticFileSystem);
    }
  }

  #projectSettingsChanged(
      event: Common.EventTarget.EventTargetEvent<ProjectSettings.ProjectSettingsModel.ProjectSettings>): void {
    const projectSettings = event.data;
    let automaticFileSystem = this.#automaticFileSystem;
    if (projectSettings.workspace) {
      const {root, uuid} = projectSettings.workspace;
      if (automaticFileSystem?.root !== root || automaticFileSystem.uuid !== uuid) {
        automaticFileSystem = Object.freeze({root, uuid, state: 'disconnected'});
      }
    } else if (automaticFileSystem !== null) {
      automaticFileSystem = null;
    }

    if (this.#automaticFileSystem !== automaticFileSystem) {
      this.disconnectedAutomaticFileSystem();
      this.#automaticFileSystem = automaticFileSystem;
      this.dispatchEventToListeners(Events.AUTOMATIC_FILE_SYSTEM_CHANGED, this.#automaticFileSystem);
      void this.connectAutomaticFileSystem(/* addIfMissing= */ false);
    }
  }

  /**
   * Attempt to connect the automatic workspace folder (if any).
   *
   * @param addIfMissing if `false` (the default), this will only try to connect
   *                     to a previously connected automatic workspace folder.
   *                     If the folder was never connected before and `true` is
   *                     specified, the user will be asked to grant permission
   *                     to allow Chrome DevTools to access the folder first.
   * @returns `true` if the automatic workspace folder was connected, `false`
   *          if there wasn't any, or the connection attempt failed (e.g. the
   *          user did not grant permission).
   */
  async connectAutomaticFileSystem(addIfMissing = false): Promise<boolean> {
    if (!this.#automaticFileSystem) {
      return false;
    }
    const {root, uuid, state} = this.#automaticFileSystem;
    if (state === 'disconnected') {
      const automaticFileSystem = this.#automaticFileSystem =
          Object.freeze({...this.#automaticFileSystem, state: 'connecting'});
      this.dispatchEventToListeners(Events.AUTOMATIC_FILE_SYSTEM_CHANGED, this.#automaticFileSystem);
      const {success} = await new Promise<{success: boolean}>(
          resolve => this.#inspectorFrontendHost.connectAutomaticFileSystem(root, uuid, addIfMissing, resolve));
      if (this.#automaticFileSystem === automaticFileSystem) {
        const state = success ? 'connected' : 'disconnected';
        this.#automaticFileSystem = Object.freeze({...automaticFileSystem, state});
        this.dispatchEventToListeners(Events.AUTOMATIC_FILE_SYSTEM_CHANGED, this.#automaticFileSystem);
      }
    }
    return this.#automaticFileSystem?.state === 'connected';
  }

  /**
   * Disconnects any automatic workspace folder.
   */
  disconnectedAutomaticFileSystem(): void {
    if (this.#automaticFileSystem && this.#automaticFileSystem.state !== 'disconnected') {
      this.#inspectorFrontendHost.disconnectAutomaticFileSystem(this.#automaticFileSystem.root);
      this.#automaticFileSystem = Object.freeze({...this.#automaticFileSystem, state: 'disconnected'});
      this.dispatchEventToListeners(Events.AUTOMATIC_FILE_SYSTEM_CHANGED, this.#automaticFileSystem);
    }
  }
}

/**
 * Events emitted by the `AutomaticFileSystemManager`.
 */
export const enum Events {
  /**
   * Emitted whenever the `automaticFileSystem` property of the
   * `AutomaticFileSystemManager` changes.
   */
  AUTOMATIC_FILE_SYSTEM_CHANGED = 'AutomaticFileSystemChanged',

  /**
   * Emitted whenever the `availability` property of the
   * `AutomaticFileSystemManager` changes.
   */
  AVAILABILITY_CHANGED = 'AvailabilityChanged',
}

/**
 * @internal
 */
export interface EventTypes {
  [Events.AUTOMATIC_FILE_SYSTEM_CHANGED]: Readonly<AutomaticFileSystem>|null;
  [Events.AVAILABILITY_CHANGED]: AutomaticFileSystemAvailability;
}
