/*
 This file is part of GNU Taler
 (C) 2022 Taler Systems S.A.
 GNU Taler is free software; you can redistribute it and/or modify it under the
 terms of the GNU General Public License as published by the Free Software
 Foundation; either version 3, or (at your option) any later version.
 GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
 WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
 A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
 You should have received a copy of the GNU General Public License along with
 GNU Taler; see the file COPYING.  If not, see 
 */
import {
  Fragment,
  FunctionComponent,
  FunctionalComponent,
  VNode,
  h as create,
  options,
  render as renderIntoDom,
} from "preact";
import { render as renderToString } from "preact-render-to-string";
// This library is expected to be included in testing environment only
// When doing tests we want the requestAnimationFrame to be as fast as possible.
// without this option the RAF will timeout after 100ms making the tests slower
options.requestAnimationFrame = (fn: () => void) => {
  return fn();
};
export type ExampleItemSetup = {
  component: FunctionalComponent;
  props: Props;
  contextProps: object;
};
/**
 *
 * @param Component component to be tested
 * @param props allow partial props for easier example setup
 * @param contextProps if the context requires params for this example
 * @returns
 */
export function createExample(
  Component: FunctionalComponent,
  props: Partial | (() => Partial),
  contextProps?: T | (() => T),
): ExampleItemSetup {
  const evaluatedProps = typeof props === "function" ? props() : props;
  const Render = (args: any): VNode => create(Component, args);
  const evaluatedContextProps =
    typeof contextProps === "function" ? contextProps() : contextProps;
  return {
    component: Render,
    props: evaluatedProps as Props,
    contextProps: !evaluatedContextProps ? {} : evaluatedContextProps,
  };
}
/**
 * Should render HTML on node and browser
 * Browser: mount update and unmount
 * Node: render to string
 *
 * @param Component
 * @param args
 */
export function renderUI(example: ExampleItemSetup, Context?: any): void {
  const vdom = !Context
    ? create(example.component, example.props)
    : create(Context, {
        ...example.contextProps,
        children: [create(example.component, example.props)],
      });
  if (typeof window === "undefined") {
    renderToString(vdom);
  } else {
    const div = document.createElement("div");
    document.body.appendChild(div);
    renderIntoDom(vdom, div);
    renderIntoDom(null, div);
    document.body.removeChild(div);
  }
}
/**
 * No need to render.
 * Should mount, update and run effects.
 *
 * Browser: mount update and unmount
 * Node: mount on a mock virtual dom
 *
 * Mounting hook doesn't use DOM api so is
 * safe to use normal mounting api in node
 *
 * @param Component
 * @param props
 * @param Context
 */
function renderHook(
  Component: FunctionComponent,
  Context?: ({ children }: { children: any }) => VNode | null,
): void {
  const vdom = !Context
    ? create(Component, {})
    : create(Context, { children: [create(Component, {})] });
  //use normal mounting API since we expect
  //useEffect to be called ( and similar APIs )
  renderIntoDom(vdom, {} as Element);
}
type RecursiveState = S | (() => RecursiveState);
interface Mounted {
  pullLastResultOrThrow: () => Exclude;
  assertNoPendingUpdate: () => Promise;
  waitForStateUpdate: () => Promise;
}
/**
 * Manual API mount the hook and return testing API
 * Consider using hookBehaveLikeThis() function
 *
 * @param hookToBeTested
 * @param Context
 *
 * @returns testing API
 */
