const variants = new Set((new URLSearchParams(location.search)).keys());

export function hasVariant(name) {
  return variants.has(name);
}

export class Recorder {
  #events = [];
  #errors = [];
  #navigationAPI;
  #domExceptionConstructor;
  #location;
  #skipCurrentChange;
  #finalExpectedEvent;
  #finalExpectedEventCount;
  #currentFinalEventCount = 0;

  #readyToAssertResolve;
  #readyToAssertPromise = new Promise(resolve => { this.#readyToAssertResolve = resolve; });

  constructor({ window = self, skipCurrentChange = false, finalExpectedEvent, finalExpectedEventCount = 1 }) {
    assert_equals(typeof finalExpectedEvent, "string", "Must pass a string for finalExpectedEvent");

    this.#navigationAPI = window.navigation;
    this.#domExceptionConstructor = window.DOMException;
    this.#location = window.location;

    this.#skipCurrentChange = skipCurrentChange;
    this.#finalExpectedEvent = finalExpectedEvent;
    this.#finalExpectedEventCount = finalExpectedEventCount;
  }

  setUpNavigationAPIListeners() {
    this.#navigationAPI.addEventListener("navigate", e => {
      this.record("navigate");

      e.signal.addEventListener("abort", () => {
        this.recordWithError("AbortSignal abort", e.signal.reason);
      });
    });

    this.#navigationAPI.addEventListener("navigateerror", e => {
      this.recordWithError("navigateerror", e.error);

      this.#navigationAPI.transition?.finished.then(
        () => this.record("transition.finished fulfilled"),
        err => this.recordWithError("transition.finished rejected", err)
      );
    });

    this.#navigationAPI.addEventListener("navigatesuccess", () => {
      this.record("navigatesuccess");

      this.#navigationAPI.transition?.finished.then(
        () => this.record("transition.finished fulfilled"),
        err => this.recordWithError("transition.finished rejected", err)
      );
    });

    if (!this.#skipCurrentChange) {
      this.#navigationAPI.addEventListener("currententrychange", () => this.record("currententrychange"));
    }
  }

  setUpResultListeners(result, suffix = "") {
    result.committed.then(
      () => this.record(`committed fulfilled${suffix}`),
      err => this.recordWithError(`committed rejected${suffix}`, err)
    );

    result.finished.then(
      () => this.record(`finished fulfilled${suffix}`),
      err => this.recordWithError(`finished rejected${suffix}`, err)
    );
  }

  record(name) {
    const transitionProps = this.#navigationAPI.transition === null ? null : {
      from: this.#navigationAPI.transition.from,
      navigationType: this.#navigationAPI.transition.navigationType
    };

    this.#events.push({ name, location: this.#location.hash, transitionProps });

    if (name === this.#finalExpectedEvent && ++this.#currentFinalEventCount === this.#finalExpectedEventCount) {
      this.#readyToAssertResolve();
    }
  }

  recordWithError(name, errorObject) {
    this.record(name);
    this.#errors.push({ name, errorObject });
  }

  get readyToAssert() {
    return this.#readyToAssertPromise;
  }

  // Usage:
  //   recorder.assert([
  //     /* event name, location.hash value, navigation.transition properties */
  //     ["currententrychange", "", null],
  //     ["committed fulfilled", "#1", { from, navigationType }],
  //     ...
  //   ]);
  //
  // The array format is to avoid repitition at the call site, but I recommend
  // you document it like above.
  //
  // This will automatically also assert that any error objects recorded are
  // equal to each other. Use the other assert functions to check the actual
  // contents of the error objects.
  assert(expectedAsArray) {
    if (this.#skipCurrentChange) {
      expectedAsArray = expectedAsArray.filter(expected => expected[0] !== "currententrychange");
    }

    // Doing this up front gives nicer error messages because
    // assert_array_equals is nice.
    const recordedNames = this.#events.map(e => e.name);
    const expectedNames = expectedAsArray.map(e => e[0]);
    assert_array_equals(recordedNames, expectedNames);

    for (let i = 0; i < expectedAsArray.length; ++i) {
      const recorded = this.#events[i];
      const expected = expectedAsArray[i];

      assert_equals(
        recorded.location,
        expected[1],
        `event ${i} (${recorded.name}): location.hash value`
      );

      if (expected[2] === null) {
        assert_equals(
          recorded.transitionProps,
          null,
          `event ${i} (${recorded.name}): navigation.transition expected to be null`
        );
      } else {
        assert_not_equals(
          recorded.transitionProps,
          null,
          `event ${i} (${recorded.name}): navigation.transition expected not to be null`
        );
        assert_equals(
          recorded.transitionProps.from,
          expected[2].from,
          `event ${i} (${recorded.name}): navigation.transition.from`
        );
        assert_equals(
          recorded.transitionProps.navigationType,
          expected[2].navigationType,
          `event ${i} (${recorded.name}): navigation.transition.navigationType`
        );
      }
    }

    if (this.#errors.length > 1) {
      for (let i = 1; i < this.#errors.length; ++i) {
        assert_equals(
          this.#errors[i].errorObject,
          this.#errors[0].errorObject,
          `error objects must match: error object for ${this.#errors[i].name} did not match the one for ${this.#errors[0].name}`
        );
      }
    }
  }

  assertErrorsAreAbortErrors() {
    assert_greater_than(
      this.#errors.length,
      0,
      "No errors were recorded but assertErrorsAreAbortErrors() was called"
    );

    // Assume assert() has been called so all error objects are the same.
    const { errorObject } = this.#errors[0];
    assert_throws_dom("AbortError", this.#domExceptionConstructor, () => { throw errorObject; });
  }

  assertErrorsAre(expectedErrorObject) {
    assert_greater_than(
      this.#errors.length,
      0,
      "No errors were recorded but assertErrorsAre() was called"
    );

    // Assume assert() has been called so all error objects are the same.
    const { errorObject } = this.#errors[0];
    assert_equals(errorObject, expectedErrorObject);
  }
}
