import { ReplaySubject } from 'rxjs';

import { Anvil, CryptoJS, JWS, b64utohex } from './anvil';
import { Session, ISessionData } from './session';

export interface IAuthorizationOptions {
  // Whether to close any popups used in the authorization process (defaults to
  // true). When disabled, requires additional logic in the authorization flow
  // callback handling to close the popup
  close_popup?: boolean;

  // Can be used to force a path on the cookie used to store the secret
  // encrypting the tokens stored in localStorage. This can be useful when
  // running the authorization flow from a sub-path of a domain while
  // authorizing for the entire domain
  force_path?: string;

  // The time in seconds for which the session may stay active
  max_age?: number;

  // By specifying a provider, the authorization process will only use that
  // specific provider instead of showing a selector providing access to all
  // identity providers available to the issuer
  provider?: string;

  replace_active_session?: boolean;

  // Setting to true implies replacing the active session
  retrieve_user_info?: boolean;

  [ index: string ]: any;
}

export interface IOidcConfiguration{
  client_id: string;
  issuer: string;
  redirect_uri: string;

  client_secret?: string;
}

export class Issuer {
  public error$: ReplaySubject<Error> = new ReplaySubject<Error>();
  public persisted_session: Session;
  public session$: ReplaySubject<Session>;

  private configuration: IOidcConfiguration;
  private sessions: Session[] = [];

  constructor(base_configuration: IOidcConfiguration) {
    this.configuration = base_configuration;
    this.session$ = new ReplaySubject(1);

    Anvil.configure(this.configuration);
    Anvil.getKeys();

    if(Object.keys(Anvil.session).length > 0) {
      this.saveSession(Anvil.session);
    }
  }

  abortAllSessions() {
    this.sessions.forEach((session: Session) => session.abort());
  }

  addAuthorizationHeaders(headers: Object = {}) {
    return Anvil.headers(headers);
  }

  authorizeFullPage(
    requested_scopes: string[] = [],
    options: IAuthorizationOptions
  ) {
    window.location.href = this.getAuthorizationUri(
      requested_scopes, options
    );
  }

  authorizeWithPopup(
    requested_scopes: string[] = [],
    options: IAuthorizationOptions
  ) {
    // Set up framework to receive response from the popup when the
    // authorization flow finishes
    let authorization_popup: Window;
    let session_data_listener = (event: MessageEvent) => {
      // Not all received events contain authorization data. Not verifying this
      // can cause crashes
      if(
        event.data && typeof(event.data) === 'string' &&
        event.data.includes('access_token')
      ) {
        if(!options || options.close_popup) {
          authorization_popup.close();
        }

        window.removeEventListener('message', session_data_listener, false);
        this.processAuthorization(this.extractSessionDataFromUri(event.data));
      }
    };
    window.addEventListener('message', session_data_listener, false);

    // Open the popup with the authorization flow
    let authorization_uri = this.getAuthorizationUri(
      requested_scopes, options
    );
    authorization_popup = window.open(
      authorization_uri, 'authorization', Anvil.popup(700, 500)
    );
  }

  canProcessAuthorization() {
    return location.hash.includes('access_token');
  }

  public extractSessionDataFromUri(uri: string) {
    return Anvil.parseFormUrlEncoded(
      uri.replace(/.+access_token/, 'access_token')
    );
  }

  // This is mostly Anvil.callback. However, since that function has
  // verification and parsing of the authorization data mixed with actual
  // replacement of the session it cannot be used to manage multiple sessions
  processAuthorization(
    session_data = this.extractSessionDataFromUri(location.hash)
  ) {
    if(session_data['access_token']) {
      location.hash = '';

      session_data = this.parseSessionOptions(session_data);

      if(session_data.error) {
        if(session_data.options.replace_active_session ||
           session_data.options.retrieve_user_info) {
          Anvil.reset();
        }

        Anvil.sessionState = session_data.session_state
        localStorage['anvil.connect.session.state'] = Anvil.sessionState;

        this.error$.next(new Error(session_data.error));
      } else {
        let accessJWS = new JWS();
        let idJWS = new JWS();

        // Get the JWKs from localStorage. This should be readily available
        // once the constructor has run and executed Anvil.getKeys()
        try {
          var jwk = JSON.parse(localStorage['anvil.connect.jwk']);
          var hN = b64utohex(jwk.n);
          var hE = b64utohex(jwk.e);
        } catch(e) {
          this.error$.next(
            new Error('JWKs are unavailable, cannot process authorization')
          );
        }

        // Decode the access token and verify signature
        if(session_data.access_token &&
           !accessJWS.verifyJWSByNE(session_data.access_token, hN, hE)) {
          this.error$.next(new Error('Failed to verify access token signature'));
        }

        // Decode the id token and verify signature
        if(session_data.id_token &&
           !idJWS.verifyJWSByNE(session_data.id_token, hN, hE)) {
          this.error$.next(new Error('Failed to verify id token signature'));
        }

        // Parse the access token payload
        try {
          session_data.access_claims = JSON.parse(
            accessJWS.parsedJWS.payloadS
          );
        } catch(e) {
          this.error$.next(new Error('Cannot parse access token payload'));
        }

        // Parse the id token payload
        try {
          session_data.id_claims = JSON.parse(idJWS.parsedJWS.payloadS);
        } catch(e) {
          this.error$.next(new Error('Cannot parse id token payload'));
        }

        // Validate the nonce
        if(session_data.id_claims &&
           !Anvil.nonce(session_data.id_claims.nonce)) {
          this.error$.next(new Error('Invalid nonce'));
        }

        // Verify at_hash
        let atHash = CryptoJS.SHA256(session_data.access_token)
                             .toString(CryptoJS.enc.Hex);
        atHash = atHash.slice(0, atHash.length / 2);
        if(session_data.id_claims &&
           atHash !== session_data.id_claims.at_hash) {
          this.error$.next(
            new Error('Invalid access token hash in id token payload')
          );
        }

        Anvil.sessionState = session_data.session_state;

        if(session_data.options.retrieve_user_info) {
          this.persistSession(session_data);

          Anvil.userInfo().then((user_info: Object) => {
            session_data.user_info = user_info;
            this.persistSession(session_data);

            this.saveSession(session_data);
          }).catch((error: string) => this.error$.next(new Error(error)));
        } else {
          if(session_data.options.replace_active_session){
            this.persistSession(session_data);
          }

          this.saveSession(session_data);
        }
      }
    } else {
      this.error$.next(new Error('No authorization data available'));
    }
  }

