/**
 * @license
 * Copyright Google LLC All Rights Reserved.
 *
 * Use of this source code is governed by an MIT-style license that can be
 * found in the LICENSE file at https://angular.io/license
 */

import {Component, destroyPlatform, DoBootstrap, EventEmitter, Injector, Input, NgModule, Output} from '@angular/core';
import {BrowserModule} from '@angular/platform-browser';
import {platformBrowserDynamic} from '@angular/platform-browser-dynamic';
import {browserDetection} from '@angular/platform-browser/testing/src/browser_util';
import {Subject} from 'rxjs';

import {createCustomElement, NgElementConstructor} from '../src/create-custom-element';
import {NgElementStrategy, NgElementStrategyEvent, NgElementStrategyFactory} from '../src/element-strategy';

type WithFooBar = {
  fooFoo: string,
  barBar: string
};

if (browserDetection.supportsCustomElements) {
  describe('createCustomElement', () => {
    let selectorUid = 0;
    let testContainer: HTMLDivElement;
    let NgElementCtor: NgElementConstructor<WithFooBar>;
    let strategy: TestStrategy;
    let strategyFactory: TestStrategyFactory;
    let injector: Injector;

    beforeAll(done => {
      testContainer = document.createElement('div');
      document.body.appendChild(testContainer);
      destroyPlatform();
      platformBrowserDynamic()
          .bootstrapModule(TestModule)
          .then(ref => {
            injector = ref.injector;
            strategyFactory = new TestStrategyFactory();
            strategy = strategyFactory.testStrategy;

            NgElementCtor = createAndRegisterTestCustomElement(strategyFactory);
          })
          .then(done, done.fail);
    });

    afterEach(() => strategy.reset());

    afterAll(() => {
      destroyPlatform();
      document.body.removeChild(testContainer);
      (testContainer as any) = null;
    });

    it('should use a default strategy for converting component inputs', () => {
      expect(NgElementCtor.observedAttributes).toEqual(['foo-foo', 'barbar']);
    });

    it('should send input values from attributes when connected', () => {
      const element = new NgElementCtor(injector);
      element.setAttribute('foo-foo', 'value-foo-foo');
      element.setAttribute('barbar', 'value-barbar');
      element.connectedCallback();
      expect(strategy.connectedElement).toBe(element);

      expect(strategy.getInputValue('fooFoo')).toBe('value-foo-foo');
      expect(strategy.getInputValue('barBar')).toBe('value-barbar');
    });

    it('should work even if the constructor is not called (due to polyfill)', () => {
      // Some polyfills (e.g. `document-register-element`) do not call the constructor of custom
      // elements. Currently, all the constructor does is initialize the `injector` property. This
      // test simulates not having called the constructor by "unsetting" the property.
      //
      // NOTE:
      // If the constructor implementation changes in the future, this test needs to be adjusted
      // accordingly.
      const element = new NgElementCtor(injector);
      delete (element as any).injector;

      element.setAttribute('foo-foo', 'value-foo-foo');
      element.setAttribute('barbar', 'value-barbar');
      element.connectedCallback();

      expect(strategy.connectedElement).toBe(element);
      expect(strategy.getInputValue('fooFoo')).toBe('value-foo-foo');
      expect(strategy.getInputValue('barBar')).toBe('value-barbar');
    });

    it('should listen to output events after connected', () => {
      const element = new NgElementCtor(injector);
      element.connectedCallback();

      let eventValue: any = null;
      element.addEventListener('some-event', (e: Event) => eventValue = (e as CustomEvent).detail);
      strategy.events.next({name: 'some-event', value: 'event-value'});

      expect(eventValue).toEqual('event-value');
    });

    it('should not listen to output events after disconnected', () => {
      const element = new NgElementCtor(injector);
      element.connectedCallback();
      element.disconnectedCallback();
      expect(strategy.disconnectCalled).toBe(true);

      let eventValue: any = null;
      element.addEventListener('some-event', (e: Event) => eventValue = (e as CustomEvent).detail);
      strategy.events.next({name: 'some-event', value: 'event-value'});

      expect(eventValue).toEqual(null);
    });

    it('should listen to output events during initialization', () => {
      const events: string[] = [];

      const element = new NgElementCtor(injector);
      element.addEventListener('strategy-event', evt => events.push((evt as CustomEvent).detail));
      element.connectedCallback();

      expect(events).toEqual(['connect']);
    });

    it('should not break if `NgElementStrategy#events` is not available before calling `NgElementStrategy#connect()`',
       () => {
         class TestStrategyWithLateEvents extends TestStrategy {
           events: Subject<NgElementStrategyEvent> = undefined!;

           connect(element: HTMLElement): void {
             this.connectedElement = element;
             this.events = new Subject<NgElementStrategyEvent>();
             this.events.next({name: 'strategy-event', value: 'connect'});
           }
         }

         const strategyWithLateEvents = new TestStrategyWithLateEvents();
         const capturedEvents: string[] = [];

         const NgElementCtorWithLateEventsStrategy =
             createAndRegisterTestCustomElement({create: () => strategyWithLateEvents});

         const element = new NgElementCtorWithLateEventsStrategy(injector);
         element.addEventListener(
             'strategy-event', evt => capturedEvents.push((evt as CustomEvent).detail));
         element.connectedCallback();

         // The "connect" event (emitted during initialization) was missed, but things didn't break.
         expect(capturedEvents).toEqual([]);

         // Subsequent events are still captured.
         strategyWithLateEvents.events.next({name: 'strategy-event', value: 'after-connect'});
         expect(capturedEvents).toEqual(['after-connect']);
       });

    it('should properly set getters/setters on the element', () => {
      const element = new NgElementCtor(injector);
      element.fooFoo = 'foo-foo-value';
      element.barBar = 'barBar-value';

      expect(strategy.inputs.get('fooFoo')).toBe('foo-foo-value');
      expect(strategy.inputs.get('barBar')).toBe('barBar-value');
    });

    it('should properly handle getting/setting properties on the element even if the constructor is not called',
       () => {
         // Create a custom element while ensuring that the `NgElementStrategy` is not created
         // inside the constructor. This is done to emulate the behavior of some polyfills that do
         // not call the constructor.
         strategyFactory.create = () => undefined as unknown as NgElementStrategy;
         const element = new NgElementCtor(injector);
         strategyFactory.create = TestStrategyFactory.prototype.create;

         element.fooFoo = 'foo-foo-value';
         element.barBar = 'barBar-value';

         expect(strategy.inputs.get('fooFoo')).toBe('foo-foo-value');
         expect(strategy.inputs.get('barBar')).toBe('barBar-value');
       });

    it('should capture properties set before upgrading the element', () => {
      // Create a regular element and set properties on it.
      const {selector, ElementCtor} = createTestCustomElement(strategyFactory);
      const element = Object.assign(document.createElement(selector), {
        fooFoo: 'foo-prop-value',
        barBar: 'bar-prop-value',
      });
      expect(element.fooFoo).toBe('foo-prop-value');
      expect(element.barBar).toBe('bar-prop-value');

      // Upgrade the element to a Custom Element and insert it into the DOM.
      customElements.define(selector, ElementCtor);
      testContainer.appendChild(element);
      expect(element.fooFoo).toBe('foo-prop-value');
      expect(element.barBar).toBe('bar-prop-value');

      expect(strategy.inputs.get('fooFoo')).toBe('foo-prop-value');
      expect(strategy.inputs.get('barBar')).toBe('bar-prop-value');
    });

    it('should capture properties set after upgrading the element but before inserting it into the DOM',
       () => {
         // Create a regular element and set properties on it.
         const {selector, ElementCtor} = createTestCustomElement(strategyFactory);
         const element = Object.assign(document.createElement(selector), {
           fooFoo: 'foo-prop-value',
           barBar: 'bar-prop-value',
         });
         expect(element.fooFoo).toBe('foo-prop-value');
         expect(element.barBar).toBe('bar-prop-value');

         // Upgrade the element to a Custom Element (without inserting it into the DOM) and update a
         // property.
         customElements.define(selector, ElementCtor);
         customElements.upgrade(element);
         element.barBar = 'bar-prop-value-2';
         expect(element.fooFoo).toBe('foo-prop-value');
         expect(element.barBar).toBe('bar-prop-value-2');

         // Insert the element into the DOM.
         testContainer.appendChild(element);
         expect(element.fooFoo).toBe('foo-prop-value');
         expect(element.barBar).toBe('bar-prop-value-2');

         expect(strategy.inputs.get('fooFoo')).toBe('foo-prop-value');
         expect(strategy.inputs.get('barBar')).toBe('bar-prop-value-2');
       });

    it('should allow overwriting properties with attributes after upgrading the element but before inserting it into the DOM',
       () => {
         // Create a regular element and set properties on it.
         const {selector, ElementCtor} = createTestCustomElement(strategyFactory);
         const element = Object.assign(document.createElement(selector), {
           fooFoo: 'foo-prop-value',
           barBar: 'bar-prop-value',
         });
         expect(element.fooFoo).toBe('foo-prop-value');
         expect(element.barBar).toBe('bar-prop-value');

         // Upgrade the element to a Custom Element (without inserting it into the DOM) and set an
         // attribute.
         customElements.define(selector, ElementCtor);
         customElements.upgrade(element);
         element.setAttribute('barbar', 'bar-attr-value');
         expect(element.fooFoo).toBe('foo-prop-value');
         expect(element.barBar).toBe('bar-attr-value');

         // Insert the element into the DOM.
         testContainer.appendChild(element);
         expect(element.fooFoo).toBe('foo-prop-value');
         expect(element.barBar).toBe('bar-attr-value');

         expect(strategy.inputs.get('fooFoo')).toBe('foo-prop-value');
         expect(strategy.inputs.get('barBar')).toBe('bar-attr-value');
       });

    // Helpers
    function createAndRegisterTestCustomElement(strategyFactory: NgElementStrategyFactory) {
      const {selector, ElementCtor} = createTestCustomElement(strategyFactory);

      // The `@webcomponents/custom-elements/src/native-shim.js` polyfill allows us to create
      // new instances of the NgElement which extends HTMLElement, as long as we define it.
      customElements.define(selector, ElementCtor);

      return ElementCtor;
    }

    function createTestCustomElement(strategyFactory: NgElementStrategyFactory) {
      return {
        selector: `test-element-${++selectorUid}`,
        ElementCtor: createCustomElement<WithFooBar>(TestComponent, {injector, strategyFactory}),
      };
    }

    @Component({
      selector: 'test-component',
      template: 'TestComponent|foo({{ fooFoo }})|bar({{ barBar }})',
    })
    class TestComponent {
      @Input() fooFoo: string = 'foo';
      // TODO(issue/24571): remove '!'.
      @Input('barbar') barBar!: string;

      @Output() bazBaz = new EventEmitter<boolean>();
      @Output('quxqux') quxQux = new EventEmitter<Object>();
    }
    @NgModule({
      imports: [BrowserModule],
      declarations: [TestComponent],
      entryComponents: [TestComponent],
    })
    class TestModule implements DoBootstrap {
      ngDoBootstrap() {}
    }

    class TestStrategy implements NgElementStrategy {
      connectedElement: HTMLElement|null = null;
      disconnectCalled = false;
      inputs = new Map<string, any>();

      events = new Subject<NgElementStrategyEvent>();

      connect(element: HTMLElement): void {
        this.events.next({name: 'strategy-event', value: 'connect'});
        this.connectedElement = element;
      }

      disconnect(): void {
        this.disconnectCalled = true;
      }

      getInputValue(propName: string): any {
        return this.inputs.get(propName);
      }

      setInputValue(propName: string, value: string): void {
        this.inputs.set(propName, value);
      }

      reset(): void {
        this.connectedElement = null;
        this.disconnectCalled = false;
        this.inputs.clear();
      }
    }

    class TestStrategyFactory implements NgElementStrategyFactory {
      testStrategy = new TestStrategy();

      create(injector: Injector): NgElementStrategy {
        // Although not used by the `TestStrategy`, verify that the injector is provided.
        if (!injector) {
          throw new Error(
              'Expected injector to be passed to `TestStrategyFactory#create()`, but received ' +
              `value of type ${typeof injector}: ${injector}`);
        }

        return this.testStrategy;
      }
    }
  });
}
