import { newInstanceForScope, newInstanceOf, resolve, toArray } from '@aurelia/kernel';
import { assert, createSpy, getVisibleText, ISpy, TestContext } from '@aurelia/testing';
import { IValidationRules } from '@aurelia/validation';
import { CustomAttribute, CustomElement, customElement, IPlatform, Aurelia } from '@aurelia/runtime-html';
import {
  IValidationController,
  ValidationController,
  ValidationErrorsCustomAttribute,
  ValidationHtmlConfiguration,
  ValidationResultsSubscriber,
} from '@aurelia/validation-html';
import { createSpecFunction, TestExecutionContext, TestFunction, ToNumberValueConverter } from '../../util.js';
import { Person } from '../../validation/_test-resources.js';

describe('validation-html/subscribers/validation-errors-custom-attribute.spec.ts', function () {
  describe('validation-errors-custom-attribute', function () {

    class App {
      public person: Person = new Person((void 0)!, (void 0)!);
      public controllerValidateSpy: ISpy;
      public controllerRemoveSubscriberSpy: ISpy;
      public controller2ValidateSpy: ISpy;
      public controller2RemoveSubscriberSpy: ISpy;
      public readonly platform: IPlatform = resolve(IPlatform);
      public controller: ValidationController = resolve(newInstanceForScope(IValidationController)) as ValidationController;
      public controller2: ValidationController = resolve(newInstanceOf(IValidationController)) as ValidationController;
      private readonly validationRules: IValidationRules = resolve(IValidationRules);
      public constructor() {
        this.controllerValidateSpy = createSpy(this.controller, 'validate', true);
        this.controllerRemoveSubscriberSpy = createSpy(this.controller, 'removeSubscriber', true);
        this.controller2ValidateSpy = createSpy(this.controller2, 'validate', true);
        this.controller2RemoveSubscriberSpy = createSpy(this.controller2, 'removeSubscriber', true);
        this.validationRules
          .on(this.person)

          .ensure('name')
          .displayName('Name')
          .required()

          .ensure('age')
          .displayName('Age')
          .required()
          .satisfies((age: any) => age !== '' && age % 3 === 0 && age % 5 === 0)
          .withMessage('${$displayName} is not fizbaz');
      }

      public unbinding() {
        this.validationRules.off();
        // mandatory cleanup in root
        this.controller.reset();
      }

      public dispose() {
        const controller = this.controller;
        assert.equal(controller.results.length, 0, 'the result should have been removed');
        assert.equal(controller.bindings.size, 0, 'the bindings should have been removed');
        assert.equal(controller.objects.size, 0, 'the objects should have been removed');
      }
    }
    interface TestSetupContext {
      template: string;
      removeSubscriberSpies?: Record<string, number>;
    }
    async function runTest(
      testFunction: TestFunction<TestExecutionContext<App>>,
      { template, removeSubscriberSpies }: TestSetupContext
    ) {
      const ctx = TestContext.create();
      const container = ctx.container;
      const host = ctx.doc.createElement('app');
      ctx.doc.body.appendChild(host);
      const au = new Aurelia(container);
      await au
        .register(
          ValidationHtmlConfiguration,
          ToNumberValueConverter
        )
        .app({
          host,
          component: CustomElement.define({ name: 'app', template }, App)
        })
        .start();

      const app = au.root.controller.viewModel as App;
      await testFunction({ app, host, container, platform: app.platform, ctx });

      await au.stop();
      ctx.doc.body.removeChild(host);
      if (removeSubscriberSpies !== void 0) {
        for (const [spy, count] of Object.entries(removeSubscriberSpies)) {
          assert.equal(app[spy].calls.length, count);
        }
      } else {
        assert.equal(app.controllerRemoveSubscriberSpy.calls.length, template.match(/validation-errors|validate/g).length);
      }
      au.dispose();
    }

    const $it = createSpecFunction(runTest);

    async function assertEventHandler(
      target: HTMLElement,
      platform: IPlatform,
      controllerValidateSpy: ISpy,
      handleValidationEventSpy: ISpy,
      ctx: TestContext,
      event: string = 'focusout',
    ) {
      handleValidationEventSpy.calls.splice(0);
      controllerValidateSpy.calls.splice(0);
      target.dispatchEvent(new ctx.Event(event));
      await platform.domQueue.yield();
      assert.equal(controllerValidateSpy.calls.length, 1, 'incorrect #calls for validate');
      assert.equal(handleValidationEventSpy.calls.length, 1, 'incorrect #calls for handleValidationEvent');
    }
    function assertSubscriber(controller: ValidationController, ca: ValidationErrorsCustomAttribute) {
      const subscribers = (controller['subscribers'] as Set<ValidationResultsSubscriber>);
      assert.equal((subscribers).has(ca), true);
      assert.equal(ca.controller, controller);
    }

    $it('registers only the errors targeted for the containing elements',
      async function ({ host, platform, app, ctx }) {
        const div1 = host.querySelector('#div1');
        const div2 = host.querySelector('#div2');
        const ca1: ValidationErrorsCustomAttribute = CustomAttribute.for(div1, 'validation-errors').viewModel as unknown as ValidationErrorsCustomAttribute;
        const ca2: ValidationErrorsCustomAttribute = CustomAttribute.for(div2, 'validation-errors').viewModel as unknown as ValidationErrorsCustomAttribute;
        const spy1 = createSpy(ca1, 'handleValidationEvent', true);
        const spy2 = createSpy(ca2, 'handleValidationEvent', true);
        const { controllerValidateSpy, controller } = app;

        // assert that things are correctly wired up
        assertSubscriber(controller, ca1);
        assertSubscriber(controller, ca2);

        const target1 = div1.querySelector('#target1') as HTMLInputElement;
        const target2 = div2.querySelector('#target2') as HTMLInputElement;

        await assertEventHandler(target1, platform, controllerValidateSpy, spy1, ctx);
        target2.value = 'foo';
        target2.dispatchEvent(new ctx.Event('change'));
        await platform.domQueue.yield();
        await assertEventHandler(target2, platform, controllerValidateSpy, spy2, ctx);

        // assert that errors are rendered in the respective containers
        let errors1 = ca1.errors;
        assert.deepEqual(app['nameErrors'], errors1);
        assert.equal(errors1.length, 1);
        assert.deepEqual(errors1[0].targets, [target1]);

        let errors2 = ca2.errors;
        assert.deepEqual(app['ageErrors'], errors2);
        assert.equal(errors2.length, 2);
        assert.deepEqual(errors2[0].targets, [target2]);
        assert.deepEqual(errors2[1].targets, [target2]);

        assert.html.textContent(div1.querySelector('span.error'), 'Name is required.');
        assert.deepEqual(
          toArray(div2.querySelectorAll('span.error'))
            .map((span) => getVisibleText(span, true)),
          ['Age is required.', 'Age is not fizbaz']
        );

        // assert that errors are removed
        target1.value = 'foo';
        target1.dispatchEvent(new ctx.Event('change'));
        await platform.domQueue.yield();
        await assertEventHandler(target1, platform, controllerValidateSpy, spy1, ctx);

        target2.value = '15';
        target2.dispatchEvent(new ctx.Event('change'));
        await platform.domQueue.yield();
        await assertEventHandler(target2, platform, controllerValidateSpy, spy2, ctx);

        errors1 = ca1.errors;
        assert.equal(errors1.length, 0, 'errors1.length');
        assert.equal(div1.querySelectorAll('span.error').length, 0, 'div1.querySelectorAll(span.error).length');
        errors2 = ca2.errors;
        assert.equal(errors2.length, 0, 'errors2.length');
        assert.equal(div2.querySelectorAll('span.error').length, 0, 'div2.querySelectorAll(span.error).length');
      },
      {
        template: `
      <div id="div1" validation-errors.bind="nameErrors">
        <input id="target1" type="text" value.two-way="person.name & validate">
        <span class="error" repeat.for="errorInfo of nameErrors">
          \${errorInfo.result.message}
        </span>
      </div>
      <div id="div2" validation-errors.bind="ageErrors">
        <input id="target2" type="text" value.two-way="person.age | toNumber & validate">
        <span class="error" repeat.for="errorInfo of ageErrors">
          \${errorInfo.result.message}
        </span>
      </div>
      ` }
    );

    $it('sorts the errors according to the target position',
      async function ({ host, platform, app, ctx }) {
        const div = host.querySelector('div');
        const ca: ValidationErrorsCustomAttribute = CustomAttribute.for(div, 'validation-errors').viewModel as unknown as ValidationErrorsCustomAttribute;
        const spy = createSpy(ca, 'handleValidationEvent', true);
        const { controllerValidateSpy, controller } = app;

        assertSubscriber(controller, ca);

        const target1 = div.querySelector('#target1') as HTMLInputElement;
        const target2 = div.querySelector('#target2') as HTMLInputElement;

        await assertEventHandler(target1, platform, controllerValidateSpy, spy, ctx);
        target2.value = 'foo';
        target2.dispatchEvent(new ctx.Event('change'));
        await platform.domQueue.yield();
        await assertEventHandler(target2, platform, controllerValidateSpy, spy, ctx);

        const errors1 = ca.errors;
        assert.deepEqual(app['errors'], errors1);
        assert.equal(errors1.length, 3);
        assert.deepEqual(errors1[0].targets, [target1]);
        assert.deepEqual(errors1[1].targets, [target2]);
        assert.deepEqual(errors1[2].targets, [target2]);

        assert.deepEqual(
          toArray(div.querySelectorAll('span.error'))
            .map((span) => getVisibleText(span, true)),
          ['Name is required.', 'Age is required.', 'Age is not fizbaz']
        );
      },
      {
        template: `
      <div id="div" validation-errors.bind="errors">
        <input id="target1" type="text" value.two-way="person.name & validate">
        <input id="target2" type="text" value.two-way="person.age | toNumber & validate">
        <span class="error" repeat.for="errorInfo of errors">
          \${errorInfo.result.message}
        </span>
      </div>
      ` }
    );

    $it('respects bound controller',
      async function ({ host, platform, app, ctx }) {
        const div1 = host.querySelector('#div1');
        const div2 = host.querySelector('#div2');
        const ca1: ValidationErrorsCustomAttribute = CustomAttribute.for(div1, 'validation-errors').viewModel as unknown as ValidationErrorsCustomAttribute;
        const ca2: ValidationErrorsCustomAttribute = CustomAttribute.for(div2, 'validation-errors').viewModel as unknown as ValidationErrorsCustomAttribute;
        const spy1 = createSpy(ca1, 'handleValidationEvent', true);
        const spy2 = createSpy(ca2, 'handleValidationEvent', true);
        const { controllerValidateSpy, controller, controller2ValidateSpy, controller2 } = app;

        // assert that things are correctly wired up
        assertSubscriber(controller, ca1);
        assertSubscriber(controller2, ca2);

        const target1 = div1.querySelector('#target1') as HTMLInputElement;
        const target2 = div2.querySelector('#target2') as HTMLInputElement;

        await assertEventHandler(target1, platform, controllerValidateSpy, spy1, ctx);
        target2.value = 'foo';
        target2.dispatchEvent(new ctx.Event('change'));
        await platform.domQueue.yield();
        await assertEventHandler(target2, platform, controller2ValidateSpy, spy2, ctx);

        // assert that errors are rendered in the respective containers
        let errors1 = ca1.errors;
        assert.deepEqual(app['nameErrors'], errors1);
        assert.equal(errors1.length, 1);
        assert.deepEqual(errors1[0].targets, [target1]);

        let errors2 = ca2.errors;
        assert.deepEqual(app['ageErrors'], errors2);
        assert.equal(errors2.length, 2);
        assert.deepEqual(errors2[0].targets, [target2]);
        assert.deepEqual(errors2[1].targets, [target2]);

        assert.html.textContent(div1.querySelector('span.error'), 'Name is required.');
        assert.deepEqual(
          toArray(div2.querySelectorAll('span.error'))
            .map((span) => getVisibleText(span, true)),
          ['Age is required.', 'Age is not fizbaz']
        );

        // assert that errors are removed
        target1.value = 'foo';
        target1.dispatchEvent(new ctx.Event('change'));
        await platform.domQueue.yield();
        await assertEventHandler(target1, platform, controllerValidateSpy, spy1, ctx);

        target2.value = '15';
        target2.dispatchEvent(new ctx.Event('change'));
        await platform.domQueue.yield();
        await assertEventHandler(target2, platform, controller2ValidateSpy, spy2, ctx);

        errors1 = ca1.errors;
        assert.equal(errors1.length, 0);
        assert.equal(div1.querySelectorAll('span.error').length, 0);
        errors2 = ca2.errors;
        assert.equal(errors2.length, 0);
        assert.equal(div2.querySelectorAll('span.error').length, 0);
      },
      {
        template: `
      <div id="div1" validation-errors="errors.bind: nameErrors; controller.bind: controller;">
        <input id="target1" type="text" value.two-way="person.name & validate:'focusout':controller">
        <span class="error" repeat.for="errorInfo of nameErrors">
          \${errorInfo.result.message}
        </span>
      </div>
      <div id="div2" validation-errors="errors.bind: ageErrors; controller.bind: controller2;">
        <input id="target2" type="text" value.two-way="person.age | toNumber & validate:'focusout':controller2">
        <span class="error" repeat.for="errorInfo of ageErrors">
          \${errorInfo.result.message}
        </span>
      </div>
      `,
        removeSubscriberSpies: { controllerRemoveSubscriberSpy: 2, controller2RemoveSubscriberSpy: 2 }
      }
    );

    $it('does not put the errors in VM when used with let',
      async function ({ host, platform, app, ctx }) {
        const div = host.querySelector('div');
        const ca: ValidationErrorsCustomAttribute = CustomAttribute.for(div, 'validation-errors').viewModel as unknown as ValidationErrorsCustomAttribute;
        const spy = createSpy(ca, 'handleValidationEvent', true);
        const { controllerValidateSpy, controller } = app;

        assertSubscriber(controller, ca);

        const target = div.querySelector('#target1') as HTMLInputElement;

        await assertEventHandler(target, platform, controllerValidateSpy, spy, ctx);

        const errors1 = ca.errors;
        assert.equal('errors' in app, false);
        assert.equal(errors1.length, 1);
        assert.deepEqual(errors1[0].targets, [target]);
        assert.html.textContent(div.querySelector('span.error'), 'Name is required.');
      },
      {
        template: `
      <let errors.bind="undefined"></let>
      <div id="div" validation-errors.bind="errors">
        <input id="target1" type="text" value.two-way="person.name & validate">
        <span class="error" repeat.for="errorInfo of errors">
          \${errorInfo.result.message}
        </span>
      </div>
      ` }
    );

    it('can be used without any available registration for scoped controller', async function () {
      @customElement({
        name: 'app',
        template: `
      <div id="div1" validation-errors="errors.bind: nameErrors; controller.bind: controller;">
        <input id="target1" type="text" value.two-way="person.name & validate:undefined:controller">
        <span class="error" repeat.for="errorInfo of nameErrors">
          \${errorInfo.result.message}
        </span>
      </div>
      `})
      class App1 {
        public person: Person = new Person((void 0)!, (void 0)!);
        public controllerValidateSpy: ISpy;

        public readonly controller: ValidationController = resolve(newInstanceOf(IValidationController)) as ValidationController;
        private readonly validationRules: IValidationRules = resolve(IValidationRules);
        public constructor() {
          this.controllerValidateSpy = createSpy(this.controller, 'validate', true);

          this.validationRules
            .on(this.person)

            .ensure('name')
            .required();
        }

        public unbinding() {
          this.validationRules.off();
        }
      }

      const ctx = TestContext.create();
      const container = ctx.container;
      const host = ctx.doc.createElement('app');
      ctx.doc.body.appendChild(host);
      const au = new Aurelia(container).register(ValidationHtmlConfiguration);

      await au
        .app({ host, component: App1 })
        .start();

      const app: App1 = au.root.controller.viewModel as App1;
      const platform = container.get(IPlatform);

      const div1 = host.querySelector('#div1');
      const ca1: ValidationErrorsCustomAttribute = CustomAttribute.for(div1, 'validation-errors').viewModel as unknown as ValidationErrorsCustomAttribute;
      const spy1 = createSpy(ca1, 'handleValidationEvent', true);
      const { controllerValidateSpy, controller } = app;

      // assert that things are correctly wired up
      assertSubscriber(controller, ca1);
      assert.equal(ca1['scopedController'], null);
      assert.notEqual(ca1['scopedController'], controller);

      const target1 = div1.querySelector('#target1') as HTMLInputElement;

      await assertEventHandler(target1, platform, controllerValidateSpy, spy1, ctx);
      await platform.domQueue.yield();

      // assert that errors are rendered in the respective containers
      let errors1 = ca1.errors;
      assert.deepEqual(app['nameErrors'], errors1);
      assert.equal(errors1.length, 1);
      assert.deepEqual(errors1[0].targets, [target1]);

      assert.html.textContent(div1.querySelector('span.error'), 'Name is required.');

      // assert that errors are removed
      target1.value = 'foo';
      target1.dispatchEvent(new ctx.Event('change'));
      await platform.domQueue.yield();
      await assertEventHandler(target1, platform, controllerValidateSpy, spy1, ctx);

      errors1 = ca1.errors;
      assert.equal(errors1.length, 0);
      assert.equal(div1.querySelectorAll('span.error').length, 0);

      await au.stop();
      ctx.doc.body.removeChild(host);
      au.dispose();
    });
  });
});
