import { TestCaseRecorder } from '../internal/logging/test_case_recorder.js';
import { JSONWithUndefined } from '../internal/params_utils.js';
import { assert, ExceptionCheckOptions, unreachable } from '../util/util.js';

export class SkipTestCase extends Error {}
export class UnexpectedPassError extends Error {}

export { TestCaseRecorder } from '../internal/logging/test_case_recorder.js';

/** The fully-general type for params passed to a test function invocation. */
export type TestParams = {
  readonly [k: string]: JSONWithUndefined;
};

type DestroyableObject =
  | { destroy(): void }
  | { destroyAsync(): Promise<void> }
  | { close(): void }
  | { getExtension(extensionName: 'WEBGL_lose_context'): WEBGL_lose_context }
  | HTMLVideoElement;

export class SubcaseBatchState {
  constructor(
    protected readonly recorder: TestCaseRecorder,
    /** The case parameters for this test fixture shared state. Subcase params are not included. */
    public readonly params: TestParams
  ) {}

  /**
   * Runs before the `.before()` function.
   * @internal MAINTENANCE_TODO: Make this not visible to test code?
   */
  async init() {}
  /**
   * Runs between the `.before()` function and the subcases.
   * @internal MAINTENANCE_TODO: Make this not visible to test code?
   */
  async postInit() {}
  /**
   * Runs after all subcases finish.
   * @internal MAINTENANCE_TODO: Make this not visible to test code?
   */
  async finalize() {}

  /** Throws an exception marking the subcase as skipped. */
  skip(msg: string): never {
    throw new SkipTestCase(msg);
  }

  /** Throws an exception making the subcase as skipped if condition is true */
  skipIf(cond: boolean, msg: string | (() => string) = '') {
    if (cond) {
      this.skip(typeof msg === 'function' ? msg() : msg);
    }
  }
}

/**
 * A Fixture is a class used to instantiate each test sub/case at run time.
 * A new instance of the Fixture is created for every single test subcase
 * (i.e. every time the test function is run).
 */
export class Fixture<S extends SubcaseBatchState = SubcaseBatchState> {
  private _params: unknown;
  private _sharedState: S;
  /**
   * Interface for recording logs and test status.
   *
   * @internal
   */
  readonly rec: TestCaseRecorder;
  private eventualExpectations: Array<Promise<unknown>> = [];
  private numOutstandingAsyncExpectations = 0;
  private objectsToCleanUp: DestroyableObject[] = [];

  public static MakeSharedState(recorder: TestCaseRecorder, params: TestParams): SubcaseBatchState {
    return new SubcaseBatchState(recorder, params);
  }

  /** @internal */
  constructor(sharedState: S, rec: TestCaseRecorder, params: TestParams) {
    this._sharedState = sharedState;
    this.rec = rec;
    this._params = params;
  }

  /**
   * Returns the (case+subcase) parameters for this test function invocation.
   */
  get params(): unknown {
    return this._params;
  }

  /**
   * Gets the test fixture's shared state. This object is shared between subcases
   * within the same testcase.
   */
  get sharedState(): S {
    return this._sharedState;
  }

  /**
   * Override this to do additional pre-test-function work in a derived fixture.
   * This has to be a member function instead of an async `createFixture` function, because
   * we need to be able to ergonomically override it in subclasses.
   *
   * @internal MAINTENANCE_TODO: Make this not visible to test code?
   */
  async init(): Promise<void> {}

  /**
   * Override this to do additional post-test-function work in a derived fixture.
   *
   * Called even if init was unsuccessful.
   *
   * @internal MAINTENANCE_TODO: Make this not visible to test code?
   */
  async finalize(): Promise<void> {
    assert(
      this.numOutstandingAsyncExpectations === 0,
      'there were outstanding immediateAsyncExpectations (e.g. expectUncapturedError) at the end of the test'
    );

    // Loop to exhaust the eventualExpectations in case they chain off each other.
    while (this.eventualExpectations.length) {
      const p = this.eventualExpectations.shift()!;
      try {
        await p;
      } catch (ex) {
        this.rec.threw(ex);
      }
    }

    // And clean up any objects now that they're done being used.
    for (const o of this.objectsToCleanUp) {
      if ('getExtension' in o) {
        const WEBGL_lose_context = o.getExtension('WEBGL_lose_context');
        if (WEBGL_lose_context) WEBGL_lose_context.loseContext();
      } else if ('destroy' in o) {
        o.destroy();
      } else if ('destroyAsync' in o) {
        await o.destroyAsync();
      } else if ('close' in o) {
        o.close();
      } else {
        // HTMLVideoElement
        o.src = '';
        o.srcObject = null;
      }
    }
  }

