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

import type * as ProtocolProxyApi from '../../generated/protocol-proxy-api.js';
import type * as Protocol from '../../generated/protocol.js';
import * as Common from '../common/common.js';
import * as i18n from '../i18n/i18n.js';
import type * as Platform from '../platform/platform.js';

import type {NameValue} from './NetworkRequest.js';
import {SDKModel} from './SDKModel.js';
import {type BucketEvent, Events as StorageBucketsModelEvents, StorageBucketsModel} from './StorageBucketsModel.js';
import {Capability, type Target} from './Target.js';

const UIStrings = {
  /**
   * @description Text in Service Worker Cache Model
   * @example {https://cache} PH1
   * @example {error message} PH2
   */
  serviceworkercacheagentError: '`ServiceWorkerCacheAgent` error deleting cache entry {PH1} in cache: {PH2}',
} as const;
const str_ = i18n.i18n.registerUIStrings('core/sdk/ServiceWorkerCacheModel.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);

export class ServiceWorkerCacheModel extends SDKModel<EventTypes> implements ProtocolProxyApi.StorageDispatcher {
  readonly cacheAgent: ProtocolProxyApi.CacheStorageApi;
  readonly #storageAgent: ProtocolProxyApi.StorageApi;
  readonly #storageBucketModel: StorageBucketsModel;

  readonly #caches = new Map<string, Cache>();
  readonly #storageKeysTracked = new Set<string>();
  readonly #storageBucketsUpdated = new Set<Protocol.Storage.StorageBucket>();
  readonly #throttler = new Common.Throttler.Throttler(2000);
  #enabled = false;

  // Used by tests to remove the Throttler timeout.
  #scheduleAsSoonAsPossible = false;

  /**
   * Invariant: This #model can only be constructed on a ServiceWorker target.
   */
  constructor(target: Target) {
    super(target);
    target.registerStorageDispatcher(this);

    this.cacheAgent = target.cacheStorageAgent();
    this.#storageAgent = target.storageAgent();
    this.#storageBucketModel = (target.model(StorageBucketsModel) as StorageBucketsModel);
  }

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

    this.#storageBucketModel.addEventListener(StorageBucketsModelEvents.BUCKET_ADDED, this.storageBucketAdded, this);
    this.#storageBucketModel.addEventListener(
        StorageBucketsModelEvents.BUCKET_REMOVED, this.storageBucketRemoved, this);

    for (const storageBucket of this.#storageBucketModel.getBuckets()) {
      this.addStorageBucket(storageBucket.bucket);
    }
    this.#enabled = true;
  }

  clearForStorageKey(storageKey: string): void {
    for (const [opaqueId, cache] of this.#caches.entries()) {
      if (cache.storageKey === storageKey) {
        this.#caches.delete((opaqueId));
        this.cacheRemoved((cache));
      }
    }
    for (const storageBucket of this.#storageBucketModel.getBucketsForStorageKey(storageKey)) {
      void this.loadCacheNames(storageBucket.bucket);
    }
  }

  refreshCacheNames(): void {
    for (const cache of this.#caches.values()) {
      this.cacheRemoved(cache);
    }
    this.#caches.clear();
    const storageBuckets = this.#storageBucketModel.getBuckets();
    for (const storageBucket of storageBuckets) {
      void this.loadCacheNames(storageBucket.bucket);
    }
  }

  async deleteCache(cache: Cache): Promise<void> {
    const response = await this.cacheAgent.invoke_deleteCache({cacheId: cache.cacheId});
    if (response.getError()) {
      console.error(`ServiceWorkerCacheAgent error deleting cache ${cache.toString()}: ${response.getError()}`);
      return;
    }
    this.#caches.delete(cache.cacheId);
    this.cacheRemoved(cache);
  }

  async deleteCacheEntry(cache: Cache, request: string): Promise<void> {
    const response = await this.cacheAgent.invoke_deleteEntry({cacheId: cache.cacheId, request});
    if (response.getError()) {
      Common.Console.Console.instance().error(i18nString(
          UIStrings.serviceworkercacheagentError, {PH1: cache.toString(), PH2: String(response.getError())}));
      return;
    }
  }

  loadCacheData(
      cache: Cache, skipCount: number, pageSize: number, pathFilter: string,
      callback: (arg0: Protocol.CacheStorage.DataEntry[], arg1: number) => void): void {
    void this.requestEntries(cache, skipCount, pageSize, pathFilter, callback);
  }

  loadAllCacheData(
      cache: Cache, pathFilter: string,
      callback: (arg0: Protocol.CacheStorage.DataEntry[], arg1: number) => void): void {
    void this.requestAllEntries(cache, pathFilter, callback);
  }

  caches(): Cache[] {
    return [...this.#caches.values()];
  }

  override dispose(): void {
    for (const cache of this.#caches.values()) {
      this.cacheRemoved(cache);
    }
    this.#caches.clear();
    if (this.#enabled) {
      this.#storageBucketModel.removeEventListener(
          StorageBucketsModelEvents.BUCKET_ADDED, this.storageBucketAdded, this);
      this.#storageBucketModel.removeEventListener(
          StorageBucketsModelEvents.BUCKET_REMOVED, this.storageBucketRemoved, this);
    }
  }

  private addStorageBucket(storageBucket: Protocol.Storage.StorageBucket): void {
    void this.loadCacheNames(storageBucket);
    if (!this.#storageKeysTracked.has(storageBucket.storageKey)) {
      this.#storageKeysTracked.add(storageBucket.storageKey);
      void this.#storageAgent.invoke_trackCacheStorageForStorageKey({storageKey: storageBucket.storageKey});
    }
  }

  private removeStorageBucket(storageBucket: Protocol.Storage.StorageBucket): void {
    let storageKeyCount = 0;
    for (const [opaqueId, cache] of this.#caches.entries()) {
      if (storageBucket.storageKey === cache.storageKey) {
        storageKeyCount++;
      }
      if (cache.inBucket(storageBucket)) {
        storageKeyCount--;
        this.#caches.delete((opaqueId));
        this.cacheRemoved((cache));
      }
    }
    if (storageKeyCount === 0) {
      this.#storageKeysTracked.delete(storageBucket.storageKey);
      void this.#storageAgent.invoke_untrackCacheStorageForStorageKey({storageKey: storageBucket.storageKey});
    }
  }

  private async loadCacheNames(storageBucket: Protocol.Storage.StorageBucket): Promise<void> {
    const response = await this.cacheAgent.invoke_requestCacheNames({storageBucket});
    if (response.getError()) {
      return;
    }
    this.updateCacheNames(storageBucket, response.caches);
  }

  private updateCacheNames(storageBucket: Protocol.Storage.StorageBucket, cachesJson: Protocol.CacheStorage.Cache[]):
      void {
    function deleteAndSaveOldCaches(this: ServiceWorkerCacheModel, cache: Cache): void {
      if (cache.inBucket(storageBucket) && !updatingCachesIds.has(cache.cacheId)) {
        oldCaches.set(cache.cacheId, cache);
        this.#caches.delete(cache.cacheId);
      }
    }

    const updatingCachesIds = new Set<string>();
    const newCaches = new Map<string, Cache>();
    const oldCaches = new Map<string, Cache>();

    for (const cacheJson of cachesJson) {
      const storageBucket = cacheJson.storageBucket ??
          this.#storageBucketModel.getDefaultBucketForStorageKey(cacheJson.storageKey)?.bucket;
      if (!storageBucket) {
        continue;
      }
      const cache = new Cache(this, storageBucket, cacheJson.cacheName, cacheJson.cacheId);
      updatingCachesIds.add(cache.cacheId);
      if (this.#caches.has(cache.cacheId)) {
        continue;
      }
      newCaches.set(cache.cacheId, cache);
      this.#caches.set(cache.cacheId, cache);
    }
    this.#caches.forEach(deleteAndSaveOldCaches, this);
    newCaches.forEach(this.cacheAdded, this);
    oldCaches.forEach(this.cacheRemoved, this);
  }

  private storageBucketAdded({data: {bucketInfo: {bucket}}}: Common.EventTarget.EventTargetEvent<BucketEvent>): void {
    this.addStorageBucket(bucket);
  }

  private storageBucketRemoved({data: {bucketInfo: {bucket}}}: Common.EventTarget.EventTargetEvent<BucketEvent>): void {
    this.removeStorageBucket(bucket);
  }

  private cacheAdded(cache: Cache): void {
    this.dispatchEventToListeners(Events.CACHE_ADDED, {model: this, cache});
  }

  private cacheRemoved(cache: Cache): void {
    this.dispatchEventToListeners(Events.CACHE_REMOVED, {model: this, cache});
  }

  private async requestEntries(
      cache: Cache, skipCount: number, pageSize: number, pathFilter: string,
      callback: (arg0: Protocol.CacheStorage.DataEntry[], arg1: number) => void): Promise<void> {
    const response =
        await this.cacheAgent.invoke_requestEntries({cacheId: cache.cacheId, skipCount, pageSize, pathFilter});
    if (response.getError()) {
      console.error('ServiceWorkerCacheAgent error while requesting entries: ', response.getError());
      return;
    }
    callback(response.cacheDataEntries, response.returnCount);
  }

  private async requestAllEntries(
      cache: Cache, pathFilter: string,
      callback: (arg0: Protocol.CacheStorage.DataEntry[], arg1: number) => void): Promise<void> {
    const response = await this.cacheAgent.invoke_requestEntries({cacheId: cache.cacheId, pathFilter});
    if (response.getError()) {
      console.error('ServiceWorkerCacheAgent error while requesting entries: ', response.getError());
      return;
    }
    callback(response.cacheDataEntries, response.returnCount);
  }

  cacheStorageListUpdated({bucketId}: Protocol.Storage.CacheStorageListUpdatedEvent): void {
    const storageBucket = this.#storageBucketModel.getBucketById(bucketId)?.bucket;
    if (storageBucket) {
      this.#storageBucketsUpdated.add(storageBucket);

      void this.#throttler.schedule(
          () => {
            const promises =
                Array.from(this.#storageBucketsUpdated, storageBucket => this.loadCacheNames(storageBucket));
            this.#storageBucketsUpdated.clear();
            return Promise.all(promises);
          },
          this.#scheduleAsSoonAsPossible ? Common.Throttler.Scheduling.AS_SOON_AS_POSSIBLE :
                                           Common.Throttler.Scheduling.DEFAULT);
    }
  }

  cacheStorageContentUpdated({bucketId, cacheName}: Protocol.Storage.CacheStorageContentUpdatedEvent): void {
    const storageBucket = this.#storageBucketModel.getBucketById(bucketId)?.bucket;
    if (storageBucket) {
      this.dispatchEventToListeners(Events.CACHE_STORAGE_CONTENT_UPDATED, {storageBucket, cacheName});
    }
  }

  indexedDBListUpdated(_event: Protocol.Storage.IndexedDBListUpdatedEvent): void {
  }

  indexedDBContentUpdated(_event: Protocol.Storage.IndexedDBContentUpdatedEvent): void {
  }

  interestGroupAuctionEventOccurred(_event: Protocol.Storage.InterestGroupAuctionEventOccurredEvent): void {
  }

  interestGroupAccessed(_event: Protocol.Storage.InterestGroupAccessedEvent): void {
  }

  interestGroupAuctionNetworkRequestCreated(_event: Protocol.Storage.InterestGroupAuctionNetworkRequestCreatedEvent):
      void {
  }

  sharedStorageAccessed(_event: Protocol.Storage.SharedStorageAccessedEvent): void {
  }

  sharedStorageWorkletOperationExecutionFinished(
      _event: Protocol.Storage.SharedStorageWorkletOperationExecutionFinishedEvent): void {
  }

  storageBucketCreatedOrUpdated(_event: Protocol.Storage.StorageBucketCreatedOrUpdatedEvent): void {
  }

  storageBucketDeleted(_event: Protocol.Storage.StorageBucketDeletedEvent): void {
  }

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

