/*!
 * Copyright (c) 2015-present, Okta, Inc. and/or its affiliates. All rights reserved.
 * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.")
 *
 * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0.
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 *
 * See the License for the specific language governing permissions and limitations under the License.
 *
 */
import { removeNils, clone } from '../util';
import { AuthSdkError } from '../errors';
import { validateToken  } from '../oidc/util';
import { isLocalhost, isIE11OrLess } from '../features';
import SdkClock from '../clock';
import {
  Token, 
  Tokens, 
  TokenType, 
  TokenManagerOptions, 
  isIDToken, 
  isAccessToken,
  isRefreshToken,
  TokenManagerErrorEventHandler,
  TokenManagerSetStorageEventHandler,
  TokenManagerRenewEventHandler,
  TokenManagerEventHandler,
  TokenManagerInterface,
  RefreshToken,
  AccessTokenCallback,
  IDTokenCallback,
  RefreshTokenCallback,
  EVENT_RENEWED,
  EVENT_ADDED,
  EVENT_ERROR,
  EVENT_EXPIRED,
  EVENT_REMOVED,
  EVENT_SET_STORAGE,
  TokenManagerAnyEventHandler,
  TokenManagerAnyEvent,
  OktaAuthOAuthInterface
} from './types';
import { REFRESH_TOKEN_STORAGE_KEY, TOKEN_STORAGE_NAME } from '../constants';
import { EventEmitter } from '../base/types';
import { StorageOptions, StorageProvider, StorageType } from '../storage/types';

const DEFAULT_OPTIONS = {
  // TODO: remove in next major version - OKTA-473815
  autoRenew: true,
  autoRemove: true,
  syncStorage: true,
  // --- //
  clearPendingRemoveTokens: true,
  storage: undefined, // will use value from storageManager config
  expireEarlySeconds: 30,
  storageKey: TOKEN_STORAGE_NAME
};

interface TokenManagerState {
  expireTimeouts: Record<string, unknown>;
  renewPromise: Promise<Token | undefined> | null;
  started?: boolean;
}
function defaultState(): TokenManagerState {
  return {
    expireTimeouts: {},
    renewPromise: null
  };
}
export class TokenManager implements TokenManagerInterface {
  private sdk: OktaAuthOAuthInterface;
  private clock: SdkClock;
  private emitter: EventEmitter;
  private storage: StorageProvider;
  private state: TokenManagerState;
  private options: TokenManagerOptions;

  on(event: typeof EVENT_RENEWED, handler: TokenManagerRenewEventHandler, context?: object): void;
  on(event: typeof EVENT_ERROR, handler: TokenManagerErrorEventHandler, context?: object): void;
  on(event: typeof EVENT_SET_STORAGE, handler: TokenManagerSetStorageEventHandler, context?: object): void;
  on(event: typeof EVENT_EXPIRED | typeof EVENT_ADDED | typeof EVENT_REMOVED, 
    handler: TokenManagerEventHandler, context?: object): void;
  on(event: TokenManagerAnyEvent, handler: TokenManagerAnyEventHandler, context?: object): void {
    if (context) {
      this.emitter.on(event, handler, context);
    } else {
      this.emitter.on(event, handler);
    }
  }

  off(event: typeof EVENT_RENEWED, handler?: TokenManagerRenewEventHandler): void;
  off(event: typeof EVENT_ERROR, handler?: TokenManagerErrorEventHandler): void;
  off(event: typeof EVENT_SET_STORAGE, handler?: TokenManagerSetStorageEventHandler): void;
  off(event: typeof EVENT_EXPIRED | typeof EVENT_ADDED | typeof EVENT_REMOVED, 
    handler?: TokenManagerEventHandler): void;
  off(event: TokenManagerAnyEvent, handler?: TokenManagerAnyEventHandler): void {
    if (handler) {
      this.emitter.off(event, handler);
    } else {
      this.emitter.off(event);
    }
  }

