/*
 * Copyright (C) 2023 StApps
 * This program is free software: you can redistribute it and/or modify it
 * under the terms of the GNU General Public License as published by the Free
 * Software Foundation, version 3.
 *
 * This program is distributed in the hope that it will be useful, but WITHOUT
 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
 * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
 * more details.
 *
 * You should have received a copy of the GNU General Public License along with
 * this program. If not, see <https://www.gnu.org/licenses/>.
 */
// Temporary use of direct file until the version with bug fix is released
// https://github.com/wi3land/ionic-appauth/blob/3716f4fc6b5491b0b75e049be0a47a5af8c4da6f/src/auth-service.ts
// Bug: https://github.com/wi3land/ionic-appauth/issues/154
import {
  AuthorizationError,
  AuthorizationNotifier,
  AuthorizationRequest,
  AuthorizationRequestHandler,
  AuthorizationRequestJson,
  AuthorizationResponse,
  AuthorizationServiceConfiguration,
  BaseTokenRequestHandler,
  DefaultCrypto,
  GRANT_TYPE_AUTHORIZATION_CODE,
  GRANT_TYPE_REFRESH_TOKEN,
  JQueryRequestor,
  LocalStorageBackend,
  Requestor,
  RevokeTokenRequest,
  RevokeTokenRequestJson,
  StorageBackend,
  StringMap,
  TokenRequest,
  TokenRequestHandler,
  TokenRequestJson,
  TokenResponse,
} from '@openid/appauth';
import {
  ActionHistoryObserver,
  AuthActionBuilder,
  AuthActions,
  AuthObserver,
  AUTHORIZATION_RESPONSE_KEY,
  AuthSubject,
  BaseAuthObserver,
  Browser,
  DefaultBrowser,
  EndSessionHandler,
  EndSessionRequest,
  EndSessionRequestJson,
  IAuthAction,
  IAuthConfig,
  IAuthService,
  IonicAuthorizationRequestHandler,
  IonicEndSessionHandler,
  IonicUserInfoHandler,
  SessionObserver,
  UserInfoHandler,
} from 'ionic-appauth';
import {BehaviorSubject, Observable} from 'rxjs';

const TOKEN_RESPONSE_KEY = 'token_response';
const AUTH_EXPIRY_BUFFER = 10 * 60 * -1; // 10 mins in seconds

export abstract class AuthService implements IAuthService {
  private _configuration?: AuthorizationServiceConfiguration;

  private _authConfig?: IAuthConfig;

  private _authSubject: AuthSubject = new AuthSubject();

  private _actionHistory: ActionHistoryObserver = new ActionHistoryObserver();

  private _session: SessionObserver = new SessionObserver();

  private _authSubjectV2 = new BehaviorSubject<IAuthAction>(AuthActionBuilder.Init());

  private _tokenSubject = new BehaviorSubject<TokenResponse | undefined>(undefined);

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  private _userSubject = new BehaviorSubject<any>(undefined);

  private _authenticatedSubject = new BehaviorSubject<boolean>(false);

  private _loggedInSubject = new BehaviorSubject<boolean>(false);

  private _initComplete = new BehaviorSubject<boolean>(false);

  protected tokenHandler: TokenRequestHandler;

  protected userInfoHandler: UserInfoHandler;

  protected requestHandler: AuthorizationRequestHandler;

  protected endSessionHandler: EndSessionHandler;

  constructor(
    protected browser: Browser = new DefaultBrowser(),
    protected storage: StorageBackend = new LocalStorageBackend(),
    protected requestor: Requestor = new JQueryRequestor(),
  ) {
    this.tokenHandler = new BaseTokenRequestHandler(requestor);
    this.userInfoHandler = new IonicUserInfoHandler(requestor);
    this.requestHandler = new IonicAuthorizationRequestHandler(browser, storage);
    this.endSessionHandler = new IonicEndSessionHandler(browser);
  }

  /**
   * @deprecated independant observers have been replaced by Rxjs
   * this will be removed in a future release
   * please use $ suffixed observers in future
   */
  get history(): IAuthAction[] {
    return [...this._actionHistory.history];
  }

  /**
   * @deprecated independant observers have been replaced by Rxjs
   * this will be removed in a future release
   * please use $ suffixed observers in future
   */
  get session() {
    return this._session.session;
  }

