/*
 * 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/>.
 */

import {
  AuthorizationError,
  AuthorizationRequest,
  AuthorizationRequestJson,
  AuthorizationServiceConfiguration,
  BasicQueryStringUtils,
  DefaultCrypto,
  JQueryRequestor,
  LocalStorageBackend,
  Requestor,
  StorageBackend,
  StringMap,
  TokenResponse,
} from '@openid/appauth';
import {
  AuthActions,
  AUTHORIZATION_RESPONSE_KEY,
  AuthSubject,
  Browser,
  DefaultBrowser,
  EndSessionHandler,
  IAuthConfig,
  IonicEndSessionHandler,
  IonicUserInfoHandler,
  UserInfoHandler,
} from 'ionic-appauth';
import {BehaviorSubject, Observable} from 'rxjs';
import {PAIATokenRequestHandler} from './token-request-handler';
import {PAIAAuthorizationRequestHandler} from './authorization-request-handler';
import {PAIATokenRequest, PAIATokenRequestJson} from './paia-token-request';
import {PAIAAuthorizationResponse} from './paia-authorization-response';
import {PAIAAuthorizationNotifier} from './paia-authorization-notifier';
import {PAIATokenResponse} from './paia-token-response';
import {IPAIAAuthAction, PAIAAuthActionBuilder} from './paia-auth-action';
import {SCAuthorizationProvider} from '@openstapps/core';
import {ConfigProvider} from '../../config/config.provider';
import {getClientConfig, getEndpointsConfig} from '../auth.provider.methods';
import {Injectable} from '@angular/core';

const TOKEN_RESPONSE_KEY = 'paia_token_response';
const AUTH_EXPIRY_BUFFER = 10 * 60 * -1; // 10 minutes in seconds

export interface IAuthService {
  signIn(authExtras?: StringMap, state?: string): void;
  signOut(state?: string, revokeTokens?: boolean): void;
  loadUserInfo(): void;
  authorizationCallback(callbackUrl: string): void;
  loadTokenFromStorage(): void;
  getValidToken(buffer?: number): Promise<PAIATokenResponse>;
}

@Injectable({
  providedIn: 'root',
})
export class PAIAAuthService {
  private _authConfig?: IAuthConfig;

  private _authSubject: AuthSubject = new AuthSubject();

  private _authSubjectV2 = new BehaviorSubject<IPAIAAuthAction>(PAIAAuthActionBuilder.Init());

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

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

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

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

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

  protected tokenHandler: PAIATokenRequestHandler;

  protected userInfoHandler: UserInfoHandler;

  protected requestHandler: PAIAAuthorizationRequestHandler;

  protected endSessionHandler: EndSessionHandler;

  public localConfiguration: AuthorizationServiceConfiguration;

  constructor(
    protected browser: Browser = new DefaultBrowser(),
    protected storage: StorageBackend = new LocalStorageBackend(),
    protected requestor: Requestor = new JQueryRequestor(),
    private readonly configProvider: ConfigProvider,
  ) {
    this.tokenHandler = new PAIATokenRequestHandler(requestor);
    this.userInfoHandler = new IonicUserInfoHandler(requestor);
    this.requestHandler = new PAIAAuthorizationRequestHandler(
      browser,
      storage,
      new BasicQueryStringUtils(),
      crypto,
    );
    this.endSessionHandler = new IonicEndSessionHandler(browser);
  }

  get token$(): Observable<PAIATokenResponse | 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._loggedIn.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<IPAIAAuthAction> {
    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.localConfiguration) throw new Error('Local Configuration Not Defined');

    return Promise.resolve(this.localConfiguration);
  }

  public async init() {
    this.setupConfiguration();
    this.setupAuthorizationNotifier();
    await this.loadTokenFromStorage();
  }

  setupConfiguration() {
    const authConfig = this.configProvider.getAnyValue('auth') as {
      paia: SCAuthorizationProvider;
    };
    this.authConfig = getClientConfig('paia', authConfig);
    this.localConfiguration = new AuthorizationServiceConfiguration(getEndpointsConfig('paia', authConfig));
  }

  protected notifyActionListers(action: IPAIAAuthAction) {
    /* 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._loggedIn.next(false);
        break;
      }
      case AuthActions.LoadTokenFromStorageFailed: {
        this._tokenSubject.next(undefined);
        this._userSubject.next(undefined);
        this._authenticatedSubject.next(false);
        this._loggedIn.next(false);
        this._initComplete.next(true);
        break;
      }
      case AuthActions.SignInSuccess: {
        this._tokenSubject.next(action.tokenResponse);
        this._authenticatedSubject.next(true);
        this._loggedIn.next(true);
        break;
      }
      case AuthActions.LoadTokenFromStorageSuccess: {
        this._tokenSubject.next(action.tokenResponse);
        this._authenticatedSubject.next((action.tokenResponse as TokenResponse).isValid(0));
        this._loggedIn.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 PAIAAuthorizationNotifier();
    this.requestHandler.setAuthorizationNotifier(notifier);
    notifier.setAuthorizationListener((request, response, error) =>
      this.onAuthorizationNotification(request, response, error),
    );
  }

  protected onAuthorizationNotification(
    request: AuthorizationRequest,
    response: PAIAAuthorizationResponse | 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, response.patron, 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 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, patron: string, codeVerifier?: string): Promise<void> {
    const requestJSON: PAIATokenRequestJson = {
      code: code,
      patron: patron,
      extras: codeVerifier
        ? {
            code_verifier: codeVerifier,
          }
        : {},
    };

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

  public async revokeTokens() {
    // Note: only locally
    await this.storage.removeItem(TOKEN_RESPONSE_KEY);
    this.notifyActionListers(PAIAAuthActionBuilder.RevokeTokensSuccess());
  }

  public async signOut() {
    await this.revokeTokens().catch(error =>
      this.notifyActionListers(PAIAAuthActionBuilder.SignOutFailed(error)),
    );
    this.notifyActionListers(PAIAAuthActionBuilder.SignOutSuccess());
  }

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

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

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

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

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

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

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

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

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

  public async getValidToken(buffer: number = AUTH_EXPIRY_BUFFER): Promise<PAIATokenResponse> {
    if (this._tokenSubject.value && this._tokenSubject.value.isValid(buffer)) {
      return this._tokenSubject.value;
    }

    const error = new Error('Unable To Obtain Valid Token');
    this.notifyActionListers(PAIAAuthActionBuilder.SignInFailed(error));

    throw error;
  }
}