  /**
   * Tracks an object to be cleaned up after the test finishes.
   *
   * Usually when creating buffers/textures/query sets, you can use the helpers in GPUTest instead.
   */
  trackForCleanup<T extends DestroyableObject | Promise<DestroyableObject>>(o: T): T {
    if (o instanceof Promise) {
      this.eventualAsyncExpectation(() =>
        o.then(
          o => this.trackForCleanup(o),
          () => {}
        )
      );
      return o;
    }

    if (o instanceof GPUDevice) {
      this.objectsToCleanUp.push({
        async destroyAsync() {
          o.destroy();
          await o.lost;
        },
      });
    } else {
      this.objectsToCleanUp.push(o);
    }
    return o;
  }

  /** Tracks an object, if it's destroyable, to be cleaned up after the test finishes. */
  tryTrackForCleanup<T>(o: T): T {
    if (typeof o === 'object' && o !== null) {
      if (
        'destroy' in o ||
        'close' in o ||
        o instanceof WebGLRenderingContext ||
        o instanceof WebGL2RenderingContext
      ) {
        this.objectsToCleanUp.push(o as unknown as DestroyableObject);
      }
    }
    return o;
  }

  /** Call requestDevice() and track the device for cleanup. */
  requestDeviceTracked(adapter: GPUAdapter, desc: GPUDeviceDescriptor | undefined = undefined) {
    // eslint-disable-next-line no-restricted-syntax
    return this.trackForCleanup(adapter.requestDevice(desc));
  }

  /** Log a debug message. */
  debug(msg: string | (() => string)): void {
    if (!this.rec.debugging) return;
    if (typeof msg === 'function') {
      msg = msg();
    }
    this.rec.debug(new Error(msg));
  }

  /**
   * Log an info message.
   * **Use sparingly. Use `debug()` instead if logs are only needed with debug logging enabled.**
   */
  info(msg: string): void {
    this.rec.info(new Error(msg));
  }

  /** Throws an exception marking the subcase as skipped. */
  skip(msg: string): never {
    throw new SkipTestCase(msg);
  }

  /** Throws an exception marking the subcase as skipped if condition is true */
  skipIf(cond: boolean, msg: string | (() => string) = '') {
    if (cond) {
      this.skip(typeof msg === 'function' ? msg() : msg);
    }
  }

  /** Log a warning and increase the result status to "Warn". */
  warn(msg?: string): void {
    this.rec.warn(new Error(msg));
  }

  /** Log an error and increase the result status to "ExpectFailed". */
  fail(msg?: string): void {
    this.rec.expectationFailed(new Error(msg));
  }

  /**
   * Wraps an async function. Tracks its status to fail if the test tries to report a test status
   * before the async work has finished.
   */
  protected async immediateAsyncExpectation<T>(fn: () => Promise<T>): Promise<T> {
    this.numOutstandingAsyncExpectations++;
    const ret = await fn();
    this.numOutstandingAsyncExpectations--;
    return ret;
  }

  /**
   * Wraps an async function, passing it an `Error` object recording the original stack trace.
   * The async work will be implicitly waited upon before reporting a test status.
   */
  eventualAsyncExpectation<T>(fn: (niceStack: Error) => Promise<T>): void {
    const promise = fn(new Error());
    this.eventualExpectations.push(promise);
  }

  private expectErrorValue(expectedError: string | true, ex: unknown, niceStack: Error): void {
    if (!(ex instanceof Error)) {
      niceStack.message = `THREW non-error value, of type ${typeof ex}: ${ex}`;
      this.rec.expectationFailed(niceStack);
      return;
    }
    const actualName = ex.name;
    if (expectedError !== true && actualName !== expectedError) {
      niceStack.message = `THREW ${actualName}, instead of ${expectedError}: ${ex}`;
      this.rec.expectationFailed(niceStack);
    } else {
      niceStack.message = `OK: threw ${actualName}: ${ex.message}`;
      this.rec.debug(niceStack);
    }
  }

  /** Expect that the provided promise resolves (fulfills). */
  shouldResolve(p: Promise<unknown>, msg?: string): void {
    this.eventualAsyncExpectation(async niceStack => {
      const m = msg ? ': ' + msg : '';
      try {
        await p;
        niceStack.message = 'resolved as expected' + m;
      } catch (ex) {
        niceStack.message = `REJECTED${m}`;
        if (ex instanceof Error) {
          niceStack.message += '\n' + ex.message;
        }
        this.rec.expectationFailed(niceStack);
      }
    });
  }