  get token$(): Observable<TokenResponse | undefined> {
    return this._tokenSubject.asObservable();
  }

  get isAuthenticated$(): Observable<boolean> {
    return this._authenticatedSubject.asObservable();
  }

  /**
   * Similar to isAuthenticated$, but will also return true if the token is expired
   */
  get isLoggedIn$(): Observable<boolean> {
    return this._loggedInSubject.asObservable();
  }

  get initComplete$(): Observable<boolean> {
    return this._initComplete.asObservable();
  }

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  get user$(): Observable<any> {
    return this._userSubject.asObservable();
  }

  get events$(): Observable<IAuthAction> {
    return this._authSubjectV2.asObservable();
  }

  get authConfig(): IAuthConfig {
    if (!this._authConfig) throw new Error('AuthConfig Not Defined');

    return this._authConfig;
  }

  set authConfig(value: IAuthConfig) {
    this._authConfig = value;
  }

  get configuration(): Promise<AuthorizationServiceConfiguration> {
    if (!this._configuration) {
      return AuthorizationServiceConfiguration.fetchFromIssuer(
        this.authConfig.server_host,
        this.requestor,
      ).catch(() => {
        throw new Error('Unable To Obtain Server Configuration');
      });
    }

    if (this._configuration == undefined) {
      throw new Error('Unable To Obtain Server Configuration');
    } else {
      return Promise.resolve(this._configuration);
    }
  }

  public async init() {
    this.setupAuthorizationNotifier();
    this.loadTokenFromStorage();
    this.addActionObserver(this._actionHistory);
    this.addActionObserver(this._session);
  }

  protected notifyActionListers(action: IAuthAction) {
    /* eslint-disable unicorn/no-useless-undefined */
    switch (action.action) {
      case AuthActions.SignInFailed:
      case AuthActions.SignOutSuccess:
      case AuthActions.SignOutFailed: {
        this._tokenSubject.next(undefined);
        this._userSubject.next(undefined);
        this._authenticatedSubject.next(false);
        this._loggedInSubject.next(false);
        break;
      }
      case AuthActions.LoadTokenFromStorageFailed: {
        this._tokenSubject.next(undefined);
        this._userSubject.next(undefined);
        this._authenticatedSubject.next(false);
        this._loggedInSubject.next(false);
        this._initComplete.next(true);
        break;
      }
      case AuthActions.SignInSuccess:
      case AuthActions.RefreshSuccess: {
        this._tokenSubject.next(action.tokenResponse);
        this._authenticatedSubject.next(true);
        this._loggedInSubject.next(true);
        break;
      }
      case AuthActions.LoadTokenFromStorageSuccess: {
        this._tokenSubject.next(action.tokenResponse);
        this._authenticatedSubject.next((action.tokenResponse as TokenResponse).isValid(0));
        this._loggedInSubject.next(true);
        this._initComplete.next(true);
        break;
      }
      case AuthActions.RevokeTokensSuccess: {
        this._tokenSubject.next(undefined);
        break;
      }
      case AuthActions.LoadUserInfoSuccess: {
        this._userSubject.next(action.user);
        break;
      }
      case AuthActions.LoadUserInfoFailed: {
        this._userSubject.next(undefined);
        break;
      }
    }

    this._authSubjectV2.next(action);
    this._authSubject.notify(action);
  }

  protected setupAuthorizationNotifier() {
    const notifier = new AuthorizationNotifier();
    this.requestHandler.setAuthorizationNotifier(notifier);
    notifier.setAuthorizationListener((request, response, error) =>
      this.onAuthorizationNotification(request, response, error),
    );
  }

  protected onAuthorizationNotification(
    request: AuthorizationRequest,
    response: AuthorizationResponse | null,
    error: AuthorizationError | null,
  ) {
    const codeVerifier: string | undefined =
      request.internal != undefined && this.authConfig.pkce ? request.internal.code_verifier : undefined;

    if (response != undefined) {
      this.requestAccessToken(response.code, codeVerifier);
    } else if (error == undefined) {
      throw new Error('Unknown Error With Authentication');
    } else {
      throw new Error(error.errorDescription);
    }
  }

  protected async internalAuthorizationCallback(url: string) {
    this.browser.closeWindow();
    await this.storage.setItem(AUTHORIZATION_RESPONSE_KEY, url);
    return this.requestHandler.completeAuthorizationRequestIfPossible();
  }

