// Copyright 2024 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 SDK from '../../core/sdk/sdk.js';
import type * as ProtocolProxyApi from '../../generated/protocol-proxy-api.js';
import * as Protocol from '../../generated/protocol.js';

export class ExtensionStorage extends Common.ObjectWrapper.ObjectWrapper<Record<string, never>> {
  readonly #model: ExtensionStorageModel;
  readonly #extensionId: string;
  readonly #name: string;
  readonly #storageArea: Protocol.Extensions.StorageArea;

  constructor(
      model: ExtensionStorageModel, extensionId: string, name: string, storageArea: Protocol.Extensions.StorageArea) {
    super();
    this.#model = model;
    this.#extensionId = extensionId;
    this.#name = name;
    this.#storageArea = storageArea;
  }

  get model(): ExtensionStorageModel {
    return this.#model;
  }

  get extensionId(): string {
    return this.#extensionId;
  }

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

  // Returns a key that uniquely identifies this extension ID and storage area,
  // but which is not unique across targets, so we can identify two identical
  // storage areas across frames.
  get key(): string {
    return `${this.extensionId}-${this.storageArea}`;
  }

  get storageArea(): Protocol.Extensions.StorageArea {
    return this.#storageArea;
  }

  async getItems(keys?: string[]): Promise<Record<string, unknown>> {
    const params: Protocol.Extensions.GetStorageItemsRequest = {
      id: this.#extensionId,
      storageArea: this.#storageArea,
    };
    if (keys) {
      params.keys = keys;
    }
    const response = await this.#model.agent.invoke_getStorageItems(params);
    if (response.getError()) {
      throw new Error(response.getError());
    }
    return response.data;
  }

  async setItem(key: string, value: unknown): Promise<void> {
    const response = await this.#model.agent.invoke_setStorageItems(
        {id: this.#extensionId, storageArea: this.#storageArea, values: {[key]: value}});
    if (response.getError()) {
      throw new Error(response.getError());
    }
  }

  async removeItem(key: string): Promise<void> {
    const response = await this.#model.agent.invoke_removeStorageItems(
        {id: this.#extensionId, storageArea: this.#storageArea, keys: [key]});
    if (response.getError()) {
      throw new Error(response.getError());
    }
  }

  async clear(): Promise<void> {
    const response =
        await this.#model.agent.invoke_clearStorageItems({id: this.#extensionId, storageArea: this.#storageArea});
    if (response.getError()) {
      throw new Error(response.getError());
    }
  }

  matchesTarget(target: SDK.Target.Target|undefined): boolean {
    if (!target) {
      return false;
    }
    const targetURL = target.targetInfo()?.url;
    const parsedURL = targetURL ? Common.ParsedURL.ParsedURL.fromString(targetURL) : null;
    return parsedURL?.scheme === 'chrome-extension' && parsedURL?.host === this.extensionId;
  }
}

export class ExtensionStorageModel extends SDK.SDKModel.SDKModel<EventTypes> {
  readonly #runtimeModel: SDK.RuntimeModel.RuntimeModel|null;
  #storages: Map<string, Map<Protocol.Extensions.StorageArea, ExtensionStorage>>;
  readonly agent: ProtocolProxyApi.ExtensionsApi;
  #enabled?: boolean;

  constructor(target: SDK.Target.Target) {
    super(target);

    this.#runtimeModel = target.model(SDK.RuntimeModel.RuntimeModel);
    this.#storages = new Map();
    this.agent = target.extensionsAgent();
  }

  enable(): void {
    if (this.#enabled) {
      return;
    }

    if (this.#runtimeModel) {
      this.#runtimeModel.addEventListener(
          SDK.RuntimeModel.Events.ExecutionContextCreated, this.#onExecutionContextCreated, this);
      this.#runtimeModel.addEventListener(
          SDK.RuntimeModel.Events.ExecutionContextDestroyed, this.#onExecutionContextDestroyed, this);
      this.#runtimeModel.executionContexts().forEach(this.#executionContextCreated, this);
    }

    this.#enabled = true;
  }

  #getStoragesForExtension(id: string): Map<Protocol.Extensions.StorageArea, ExtensionStorage> {
    const existingStorages = this.#storages.get(id);

    if (existingStorages) {
      return existingStorages;
    }

    const newStorages = new Map();
    this.#storages.set(id, newStorages);
    return newStorages;
  }

  #addExtension(id: string, name: string): void {
    for (const storageArea
             of [Protocol.Extensions.StorageArea.Session, Protocol.Extensions.StorageArea.Local,
                 Protocol.Extensions.StorageArea.Sync, Protocol.Extensions.StorageArea.Managed]) {
      const storages = this.#getStoragesForExtension(id);
      const storage = new ExtensionStorage(this, id, name, storageArea);

      console.assert(!storages.get(storageArea));

      storage.getItems([])
          .then(() => {
            // The extension may have been removed in the meantime.
            if (this.#storages.get(id) !== storages) {
              return;
            }
            // The storage area may have been added in the meantime.
            if (storages.get(storageArea)) {
              return;
            }
            storages.set(storageArea, storage);
            this.dispatchEventToListeners(Events.EXTENSION_STORAGE_ADDED, storage);
          })
          .catch(
              () => {
                  // Storage area is inaccessible (extension may have restricted access
                  // or not enabled the API).
              });
    }
  }

