Source: services/identity-manager.js

import Axios from 'axios'

/**
 * @typedef {Object} identityApplyOptions
 * @property {String} [type='query'] Type d'application (query, header, headers, bearer, basic, cookie)
 * @property {String} [name] Nom de la clé (header, cookie, query)
 * @property {String} [scheme] Schéma du header d'autorisation (ex: Bearer)
 * @property {String} [username] Identifiant pour basic auth (en clair, peu recommandé)
 * @property {String} [password] Mot de passe pour basic auth (en clair, peu recommandé)
 * @property {Boolean} [encoded=false] Indique si la valeur basic est déjà encodée en base64
 */

/**
 * paramètre de type tokenOption
 * @typedef {Object} identityOptions
 * @property {String} type Type de token : TOKEN (token en clair), IDENTITY (info d'identité en clair), LOCALSTORAGE (identité stockée dans le localstorage), URL (identity demandée a un tiers de confiance), FUNCTION (identité demandée a une fonction)
 * @property {Number} [refreshInterval] Intervalle de rafraichissement du token en ms (optionnel)
 * @property {String} [token] Le token (si type TOKEN)
 * @property {String | Object} [identity] L'identité (si type IDENTITY)
 * @property {String} [path] Le path en localstorage (si type LOCALSTORAGE)
 * @property {String} [url] L'url à appeler pour récupérer le token (si type URL)
 * @property {String} [method] La méthode d'appel (GET, POST, PUT, DELETE) (si type URL) (défaut POST)
 * @property {Object} [data] Les données à envoyer (si type URL)
 * @property {Object} [headers] Les headers à envoyer (si type URL)
 * @property {Object} [auth] Les informations d'authentification {username, password} à envoyer (si type URL)
 * @property {String} [responseIdentityName] Le nom de la propriété dans la réponse qui contient le token (si type URL ou localstorage)
 * @property {Function} [getIdentity] La fonction à appeler pour récupérer l'identité (si type FUNCTION)
 * @property {identityApplyOptions} [apply] Stratégie d'application du token/identité dans la Request
 * @example
 * // Header Bearer
 * {
 *   type: 'TOKEN',
 *   token: 'abc123',
 *   apply: { type: 'bearer' }
 * }
 * @example
 * // Multi headers
 * {
 *   type: 'IDENTITY',
 *   identity: {
 *     Authorization: 'Bearer abc123',
 *     'X-Api-Key': 'my-key'
 *   },
 *   apply: { type: 'headers' }
 * }
 */

export default class IdentityManager {
  /**
   * @ type {identityOptions | String}
   */
  #options

  /**
   * @ type {String | Object | null } valeur en fonction du type d'application (string ou object)
   */
  #identityValue

  /**
   * @ type {Number | null} Timestamp de la dernière récupération d'identité
   */
  #lastFetchTime = null

  /**
   * @ type {Promise | null} Promise de récupération en cours (évite les race conditions)
   */
  #fetchPromise = null

  get identity () {
    return this.#identityValue
  }

  /**
   * @param {identityOptions | String} options Options de gestion du token ou token en clair (historique)
   */
  constructor (options) {
    this.#options = options
  }

  /**
   * Vérifie si l'identité a expiré et doit être rafraîchie
   * @returns {boolean}
   */
  #isIdentityExpired () {
    // Pas encore récupérée
    if (this.#lastFetchTime === null) {
      return true
    }

    // Pas de refreshInterval configuré = pas d'expiration
    if (!this.#options?.refreshInterval) {
      return false
    }

