/**
 * @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.dev/license
 */

import {
  Component,
  destroyPlatform,
  DoBootstrap,
  EventEmitter,
  Injector,
  input,
  Input,
  isSignal,
  NgModule,
  Output,
  signal,
  WritableSignal,
} from '@angular/core';
import {BrowserModule, platformBrowser} from '@angular/platform-browser';
import {Subject} from 'rxjs';

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

interface WithFooBar {
  fooFoo: string;
  barBar: string;
  fooTransformed: unknown;
  fooSignal: string | null;
}

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();
    platformBrowser()
      .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();
    testContainer.remove();
    (testContainer as any) = null;
  });

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

  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.setAttribute('foo-transformed', 'truthy');
    element.setAttribute('foo-signal', 'value-signal');
    element.connectedCallback();
    expect(strategy.connectedElement).toBe(element);

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

  it('should work even if the constructor is not called (due to polyfill)', () => {
    // 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.setAttribute('foo-transformed', 'truthy');
    element.setAttribute('foo-signal', 'value-signal');
    element.connectedCallback();

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

  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 {
      override events: Subject<NgElementStrategyEvent> = undefined!;

      override 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';
    element.fooTransformed = 'truthy';
    element.fooSignal = 'value-signal';

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

  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';
    element.fooTransformed = 'truthy';
    element.fooSignal = 'value-signal';

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

  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',
      fooTransformed: 'truthy' as unknown,
      fooSignal: 'value-signal',
    });
    expect(element.fooFoo).toBe('foo-prop-value');
    expect(element.barBar).toBe('bar-prop-value');
    expect(element.fooTransformed).toBe('truthy');
    expect(element.fooSignal).toBe('value-signal');

    // 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(element.fooTransformed).toBe(true);
    expect(element.fooSignal).toBe('value-signal');

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

  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',
      fooTransformed: 'truthy' as unknown,
      fooSignal: 'value-signal',
    });
    expect(element.fooFoo).toBe('foo-prop-value');
    expect(element.barBar).toBe('bar-prop-value');
    expect(element.fooTransformed).toBe('truthy');
    expect(element.fooSignal).toBe('value-signal');

    // 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';
    element.fooTransformed = '';
    element.fooSignal = 'value-signal-changed';
    expect(element.fooFoo).toBe('foo-prop-value');
    expect(element.barBar).toBe('bar-prop-value-2');
    expect(element.fooTransformed).toBe('');
    expect(element.fooSignal).toBe('value-signal-changed');

    // 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(element.fooTransformed).toBe(false);
    expect(element.fooSignal).toBe('value-signal-changed');

    expect(strategy.inputs.get('fooFoo')).toBe('foo-prop-value');
    expect(strategy.inputs.get('barBar')).toBe('bar-prop-value-2');
    expect(strategy.inputs.get('fooTransformed')).toBe(false);
    expect(strategy.inputs.get('fooSignal')).toBe('value-signal-changed');
  });

  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',
      fooTransformed: 'truthy' as unknown,
      fooSignal: 'value-signal',
    });
    expect(element.fooFoo).toBe('foo-prop-value');
    expect(element.barBar).toBe('bar-prop-value');
    expect(element.fooTransformed).toBe('truthy');
    expect(element.fooSignal).toBe('value-signal');

    // 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');
    element.setAttribute('foo-transformed', '');
    expect(element.fooFoo).toBe('foo-prop-value');
    expect(element.barBar).toBe('bar-attr-value');
    expect(element.fooTransformed).toBe(false);
    expect(element.fooSignal).toBe('value-signal');

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

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

  it('should return value from input getter for input signal', () => {
    const {selector, ElementCtor} = createTestCustomElementForSignal();
    const element = document.createElement(selector) as HTMLElement & {
      fooSignal: string | null;
    };
    element.setAttribute('foo-signal', 'value-signal');

    customElements.define(selector, ElementCtor);
    testContainer.appendChild(element);
    expect(element.fooSignal).toBe('value-signal');
  });

  it('should not unpack signal value with input decorator having signal as value', () => {
    const {selector, ElementCtor} = createTestCustomElementForSignal();
    const element = document.createElement(selector) as HTMLElement & {
      fooFoo: WritableSignal<string | null>;
    };

    customElements.define(selector, ElementCtor);
    testContainer.appendChild(element);
    expect(isSignal(element.fooFoo)).toBe(true);
  });

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

    customElements.define(selector, ElementCtor);

    return ElementCtor;
  }

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

  function createTestCustomElementForSignal() {
    return {
      selector: `test-element-${++selectorUid}`,
      ElementCtor: createCustomElement(TestSignalComponent, {injector}),
    };
  }

  @Component({
    selector: 'test-component',
    template: 'TestComponent|foo({{ fooFoo }})|bar({{ barBar }})',
    standalone: false,
  })
  class TestComponent {
    @Input() fooFoo: string = 'foo';
    @Input('barbar') barBar!: string;
    @Input({transform: (value: unknown) => !!value}) fooTransformed!: boolean;

    // This needs to apply the decorator and pass `isSignal`, because
    // the compiler transform doesn't run against JIT tests.
    @Input({isSignal: true} as Input) fooSignal = input<string | null>(null);

    @Output() bazBaz = new EventEmitter<boolean>();
    @Output('quxqux') quxQux = new EventEmitter<Object>();
  }

  @Component({
    selector: 'test-signal-component',
    template: 'TestSignalComponent|foo({{ fooFoo() }})|signal({{ fooSignal() }})',
    standalone: false,
  })
  class TestSignalComponent {
    @Input() fooFoo = signal<string | null>(null);
    // This needs to apply the decorator and pass `isSignal`, because
    // the compiler transform doesn't run against JIT tests.
    @Input({isSignal: true} as Input) fooSignal = input<string | null>(null);
  }
  @NgModule({
    imports: [BrowserModule],
    declarations: [TestComponent, TestSignalComponent],
  })
  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, transform?: (value: any) => any): void {
      this.inputs.set(propName, transform ? transform(value) : 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;
    }
  }
});