  // eslint-disable-next-line complexity
  constructor(sdk: OktaAuthOAuthInterface, options: TokenManagerOptions = {}) {
    this.sdk = sdk;
    this.emitter = (sdk as any).emitter;
    if (!this.emitter) {
      throw new AuthSdkError('Emitter should be initialized before TokenManager');
    }
    
    options = Object.assign({}, DEFAULT_OPTIONS, removeNils(options));
    if (!isLocalhost()) {
      options.expireEarlySeconds = DEFAULT_OPTIONS.expireEarlySeconds;
    }

    this.options = options;

    const storageOptions: StorageOptions = removeNils({
      storageKey: options.storageKey,
      secure: options.secure,
    });
    if (typeof options.storage === 'object') {
      // A custom storage provider must implement getItem(key) and setItem(key, val)
      storageOptions.storageProvider = options.storage;
    } else if (options.storage) {
      storageOptions.storageType = options.storage as StorageType;
    }

    this.storage = sdk.storageManager.getTokenStorage({...storageOptions, useSeparateCookies: true});
    this.clock = SdkClock.create(/* sdk, options */);
    this.state = defaultState();
  }

  start() {
    if (this.options.clearPendingRemoveTokens) {
      this.clearPendingRemoveTokens();
    }
    this.setExpireEventTimeoutAll();
    this.state.started = true;
  }
  
  stop() {
    this.clearExpireEventTimeoutAll();
    this.state.started = false;
  }

  isStarted() {
    return !!this.state.started;
  }

  getOptions(): TokenManagerOptions {
    return clone(this.options);
  }
  
  getExpireTime(token) {
    const expireEarlySeconds = this.options.expireEarlySeconds || 0;
    var expireTime = token.expiresAt - expireEarlySeconds;
    return expireTime;
  }
  
  hasExpired(token) {
    var expireTime = this.getExpireTime(token);
    return expireTime <= this.clock.now();
  }
  
  emitExpired(key, token) {
    this.emitter.emit(EVENT_EXPIRED, key, token);
  }
  
  emitRenewed(key, freshToken, oldToken) {
    this.emitter.emit(EVENT_RENEWED, key, freshToken, oldToken);
  }
  
  emitAdded(key, token) {
    this.emitter.emit(EVENT_ADDED, key, token);
  }
  
  emitRemoved(key, token?) {
    this.emitter.emit(EVENT_REMOVED, key, token);
  }
  
  emitError(error) {
    this.emitter.emit(EVENT_ERROR, error);
  }
  
  clearExpireEventTimeout(key) {
    clearTimeout(this.state.expireTimeouts[key] as any);
    delete this.state.expireTimeouts[key];
  
    // Remove the renew promise (if it exists)
    this.state.renewPromise = null;
  }
  
  clearExpireEventTimeoutAll() {
    var expireTimeouts = this.state.expireTimeouts;
    for (var key in expireTimeouts) {
      if (!Object.prototype.hasOwnProperty.call(expireTimeouts, key)) {
        continue;
      }
      this.clearExpireEventTimeout(key);
    }
  }
  
  setExpireEventTimeout(key, token) {
    if (isRefreshToken(token)) {
      return;
    }

    var expireTime = this.getExpireTime(token);
    var expireEventWait = Math.max(expireTime - this.clock.now(), 0) * 1000;
  
    // Clear any existing timeout
    this.clearExpireEventTimeout(key);
  
    var expireEventTimeout = setTimeout(() => {
      this.emitExpired(key, token);
    }, expireEventWait);
  
    // Add a new timeout
    this.state.expireTimeouts[key] = expireEventTimeout;
  }
  
  setExpireEventTimeoutAll() {
    var tokenStorage = this.storage.getStorage();
    for(var key in tokenStorage) {
      if (!Object.prototype.hasOwnProperty.call(tokenStorage, key)) {
        continue;
      }
      var token = tokenStorage[key];
      this.setExpireEventTimeout(key, token);
    }
  }
  
  // reset timeouts to setup autoRenew for tokens from other document context (tabs)
  resetExpireEventTimeoutAll() {
    this.clearExpireEventTimeoutAll();
    this.setExpireEventTimeoutAll();
  }
  