    // Vérifier si le temps écoulé dépasse l'intervalle
    const elapsed = Date.now() - this.#lastFetchTime
    return elapsed > this.#options.refreshInterval
  }

  /**
   * Récupère ou rafraîchit l'identité si nécessaire
   * Gère les appels concurrents via cache de Promise
   */
  async ensureIdentity () {
    if (!this.#isIdentityExpired()) {
      return // Identité valide, rien à faire
    }

    // Si un fetch est déjà en cours, attendre qu'il se termine
    if (this.#fetchPromise) {
      await this.#fetchPromise
      return
    }

    // Lancer le fetch et cacher la Promise
    this.#fetchPromise = this.#parseIdentityOptions()
      .finally(() => {
        // Nettoyer la Promise après résolution (succès ou erreur)
        this.#fetchPromise = null
      })

    await this.#fetchPromise

    // Vérifier qu'on a bien récupéré une identité
    if (!this.#identityValue && this.#options?.type) {
      throw new Error(`Failed to retrieve identity for type ${this.#options.type}`)
    }
  }

  /** parsing des options de token */
  async #parseIdentityOptions () {
    const applyOptions = this.#getApplyOptions()

    const identityOptions = this.#options
    // cas "historique", on a juste une chaine de caractère
    if (identityOptions === null || typeof identityOptions === 'string') {
      this.#identityValue = identityOptions
      return
    }

    if (typeof identityOptions !== 'object' || !identityOptions.type) {
      console.error('tokenOptions doit être une chaine de caractère ou un objet avec une propriété "type"')
      return
    }

    switch (identityOptions.type.toUpperCase()) {
      // le token est dans une chaine de caractère
      case 'TOKEN':
        this.#identityValue = identityOptions.token
        break
      case 'IDENTITY':
        this.#identityValue = identityOptions.identity
        break
      case 'LOCALSTORAGE':
        { // on essaye de lire l'identité en localstorage
          let identityFromLocalStorage = localStorage.getItem(identityOptions.path)
          if (identityFromLocalStorage !== null && identityFromLocalStorage !== '') {
            try {
              identityFromLocalStorage = JSON.parse(identityFromLocalStorage)
              this.#identityValue = identityOptions.responseIdentityName
                ? identityFromLocalStorage[identityOptions.responseIdentityName]
                : ['QUERY', 'COOKIE'].includes(applyOptions.type)
                    ? identityFromLocalStorage.token
                    : identityFromLocalStorage
            } catch (ex) {
              console.error("erreur parsing de l'identité format `tokenOptions.responseIdentityName` attendu, recu : ", identityFromLocalStorage, ex)
            }
          }
        }
        break
      case 'URL':
        // on appelles une URL avec des options pour récupérer les infos d'identité
        {
          const { url, method = 'POST', data, headers, responseIdentityName, auth } = identityOptions
          const response = await Axios({
            url,
            auth,
            method,
            data,
            headers,
          })
          if (response.status === 200) {
            if (responseIdentityName) {
              this.#identityValue = response.data[responseIdentityName]
            } else {
              this.#identityValue = response.data
            }
          } else {
            console.error('erreur récupération de l\'identité', response)
          }
        }
        break
      case 'FUNCTION':
        // on appelle une fonction pour récupérer le token
        this.#identityValue = await identityOptions.getIdentity()
        break
    }

    // Mettre à jour le timestamp après récupération réussie
    this.#lastFetchTime = Date.now()
  }

  destroy () {
    this.#identityValue = null
    this.#lastFetchTime = null
    this.#fetchPromise = null
  }

  pause () {
    console.warn('IdentityManager.pause() est déprécié et n\'a plus d\'effet')
  }

  async restart () {
    console.warn('IdentityManager.restart() est déprécié : getRequest() rafraîchit automatiquement l\'identité si nécessaire')
    // Forcer un refresh au prochain appel
    this.#lastFetchTime = null
  }

  /**
   * Construit une Request en appliquant l'identité sur query/header/cookie
   * @param {String} url
   * @param {RequestInit} [init = {}]
   * @returns {Promise<Request>}
   */
  async getRequest (url, init = {}) {
    // Assurer que l'identité est disponible et valide
    await this.ensureIdentity()

    const applyOptions = this.#getApplyOptions()
    let requestUrl = url
    const headers = new Headers(init.headers || {})

    switch (applyOptions.type) {
      case 'HEADERS':
        if (this.#identityValue && typeof this.#identityValue === 'object') {
          Object.entries(this.#identityValue).forEach(([headerName, headerValue]) => {
            if (headerValue !== undefined && headerValue !== null) {
              headers.set(headerName, String(headerValue))
            }
          })
        }
        break
      case 'BEARER':
        if (this.#identityValue) {
          headers.set('Authorization', `Bearer ${this.#identityValue}`)
        }
        break
      case 'HEADER':
        if (this.#identityValue) {
          const headerName = applyOptions.name || 'Authorization'
          const headerValue = applyOptions.scheme ? `${applyOptions.scheme} ${this.#identityValue}` : this.#identityValue
          headers.set(headerName, headerValue)
        }
        break
      case 'BASIC':
        {
          const basicValue = this.#buildBasicAuthorizationValue(applyOptions)
          if (basicValue) {
            headers.set('Authorization', `Basic ${basicValue}`)
          }
        }
        break
      case 'COOKIE':
        if (this.#identityValue) {
          const cookieName = applyOptions.name || 'token'
          const nextCookie = `${encodeURIComponent(cookieName)}=${encodeURIComponent(this.#identityValue)}`
          const existingCookie = headers.get('Cookie') || headers.get('cookie')
          headers.set('Cookie', existingCookie ? `${existingCookie}; ${nextCookie}` : nextCookie)
        }
        break
      case 'QUERY':
      default:
        requestUrl = this.#appendValueToUrl(url, applyOptions.name || 'token', this.#identityValue)
        break
    }

    return new Request(requestUrl, {
      ...init,
      headers,
    })
  }

  /**
   * Parse une url pour y ajouter le token
   * @param {String} url
   * @returns {String} url avec token
   */
  addTokenToUrl (url) {
    if (this.identity) {
      const applyOptions = this.#getApplyOptions()
      const tokenKey = applyOptions.name || 'token'
      url = this.#appendValueToUrl(url, tokenKey, this.#identityValue)
    }
    return url
  }

  #getApplyOptions () {
    if (typeof this.#options !== 'object' || this.#options === null) {
      return {
        type: 'QUERY',
      }
    }

    const apply = this.#options.apply || {}
    return {
      ...apply,
      type: (apply.type || 'query').toUpperCase(),
    }
  }

  #appendValueToUrl (url, key, value) {
    if (value === null || value === undefined || value === '') {
      return url
    }
    const stringUrl = String(url)
    const [baseUrl, hashPart] = stringUrl.split('#')
    const separator = baseUrl.indexOf('?') === -1 ? '?' : '&'
    const param = `${encodeURIComponent(key)}=${encodeURIComponent(value)}`
    return `${baseUrl}${separator}${param}${hashPart !== undefined ? `#${hashPart}` : ''}`
  }

  #buildBasicAuthorizationValue (applyOptions) {
    const username = applyOptions.username
    const password = applyOptions.password

    if (username !== undefined && password !== undefined) {
      return this.#encodeBase64(`${username}:${password}`)
    }

    if (!this.#identityValue) {
      return null
    }
    if (typeof this.#identityValue === 'object') {
      const identityUsername = this.#identityValue.username
      const identityPassword = this.#identityValue.password
      if (identityUsername !== undefined && identityPassword !== undefined) {
        return this.#encodeBase64(`${identityUsername}:${identityPassword}`)
      }
    }
    return applyOptions.encoded ? this.#identityValue : this.#encodeBase64(this.#identityValue)
  }

  #encodeBase64 (value) {
    if (typeof btoa === 'function') {
      // Use TextEncoder to convert the string to a UTF-8 byte array,
      // then convert each byte to a character to satisfy btoa's Latin1 requirement.
      const bytes = new TextEncoder().encode(value)
      const binString = Array.from(bytes, (byte) => String.fromCharCode(byte)).join('')
      return btoa(binString)
    }

    console.error('Aucune méthode disponible pour encoder en base64')
    return ''
  }
}