/*
 * Copyright (C) 2019 Robert Bosch GmbH Copyright (C) 2019 Robert Bosch Manufacturing Solutions GmbH, Germany. All rights reserved.
 */

import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Inject, Injectable } from '@angular/core';
import { BehaviorSubject, forkJoin, isObservable, Observable, of, throwError, timer } from 'rxjs';
import { distinctUntilChanged, filter, first, map, mergeMap, shareReplay, switchMap, tap } from 'rxjs/operators';
import { AUTH_OPTIONS } from '../../auth-options.token';
import { DefaultAuthModuleConfig, IAuthModuleConfig } from '../../auth.options';
import { AccessControlAuthResponse } from '../../models/access-control-auth-response.model';
import { JwtToken } from '../../models/jwt-token.model';
import { AuthenticationService } from '../../services/authentication.service';
import { STORAGE_EXPIRES_AT, STORAGE_TOKEN } from '../auth.constants';
import { User } from '../models/user.model';
import { asObservable } from '../../as-observable';

@Injectable()
export class AccessControlAuthenticationService extends AuthenticationService {
  public redirectUrl: string;
  private user: User;
  private authResponse$ = new BehaviorSubject<AccessControlAuthResponse>(this._restoreAccessControlAuthResponse());

  constructor(@Inject(AUTH_OPTIONS) private config: IAuthModuleConfig, private _http: HttpClient) {
    super();
    this.getUser().subscribe((user) => (this.user = user));
    this._automaticTokenRefresh();
  }

  login(
    username: string,
    password: string,
    tenant?: string,
    useWinAuth = false
  ): Observable<AccessControlAuthResponse> {
    // Refreshing of tokens only works with exactly the same username, as the username, the user used to login
    if (!!username && username.indexOf('\\') === -1) {
      username = 'MES\\' + username;
    }

    this._deleteToken();
    return this._makeLoginRequest(username, password, tenant, useWinAuth).pipe(
      mergeMap((t) => this._saveToken(t)),
      tap(() => this._automaticTokenRefresh())
    );
  }

  logout(): Observable<boolean> {
    this._deleteToken();
    return of(true);
  }

  getAccessControlAuthResponse(): Observable<AccessControlAuthResponse> {
    return this.authResponse$.asObservable();
  }

  isAuthenticated(): Observable<boolean> {
    return this.getAccessControlAuthResponse().pipe(
      switchMap((token) => forkJoin([of(token), this.getExpiration().pipe(first())])),
      map(([token, expiresAt]) => {
        return !!token && Object.keys(token).length > 0 && expiresAt > new Date().getTime();
      }),
      distinctUntilChanged()
    );
  }

  /**
   * Returns expiration time of accessToken or 0.
   */
  getExpiration(): Observable<number> {
    const expiresAtString = localStorage.getItem(STORAGE_EXPIRES_AT);
    if (!expiresAtString) {
      return of(0);
    }
    return of(Number(expiresAtString));
  }

  /**
   * Returns accessToken as string or null.
   */
  getAccessToken(): Observable<string> {
    return this.getAccessControlAuthResponse().pipe(
      map((response) => (!!response && !!response.access_token ? response.access_token : null))
    );
  }

  getRoles(): Observable<string[]> {
    return this.getUser().pipe(map((user) => user.roles));
  }

  getUser(): Observable<User> {
    return this.getAccessControlAuthResponse().pipe(
      map((response) => {
        if (!response || !response.access_token) {
          return <User>{};
        }
        const accessToken = this._decodeAccessToken(response.access_token);
        return <User>{
          username: accessToken.payload.unique_name,
          roles: accessToken.payload.role,
          token: accessToken,
        };
      }),
      shareReplay(1)
    );
  }

  /**
   * Return object representing payload of id token or null.
   */
  getIdTokenPayload() {
    return !!this.user && !!this.user.token ? this.user.token.payload : null;
  }

  /**
   * Returns name of logged in user or null.
   */
  getUsername() {
    return !!this.user ? this.user.username : null;
  }

    /**
   * Returns realm of logged in user or null.
   */
    getTenantId() {
      const idTokenPayload = this.getIdTokenPayload();
      return !!idTokenPayload ? idTokenPayload.tid : null;
    }

  private _restoreAccessControlAuthResponse(): AccessControlAuthResponse {
    return JSON.parse(localStorage.getItem(STORAGE_TOKEN) || '{}');
  }