  protected async internalEndSessionCallback() {
    this.browser.closeWindow();
    this._actionHistory.clear();
    this.notifyActionListers(AuthActionBuilder.SignOutSuccess());
  }

  protected async performEndSessionRequest(state?: string): Promise<void> {
    if (this._tokenSubject.value == undefined) {
      //if user has no token they should not be logged in in the first place
      this.endSessionCallback();
    } else {
      const requestJson: EndSessionRequestJson = {
        postLogoutRedirectURI: this.authConfig.end_session_redirect_url,
        idTokenHint: this._tokenSubject.value.idToken || '',
        state: state || undefined,
      };

      const request: EndSessionRequest = new EndSessionRequest(requestJson);
      const returnedUrl: string | undefined = await this.endSessionHandler.performEndSessionRequest(
        await this.configuration,
        request,
      );

      //callback may come from showWindow or via another method
      if (returnedUrl != undefined) {
        this.endSessionCallback();
      }
    }
  }

  protected async performAuthorizationRequest(authExtras?: StringMap, state?: string): Promise<void> {
    const requestJson: AuthorizationRequestJson = {
      response_type: AuthorizationRequest.RESPONSE_TYPE_CODE,
      client_id: this.authConfig.client_id,
      redirect_uri: this.authConfig.redirect_url,
      scope: this.authConfig.scopes,
      extras: authExtras,
      state: state || undefined,
    };

    const request = new AuthorizationRequest(requestJson, new DefaultCrypto(), this.authConfig.pkce);

    if (this.authConfig.pkce) await request.setupCodeVerifier();

    return this.requestHandler.performAuthorizationRequest(await this.configuration, request);
  }

  protected async requestAccessToken(code: string, codeVerifier?: string): Promise<void> {
    const requestJSON: TokenRequestJson = {
      grant_type: GRANT_TYPE_AUTHORIZATION_CODE,
      code: code,
      refresh_token: undefined,
      redirect_uri: this.authConfig.redirect_url,
      client_id: this.authConfig.client_id,
      extras: codeVerifier
        ? {
            code_verifier: codeVerifier,
            client_secret: this.authConfig.client_secret as string,
          }
        : {
            client_secret: this.authConfig.client_secret as string,
          },
    };

    const token: TokenResponse = await this.tokenHandler.performTokenRequest(
      await this.configuration,
      new TokenRequest(requestJSON),
    );
    await this.storage.setItem(TOKEN_RESPONSE_KEY, JSON.stringify(token.toJson()));
    this.notifyActionListers(AuthActionBuilder.SignInSuccess(token));
  }

  protected async requestTokenRefresh() {
    if (!this._tokenSubject.value) {
      throw new Error('No Token Defined!');
    }

    const requestJSON: TokenRequestJson = {
      grant_type: GRANT_TYPE_REFRESH_TOKEN,
      refresh_token: this._tokenSubject.value?.refreshToken,
      redirect_uri: this.authConfig.redirect_url,
      client_id: this.authConfig.client_id,
    };

    const token: TokenResponse = await this.tokenHandler.performTokenRequest(
      await this.configuration,
      new TokenRequest(requestJSON),
    );
    if (!token.accessToken) {
      throw new Error('No Access Token Defined In Refresh Response');
    }
    await this.storage.setItem(TOKEN_RESPONSE_KEY, JSON.stringify(token.toJson()));
    this.notifyActionListers(AuthActionBuilder.RefreshSuccess(token));
  }

  protected async internalLoadTokenFromStorage() {
    let token: TokenResponse | undefined;
    const tokenResponseString: string | null = await this.storage.getItem(TOKEN_RESPONSE_KEY);

    if (tokenResponseString != undefined) {
      token = new TokenResponse(JSON.parse(tokenResponseString));

      if (token) {
        return this.notifyActionListers(AuthActionBuilder.LoadTokenFromStorageSuccess(token));
      }
    }

    throw new Error('No Token In Storage');
  }

