/**
 * @license
 * Copyright Google LLC All Rights Reserved.
 *
 * Use of this source code is governed by an MIT-style license that can be
 * found in the LICENSE file at https://angular.dev/license
 */

import {Adapter} from '../src/adapter';
import {AssetGroupConfig, Manifest} from '../src/manifest';
import {sha1} from '../src/sha1';

import {MockCacheStorage} from './cache';
import {MockClient, MockClients} from './clients';
import {
  MockActivateEvent,
  MockExtendableMessageEvent,
  MockFetchEvent,
  MockInstallEvent,
  MockNotificationEvent,
  MockPushEvent,
} from './events';
import {MockHeaders, MockRequest, MockResponse} from './fetch';
import {MockServerState, MockServerStateBuilder} from './mock';
import {normalizeUrl, parseUrl} from './utils';

const EMPTY_SERVER_STATE = new MockServerStateBuilder().build();

export class SwTestHarnessBuilder {
  private origin: string;
  private server = EMPTY_SERVER_STATE;
  private caches: MockCacheStorage;

  constructor(private scopeUrl = 'http://localhost/') {
    this.origin = parseUrl(this.scopeUrl).origin;
    this.caches = new MockCacheStorage(this.origin);
  }

  withCacheState(cache: string): SwTestHarnessBuilder {
    this.caches = new MockCacheStorage(this.origin, cache);
    return this;
  }

  withServerState(state: MockServerState): SwTestHarnessBuilder {
    this.server = state;
    return this;
  }

  build(): SwTestHarness {
    return new SwTestHarnessImpl(this.server, this.caches, this.scopeUrl) as SwTestHarness;
  }
}

export type SwTestHarness = SwTestHarnessImpl & ServiceWorkerGlobalScope;