export const enum Events {
  CACHE_ADDED = 'CacheAdded',
  CACHE_REMOVED = 'CacheRemoved',
  CACHE_STORAGE_CONTENT_UPDATED = 'CacheStorageContentUpdated',
}

export interface CacheEvent {
  model: ServiceWorkerCacheModel;
  cache: Cache;
}

export interface CacheStorageContentUpdatedEvent {
  storageBucket: Protocol.Storage.StorageBucket;
  cacheName: string;
}

export interface EventTypes {
  [Events.CACHE_ADDED]: CacheEvent;
  [Events.CACHE_REMOVED]: CacheEvent;
  [Events.CACHE_STORAGE_CONTENT_UPDATED]: CacheStorageContentUpdatedEvent;
}

export class Cache {
  readonly #model: ServiceWorkerCacheModel;
  storageKey: string;
  storageBucket: Protocol.Storage.StorageBucket;
  cacheName: string;
  cacheId: Protocol.CacheStorage.CacheId;

  constructor(
      model: ServiceWorkerCacheModel, storageBucket: Protocol.Storage.StorageBucket, cacheName: string,
      cacheId: Protocol.CacheStorage.CacheId) {
    this.#model = model;
    this.storageBucket = storageBucket;
    this.storageKey = storageBucket.storageKey;
    this.cacheName = cacheName;
    this.cacheId = cacheId;
  }

  inBucket(storageBucket: Protocol.Storage.StorageBucket): boolean {
    return this.storageKey === storageBucket.storageKey && this.storageBucket.name === storageBucket.name;
  }

  equals(cache: Cache): boolean {
    return this.cacheId === cache.cacheId;
  }

  toString(): string {
    return this.storageKey + this.cacheName;
  }

  async requestCachedResponse(url: Platform.DevToolsPath.UrlString, requestHeaders: NameValue[]):
      Promise<Protocol.CacheStorage.CachedResponse|null> {
    const response = await this.#model.cacheAgent.invoke_requestCachedResponse(
        {cacheId: this.cacheId, requestURL: url, requestHeaders});
    if (response.getError()) {
      return null;
    }
    return response.response;
  }
}

SDKModel.register(ServiceWorkerCacheModel, {capabilities: Capability.STORAGE, autostart: false});