function mountHook(
  hookToBeTested: () => RecursiveState,
  Context?: ({ children }: { children: any }) => VNode | null,
): Mounted {
  let lastResult: Exclude | Error | null = null;
  const listener: Array<() => void> = [];
  // component that's going to hold the hook
  function Component(): VNode {
    try {
      let componentOrResult = hookToBeTested();
      // special loop
      // since Taler use a special type of hook that can return
      // a function and it will be treated as a composed component
      // then tests should be aware of it and reproduce the same behavior
      while (typeof componentOrResult === "function") {
        componentOrResult = componentOrResult();
      }
      //typecheck fails here
      const l: Exclude void> = componentOrResult as any;
      lastResult = l;
    } catch (e) {
      if (e instanceof Error) {
        lastResult = e;
      } else {
        lastResult = new Error(`mounting the hook throw an exception: ${e}`);
      }
    }
    // notify to everyone waiting for an update and clean the queue
    listener.splice(0, listener.length).forEach((cb) => cb());
    return create(Fragment, {});
  }
  renderHook(Component, Context);
  function pullLastResult(): Exclude {
    const copy: Exclude = lastResult;
    lastResult = null;
    return copy;
  }
  function pullLastResultOrThrow(): Exclude {
    const r = pullLastResult();
    if (r instanceof Error) throw r;
    //sanity check
    if (!r) throw Error("there was no last result");
    return r;
  }
  async function assertNoPendingUpdate(): Promise {
    await new Promise((res, rej) => {
      const tid = setTimeout(() => {
        res(true);
      }, 10);
      listener.push(() => {
        clearTimeout(tid);
        res(false);
        //   Error(`Expecting no pending result but the hook got updated.
        //  If the update was not intended you need to check the hook dependencies
        //  (or dependencies of the internal state) but otherwise make
        //  sure to consume the result before ending the test.`),
        // );
      });
    });
    const r = pullLastResult();
    if (r) {
      return Promise.resolve(false);
    }
    return Promise.resolve(true);
    //  This may happen because the hook did a new update but the test didn't consume the result using pullLastResult`);
  }
  async function waitForStateUpdate(): Promise {
    return await new Promise((res, rej) => {
      const tid = setTimeout(() => {
        res(false);
      }, 10);
      listener.push(() => {
        clearTimeout(tid);
        res(true);
      });
    });
  }
  return {
    pullLastResultOrThrow,
    waitForStateUpdate,
    assertNoPendingUpdate,
  };
}
export const nullFunction = (): void => {
  null;
};
export const nullAsyncFunction = (): Promise => {
  return Promise.resolve();
};
type HookTestResult = HookTestResultOk | HookTestResultError;
interface HookTestResultOk {
  result: "ok";
}
interface HookTestResultError {
  result: "fail";
  error: string;
  index: number;
}
/**
 * Main testing driver.
 * It will assert that there are no more and no less hook updates than expected.
 *
 * @param hookFunction hook function to be tested
 * @param props initial props for the hook
 * @param checks step by step state validation
 * @param Context additional testing context for overrides
 *
 * @returns testing result, should also be checked to be "ok"
 */
// eslint-disable-next-line @typescript-eslint/ban-types
export async function hookBehaveLikeThis(
  hookFunction: (p: PropsType) => RecursiveState,
  props: PropsType,
  checks: Array<(state: Exclude) => void>,
  Context?: ({ children }: { children: any }) => VNode | null,
): Promise {
  const { pullLastResultOrThrow, waitForStateUpdate, assertNoPendingUpdate } =
    mountHook(() => hookFunction(props), Context);
  const [firstCheck, ...restOfTheChecks] = checks;
  {
    const state = pullLastResultOrThrow();
    const checkError = firstCheck(state);
    if (checkError !== undefined) {
      return {
        result: "fail",
        index: 0,
        error: `First check returned with error: ${checkError}`,
      };
    }
  }
  let index = 1;
  for (const check of restOfTheChecks) {
    const hasNext = await waitForStateUpdate();
    if (!hasNext) {
      return {
        result: "fail",
        error: "Component didn't update and the test expected one more state",
        index,
      };
    }
    const state = pullLastResultOrThrow();
    const checkError = check(state);
    if (checkError !== undefined) {
      return {
        result: "fail",
        index,
        error: `Check returned with error: ${checkError}`,
      };
    }
    index++;
  }
  const hasNext = await waitForStateUpdate();
  if (hasNext) {
    return {
      result: "fail",
      index,
      error: "Component updated and test didn't expect more states",
    };
  }
  const noMoreUpdates = await assertNoPendingUpdate();
  if (noMoreUpdates === false) {
    return {
      result: "fail",
      index,
      error: "Component was updated but the test does not cover the update",
    };
  }
  return {
    result: "ok",
  };
}