/*
 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 <http://www.gnu.org/licenses/>
 */

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<Props extends object = {}> = {
  component: FunctionalComponent<Props>;
  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
 */
export function createExample<T extends object, Props extends object>(
  Component: FunctionalComponent<Props>,
  props: Partial<Props> | (() => Partial<Props>),
  contextProps?: T | (() => T),
): ExampleItemSetup<Props> {
  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<any>, 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> = S | (() => RecursiveState<S>);

interface Mounted<T> {
  pullLastResultOrThrow: () => Exclude<T, VoidFunction>;
  assertNoPendingUpdate: () => Promise<boolean>;
  waitForStateUpdate: () => Promise<boolean>;
}

/**
 * Manual API mount the hook and return testing API
 * Consider using hookBehaveLikeThis() function
 *
 * @param hookToBeTested
 * @param Context
 *
 * @returns testing API
 */
function mountHook<T extends object>(
  hookToBeTested: () => RecursiveState<T>,
  Context?: ({ children }: { children: any }) => VNode | null,
): Mounted<T> {
  let lastResult: Exclude<T, VoidFunction> | 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<T, () => 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<T | Error | null, VoidFunction> {
    const copy: Exclude<T | Error | null, VoidFunction> = lastResult;
    lastResult = null;
    return copy;
  }

  function pullLastResultOrThrow(): Exclude<T, VoidFunction> {
    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<boolean> {
    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<boolean> {
    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<void> => {
  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<T extends object, PropsType>(
  hookFunction: (p: PropsType) => RecursiveState<T>,
  props: PropsType,
  checks: Array<(state: Exclude<T, VoidFunction>) => void>,
  Context?: ({ children }: { children: any }) => VNode | null,
): Promise<HookTestResult> {
  const { pullLastResultOrThrow, waitForStateUpdate, assertNoPendingUpdate } =
    mountHook<T>(() => 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",
  };
}
