import { IContainer } from '@aurelia/kernel';
import { IRoute, IRouter, IRouterOptions, RouterConfiguration } from '@aurelia/router';
import { Aurelia, CustomElement, IPlatform, lifecycleHooks } from '@aurelia/runtime-html';
import { MockBrowserHistoryLocation, TestContext, assert } from '@aurelia/testing';

describe('router/router.lifecycle-hooks.spec.ts', function () {
  function getModifiedRouter(container: IContainer) {
    const router = container.get(IRouter) as IRouter;
    const mockBrowserHistoryLocation = new MockBrowserHistoryLocation();
    mockBrowserHistoryLocation.changeCallback = async (ev) => { router.viewer.handlePopStateEvent(ev); };
    router.viewer.history = mockBrowserHistoryLocation as any;
    router.viewer.location = mockBrowserHistoryLocation as any;
    return router;
  }

  type NavigationStateSpy = (action: 'push' | 'replace', data: any, title: string, path: string) => void;
  function spyNavigationStates(router: IRouter, spy?: NavigationStateSpy) {
    let _pushState;
    let _replaceState;
    if (spy) {
      _pushState = router.viewer.location.pushState;
      router.viewer.location.pushState = function (data, title, path) {
        spy('push', data, title, path);
        _pushState.call(router.viewer.location, data, title, path);
      };
      _replaceState = router.viewer.location.replaceState;
      router.viewer.location.replaceState = function (data, title, path) {
        spy('replace', data, title, path);
        _replaceState.call(router.viewer.location, data, title, path);
      };
    }
    return { _pushState, _replaceState };
  }
  function unspyNavigationStates(router, _push, _replace) {
    if (_push) {
      router.viewer.location.pushState = _push;
      router.viewer.location.replaceState = _replace;
    }
  }

  async function $setup(
    config?: IRouterOptions,
    dependencies: any[] = [],
    routes: IRoute[] = [],
    stateSpy: NavigationStateSpy = void 0,
  ) {
    const ctx = TestContext.create();

    const { container, platform } = ctx;

    const App = CustomElement.define({
      name: 'app',
      template: '<au-viewport name="app"></au-viewport>',
      dependencies
    }, class {
      public static routes: IRoute[] = routes;
    });

    const host = ctx.doc.createElement('div');
    ctx.doc.body.appendChild(host as any);

    const au = new Aurelia(container)
      .register(
        RouterConfiguration.customize(config ?? {}),
        App)
      .app({ host: host, component: App });

    const router = getModifiedRouter(container);
    const { _pushState, _replaceState } = spyNavigationStates(router, stateSpy);

    await au.start();

    async function $teardown() {
      unspyNavigationStates(router, _pushState, _replaceState);
      RouterConfiguration.customize();
      await au.stop(true);
      ctx.doc.body.removeChild(host);

      au.dispose();
    }

    return { ctx, container, platform, host, au, router, $teardown, App };
  }

  describe('[sync] lifecycleHooks', function () {
    this.timeout(5000);

    function _elements(config, calledHooks) {
      @lifecycleHooks()
      class Hooks {
        name = 'Hooks';
        canLoad(vm) { calledHooks.push(`${this.name}:${vm.name}:canLoad`); return config.Hooks_canLoad; }
        loading(vm) { calledHooks.push(`${this.name}:${vm.name}:loading`); }
        canUnload(vm) { calledHooks.push(`${this.name}:${vm.name}:canUnload`); return config.Hooks_canUnload; }
        unloading(vm) { calledHooks.push(`${this.name}:${vm.name}:unloading`); }
      }

      const One = CustomElement.define({ name: 'my-one', template: '!my-one!<au-viewport></au-viewport>', dependencies: [Hooks] },
        class {
          name = 'my-one';
          canLoad() { calledHooks.push(`VM:${this.name}:canLoad`); return config.One_canLoad; }
          loading() { calledHooks.push(`VM:${this.name}:loading`); }
          canUnload() { calledHooks.push(`VM:${this.name}:canUnload`); return config.One_canUnload; }
          unloading() { calledHooks.push(`VM:${this.name}:unloading`); }
          binding() { calledHooks.push(`VM:${this.name}:binding`); }
        });
      const Two = CustomElement.define({ name: 'my-two', template: '!my-two!', dependencies: [Hooks] },
        class {
          name = 'my-two';
          canLoad() { calledHooks.push(`VM:${this.name}:canLoad`); return config.Two_canLoad; }
          loading() { calledHooks.push(`VM:${this.name}:loading`); }
          canUnload() { calledHooks.push(`VM:${this.name}:canUnload`); return config.Two_canUnload; }
          unloading() { calledHooks.push(`VM:${this.name}:unloading`); }
          binding() { calledHooks.push(`VM:${this.name}:binding`); }
        });
      return { Hooks, One, Two };
    }

    const configs = [
      { Hooks_canLoad: true, Hooks_canUnload: true, One_canLoad: true, One_canUnload: true, Two_canLoad: true, Two_canUnload: true },
      { Hooks_canLoad: false, Hooks_canUnload: true, One_canLoad: true, One_canUnload: true, Two_canLoad: true, Two_canUnload: true },
      { Hooks_canLoad: true, Hooks_canUnload: true, One_canLoad: false, One_canUnload: true, Two_canLoad: true, Two_canUnload: true },
      { Hooks_canLoad: true, Hooks_canUnload: true, One_canLoad: true, One_canUnload: true, Two_canLoad: false, Two_canUnload: true },
      { Hooks_canLoad: true, Hooks_canUnload: false, One_canLoad: true, One_canUnload: true, Two_canLoad: true, Two_canUnload: true },
      { Hooks_canLoad: true, Hooks_canUnload: true, One_canLoad: true, One_canUnload: false, Two_canLoad: true, Two_canUnload: true },
      { Hooks_canLoad: true, Hooks_canUnload: true, One_canLoad: true, One_canUnload: true, Two_canLoad: true, Two_canUnload: false },
    ];

    function _falses($config: typeof configs[0]) {
      const falses = [];
      for (const [key, value] of Object.entries($config)) {
        if (!value) {
          falses.push(key);
        }
      }
      return falses.join(', ');
    }

    for (const config of configs) {
      const calledHooks = [];
      const { Hooks, One, Two } = _elements(config, calledHooks);

      function _expected($config: typeof config) {
        let oneLoaded = false;
        let twoChecked = false;
        let twoLoaded = false;
        const expected = [];

        expected.push('Hooks:my-one:canLoad');
        if (!$config.Hooks_canLoad) {
          expected.push('Hooks:my-two:canLoad');
          return expected;
        }

        expected.push('VM:my-one:canLoad');
        if ($config.One_canLoad) {
          expected.push('Hooks:my-one:loading', 'VM:my-one:loading', 'VM:my-one:binding');
          oneLoaded = true;

          expected.push('Hooks:my-one:canUnload');

          if (!$config.Hooks_canUnload) {
            expected.push('Hooks:my-one:canUnload');
            return expected;
          }

          expected.push('VM:my-one:canUnload');
          if (!$config.One_canUnload) {
            expected.push('Hooks:my-one:canUnload');
            expected.push('VM:my-one:canUnload');
            return expected;
          }

          expected.push('Hooks:my-two:canLoad');
          expected.push('VM:my-two:canLoad');
          twoChecked = true;
          if ($config.Two_canLoad) {
            expected.push('Hooks:my-one:unloading', 'VM:my-one:unloading');
            oneLoaded = false;

            expected.push('Hooks:my-two:loading', 'VM:my-two:loading', 'VM:my-two:binding');
            twoLoaded = true;

            expected.push('Hooks:my-two:canUnload');
            expected.push('VM:my-two:canUnload');
            if ($config.Two_canUnload) {
              expected.push('Hooks:my-two:unloading', 'VM:my-two:unloading');
              // two = false;
            }
          }
        }

        if (!oneLoaded && !twoChecked) {
          expected.push('Hooks:my-two:canLoad');
          expected.push('VM:my-two:canLoad');
          if ($config.Two_canLoad) {
            expected.push('Hooks:my-two:loading', 'VM:my-two:loading', 'VM:my-two:binding');
            // eslint-disable-next-line @typescript-eslint/no-unused-vars
            twoLoaded = true;

            expected.push('Hooks:my-two:canUnload');
            if ($config.Hooks_canUnload) {
              expected.push('VM:my-two:canUnload');
              if ($config.Two_canUnload) {
                expected.push('Hooks:my-two:unloading', 'VM:my-two:unloading');
                // two = false;
              }
            }
          }
        }

        if (oneLoaded) {
          expected.push('Hooks:my-one:canUnload');
          expected.push('VM:my-one:canUnload');
          if (!$config.Hooks_canUnload || !$config.One_canUnload) {
            return expected;
          }

          expected.push('Hooks:my-one:unloading', 'VM:my-one:unloading');
          oneLoaded = false;
        }

        return expected;
      }

      it(`with hook and vm (falses: ${_falses(config)})`, async function () {
        const { platform, router, $teardown } = await $setup({}, [Hooks, One, Two]);

        const expected = _expected(config);

        await $load('/my-one', router, platform);

        await $load('/my-two', router, platform);

        await $load('-', router, platform);

        assert.strictEqual(calledHooks.join('|'), expected.join('|'), `calledHooks`);

        // Make sure there's time to store the result before teardown. Should only be needed in testing.
        await new Promise((resolve) => setTimeout(resolve, 0));

        await $teardown();
      });
    }

    for (const config of configs) {
      const calledHooks = [];
      const { Hooks, One, Two } = _elements(config, calledHooks);

      function _expected(config: typeof configs[0]) {
        const expected = ['Hooks:my-one:canLoad'];
        if (!config.Hooks_canLoad) return expected;
        expected.push('VM:my-one:canLoad');
        if (!config.One_canLoad) return expected;
        expected.push('Hooks:my-one:loading', 'VM:my-one:loading', 'VM:my-one:binding', 'Hooks:my-two:canLoad');
        expected.push('VM:my-two:canLoad');
        if (!config.Two_canLoad) {
          expected.push('Hooks:my-one:canUnload', 'VM:my-one:canUnload', 'Hooks:my-one:unloading', 'VM:my-one:unloading');
          return expected;
        }
        expected.push('Hooks:my-two:loading', 'VM:my-two:loading', 'VM:my-two:binding');

        expected.push('Hooks:my-two:canUnload');
        if (!config.Hooks_canUnload) return expected;
        expected.push('VM:my-two:canUnload');
        if (!config.Two_canUnload) return expected;
        expected.push('Hooks:my-one:canUnload');
        expected.push('VM:my-one:canUnload');
        if (!config.One_canUnload) return expected;
        expected.push('Hooks:my-two:unloading', 'VM:my-two:unloading', 'Hooks:my-one:unloading', 'VM:my-one:unloading');
        return expected;
      }

      it(`in parent-child with hook and vm (falses: ${_falses(config)})`, async function () {
        const { platform, router, $teardown } = await $setup({}, [Hooks, One, Two]);

        await $load('/my-one/my-two', router, platform);

        await $load('-', router, platform);

        const expected = _expected(config);

        assert.strictEqual(calledHooks.join('|'), expected.join('|'), `calledHooks`);

        await $teardown();
      });
    }
  });

  describe('[async] lifecycleHooks', function () {
    this.timeout(30000);

    function _elements(config: typeof configs[0], calledHooks: string[]) {
      type Vm = { name: string };
      @lifecycleHooks()
      class Hooks {
        public name = 'Hooks';
        public async canLoad(vm: Vm) { calledHooks.push(`${this.name}:${vm.name}:canLoad`); /* return config.Hooks_canLoad; */ return new Promise((res) => { setTimeout(() => res(config.Hooks_canLoad), 100); }); }
        public async loading(vm: Vm) { calledHooks.push(`${this.name}:${vm.name}:loading`); return new Promise((res) => { setTimeout(() => res(void 0), 75); }); }
        public async canUnload(vm: Vm) { calledHooks.push(`${this.name}:${vm.name}:canUnload`); /* return config.Hooks_canUnload; */ return new Promise((res) => { setTimeout(() => res(config.Hooks_canUnload), 100); }); }
        public async unloading(vm: Vm) { calledHooks.push(`${this.name}:${vm.name}:unloading`); return new Promise((res) => { setTimeout(() => res(void 0), 75); }); }

        // TODO: Put these in once core supports them
        // public binding(vm) { calledHooks.push(`${this.name}:${vm.name}:binding`); }
        // public bound(vm) { calledHooks.push(`${this.name}:${vm.name}:bound`); }
        // public attaching(vm) { calledHooks.push(`${this.name}:${vm.name}:attaching`); }
        // public attached(vm) { calledHooks.push(`${this.name}:${vm.name}:attached`); }
      }

      const One = CustomElement.define({ name: 'my-one', template: '!my-one!<au-viewport></au-viewport>', dependencies: [Hooks] },
        class {
          public name = 'my-one';
          public canLoad() { calledHooks.push(`VM:${this.name}:canLoad`); /* return config.One_canLoad; */ return new Promise((res) => { setTimeout(() => res(config.One_canLoad), 100); }); }
          public loading() { calledHooks.push(`VM:${this.name}:loading`); return new Promise((res) => { setTimeout(() => res(void 0), 50); }); }
          public canUnload() { calledHooks.push(`VM:${this.name}:canUnload`); /* return config.One_canUnload; */ return new Promise((res) => { setTimeout(() => res(config.One_canUnload), 100); }); }
          public unloading() { calledHooks.push(`VM:${this.name}:unloading`); return new Promise((res) => { setTimeout(() => res(void 0), 50); }); }
          public binding() { calledHooks.push(`VM:${this.name}:binding`); }
        });

      const Two = CustomElement.define({ name: 'my-two', template: '!my-two!', dependencies: [Hooks] },
        class {
          public name = 'my-two';
          public canLoad() { calledHooks.push(`VM:${this.name}:canLoad`); /* return config.Two_canLoad; */ return new Promise((res) => { setTimeout(() => res(config.Two_canLoad), 100); }); }
          public loading() { calledHooks.push(`VM:${this.name}:loading`); return new Promise((res) => { setTimeout(() => res(void 0), 50); }); }
          public canUnload() { calledHooks.push(`VM:${this.name}:canUnload`); /* return config.Two_canUnload; */ return new Promise((res) => { setTimeout(() => res(config.Two_canUnload), 100); }); }
          public unloading() { calledHooks.push(`VM:${this.name}:unloading`); return new Promise((res) => { setTimeout(() => res(void 0), 50); }); }
          public binding() { calledHooks.push(`VM:${this.name}:binding`); }
        });
      return { Hooks, One, Two };
    }

    const configs = [
      { Hooks_canLoad: true, Hooks_canUnload: true, One_canLoad: true, One_canUnload: true, Two_canLoad: true, Two_canUnload: true },
      { Hooks_canLoad: false, Hooks_canUnload: true, One_canLoad: true, One_canUnload: true, Two_canLoad: true, Two_canUnload: true },
      { Hooks_canLoad: true, Hooks_canUnload: true, One_canLoad: false, One_canUnload: true, Two_canLoad: true, Two_canUnload: true },
      { Hooks_canLoad: true, Hooks_canUnload: true, One_canLoad: true, One_canUnload: true, Two_canLoad: false, Two_canUnload: true },
      { Hooks_canLoad: true, Hooks_canUnload: false, One_canLoad: true, One_canUnload: true, Two_canLoad: true, Two_canUnload: true },
      { Hooks_canLoad: true, Hooks_canUnload: true, One_canLoad: true, One_canUnload: false, Two_canLoad: true, Two_canUnload: true },
      { Hooks_canLoad: true, Hooks_canUnload: true, One_canLoad: true, One_canUnload: true, Two_canLoad: true, Two_canUnload: false },
    ];

    function _falses(config: typeof configs[0]) {
      const falses = [];
      for (const [key, value] of Object.entries(config)) {
        if (!value) {
          falses.push(key);
        }
      }
      return falses.join(', ');
    }

    for (const config of configs) {
      const calledHooks = [];
      const { Hooks, One, Two } = _elements(config, calledHooks);

      function _expected(config: typeof configs[0]) {
        let oneLoaded = false;
        let twoChecked = false;
        // let twoLoaded = false;
        const expected = [];

        expected.push('Hooks:my-one:canLoad');
        if (!config.Hooks_canLoad) {
          expected.push('Hooks:my-two:canLoad');
          return expected;
        }

        expected.push('VM:my-one:canLoad');
        if (config.One_canLoad) {
          expected.push('Hooks:my-one:loading', 'VM:my-one:loading', 'VM:my-one:binding');
          oneLoaded = true;

          expected.push('Hooks:my-one:canUnload');

          if (!config.Hooks_canUnload) {
            expected.push('Hooks:my-one:canUnload');
            return expected;
          }

          expected.push('VM:my-one:canUnload');
          if (!config.One_canUnload) {
            expected.push('Hooks:my-one:canUnload');
            expected.push('VM:my-one:canUnload');
            return expected;
          }

          expected.push('Hooks:my-two:canLoad');
          expected.push('VM:my-two:canLoad');
          twoChecked = true;
          if (config.Two_canLoad) {
            expected.push('Hooks:my-one:unloading', 'VM:my-one:unloading');
            oneLoaded = false;

            expected.push('Hooks:my-two:loading', 'VM:my-two:loading', 'VM:my-two:binding');
            // twoLoaded = true;

            expected.push('Hooks:my-two:canUnload');
            expected.push('VM:my-two:canUnload');
            if (config.Two_canUnload) {
              expected.push('Hooks:my-two:unloading', 'VM:my-two:unloading');
              // two = false;
            }
            // if (config.Hooks_canUnload) {
            // }
          }
          // if (config.Hooks_canLoad) {

          // }
        }

        if (!oneLoaded && !twoChecked) {
          expected.push('Hooks:my-two:canLoad');
          expected.push('VM:my-two:canLoad');
          if (config.Two_canLoad) {
            expected.push('Hooks:my-two:loading', 'VM:my-two:loading', 'VM:my-two:binding');
            // twoLoaded = true;

            expected.push('Hooks:my-two:canUnload');
            if (config.Hooks_canUnload) {
              expected.push('VM:my-two:canUnload');
              if (config.Two_canUnload) {
                expected.push('Hooks:my-two:unloading', 'VM:my-two:unloading');
                // two = false;
              }
            }
          }
          // if (config.Hooks_canLoad) {
          // }
        }

        if (oneLoaded) {
          expected.push('Hooks:my-one:canUnload');
          expected.push('VM:my-one:canUnload');
          if (!config.Hooks_canUnload || !config.One_canUnload) {
            return expected;
          }

          expected.push('Hooks:my-one:unloading', 'VM:my-one:unloading');
          oneLoaded = false;
        }

        return expected;
      }
      it(`with hook and vm (falses: ${_falses(config)})`, async function () {
        const { platform, router, $teardown } = await $setup({}, [Hooks, One, Two]);

        const expected = _expected(config);

        await $load('/my-one', router, platform);

        await $load('/my-two', router, platform);

        await $load('-', router, platform);

        assert.strictEqual(calledHooks.join('|'), expected.join('|'), `calledHooks`);

        await $teardown();
      });
    }

    for (const config of configs) {
      const calledHooks = [];
      const { Hooks, One, Two } = _elements(config, calledHooks);

      function _expected(config: typeof configs[0]) {
        const expected = ['Hooks:my-one:canLoad'];
        if (!config.Hooks_canLoad) return expected;
        expected.push('VM:my-one:canLoad');
        if (!config.One_canLoad) return expected;
        expected.push('Hooks:my-one:loading', 'VM:my-one:loading', 'VM:my-one:binding', 'Hooks:my-two:canLoad');
        // if (!config.Hooks_canLoad) return expected;
        expected.push('VM:my-two:canLoad');
        if (!config.Two_canLoad) {
          expected.push('Hooks:my-one:canUnload', 'VM:my-one:canUnload', 'Hooks:my-one:unloading', 'VM:my-one:unloading');
          return expected;
        }
        expected.push('Hooks:my-two:loading', 'VM:my-two:loading', 'VM:my-two:binding');

        expected.push('Hooks:my-two:canUnload');
        if (!config.Hooks_canUnload) return expected;
        expected.push('VM:my-two:canUnload');
        if (!config.Two_canUnload) return expected;
        expected.push('Hooks:my-one:canUnload');
        // if (!config.Hooks_canUnload) return expected;
        expected.push('VM:my-one:canUnload');
        if (!config.One_canUnload) return expected;
        expected.push('Hooks:my-two:unloading', 'VM:my-two:unloading', 'Hooks:my-one:unloading', 'VM:my-one:unloading');
        return expected;
      }

      it(`in parent-child with hook and vm (falses: ${_falses(config)})`, async function () {
        const { platform, router, $teardown } = await $setup({}, [Hooks, One, Two]);

        await $load('/my-one/my-two', router, platform);
        await $load('-', router, platform);

        const expected = _expected(config);

        assert.strictEqual(calledHooks.join('|'), expected.join('|'), `calledHooks`);

        await $teardown();
      });
    }
  });
});

const $load = async (path: string, router: IRouter, platform: IPlatform) => {
  await router.load(path);
  platform.domQueue.flush();
};
