import { registerShutdownTask, runShutdownTasks } from '../../framework/on_shutdown.js';
import { LogMessageWithStack } from '../../internal/logging/log_message.js';
import { TransferredTestCaseResult, LiveTestCaseResult } from '../../internal/logging/result.js';
import { TestCaseRecorder } from '../../internal/logging/test_case_recorder.js';
import { TestQueryWithExpectation } from '../../internal/query/query.js';
import { timeout } from '../../util/timeout.js';
import { assert } from '../../util/util.js';

import { CTSOptions, WorkerMode, kDefaultCTSOptions } from './options.js';
import { WorkerTestRunRequest } from './utils_worker.js';

/** Query all currently-registered service workers, and unregister them. */
function unregisterAllServiceWorkers() {
  void navigator.serviceWorker.getRegistrations().then(registrations => {
    for (const registration of registrations) {
      void registration.unregister();
    }
  });
}

// In practice all Web-based runtimes should be including this file, so take the opportunity to set
// up runShutdownTasks. None of these events are guaranteed to happen, but cleaning up is very
// important, so we try our best (and don't worry about shutdown performance or disabling bfcache).
// (We could try 'visibilitychange', but since it can happen in the middle of the page lifetime,
// it is more likely to have unintended consequences and would need to do different stuff.)
// - 'unload' supposedly always disables the bfcache, but is deprecated in Chrome.
// - 'beforeunload' may disable the bfcache but may be called more reliably than 'unload'.
window.addEventListener('beforeunload', runShutdownTasks);
// - 'pagehide' won't disable the bfcache but may be called more reliably than the others.
window.addEventListener('pagehide', runShutdownTasks);

abstract class TestBaseWorker {
  protected readonly ctsOptions: CTSOptions;
  protected readonly resolvers = new Map<string, (result: LiveTestCaseResult) => void>();

  constructor(worker: WorkerMode, ctsOptions?: CTSOptions) {
    this.ctsOptions = { ...(ctsOptions || kDefaultCTSOptions), ...{ worker } };
  }

  onmessage(ev: MessageEvent) {
    const query: string = ev.data.query;
    const transferredResult: TransferredTestCaseResult = ev.data.result;

    const result: LiveTestCaseResult = {
      status: transferredResult.status,
      timems: transferredResult.timems,
      logs: transferredResult.logs?.map(l => new LogMessageWithStack(l)),
    };

    this.resolvers.get(query)!(result);
    this.resolvers.delete(query);

    // MAINTENANCE_TODO(kainino0x): update the Logger with this result (or don't have a logger and
    // update the entire results JSON somehow at some point).
  }

  makeRequestAndRecordResult(
    target: MessagePort | Worker | ServiceWorker,
    query: string,
    expectations: TestQueryWithExpectation[]
  ): Promise<LiveTestCaseResult> {
    const request: WorkerTestRunRequest = {
      query,
      expectations,
      ctsOptions: this.ctsOptions,
    };
    target.postMessage(request);

    return new Promise<LiveTestCaseResult>(resolve => {
      assert(!this.resolvers.has(query), "can't request same query twice simultaneously");
      this.resolvers.set(query, resolve);
    });
  }

  async run(
    rec: TestCaseRecorder,
    query: string,
    expectations: TestQueryWithExpectation[] = []
  ): Promise<void> {
    try {
      rec.injectResult(await this.runImpl(query, expectations));
    } catch (ex) {
      rec.start();
      rec.threw(ex);
      rec.finish();
    }
  }

  protected abstract runImpl(
    query: string,
    expectations: TestQueryWithExpectation[]
  ): Promise<LiveTestCaseResult>;
}

export class TestDedicatedWorker extends TestBaseWorker {
  private readonly worker: Worker | Error;

  constructor(ctsOptions?: CTSOptions) {
    super('dedicated', ctsOptions);
    try {
      if (typeof Worker === 'undefined') {
        throw new Error('Dedicated Workers not available');
      }

      const selfPath = import.meta.url;
      const selfPathDir = selfPath.substring(0, selfPath.lastIndexOf('/'));
      const workerPath = selfPathDir + '/test_worker-worker.js';
      const worker = new Worker(workerPath, { type: 'module' });
      this.worker = worker;
      this.worker.onmessage = ev => this.onmessage(ev);

      // Try to send a shutdown signal to the worker on shutdown.
      registerShutdownTask(() => worker.postMessage('shutdown'));
    } catch (ex) {
      assert(ex instanceof Error);
      // Save the exception to re-throw in runImpl().
      this.worker = ex;
    }
  }