  /** Expect that the provided promise rejects, with the provided exception name. */
  shouldReject(
    expectedName: string,
    p: Promise<unknown>,
    { allowMissingStack = false, message }: ExceptionCheckOptions = {}
  ): void {
    this.eventualAsyncExpectation(async niceStack => {
      const m = message ? ': ' + message : '';
      try {
        await p;
        niceStack.message = 'DID NOT REJECT' + m;
        this.rec.expectationFailed(niceStack);
      } catch (ex) {
        this.expectErrorValue(expectedName, ex, niceStack);
        if (!allowMissingStack) {
          if (!(ex instanceof Error && typeof ex.stack === 'string')) {
            const exMessage = ex instanceof Error ? ex.message : '?';
            niceStack.message = `rejected as expected, but missing stack (${exMessage})${m}`;
            this.rec.expectationFailed(niceStack);
          }
        }
      }
    });
  }

  /**
   * Expect that the provided function throws (if `true` or `string`) or not (if `false`).
   * If a string is provided, expect that the throw exception has that name.
   *
   * MAINTENANCE_TODO: Change to `string | false` so the exception name is always checked.
   */
  shouldThrow(
    expectedError: string | boolean,
    fn: () => void,
    { allowMissingStack = false, message }: ExceptionCheckOptions = {}
  ) {
    const m = message ? ': ' + message : '';
    try {
      fn();
      if (expectedError === false) {
        this.rec.debug(new Error('did not throw, as expected' + m));
      } else {
        this.rec.expectationFailed(new Error('unexpectedly did not throw' + m));
      }
    } catch (ex) {
      if (expectedError === false) {
        this.rec.expectationFailed(new Error('threw unexpectedly' + m));
      } else {
        this.expectErrorValue(expectedError, ex, new Error(m));
        if (!allowMissingStack) {
          if (!(ex instanceof Error && typeof ex.stack === 'string')) {
            this.rec.expectationFailed(new Error('threw as expected, but missing stack' + m));
          }
        }
      }
    }
  }

  /**
   * Expect that a condition is true.
   *
   * Note: You can pass a boolean condition, or a function that returns a boolean.
   * The advantage to passing a function is that if it's short it is self documenting.
   *
   * t.expect(size >= maxSize);      // prints Expect OK:
   * t.expect(() => size >= maxSize) // prints Expect OK: () => size >= maxSize
   */
  expect(cond: boolean | (() => boolean), msg?: string): boolean {
    if (typeof cond === 'function') {
      if (msg === undefined) {
        msg = cond.toString();
      }
      cond = cond();
    }
    if (cond) {
      const m = msg ? ': ' + msg : '';
      this.rec.debug(new Error('expect OK' + m));
    } else {
      this.rec.expectationFailed(new Error(msg));
    }
    return cond;
  }

  /**
   * If the argument is an `Error`, fail (or warn). If it's `undefined`, no-op.
   * If the argument is an array, apply the above behavior on each of elements.
   */
  expectOK(
    error: Error | undefined | (Error | undefined)[],
    { mode = 'fail', niceStack }: { mode?: 'fail' | 'warn'; niceStack?: Error } = {}
  ): void {
    const handleError = (error: Error | undefined) => {
      if (error instanceof Error) {
        if (niceStack) {
          error.stack = niceStack.stack;
        }
        if (mode === 'fail') {
          this.rec.expectationFailed(error);
        } else if (mode === 'warn') {
          this.rec.warn(error);
        } else {
          unreachable();
        }
      }
    };

    if (Array.isArray(error)) {
      for (const e of error) {
        handleError(e);
      }
    } else {
      handleError(error);
    }
  }

  eventualExpectOK(
    error: Promise<Error | undefined | (Error | undefined)[]>,
    { mode = 'fail' }: { mode?: 'fail' | 'warn' } = {}
  ) {
    this.eventualAsyncExpectation(async niceStack => {
      this.expectOK(await error, { mode, niceStack });
    });
  }
}

export type SubcaseBatchStateFromFixture<F> = F extends Fixture<infer S> ? S : never;

/**
 * FixtureClass encapsulates a constructor for fixture and a corresponding
 * shared state factory function. An interface version of the type is also
 * defined for mixin declaration use ONLY. The interface version is necessary
 * because mixin classes need a constructor with a single any[] rest
 * parameter.
 */
export type FixtureClass<F extends Fixture = Fixture> = {
  new (sharedState: SubcaseBatchStateFromFixture<F>, log: TestCaseRecorder, params: TestParams): F;
  MakeSharedState(recorder: TestCaseRecorder, params: TestParams): SubcaseBatchStateFromFixture<F>;
};
export type FixtureClassInterface<F extends Fixture = Fixture> = {
  /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
  new (...args: any[]): F;
  MakeSharedState(recorder: TestCaseRecorder, params: TestParams): SubcaseBatchStateFromFixture<F>;
};
export type FixtureClassWithMixin<FC, M> = FC extends FixtureClass<infer F>
  ? FixtureClass<F & M>
  : never;