  add(key, token: Token) {
    var tokenStorage = this.storage.getStorage();
    validateToken(token);
    tokenStorage[key] = token;
    this.storage.setStorage(tokenStorage);
    this.emitSetStorageEvent();
    this.emitAdded(key, token);
    this.setExpireEventTimeout(key, token);
  }
  
  getSync(key): Token | undefined {
    var tokenStorage = this.storage.getStorage();
    return tokenStorage[key];
  }
  
  async get(key): Promise<Token | undefined> {
    return this.getSync(key);
  }
  
  getTokensSync(): Tokens {
    const tokens = {} as Tokens;
    const tokenStorage = this.storage.getStorage();
    Object.keys(tokenStorage).forEach(key => {
      const token = tokenStorage[key];
      if (isAccessToken(token)) {
        tokens.accessToken = token;
      } else if (isIDToken(token)) {
        tokens.idToken = token;
      } else if (isRefreshToken(token)) { 
        tokens.refreshToken = token;
      }
    });
    return tokens;
  }
  
  async getTokens(): Promise<Tokens> {
    return this.getTokensSync();
  }

  getStorageKeyByType(type: TokenType): string {
    const tokenStorage = this.storage.getStorage();
    const key = Object.keys(tokenStorage).filter(key => {
      const token = tokenStorage[key];
      return (isAccessToken(token) && type === 'accessToken') 
        || (isIDToken(token) && type === 'idToken')
        || (isRefreshToken(token) && type === 'refreshToken');
    })[0];
    return key;
  }

  private getTokenType(token: Token): TokenType {
    if (isAccessToken(token)) {
      return 'accessToken';
    }
    if (isIDToken(token)) {
      return 'idToken';
    }
    if(isRefreshToken(token)) {
      return 'refreshToken';
    }
    throw new AuthSdkError('Unknown token type');
  }

  // for synchronization of LocalStorage cross tabs for IE11
  private emitSetStorageEvent() {
    if (isIE11OrLess()) {
      const storage = this.storage.getStorage();
      this.emitter.emit(EVENT_SET_STORAGE, storage);
    }
  }

  // used in `SyncStorageService` for synchronization of LocalStorage cross tabs for IE11
  public getStorage() {
    return this.storage;
  }

  setTokens(
    tokens: Tokens,
    // TODO: callbacks can be removed in the next major version OKTA-407224
    accessTokenCb?: AccessTokenCallback, 
    idTokenCb?: IDTokenCallback,
    refreshTokenCb?: RefreshTokenCallback
  ): void {
    const handleTokenCallback = (key, token) => {
      const type = this.getTokenType(token);
      if (type === 'accessToken') {
        accessTokenCb && accessTokenCb(key, token);
      } else if (type === 'idToken') {
        idTokenCb && idTokenCb(key, token);
      } else if (type === 'refreshToken') {
        refreshTokenCb && refreshTokenCb(key, token);
      }
    };
    const handleAdded = (key, token) => {
      this.emitAdded(key, token);
      this.setExpireEventTimeout(key, token);
      handleTokenCallback(key, token);
    };
    const handleRenewed = (key, token, oldToken) => {
      this.emitRenewed(key, token, oldToken);
      this.clearExpireEventTimeout(key);
      this.setExpireEventTimeout(key, token);
      handleTokenCallback(key, token);
    };
    const handleRemoved = (key, token) => {
      this.clearExpireEventTimeout(key);
      this.emitRemoved(key, token);
      handleTokenCallback(key, token);
    };
    
    const types: TokenType[] = ['idToken', 'accessToken', 'refreshToken'];
    const existingTokens = this.getTokensSync();

    // valid tokens
    types.forEach((type) => {
      const token = tokens[type];
      if (token) {
        validateToken(token, type);
      }
    });
  
    // add token to storage
    const storage = types.reduce((storage, type) => {
      const token = tokens[type];
      if (token) {
        const storageKey = this.getStorageKeyByType(type) || type;
        storage[storageKey] = token;
      }
      return storage;
    }, {});
    this.storage.setStorage(storage);
    this.emitSetStorageEvent();

    // emit event and start expiration timer
    types.forEach(type => {
      const newToken = tokens[type];
      const existingToken = existingTokens[type];
      const storageKey = this.getStorageKeyByType(type) || type;
      if (newToken && existingToken) { // renew
        // call handleRemoved first, since it clears timers
        handleRemoved(storageKey, existingToken);
        handleAdded(storageKey, newToken);
        handleRenewed(storageKey, newToken, existingToken);
      } else if (newToken) { // add
        handleAdded(storageKey, newToken);
      } else if (existingToken) { //remove
        handleRemoved(storageKey, existingToken);
      }
    });
  }
  