  /**
   * Works only, if login with following scheme is used: <DOMAIN>\<USER>. Does not work with <USER>!!
   */
  refreshToken(): Observable<AccessControlAuthResponse> {
    return this.getAccessControlAuthResponse().pipe(
      first(),
      filter((token) => !!token && !!token.access_token),
      map((token) => {
        const jwtToken = this._decodeAccessToken(token.access_token);
        return {token: token, accessToken: jwtToken};
      }),
      mergeMap((token) => {
        let headers = new HttpHeaders();
        let body = new HttpParams();

        headers = headers.set('Content-Type', 'application/x-www-form-urlencoded');
        body = body
          .set('grant_type', 'refresh_token')
          .set('client_id', token.accessToken.payload.unique_name)
          .set('refresh_token', token.token.refresh_token);

        return this._getOauthTokenEndpoint().pipe(
          mergeMap((endpoint) =>
            this._http.post<AccessControlAuthResponse>(endpoint, body, {headers: headers, withCredentials: true})
          ),
          mergeMap((refreshedToken) => this._saveToken(refreshedToken))
        );
      })
    );
  }

  private _makeLoginRequest(
    username: string,
    password: string,
    tenant: string,
    useWinAuth: boolean
  ): Observable<AccessControlAuthResponse> {
    let headers = new HttpHeaders();
    let body = new HttpParams();

    headers = headers.set('Content-Type', 'application/x-www-form-urlencoded');
    body = body.set('grant_type', 'client_credentials');

    if (!useWinAuth) {
      headers = headers.set('Authorization', 'Basic ' + btoa(unescape(encodeURIComponent(username + ':' + password))));
      body = body.set('username', username).set('password', password);
      if (tenant) {
        body = body.set('tenant', tenant);
      }
    } else {
      headers = headers.set('WinAuth', 'true');
    }

    return this._getOauthTokenEndpoint().pipe(
      mergeMap((endpoint) =>
        this._http.post<AccessControlAuthResponse>(endpoint, body, {headers: headers, withCredentials: true})
      )
    );
  }

  private _saveToken(token: AccessControlAuthResponse): Observable<AccessControlAuthResponse> {
    const expiresAt = new Date().getTime() + token.expires_in * 1000;

    localStorage.setItem(STORAGE_EXPIRES_AT, expiresAt.toString());
    localStorage.setItem(STORAGE_TOKEN, JSON.stringify(token));

    this.authResponse$.next(token);
    return of(token);
  }

  private _deleteToken() {
    localStorage.removeItem(STORAGE_TOKEN);
    localStorage.removeItem(STORAGE_EXPIRES_AT);
    this.authResponse$.next(undefined);
  }

  private _decodeAccessToken(accessToken: string): JwtToken {
    if (!accessToken) {
      return {header: undefined, payload: undefined};
    }
    const splittedAccessToken = accessToken.split('.');
    if (splittedAccessToken.length >= 2) {
      const header = JSON.parse(atob(splittedAccessToken[0]));
      const payload = JSON.parse(atob(splittedAccessToken[1]));

      return {
        header: header,
        payload: payload,
      };
    }
    return {header: undefined, payload: undefined};
  }

  private _getOauthTokenEndpoint(): Observable<string> {
    const authServerBaseUrl = this.config.authServerBaseUrl ?? this.config.accessControlEndpoint;
    if (
      !(
        (!!authServerBaseUrl && typeof authServerBaseUrl === 'string' && authServerBaseUrl.length > 0) ||
        isObservable(authServerBaseUrl)
      )
    ) {
      const error = 'Empty authServerBaseUrl in ngAuth-Module Configuration. Please define an authServerBaseUrl.';
      console.error(error);
      return throwError(error);
    }

    return asObservable(authServerBaseUrl).pipe(
      map((accessControlEndpoint) => {
        if (!accessControlEndpoint.endsWith('/')) {
          accessControlEndpoint += '/';
        }
        accessControlEndpoint += 'oauth/token';
        return accessControlEndpoint;
      })
    );
  }

  private _automaticTokenRefresh() {
    if (this.config.automaticTokenRefresh === true) {
      this._activateAutomaticRefresh().subscribe();
    }
  }

  private _activateAutomaticRefresh(): Observable<AccessControlAuthResponse> {
    return this.getExpiration().pipe(
      map(
        (exp) =>
          exp - (this.config.refreshTokenTimeInSeconds || DefaultAuthModuleConfig.refreshTokenTimeInSeconds) * 1000
      ),
      mergeMap((expiration) => timer(expiration - new Date().getTime())),
      mergeMap(() => this.refreshToken()),
      mergeMap(() => this._activateAutomaticRefresh())
    );
  }
}