  override runImpl(query: string, expectations: TestQueryWithExpectation[] = []) {
    if (this.worker instanceof Worker) {
      return this.makeRequestAndRecordResult(this.worker, query, expectations);
    } else {
      throw this.worker;
    }
  }
}

/** @deprecated Use TestDedicatedWorker instead. */
export class TestWorker extends TestDedicatedWorker {}

export class TestSharedWorker extends TestBaseWorker {
  /** MessagePort to the SharedWorker, or an Error if it couldn't be initialized. */
  private readonly port: MessagePort | Error;

  constructor(ctsOptions?: CTSOptions) {
    super('shared', ctsOptions);
    try {
      if (typeof SharedWorker === 'undefined') {
        throw new Error('Shared Workers not available');
      }

      const selfPath = import.meta.url;
      const selfPathDir = selfPath.substring(0, selfPath.lastIndexOf('/'));
      const workerPath = selfPathDir + '/test_worker-worker.js';
      const worker = new SharedWorker(workerPath, { type: 'module' });
      this.port = worker.port;
      this.port.start();
      this.port.onmessage = ev => this.onmessage(ev);

      // Try to send a shutdown signal to the worker on shutdown.
      registerShutdownTask(() => worker.port.postMessage('shutdown'));
    } catch (ex) {
      assert(ex instanceof Error);
      // Save the exception to re-throw in runImpl().
      this.port = ex;
    }
  }

  override runImpl(query: string, expectations: TestQueryWithExpectation[] = []) {
    if (this.port instanceof MessagePort) {
      return this.makeRequestAndRecordResult(this.port, query, expectations);
    } else {
      throw this.port;
    }
  }
}

export class TestServiceWorker extends TestBaseWorker {
  private lastServiceWorker: ServiceWorker | null = null;

  constructor(ctsOptions?: CTSOptions) {
    super('service', ctsOptions);

    // If the runtime is trying to use service workers, first clean up any service workers that may
    // have leaked from previous incarnations of the page. Service workers *shouldn't* affect
    // worker=none/dedicated/shared, so it should be OK if this constructor isn't called.
    unregisterAllServiceWorkers();

    // Try to send a shutdown signal to the worker on shutdown.
    registerShutdownTask(() => {
      this.runShutdownTasksOnLastServiceWorker();
      unregisterAllServiceWorkers();
    });
  }

  private runShutdownTasksOnLastServiceWorker() {
    this.lastServiceWorker?.postMessage('shutdown');
  }

  override async runImpl(query: string, expectations: TestQueryWithExpectation[] = []) {
    if (!('serviceWorker' in navigator)) {
      throw new Error('Service Workers not available');
    }
    const [suite, name] = query.split(':', 2);
    const fileName = name.split(',').join('/');

    const selfPath = import.meta.url;
    const selfPathDir = selfPath.substring(0, selfPath.lastIndexOf('/'));
    // Construct the path to the worker file, then use URL to resolve the `../` components.
    const serviceWorkerURL = new URL(
      // NOTE: This is a Mozilla-specific hack! Please follow-up at
      // <https://bugzilla.mozilla.org/asdf> for removal.
      `${location.origin}/_mozilla/webgpu/${suite}/webworker/${fileName}.as_worker.js`
    ).toString();

    // Ensure the correct service worker is registered.
    const registration = await navigator.serviceWorker.register(serviceWorkerURL, {
      type: 'module',
    });
    const registrationPending = () =>
      !registration.active || registration.active.scriptURL !== serviceWorkerURL;

    // Ensure the registration we just requested is active. (We don't worry about it being
    // outdated from a previous page load, because we wipe all service workers on shutdown/startup.)
    const isNewInstance = registrationPending();
    if (isNewInstance) {
      this.runShutdownTasksOnLastServiceWorker();
      do {
        await new Promise(resolve => timeout(resolve, 0));
      } while (registrationPending());
    }
    const serviceWorker = registration.active!;
    this.lastServiceWorker = serviceWorker;

    navigator.serviceWorker.onmessage = ev => this.onmessage(ev);
    return this.makeRequestAndRecordResult(serviceWorker, query, expectations);
  }
}
