/* eslint-disable import/no-unused-modules */

/**

Usage:

// logs out the calls per frame, mean time, etc, under "myFunction"
const myFunction = instrumentPerFrame(function myFunction() {...});

// pass the name as the first parameter to wrap around methods and give them a name.
// if you don't do this, the name is the stringified function!
const object = {
  method: instrumentPerFrame("method", () => {...});
}

// you can use timePerFrame.start/end like console.time/timeEnd except it
// aggregates the accumulated time over all the calls in a frame.
function someOtherFunctionThatIsCalledALot() {
  timePerFrame.start("thing a");
  doThingA();
  anotherAThing();
  timePerFrame.end("thing a");

  timePerFrame.start("thing b");
  doThingB();
  anotherBThing();
  timePerFrame.end("thing b");
}
*/

// Record call usage per frame.
let isInstrumentingCalls = false;
type LoggingThreshold = {
  property: "count" | "time";
  value: number;
};

type TrackedCall = {
  key: string;
  threshold: LoggingThreshold;
  count: number;
  time: number;
  lastStartTimes: number[];
};
const calls: Map<string, TrackedCall> = new Map();
function logInstrumentedCalls() {
  const tableData: Record<
    string,
    { count: number; "total time (ms)": number; "mean time (ms)": number }
  > = {};
  for (const [, entry] of calls) {
    if (entry.lastStartTimes.length) {
      // eslint-disable-next-line no-console
      console.warn(
        `Unfinsihed timePerFrame.start("${entry.key}"). It looks like you forgot to call timePerFrame.end("${entry.key}").`
      );
      entry.lastStartTimes = [];
    }
    if (
      (entry.threshold.property === "count" &&
        entry.count > entry.threshold.value) ||
      (entry.threshold.property === "time" &&
        entry.time > entry.threshold.value)
    ) {
      tableData[entry.key] = {
        "total time (ms)": +entry.time.toFixed(5),
        "mean time (ms)": +(entry.time / entry.count).toFixed(5),
        count: entry.count,
      };
    }

    entry.count = 0;
    entry.time = 0;
  }

  if (Object.keys(tableData).length) {
    // eslint-disable-next-line no-console
    console.table(tableData);
  }

  if (logInstrumentedCalls) {
    window.requestAnimationFrame(logInstrumentedCalls);
  }
}

function getOrCreate(key: string, threshold?: TrackedCall["threshold"]) {
  const existing = calls.get(key);
  if (existing) return existing;

  const newCall: TrackedCall = {
    key,
    count: 0,
    time: 0,
    lastStartTimes: [],
    threshold: threshold ?? { property: "count", value: 0 },
  };
  calls.set(key, newCall);

  return newCall;
}

function startTrackingCall(key: string) {
  const trackEntry = getOrCreate(key);
  trackEntry.lastStartTimes.push(performance.now());
}

function endTrackingCall(key: string) {
  const trackEntry = getOrCreate(key);
  const start = trackEntry.lastStartTimes.pop();
  if (!start) {
    // eslint-disable-next-line no-console
    console.error(new Error("No tracking call found"));
    return;
  }

  trackEntry.count += 1;
  trackEntry.time += performance.now() - start;
}

function startInstrumenting() {
  if (!isInstrumentingCalls) {
    // eslint-disable-next-line no-console
    console.log("Starting instrumentation");
    isInstrumentingCalls = true;
    window.requestAnimationFrame(logInstrumentedCalls);
  }
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyFn = (this: any, ...args: any[]) => unknown;
type Options = {
  key?: string;
  threshold?: LoggingThreshold;
};

export function instrumentPerFrame<F extends AnyFn>(fn: F): F;
export function instrumentPerFrame<F extends AnyFn>(options: Options, fn: F): F;
export function instrumentPerFrame<F extends AnyFn>(key: string, fn: F): F;

export function instrumentPerFrame<F extends AnyFn>(
  fnOrOptions: F | Options | string,
  nothingOrFn?: typeof fnOrOptions extends AnyFn ? never : F
): F {
  startInstrumenting();
  const fn = nothingOrFn ?? (fnOrOptions as F);
  const options =
    typeof fnOrOptions === "function"
      ? {}
      : typeof fnOrOptions === "string"
      ? { key: fnOrOptions }
      : fnOrOptions;

  if (!options?.key && !fn.name) {
    // eslint-disable-next-line no-console
    console.warn(
      `Instrumented function does not have a name: ${fn
        .toString()
        .slice(0, 100)}`
    );
  }
  const key =
    options?.key ?? (fn.name || fn.toString().split("\n")[0]) ?? "(anonymous)";

  const alreadyInstrumented = calls.has(key);
  if (typeof fnOrOptions === "function" && alreadyInstrumented) {
    // eslint-disable-next-line no-console
    console.warn(
      `There is already an instrumented function with the key "${key}". This might be a mistake. If you really mean to instrument different functions under the same grouping, give them an explicit key by passing a key as the first parameter.`
    );
  }
  if (!alreadyInstrumented) {
    // eslint-disable-next-line no-console
    console.log(`Instrumenting ${calls.get(key)?.key}`);
    getOrCreate(key, options?.threshold);
  }

  return function (
    this: ThisParameterType<F>,
    ...args: Parameters<F>
  ): ReturnType<F> {
    startTrackingCall(key);
    const result = fn.apply(this, args) as ReturnType<F>;
    endTrackingCall(key);
    return result;
  } as F;
}

export const timePerFrame = {
  start: (key: string) => {
    startInstrumenting();
    startTrackingCall(key);
  },
  end: endTrackingCall,
};