/**
 * @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 {CommonModule, HashLocationStrategy, Location, LocationStrategy} from '@angular/common';
import {provideLocationMocks, SpyLocation} from '@angular/common/testing';
import {
  ChangeDetectionStrategy,
  ChangeDetectorRef,
  Component,
  Injectable,
  NgModule,
  TemplateRef,
  Type,
  ViewChild,
  ViewContainerRef,
  inject,
  signal,
} from '@angular/core';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {
  ChildrenOutletContexts,
  DefaultUrlSerializer,
  NavigationCancel,
  NavigationError,
  Router,
  RouterModule,
  RouterOutlet,
  UrlSerializer,
  UrlTree,
} from '../index';
import {of} from 'rxjs';
import {switchMap, filter, mapTo, take} from 'rxjs/operators';

import {
  provideRouter,
  withDisabledInitialNavigation,
  withRouterConfig,
  withViewTransitions,
} from '../src/provide_router';
import {afterNextNavigation} from '../src/utils/navigations';
import {isBrowser, withBody, timeout} from '@angular/private/testing';
import {bootstrapApplication} from '@angular/platform-browser';

describe('Integration', () => {
  describe('routerLinkActive', () => {
    it('should update when the associated routerLinks change - #18469', async () => {
      @Component({
        template: `
          <a id="first-link" [routerLink]="[firstLink]" routerLinkActive="active">{{
            firstLink
          }}</a>
          <div id="second-link" routerLinkActive="active">
            <a [routerLink]="[secondLink]">{{ secondLink }}</a>
          </div>
        `,
        standalone: false,
      })
      class LinkComponent {
        firstLink = 'link-a';
        secondLink = 'link-b';
        cdr = inject(ChangeDetectorRef);

        changeLinks(): void {
          const temp = this.secondLink;
          this.secondLink = this.firstLink;
          this.firstLink = temp;
          this.cdr.markForCheck();
        }
      }

      @Component({
        template: 'simple',
        standalone: false,
      })
      class SimpleCmp {}

      TestBed.configureTestingModule({
        imports: [
          RouterModule.forRoot([
            {path: 'link-a', component: SimpleCmp},
            {path: 'link-b', component: SimpleCmp},
          ]),
        ],
        declarations: [LinkComponent, SimpleCmp],
      });

      const router: Router = TestBed.inject(Router);
      const fixture = await createRoot(router, LinkComponent);
      const firstLink = fixture.debugElement.query((p) => p.nativeElement.id === 'first-link');
      const secondLink = fixture.debugElement.query((p) => p.nativeElement.id === 'second-link');
      router.navigateByUrl('/link-a');
      await advance(fixture);

      expect(firstLink.nativeElement.classList).toContain('active');
      expect(secondLink.nativeElement.classList).not.toContain('active');

      fixture.componentInstance.changeLinks();
      fixture.detectChanges();
      await advance(fixture);

      expect(firstLink.nativeElement.classList).not.toContain('active');
      expect(secondLink.nativeElement.classList).toContain('active');
    });

    it('should not cause infinite loops in the change detection - #15825', async () => {
      @Component({
        selector: 'simple',
        template: 'simple',
        standalone: false,
      })
      class SimpleCmp {}

      @Component({
        selector: 'some-root',
        template: ` <div *ngIf="show">
            <ng-container *ngTemplateOutlet="tpl"></ng-container>
          </div>
          <router-outlet></router-outlet>
          <ng-template #tpl>
            <a routerLink="/simple" routerLinkActive="active"></a>
          </ng-template>`,
        standalone: false,
      })
      class MyCmp {
        show: boolean = false;
      }

      @NgModule({
        imports: [CommonModule, RouterModule.forRoot([])],
        declarations: [MyCmp, SimpleCmp],
      })
      class MyModule {}

      TestBed.configureTestingModule({imports: [MyModule]});

      const router: Router = TestBed.inject(Router);
      const fixture = await createRoot(router, MyCmp);
      router.resetConfig([{path: 'simple', component: SimpleCmp}]);

      router.navigateByUrl('/simple');
      await advance(fixture);

      const instance = fixture.componentInstance;
      instance.show = true;
      expect(() => advance(fixture)).not.toThrow();
    });

    it('should set isActive right after looking at its children -- #18983', async () => {
      @Component({
        template: `
          <div #rla="routerLinkActive" routerLinkActive>
            isActive: {{ rla.isActive }}

            <ng-template let-data>
              <a [routerLink]="data">link</a>
            </ng-template>

            <ng-container #container></ng-container>
          </div>
        `,
        standalone: false,
      })
      class ComponentWithRouterLink {
        @ViewChild(TemplateRef, {static: true}) templateRef?: TemplateRef<unknown>;
        @ViewChild('container', {read: ViewContainerRef, static: true})
        container?: ViewContainerRef;

        addLink() {
          if (this.templateRef) {
            this.container?.createEmbeddedView(this.templateRef, {$implicit: '/simple'});
          }
        }

        removeLink() {
          this.container?.clear();
        }
      }

      @Component({
        template: 'simple',
        standalone: false,
      })
      class SimpleCmp {}

      TestBed.configureTestingModule({
        imports: [RouterModule.forRoot([{path: 'simple', component: SimpleCmp}])],
        declarations: [ComponentWithRouterLink, SimpleCmp],
      });

      const router: Router = TestBed.inject(Router);
      const fixture = await createRoot(router, ComponentWithRouterLink);
      router.navigateByUrl('/simple');
      await advance(fixture);

      fixture.componentInstance.addLink();
      fixture.detectChanges();

      fixture.componentInstance.removeLink();
      await advance(fixture);
      await advance(fixture);

      expect(fixture.nativeElement.innerHTML).toContain('isActive: false');
    });

    it('should set isActive with OnPush change detection - #19934', async () => {
      @Component({
        template: `
          <div routerLink="/simple" #rla="routerLinkActive" routerLinkActive>
            isActive: {{ rla.isActive }}
          </div>
        `,
        changeDetection: ChangeDetectionStrategy.OnPush,
        standalone: false,
      })
      class OnPushComponent {}

      @Component({
        template: 'simple',
        standalone: false,
      })
      class SimpleCmp {}

      TestBed.configureTestingModule({
        imports: [RouterModule.forRoot([{path: 'simple', component: SimpleCmp}])],
        declarations: [OnPushComponent, SimpleCmp],
      });

      const router = TestBed.inject(Router);
      const fixture = await createRoot(router, OnPushComponent);
      router.navigateByUrl('/simple');
      await advance(fixture);

      expect(fixture.nativeElement.innerHTML).toContain('isActive: true');
    });
  });

  it('should not reactivate a deactivated outlet when destroyed and recreated - #41379', async () => {
    @Component({
      template: 'simple',
      standalone: false,
    })
    class SimpleComponent {}

    @Component({
      template: ` <router-outlet *ngIf="outletVisible" name="aux"></router-outlet> `,
      standalone: false,
    })
    class AppComponent {
      outletVisible = true;
    }

    TestBed.configureTestingModule({
      imports: [RouterModule.forRoot([{path: ':id', component: SimpleComponent, outlet: 'aux'}])],
      declarations: [SimpleComponent, AppComponent],
    });

    const router = TestBed.inject(Router);
    const fixture = await createRoot(router, AppComponent);
    const componentCdr = fixture.componentRef.injector.get<ChangeDetectorRef>(ChangeDetectorRef);

    router.navigate([{outlets: {aux: ['1234']}}]);
    await advance(fixture);
    expect(fixture.nativeElement.innerHTML).toContain('simple');

    router.navigate([{outlets: {aux: null}}]);
    await advance(fixture);
    expect(fixture.nativeElement.innerHTML).not.toContain('simple');

    fixture.componentInstance.outletVisible = false;
    componentCdr.detectChanges();
    expect(fixture.nativeElement.innerHTML).not.toContain('simple');
    expect(fixture.nativeElement.innerHTML).not.toContain('router-outlet');

    fixture.componentInstance.outletVisible = true;
    componentCdr.detectChanges();
    expect(fixture.nativeElement.innerHTML).toContain('router-outlet');
    expect(fixture.nativeElement.innerHTML).not.toContain('simple');
  });

  describe('useHash', () => {
    it('should restore hash to match current route - #28561', async () => {
      @Component({
        selector: 'root-cmp',
        template: `<router-outlet></router-outlet>`,
        standalone: false,
      })
      class RootCmp {}

      @Component({
        template: 'simple',
        standalone: false,
      })
      class SimpleCmp {}
      @Component({
        template: 'one',
        standalone: false,
      })
      class OneCmp {}

      TestBed.configureTestingModule({
        imports: [
          RouterModule.forRoot([
            {path: '', component: SimpleCmp},
            {path: 'one', component: OneCmp, canActivate: [() => inject(Router).parseUrl('/')]},
          ]),
        ],
        declarations: [SimpleCmp, RootCmp, OneCmp],
        providers: [provideLocationMocks()],
      });

      const router = TestBed.inject(Router);
      const location = TestBed.inject(Location) as SpyLocation;

      router.navigateByUrl('/');
      // Will setup location change listeners
      const fixture = await createRoot(router, RootCmp);

      location.simulateHashChange('/one');
      await advance(fixture);

      expect(location.path()).toEqual('/');
      expect(fixture.nativeElement.innerHTML).toContain('one');
    });
  });

  describe('duplicate navigation handling (#43447, #43446)', () => {
    let location: Location;
    let router: Router;
    let fixture: ComponentFixture<{}>;

    beforeEach(async () => {
      @Injectable()
      class DelayedResolve {
        resolve() {
          return of('').pipe(
            switchMap((v) => new Promise((r) => setTimeout(r, 10)).then(() => v)),
            mapTo(true),
          );
        }
      }
      @Component({
        selector: 'root-cmp',
        template: `<router-outlet></router-outlet>`,
        standalone: false,
      })
      class RootCmp {}

      @Component({
        template: 'simple',
        standalone: false,
      })
      class SimpleCmp {}
      @Component({
        template: 'one',
        standalone: false,
      })
      class OneCmp {}
      TestBed.configureTestingModule({
        declarations: [SimpleCmp, RootCmp, OneCmp],
        imports: [RouterOutlet],
        providers: [
          DelayedResolve,
          provideLocationMocks(),
          provideRouter([
            {path: '', component: SimpleCmp},
            {path: 'one', component: OneCmp, resolve: {x: DelayedResolve}},
          ]),
          {provide: LocationStrategy, useClass: HashLocationStrategy},
        ],
      });

      router = TestBed.inject(Router);
      location = TestBed.inject(Location);

      router.navigateByUrl('/');
      // Will setup location change listeners
      fixture = await createRoot(router, RootCmp);
    });

    it('duplicate navigation to same url', async () => {
      location.go('/one');
      await timeout(1);
      location.go('/one');
      await timeout(10);
      await advance(fixture);

      expect(location.path()).toEqual('/one');
      expect(fixture.nativeElement.innerHTML).toContain('one');
    });

    it('works with a duplicate popstate/hashchange navigation (as seen in firefox)', async () => {
      (location as any)._subject.next({'url': 'one', 'pop': true, 'type': 'popstate'});
      await timeout(1);
      (location as any)._subject.next({'url': 'one', 'pop': true, 'type': 'hashchange'});
      await timeout(10);
      await advance(fixture);

      expect(router.routerState.toString()).toContain(`url:'one'`);
      expect(fixture.nativeElement.innerHTML).toContain('one');
    });
  });

  it('should not unregister outlet if a different one already exists #36711, 32453', async () => {
    @Component({
      template: `
        <router-outlet *ngIf="outlet1()"></router-outlet>
        <router-outlet *ngIf="outlet2()"></router-outlet>
      `,
      standalone: false,
    })
    class TestCmp {
      outlet1 = signal(true);
      outlet2 = signal(false);
    }

    @Component({
      template: '',
      standalone: false,
    })
    class EmptyCmp {}

    TestBed.configureTestingModule({
      imports: [CommonModule, RouterModule.forRoot([{path: '**', component: EmptyCmp}])],
      declarations: [TestCmp, EmptyCmp],
    });
    const fixture = TestBed.createComponent(TestCmp);
    const contexts = TestBed.inject(ChildrenOutletContexts);
    await TestBed.inject(Router).navigateByUrl('/');
    fixture.detectChanges();

    expect(contexts.getContext('primary')).toBeDefined();
    expect(contexts.getContext('primary')?.outlet).not.toBeNull();

    // Show the second outlet. Applications shouldn't really have more than one outlet but there can
    // be timing issues between destroying and recreating a second one in some cases:
    // https://github.com/angular/angular/issues/36711,
    // https://github.com/angular/angular/issues/32453
    fixture.componentInstance.outlet2.set(true);
    fixture.detectChanges();
    expect(contexts.getContext('primary')?.outlet).not.toBeNull();

    fixture.componentInstance.outlet1.set(false);
    fixture.detectChanges();
    // Destroying the first one show not clear the outlet context because the second one takes over
    // as the registered outlet.
    expect(contexts.getContext('primary')?.outlet).not.toBeNull();
  });

  it('should respect custom serializer all the way to the final url on state', async () => {
    const QUERY_VALUE = {user: 'atscott'};
    const SPECIAL_SERIALIZATION = 'special';

    class CustomSerializer extends DefaultUrlSerializer {
      override serialize(tree: UrlTree): string {
        const mutableCopy = new UrlTree(tree.root, {...tree.queryParams}, tree.fragment);
        mutableCopy.queryParams['q'] &&= SPECIAL_SERIALIZATION;
        return new DefaultUrlSerializer().serialize(mutableCopy);
      }
    }

    TestBed.configureTestingModule({
      providers: [provideRouter([]), {provide: UrlSerializer, useValue: new CustomSerializer()}],
    });

    const router = TestBed.inject(Router);
    const tree = router.createUrlTree([]);
    tree.queryParams = {q: QUERY_VALUE};
    await router.navigateByUrl(tree);

    expect(router.url).toEqual(`/?q=${SPECIAL_SERIALIZATION}`);
  });

  it('navigation works when a redirecting NavigationCancel event causes another synchronous navigation', async () => {
    TestBed.configureTestingModule({
      providers: [
        provideRouter(
          [
            {path: 'a', children: []},
            {path: 'b', children: []},
            {path: 'c', children: []},
          ],
          withRouterConfig({resolveNavigationPromiseOnError: true}),
        ),
      ],
    });

    let errors: NavigationError[] = [];
    let cancellations: NavigationCancel[] = [];
    const router = TestBed.inject(Router);
    router.events
      .pipe(filter((e): e is NavigationError => e instanceof NavigationError))
      .subscribe((e) => errors.push(e));
    router.events
      .pipe(filter((e): e is NavigationCancel => e instanceof NavigationCancel))
      .subscribe((e) => cancellations.push(e));

    router.events
      .pipe(
        filter((e) => e instanceof NavigationCancel),
        take(1),
      )
      .subscribe(() => {
        router.navigateByUrl('/c');
      });
    router.navigateByUrl('/a');
    router.navigateByUrl('/b');
    await new Promise<void>((resolve) => afterNextNavigation(router, resolve));

    expect(router.url).toEqual('/c');
    expect(errors).toEqual([]);
    // navigations to a and b were both cancelled.
    expect(cancellations.length).toEqual(2);
  });

  if (isBrowser) {
    it(
      'should allow navigating during template execution',
      withBody('<test-app></test-app>', async () => {
        // Note this might not be a behavior that we want to keep, but it is currently relied on by some targets on G3
        @Component({
          template: 'simple',
        })
        class SimpleCmp {}

        @Component({
          selector: 'test-app',
          template: `<router-outlet /> {{ sideEffectWithRedirection() }} `,
          imports: [RouterOutlet],
        })
        class App {
          navigate = true;
          constructor(private router: Router) {}

          sideEffectWithRedirection() {
            if (this.navigate) {
              this.router.navigateByUrl('/simple');
              this.navigate = false;
            }
          }
        }

        const appRef = await bootstrapApplication(App, {
          providers: [
            provideRouter(
              [{path: 'simple', component: SimpleCmp}],
              withDisabledInitialNavigation(),
              withViewTransitions({skipInitialTransition: true}),
            ),
          ],
        });

        await appRef.injector.get(Router).navigateByUrl('/simple');
        await expectAsync(appRef.whenStable()).toBeResolved();
      }),
    );
  }
});

async function advance<T>(fixture: ComponentFixture<T>): Promise<void> {
  await timeout();
  fixture.detectChanges();
}

function createRoot<T>(router: Router, type: Type<T>): ComponentFixture<T> {
  const f = TestBed.createComponent(type);
  advance(f);
  router.initialNavigation();
  advance(f);
  return f;
}
