import {
  Component,
  ViewChild,
  ViewContainerRef,
  ComponentRef,
  SimpleChanges,
  OnInit,
  OnDestroy,
  ComponentFactoryResolver,
  ChangeDetectorRef,
  OnChanges,
  HostBinding,
  ElementRef,
} from '@angular/core';
import { hasNoValue, hasValue, isNotEmpty } from '../empty.util';
import { combineLatest, from as fromPromise, Observable, of as observableOf, Subscription, BehaviorSubject } from 'rxjs';
import { ThemeService } from './theme.service';
import { catchError, switchMap, map, tap } from 'rxjs/operators';
import { GenericConstructor } from '../../core/shared/generic-constructor';
import { BASE_THEME_NAME } from './theme.constants';

@Component({
  selector: 'ds-themed',
  styleUrls: ['./themed.component.scss'],
  templateUrl: './themed.component.html',
})
export abstract class ThemedComponent<T> implements OnInit, OnDestroy, OnChanges {
  @ViewChild('vcr', { read: ViewContainerRef }) vcr: ViewContainerRef;
  @ViewChild('content') themedElementContent: ElementRef;
  protected compRef: ComponentRef<T>;

  /**
   * A reference to the themed component. Will start as undefined and emit every time the themed
   * component is rendered
   */
  public compRef$: BehaviorSubject<ComponentRef<T>> = new BehaviorSubject(undefined);

  protected lazyLoadObs: Observable<any>;
  protected lazyLoadSub: Subscription;
  protected themeSub: Subscription;

  protected inAndOutputNames: (keyof T & keyof this)[] = [];

  /**
   * A data attribute on the ThemedComponent to indicate which theme the rendered component came from.
   */
  @HostBinding('attr.data-used-theme') usedTheme: string;

  constructor(
    protected resolver: ComponentFactoryResolver,
    protected cdr: ChangeDetectorRef,
    protected themeService: ThemeService,
  ) {
  }

  protected abstract getComponentName(): string;

  protected abstract importThemedComponent(themeName: string): Promise<any>;
  protected abstract importUnthemedComponent(): Promise<any>;

  ngOnChanges(changes: SimpleChanges): void {
    if (hasNoValue(this.compRef)) {
      // sometimes the component has not been initialized yet, so it first needs to be initialized
      // before being called again
      this.initComponentInstance(changes);
    } else {
      // if an input or output has changed
      if (this.inAndOutputNames.some((name: any) => hasValue(changes[name]))) {
        this.connectInputsAndOutputs();
        if (this.compRef?.instance && 'ngOnChanges' in this.compRef.instance) {
          (this.compRef.instance as any).ngOnChanges(changes);
        }
      }
    }
  }

  ngOnInit(): void {
    this.destroyComponentInstance();
    this.initComponentInstance();
  }

  ngOnDestroy(): void {
    [this.themeSub, this.lazyLoadSub].filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
    this.destroyComponentInstance();
  }

  initComponentInstance(changes?: SimpleChanges) {
    this.themeSub = this.themeService?.getThemeName$().subscribe(() => {
      this.renderComponentInstance(changes);
    });
  }

  protected renderComponentInstance(changes?: SimpleChanges): void {
    if (hasValue(this.lazyLoadSub)) {
      this.lazyLoadSub.unsubscribe();
    }

    if (hasNoValue(this.lazyLoadObs)) {
      this.destroyComponentInstance();

      this.lazyLoadObs = combineLatest([
        observableOf(changes),
        this.resolveThemedComponent(this.themeService.getThemeName()).pipe(
          switchMap((themedFile: any) => {
            if (hasValue(themedFile) && hasValue(themedFile[this.getComponentName()])) {
              // if the file is not null, and exports a component with the specified name,
              // return that component
              return [themedFile[this.getComponentName()]];
            } else {
              // otherwise import and return the default component
              return fromPromise(this.importUnthemedComponent()).pipe(
            tap(() => this.usedTheme = BASE_THEME_NAME),
                map((unthemedFile: any) => {
                  return unthemedFile[this.getComponentName()];
                })
              );
            }
          })),
      ]);
    }

    this.lazyLoadSub = this.lazyLoadObs.subscribe(([simpleChanges, constructor]: [SimpleChanges, GenericConstructor<T>]) => {
      const factory = this.resolver.resolveComponentFactory(constructor);
      this.compRef = this.vcr.createComponent(factory, undefined, undefined, [this.themedElementContent.nativeElement.childNodes]);
      if (hasValue(simpleChanges)) {
        this.ngOnChanges(simpleChanges);
      } else {
        this.connectInputsAndOutputs();
      }
      this.compRef$.next(this.compRef);
      this.cdr.markForCheck();
      this.themedElementContent.nativeElement.remove();
    });
  }

  protected destroyComponentInstance(): void {
    if (hasValue(this.compRef)) {
      this.compRef.destroy();
      this.compRef = null;
    }
    if (hasValue(this.vcr)) {
      this.vcr.clear();
    }
  }

  protected connectInputsAndOutputs(): void {
    if (isNotEmpty(this.inAndOutputNames) && hasValue(this.compRef) && hasValue(this.compRef.instance)) {
      this.inAndOutputNames.filter((name: any) => this[name] !== undefined).forEach((name: any) => {
        this.compRef.instance[name] = this[name];
      });
    }
  }

  /**
   * Attempt to import this component from the current theme or a theme it {@link NamedThemeConfig.extends}.
   * Recurse until we succeed or when until we run out of themes to fall back to.
   *
   * @param themeName The name of the theme to check
   * @param checkedThemeNames The list of theme names that are already checked
   * @private
   */
  private resolveThemedComponent(themeName?: string, checkedThemeNames: string[] = []): Observable<any> {
    if (isNotEmpty(themeName)) {
      return fromPromise(this.importThemedComponent(themeName)).pipe(
        tap(() => this.usedTheme = themeName),
        catchError(() => {
          // Try the next ancestor theme instead
          const nextTheme = this.themeService.getThemeConfigFor(themeName)?.extends;
          const nextCheckedThemeNames = [...checkedThemeNames, themeName];
          if (checkedThemeNames.includes(nextTheme)) {
            throw new Error('Theme extension cycle detected: ' + [...nextCheckedThemeNames, nextTheme].join(' -> '));
          } else {
            return this.resolveThemedComponent(nextTheme, nextCheckedThemeNames);
          }
        }),
      );
    } else {
      // If we got here, we've failed to import this component from any ancestor theme → fall back to unthemed
      return observableOf(null);
    }
  }
}