  #removeExtension(id: string): void {
    const storages = this.#storages.get(id);

    if (!storages) {
      return;
    }

    for (const [key, storage] of storages) {
      // Delete this before firing the event, since this matches the behavior
      // of other models and meets expectations for a removed event.
      storages.delete(key);
      this.dispatchEventToListeners(Events.EXTENSION_STORAGE_REMOVED, storage);
    }

    this.#storages.delete(id);
  }

  #executionContextCreated(context: SDK.RuntimeModel.ExecutionContext): void {
    const extensionId = this.#extensionIdForContext(context);
    if (extensionId) {
      this.#addExtension(extensionId, context.name);
    }
  }

  #onExecutionContextCreated(event: Common.EventTarget.EventTargetEvent<SDK.RuntimeModel.ExecutionContext>): void {
    this.#executionContextCreated(event.data);
  }

  #extensionIdForContext(context: SDK.RuntimeModel.ExecutionContext): string|undefined {
    const url = Common.ParsedURL.ParsedURL.fromString(context.origin);
    return url?.scheme === 'chrome-extension' ? url.host : undefined;
  }

  #executionContextDestroyed(context: SDK.RuntimeModel.ExecutionContext): void {
    const extensionId = this.#extensionIdForContext(context);
    if (extensionId) {
      // Ignore event if there is still another context for this extension.
      if (this.#runtimeModel?.executionContexts().some(c => this.#extensionIdForContext(c) === extensionId)) {
        return;
      }

      this.#removeExtension(extensionId);
    }
  }

  #onExecutionContextDestroyed(event: Common.EventTarget.EventTargetEvent<SDK.RuntimeModel.ExecutionContext>): void {
    this.#executionContextDestroyed(event.data);
  }

  storageForIdAndArea(id: string, storageArea: Protocol.Extensions.StorageArea): ExtensionStorage|undefined {
    return this.#storages.get(id)?.get(storageArea);
  }

  storages(): ExtensionStorage[] {
    const result = [];
    for (const storages of this.#storages.values()) {
      result.push(...storages.values());
    }
    return result;
  }
}

SDK.SDKModel.SDKModel.register(ExtensionStorageModel, {capabilities: SDK.Target.Capability.JS, autostart: false});

export const enum Events {
  EXTENSION_STORAGE_ADDED = 'ExtensionStorageAdded',
  EXTENSION_STORAGE_REMOVED = 'ExtensionStorageRemoved',
}

export interface EventTypes {
  [Events.EXTENSION_STORAGE_ADDED]: ExtensionStorage;
  [Events.EXTENSION_STORAGE_REMOVED]: ExtensionStorage;
}