  removeSession(session: Session) {
    if(this.persisted_session === session) {
      this.persisted_session = null;
    }

    this.sessions = this.sessions.filter(
      stored_session => stored_session !== session
    );
  }

  private getAuthorizationUri(
    requested_scopes: string[] = [],
    options: IAuthorizationOptions
  ) {
    // Temporarily reconfigure Anvil to authorize the requested scopes
    Anvil.configure(Object.assign(
      { scope: requested_scopes }, this.configuration
    ));

    let authorization_uri = Anvil.uri();

    if(options) {
      if(options.provider) {
        authorization_uri = Anvil.uri(`connect/${options.provider}`);
      }

      if(options.force_path) {
        options.force_path = encodeURIComponent(options.force_path);
        authorization_uri += `&force_path=${options.force_path}`;
      }

      if(options.max_age) {
        authorization_uri += `&max_age=${options.max_age}`;
        delete options.max_age;
      }

      authorization_uri += `&state=${JSON.stringify(options)}`;
    }

    // Reset back to base configuration
    Anvil.configure(this.configuration);

    return authorization_uri;
  }

  private parseSessionOptions(session_data: ISessionData) {
    session_data.options = {
      replace_active_session: true,
      retrieve_user_info: false
    };

    if(session_data.state) {
      try {
        let received_options = JSON.parse(session_data.state);
        Object.assign(session_data.options, received_options);
      } catch(exception) {
        this.error$.next(new Error('Could not parse session state'));
      }
    }

    return session_data;
  }

  // Store session data across page reloads
  private persistSession(session_data: ISessionData) {
    Anvil.session = session_data;
    Anvil.serialize();

    // The `serialize` method stores the secret for the currently active path.
    // The path can be forced to a given path by setting the appropriate
    // option, but this needs to be updated after the secret is serialized for
    // the default path (or it will not be available)
    if(session_data.options.force_path) {

      // Token expirations are in seconds, JavaScript uses milliseconds
      let expires = new Date(session_data.id_claims.exp * 1000).toUTCString();
      let path = decodeURIComponent(session_data.options.force_path);
      let secret = document.cookie.replace(
        /(?:(?:^|.*;\s*)anvil.connect\s*\=\s*([^;]*).*$)|^.*$/, "$1"
      );

      // Expire the original cookie. This has to be done first, to prevent the
      // cookie for the forced path to be removed in case the forced path
      // happens to coincide with the original path
      document.cookie = `anvil.connect=clear; expires=${new Date(0).toUTCString()}`;

      // Set the new cookie with the previous cookies secret and expiration
      // date, but the path forced to the desired path
      document.cookie = `anvil.connect=${secret}; expires=${expires}; path=${path}`;
    }
  }

  // Store a reference to the session for the current page load
  private saveSession(session_data: ISessionData) {
    const session = new Session(this, session_data);

    if(!session.expired) {
      this.sessions.push(session);

      // Store a separate reference to the persisted (main) authorization session
      // for the Issuer
      if(session_data.options.replace_active_session ||
        session_data.options.retrieve_user_info) {
        this.persisted_session = this.sessions[this.sessions.length - 1];
      }

      this.session$.next(this.sessions[this.sessions.length - 1]);
    } else {
      this.error$.next(new Error(
        'Processed data for an expired session... Please verify the ' +
        'specified max_age and/or check for clock skew on the systems ' +
        'involved in the authorization flow'
      ));
    }
  }
}