  protected async requestTokenRevoke() {
    const revokeRefreshJson: RevokeTokenRequestJson = {
      token: (this._tokenSubject.value as TokenResponse).refreshToken as string,
      token_type_hint: 'refresh_token',
      client_id: this.authConfig.client_id,
    };

    const revokeAccessJson: RevokeTokenRequestJson = {
      token: (this._tokenSubject.value as TokenResponse).accessToken,
      token_type_hint: 'access_token',
      client_id: this.authConfig.client_id,
    };

    await this.tokenHandler.performRevokeTokenRequest(
      await this.configuration,
      new RevokeTokenRequest(revokeRefreshJson),
    );
    await this.tokenHandler.performRevokeTokenRequest(
      await this.configuration,
      new RevokeTokenRequest(revokeAccessJson),
    );
    await this.storage.removeItem(TOKEN_RESPONSE_KEY);
    this.notifyActionListers(AuthActionBuilder.RevokeTokensSuccess());
  }

  protected async internalRequestUserInfo() {
    if (this._tokenSubject.value) {
      const userInfo = await this.userInfoHandler.performUserInfoRequest(
        await this.configuration,
        this._tokenSubject.value,
      );
      this.notifyActionListers(AuthActionBuilder.LoadUserInfoSuccess(userInfo));
    } else {
      throw new Error('No Token Available');
    }
  }

  public async loadTokenFromStorage() {
    await this.internalLoadTokenFromStorage().catch(error => {
      this.notifyActionListers(AuthActionBuilder.LoadTokenFromStorageFailed(error));
    });
  }

  public async signIn(authExtras?: StringMap, state?: string) {
    await this.performAuthorizationRequest(authExtras, state).catch(error => {
      this.notifyActionListers(AuthActionBuilder.SignInFailed(error));
    });
  }

  public async signOut(state?: string, revokeTokens?: boolean) {
    if (revokeTokens) {
      await this.revokeTokens();
    }

    await this.storage.removeItem(TOKEN_RESPONSE_KEY);

    if ((await this.configuration).endSessionEndpoint) {
      await this.performEndSessionRequest(state).catch(error => {
        this.notifyActionListers(AuthActionBuilder.SignOutFailed(error));
      });
    }
  }

  public async revokeTokens() {
    await this.requestTokenRevoke().catch(error => {
      this.storage.removeItem(TOKEN_RESPONSE_KEY);
      this.notifyActionListers(AuthActionBuilder.RevokeTokensFailed(error));
    });
  }

  public async refreshToken() {
    await this.requestTokenRefresh().catch(error => {
      this.notifyActionListers(AuthActionBuilder.RefreshFailed(error));
    });
  }

  public async loadUserInfo() {
    await this.internalRequestUserInfo().catch(error => {
      this.notifyActionListers(AuthActionBuilder.LoadUserInfoFailed(error));
    });
  }

  public authorizationCallback(callbackUrl: string): void {
    this.internalAuthorizationCallback(callbackUrl).catch(error => {
      this.notifyActionListers(AuthActionBuilder.SignInFailed(error));
    });
  }

  public endSessionCallback(): void {
    this.internalEndSessionCallback().catch(error => {
      this.notifyActionListers(AuthActionBuilder.SignOutFailed(error));
    });
  }

  public async getValidToken(buffer: number = AUTH_EXPIRY_BUFFER): Promise<TokenResponse> {
    if (this._tokenSubject.value) {
      if (this._tokenSubject.value.isValid(buffer)) {
        return this._tokenSubject.value;
      } else {
        await this.refreshToken();
        if (this._tokenSubject.value) {
          return this._tokenSubject.value;
        }
      }
    }

    throw new Error('Unable To Obtain Valid Token');
  }

  /**
   * @deprecated independant observers have been replaced by Rxjs
   * this will be removed in a future release
   * please use $ suffixed observers in future
   */
  public addActionListener(function_: (action: IAuthAction) => void): AuthObserver {
    const observer: AuthObserver = AuthObserver.Create(function_);
    this.addActionObserver(observer);
    return observer;
  }

  /**
   * @deprecated independant observers have been replaced by Rxjs
   * this will be removed in a future release
   * please use $ suffixed observers in future
   */
  public addActionObserver(observer: BaseAuthObserver): void {
    if (this._actionHistory.lastAction) {
      observer.update(this._actionHistory.lastAction);
    }

    this._authSubject.attach(observer);
  }

  /**
   * @deprecated independant observers have been replaced by Rxjs
   * this will be removed in a future release
   * please use $ suffixed observers in future
   */
  public removeActionObserver(observer: BaseAuthObserver): void {
    this._authSubject.detach(observer);
  }
}