  remove(key) {
    // Clear any listener for this token
    this.clearExpireEventTimeout(key);
  
    var tokenStorage = this.storage.getStorage();
    var removedToken = tokenStorage[key];
    delete tokenStorage[key];
    this.storage.setStorage(tokenStorage);
    this.emitSetStorageEvent();
  
    this.emitRemoved(key, removedToken);
  }
  
  // TODO: this methods is redundant and can be removed in the next major version OKTA-407224
  async renewToken(token) {
    return this.sdk.token?.renew(token);
  }
  // TODO: this methods is redundant and can be removed in the next major version OKTA-407224
  validateToken(token: Token) {
    return validateToken(token);
  }

  // TODO: renew method should take no param, change in the next major version OKTA-407224
  renew(key): Promise<Token | undefined> {
    // Multiple callers may receive the same promise. They will all resolve or reject from the same request.
    if (this.state.renewPromise) {
      return this.state.renewPromise;
    }
  
    try {
      var token = this.getSync(key);
      if (!token) {
        throw new AuthSdkError('The tokenManager has no token for the key: ' + key);
      }
    } catch (e) {
      return Promise.reject(e);
    }
  
    // Remove existing autoRenew timeout
    this.clearExpireEventTimeout(key);
  
    // A refresh token means a replace instead of renewal
    // Store the renew promise state, to avoid renewing again
    const renewPromise = this.state.renewPromise = this.sdk.token.renewTokens()
      .then(tokens => {
        this.setTokens(tokens);

        // resolve token based on the key
        const tokenType = this.getTokenType(token!);
        return tokens[tokenType];
      })
      .catch(err => {
        // If renew fails, remove token from storage and emit error
        this.remove(key);
        err.tokenKey = key;
        this.emitError(err);
        throw err;
      })
      .finally(() => {
        // Remove existing promise key
        this.state.renewPromise = null;
      });
  
    return renewPromise;
  }
  
  clear() {
    const tokens = this.getTokensSync();
    this.clearExpireEventTimeoutAll();
    this.storage.clearStorage();
    this.emitSetStorageEvent();

    Object.keys(tokens).forEach(key => {
      this.emitRemoved(key, tokens[key]);
    });
  }

  clearPendingRemoveTokens() {
    const tokenStorage = this.storage.getStorage();
    const removedTokens = {};
    Object.keys(tokenStorage).forEach(key => {
      if (tokenStorage[key].pendingRemove) {
        removedTokens[key] = tokenStorage[key];
        delete tokenStorage[key];
      }
    });
    this.storage.setStorage(tokenStorage);
    this.emitSetStorageEvent();
    Object.keys(removedTokens).forEach(key => {
      this.clearExpireEventTimeout(key);
      this.emitRemoved(key, removedTokens[key]);
    });
  }

  updateRefreshToken(token: RefreshToken) {
    const key = this.getStorageKeyByType('refreshToken') || REFRESH_TOKEN_STORAGE_KEY;

    // do not emit any event
    var tokenStorage = this.storage.getStorage();
    validateToken(token);
    tokenStorage[key] = token;
    this.storage.setStorage(tokenStorage);
    this.emitSetStorageEvent();
  }

  removeRefreshToken () {
    const key = this.getStorageKeyByType('refreshToken') || REFRESH_TOKEN_STORAGE_KEY;
    this.remove(key);
  }

  addPendingRemoveFlags() {
    const tokens = this.getTokensSync();
    Object.keys(tokens).forEach(key => {
      tokens[key].pendingRemove = true;
    });
    this.setTokens(tokens);
  }
  
}
