/**
 * @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 {DOCUMENT, ɵgetDOM as getDOM} from '@angular/common';
import {ResourceLoader} from '@angular/compiler';
import {APP_BOOTSTRAP_LISTENER, APP_INITIALIZER, Compiler, CompilerFactory, Component, InjectionToken, LOCALE_ID, NgModule, NgZone, PlatformRef, TemplateRef, Type, ViewChild, ViewContainerRef} from '@angular/core';
import {ApplicationRef} from '@angular/core/src/application_ref';
import {ErrorHandler} from '@angular/core/src/error_handler';
import {ComponentRef} from '@angular/core/src/linker/component_factory';
import {getLocaleId} from '@angular/core/src/render3';
import {BrowserModule} from '@angular/platform-browser';
import {createTemplate, dispatchEvent, getContent} from '@angular/platform-browser/testing/src/browser_util';
import {expect} from '@angular/platform-browser/testing/src/matchers';
import {onlyInIvy} from '@angular/private/testing';

import {NoopNgZone} from '../src/zone/ng_zone';
import {ComponentFixtureNoNgZone, inject, TestBed, waitForAsync, withModule} from '../testing';

@Component({selector: 'bootstrap-app', template: 'hello'})
class SomeComponent {
}

{
  describe('bootstrap', () => {
    let mockConsole: MockConsole;

    beforeEach(() => {
      mockConsole = new MockConsole();
    });

    function createRootEl(selector = 'bootstrap-app') {
      const doc = TestBed.inject(DOCUMENT);
      const rootEl =
          <HTMLElement>getContent(createTemplate(`<${selector}></${selector}>`)).firstChild;
      const oldRoots = doc.querySelectorAll(selector);
      for (let i = 0; i < oldRoots.length; i++) {
        getDOM().remove(oldRoots[i]);
      }
      doc.body.appendChild(rootEl);
    }

    type CreateModuleOptions =
        {providers?: any[], ngDoBootstrap?: any, bootstrap?: any[], component?: Type<any>};

    function createModule(providers?: any[]): Type<any>;
    function createModule(options: CreateModuleOptions): Type<any>;
    function createModule(providersOrOptions: any[]|CreateModuleOptions|undefined): Type<any> {
      let options: CreateModuleOptions = {};
      if (Array.isArray(providersOrOptions)) {
        options = {providers: providersOrOptions};
      } else {
        options = providersOrOptions || {};
      }
      const errorHandler = new ErrorHandler();
      (errorHandler as any)._console = mockConsole as any;

      const platformModule = getDOM().supportsDOMEvents() ?
          BrowserModule :
          require('@angular/platform-server').ServerModule;

      @NgModule({
        providers: [{provide: ErrorHandler, useValue: errorHandler}, options.providers || []],
        imports: [platformModule],
        declarations: [options.component || SomeComponent],
        entryComponents: [options.component || SomeComponent],
        bootstrap: options.bootstrap || []
      })
      class MyModule {
      }
      if (options.ngDoBootstrap !== false) {
        (<any>MyModule.prototype).ngDoBootstrap = options.ngDoBootstrap || (() => {});
      }
      return MyModule;
    }

    it('should bootstrap a component from a child module',
       waitForAsync(
           inject([ApplicationRef, Compiler], (app: ApplicationRef, compiler: Compiler) => {
             @Component({
               selector: 'bootstrap-app',
               template: '',
             })
             class SomeComponent {
             }

             const helloToken = new InjectionToken<string>('hello');

             @NgModule({
               providers: [{provide: helloToken, useValue: 'component'}],
               declarations: [SomeComponent],
               entryComponents: [SomeComponent],
             })
             class SomeModule {
             }

             createRootEl();
             const modFactory = compiler.compileModuleSync(SomeModule);
             const module = modFactory.create(TestBed);
             const cmpFactory =
                 module.componentFactoryResolver.resolveComponentFactory(SomeComponent)!;
             const component = app.bootstrap(cmpFactory);

             // The component should see the child module providers
             expect(component.injector.get(helloToken)).toEqual('component');
           })));

    it('should bootstrap a component with a custom selector',
       waitForAsync(
           inject([ApplicationRef, Compiler], (app: ApplicationRef, compiler: Compiler) => {
             @Component({
               selector: 'bootstrap-app',
               template: '',
             })
             class SomeComponent {
             }

             const helloToken = new InjectionToken<string>('hello');

             @NgModule({
               providers: [{provide: helloToken, useValue: 'component'}],
               declarations: [SomeComponent],
               entryComponents: [SomeComponent],
             })
             class SomeModule {
             }

             createRootEl('custom-selector');
             const modFactory = compiler.compileModuleSync(SomeModule);
             const module = modFactory.create(TestBed);
             const cmpFactory =
                 module.componentFactoryResolver.resolveComponentFactory(SomeComponent)!;
             const component = app.bootstrap(cmpFactory, 'custom-selector');

             // The component should see the child module providers
             expect(component.injector.get(helloToken)).toEqual('component');
           })));

    describe('ApplicationRef', () => {
      beforeEach(() => {
        TestBed.configureTestingModule({imports: [createModule()]});
      });

      it('should throw when reentering tick', () => {
        @Component({template: '{{reenter()}}'})
        class ReenteringComponent {
          reenterCount = 1;
          reenterErr: any;

          constructor(private appRef: ApplicationRef) {}

          reenter() {
            if (this.reenterCount--) {
              try {
                this.appRef.tick();
              } catch (e) {
                this.reenterErr = e;
              }
            }
          }
        }

        const fixture = TestBed.configureTestingModule({declarations: [ReenteringComponent]})
                            .createComponent(ReenteringComponent);
        const appRef = TestBed.inject(ApplicationRef);
        appRef.attachView(fixture.componentRef.hostView);
        appRef.tick();
        expect(fixture.componentInstance.reenterErr.message)
            .toBe('ApplicationRef.tick is called recursively');
      });

      describe('APP_BOOTSTRAP_LISTENER', () => {
        let capturedCompRefs: ComponentRef<any>[];
        beforeEach(() => {
          capturedCompRefs = [];
          TestBed.configureTestingModule({
            providers: [{
              provide: APP_BOOTSTRAP_LISTENER,
              multi: true,
              useValue: (compRef: any) => {
                capturedCompRefs.push(compRef);
              }
            }]
          });
        });

        it('should be called when a component is bootstrapped',
           inject([ApplicationRef], (ref: ApplicationRef) => {
             createRootEl();
             const compRef = ref.bootstrap(SomeComponent);
             expect(capturedCompRefs).toEqual([compRef]);
           }));
      });

      describe('bootstrap', () => {
        it('should throw if an APP_INITIIALIZER is not yet resolved',
           withModule(
               {
                 providers: [
                   {provide: APP_INITIALIZER, useValue: () => new Promise(() => {}), multi: true}
                 ]
               },
               inject([ApplicationRef], (ref: ApplicationRef) => {
                 createRootEl();
                 expect(() => ref.bootstrap(SomeComponent))
                     .toThrowError(
                         'Cannot bootstrap as there are still asynchronous initializers running. Bootstrap components in the `ngDoBootstrap` method of the root module.');
               })));
      });
    });

    describe('bootstrapModule', () => {
      let defaultPlatform: PlatformRef;
      beforeEach(inject([PlatformRef], (_platform: PlatformRef) => {
        createRootEl();
        defaultPlatform = _platform;
      }));

      it('should wait for asynchronous app initializers', waitForAsync(() => {
           let resolve: (result: any) => void;
           const promise: Promise<any> = new Promise((res) => {
             resolve = res;
           });
           let initializerDone = false;
           setTimeout(() => {
             resolve(true);
             initializerDone = true;
           }, 1);

           defaultPlatform
               .bootstrapModule(
                   createModule([{provide: APP_INITIALIZER, useValue: () => promise, multi: true}]))
               .then(_ => {
                 expect(initializerDone).toBe(true);
               });
         }));

      it('should rethrow sync errors even if the exceptionHandler is not rethrowing',
         waitForAsync(() => {
           defaultPlatform
               .bootstrapModule(createModule([{
                 provide: APP_INITIALIZER,
                 useValue: () => {
                   throw 'Test';
                 },
                 multi: true
               }]))
               .then(() => expect(false).toBe(true), (e) => {
                 expect(e).toBe('Test');
                 // Error rethrown will be seen by the exception handler since it's after
                 // construction.
                 expect(mockConsole.res[0].join('#')).toEqual('ERROR#Test');
               });
         }));

      it('should rethrow promise errors even if the exceptionHandler is not rethrowing',
         waitForAsync(() => {
           defaultPlatform
               .bootstrapModule(createModule([
                 {provide: APP_INITIALIZER, useValue: () => Promise.reject('Test'), multi: true}
               ]))
               .then(() => expect(false).toBe(true), (e) => {
                 expect(e).toBe('Test');
                 expect(mockConsole.res[0].join('#')).toEqual('ERROR#Test');
               });
         }));

      it('should throw useful error when ApplicationRef is not configured', waitForAsync(() => {
           @NgModule()
           class EmptyModule {
           }

           return defaultPlatform.bootstrapModule(EmptyModule)
               .then(() => fail('expecting error'), (error) => {
                 expect(error.message)
                     .toEqual('No ErrorHandler. Is platform module (BrowserModule) included?');
               });
         }));

      it('should call the `ngDoBootstrap` method with `ApplicationRef` on the main module',
         waitForAsync(() => {
           const ngDoBootstrap = jasmine.createSpy('ngDoBootstrap');
           defaultPlatform.bootstrapModule(createModule({ngDoBootstrap: ngDoBootstrap}))
               .then((moduleRef) => {
                 const appRef = moduleRef.injector.get(ApplicationRef);
                 expect(ngDoBootstrap).toHaveBeenCalledWith(appRef);
               });
         }));

      it('should auto bootstrap components listed in @NgModule.bootstrap', waitForAsync(() => {
           defaultPlatform.bootstrapModule(createModule({bootstrap: [SomeComponent]}))
               .then((moduleRef) => {
                 const appRef: ApplicationRef = moduleRef.injector.get(ApplicationRef);
                 expect(appRef.componentTypes).toEqual([SomeComponent]);
               });
         }));

      it('should error if neither `ngDoBootstrap` nor @NgModule.bootstrap was specified',
         waitForAsync(() => {
           defaultPlatform.bootstrapModule(createModule({ngDoBootstrap: false}))
               .then(() => expect(false).toBe(true), (e) => {
                 const expectedErrMsg =
                     `The module MyModule was bootstrapped, but it does not declare "@NgModule.bootstrap" components nor a "ngDoBootstrap" method. Please define one of these.`;
                 expect(e.message).toEqual(expectedErrMsg);
                 expect(mockConsole.res[0].join('#')).toEqual('ERROR#Error: ' + expectedErrMsg);
               });
         }));

      it('should add bootstrapped module into platform modules list', waitForAsync(() => {
           defaultPlatform.bootstrapModule(createModule({bootstrap: [SomeComponent]}))
               .then(module => expect((<any>defaultPlatform)._modules).toContain(module));
         }));

      it('should bootstrap with NoopNgZone', waitForAsync(() => {
           defaultPlatform
               .bootstrapModule(createModule({bootstrap: [SomeComponent]}), {ngZone: 'noop'})
               .then((module) => {
                 const ngZone = module.injector.get(NgZone);
                 expect(ngZone instanceof NoopNgZone).toBe(true);
               });
         }));

      it('should resolve component resources when creating module factory', async () => {
        @Component({
          selector: 'with-templates-app',
          templateUrl: '/test-template.html',
        })
        class WithTemplateUrlComponent {
        }

        const loadResourceSpy = jasmine.createSpy('load resource').and.returnValue('fakeContent');
        const testModule = createModule({component: WithTemplateUrlComponent});

        await defaultPlatform.bootstrapModule(testModule, {
          providers: [
            {provide: ResourceLoader, useValue: {get: loadResourceSpy}},
          ]
        });

        expect(loadResourceSpy).toHaveBeenCalledTimes(1);
        expect(loadResourceSpy).toHaveBeenCalledWith('/test-template.html');
      });

      onlyInIvy('We only need to define `LOCALE_ID` for runtime i18n')
          .it('should define `LOCALE_ID`', async () => {
            @Component({
              selector: 'i18n-app',
              templateUrl: '',
            })
            class I18nComponent {
            }

            const testModule = createModule(
                {component: I18nComponent, providers: [{provide: LOCALE_ID, useValue: 'ro'}]});
            await defaultPlatform.bootstrapModule(testModule);

            expect(getLocaleId()).toEqual('ro');
          });

      it('should wait for APP_INITIALIZER to set providers for `LOCALE_ID`', async () => {
        let locale: string = '';

        const testModule = createModule({
          providers: [
            {provide: APP_INITIALIZER, useValue: () => locale = 'fr-FR', multi: true},
            {provide: LOCALE_ID, useFactory: () => locale}
          ]
        });
        const app = await defaultPlatform.bootstrapModule(testModule);
        expect(app.injector.get(LOCALE_ID)).toEqual('fr-FR');
      });
    });

    describe('bootstrapModuleFactory', () => {
      let defaultPlatform: PlatformRef;
      beforeEach(inject([PlatformRef], (_platform: PlatformRef) => {
        createRootEl();
        defaultPlatform = _platform;
      }));
      it('should wait for asynchronous app initializers', waitForAsync(() => {
           let resolve: (result: any) => void;
           const promise: Promise<any> = new Promise((res) => {
             resolve = res;
           });
           let initializerDone = false;
           setTimeout(() => {
             resolve(true);
             initializerDone = true;
           }, 1);

           const compilerFactory: CompilerFactory =
               defaultPlatform.injector.get(CompilerFactory, null)!;
           const moduleFactory = compilerFactory.createCompiler().compileModuleSync(
               createModule([{provide: APP_INITIALIZER, useValue: () => promise, multi: true}]));
           defaultPlatform.bootstrapModuleFactory(moduleFactory).then(_ => {
             expect(initializerDone).toBe(true);
           });
         }));

      it('should rethrow sync errors even if the exceptionHandler is not rethrowing',
         waitForAsync(() => {
           const compilerFactory: CompilerFactory =
               defaultPlatform.injector.get(CompilerFactory, null)!;
           const moduleFactory = compilerFactory.createCompiler().compileModuleSync(createModule([{
             provide: APP_INITIALIZER,
             useValue: () => {
               throw 'Test';
             },
             multi: true
           }]));
           expect(() => defaultPlatform.bootstrapModuleFactory(moduleFactory)).toThrow('Test');
           // Error rethrown will be seen by the exception handler since it's after
           // construction.
           expect(mockConsole.res[0].join('#')).toEqual('ERROR#Test');
         }));

      it('should rethrow promise errors even if the exceptionHandler is not rethrowing',
         waitForAsync(() => {
           const compilerFactory: CompilerFactory =
               defaultPlatform.injector.get(CompilerFactory, null)!;
           const moduleFactory = compilerFactory.createCompiler().compileModuleSync(createModule(
               [{provide: APP_INITIALIZER, useValue: () => Promise.reject('Test'), multi: true}]));
           defaultPlatform.bootstrapModuleFactory(moduleFactory)
               .then(() => expect(false).toBe(true), (e) => {
                 expect(e).toBe('Test');
                 expect(mockConsole.res[0].join('#')).toEqual('ERROR#Test');
               });
         }));
    });

    describe('attachView / detachView', () => {
      @Component({template: '{{name}}'})
      class MyComp {
        name = 'Initial';
      }

      @Component({template: '<ng-container #vc></ng-container>'})
      class ContainerComp {
        // TODO(issue/24571): remove '!'.
        @ViewChild('vc', {read: ViewContainerRef}) vc!: ViewContainerRef;
      }

      @Component({template: '<ng-template #t>Dynamic content</ng-template>'})
      class EmbeddedViewComp {
        // TODO(issue/24571): remove '!'.
        @ViewChild(TemplateRef, {static: true}) tplRef!: TemplateRef<Object>;
      }

      beforeEach(() => {
        TestBed.configureTestingModule({
          declarations: [MyComp, ContainerComp, EmbeddedViewComp],
          providers: [{provide: ComponentFixtureNoNgZone, useValue: true}]
        });
      });

      it('should dirty check attached views', () => {
        const comp = TestBed.createComponent(MyComp);
        const appRef: ApplicationRef = TestBed.inject(ApplicationRef);
        expect(appRef.viewCount).toBe(0);

        appRef.tick();
        expect(comp.nativeElement).toHaveText('');

        appRef.attachView(comp.componentRef.hostView);
        appRef.tick();
        expect(appRef.viewCount).toBe(1);
        expect(comp.nativeElement).toHaveText('Initial');
      });

      it('should not dirty check detached views', () => {
        const comp = TestBed.createComponent(MyComp);
        const appRef: ApplicationRef = TestBed.inject(ApplicationRef);

        appRef.attachView(comp.componentRef.hostView);
        appRef.tick();
        expect(comp.nativeElement).toHaveText('Initial');

        appRef.detachView(comp.componentRef.hostView);
        comp.componentInstance.name = 'New';
        appRef.tick();
        expect(appRef.viewCount).toBe(0);
        expect(comp.nativeElement).toHaveText('Initial');
      });

      it('should detach attached views if they are destroyed', () => {
        const comp = TestBed.createComponent(MyComp);
        const appRef: ApplicationRef = TestBed.inject(ApplicationRef);

        appRef.attachView(comp.componentRef.hostView);
        comp.destroy();

        expect(appRef.viewCount).toBe(0);
      });

      it('should detach attached embedded views if they are destroyed', () => {
        const comp = TestBed.createComponent(EmbeddedViewComp);
        const appRef: ApplicationRef = TestBed.inject(ApplicationRef);

        const embeddedViewRef = comp.componentInstance.tplRef.createEmbeddedView({});

        appRef.attachView(embeddedViewRef);
        embeddedViewRef.destroy();

        expect(appRef.viewCount).toBe(0);
      });


      it('should not allow to attach a view to both, a view container and the ApplicationRef',
         () => {
           const comp = TestBed.createComponent(MyComp);
           let hostView = comp.componentRef.hostView;
           const containerComp = TestBed.createComponent(ContainerComp);
           containerComp.detectChanges();
           const vc = containerComp.componentInstance.vc;
           const appRef: ApplicationRef = TestBed.inject(ApplicationRef);

           vc.insert(hostView);
           expect(() => appRef.attachView(hostView))
               .toThrowError('This view is already attached to a ViewContainer!');
           hostView = vc.detach(0)!;

           appRef.attachView(hostView);
           expect(() => vc.insert(hostView))
               .toThrowError('This view is already attached directly to the ApplicationRef!');
         });
    });
  });

  describe('AppRef', () => {
    @Component({selector: 'sync-comp', template: `<span>{{text}}</span>`})
    class SyncComp {
      text: string = '1';
    }

    @Component({selector: 'click-comp', template: `<span (click)="onClick()">{{text}}</span>`})
    class ClickComp {
      text: string = '1';

      onClick() {
        this.text += '1';
      }
    }

    @Component({selector: 'micro-task-comp', template: `<span>{{text}}</span>`})
    class MicroTaskComp {
      text: string = '1';

      ngOnInit() {
        Promise.resolve(null).then((_) => {
          this.text += '1';
        });
      }
    }

    @Component({selector: 'macro-task-comp', template: `<span>{{text}}</span>`})
    class MacroTaskComp {
      text: string = '1';

      ngOnInit() {
        setTimeout(() => {
          this.text += '1';
        }, 10);
      }
    }

    @Component({selector: 'micro-macro-task-comp', template: `<span>{{text}}</span>`})
    class MicroMacroTaskComp {
      text: string = '1';

      ngOnInit() {
        Promise.resolve(null).then((_) => {
          this.text += '1';
          setTimeout(() => {
            this.text += '1';
          }, 10);
        });
      }
    }

    @Component({selector: 'macro-micro-task-comp', template: `<span>{{text}}</span>`})
    class MacroMicroTaskComp {
      text: string = '1';

      ngOnInit() {
        setTimeout(() => {
          this.text += '1';
          Promise.resolve(null).then((_: any) => {
            this.text += '1';
          });
        }, 10);
      }
    }

    let stableCalled = false;

    beforeEach(() => {
      stableCalled = false;
      TestBed.configureTestingModule({
        declarations: [
          SyncComp, MicroTaskComp, MacroTaskComp, MicroMacroTaskComp, MacroMicroTaskComp, ClickComp
        ],
      });
    });

    afterEach(() => {
      expect(stableCalled).toBe(true, 'isStable did not emit true on stable');
    });

    function expectStableTexts(component: Type<any>, expected: string[]) {
      const fixture = TestBed.createComponent(component);
      const appRef: ApplicationRef = TestBed.inject(ApplicationRef);
      const zone: NgZone = TestBed.inject(NgZone);
      appRef.attachView(fixture.componentRef.hostView);
      zone.run(() => appRef.tick());

      let i = 0;
      appRef.isStable.subscribe({
        next: (stable: boolean) => {
          if (stable) {
            expect(i).toBeLessThan(expected.length);
            expect(fixture.nativeElement).toHaveText(expected[i++]);
            stableCalled = true;
          }
        }
      });
    }

    it('isStable should fire on synchronous component loading', waitForAsync(() => {
         expectStableTexts(SyncComp, ['1']);
       }));

    it('isStable should fire after a microtask on init is completed', waitForAsync(() => {
         expectStableTexts(MicroTaskComp, ['11']);
       }));

    it('isStable should fire after a macrotask on init is completed', waitForAsync(() => {
         expectStableTexts(MacroTaskComp, ['11']);
       }));

    it('isStable should fire only after chain of micro and macrotasks on init are completed',
       waitForAsync(() => {
         expectStableTexts(MicroMacroTaskComp, ['111']);
       }));

    it('isStable should fire only after chain of macro and microtasks on init are completed',
       waitForAsync(() => {
         expectStableTexts(MacroMicroTaskComp, ['111']);
       }));

    describe('unstable', () => {
      let unstableCalled = false;

      afterEach(() => {
        expect(unstableCalled).toBe(true, 'isStable did not emit false on unstable');
      });

      function expectUnstable(appRef: ApplicationRef) {
        appRef.isStable.subscribe({
          next: (stable: boolean) => {
            if (stable) {
              stableCalled = true;
            }
            if (!stable) {
              unstableCalled = true;
            }
          }
        });
      }

      it('should be fired after app becomes unstable', waitForAsync(() => {
           const fixture = TestBed.createComponent(ClickComp);
           const appRef: ApplicationRef = TestBed.inject(ApplicationRef);
           const zone: NgZone = TestBed.inject(NgZone);
           appRef.attachView(fixture.componentRef.hostView);
           zone.run(() => appRef.tick());

           fixture.whenStable().then(() => {
             expectUnstable(appRef);
             const element = fixture.debugElement.children[0];
             dispatchEvent(element.nativeElement, 'click');
           });
         }));
    });
  });
}

class MockConsole {
  res: any[][] = [];
  log(...args: any[]): void {
    // Logging from ErrorHandler should run outside of the Angular Zone.
    NgZone.assertNotInAngularZone();
    this.res.push(args);
  }
  error(...args: any[]): void {
    // Logging from ErrorHandler should run outside of the Angular Zone.
    NgZone.assertNotInAngularZone();
    this.res.push(args);
  }
}
