type AnyFn = (...args: Array<any>) => any;

export interface MethodSpy {
  readonly callCount: number;
  restore: () => void;
}

export interface SpyOptions {
  readonly stackMatcher?: (stack: string) => boolean;
}

export type SpyFn<T extends AnyFn> = T & MethodSpy;

export function spyOn<T extends AnyFn>(fn: T, options?: SpyOptions): SpyFn<T> {
  let callCount = 0;

  const spy = function (this: unknown, ...args: Parameters<T>): ReturnType<T> {
    if (
      options?.stackMatcher === undefined ||
      options.stackMatcher(new Error().stack ?? '')
    ) {
      callCount += 1;
    }
    return fn.apply(this, args) as ReturnType<T>;
  };

  Object.defineProperty(spy, 'callCount', {
    get() {
      return callCount;
    },
    enumerable: true,
  });

  return spy as unknown as SpyFn<T>;
}

export function spyOnMethod<T extends object>(
  target: T,
  key: keyof T,
  options?: SpyOptions,
): MethodSpy {
  const original = target[key];
  const wasOwnProperty = Object.hasOwn(target, key);

  if (typeof original !== 'function') {
    throw new Error(
      `Cannot spy on '${String(key)}' because it is not a function.`,
    );
  }

  const spy = spyOn(original as AnyFn, options);
  target[key] = spy as T[keyof T];

  const methodSpy: MethodSpy = {
    get callCount() {
      return spy.callCount;
    },
    restore() {
      if (wasOwnProperty) {
        target[key] = original;
      } else {
        delete target[key];
      }
    },
  };

  return methodSpy;
}