// 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.

import type * as ProtocolProxyApi from '../../generated/protocol-proxy-api.js';
import * 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 {Events as RuntimeModelEvents, type ExecutionContext, RuntimeModel} from './RuntimeModel.js';
import {SDKModel} from './SDKModel.js';
import {Capability, type Target, Type} from './Target.js';

const UIStrings = {
  /**
   * @description Service worker running status displayed in the Service Workers view in the Application panel
   */
  running: 'running',
  /**
   * @description Service worker running status displayed in the Service Workers view in the Application panel
   */
  starting: 'starting',
  /**
   * @description Service worker running status displayed in the Service Workers view in the Application panel
   */
  stopped: 'stopped',
  /**
   * @description Service worker running status displayed in the Service Workers view in the Application panel
   */
  stopping: 'stopping',
  /**
   * @description Service worker version status displayed in the Threads view of the Debugging side pane in the Sources panel
   */
  activated: 'activated',
  /**
   * @description Service worker version status displayed in the Threads view of the Debugging side pane in the Sources panel
   */
  activating: 'activating',
  /**
   * @description Service worker version status displayed in the Threads view of the Debugging side pane in the Sources panel
   */
  installed: 'installed',
  /**
   * @description Service worker version status displayed in the Threads view of the Debugging side pane in the Sources panel
   */
  installing: 'installing',
  /**
   * @description Service worker version status displayed in the Threads view of the Debugging side pane in the Sources panel
   */
  new: 'new',
  /**
   * @description Service worker version status displayed in the Threads view of the Debugging side pane in the Sources panel
   */
  redundant: 'redundant',
  /**
   * @description Service worker version status displayed in the Threads view of the Debugging side pane in the Sources panel
   * @example {sw.js} PH1
   * @example {117} PH2
   * @example {activated} PH3
   */
  sSS: '{PH1} #{PH2} ({PH3})',
} as const;
const str_ = i18n.i18n.registerUIStrings('core/sdk/ServiceWorkerManager.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
const i18nLazyString = i18n.i18n.getLazilyComputedLocalizedString.bind(undefined, str_);

export class ServiceWorkerManager extends SDKModel<EventTypes> {
  readonly #agent: ProtocolProxyApi.ServiceWorkerApi;
  readonly #registrations = new Map<string, ServiceWorkerRegistration>();
  #enabled = false;
  readonly #forceUpdateSetting: Common.Settings.Setting<boolean>;

  constructor(target: Target) {
    super(target);
    target.registerServiceWorkerDispatcher(new ServiceWorkerDispatcher(this));
    this.#agent = target.serviceWorkerAgent();
    void this.enable();
    this.#forceUpdateSetting = this.target()
                                   .targetManager()
                                   .context.get(Common.Settings.Settings)
                                   .createSetting('service-worker-update-on-reload', false);
    if (this.#forceUpdateSetting.get()) {
      this.forceUpdateSettingChanged();
    }
    this.#forceUpdateSetting.addChangeListener(this.forceUpdateSettingChanged, this);
    new ServiceWorkerContextNamer(target, this);
  }

  async enable(): Promise<void> {
    if (this.#enabled) {
      return;
    }
    this.#enabled = true;
    await this.#agent.invoke_enable();
  }

  async disable(): Promise<void> {
    if (!this.#enabled) {
      return;
    }
    this.#enabled = false;
    this.#registrations.clear();
    await this.#agent.invoke_enable();
  }

  registrations(): Map<string, ServiceWorkerRegistration> {
    return this.#registrations;
  }

  findVersion(versionId: string): ServiceWorkerVersion|null {
    for (const registration of this.registrations().values()) {
      const version = registration.versions.get(versionId);
      if (version) {
        return version;
      }
    }
    return null;
  }

  deleteRegistration(registrationId: string): void {
    const registration = this.#registrations.get(registrationId);
    if (!registration) {
      return;
    }
    if (registration.isRedundant()) {
      this.#registrations.delete(registrationId);
      this.dispatchEventToListeners(Events.REGISTRATION_DELETED, registration);
      return;
    }
    registration.deleting = true;
    for (const version of registration.versions.values()) {
      void this.stopWorker(version.id);
    }
    void this.unregister(registration.scopeURL);
  }

  async updateRegistration(registrationId: string): Promise<void> {
    const registration = this.#registrations.get(registrationId);
    if (!registration) {
      return;
    }
    await this.#agent.invoke_updateRegistration({scopeURL: registration.scopeURL});
  }

  async deliverPushMessage(registrationId: Protocol.ServiceWorker.RegistrationID, data: string): Promise<void> {
    const registration = this.#registrations.get(registrationId);
    if (!registration) {
      return;
    }
    const origin = Common.ParsedURL.ParsedURL.extractOrigin(registration.scopeURL);
    await this.#agent.invoke_deliverPushMessage({origin, registrationId, data});
  }

  async dispatchSyncEvent(registrationId: Protocol.ServiceWorker.RegistrationID, tag: string, lastChance: boolean):
      Promise<void> {
    const registration = this.#registrations.get(registrationId);
    if (!registration) {
      return;
    }
    const origin = Common.ParsedURL.ParsedURL.extractOrigin(registration.scopeURL);
    await this.#agent.invoke_dispatchSyncEvent({origin, registrationId, tag, lastChance});
  }

  async dispatchPeriodicSyncEvent(registrationId: Protocol.ServiceWorker.RegistrationID, tag: string): Promise<void> {
    const registration = this.#registrations.get(registrationId);
    if (!registration) {
      return;
    }
    const origin = Common.ParsedURL.ParsedURL.extractOrigin(registration.scopeURL);
    await this.#agent.invoke_dispatchPeriodicSyncEvent({origin, registrationId, tag});
  }

  private async unregister(scopeURL: string): Promise<void> {
    await this.#agent.invoke_unregister({scopeURL});
  }

  async startWorker(scopeURL: string): Promise<void> {
    await this.#agent.invoke_startWorker({scopeURL});
  }

  async skipWaiting(scopeURL: string): Promise<void> {
    await this.#agent.invoke_skipWaiting({scopeURL});
  }

  async stopWorker(versionId: string): Promise<void> {
    await this.#agent.invoke_stopWorker({versionId});
  }

  workerRegistrationUpdated(registrations: Protocol.ServiceWorker.ServiceWorkerRegistration[]): void {
    for (const payload of registrations) {
      let registration = this.#registrations.get(payload.registrationId);
      if (!registration) {
        registration = new ServiceWorkerRegistration(payload);
        this.#registrations.set(payload.registrationId, registration);
        this.dispatchEventToListeners(Events.REGISTRATION_UPDATED, registration);
        continue;
      }
      registration.update(payload);

      if (registration.shouldBeRemoved()) {
        this.#registrations.delete(registration.id);
        this.dispatchEventToListeners(Events.REGISTRATION_DELETED, registration);
      } else {
        this.dispatchEventToListeners(Events.REGISTRATION_UPDATED, registration);
      }
    }
  }

  workerVersionUpdated(versions: Protocol.ServiceWorker.ServiceWorkerVersion[]): void {
    const registrations = new Set<ServiceWorkerRegistration>();
    for (const payload of versions) {
      const registration = this.#registrations.get(payload.registrationId);
      if (!registration) {
        continue;
      }
      registration.updateVersion(payload);
      registrations.add(registration);
    }
    for (const registration of registrations) {
      if (registration.shouldBeRemoved()) {
        this.#registrations.delete(registration.id);
        this.dispatchEventToListeners(Events.REGISTRATION_DELETED, registration);
      } else {
        this.dispatchEventToListeners(Events.REGISTRATION_UPDATED, registration);
      }
    }
  }

  workerErrorReported(payload: Protocol.ServiceWorker.ServiceWorkerErrorMessage): void {
    const registration = this.#registrations.get(payload.registrationId);
    if (!registration) {
      return;
    }
    registration.errors.push(payload);
    this.dispatchEventToListeners(Events.REGISTRATION_ERROR_ADDED, {registration, error: payload});
  }

  private forceUpdateSettingChanged(): void {
    const forceUpdateOnPageLoad = this.#forceUpdateSetting.get();
    void this.#agent.invoke_setForceUpdateOnPageLoad({forceUpdateOnPageLoad});
  }
}

export const enum Events {
  REGISTRATION_UPDATED = 'RegistrationUpdated',
  REGISTRATION_ERROR_ADDED = 'RegistrationErrorAdded',
  REGISTRATION_DELETED = 'RegistrationDeleted',
}

export interface RegistrationErrorAddedEvent {
  registration: ServiceWorkerRegistration;
  error: Protocol.ServiceWorker.ServiceWorkerErrorMessage;
}

export interface EventTypes {
  [Events.REGISTRATION_UPDATED]: ServiceWorkerRegistration;
  [Events.REGISTRATION_ERROR_ADDED]: RegistrationErrorAddedEvent;
  [Events.REGISTRATION_DELETED]: ServiceWorkerRegistration;
}

class ServiceWorkerDispatcher implements ProtocolProxyApi.ServiceWorkerDispatcher {
  readonly #manager: ServiceWorkerManager;
  constructor(manager: ServiceWorkerManager) {
    this.#manager = manager;
  }

  workerRegistrationUpdated({registrations}: Protocol.ServiceWorker.WorkerRegistrationUpdatedEvent): void {
    this.#manager.workerRegistrationUpdated(registrations);
  }

  workerVersionUpdated({versions}: Protocol.ServiceWorker.WorkerVersionUpdatedEvent): void {
    this.#manager.workerVersionUpdated(versions);
  }

  workerErrorReported({errorMessage}: Protocol.ServiceWorker.WorkerErrorReportedEvent): void {
    this.#manager.workerErrorReported(errorMessage);
  }
}

/**
 * For every version, we keep a history of ServiceWorkerVersionState. Every time
 * a version is updated we will add a new state at the head of the history chain.
 * This history tells us information such as what the current state is, or when
 * the version becomes installed.
 */
export class ServiceWorkerVersionState {
  runningStatus: Protocol.ServiceWorker.ServiceWorkerVersionRunningStatus;
  status: Protocol.ServiceWorker.ServiceWorkerVersionStatus;
  lastUpdatedTimestamp: number;
  previousState: ServiceWorkerVersionState|null;
  constructor(
      runningStatus: Protocol.ServiceWorker.ServiceWorkerVersionRunningStatus,
      status: Protocol.ServiceWorker.ServiceWorkerVersionStatus, previousState: ServiceWorkerVersionState|null,
      timestamp: number) {
    this.runningStatus = runningStatus;
    this.status = status;
    this.lastUpdatedTimestamp = timestamp;
    this.previousState = previousState;
  }
}

export class ServiceWorkerRouterRule {
  condition: string;
  source: string;
  id: number;
  constructor(condition: string, source: string, id: number) {
    this.condition = condition;
    this.source = source;
    this.id = id;
  }
}

export class ServiceWorkerVersion {
  id!: string;
  scriptURL!: Platform.DevToolsPath.UrlString;
  parsedURL!: Common.ParsedURL.ParsedURL;
  securityOrigin!: string;
  scriptLastModified!: number|undefined;
  scriptResponseTime!: number|undefined;
  controlledClients!: Protocol.Target.TargetID[];
  targetId!: string|null;
  routerRules!: ServiceWorkerRouterRule[]|null;
  currentState!: ServiceWorkerVersionState;
  registration: ServiceWorkerRegistration;
  constructor(registration: ServiceWorkerRegistration, payload: Protocol.ServiceWorker.ServiceWorkerVersion) {
    this.registration = registration;
    this.update(payload);
  }

  update(payload: Protocol.ServiceWorker.ServiceWorkerVersion): void {
    this.id = payload.versionId;
    this.scriptURL = payload.scriptURL as Platform.DevToolsPath.UrlString;
    const parsedURL = new Common.ParsedURL.ParsedURL(payload.scriptURL);
    this.securityOrigin = parsedURL.securityOrigin();
    this.currentState =
        new ServiceWorkerVersionState(payload.runningStatus, payload.status, this.currentState, Date.now());
    this.scriptLastModified = payload.scriptLastModified;
    this.scriptResponseTime = payload.scriptResponseTime;
    if (payload.controlledClients) {
      this.controlledClients = payload.controlledClients.slice();
    } else {
      this.controlledClients = [];
    }
    this.targetId = payload.targetId || null;
    this.routerRules = null;
    if (payload.routerRules) {
      this.routerRules = this.parseJSONRules(payload.routerRules);
    }
  }

  isStartable(): boolean {
    return !this.registration.isDeleted && this.isActivated() && this.isStopped();
  }

  isStoppedAndRedundant(): boolean {
    return this.runningStatus === Protocol.ServiceWorker.ServiceWorkerVersionRunningStatus.Stopped &&
        this.status === Protocol.ServiceWorker.ServiceWorkerVersionStatus.Redundant;
  }

  isStopped(): boolean {
    return this.runningStatus === Protocol.ServiceWorker.ServiceWorkerVersionRunningStatus.Stopped;
  }

  isStarting(): boolean {
    return this.runningStatus === Protocol.ServiceWorker.ServiceWorkerVersionRunningStatus.Starting;
  }

  isRunning(): boolean {
    return this.runningStatus === Protocol.ServiceWorker.ServiceWorkerVersionRunningStatus.Running;
  }

  isStopping(): boolean {
    return this.runningStatus === Protocol.ServiceWorker.ServiceWorkerVersionRunningStatus.Stopping;
  }

  isNew(): boolean {
    return this.status === Protocol.ServiceWorker.ServiceWorkerVersionStatus.New;
  }

  isInstalling(): boolean {
    return this.status === Protocol.ServiceWorker.ServiceWorkerVersionStatus.Installing;
  }

  isInstalled(): boolean {
    return this.status === Protocol.ServiceWorker.ServiceWorkerVersionStatus.Installed;
  }

  isActivating(): boolean {
    return this.status === Protocol.ServiceWorker.ServiceWorkerVersionStatus.Activating;
  }

  isActivated(): boolean {
    return this.status === Protocol.ServiceWorker.ServiceWorkerVersionStatus.Activated;
  }

  isRedundant(): boolean {
    return this.status === Protocol.ServiceWorker.ServiceWorkerVersionStatus.Redundant;
  }

  get status(): Protocol.ServiceWorker.ServiceWorkerVersionStatus {
    return this.currentState.status;
  }

  get runningStatus(): Protocol.ServiceWorker.ServiceWorkerVersionRunningStatus {
    return this.currentState.runningStatus;
  }

  mode(): string {
    if (this.isNew() || this.isInstalling()) {
      return ServiceWorkerVersion.Modes.INSTALLING;
    }
    if (this.isInstalled()) {
      return ServiceWorkerVersion.Modes.WAITING;
    }
    if (this.isActivating() || this.isActivated()) {
      return ServiceWorkerVersion.Modes.ACTIVE;
    }
    return ServiceWorkerVersion.Modes.REDUNDANT;
  }

  private parseJSONRules(input: string): ServiceWorkerRouterRule[]|null {
    try {
      const parsedObject = JSON.parse(input);
      if (!Array.isArray(parsedObject)) {
        console.error('Parse error: `routerRules` in ServiceWorkerVersion should be an array');
        return null;
      }
      const routerRules: ServiceWorkerRouterRule[] = [];
      for (const parsedRule of parsedObject) {
        const {condition, source, id} = parsedRule;
        if (condition === undefined || source === undefined || id === undefined) {
          console.error('Parse error: Missing some fields of `routerRules` in ServiceWorkerVersion');
          return null;
        }
        routerRules.push(new ServiceWorkerRouterRule(JSON.stringify(condition), JSON.stringify(source), id));
      }
      return routerRules;
    } catch {
      console.error('Parse error: Invalid `routerRules` in ServiceWorkerVersion');
      return null;
    }
  }
}

export namespace ServiceWorkerVersion {
  export const RunningStatus = {
    [Protocol.ServiceWorker.ServiceWorkerVersionRunningStatus.Running]: i18nLazyString(UIStrings.running),
    [Protocol.ServiceWorker.ServiceWorkerVersionRunningStatus.Starting]: i18nLazyString(UIStrings.starting),
    [Protocol.ServiceWorker.ServiceWorkerVersionRunningStatus.Stopped]: i18nLazyString(UIStrings.stopped),
    [Protocol.ServiceWorker.ServiceWorkerVersionRunningStatus.Stopping]: i18nLazyString(UIStrings.stopping),
  };

  export const Status = {
    [Protocol.ServiceWorker.ServiceWorkerVersionStatus.Activated]: i18nLazyString(UIStrings.activated),
    [Protocol.ServiceWorker.ServiceWorkerVersionStatus.Activating]: i18nLazyString(UIStrings.activating),
    [Protocol.ServiceWorker.ServiceWorkerVersionStatus.Installed]: i18nLazyString(UIStrings.installed),
    [Protocol.ServiceWorker.ServiceWorkerVersionStatus.Installing]: i18nLazyString(UIStrings.installing),
    [Protocol.ServiceWorker.ServiceWorkerVersionStatus.New]: i18nLazyString(UIStrings.new),
    [Protocol.ServiceWorker.ServiceWorkerVersionStatus.Redundant]: i18nLazyString(UIStrings.redundant),
  };

  export const enum Modes {
    INSTALLING = 'installing',
    WAITING = 'waiting',
    ACTIVE = 'active',
    REDUNDANT = 'redundant',
  }
}

export class ServiceWorkerRegistration {
  #fingerprint!: symbol;
  id!: Protocol.ServiceWorker.RegistrationID;
  scopeURL!: Platform.DevToolsPath.UrlString;
  securityOrigin!: Platform.DevToolsPath.UrlString;
  isDeleted!: boolean;
  versions = new Map<string, ServiceWorkerVersion>();
  deleting = false;
  errors: Protocol.ServiceWorker.ServiceWorkerErrorMessage[] = [];

  constructor(payload: Protocol.ServiceWorker.ServiceWorkerRegistration) {
    this.update(payload);
  }

  update(payload: Protocol.ServiceWorker.ServiceWorkerRegistration): void {
    this.#fingerprint = Symbol('fingerprint');
    this.id = payload.registrationId;
    this.scopeURL = payload.scopeURL as Platform.DevToolsPath.UrlString;
    const parsedURL = new Common.ParsedURL.ParsedURL(payload.scopeURL);
    this.securityOrigin = parsedURL.securityOrigin();
    this.isDeleted = payload.isDeleted;
  }

  fingerprint(): symbol {
    return this.#fingerprint;
  }

  versionsByMode(): Map<string, ServiceWorkerVersion> {
    const result = new Map<string, ServiceWorkerVersion>();
    for (const version of this.versions.values()) {
      result.set(version.mode(), version);
    }
    return result;
  }

  updateVersion(payload: Protocol.ServiceWorker.ServiceWorkerVersion): ServiceWorkerVersion {
    this.#fingerprint = Symbol('fingerprint');
    let version = this.versions.get(payload.versionId);
    if (!version) {
      version = new ServiceWorkerVersion(this, payload);
      this.versions.set(payload.versionId, version);
      return version;
    }
    version.update(payload);
    return version;
  }

  isRedundant(): boolean {
    for (const version of this.versions.values()) {
      if (!version.isStoppedAndRedundant()) {
        return false;
      }
    }
    return true;
  }

  shouldBeRemoved(): boolean {
    return this.isRedundant() && (!this.errors.length || this.deleting);
  }

  canBeRemoved(): boolean {
    return this.isDeleted || this.deleting;
  }
}

class ServiceWorkerContextNamer {
  readonly #target: Target;
  readonly #serviceWorkerManager: ServiceWorkerManager;
  readonly #versionByTargetId = new Map<string, ServiceWorkerVersion>();

  constructor(target: Target, serviceWorkerManager: ServiceWorkerManager) {
    this.#target = target;
    this.#serviceWorkerManager = serviceWorkerManager;
    serviceWorkerManager.addEventListener(Events.REGISTRATION_UPDATED, this.registrationsUpdated, this);
    serviceWorkerManager.addEventListener(Events.REGISTRATION_DELETED, this.registrationsUpdated, this);
    this.#target.targetManager().addModelListener(
        RuntimeModel, RuntimeModelEvents.ExecutionContextCreated, this.executionContextCreated, this);
  }

  private registrationsUpdated(): void {
    this.#versionByTargetId.clear();
    const registrations = this.#serviceWorkerManager.registrations().values();
    for (const registration of registrations) {
      for (const version of registration.versions.values()) {
        if (version.targetId) {
          this.#versionByTargetId.set(version.targetId, version);
        }
      }
    }
    this.updateAllContextLabels();
  }

  private executionContextCreated(event: Common.EventTarget.EventTargetEvent<ExecutionContext>): void {
    const executionContext = event.data;
    const serviceWorkerTargetId = this.serviceWorkerTargetId(executionContext.target());
    if (!serviceWorkerTargetId) {
      return;
    }
    this.updateContextLabel(executionContext, this.#versionByTargetId.get(serviceWorkerTargetId) || null);
  }

  private serviceWorkerTargetId(target: Target): string|null {
    if (target.parentTarget() !== this.#target || target.type() !== Type.ServiceWorker) {
      return null;
    }
    return target.id();
  }

  private updateAllContextLabels(): void {
    for (const target of this.#target.targetManager().targets()) {
      const serviceWorkerTargetId = this.serviceWorkerTargetId(target);
      if (!serviceWorkerTargetId) {
        continue;
      }
      const version = this.#versionByTargetId.get(serviceWorkerTargetId) || null;
      const runtimeModel = target.model(RuntimeModel);
      const executionContexts = runtimeModel ? runtimeModel.executionContexts() : [];
      for (const context of executionContexts) {
        this.updateContextLabel(context, version);
      }
    }
  }

  private updateContextLabel(context: ExecutionContext, version: ServiceWorkerVersion|null): void {
    if (!version) {
      context.setLabel('');
      return;
    }
    const parsedUrl = Common.ParsedURL.ParsedURL.fromString(context.origin);
    const label = parsedUrl ? parsedUrl.lastPathComponentWithFragment() : context.name;
    const localizedStatus = ServiceWorkerVersion.Status[version.status];
    context.setLabel(i18nString(UIStrings.sSS, {PH1: label, PH2: version.id, PH3: localizedStatus()}));
  }
}

SDKModel.register(ServiceWorkerManager, {capabilities: Capability.SERVICE_WORKER, autostart: true});