export class SwTestHarnessImpl
  extends Adapter<MockCacheStorage>
  implements Partial<ServiceWorkerGlobalScope>
{
  readonly clients = new MockClients();
  private eventHandlers = new Map<string, EventListener>();
  private skippedWaiting = false;

  private selfMessageQueue: any[] = [];
  autoAdvanceTime = false;
  unregistered: boolean = false;
  readonly notifications: {title: string; options: Object}[] = [];
  readonly registration: ServiceWorkerRegistration = {
    active: {
      postMessage: (msg: any) => {
        this.selfMessageQueue.push(msg);
      },
    },
    scope: this.scopeUrl,
    showNotification: (title: string, options: Object) => {
      this.notifications.push({title, options});
    },
    unregister: () => {
      this.unregistered = true;
    },
  } as any;

  override get time() {
    return this.mockTime;
  }

  private mockTime = Date.now();

  private timers: {
    at: number;
    duration: number;
    fn: Function;
    fired: boolean;
  }[] = [];

  override parseUrl = parseUrl;

  constructor(
    private server: MockServerState,
    caches: MockCacheStorage,
    scopeUrl: string,
  ) {
    super(scopeUrl, caches);
  }

  async resolveSelfMessages(): Promise<void> {
    while (this.selfMessageQueue.length > 0) {
      const queue = this.selfMessageQueue;
      this.selfMessageQueue = [];
      await queue.reduce(async (previous, msg) => {
        await previous;
        await this.handleMessage(msg, null);
      }, Promise.resolve());
    }
  }

  async startup(firstTime: boolean = false): Promise<boolean | null> {
    if (!firstTime) {
      return null;
    }
    let skippedWaiting: boolean = false;
    if (this.eventHandlers.has('install')) {
      const installEvent = new MockInstallEvent();
      this.eventHandlers.get('install')!(installEvent);
      await installEvent.ready;
      skippedWaiting = this.skippedWaiting;
    }
    if (this.eventHandlers.has('activate')) {
      const activateEvent = new MockActivateEvent();
      this.eventHandlers.get('activate')!(activateEvent);
      await activateEvent.ready;
    }
    return skippedWaiting;
  }

  updateServerState(server?: MockServerState): void {
    this.server = server || EMPTY_SERVER_STATE;
  }

  fetch(req: RequestInfo): Promise<Response> {
    if (typeof req === 'string') {
      return this.server.fetch(new MockRequest(normalizeUrl(req, this.scopeUrl)));
    } else {
      const mockReq = req.clone() as MockRequest;
      mockReq.url = normalizeUrl(mockReq.url, this.scopeUrl);
      return this.server.fetch(mockReq);
    }
  }

  addEventListener(
    type: string,
    listener: EventListenerOrEventListenerObject,
    options?: boolean | AddEventListenerOptions,
  ): void {
    if (options !== undefined) {
      throw new Error('Mock `addEventListener()` does not support `options`.');
    }

    const handler: EventListener =
      typeof listener === 'function' ? listener : (evt) => listener.handleEvent(evt);
    this.eventHandlers.set(type, handler);
  }

  removeEventListener(
    type: string,
    listener: EventListenerOrEventListenerObject,
    options?: boolean | AddEventListenerOptions,
  ): void {
    if (options !== undefined) {
      throw new Error('Mock `removeEventListener()` does not support `options`.');
    }

    this.eventHandlers.delete(type);
  }

  override newRequest(url: string, init: Object = {}): Request {
    return new MockRequest(normalizeUrl(url, this.scopeUrl), init);
  }

  override newResponse(body: string, init: Object = {}): Response {
    return new MockResponse(body, init);
  }

  override newHeaders(headers: {[name: string]: string}): Headers {
    return Object.keys(headers).reduce((mock, name) => {
      mock.set(name, headers[name]);
      return mock;
    }, new MockHeaders());
  }

  async skipWaiting(): Promise<void> {
    this.skippedWaiting = true;
  }

  handleFetch(
    req: Request,
    clientId = '',
    resultingClientId?: string,
  ): [Promise<Response | undefined>, Promise<void>] {
    if (!this.eventHandlers.has('fetch')) {
      throw new Error('No fetch handler registered');
    }

    const isNavigation = req.mode === 'navigate';

    if (clientId && !this.clients.getMock(clientId)) {
      this.clients.add(clientId, isNavigation ? req.url : this.scopeUrl);
    }

    const event =
      clientId && resultingClientId
        ? new MockFetchEvent(req, clientId, resultingClientId)
        : isNavigation
          ? new MockFetchEvent(req, '', clientId)
          : new MockFetchEvent(req, clientId, '');
    this.eventHandlers.get('fetch')!.call(this, event);

    return [event.response, event.ready];
  }

  handleMessage(data: Object, clientId: string | null): Promise<void> {
    if (!this.eventHandlers.has('message')) {
      throw new Error('No message handler registered');
    }

    if (clientId && !this.clients.getMock(clientId)) {
      this.clients.add(clientId, this.scopeUrl);
    }

    const event = new MockExtendableMessageEvent(
      data,
      (clientId && this.clients.getMock(clientId)) || null,
    );
    this.eventHandlers.get('message')!.call(this, event);

    return event.ready;
  }

  handleMessageError(clientId: string | null) {
    if (!this.eventHandlers.has('messageerror')) {
      throw new Error('No messageerror handler registered');
    }

    if (clientId && !this.clients.getMock(clientId)) {
      this.clients.add(clientId, this.scopeUrl);
    }

    const event = new MockExtendableMessageEvent(
      null,
      (clientId && this.clients.getMock(clientId)) || null,
    );
    this.eventHandlers.get('messageerror')!.call(this, event);

    return event.ready;
  }

  handlePush(data: Object): Promise<void> {
    if (!this.eventHandlers.has('push')) {
      throw new Error('No push handler registered');
    }
    const event = new MockPushEvent(data);
    this.eventHandlers.get('push')!.call(this, event);
    return event.ready;
  }

  handleClick(notification: Object, action?: string): Promise<void> {
    if (!this.eventHandlers.has('notificationclick')) {
      throw new Error('No notificationclick handler registered');
    }
    const event = new MockNotificationEvent(notification, action);
    this.eventHandlers.get('notificationclick')!.call(this, event);
    return event.ready;
  }

  handleClose(notification: Object, action: string): Promise<void> {
    if (!this.eventHandlers.has('notificationclose')) {
      throw new Error('No notificationclose handler registered');
    }
    const event = new MockNotificationEvent(notification, action);
    this.eventHandlers.get('notificationclose')!.call(this, event);
    return event.ready;
  }

  handleUnhandledRejection(reason: any): void {
    if (!this.eventHandlers.has('unhandledrejection')) {
      throw new Error('No unhandledrejection handler registered');
    }
    const promise = Promise.reject(reason);
    // We want to simulate an unhandled rejection, but we don't want the test runner (Node)
    // to actually see an unhandled rejection and fail the test. So we attach a dummy handler.
    promise.catch(() => {});
    const event = {
      reason,
      promise,
    } as unknown as PromiseRejectionEvent;
    this.eventHandlers.get('unhandledrejection')!.call(this, event);
  }

  override timeout(ms: number): Promise<void> {
    const promise = new Promise<void>((resolve) => {
      this.timers.push({
        at: this.mockTime + ms,
        duration: ms,
        fn: resolve,
        fired: false,
      });
    });

    if (this.autoAdvanceTime) {
      this.advance(ms);
    }

    return promise;
  }

  advance(by: number): void {
    this.mockTime += by;
    this.timers
      .filter((timer) => !timer.fired)
      .filter((timer) => timer.at <= this.mockTime)
      .forEach((timer) => {
        timer.fired = true;
        timer.fn();
      });
  }

  override isClient(obj: any): obj is Client {
    return obj instanceof MockClient;
  }
}

interface StaticFile {
  url: string;
  contents: string;
  hash?: string;
}

export class AssetGroupBuilder {
  constructor(
    private up: ConfigBuilder,
    readonly name: string,
  ) {}

  private files: StaticFile[] = [];

  addFile(url: string, contents: string, hashed: boolean = true): AssetGroupBuilder {
    const file: StaticFile = {url, contents, hash: undefined};
    if (hashed) {
      file.hash = sha1(contents);
    }
    this.files.push(file);
    return this;
  }

  finish(): ConfigBuilder {
    return this.up;
  }

  toManifestGroup(): AssetGroupConfig {
    return null!;
  }
}

export class ConfigBuilder {
  assetGroups = new Map<string, AssetGroupBuilder>();

  addAssetGroup(name: string): ConfigBuilder {
    const builder = new AssetGroupBuilder(this, name);
    this.assetGroups.set(name, builder);
    return this;
  }

  finish(): Manifest {
    const assetGroups = Array.from(this.assetGroups.values()).map((group) =>
      group.toManifestGroup(),
    );
    const hashTable = {};
    return {
      configVersion: 1,
      timestamp: 1234567890123,
      index: '/index.html',
      assetGroups,
      navigationUrls: [],
      navigationRequestStrategy: 'performance',
      hashTable,
    };
  }
}
